feat: 添加 Claude 到 OpenAI 协议转换支持
新增功能: - 新增 claude-to-openai channel 类型,支持 Claude 协议请求转换为 OpenAI 协议 - 支持流式和非流式响应转换 - 完整的 SSE 事件转换 (message_start, content_block_*, message_delta, message_stop) - 支持工具调用 (tool_use) 转换 优化: - 统一在上层完成模型名映射,移除各 provider 重复逻辑 - 添加通配符匹配支持 (如 *claude*, claude-*) - 简化代码,移除冗余变量 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
59149af970
commit
c75dade7df
10 files changed
+692
-56
No files matched your search
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -115,25 +115,19 @@ export default {
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
): Promise<Response> {
|
||||
// 准备请求数据
|
||||
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)
|
||||
|
||||
@@ -130,22 +130,16 @@ export default {
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
): Promise<Response> {
|
||||
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,
|
||||
|
||||
@@ -264,24 +264,15 @@ export default {
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
): Promise<Response> {
|
||||
// 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
|
||||
|
||||
@@ -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<void>
|
||||
): TransformStream<Uint8Array, Uint8Array> => {
|
||||
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<number, { contentIndex: number; id: string; name: string }>();
|
||||
const openBlocks: number[] = [];
|
||||
const openBlockSet = new Set<number>();
|
||||
|
||||
const enqueueEvent = (
|
||||
controller: TransformStreamDefaultController<Uint8Array>,
|
||||
eventType: string,
|
||||
data: any
|
||||
) => {
|
||||
const payload = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(payload));
|
||||
};
|
||||
|
||||
const startMessage = (controller: TransformStreamDefaultController<Uint8Array>, 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<Uint8Array>,
|
||||
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<Uint8Array>) => {
|
||||
if (textBlockIndex == null) {
|
||||
textBlockIndex = nextContentIndex++;
|
||||
startContentBlock(controller, textBlockIndex, {
|
||||
type: "text",
|
||||
text: "",
|
||||
});
|
||||
}
|
||||
return textBlockIndex;
|
||||
};
|
||||
|
||||
const ensureToolBlock = (
|
||||
controller: TransformStreamDefaultController<Uint8Array>,
|
||||
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<Uint8Array>, 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<Uint8Array>) => {
|
||||
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<Uint8Array, Uint8Array>({
|
||||
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<HonoCustomType>,
|
||||
config: ChannelConfig,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
): Promise<Response> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -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);
|
||||
|
||||
@@ -103,28 +103,20 @@ export default {
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
): Promise<Response> {
|
||||
// 准备请求数据
|
||||
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)
|
||||
|
||||
@@ -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<string, string> | 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,
|
||||
}
|
||||
Reference in new issue
Block a user