325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
import { Context } from "hono"
|
|
import { contentJson, OpenAPIRoute } from 'chanfana';
|
|
import { z } from 'zod';
|
|
|
|
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
|
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 = buildPrefixedTargetUrl(config.endpoint, "/v1/models");
|
|
headers.set("x-api-key", apiKey);
|
|
headers.set("anthropic-version", DEFAULT_CLAUDE_API_VERSION);
|
|
break;
|
|
case "openai":
|
|
case "gemini":
|
|
case "openai-audio":
|
|
case "openai-responses":
|
|
case "claude-to-openai":
|
|
default:
|
|
targetUrl = buildPrefixedTargetUrl(config.endpoint, "/v1/models", "/v1", config.type);
|
|
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 {
|
|
schema = {
|
|
tags: ['Admin API'],
|
|
summary: 'Get all channel configurations',
|
|
responses: {
|
|
...CommonSuccessfulResponse(z.array(z.object({
|
|
key: z.string(),
|
|
value: z.string(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}))),
|
|
...CommonErrorResponse,
|
|
},
|
|
};
|
|
|
|
async handle(c: Context<HonoCustomType>) {
|
|
const result = await c.env.DB.prepare(
|
|
"SELECT * FROM channel_config"
|
|
).all<ChannelConfigRow>();
|
|
|
|
return {
|
|
success: true,
|
|
data: result.results
|
|
} as CommonResponse;
|
|
}
|
|
}
|
|
|
|
// 创建或更新 Channel 配置
|
|
export class ChannelUpsertEndpoint extends OpenAPIRoute {
|
|
schema = {
|
|
tags: ['Admin API'],
|
|
summary: 'Create or update channel configuration',
|
|
request: {
|
|
params: z.object({
|
|
key: z.string().describe('Channel key'),
|
|
}),
|
|
body: {
|
|
content: {
|
|
'application/json': {
|
|
schema: z.object({
|
|
name: z.string().describe('Channel name'),
|
|
type: z.string().describe('Channel type'),
|
|
endpoint: z.string().describe('API endpoint'),
|
|
api_key: z.string().optional().describe('Deprecated single API key'),
|
|
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'),
|
|
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(), z.string()).optional().describe('Deprecated model deployment mapping'),
|
|
model_pricing: z.record(z.string(), z.object({
|
|
input: z.number().describe('Input token price per 1000 tokens'),
|
|
output: z.number().describe('Output token price per 1000 tokens'),
|
|
cache: z.number().optional().describe('Cache token price per 1000 tokens'),
|
|
request: z.number().optional().describe('Fixed request price'),
|
|
})).optional().describe('Custom model pricing for this channel'),
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
...CommonSuccessfulResponse(z.boolean()),
|
|
...CommonErrorResponse,
|
|
},
|
|
};
|
|
|
|
async handle(c: Context<HonoCustomType>) {
|
|
const { key } = c.req.param();
|
|
const rawConfig = await c.req.json<ChannelConfig>();
|
|
const config = sanitizeChannelConfig(rawConfig);
|
|
|
|
if (!config.name || !config.endpoint) {
|
|
return c.text('Channel name and endpoint are required', 400);
|
|
}
|
|
|
|
if (!config.api_keys || config.api_keys.length === 0) {
|
|
return c.text('At least one API key is required', 400);
|
|
}
|
|
|
|
// Upsert channel config directly using SQL
|
|
// excluded.value 指的是 INSERT 语句中要插入的新值
|
|
// 当发生冲突时,用新值更新现有记录
|
|
const result = await c.env.DB.prepare(
|
|
`INSERT INTO channel_config (key, value)
|
|
VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
value = excluded.value,
|
|
updated_at = datetime('now')`
|
|
).bind(key, JSON.stringify(config)).run();
|
|
|
|
if (!result.success) {
|
|
return c.text('Failed to upsert channel config', 500);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: true
|
|
} as CommonResponse;
|
|
}
|
|
}
|
|
|
|
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(), z.string()).optional(),
|
|
model_pricing: z.record(z.string(), 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 = {
|
|
tags: ['Admin API'],
|
|
summary: 'Delete channel configuration',
|
|
request: {
|
|
params: z.object({
|
|
key: z.string().describe('Channel key'),
|
|
}),
|
|
},
|
|
responses: {
|
|
...CommonSuccessfulResponse(z.boolean()),
|
|
...CommonErrorResponse,
|
|
},
|
|
};
|
|
|
|
async handle(c: Context<HonoCustomType>) {
|
|
const { key } = c.req.param();
|
|
|
|
// Delete channel config directly using SQL
|
|
const result = await c.env.DB.prepare(
|
|
`DELETE FROM channel_config WHERE key = ?`
|
|
).bind(key).run();
|
|
|
|
return {
|
|
success: true,
|
|
data: result.success
|
|
} as CommonResponse;
|
|
}
|
|
}
|