feat(channel): 添加模型映射和获取模型的 API

- 在 ChannelConfig 中添加 models 字段以支持模型映射
- 新增 ChannelFetchModelsEndpoint 用于获取上游模型列表
- 更新 normalizeChannelConfig 函数以处理新模型结构
- 修改相关的数据库操作以适应新的配置结构
- 引入 buildPrefixedTargetUrl 函数以简化 URL 构建
- 更新 utils 函数以支持模型映射查找
This commit is contained in:
Jeff Gu committed 2026-04-09 11:56:38 +08:00
1 parent a2c97959ba
commit 84cc346ebd
17 files changed
+951 -424

No files matched your search

+187 -3
View File
@@ -3,7 +3,138 @@ import { contentJson, OpenAPIRoute } from 'chanfana';
import { z } from 'zod';
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
import { sanitizeChannelConfig } from "../channel-config";
import {
DEFAULT_CLAUDE_API_VERSION,
type NormalizedChannelConfig,
normalizeChannelConfig,
sanitizeChannelConfig,
} from "../channel-config";
import { buildAzureTargetUrlFromPath } from "../providers/shared/azure-target-url";
import { buildPrefixedTargetUrl } from "../providers/shared/prefixed-target-url";
const ChannelModelSchema = z.object({
id: z.string().describe('Upstream model ID'),
name: z.string().describe('External model name exposed by this proxy'),
});
const parseFetchedModels = (payload: any): ChannelModelMapping[] => {
const rawModels = Array.isArray(payload)
? payload
: Array.isArray(payload?.data)
? payload.data
: Array.isArray(payload?.models)
? payload.models
: [];
const normalizedModels: ChannelModelMapping[] = [];
const seenIds = new Set<string>();
for (const rawModel of rawModels) {
if (typeof rawModel === "string") {
const id = rawModel.trim();
if (!id || seenIds.has(id)) {
continue;
}
seenIds.add(id);
normalizedModels.push({ id, name: id });
continue;
}
if (!rawModel || typeof rawModel !== "object") {
continue;
}
const id = (
typeof rawModel.id === "string" ? rawModel.id
: typeof rawModel.model === "string" ? rawModel.model
: typeof rawModel.name === "string" ? rawModel.name
: ""
).trim();
if (!id || seenIds.has(id)) {
continue;
}
const name = (
typeof rawModel.display_name === "string" ? rawModel.display_name
: typeof rawModel.name === "string" ? rawModel.name
: id
).trim() || id;
seenIds.add(id);
normalizedModels.push({ id, name: name || id });
}
return normalizedModels;
};
const buildModelsFetchRequest = (
config: NormalizedChannelConfig,
apiKey: string
): Request => {
let targetUrl: URL;
const headers = new Headers({
"Accept": "application/json",
});
switch (config.type) {
case "azure-openai":
case "azure-openai-audio":
case "azure-openai-responses":
targetUrl = buildAzureTargetUrlFromPath(config.endpoint, "/v1/models");
headers.set("api-key", apiKey);
break;
case "claude":
targetUrl = new URL("v1/models", config.endpoint);
headers.set("x-api-key", apiKey);
headers.set("anthropic-version", DEFAULT_CLAUDE_API_VERSION);
break;
case "openai":
case "openai-audio":
case "openai-responses":
case "claude-to-openai":
default:
targetUrl = buildPrefixedTargetUrl(config.endpoint, "/v1/models");
headers.set("Authorization", `Bearer ${apiKey}`);
break;
}
return new Request(targetUrl, {
method: "GET",
headers,
});
};
const fetchModelsFromChannel = async (
config: ChannelConfig
): Promise<ChannelModelMapping[]> => {
const normalizedConfig = normalizeChannelConfig(config);
if (!normalizedConfig.endpoint || !normalizedConfig.type) {
throw new Error("Channel endpoint and type are required");
}
if (normalizedConfig.api_keys.length === 0) {
throw new Error("At least one API key is required");
}
let lastError = "Failed to fetch models from upstream";
for (const apiKey of normalizedConfig.api_keys) {
const response = await fetch(buildModelsFetchRequest(normalizedConfig, apiKey));
if (!response.ok) {
const errorText = await response.text();
lastError = errorText || `Upstream returned ${response.status}`;
continue;
}
const responseJson = await response.json();
return parseFetchedModels(responseJson);
}
throw new Error(lastError);
};
// 获取所有 Channel 配置
export class ChannelGetEndpoint extends OpenAPIRoute {
@@ -53,8 +184,9 @@ export class ChannelUpsertEndpoint extends OpenAPIRoute {
api_keys: z.array(z.string()).optional().describe('API keys, one request will pick one randomly'),
auto_retry: z.boolean().optional().describe('Automatically retry the same API key'),
auto_rotate: z.boolean().optional().describe('Automatically rotate to other API keys on retryable failures'),
supported_models: z.array(z.string()).describe('Supported request model list'),
deployment_mapper: z.record(z.string()).describe('Model deployment mapping'),
models: z.array(ChannelModelSchema).optional().describe('External model name to upstream model ID mappings'),
supported_models: z.array(z.string()).optional().describe('Deprecated supported request model list'),
deployment_mapper: z.record(z.string()).optional().describe('Deprecated model deployment mapping'),
model_pricing: z.record(z.object({
input: z.number().describe('Input token price per 1000 tokens'),
output: z.number().describe('Output token price per 1000 tokens'),
@@ -107,6 +239,58 @@ export class ChannelUpsertEndpoint extends OpenAPIRoute {
}
}
export class ChannelFetchModelsEndpoint extends OpenAPIRoute {
schema = {
tags: ['Admin API'],
summary: 'Fetch upstream model list for a channel config',
request: {
body: {
content: {
'application/json': {
schema: z.object({
name: z.string().optional(),
type: z.string().optional(),
endpoint: z.string().optional(),
api_key: z.string().optional(),
api_keys: z.array(z.string()).optional(),
auto_retry: z.boolean().optional(),
auto_rotate: z.boolean().optional(),
models: z.array(ChannelModelSchema).optional(),
supported_models: z.array(z.string()).optional(),
deployment_mapper: z.record(z.string()).optional(),
model_pricing: z.record(z.object({
input: z.number(),
output: z.number(),
cache: z.number().optional(),
request: z.number().optional(),
})).optional(),
}),
},
},
},
},
responses: {
...CommonSuccessfulResponse(z.array(ChannelModelSchema)),
...CommonErrorResponse,
},
};
async handle(c: Context<HonoCustomType>) {
const rawConfig = await c.req.json<ChannelConfig>();
try {
const models = await fetchModelsFromChannel(rawConfig);
return {
success: true,
data: models,
} as CommonResponse;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to fetch models";
return c.text(message, 502);
}
}
}
// 删除 Channel 配置
export class ChannelDeleteEndpoint extends OpenAPIRoute {
schema = {
+2 -1
View File
@@ -3,7 +3,7 @@ import { fromHono } from 'chanfana';
import { DBInitializeEndpoint } from "./db_api"
import db from "../db"
import {
ChannelGetEndpoint, ChannelUpsertEndpoint, ChannelDeleteEndpoint
ChannelGetEndpoint, ChannelUpsertEndpoint, ChannelDeleteEndpoint, ChannelFetchModelsEndpoint
} from "./channel_api"
import {
TokenListEndpoint, TokenUpsertEndpoint, TokenDeleteEndpoint, TokenResetUsageEndpoint
@@ -36,6 +36,7 @@ api.post("/api/admin/db_initialize", DBInitializeEndpoint)
api.get("/api/admin/channel", ChannelGetEndpoint)
api.post("/api/admin/channel/:key", ChannelUpsertEndpoint)
api.delete("/api/admin/channel/:key", ChannelDeleteEndpoint)
api.post("/api/admin/channel/models/fetch", ChannelFetchModelsEndpoint)
// Token management routes
api.get("/api/admin/token", TokenListEndpoint)