diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index b248652..342f3bb 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -34,6 +34,7 @@ const channelTypes = [ { value: 'azure-openai', label: 'Azure OpenAI' }, { value: 'openai', label: 'OpenAI' }, { value: 'claude', label: 'Claude' }, + { value: 'claude-to-openai', label: 'Claude → OpenAI' }, { value: 'openai-responses', label: 'OpenAI Responses' }, { value: 'azure-openai-responses', label: 'Azure OpenAI Responses' }, ] diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 911caf4..805a5c6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -6,7 +6,7 @@ export interface Channel { export interface ChannelConfig { name: string - type: 'openai' | 'azure-openai' | 'claude' | 'openai-responses' | 'azure-openai-responses' + type: 'openai' | 'azure-openai' | 'claude' | 'claude-to-openai' | 'openai-responses' | 'azure-openai-responses' endpoint: string api_key: string api_version?: string diff --git a/src/providers/azure-openai-proxy.ts b/src/providers/azure-openai-proxy.ts index c5243aa..b6f0a7f 100644 --- a/src/providers/azure-openai-proxy.ts +++ b/src/providers/azure-openai-proxy.ts @@ -115,25 +115,19 @@ export default { requestBody: any, saveUsage: (usage: Usage) => Promise, ): Promise { - // 准备请求数据 - const reqJson = requestBody + // model 已在上层完成映射,直接作为 deploymentName 使用 + const { model: deploymentName, stream } = requestBody; + // 强制包含使用数据 - const { model: modelName, stream } = reqJson; if (stream) { - reqJson.stream_options = { - ...(reqJson.stream_options || {}), + requestBody.stream_options = { + ...(requestBody.stream_options || {}), include_usage: true, } } - // 获取部署名称 - const deploymentName = config.deployment_mapper[modelName] - if (!deploymentName) { - throw new Error(`Model ${modelName} not supported`) - } - // 构建目标请求 - const proxyRequest = buildProxyRequest(c.req.raw, reqJson, config, deploymentName) + const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config, deploymentName) // 发送请求并获取响应 const response = await fetch(proxyRequest) diff --git a/src/providers/azure-openai-responses-proxy.ts b/src/providers/azure-openai-responses-proxy.ts index 1e2988c..68ee305 100644 --- a/src/providers/azure-openai-responses-proxy.ts +++ b/src/providers/azure-openai-responses-proxy.ts @@ -130,22 +130,16 @@ export default { requestBody: any, saveUsage: (usage: Usage) => Promise, ): Promise { - const reqJson = requestBody - const { model: modelName, stream } = reqJson + const { stream } = requestBody - const deploymentName = config.deployment_mapper[modelName] - if (!deploymentName) { - throw new Error(`Model ${modelName} not supported`) - } + // model 已在上层完成映射 - reqJson.model = deploymentName - - const proxyRequest = buildProxyRequest(c.req.raw, reqJson, config) + const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const response = await fetch(proxyRequest) if (stream) { const [streamForClient, streamForServer] = response.body?.tee() || [] - c.executionCtx.waitUntil(handleStreamResponse(c, streamForServer, reqJson, saveUsage)) + c.executionCtx.waitUntil(handleStreamResponse(c, streamForServer, requestBody, saveUsage)) return new Response(streamForClient, { headers: response.headers, status: response.status, diff --git a/src/providers/claude-proxy.ts b/src/providers/claude-proxy.ts index 5614307..cda43ff 100644 --- a/src/providers/claude-proxy.ts +++ b/src/providers/claude-proxy.ts @@ -264,24 +264,15 @@ export default { requestBody: any, saveUsage: (usage: Usage) => Promise, ): Promise { - // Parse request body - const reqJson = requestBody - const { model: modelName, stream } = reqJson; + const { stream } = requestBody; - // Validate model support - const deploymentName = config.deployment_mapper[modelName] - if (!deploymentName) { - throw new Error(`Model ${modelName} not supported`) - } - - // Map model name - reqJson.model = deploymentName + // model 已在上层完成映射 // Note: Unlike OpenAI, Claude's non-streaming response already includes usage data, // so we don't need to force stream=true for usage tracking // Build and send proxy request - const proxyRequest = buildProxyRequest(c.req.raw, reqJson, config) + const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const response = await fetch(proxyRequest) // Handle streaming response diff --git a/src/providers/claude-to-openai-proxy.ts b/src/providers/claude-to-openai-proxy.ts new file mode 100644 index 0000000..6733da1 --- /dev/null +++ b/src/providers/claude-to-openai-proxy.ts @@ -0,0 +1,603 @@ +import { Context } from "hono" + +type OpenAIUsageLike = { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + input_tokens?: number; + output_tokens?: number; +} + +type OpenAIStreamChoice = { + index?: number; + delta?: { + role?: string; + content?: string | Array<{ type?: string; text?: string }>; + tool_calls?: Array<{ + index?: number; + id?: string; + type?: string; + function?: { + name?: string; + arguments?: string; + }; + }>; + }; + finish_reason?: string | null; +} + +type OpenAIStreamChunk = { + id?: string; + model?: string; + choices?: OpenAIStreamChoice[]; + usage?: OpenAIUsageLike; +} + +const mapFinishReasonToClaude = (finishReason?: string | null): string | null => { + switch (finishReason) { + case "stop": + return "end_turn"; + case "length": + return "max_tokens"; + case "tool_calls": + return "tool_use"; + default: + return null; + } +} + +const extractSystemText = (system: any): string | null => { + if (typeof system === "string") { + return system; + } + if (Array.isArray(system)) { + let systemText = ""; + for (const block of system) { + if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") { + systemText += block.text; + } + } + return systemText || null; + } + return null; +} + +const extractTextFromContent = (content: any): string => { + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + return content + .map(part => (part && typeof part === "object" && typeof part.text === "string") ? part.text : "") + .join(""); + } + return ""; +} + +const buildProxyRequest = ( + request: Request, + reqJson: any, + config: ChannelConfig +): Request => { + const targetUrl = new URL("v1/chat/completions", config.endpoint); + const targetHeaders = new Headers(request.headers); + + targetHeaders.delete("Authorization"); + targetHeaders.delete("x-api-key"); + targetHeaders.set("Authorization", `Bearer ${config.api_key}`); + + return new Request(targetUrl, { + method: request.method, + headers: targetHeaders, + body: JSON.stringify(reqJson), + }); +} + +const convertClaudeToOpenAIRequest = (reqJson: any): any => { + const openaiReq: any = { + model: reqJson.model, + stream: reqJson.stream, + max_tokens: reqJson.max_tokens, + temperature: reqJson.temperature, + top_p: reqJson.top_p, + }; + + if (Array.isArray(reqJson.stop_sequences) && reqJson.stop_sequences.length > 0) { + openaiReq.stop = reqJson.stop_sequences; + } + + const messages: any[] = []; + const systemText = extractSystemText(reqJson.system); + if (systemText) { + messages.push({ + role: "system", + content: systemText, + }); + } + + if (Array.isArray(reqJson.messages)) { + for (const msg of reqJson.messages) { + const openaiMsg: any = { + role: msg?.role, + }; + const content = msg?.content; + + if (typeof content === "string") { + openaiMsg.content = content; + } else if (Array.isArray(content)) { + const textParts: Array<{ type: "text"; text: string }> = []; + const toolCalls: any[] = []; + + for (const block of content) { + if (!block || typeof block !== "object") continue; + const blockType = block.type; + + if (blockType === "text") { + if (typeof block.text === "string") { + textParts.push({ type: "text", text: block.text }); + } + continue; + } + + if (blockType === "tool_use") { + const id = typeof block.id === "string" ? block.id : ""; + const name = typeof block.name === "string" ? block.name : ""; + let inputArgs = "{}"; + try { + inputArgs = JSON.stringify(block.input ?? {}); + } catch (error) { + inputArgs = "{}"; + } + toolCalls.push({ + id, + type: "function", + function: { + name, + arguments: inputArgs, + }, + }); + continue; + } + + if (blockType === "tool_result") { + const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : ""; + const toolContent = extractTextFromContent(block.content); + messages.push({ + role: "tool", + content: toolContent, + tool_call_id: toolUseId, + }); + continue; + } + } + + if (toolCalls.length > 0) { + openaiMsg.tool_calls = toolCalls; + } + + if (textParts.length === 1) { + openaiMsg.content = textParts[0].text; + } else if (textParts.length > 0) { + openaiMsg.content = textParts; + } + } + + messages.push(openaiMsg); + } + } + + openaiReq.messages = messages; + + if (Array.isArray(reqJson.tools)) { + openaiReq.tools = reqJson.tools.map((tool: any) => ({ + type: "function", + function: { + name: tool?.name, + description: tool?.description, + parameters: tool?.input_schema, + }, + })); + } + + return openaiReq; +} + +const convertOpenAIResponseToClaude = (resJson: any): any => { + const choice = Array.isArray(resJson?.choices) ? resJson.choices[0] : undefined; + const message = choice?.message ?? {}; + + const contentBlocks: any[] = []; + const textContent = extractTextFromContent(message?.content); + if (textContent) { + contentBlocks.push({ + type: "text", + text: textContent, + }); + } + + if (Array.isArray(message?.tool_calls)) { + for (const toolCall of message.tool_calls) { + const id = typeof toolCall?.id === "string" ? toolCall.id : ""; + const name = typeof toolCall?.function?.name === "string" ? toolCall.function.name : ""; + let input = {}; + const args = toolCall?.function?.arguments; + if (typeof args === "string") { + try { + input = JSON.parse(args); + } catch (error) { + input = {}; + } + } else if (args && typeof args === "object") { + input = args; + } + contentBlocks.push({ + type: "tool_use", + id, + name, + input, + }); + } + } + + const usage = resJson?.usage ?? {}; + const inputTokens = usage.prompt_tokens ?? usage.input_tokens ?? 0; + const outputTokens = usage.completion_tokens ?? usage.output_tokens ?? 0; + + return { + id: resJson?.id ?? "", + type: "message", + role: "assistant", + model: resJson?.model ?? "", + content: contentBlocks, + stop_reason: mapFinishReasonToClaude(choice?.finish_reason ?? null), + stop_sequence: null, + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens, + }, + }; +} + +const normalizeUsage = (raw?: OpenAIUsageLike): Usage | null => { + if (!raw) return null; + const promptTokens = raw.prompt_tokens ?? raw.input_tokens; + const completionTokens = raw.completion_tokens ?? raw.output_tokens; + const totalTokens = raw.total_tokens ?? ( + (promptTokens ?? 0) + (completionTokens ?? 0) + ); + if (promptTokens == null && completionTokens == null && totalTokens == null) { + return null; + } + return { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: totalTokens, + }; +} + +const createClaudeStreamTransformer = ( + saveUsage: (usage: Usage) => Promise +): TransformStream => { + const decoder = new TextDecoder('utf-8'); + const encoder = new TextEncoder(); + + let buffer = ""; + let messageStarted = false; + let messageStopped = false; + let messageId = ""; + let model = ""; + let textBlockIndex: number | null = null; + let nextContentIndex = 0; + let stopReason: string | null = null; + let outputTokens = 0; + let usageToSave: Usage | null = null; + + const toolBlocks = new Map(); + const openBlocks: number[] = []; + const openBlockSet = new Set(); + + const enqueueEvent = ( + controller: TransformStreamDefaultController, + eventType: string, + data: any + ) => { + const payload = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`; + controller.enqueue(encoder.encode(payload)); + }; + + const startMessage = (controller: TransformStreamDefaultController, chunk?: OpenAIStreamChunk) => { + if (messageStarted) return; + messageId = chunk?.id ?? messageId ?? ""; + model = chunk?.model ?? model ?? ""; + messageStarted = true; + enqueueEvent(controller, "message_start", { + type: "message_start", + message: { + id: messageId || "", + type: "message", + role: "assistant", + model: model || "", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + }, + }); + }; + + const startContentBlock = ( + controller: TransformStreamDefaultController, + index: number, + contentBlock: any + ) => { + if (!openBlockSet.has(index)) { + openBlockSet.add(index); + openBlocks.push(index); + } + enqueueEvent(controller, "content_block_start", { + type: "content_block_start", + index, + content_block: contentBlock, + }); + }; + + const ensureTextBlock = (controller: TransformStreamDefaultController) => { + if (textBlockIndex == null) { + textBlockIndex = nextContentIndex++; + startContentBlock(controller, textBlockIndex, { + type: "text", + text: "", + }); + } + return textBlockIndex; + }; + + const ensureToolBlock = ( + controller: TransformStreamDefaultController, + toolIndex: number, + id: string, + name: string + ) => { + const existing = toolBlocks.get(toolIndex); + if (existing) { + if (id && !existing.id) existing.id = id; + if (name && !existing.name) existing.name = name; + return existing.contentIndex; + } + const contentIndex = nextContentIndex++; + toolBlocks.set(toolIndex, { + contentIndex, + id, + name, + }); + startContentBlock(controller, contentIndex, { + type: "tool_use", + id: id || "", + name: name || "", + input: {}, + }); + return contentIndex; + }; + + const handleFinish = (controller: TransformStreamDefaultController, finishReason?: string | null) => { + if (messageStopped) return; + if (!messageStarted) { + startMessage(controller); + } + if (finishReason != null) { + stopReason = mapFinishReasonToClaude(finishReason); + } + + for (const index of openBlocks) { + enqueueEvent(controller, "content_block_stop", { + type: "content_block_stop", + index, + }); + } + + enqueueEvent(controller, "message_delta", { + type: "message_delta", + delta: { + stop_reason: stopReason, + }, + usage: { + output_tokens: outputTokens, + }, + }); + + enqueueEvent(controller, "message_stop", { + type: "message_stop", + }); + + messageStopped = true; + }; + + const processLine = (rawLine: string, controller: TransformStreamDefaultController) => { + const line = rawLine.trim(); + if (!line || !line.startsWith("data:")) return; + + const jsonContent = line.replace(/^data:\s*/, "").trim(); + if (!jsonContent || jsonContent === "[DONE]") { + if (jsonContent === "[DONE]") { + handleFinish(controller, undefined); + } + return; + } + + let chunkJson: OpenAIStreamChunk | null = null; + try { + chunkJson = JSON.parse(jsonContent) as OpenAIStreamChunk; + } catch (error) { + console.error("Error parsing OpenAI stream chunk:", error); + return; + } + + if (!chunkJson) return; + + if (chunkJson.usage) { + const normalized = normalizeUsage(chunkJson.usage); + if (normalized) { + usageToSave = normalized; + outputTokens = normalized.completion_tokens ?? outputTokens; + } + } + + const choice = Array.isArray(chunkJson.choices) ? chunkJson.choices[0] : undefined; + if (!choice) return; + + const delta = choice.delta ?? {}; + if (!messageStarted && (delta.role === "assistant" || delta.content || delta.tool_calls)) { + startMessage(controller, chunkJson); + } + + if (delta.content) { + const textBlocks = Array.isArray(delta.content) + ? delta.content + : [{ type: "text", text: delta.content }]; + for (const part of textBlocks) { + const text = typeof part?.text === "string" ? part.text : ""; + if (!text) continue; + const index = ensureTextBlock(controller); + enqueueEvent(controller, "content_block_delta", { + type: "content_block_delta", + index, + delta: { + type: "text_delta", + text, + }, + }); + } + } + + if (Array.isArray(delta.tool_calls)) { + for (const toolCall of delta.tool_calls) { + const toolIndex = typeof toolCall?.index === "number" ? toolCall.index : 0; + const id = typeof toolCall?.id === "string" ? toolCall.id : ""; + const name = typeof toolCall?.function?.name === "string" ? toolCall.function.name : ""; + const argumentsDelta = typeof toolCall?.function?.arguments === "string" + ? toolCall.function.arguments + : ""; + + const contentIndex = ensureToolBlock(controller, toolIndex, id, name); + + if (argumentsDelta) { + enqueueEvent(controller, "content_block_delta", { + type: "content_block_delta", + index: contentIndex, + delta: { + type: "input_json_delta", + partial_json: argumentsDelta, + }, + }); + } + } + } + + if (choice.finish_reason) { + handleFinish(controller, choice.finish_reason); + } + }; + + return new TransformStream({ + async transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + + if (!buffer.includes("\n")) { + return; + } + + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const rawLine of lines) { + processLine(rawLine, controller); + } + }, + async flush(controller) { + if (buffer.trim()) { + processLine(buffer, controller); + } + + if (!messageStopped) { + handleFinish(controller, stopReason ?? undefined); + } + + if (usageToSave) { + try { + await saveUsage(usageToSave); + } catch (error) { + console.error("Error saving usage data:", error); + } + } + }, + }); +} + +export default { + async fetch( + c: Context, + config: ChannelConfig, + requestBody: any, + saveUsage: (usage: Usage) => Promise, + ): Promise { + const { stream } = requestBody; + + // model 已在上层完成映射 + const openaiReq = convertClaudeToOpenAIRequest(requestBody); + + if (stream) { + openaiReq.stream_options = { + ...(openaiReq.stream_options || {}), + include_usage: true, + }; + } + + const proxyRequest = buildProxyRequest(c.req.raw, openaiReq, config); + const response = await fetch(proxyRequest); + + if (stream) { + if (!response.ok || !response.body) { + return response; + } + + const transformedStream = response.body.pipeThrough( + createClaudeStreamTransformer(saveUsage) + ); + const headers = new Headers(response.headers); + headers.set("content-type", "text/event-stream; charset=utf-8"); + headers.delete("content-length"); + + return new Response(transformedStream, { + headers, + status: response.status, + statusText: response.statusText, + }); + } + + if (response.ok) { + try { + const resJson = await response.clone().json(); + const usage = normalizeUsage(resJson?.usage); + if (usage) { + await saveUsage(usage); + } + const claudeResp = convertOpenAIResponseToClaude(resJson); + const headers = new Headers(response.headers); + headers.set("content-type", "application/json"); + headers.delete("content-length"); + return new Response(JSON.stringify(claudeResp), { + headers, + status: response.status, + statusText: response.statusText, + }); + } catch (error) { + console.error("Error converting OpenAI response:", error); + } + } + + return response; + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 697ba90..209c211 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -4,9 +4,10 @@ import { contentJson, fromHono, OpenAPIRoute } from 'chanfana'; import azureOpenaiProxy from "./azure-openai-proxy" import openaiProxy from "./openai-proxy" import claudeProxy from "./claude-proxy" +import claudeToOpenaiProxy from "./claude-to-openai-proxy" import openaiResponsesProxy from "./openai-responses-proxy" import azureOpenaiResponsesProxy from "./azure-openai-responses-proxy" -import utils from "../utils" +import utils, { findDeploymentMapping } from "../utils" import { TokenUtils } from "../admin/token_utils" import { CONSTANTS } from "../constants" import { z } from "zod"; @@ -25,6 +26,7 @@ const providerMap: Record< "azure-openai": azureOpenaiProxy.fetch, "openai": openaiProxy.fetch, "claude": claudeProxy.fetch, + "claude-to-openai": claudeToOpenaiProxy.fetch, "openai-responses": openaiResponsesProxy.fetch, "azure-openai-responses": azureOpenaiResponsesProxy.fetch, }; @@ -130,16 +132,18 @@ class ProxyEndpoint extends OpenAPIRoute { } // Filter channels that support the requested model - const availableChannels: Array<{ key: string, config: ChannelConfig }> = []; + const availableChannels: Array<{ key: string, config: ChannelConfig, mapping: { pattern: string, deployment: string } }> = []; for (const row of channelsResult.results) { const config = JSON.parse(row.value) as ChannelConfig; // Check if channel supports the model (has deployment mapping for it) - if (config.deployment_mapper && config.deployment_mapper[model]) { + const mapping = findDeploymentMapping(config.deployment_mapper, model); + if (mapping) { availableChannels.push({ key: row.key, - config: config + config: config, + mapping: mapping }); } } @@ -157,6 +161,9 @@ class ProxyEndpoint extends OpenAPIRoute { const targetChannelKey = selectedChannel.key; const targetChannelConfig = selectedChannel.config; + // 统一在上层完成模型名映射 + requestBody.model = selectedChannel.mapping.deployment; + if (!targetChannelConfig.type) { return c.text("Channel type invalid", 400); } @@ -235,7 +242,7 @@ class ResponsesProxyEndpoint extends OpenAPIRoute { } const allowedTypes: ChannelType[] = ["openai-responses", "azure-openai-responses"]; - const availableChannels: Array<{ key: string, config: ChannelConfig }> = []; + const availableChannels: Array<{ key: string, config: ChannelConfig, mapping: { pattern: string, deployment: string } }> = []; for (const row of channelsResult.results) { const config = JSON.parse(row.value) as ChannelConfig; @@ -244,10 +251,12 @@ class ResponsesProxyEndpoint extends OpenAPIRoute { continue; } - if (config.deployment_mapper && config.deployment_mapper[model]) { + const mapping = findDeploymentMapping(config.deployment_mapper, model); + if (mapping) { availableChannels.push({ key: row.key, - config: config + config: config, + mapping: mapping }); } } @@ -264,6 +273,9 @@ class ResponsesProxyEndpoint extends OpenAPIRoute { const targetChannelKey = selectedChannel.key; const targetChannelConfig = selectedChannel.config; + // 统一在上层完成模型名映射 + requestBody.model = selectedChannel.mapping.deployment; + const proxyFetch = providerMap[targetChannelConfig.type || ""]; if (!proxyFetch) { return c.text("Channel type not supported", 400); diff --git a/src/providers/openai-proxy.ts b/src/providers/openai-proxy.ts index 07d95c8..78da4c2 100644 --- a/src/providers/openai-proxy.ts +++ b/src/providers/openai-proxy.ts @@ -103,28 +103,20 @@ export default { requestBody: any, saveUsage: (usage: Usage) => Promise, ): Promise { - // 准备请求数据 - const reqJson = requestBody + const { stream } = requestBody; + // 强制包含使用数据 - const { model: modelName, stream } = reqJson; if (stream) { - reqJson.stream_options = { - ...(reqJson.stream_options || {}), + requestBody.stream_options = { + ...(requestBody.stream_options || {}), include_usage: true, } } - // 检查模型是否受支持 - const deploymentName = config.deployment_mapper[modelName] - if (!deploymentName) { - throw new Error(`Model ${modelName} not supported`) - } - - // 替换模型名称为映射后的名称 - reqJson.model = deploymentName + // model 已在上层完成映射 // 构建目标请求 - const proxyRequest = buildProxyRequest(c.req.raw, reqJson, config) + const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) // 发送请求并获取响应 const response = await fetch(proxyRequest) diff --git a/src/utils.ts b/src/utils.ts index 510ff4e..1631f52 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -61,9 +61,57 @@ export const saveSetting = async ( return true; } +/** + * 通配符匹配函数 + * 支持 * 匹配任意字符 + * 例如: "claude-*" 匹配 "claude-opus-4", "*claude*" 匹配 "my-claude-model" + */ +export const wildcardMatch = (pattern: string, str: string): boolean => { + // 精确匹配优先 + if (pattern === str) return true; + // 没有通配符则不匹配 + if (!pattern.includes('*')) return false; + + // 将通配符模式转换为正则表达式 + const regexPattern = pattern + .split('*') + .map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*'); + const regex = new RegExp(`^${regexPattern}$`, 'i'); + return regex.test(str); +} + +/** + * 从 deployment_mapper 中查找匹配的部署名称 + * 支持精确匹配和通配符匹配 + * 返回 { pattern, deployment } 或 null + */ +export const findDeploymentMapping = ( + deploymentMapper: Record | undefined, + model: string +): { pattern: string; deployment: string } | null => { + if (!deploymentMapper) return null; + + // 1. 精确匹配优先 + if (deploymentMapper[model]) { + return { pattern: model, deployment: deploymentMapper[model] }; + } + + // 2. 通配符匹配 + for (const pattern of Object.keys(deploymentMapper)) { + if (pattern.includes('*') && wildcardMatch(pattern, model)) { + return { pattern, deployment: deploymentMapper[pattern] }; + } + } + + return null; +} + export default { getJsonObjectValue, getJsonSetting, getSetting, saveSetting, + wildcardMatch, + findDeploymentMapping, } diff --git a/type.d.ts b/type.d.ts index 3db7a6c..3f27f84 100644 --- a/type.d.ts +++ b/type.d.ts @@ -36,6 +36,7 @@ type ChannelType = | "azure-openai" | "openai" | "claude" + | "claude-to-openai" | "openai-responses" | "azure-openai-responses" | undefined