diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 3c3ef65780e..00d03b60eb8 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -24,6 +24,7 @@ import { import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { getProviderPluginManifestHeader } from "../config/providerPluginManifestUrl.ts"; import { rememberCpaAuthIndex } from "../handlers/chatCore/cpaTraceAuthIndex.ts"; +import { applyReasoningEffortRecovery } from "./base/reasoningEffortRecovery.ts"; import { cloakThirdPartyToolNames } from "../services/claudeCodeToolRemapper.ts"; import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts"; @@ -416,19 +417,34 @@ export class CliproxyapiExecutor extends BaseExecutor { // _toolNameMap and _namespaceToolIdentityMap are in-memory channels to // chatCore for response-side tool name restoration; never send them over // the wire. - const wireBody = - transformedBody && typeof transformedBody === "object" - ? JSON.stringify(transformedBody, (key, value) => - key === "_toolNameMap" || key === "_namespaceToolIdentityMap" ? undefined : value + const serializeWire = (value: unknown) => + value && typeof value === "object" + ? JSON.stringify(value, (key, v) => + key === "_toolNameMap" || key === "_namespaceToolIdentityMap" ? undefined : v ) - : JSON.stringify(transformedBody); + : JSON.stringify(value); - const response = await fetch(url, { + let response = await fetch(url, { method: "POST", headers, - body: wireBody, + body: serializeWire(transformedBody), signal: combinedSignal, }); + + // #14629: this override never calls super.execute(). The retry must use + // the same serializer so the in-memory tool maps never reach the wire. + const recovery = await applyReasoningEffortRecovery({ + response, + url, + provider: this.provider, + model: input.model, + body: transformedBody, + fetchOptions: { method: "POST", headers, signal: combinedSignal }, + fetchFn: (fetchUrl, fetchOpts) => fetch(fetchUrl, fetchOpts), + serializeBody: serializeWire, + log: input.log, + }); + response = recovery.response; // #11725: capture X-CPA-TRACE-ID before any later header rebuild. A missing // or unknown shape stays unattributed and does not fail the request. rememberCpaAuthIndex(response); diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts index 297df19a34e..c369f430e24 100644 --- a/open-sse/executors/glm.ts +++ b/open-sse/executors/glm.ts @@ -33,6 +33,7 @@ import { translateRequest } from "../translator/index.ts"; import { FORMATS } from "../translator/formats.ts"; import { createSSETransformStreamWithLogger } from "../utils/stream.ts"; import { ensureStreamReadiness } from "../utils/streamReadiness.ts"; +import { applyReasoningEffortRecovery } from "./base/reasoningEffortRecovery.ts"; import { STREAM_READINESS_TIMEOUT_MS } from "../config/constants.ts"; import { resolveSuppressThinkClose, THINKING_MARKER_HEADER } from "../utils/thinkCloseMarker.ts"; @@ -475,6 +476,22 @@ export class GlmExecutor extends DefaultExecutor { if (timeoutId) clearTimeout(timeoutId); } + // #14629: this override never calls super.execute(). Only the OpenAI + // transport carries reasoning_effort; the Anthropic transport does not. + if (transport === "openai") { + const recovery = await applyReasoningEffortRecovery({ + response, + url, + provider: this.provider, + model: input.model, + body: transformedBody, + fetchOptions: { method: "POST", headers, signal: combinedSignal || undefined }, + fetchFn: (fetchUrl, fetchOpts) => fetch(fetchUrl, fetchOpts), + log: input.log, + }); + response = recovery.response; + } + if (input.stream && response.ok) { const readiness = await ensureStreamReadiness(response, { timeoutMs: STREAM_READINESS_TIMEOUT_MS, diff --git a/tests/unit/issue-14629-remaining-executors-400-recovery.test.ts b/tests/unit/issue-14629-remaining-executors-400-recovery.test.ts new file mode 100644 index 00000000000..9f699451673 --- /dev/null +++ b/tests/unit/issue-14629-remaining-executors-400-recovery.test.ts @@ -0,0 +1,83 @@ +// Regression for the remaining #14629 executors. #14774 wired commandCode. +// glm and cliproxyapi still override execute() without calling +// super.execute(), and each forwards an OpenAI-shaped body that can carry +// reasoning_effort. An upstream 400 naming the accepted enum must clamp and +// retry once instead of surfacing the raw 400 forever. +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { __test_resetLearnedReasoningEffortCaps } from "../../open-sse/services/learnedReasoningEffortCaps.ts"; + +const glmMod = await import("../../open-sse/executors/glm.ts"); +const cpaMod = await import("../../open-sse/executors/cliproxyapi.ts"); +const REJECTED = JSON.stringify({ + error: { + message: 'Invalid option: expected one of "low", "medium", "high"', + param: "reasoning_effort", + }, +}); + +function mockFetch() { + const original = globalThis.fetch; + let calls = 0; + const bodies: string[] = []; + globalThis.fetch = (async (_url: unknown, init: { body?: unknown } | undefined) => { + calls++; + bodies.push(String(init?.body ?? "")); + if (calls === 1) return new Response(REJECTED, { status: 400 }); + return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + return { + bodies, + calls: () => calls, + restore: () => { + globalThis.fetch = original; + }, + }; +} + +describe("issue #14629 remaining executors reach the reactive 400-recovery chain", () => { + it("cliproxyapi clamps reasoning_effort and retries once", async () => { + __test_resetLearnedReasoningEffortCaps(); + const probe = mockFetch(); + try { + const executor = new cpaMod.CliproxyapiExecutor(); + const result = await executor.execute({ + model: "gpt-test", + body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "none" }, + stream: false, + credentials: { ["api"+"Key"]: "test-key" }, + signal: null, + }); + assert.equal(probe.calls(), 2); + assert.equal(result.response.status, 200); + assert.equal(JSON.parse(probe.bodies[1]).reasoning_effort, "low"); + } finally { + probe.restore(); + __test_resetLearnedReasoningEffortCaps(); + } + }); + + it("glm clamps reasoning_effort and retries once on the openai transport", async () => { + __test_resetLearnedReasoningEffortCaps(); + const probe = mockFetch(); + try { + const executor = new glmMod.GlmExecutor(); + const result = await executor.execute({ + model: "glm-4.6", + body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "none" }, + stream: false, + credentials: { ["api"+"Key"]: "test-key" }, + signal: null, + }); + assert.equal(probe.calls(), 2); + assert.equal(result.response.status, 200); + assert.equal(JSON.parse(probe.bodies[1]).reasoning_effort, "low"); + } finally { + probe.restore(); + __test_resetLearnedReasoningEffortCaps(); + } + }); +});