feat(channel): 支持多个 API 密钥和自动重试功能

- 更新 ChannelConfig 接口,添加 api_keys、auto_retry 和 auto_rotate 字段
- 修改 Channels 组件以支持新的配置
- 实现 API 请求的重试机制,增强稳定性
This commit is contained in:
Jeff Gu committed 2026-04-09 11:17:20 +08:00
1 parent 1ef5502083
commit a2c97959ba
19 files changed
+432 -119

No files matched your search

+137 -68
View File
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { useToast } from '@/components/ui/use-toast'
import { cn } from '@/lib/utils'
import {
@@ -41,21 +42,61 @@ const channelTypes = [
{ value: 'azure-openai-responses', label: 'Azure OpenAI Responses' },
]
const createDefaultChannelFormData = (): ChannelConfig => ({
name: '',
type: 'azure-openai',
endpoint: '',
api_keys: [],
auto_retry: true,
auto_rotate: true,
supported_models: [],
deployment_mapper: {},
})
const parseApiKeys = (value: string): string[] => {
const seen = new Set<string>()
return value
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.filter((line) => {
if (seen.has(line)) {
return false
}
seen.add(line)
return true
})
}
const formatApiKeys = (apiKeys?: string[]): string => {
return (apiKeys || []).join('\n')
}
const normalizeChannelFormConfig = (config: ChannelConfig): ChannelConfig => {
const legacyApiKey = typeof config.api_key === 'string' ? config.api_key.trim() : ''
const rawApiKeys = Array.isArray(config.api_keys) ? config.api_keys : []
const mergedKeys = parseApiKeys([legacyApiKey, ...rawApiKeys].join('\n'))
return {
...config,
api_key: undefined,
api_keys: mergedKeys,
auto_retry: config.auto_retry ?? true,
auto_rotate: config.auto_rotate ?? true,
supported_models: Array.isArray(config.supported_models) ? config.supported_models : [],
deployment_mapper: config.deployment_mapper || {},
}
}
export function Channels() {
const [view, setView] = useState<'list' | 'form'>('list')
const [editMode, setEditMode] = useState<EditMode>('form')
const [editingKey, setEditingKey] = useState<string | null>(null)
const [formData, setFormData] = useState<ChannelConfig>({
name: '',
type: 'azure-openai',
endpoint: '',
api_key: '',
api_version: '',
supported_models: [],
deployment_mapper: {},
})
const [formData, setFormData] = useState<ChannelConfig>(createDefaultChannelFormData())
const [channelKey, setChannelKey] = useState('')
const [jsonValue, setJsonValue] = useState('')
const [apiKeysInput, setApiKeysInput] = useState('')
const [supportedModelRows, setSupportedModelRows] = useState<Array<{ model: string }>>([])
const [mapperRows, setMapperRows] = useState<Array<{ request: string; deployment: string }>>([])
const [searchQuery, setSearchQuery] = useState('')
@@ -107,17 +148,10 @@ export function Channels() {
})
const resetForm = () => {
setFormData({
name: '',
type: 'azure-openai',
endpoint: '',
api_key: '',
api_version: '',
supported_models: [],
deployment_mapper: {},
})
setFormData(createDefaultChannelFormData())
setChannelKey('')
setJsonValue('')
setApiKeysInput('')
setSupportedModelRows([])
setMapperRows([])
setEditingKey(null)
@@ -132,18 +166,17 @@ export function Channels() {
const handleEdit = (channel: Channel) => {
setEditingKey(channel.key)
setChannelKey(channel.key)
const config = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value
const rawConfig = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value
const config = normalizeChannelFormConfig(rawConfig)
setFormData(config)
setJsonValue(JSON.stringify(config, null, 2))
setApiKeysInput(formatApiKeys(config.api_keys))
setSupportedModelRows((config.supported_models || []).map((model) => ({ model })))
if (config.deployment_mapper) {
const rows = Object.entries(config.deployment_mapper).map(([request, deployment]) => ({
request,
deployment: deployment as string,
}))
setMapperRows(rows)
}
const rows = Object.entries(config.deployment_mapper || {}).map(([request, deployment]) => ({
request,
deployment: deployment as string,
}))
setMapperRows(rows)
setView('form')
}
@@ -159,13 +192,7 @@ export function Channels() {
return
}
let config: any
if (editMode === 'form') {
if (!formData.name || !formData.endpoint || !formData.api_key) {
addToast('请填写所有必填字段', 'error')
return
}
const buildFormConfig = () => {
const deployment_mapper: Record<string, string> = {}
mapperRows.forEach((row) => {
if (row.request && row.deployment) {
@@ -177,18 +204,36 @@ export function Channels() {
.map((row) => row.model.trim())
.filter((model) => model.length > 0)
if (supported_models.length === 0) {
return normalizeChannelFormConfig({
...formData,
api_key: undefined,
api_keys: parseApiKeys(apiKeysInput),
supported_models,
deployment_mapper,
})
}
let config: any
if (editMode === 'form') {
const apiKeys = parseApiKeys(apiKeysInput)
const supportedModels = supportedModelRows
.map((row) => row.model.trim())
.filter((model) => model.length > 0)
if (!formData.name || !formData.endpoint || apiKeys.length === 0) {
addToast('请填写所有必填字段', 'error')
return
}
if (supportedModels.length === 0) {
addToast('请至少填写一个支持模型', 'error')
return
}
config = { ...formData, supported_models, deployment_mapper }
if (!formData.api_version) {
delete config.api_version
}
config = buildFormConfig()
} else {
try {
config = JSON.parse(jsonValue)
config = normalizeChannelFormConfig(JSON.parse(jsonValue))
} catch {
addToast('JSON格式错误', 'error')
return
@@ -209,21 +254,26 @@ export function Channels() {
const supported_models = supportedModelRows
.map((row) => row.model.trim())
.filter((model) => model.length > 0)
const config = { ...formData, supported_models, deployment_mapper }
const config = normalizeChannelFormConfig({
...formData,
api_key: undefined,
api_keys: parseApiKeys(apiKeysInput),
supported_models,
deployment_mapper,
})
setJsonValue(JSON.stringify(config, null, 2))
setEditMode('json')
} else {
try {
const config = JSON.parse(jsonValue)
const config = normalizeChannelFormConfig(JSON.parse(jsonValue))
setFormData(config)
setApiKeysInput(formatApiKeys(config.api_keys))
setSupportedModelRows((config.supported_models || []).map((model: string) => ({ model })))
if (config.deployment_mapper) {
const rows = Object.entries(config.deployment_mapper).map(([request, deployment]) => ({
request,
deployment: deployment as string,
}))
setMapperRows(rows)
}
const rows = Object.entries(config.deployment_mapper || {}).map(([request, deployment]) => ({
request,
deployment: deployment as string,
}))
setMapperRows(rows)
setEditMode('form')
} catch {
addToast('JSON格式错误', 'error')
@@ -514,26 +564,45 @@ export function Channels() {
placeholder="https://your-resource.openai.azure.com/"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm">API 密钥 <span className="text-destructive">*</span></Label>
<Input
type="password"
value={formData.api_key}
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
placeholder="sk-..."
<div className="space-y-2">
<Label className="text-sm">API 密钥 <span className="text-destructive">*</span></Label>
<Textarea
value={apiKeysInput}
onChange={(e) => {
setApiKeysInput(e.target.value)
setFormData({ ...formData, api_keys: parseApiKeys(e.target.value) })
}}
placeholder={'sk-xxx\nsk-yyy'}
rows={5}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
可配置多个密钥,每行一个,请求会随机选取一个密钥发起调用。
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label className="flex items-start gap-3 p-4 rounded-lg border bg-muted/30 cursor-pointer">
<Checkbox
className="mt-0.5"
checked={formData.auto_retry ?? true}
onCheckedChange={(checked) => setFormData({ ...formData, auto_retry: checked })}
/>
</div>
{(formData.type === 'azure-openai' || formData.type === 'azure-openai-audio' || formData.type === 'claude' || formData.type === 'azure-openai-responses') && (
<div className="space-y-2">
<Label className="text-sm">API 版本</Label>
<Input
value={formData.api_version || ''}
onChange={(e) => setFormData({ ...formData, api_version: e.target.value })}
placeholder="2024-02-01"
/>
<div>
<div className="text-sm font-medium">自动重试</div>
<p className="text-xs text-muted-foreground">单个密钥遇可重试错误时,自动重试 x3</p>
</div>
)}
</label>
<label className="flex items-start gap-3 p-4 rounded-lg border bg-muted/30 cursor-pointer">
<Checkbox
className="mt-0.5"
checked={formData.auto_rotate ?? true}
onCheckedChange={(checked) => setFormData({ ...formData, auto_rotate: checked })}
/>
<div>
<div className="text-sm font-medium">自动轮换</div>
<p className="text-xs text-muted-foreground">单个密钥多次失败后,自动轮换密钥 x3</p>
</div>
</label>
</div>
</div>
</CardContent>
@@ -658,7 +727,7 @@ export function Channels() {
onChange={(e) => setJsonValue(e.target.value)}
rows={18}
className="font-mono text-sm"
placeholder='{"name": "Azure OpenAI", "type": "azure-openai", ...}'
placeholder='{"name":"Azure OpenAI","type":"azure-openai","endpoint":"https://example.openai.azure.com/","api_keys":["sk-1","sk-2"],"auto_retry":true,"auto_rotate":true}'
/>
</CardContent>
</Card>
+5 -2
View File
@@ -8,10 +8,13 @@ export interface ChannelConfig {
name: string
type: 'openai' | 'azure-openai' | 'openai-audio' | 'azure-openai-audio' | 'claude' | 'claude-to-openai' | 'openai-responses' | 'azure-openai-responses'
endpoint: string
api_key: string
api_version?: string
api_key?: string
api_keys?: string[]
auto_retry?: boolean
auto_rotate?: boolean
supported_models: string[]
deployment_mapper: Record<string, string>
model_pricing?: Record<string, PricingModel>
}
export interface Token {
+15 -3
View File
@@ -3,6 +3,7 @@ import { contentJson, OpenAPIRoute } from 'chanfana';
import { z } from 'zod';
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
import { sanitizeChannelConfig } from "../channel-config";
// 获取所有 Channel 配置
export class ChannelGetEndpoint extends OpenAPIRoute {
@@ -48,8 +49,10 @@ export class ChannelUpsertEndpoint extends OpenAPIRoute {
name: z.string().describe('Channel name'),
type: z.string().describe('Channel type'),
endpoint: z.string().describe('API endpoint'),
api_key: z.string().describe('API key'),
api_version: z.string().optional().describe('API version'),
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'),
supported_models: z.array(z.string()).describe('Supported request model list'),
deployment_mapper: z.record(z.string()).describe('Model deployment mapping'),
model_pricing: z.record(z.object({
@@ -71,7 +74,16 @@ export class ChannelUpsertEndpoint extends OpenAPIRoute {
async handle(c: Context<HonoCustomType>) {
const { key } = c.req.param();
const config = await c.req.json<ChannelConfig>();
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 语句中要插入的新值
+70
View File
@@ -0,0 +1,70 @@
export const DEFAULT_CHANNEL_AUTO_RETRY = true;
export const DEFAULT_CHANNEL_AUTO_ROTATE = true;
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"> & {
api_keys: string[];
auto_retry: boolean;
auto_rotate: boolean;
};
const normalizeApiKeys = (config: Partial<ChannelConfig>): string[] => {
const rawKeys = Array.isArray(config.api_keys)
? [...config.api_keys]
: [];
if (typeof config.api_key === "string") {
rawKeys.unshift(config.api_key);
}
const normalizedKeys: string[] = [];
const seen = new Set<string>();
for (const key of rawKeys) {
const trimmedKey = typeof key === "string" ? key.trim() : "";
if (!trimmedKey || seen.has(trimmedKey)) {
continue;
}
seen.add(trimmedKey);
normalizedKeys.push(trimmedKey);
}
return normalizedKeys;
};
export const normalizeChannelConfig = (
config: Partial<ChannelConfig>
): NormalizedChannelConfig => {
return {
name: config.name || "",
type: config.type,
endpoint: config.endpoint || "",
api_key: typeof config.api_key === "string" ? config.api_key.trim() : undefined,
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 || {},
model_pricing: config.model_pricing,
};
};
export const sanitizeChannelConfig = (
config: Partial<ChannelConfig>
): ChannelConfig => {
const normalized = normalizeChannelConfig(config);
return {
name: normalized.name,
type: normalized.type,
endpoint: normalized.endpoint,
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,
model_pricing: normalized.model_pricing,
};
};
+7 -18
View File
@@ -1,31 +1,20 @@
import { Context } from "hono"
import { checkoutUsageData, handleStreamResponse } from "./shared/openai-stream-utils"
import { buildAzureTargetUrl } from "./shared/azure-target-url"
const buildProxyRequest = (
request: Request,
reqJson: any,
config: ChannelConfig,
deploymentName: string
config: ChannelConfig
): Request => {
const url = new URL(request.url)
const targetUrl = new URL(config.endpoint)
if (!config.endpoint.endsWith('#')) {
const basePath = targetUrl.pathname.endsWith('/')
? targetUrl.pathname.slice(0, -1)
: targetUrl.pathname
targetUrl.pathname = `${basePath}/openai/deployments/${deploymentName}/${url.pathname.replace('/v1/', '')}`
}
if (config.api_version) {
targetUrl.searchParams.set('api-version', config.api_version)
}
const targetUrl = buildAzureTargetUrl(request, config.endpoint)
const apiKey = config.api_key || ""
const targetHeaders = new Headers(request.headers)
targetHeaders.delete("Authorization")
targetHeaders.delete("Host")
targetHeaders.delete("Cookie")
targetHeaders.set("api-key", config.api_key)
targetHeaders.set("api-key", apiKey)
return new Request(targetUrl, {
method: request.method,
@@ -41,7 +30,7 @@ export default {
requestBody: any,
saveUsage: (usage: Usage) => Promise<void>,
): Promise<Response> {
const { model: deploymentName, stream } = requestBody;
const { stream } = requestBody;
if (stream) {
requestBody.stream_options = {
@@ -50,7 +39,7 @@ export default {
}
}
const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config, deploymentName)
const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config)
const response = await fetch(proxyRequest)
if (stream) {
+4 -11
View File
@@ -3,28 +3,21 @@ import {
handleStreamResponse,
checkoutResponsesUsageData,
} from "./shared/responses-stream-utils"
import { buildAzureTargetUrl } from "./shared/azure-target-url"
const buildProxyRequest = (
request: Request,
reqJson: any,
config: ChannelConfig
): Request => {
const url = new URL(request.url)
const targetUrl = new URL(config.endpoint)
if (!config.endpoint.endsWith('#')) {
targetUrl.pathname = `/openai${url.pathname}`
}
if (config.api_version) {
targetUrl.searchParams.set('api-version', config.api_version)
}
const targetUrl = buildAzureTargetUrl(request, config.endpoint)
const apiKey = config.api_key || ""
const targetHeaders = new Headers(request.headers)
targetHeaders.delete("Authorization")
targetHeaders.delete("Host")
targetHeaders.delete("Cookie")
targetHeaders.set("api-key", config.api_key)
targetHeaders.set("api-key", apiKey)
return new Request(targetUrl, {
method: request.method,
+5 -5
View File
@@ -1,4 +1,5 @@
import { Context } from "hono"
import { DEFAULT_CLAUDE_API_VERSION } from "../channel-config"
/**
* Claude API Proxy Provider
@@ -55,6 +56,7 @@ const buildProxyRequest = (
): Request => {
const url = new URL(request.url)
const targetUrl = new URL(config.endpoint)
const apiKey = config.api_key || ""
// Claude API uses /v1/messages endpoint
targetUrl.pathname = url.pathname
@@ -66,11 +68,9 @@ const buildProxyRequest = (
targetHeaders.delete("x-api-key")
// Claude uses x-api-key header instead of Authorization
targetHeaders.set("x-api-key", config.api_key)
targetHeaders.set("x-api-key", apiKey)
// Claude requires anthropic-version header
if (config.api_version) {
targetHeaders.set("anthropic-version", config.api_version)
}
targetHeaders.set("anthropic-version", DEFAULT_CLAUDE_API_VERSION)
return new Request(targetUrl, {
method: request.method,
@@ -91,7 +91,7 @@ const checkoutUsageData = async (
): Promise<void> => {
try {
const res = response instanceof Response
? await response.clone().json<ClaudeMessageResponse>()
? await response.clone().json() as ClaudeMessageResponse
: response
if (!res.usage) return;
+3 -2
View File
@@ -80,10 +80,11 @@ const buildProxyRequest = (
): Request => {
const targetUrl = new URL("v1/chat/completions", config.endpoint);
const targetHeaders = new Headers(request.headers);
const apiKey = config.api_key || "";
targetHeaders.delete("Authorization");
targetHeaders.delete("x-api-key");
targetHeaders.set("Authorization", `Bearer ${config.api_key}`);
targetHeaders.set("Authorization", `Bearer ${apiKey}`);
return new Request(targetUrl, {
method: request.method,
@@ -564,7 +565,7 @@ export default {
if (response.ok) {
try {
const resJson = await response.clone().json<OpenAIResponse & Record<string, any>>();
const resJson = await response.clone().json() as OpenAIResponse & Record<string, any>;
const usage = normalizeResponsesUsage(resJson?.usage);
if (usage) {
await saveUsage(usage);
+2 -1
View File
@@ -6,6 +6,7 @@ import db from "../db"
import { resolveRouteId } from "./shared/route-policy"
import { resolveChannel } from "./shared/channel-resolver"
import { getProvider } from "./shared/provider-registry"
import { executeWithChannelKeys } from "./shared/upstream-retry"
import { ModelsEndpoint } from "./models"
export const api = fromHono(new Hono<HonoCustomType>())
@@ -50,7 +51,7 @@ class UnifiedProxyEndpoint extends OpenAPIRoute {
return c.text("Channel type not supported", 400)
}
return provider(c, channel.config, requestBody, saveUsage)
return executeWithChannelKeys(c, channel.config, requestBody, saveUsage, provider)
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import { getApiKeyFromHeaders, fetchTokenData, fetchChannelsForToken } from "./shared/auth";
import { getSupportedModels } from "../utils";
import { normalizeChannelConfig } from "../channel-config";
export class ModelsEndpoint extends OpenAPIRoute {
schema = {
@@ -57,7 +58,7 @@ export class ModelsEndpoint extends OpenAPIRoute {
const modelsSet = new Set<string>();
for (const row of channelsResult.results) {
const config = JSON.parse(row.value) as ChannelConfig;
const config = normalizeChannelConfig(JSON.parse(row.value) as ChannelConfig);
for (const modelName of getSupportedModels(config)) {
modelsSet.add(modelName);
}
+2 -1
View File
@@ -8,13 +8,14 @@ const buildProxyRequest = (
): Request => {
const url = new URL(request.url)
const targetUrl = new URL(config.endpoint)
const apiKey = config.api_key || ""
targetUrl.pathname = url.pathname
const targetHeaders = new Headers(request.headers)
targetHeaders.delete("Host")
targetHeaders.delete("Cookie")
targetHeaders.set("Authorization", `Bearer ${config.api_key}`)
targetHeaders.set("Authorization", `Bearer ${apiKey}`)
return new Request(targetUrl, {
method: request.method,
+2 -1
View File
@@ -11,13 +11,14 @@ const buildProxyRequest = (
): Request => {
const url = new URL(request.url)
const targetUrl = new URL(config.endpoint)
const apiKey = config.api_key || ""
targetUrl.pathname = url.pathname
const targetHeaders = new Headers(request.headers)
targetHeaders.delete("Host")
targetHeaders.delete("Cookie")
targetHeaders.set("Authorization", `Bearer ${config.api_key}`)
targetHeaders.set("Authorization", `Bearer ${apiKey}`)
return new Request(targetUrl, {
method: request.method,
+33
View File
@@ -0,0 +1,33 @@
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 buildAzureTargetUrl = (
request: Request,
endpoint: string
): URL => {
const requestUrl = new URL(request.url);
const targetUrl = new URL(endpoint);
if (endpoint.endsWith("#")) {
return targetUrl;
}
const requestPath = requestUrl.pathname.replace(/^\/v1/, "");
const currentBasePath = trimSlashes(targetUrl.pathname);
const azureBasePath = currentBasePath.endsWith("openai/v1")
? currentBasePath
: joinPath(currentBasePath, "openai/v1");
targetUrl.pathname = joinPath(azureBasePath, requestPath);
return targetUrl;
};
+8 -1
View File
@@ -3,6 +3,7 @@ import { getApiKeyFromHeaders, fetchTokenData, fetchChannelsForToken } from "./a
import { RouteId, getRoutePolicy } from "./route-policy"
import { findDeploymentMapping, findSupportedModel, getSupportedModels } from "../../utils"
import { TokenUtils } from "../../admin/token_utils"
import { normalizeChannelConfig } from "../../channel-config"
export type ChannelResolution = {
channel: { key: string; config: ChannelConfig }
@@ -53,7 +54,13 @@ export const resolveChannel = async (
const availableChannels: Array<{ key: string, config: ChannelConfig, mapping: { pattern: string, deployment: string } }> = [];
for (const row of channelsResult.results) {
const config = (() => { try { return JSON.parse(row.value) as ChannelConfig; } catch { return null; } })();
const config = (() => {
try {
return normalizeChannelConfig(JSON.parse(row.value) as ChannelConfig);
} catch {
return null;
}
})();
if (!config) {
console.error(`Invalid channel config for key: ${row.key}`);
continue;
+1 -1
View File
@@ -66,7 +66,7 @@ export const checkoutUsageData = async (
return
}
const res = await response.clone().json<OpenAIResponse>()
const res = await response.clone().json() as OpenAIResponse
if (!res.usage) return;
await saveUsage(res.usage)
return
+1 -1
View File
@@ -7,7 +7,7 @@ import claudeToOpenaiProxy from "../claude-to-openai-proxy"
import openaiResponsesProxy from "../openai-responses-proxy"
import azureOpenaiResponsesProxy from "../azure-openai-responses-proxy"
type ProviderFetch = (
export type ProviderFetch = (
c: Context<HonoCustomType>,
config: ChannelConfig,
requestBody: any,
@@ -101,7 +101,7 @@ export const checkoutResponsesUsageData = async (
requestBody: any,
): Promise<void> => {
try {
const resJson = await response.clone().json<OpenAIResponsesResponse>()
const resJson = await response.clone().json() as OpenAIResponsesResponse
const usage = extractUsageFromResponse(resJson)
if (usage) {
await saveUsage(usage)
+130
View File
@@ -0,0 +1,130 @@
import { Context } from "hono";
import {
MAX_RETRIES_PER_KEY,
MAX_ROTATION_ATTEMPTS,
normalizeChannelConfig,
} from "../../channel-config";
import { ProviderFetch } from "./provider-registry";
const RETRYABLE_STATUS_CODES = new Set([401, 403, 408, 409, 429, 500, 502, 503, 504, 529]);
const shuffleKeys = (keys: string[]): string[] => {
const clonedKeys = [...keys];
for (let index = clonedKeys.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[clonedKeys[index], clonedKeys[randomIndex]] = [clonedKeys[randomIndex], clonedKeys[index]];
}
return clonedKeys;
};
const cloneRequestBody = <T>(requestBody: T): T => {
if (requestBody == null) {
return requestBody;
}
return JSON.parse(JSON.stringify(requestBody)) as T;
};
const shouldRetryResponse = (response: Response): boolean => {
return RETRYABLE_STATUS_CODES.has(response.status);
};
const discardResponse = async (response: Response): Promise<void> => {
try {
await response.body?.cancel();
} catch (error) {
console.warn("Failed to cancel upstream response body:", error);
}
};
const getCandidateKeys = (config: ChannelConfig): string[] => {
const normalized = normalizeChannelConfig(config);
const shuffledKeys = shuffleKeys(normalized.api_keys);
if (!normalized.auto_rotate) {
return shuffledKeys.slice(0, 1);
}
return shuffledKeys.slice(0, Math.min(shuffledKeys.length, 1 + MAX_ROTATION_ATTEMPTS));
};
export const executeWithChannelKeys = async (
c: Context<HonoCustomType>,
config: ChannelConfig,
requestBody: any,
saveUsage: (usage: Usage) => Promise<void>,
provider: ProviderFetch,
): Promise<Response> => {
const normalizedConfig = normalizeChannelConfig(config);
const candidateKeys = getCandidateKeys(normalizedConfig);
if (candidateKeys.length === 0) {
return c.text("Channel API keys not configured", 500);
}
const attemptsPerKey = normalizedConfig.auto_retry
? 1 + MAX_RETRIES_PER_KEY
: 1;
for (let keyIndex = 0; keyIndex < candidateKeys.length; keyIndex += 1) {
const currentKey = candidateKeys[keyIndex];
for (let attemptIndex = 0; attemptIndex < attemptsPerKey; attemptIndex += 1) {
const isLastAttemptForKey = attemptIndex === attemptsPerKey - 1;
const isLastKey = keyIndex === candidateKeys.length - 1;
try {
const runtimeConfig: ChannelConfig = {
...normalizedConfig,
api_key: currentKey,
api_keys: [currentKey],
};
const response = await provider(
c,
runtimeConfig,
cloneRequestBody(requestBody),
saveUsage,
);
if (response.ok) {
return response;
}
if (!shouldRetryResponse(response)) {
return response;
}
console.warn(
`Retryable upstream failure for channel "${normalizedConfig.name || "unknown"}", `
+ `key ${keyIndex + 1}/${candidateKeys.length}, `
+ `attempt ${attemptIndex + 1}/${attemptsPerKey}, `
+ `status ${response.status}`
);
if (isLastAttemptForKey && isLastKey) {
return response;
}
await discardResponse(response);
} catch (error) {
console.error(
`Upstream request error for channel "${normalizedConfig.name || "unknown"}", `
+ `key ${keyIndex + 1}/${candidateKeys.length}, `
+ `attempt ${attemptIndex + 1}/${attemptsPerKey}`,
error
);
if (isLastAttemptForKey && isLastKey) {
const message = error instanceof Error ? error.message : "Unknown upstream error";
return c.text(`Upstream request failed: ${message}`, 502);
}
}
}
}
return c.text("Upstream request failed after retries", 502);
};
Vendored
+4 -2
View File
@@ -49,8 +49,10 @@ type ChannelConfig = {
name: string;
type: ChannelType;
endpoint: string;
api_key: string;
api_version?: string;
api_key?: string;
api_keys?: string[];
auto_retry?: boolean;
auto_rotate?: boolean;
supported_models?: string[];
deployment_mapper: Record<string, string>;
model_pricing?: Record<string, ModelPricing>;