diff --git a/open-sse/config/providerFieldStrips.ts b/open-sse/config/providerFieldStrips.ts index b02bb37851..74282febdc 100644 --- a/open-sse/config/providerFieldStrips.ts +++ b/open-sse/config/providerFieldStrips.ts @@ -11,6 +11,11 @@ export const KNOWN_OFFENDING_FIELDS: readonly string[] = [ "chat_template", "reasoning_content", "context_management", + // GPT-5's Chat Completions-only output control. It can be present when a + // routing rule substitutes a non-GPT OpenAI-compatible target (for example + // Codex → GLM or Ollama Cloud), whose strict endpoint rejects it as an extra + // field. Retrying without it is safe because it only changes output style. + "verbosity", ]; /** Return the first known-offending field literally named in a 400 body, or null. */ diff --git a/open-sse/handlers/chatCore/upstreamBody.ts b/open-sse/handlers/chatCore/upstreamBody.ts index d108e5c2bf..85b0624a6b 100644 --- a/open-sse/handlers/chatCore/upstreamBody.ts +++ b/open-sse/handlers/chatCore/upstreamBody.ts @@ -21,6 +21,7 @@ import { type ConnectionCacheOverride, } from "../../utils/cacheControlPolicy.ts"; import { FORMATS } from "../../translator/formats.ts"; +import { sanitizeRequestForResolvedTarget } from "../../services/targetRequestSanitizer.ts"; type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; type Body = Record; @@ -168,6 +169,11 @@ export async function prepareUpstreamBody(opts: { ); } + bodyToSend = sanitizeRequestForResolvedTarget(bodyToSend, { + provider, + model: payloadRuleModel, + log, + }); bodyToSend = truncateToolList(bodyToSend, provider, bypassDefaultToolLimit ?? false, log); bodyToSend = backfillQwenOAuthUser(bodyToSend, provider, credentials, log); const connectionCacheOverride = resolveConnectionCacheOverride(credentials?.providerSpecificData); diff --git a/open-sse/services/AGENTS.md b/open-sse/services/AGENTS.md index 4b1d3efa0e..3b0fa37eb2 100644 --- a/open-sse/services/AGENTS.md +++ b/open-sse/services/AGENTS.md @@ -31,6 +31,7 @@ Live count: `ls open-sse/services/*.ts | wc -l` (currently 134). More including - **`wildcardRouter.ts`** — Wildcard route matching in combo configs. - **`intentClassifier.ts`** — Request intent classification for intelligent routing. - **`taskAwareRouter.ts`** — Task-type-based routing (reasoning → o1, code-gen → Cursor). +- **`targetRequestSanitizer.ts`** — Final provider/model-aware parameter sanitation after routing resolution and before executor dispatch. - **`thinkingBudget.ts`** — Thinking token allocation for o1/o3 models. - **`contextManager.ts`** — Routing context injection (system prompts, memory). diff --git a/open-sse/services/targetRequestSanitizer.ts b/open-sse/services/targetRequestSanitizer.ts new file mode 100644 index 0000000000..291b3051f5 --- /dev/null +++ b/open-sse/services/targetRequestSanitizer.ts @@ -0,0 +1,90 @@ +/** + * Final request sanitation against the resolved upstream target. + * + * Clients legitimately send controls for the model they selected. Routing rules, + * combos and fallbacks may replace that model with a different family after the + * request has already been parsed and translated. This boundary removes controls + * that belong to the source model but are invalid for the actual target. + */ + +import { stripUnsupportedParams } from "../translator/paramSupport.ts"; +import { sanitizeReasoningEffortForProvider } from "../executors/base/reasoningEffort.ts"; + +type JsonRecord = Record; +type LoggerLike = + | { + debug?: (tag: string, message: string) => void; + info?: (tag: string, message: string) => void; + } + | null + | undefined; + +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** GPT-5 Chat/Responses models are the only family that owns `verbosity`. */ +export function targetSupportsVerbosity(model: string | null | undefined): boolean { + return typeof model === "string" && /(?:^|\/)gpt-5(?:[._-]|$)/i.test(model.trim()); +} + +function stripVerbosityForTarget(body: JsonRecord, model: string): string[] { + if (targetSupportsVerbosity(model)) return []; + + const stripped: string[] = []; + if (Object.hasOwn(body, "verbosity")) { + delete body.verbosity; + stripped.push("verbosity"); + } + + if (isRecord(body.text) && Object.hasOwn(body.text, "verbosity")) { + const text = { ...body.text }; + delete text.verbosity; + if (Object.keys(text).length === 0) delete body.text; + else body.text = text; + stripped.push("text.verbosity"); + } + + return stripped; +} + +/** + * Sanitize a translated request using the concrete provider/model selected by + * routing. Returns a fresh top-level object and never mutates the caller body. + */ +export function sanitizeRequestForResolvedTarget( + body: T, + options: { + provider: string | null | undefined; + model: string; + log?: LoggerLike; + } +): T { + let next = { ...body } as T; + const stripped = stripVerbosityForTarget(next, options.model); + + // Keep reasoning intent, but normalize its effort vocabulary for the + // concrete provider/model selected by routing (for example xhigh → high on + // explicit opt-outs, or xhigh → max for native DeepSeek). The request-format + // translators have already mapped the shape itself: Responses + // reasoning.effort → Chat reasoning_effort, or → Claude thinking. + next = sanitizeReasoningEffortForProvider( + next, + options.provider || "", + options.model, + options.log + ) as T; + + // Apply operator-configured provider/model filters at the common dispatch + // boundary so custom executors cannot accidentally bypass them. + stripUnsupportedParams(options.provider, options.model, next); + + if (stripped.length > 0) { + options.log?.debug?.( + "TARGET_PARAMS", + `Stripped ${stripped.join(", ")} for resolved target ${options.provider || "unknown"}/${options.model}` + ); + } + + return next; +} diff --git a/tests/unit/chatcore-upstream-body.test.ts b/tests/unit/chatcore-upstream-body.test.ts index 160f57ccc4..095fc6b96b 100644 --- a/tests/unit/chatcore-upstream-body.test.ts +++ b/tests/unit/chatcore-upstream-body.test.ts @@ -14,6 +14,10 @@ process.env.DATA_DIR = testDataDir; const coreDb = await import("../../src/lib/db/core.ts"); const { prepareUpstreamBody } = await import("../../open-sse/handlers/chatCore/upstreamBody.ts"); +const { translateRequest } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { setParamFilterConfig, deleteParamFilterConfig } = + await import("../../src/lib/db/paramFilters.ts"); before(async () => { await coreDb.ensureDbInitialized(); @@ -46,6 +50,153 @@ test("leaves the model untouched when it already matches", async () => { assert.equal(out.model, "model-a"); }); +test("strips Codex GPT-5 verbosity after routing resolves to opencode-go/GLM", async () => { + const translatedBody = { + model: "glm-5.2", + messages: [{ role: "user", content: "hi" }], + verbosity: "low", + }; + const out = await prepareUpstreamBody({ + translatedBody, + modelToCall: "glm-5.2", + provider: "opencode-go", + targetFormat: "openai", + credentials: null, + }); + + assert.equal(out.verbosity, undefined); + assert.equal(translatedBody.verbosity, "low", "translated caller body must not be mutated"); +}); + +test("Codex Responses routing keeps reasoning effort while dropping GPT-only verbosity", async () => { + // Simulates a combo/fallback reroute: the request is first translated while still + // addressed at Codex (an allowlisted OpenAI-param destination, #7533), which is why + // `text.verbosity` survives the Responses->Chat hop as top-level `verbosity`. Routing + // then resolves the actual upstream target to opencode-go/GLM (a fallback target), + // so `prepareUpstreamBody`'s final sanitizeRequestForResolvedTarget (#7050/#7533) must + // strip the GPT-only `verbosity` for that concrete target while keeping + // `reasoning_effort`, which is not gated by destination provider. + const translated = translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI, + "glm-5.2", + { + model: "gpt-5.2", + input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + reasoning: { effort: "low", summary: "auto" }, + text: { verbosity: "low" }, + }, + true, + { provider: "codex" }, + "codex" + ) as Record; + + assert.equal(translated.reasoning_effort, "low"); + assert.equal(translated.verbosity, "low"); + + const outbound = await prepareUpstreamBody({ + translatedBody: translated, + modelToCall: "glm-5.2", + provider: "opencode-go", + targetFormat: FORMATS.OPENAI, + credentials: null, + }); + + assert.equal(outbound.reasoning_effort, "low"); + assert.equal(outbound.verbosity, undefined); +}); + +test("Codex Responses reasoning effort is translated to Claude thinking for z.ai", () => { + const translated = translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.CLAUDE, + "glm-5.2", + { + model: "gpt-5.2", + input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + reasoning: { effort: "low" }, + text: { verbosity: "low" }, + }, + true, + null, + "zai" + ) as Record; + + assert.deepEqual(translated.thinking, { type: "enabled", budget_tokens: 1024 }); + assert.equal(translated.reasoning_effort, undefined); + assert.equal(translated.verbosity, undefined); +}); + +test("resolved-target sanitation preserves Ollama Cloud reasoning effort", async () => { + const outbound = await prepareUpstreamBody({ + translatedBody: { + model: "glm-5.2", + messages: [{ role: "user", content: "hi" }], + reasoning_effort: "max", + verbosity: "low", + }, + modelToCall: "glm-5.2", + provider: "ollama-cloud", + targetFormat: FORMATS.OPENAI, + credentials: null, + }); + + assert.equal(outbound.reasoning_effort, "max"); + assert.equal(outbound.verbosity, undefined); +}); + +test("strips nested Responses text.verbosity for a non-GPT routed target", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { + model: "glm-5.2", + input: "hi", + text: { verbosity: "low", format: { type: "text" } }, + }, + modelToCall: "glm-5.2", + provider: "ollama-cloud", + targetFormat: "openai-responses", + credentials: null, + }); + + assert.deepEqual(out.text, { format: { type: "text" } }); +}); + +test("preserves verbosity when the resolved target is actually GPT-5", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { model: "gpt-5.2", messages: [], verbosity: "low" }, + modelToCall: "gpt-5.2", + provider: "openai", + targetFormat: "openai", + credentials: null, + }); + + assert.equal(out.verbosity, "low"); +}); + +test("applies provider parameter filters at the universal target boundary", async () => { + setParamFilterConfig("opencode-go", { + block: ["source_only_control"], + allow: [], + autoLearn: false, + }); + try { + const out = await prepareUpstreamBody({ + translatedBody: { + model: "glm-5.2", + messages: [], + source_only_control: true, + }, + modelToCall: "glm-5.2", + provider: "opencode-go", + targetFormat: "openai", + credentials: null, + }); + assert.equal(out.source_only_control, undefined); + } finally { + deleteParamFilterConfig("opencode-go"); + } +}); + // PR #5563: the `effectiveToolLimit < MAX_TOOLS_LIMIT` gate was removed from // truncateToolList, so providers whose proactive limit is >= the 128 default // (e.g. grok-cli at 200) are actually truncated. Without the gate removal these diff --git a/tests/unit/provider-field-strips.test.ts b/tests/unit/provider-field-strips.test.ts index 9e57ca60af..5323683617 100644 --- a/tests/unit/provider-field-strips.test.ts +++ b/tests/unit/provider-field-strips.test.ts @@ -1,12 +1,16 @@ import { test } from "node:test"; import assert from "node:assert/strict"; + import { findOffendingField, stripGroqUnsupportedFields, } from "../../open-sse/config/providerFieldStrips.ts"; test("findOffendingField matches known field names in a 400 body", () => { - assert.equal(findOffendingField("Invalid argument: reasoning_budget not supported"), "reasoning_budget"); + assert.equal( + findOffendingField("Invalid argument: reasoning_budget not supported"), + "reasoning_budget" + ); assert.equal(findOffendingField("unexpected field chat_template"), "chat_template"); assert.equal(findOffendingField("reasoning_content is not allowed"), "reasoning_content"); // #1468: Claude Code's top-level context_management field rejected by strict @@ -15,18 +19,29 @@ test("findOffendingField matches known field names in a 400 body", () => { findOffendingField("context_management: Extra inputs are not permitted"), "context_management" ); + assert.equal( + findOffendingField("Extra inputs are not permitted, field: 'verbosity', value: 'low'"), + "verbosity" + ); assert.equal(findOffendingField("all good"), null); assert.equal(findOffendingField(""), null); }); test("stripGroqUnsupportedFields drops non-empty messages[].name", () => { - const out = stripGroqUnsupportedFields({ messages: [{ role: "user", content: "hi", name: "bob" }] }); + const out = stripGroqUnsupportedFields({ + messages: [{ role: "user", content: "hi", name: "bob" }], + }); assert.equal("name" in out.messages[0], false); assert.equal(out.messages[0].content, "hi"); }); test("stripGroqUnsupportedFields drops logprobs/logit_bias/top_logprobs", () => { - const out = stripGroqUnsupportedFields({ messages: [], logprobs: true, logit_bias: { 1: 2 }, top_logprobs: 5 }); + const out = stripGroqUnsupportedFields({ + messages: [], + logprobs: true, + logit_bias: { 1: 2 }, + top_logprobs: 5, + }); assert.equal("logprobs" in out, false); assert.equal("logit_bias" in out, false); assert.equal("top_logprobs" in out, false);