Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(providers): map `thinking.type: "adaptive"` to `"enabled"` for AgentRouter GLM models instead of forwarding it unhandled, fixing a 400 from AgentRouter's upstream GLM endpoint (#13696)
35 changes: 35 additions & 0 deletions open-sse/translator/paramSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ type StripRule = {
drop?: string[];
clampToModelMaxOutput?: boolean;
maxOutputCap?: number;
// Remap `thinking.type` from one value to another instead of dropping the
// whole field, preserving any other keys already on `thinking` (e.g.
// budget_tokens). Only applies when `thinking` is an object whose current
// `type` matches the key.
mapThinkingType?: Record<string, string>;
};

const MAX_OUTPUT_TOKEN_KEYS = ["max_tokens", "max_completion_tokens", "max_output_tokens"] as const;
Expand Down Expand Up @@ -100,6 +105,17 @@ const STRIP_RULES: StripRule[] = [
// to read), hence the fixed cap.
{ provider: "azure-openai", match: /^gpt-4o-mini/i, maxOutputCap: 16384 },
{ provider: "azure-ai", match: /^gpt-4o-mini/i, maxOutputCap: 16384 },
// AgentRouter routes GLM models through the generic DefaultExecutor (no
// GLM-specific handling), so a Claude-style `thinking.type: "adaptive"`
// (the default a Claude-format client like Claude Code sends) reaches
// AgentRouter's upstream GLM endpoint verbatim and 400s: `thinking.type
// "adaptive" is not supported by glm models; must be one of enabled,
// disabled`. The native GLM/ZAI executor already remaps adaptive->enabled
// for its own provider (glm.ts); AgentRouter needs the same mapping since
// it never reaches that code path. Scoped to the whole glm-* family, not a
// single model id, since AgentRouter's static catalog carries no glm-*
// entries (passthroughModels: true lets arbitrary GLM ids through). #13696.
{ provider: "agentrouter", match: /glm-/i, mapThinkingType: { adaptive: "enabled" } },
];

function matches(rule: StripRule, model: string): boolean {
Expand Down Expand Up @@ -141,6 +157,24 @@ function applyMaxOutputClamp(
}
}

/**
* When a rule requests it, remap `body.thinking.type` from one value to
* another (e.g. "adaptive" -> "enabled"), preserving every other key already
* present on `thinking`. No-op when `thinking` is absent, not an object, or
* its current `type` has no entry in the rule's map.
*/
function applyThinkingTypeMap(rule: StripRule, body: Record<string, unknown>): void {
if (!rule.mapThinkingType) return;
const thinking = body.thinking;
if (!thinking || typeof thinking !== "object" || Array.isArray(thinking)) return;
const thinkingRecord = thinking as Record<string, unknown>;
const currentType = thinkingRecord.type;
if (typeof currentType !== "string") return;
const mapped = rule.mapThinkingType[currentType];
if (mapped === undefined || mapped === currentType) return;
body.thinking = { ...thinkingRecord, type: mapped };
}

/**
* Remove unsupported params from `body` in place. Returns the same reference
* (or `body` unchanged when it is not a plain object / model is empty).
Expand All @@ -164,6 +198,7 @@ export function stripUnsupportedParams<T>(
if (rec[key] !== undefined) delete rec[key];
}
applyMaxOutputClamp(rule, provider, model, rec);
applyThinkingTypeMap(rule, rec);
}

// Phase 2: Config-driven rules from DB
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/agentrouter-glm-thinking-adaptive-map.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// #13696: AgentRouter routes GLM models through the generic DefaultExecutor
// (executor: "default"), which has no GLM-specific handling. When the
// connection's OpenAI-compatible alternate format is used, a Claude-style
// `thinking: {type: "adaptive"}` field survives stripUnsupportedParams
// untouched and reaches AgentRouter's upstream GLM endpoint verbatim, which
// 400s with `thinking.type "adaptive" is not supported by glm models; must
// be one of enabled, disabled`.
//
// Fix: paramSupport.ts STRIP_RULES maps thinking.type "adaptive" -> "enabled"
// (preserving other thinking fields, e.g. budget_tokens) for provider
// "agentrouter" + model matching /glm-/i.

import { test } from "node:test";
import assert from "node:assert/strict";
import { stripUnsupportedParams } from "../../open-sse/translator/paramSupport.ts";

test("stripUnsupportedParams: agentrouter + glm-5.3 maps thinking.type adaptive -> enabled", () => {
const body: Record<string, unknown> = {
model: "glm-5.3",
thinking: { type: "adaptive", budget_tokens: 1024 },
max_tokens: 512,
};
stripUnsupportedParams("agentrouter", "glm-5.3", body);
assert.deepEqual(
body.thinking,
{ type: "enabled", budget_tokens: 1024 },
"adaptive must be mapped to enabled for agentrouter GLM models, preserving other thinking fields"
);
assert.equal(body.max_tokens, 512, "other params must survive");
});

test("stripUnsupportedParams: agentrouter + claude-opus-5 KEEPS thinking.type adaptive (regression guard)", () => {
const body: Record<string, unknown> = {
model: "claude-opus-5",
thinking: { type: "adaptive" },
};
stripUnsupportedParams("agentrouter", "claude-opus-5", body);
assert.deepEqual(
body.thinking,
{ type: "adaptive" },
"non-GLM agentrouter models must not be affected"
);
});

test("stripUnsupportedParams: non-agentrouter provider with a glm-* model id KEEPS thinking.type adaptive", () => {
const body: Record<string, unknown> = {
model: "glm-5.3",
thinking: { type: "adaptive" },
};
stripUnsupportedParams("glm", "glm-5.3", body);
assert.deepEqual(
body.thinking,
{ type: "adaptive" },
"the native GLM provider's own executor handles this mapping separately; this rule must not fire for it"
);
});

test("stripUnsupportedParams: agentrouter + glm-5.3 without a thinking field is a no-op", () => {
const body: Record<string, unknown> = { model: "glm-5.3", max_tokens: 100 };
stripUnsupportedParams("agentrouter", "glm-5.3", body);
assert.equal(body.thinking, undefined, "no thinking field must not be introduced");
});
11 changes: 9 additions & 2 deletions tests/unit/executors-strip-unsupported-params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,19 @@ test("stripUnsupportedParams: nvidia non-glm-5 model keeps reasoning", () => {
assert.ok(body.reasoning !== undefined, "reasoning must survive for non-glm-5 nvidia model");
});

test("STRIP_RULES is non-empty and every rule has a drop list or a clamp mechanism", () => {
test("STRIP_RULES is non-empty and every rule has a drop list, a clamp mechanism, or a thinking-type map", () => {
assert.ok(__STRIP_RULES_FOR_TEST.length > 0);
for (const rule of __STRIP_RULES_FOR_TEST) {
const hasDrop = Array.isArray(rule.drop) && rule.drop.length > 0;
const hasClamp = rule.clampToModelMaxOutput === true || Number.isFinite(rule.maxOutputCap);
assert.ok(hasDrop || hasClamp, "rule must either drop params or clamp max output");
const hasThinkingMap =
typeof rule.mapThinkingType === "object" &&
rule.mapThinkingType !== null &&
Object.keys(rule.mapThinkingType).length > 0;
assert.ok(
hasDrop || hasClamp || hasThinkingMap,
"rule must either drop params, clamp max output, or map a thinking type"
);
assert.ok(typeof rule.match === "function" || rule.match instanceof RegExp);
}
});
Expand Down
Loading