Fix routed target request parameters (#7323)

* fix routed target request parameters

* chore: rerun CI

* test(chatcore): align PR #7323 Codex-routing test with #7533 verbosity gating

The "Codex Responses routing keeps reasoning effort while dropping GPT-only
verbosity" test translated a Responses-shape request with credentials=null
and only the positional `provider` arg set to "opencode-go". #7533's
verbosity carry-over (Responses `text.verbosity` -> Chat `verbosity`) reads
the destination from `credentials.provider`, which the source->openai
translation step never threads from the positional provider arg — so
`translated.verbosity` came back undefined instead of "low", failing before
prepareUpstreamBody's sanitizer was even reached.

The test's intent (per its own name/comment) is a combo/fallback reroute:
translate while still addressed at Codex (an #7533-allowlisted OpenAI-param
destination, so verbosity legitimately survives translateRequest), then
resolve the final upstream target to opencode-go/GLM so prepareUpstreamBody's
sanitizeRequestForResolvedTarget (#7050/#7533) strips the GPT-only verbosity
for that concrete target while preserving reasoning_effort. Fixed by passing
`credentials: { provider: "codex" }` to the first translateRequest call to
match how production actually carries the destination provider, instead of
relying on the provider positional argument. No production code changed —
#7050 and #7533's sanitization are intentional and protected; only the test's
setup was misaligned with them.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Jan Leon
2026-07-19 02:18:34 +02:00
committed by GitHub
parent aa0b56d350
commit b2b568b08e
6 changed files with 271 additions and 3 deletions

View File

@@ -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. */

View File

@@ -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<string, unknown>;
@@ -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);

View File

@@ -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).

View File

@@ -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<string, unknown>;
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<T extends JsonRecord>(
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;
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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

View File

@@ -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);