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

+6
View File
@@ -161,6 +161,12 @@ export const apiClient = {
body: JSON.stringify(config),
}),
fetchChannelModels: (config: unknown) =>
request<ApiResponse>('/api/admin/channel/models/fetch', {
method: 'POST',
body: JSON.stringify(config),
}),
deleteChannel: (key: string) =>
request(`/api/admin/channel/${encodeURIComponent(key)}`, { method: 'DELETE' }),
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -4,6 +4,11 @@ export interface Channel {
usage?: number
}
export interface ChannelModelMapping {
id: string
name: string
}
export interface ChannelConfig {
name: string
type: 'openai' | 'azure-openai' | 'openai-audio' | 'azure-openai-audio' | 'claude' | 'claude-to-openai' | 'openai-responses' | 'azure-openai-responses'
@@ -12,8 +17,9 @@ export interface ChannelConfig {
api_keys?: string[]
auto_retry?: boolean
auto_rotate?: boolean
supported_models: string[]
deployment_mapper: Record<string, string>
models?: ChannelModelMapping[]
supported_models?: string[]
deployment_mapper?: Record<string, string>
model_pricing?: Record<string, PricingModel>
}
+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)
+69 -5
View File
@@ -4,10 +4,13 @@ export const DEFAULT_CLAUDE_API_VERSION = "2023-06-01";
export const MAX_RETRIES_PER_KEY = 3;
export const MAX_ROTATION_ATTEMPTS = 3;
export type NormalizedChannelConfig = Omit<ChannelConfig, "api_keys" | "auto_retry" | "auto_rotate"> & {
export type NormalizedChannelConfig = Omit<ChannelConfig, "api_keys" | "auto_retry" | "auto_rotate" | "models" | "supported_models" | "deployment_mapper"> & {
api_keys: string[];
auto_retry: boolean;
auto_rotate: boolean;
models: ChannelModelMapping[];
supported_models: string[];
deployment_mapper: Record<string, string>;
};
const normalizeApiKeys = (config: Partial<ChannelConfig>): string[] => {
@@ -34,9 +37,70 @@ const normalizeApiKeys = (config: Partial<ChannelConfig>): string[] => {
return normalizedKeys;
};
const normalizeLegacyModels = (config: Partial<ChannelConfig>): ChannelModelMapping[] => {
const normalizedModels: ChannelModelMapping[] = [];
const seenNames = new Set<string>();
const pushModel = (modelId: string, modelName?: string) => {
const id = modelId.trim();
const name = (modelName || modelId).trim();
if (!id || !name || seenNames.has(name)) {
return;
}
seenNames.add(name);
normalizedModels.push({ id, name });
};
if (Array.isArray(config.models)) {
for (const model of config.models) {
if (!model || typeof model !== "object") {
continue;
}
const id = typeof model.id === "string" ? model.id : "";
const name = typeof model.name === "string" ? model.name : id;
pushModel(id, name);
}
}
const deploymentMapper = config.deployment_mapper || {};
const supportedModels = Array.isArray(config.supported_models)
? config.supported_models
: [];
for (const supportedModel of supportedModels) {
if (typeof supportedModel !== "string") {
continue;
}
pushModel(
typeof deploymentMapper[supportedModel] === "string"
? deploymentMapper[supportedModel]
: supportedModel,
supportedModel,
);
}
for (const [modelName, modelId] of Object.entries(deploymentMapper)) {
if (typeof modelId !== "string") {
continue;
}
pushModel(modelId, modelName);
}
return normalizedModels;
};
export const normalizeChannelConfig = (
config: Partial<ChannelConfig>
): NormalizedChannelConfig => {
const models = normalizeLegacyModels(config);
const deploymentMapper = Object.fromEntries(
models.map((model) => [model.name, model.id])
);
return {
name: config.name || "",
type: config.type,
@@ -45,8 +109,9 @@ export const normalizeChannelConfig = (
api_keys: normalizeApiKeys(config),
auto_retry: config.auto_retry ?? DEFAULT_CHANNEL_AUTO_RETRY,
auto_rotate: config.auto_rotate ?? DEFAULT_CHANNEL_AUTO_ROTATE,
supported_models: Array.isArray(config.supported_models) ? config.supported_models : [],
deployment_mapper: config.deployment_mapper || {},
models,
supported_models: models.map((model) => model.name),
deployment_mapper: deploymentMapper,
model_pricing: config.model_pricing,
};
};
@@ -63,8 +128,7 @@ export const sanitizeChannelConfig = (
api_keys: normalized.api_keys,
auto_retry: normalized.auto_retry,
auto_rotate: normalized.auto_rotate,
supported_models: normalized.supported_models,
deployment_mapper: normalized.deployment_mapper,
models: normalized.models,
model_pricing: normalized.model_pricing,
};
};
+1 -1
View File
@@ -1,6 +1,6 @@
export const CONSTANTS = {
VERSION: "v0.0.1",
DB_VERSION_KEY: "db_version",
DB_VERSION: "v0.0.2",
DB_VERSION: "v0.0.3",
MODEL_PRICING_KEY: "MODEL_PRICING_CONFIG",
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { Context } from "hono";
import { CONSTANTS } from "../constants";
import { getSetting, saveSetting } from "../utils";
import { sanitizeChannelConfig } from "../channel-config";
const DB_INIT_QUERIES = `
CREATE TABLE IF NOT EXISTS channel_config (
@@ -65,13 +66,13 @@ const dbOperations = {
continue;
}
config.supported_models = Object.keys(config.deployment_mapper || {});
const sanitizedConfig = sanitizeChannelConfig(config);
await c.env.DB.prepare(
`UPDATE channel_config
SET value = ?, updated_at = datetime('now')
WHERE key = ?`
).bind(JSON.stringify(config), row.key).run();
).bind(JSON.stringify(sanitizedConfig), row.key).run();
}
await saveSetting(c, CONSTANTS.DB_VERSION_KEY, CONSTANTS.DB_VERSION);
+2 -1
View File
@@ -1,5 +1,6 @@
import { Context } from "hono"
import { normalizeUsage as normalizeResponsesUsage } from "./shared/usage-utils"
import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url"
type OpenAIStreamChoice = {
index?: number;
@@ -78,7 +79,7 @@ const buildProxyRequest = (
reqJson: any,
config: ChannelConfig
): Request => {
const targetUrl = new URL("v1/chat/completions", config.endpoint);
const targetUrl = buildPrefixedTargetUrl(config.endpoint, "/v1/chat/completions");
const targetHeaders = new Headers(request.headers);
const apiKey = config.api_key || "";
+3 -3
View File
@@ -2,7 +2,7 @@ import { Context } from "hono";
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import { getApiKeyFromHeaders, fetchTokenData, fetchChannelsForToken } from "./shared/auth";
import { getSupportedModels } from "../utils";
import { getChannelModels } from "../utils";
import { normalizeChannelConfig } from "../channel-config";
export class ModelsEndpoint extends OpenAPIRoute {
@@ -59,8 +59,8 @@ export class ModelsEndpoint extends OpenAPIRoute {
for (const row of channelsResult.results) {
const config = normalizeChannelConfig(JSON.parse(row.value) as ChannelConfig);
for (const modelName of getSupportedModels(config)) {
modelsSet.add(modelName);
for (const model of getChannelModels(config)) {
modelsSet.add(model.name);
}
}
+2 -3
View File
@@ -1,5 +1,6 @@
import { Context } from "hono"
import { checkoutUsageData, handleStreamResponse } from "./shared/openai-stream-utils"
import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url"
const buildProxyRequest = (
request: Request,
@@ -7,11 +8,9 @@ const buildProxyRequest = (
config: ChannelConfig
): Request => {
const url = new URL(request.url)
const targetUrl = new URL(config.endpoint)
const targetUrl = buildPrefixedTargetUrl(config.endpoint, url.pathname)
const apiKey = config.api_key || ""
targetUrl.pathname = url.pathname
const targetHeaders = new Headers(request.headers)
targetHeaders.delete("Host")
targetHeaders.delete("Cookie")
+2 -3
View File
@@ -3,6 +3,7 @@ import {
handleStreamResponse,
checkoutResponsesUsageData,
} from "./shared/responses-stream-utils"
import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url"
const buildProxyRequest = (
request: Request,
@@ -10,11 +11,9 @@ const buildProxyRequest = (
config: ChannelConfig
): Request => {
const url = new URL(request.url)
const targetUrl = new URL(config.endpoint)
const targetUrl = buildPrefixedTargetUrl(config.endpoint, url.pathname)
const apiKey = config.api_key || ""
targetUrl.pathname = url.pathname
const targetHeaders = new Headers(request.headers)
targetHeaders.delete("Host")
targetHeaders.delete("Cookie")
+8 -1
View File
@@ -15,13 +15,20 @@ export const buildAzureTargetUrl = (
endpoint: string
): URL => {
const requestUrl = new URL(request.url);
return buildAzureTargetUrlFromPath(endpoint, requestUrl.pathname);
};
export const buildAzureTargetUrlFromPath = (
endpoint: string,
path: string
): URL => {
const targetUrl = new URL(endpoint);
if (endpoint.endsWith("#")) {
return targetUrl;
}
const requestPath = requestUrl.pathname.replace(/^\/v1/, "");
const requestPath = path.replace(/^\/v1/, "");
const currentBasePath = trimSlashes(targetUrl.pathname);
const azureBasePath = currentBasePath.endsWith("openai/v1")
? currentBasePath
+14 -22
View File
@@ -1,7 +1,7 @@
import { Context } from "hono"
import { getApiKeyFromHeaders, fetchTokenData, fetchChannelsForToken } from "./auth"
import { RouteId, getRoutePolicy } from "./route-policy"
import { findDeploymentMapping, findSupportedModel, getSupportedModels } from "../../utils"
import { findChannelModelMapping } from "../../utils"
import { TokenUtils } from "../../admin/token_utils"
import { normalizeChannelConfig } from "../../channel-config"
@@ -43,15 +43,15 @@ export const resolveChannel = async (
} catch (error) {
return c.text("Invalid JSON body", 400);
}
const model = requestBody.model;
if (!model) {
const requestedModel = requestBody.model;
if (!requestedModel) {
return c.text("Model is required", 400);
}
const policy = getRoutePolicy(routeId);
const allowedTypes = policy.allowedTypes;
const availableChannels: Array<{ key: string, config: ChannelConfig, mapping: { pattern: string, deployment: string } }> = [];
const availableChannels: Array<{ key: string, config: ChannelConfig, mapping: ChannelModelMapping }> = [];
for (const row of channelsResult.results) {
const config = (() => {
@@ -70,28 +70,20 @@ export const resolveChannel = async (
continue;
}
const supportedPattern = findSupportedModel(getSupportedModels(config), model)
if (!supportedPattern) {
const mapping = findChannelModelMapping(config, requestedModel);
if (!mapping) {
continue;
}
const mapping = findDeploymentMapping(config.deployment_mapper, model) || {
pattern: supportedPattern,
deployment: model,
};
if (mapping) {
availableChannels.push({
key: row.key,
config: config,
mapping: mapping
});
}
availableChannels.push({
key: row.key,
config: config,
mapping,
});
}
if (availableChannels.length === 0) {
return c.text(`Model not supported: ${model}. Please configure supported_models or deployment_mapper.`, 400);
return c.text(`Model not supported: ${requestedModel}. Please configure models.`, 400);
}
const randomIndex = Math.floor(Math.random() * availableChannels.length);
@@ -99,7 +91,7 @@ export const resolveChannel = async (
const targetChannelKey = selectedChannel.key;
const targetChannelConfig = selectedChannel.config;
requestBody.model = selectedChannel.mapping.deployment;
requestBody.model = selectedChannel.mapping.id;
if (!targetChannelConfig.type) {
return c.text("Channel type invalid", 400);
@@ -107,7 +99,7 @@ export const resolveChannel = async (
const saveUsage = async (usage: Usage) => {
try {
await TokenUtils.processUsage(c, apiKey, requestBody.model, targetChannelKey, targetChannelConfig, usage);
await TokenUtils.processUsage(c, apiKey, requestedModel, targetChannelKey, targetChannelConfig, usage);
} catch (error) {
console.error('Error processing usage:', error);
}
@@ -0,0 +1,36 @@
const trimSlashes = (value: string): string => {
return value.replace(/^\/+|\/+$/g, "");
};
const joinPath = (...segments: string[]): string => {
const cleanedSegments = segments
.map((segment) => trimSlashes(segment))
.filter((segment) => segment.length > 0);
return `/${cleanedSegments.join("/")}`;
};
export const buildPrefixedTargetUrl = (
endpoint: string,
requestPath: string,
prefixToStrip = "/v1"
): URL => {
const targetUrl = new URL(endpoint);
if (endpoint.endsWith("#")) {
return targetUrl;
}
const currentBasePath = trimSlashes(targetUrl.pathname);
const normalizedPrefix = trimSlashes(prefixToStrip);
const baseAlreadyContainsPrefix = normalizedPrefix.length > 0
&& currentBasePath.endsWith(normalizedPrefix);
let normalizedRequestPath = requestPath;
if (baseAlreadyContainsPrefix && normalizedRequestPath.startsWith(prefixToStrip)) {
normalizedRequestPath = normalizedRequestPath.slice(prefixToStrip.length);
}
targetUrl.pathname = joinPath(currentBasePath, normalizedRequestPath);
return targetUrl;
};
+30 -6
View File
@@ -1,4 +1,5 @@
import { Context } from "hono";
import { normalizeChannelConfig } from "./channel-config";
const getJsonObjectValue = <T = any>(
value: string | any
@@ -107,6 +108,33 @@ export const findDeploymentMapping = (
return null;
}
export const getChannelModels = (
config: Partial<Pick<ChannelConfig, "models" | "supported_models" | "deployment_mapper">>
): ChannelModelMapping[] => {
return normalizeChannelConfig(config as Partial<ChannelConfig>).models;
}
export const findChannelModelMapping = (
config: Partial<Pick<ChannelConfig, "models" | "supported_models" | "deployment_mapper">>,
model: string
): ChannelModelMapping | null => {
const channelModels = getChannelModels(config);
for (const channelModel of channelModels) {
if (channelModel.name === model) {
return channelModel;
}
}
for (const channelModel of channelModels) {
if (channelModel.name.includes('*') && wildcardMatch(channelModel.name, model)) {
return channelModel;
}
}
return null;
}
export const findSupportedModel = (
supportedModels: string[] | undefined,
model: string
@@ -127,11 +155,7 @@ export const findSupportedModel = (
}
export const getSupportedModels = (
config: Pick<ChannelConfig, "supported_models" | "deployment_mapper">
config: Pick<ChannelConfig, "models" | "supported_models" | "deployment_mapper">
): string[] => {
if (config.supported_models && config.supported_models.length > 0) {
return config.supported_models;
}
return Object.keys(config.deployment_mapper || {});
return getChannelModels(config).map((channelModel) => channelModel.name);
}
Vendored
+7 -1
View File
@@ -45,6 +45,11 @@ type ChannelType =
| undefined
| null;
type ChannelModelMapping = {
id: string;
name: string;
}
type ChannelConfig = {
name: string;
type: ChannelType;
@@ -53,8 +58,9 @@ type ChannelConfig = {
api_keys?: string[];
auto_retry?: boolean;
auto_rotate?: boolean;
models?: ChannelModelMapping[];
supported_models?: string[];
deployment_mapper: Record<string, string>;
deployment_mapper?: Record<string, string>;
model_pricing?: Record<string, ModelPricing>;
}