diff --git a/src/app/v1/_lib/converters/claude-to-codex/response.ts b/src/app/v1/_lib/converters/claude-to-codex/response.ts index b8df47b96..fc647253b 100644 --- a/src/app/v1/_lib/converters/claude-to-codex/response.ts +++ b/src/app/v1/_lib/converters/claude-to-codex/response.ts @@ -397,6 +397,16 @@ export function transformClaudeNonStreamResponseToCodex( }); break; } + + case "tool_result": { + // tool_result blocks belong to tool execution results; Codex Responses output is + // derived from assistant message/tool_use. Ignore if present in Claude response. + break; + } + + default: + // Unknown block types are ignored for non-stream output. + break; } } } diff --git a/src/app/v1/_lib/converters/openai-to-claude/response.ts b/src/app/v1/_lib/converters/openai-to-claude/response.ts index b0beba7cd..30da15ba9 100644 --- a/src/app/v1/_lib/converters/openai-to-claude/response.ts +++ b/src/app/v1/_lib/converters/openai-to-claude/response.ts @@ -418,6 +418,17 @@ export function transformClaudeNonStreamResponseToOpenAI( }, }); break; + + case "tool_result": { + // tool_result blocks do not have a .text field; they carry data in .content. + // This is typically present in requests, but some proxies may echo it in responses. + // Ignore for OpenAI chat completions output. + break; + } + + default: + // Unknown block types are ignored for non-stream output. + break; } } diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 62f03b8e1..440f2d062 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -239,7 +239,7 @@ export class ProxyProviderResolver { targetType: reusedProvider.providerType as NonNullable< ProviderChainItem["decisionContext"] >["targetType"], - requestedModel: session.getCurrentModel() || "", + requestedModel: session.getOriginalModel() || "", groupFilterApplied: false, beforeHealthCheck: 0, afterHealthCheck: 0, @@ -322,7 +322,7 @@ export class ProxyProviderResolver { targetType: session.provider.providerType as NonNullable< ProviderChainItem["decisionContext"] >["targetType"], - requestedModel: session.getCurrentModel() || "", + requestedModel: session.getOriginalModel() || "", groupFilterApplied: false, beforeHealthCheck: 0, afterHealthCheck: 0, @@ -379,7 +379,7 @@ export class ProxyProviderResolver { targetType: session.provider.providerType as NonNullable< ProviderChainItem["decisionContext"] >["targetType"], - requestedModel: session.getCurrentModel() || "", + requestedModel: session.getOriginalModel() || "", groupFilterApplied: false, beforeHealthCheck: 0, afterHealthCheck: 0, @@ -540,7 +540,7 @@ export class ProxyProviderResolver { } // 检查模型支持(使用新的模型匹配逻辑) - const requestedModel = session.getCurrentModel(); + const requestedModel = session.getOriginalModel(); if (requestedModel && !providerSupportsModel(provider, requestedModel)) { logger.debug("ProviderSelector: Session provider does not support requested model", { sessionId: session.sessionId, @@ -648,7 +648,7 @@ export class ProxyProviderResolver { // 使用 Session 快照保证故障迁移期间数据一致性 // 如果没有 session,回退到 findAllProviders(内部已使用缓存) const allProviders = session ? await session.getProvidersSnapshot() : await findAllProviders(); - const requestedModel = session?.getCurrentModel() || ""; + const requestedModel = session?.getOriginalModel() || ""; // === Step 1: 分组预过滤(静默,用户只能看到自己分组内的供应商)=== const effectiveGroupPick = getEffectiveProviderGroup(session); diff --git a/tests/unit/proxy/converters-tool-result-nonstream.test.ts b/tests/unit/proxy/converters-tool-result-nonstream.test.ts new file mode 100644 index 000000000..044112cc1 --- /dev/null +++ b/tests/unit/proxy/converters-tool-result-nonstream.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { transformClaudeNonStreamResponseToOpenAI } from "@/app/v1/_lib/converters/openai-to-claude/response"; +import { transformClaudeNonStreamResponseToCodex } from "@/app/v1/_lib/converters/claude-to-codex/response"; + +function createCtx(): any { + return null; +} + +describe("Non-stream converters tolerate tool_result blocks", () => { + it("Claude->OpenAI: ignores tool_result without crashing", () => { + const response = { + type: "message", + id: "msg_1", + model: "claude-test", + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + content: [ + { type: "text", text: "hello" }, + { type: "tool_result", tool_use_id: "toolu_1", content: "ok" }, + { type: "text", text: " world" }, + ], + } as Record; + + const out = transformClaudeNonStreamResponseToOpenAI( + createCtx(), + "claude-test", + {}, + {}, + response + ); + + expect(out).toMatchObject({ + object: "chat.completion", + choices: [ + { + message: { + role: "assistant", + content: "hello world", + }, + }, + ], + }); + }); + + it("Claude->Codex: ignores tool_result without crashing", () => { + const response = { + type: "message", + id: "msg_1", + model: "claude-test", + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + content: [ + { type: "text", text: "hello" }, + { type: "tool_result", tool_use_id: "toolu_1", content: [{ type: "text", text: "ok" }] }, + { type: "tool_use", id: "toolu_2", name: "do", input: { a: 1 } }, + ], + } as Record; + + const out = transformClaudeNonStreamResponseToCodex( + createCtx(), + "claude-test", + {}, + {}, + response + ); + + expect(out).toMatchObject({ + type: "response.completed", + response: { + type: "response", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hello" }], + }, + { + type: "function_call", + call_id: "toolu_2", + name: "do", + }, + ], + }, + }); + }); +}); diff --git a/tests/unit/proxy/provider-selector-model-redirect.test.ts b/tests/unit/proxy/provider-selector-model-redirect.test.ts new file mode 100644 index 000000000..b29e72f16 --- /dev/null +++ b/tests/unit/proxy/provider-selector-model-redirect.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test, vi } from "vitest"; +import type { Provider } from "@/types/provider"; + +const circuitBreakerMocks = vi.hoisted(() => ({ + isCircuitOpen: vi.fn(async () => false), + getCircuitState: vi.fn(() => "closed"), +})); + +vi.mock("@/lib/circuit-breaker", () => circuitBreakerMocks); + +describe("ProxyProviderResolver.pickRandomProvider - model redirect", () => { + test("filters providers using original model (not redirected current model)", async () => { + const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector"); + + vi.spyOn(ProxyProviderResolver as any, "filterByLimits").mockImplementation( + async (...args: unknown[]) => args[0] as Provider[] + ); + + const providers: Provider[] = [ + { + id: 1, + name: "p1", + isEnabled: true, + providerType: "claude", + groupTag: null, + weight: 1, + priority: 0, + costMultiplier: 1, + allowedModels: ["claude-test"], + } as unknown as Provider, + ]; + + const session = { + originalFormat: "claude", + authState: null, + getProvidersSnapshot: async () => providers, + getOriginalModel: () => "claude-test", + getCurrentModel: () => "glm-test", + clientRequestsContext1m: () => false, + } as any; + + const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider( + session, + [] + ); + + expect(context.requestedModel).toBe("claude-test"); + expect(provider?.id).toBe(1); + }); +}); diff --git a/tests/unit/proxy/provider-selector-total-limit.test.ts b/tests/unit/proxy/provider-selector-total-limit.test.ts index ecb93f34e..74528baa3 100644 --- a/tests/unit/proxy/provider-selector-total-limit.test.ts +++ b/tests/unit/proxy/provider-selector-total-limit.test.ts @@ -139,6 +139,7 @@ describe("ProxyProviderResolver.findReusable - provider total limit", () => { shouldReuseProvider: () => true, authState: null, getCurrentModel: () => null, + getOriginalModel: () => null, } as any; const reused = await (ProxyProviderResolver as any).findReusable(session);