feat(channel): 支持多个 API 密钥和自动重试功能
- 更新 ChannelConfig 接口,添加 api_keys、auto_retry 和 auto_rotate 字段 - 修改 Channels 组件以支持新的配置 - 实现 API 请求的重试机制,增强稳定性
This commit is contained in:
1 parent
1ef5502083
commit
a2c97959ba
19 files changed
+432
-119
No files matched your search
+137
-68
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 语句中要插入的新值
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user