mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
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:
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
90
open-sse/services/targetRequestSanitizer.ts
Normal file
90
open-sse/services/targetRequestSanitizer.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user