From e66181b18294a5a2aab75e1751ea7bc33e49d319 Mon Sep 17 00:00:00 2001 From: Harvey Doan Date: Sun, 23 Aug 2026 19:47:07 +0700 Subject: [PATCH] fix(sse): append synthetic user turn for GLM-family upstreams (400 [1214]) (#11209) Validated on the combined batch board over tip 0b41259f: static gates clean (changelog, file-size 160 frozen, complexity 2628<=2774, cognitive 1187<=1223, dead-code 408<=416, docs-counts green at 351 providers, provider-consistency 268/351/0), typecheck:core clean, 430+ focused tests green across 5 groups. GLM-family upstreams reject message arrays with no user turn (400 [1214], verified live 2026-08-23); the synthetic user turn keeps Claude Code sessions on opencode-go alive. Thank you @linhdmn! --- open-sse/translator/index.ts | 13 +++- .../translator/request/claude-to-openai.ts | 18 +++++ stryker.conf.json | 1 + .../claude-to-openai-glm-user-turn.test.ts | 70 +++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/unit/claude-to-openai-glm-user-turn.test.ts diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 0e4dcc23ff..3528a0dcda 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -334,6 +334,15 @@ export function translateRequest( const isKimiCoding = normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey"; + // GLM-family upstreams (Z.AI / Zhipu console gateways) reject messages arrays + // with no role:"user" turn (400 [1214] "The messages parameter is illegal"). + // Pure tool-loop continuations from coding agents produce exactly that shape + // after Claude→OpenAI conversion, so flag those providers to have the source→ + // openai translator append a synthetic user turn when none survives. + const isGlmFamilyUpstream = + ["opencode-go", "opencode-zen"].includes(normalizedProvider) || + /glm|zhipu|z-ai/i.test(normalizedModel); + // Phase 2: Apply thinking budget control before normalization result = applyThinkingBudget(result); // Explicit reasoning-routing policies are final. The marker is internal and is @@ -463,13 +472,15 @@ export function translateRequest( options?.copilotClient || hasTargetHint || preserveCacheControl || - preserveResponsesReasoning + preserveResponsesReasoning || + isGlmFamilyUpstream ? { ...(credentials && typeof credentials === "object" ? credentials : {}), ...(options?.copilotClient ? { _copilotClient: true } : {}), ...(hasTargetHint ? { _targetFormat: targetFormat } : {}), ...(preserveCacheControl ? { _preserveCacheControl: true } : {}), ...(preserveResponsesReasoning ? { _preserveReasoningContent: true } : {}), + ...(isGlmFamilyUpstream ? { _ensureUserTurn: true } : {}), } : credentials; result = toOpenAI(model, result, stream, step1Credentials); diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index 1ba8a6e7f1..acfd4e9230 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -191,6 +191,24 @@ export function claudeToOpenAIRequest(model, body, stream, credentials: unknown // unanswered tool_call receives a "[No response received]" placeholder. fixMissingToolResponses(result.messages); + // GLM-family gateways (Z.AI / Zhipu — fronted by opencode-go / opencode-zen / + // glm-* targets) reject any payload whose messages array has NO role:"user" + // turn with `400 [1214] The messages parameter is illegal`. Claude Code agent + // loops legitimately produce such payloads: every inbound user turn carries + // only tool_result blocks (translated to role:"tool") and context compression + // can evict the original prompt. When the caller flags a GLM-family upstream + // (_ensureUserTurn), append a minimal synthetic user turn so the request + // satisfies the validator. Appending at the end keeps every earlier byte + // identical for upstream prompt caches. + const ensureUserTurn = + credentials !== null && + typeof credentials === "object" && + !Array.isArray(credentials) && + (credentials as JsonRecord)._ensureUserTurn === true; + if (ensureUserTurn && !result.messages.some((m) => m && m.role === "user")) { + result.messages.push({ role: "user", content: "(continue)" }); + } + const useNativeResponsesWebSearch = shouldUseNativeResponsesWebSearch(credentials); // Tools diff --git a/stryker.conf.json b/stryker.conf.json index 9658a82d51..4b753708cd 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -283,6 +283,7 @@ "tests/unit/observability-payloads.test.ts", "tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts", "tests/unit/openapi-security-tiers.test.ts", + "tests/unit/claude-to-openai-glm-user-turn.test.ts", "tests/unit/opencode-autocombo-search-pair.test.ts", "tests/unit/opencode-v2-config-11070.test.ts", "tests/unit/openrouter-free-model-credits-exhausted.test.ts", diff --git a/tests/unit/claude-to-openai-glm-user-turn.test.ts b/tests/unit/claude-to-openai-glm-user-turn.test.ts new file mode 100644 index 0000000000..9fca339d02 --- /dev/null +++ b/tests/unit/claude-to-openai-glm-user-turn.test.ts @@ -0,0 +1,70 @@ +/** + * GLM-family gateways (Z.AI / Zhipu — fronted by opencode-go, opencode-zen, and + * glm-* model ids) reject any chat.completions payload whose `messages` array + * contains NO role:"user" turn with `400 [1214] The messages parameter is + * illegal`. + * + * Claude Code agent loops legitimately produce such payloads: every inbound + * user turn carries only tool_result blocks (translated to role:"tool"), and + * context compression can evict the original prompt. Verified live against the + * upstream on 2026-08-23: + * - system + assistant(tool_calls) + tool → 1214 + * - same + trailing user → 200 + * - assistant content:null / "" with a user present → 200 + * + * Fix: when the credentials carry `_ensureUserTurn === true` and the translated + * messages have no user turn, append a minimal synthetic user turn. The flag is + * set by translateRequest for GLM-family providers only, so every other + * backend keeps byte-identical request bodies. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { claudeToOpenAIRequest } = await import( + "../../open-sse/translator/request/claude-to-openai.ts" +); + +const TOOL_LOOP_BODY = { + system: "You are helpful.", + max_tokens: 64, + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", id: "tool-1", name: "Read", input: { file_path: "/x" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool-1", content: "file contents" }], + }, + ], +}; + +test("RED: pure tool-loop with _ensureUserTurn gains a synthetic trailing user message", () => { + const result = claudeToOpenAIRequest("ox-alpha-free", TOOL_LOOP_BODY, false, { + _ensureUserTurn: true, + }); + const roles = result.messages.map((m) => m.role); + assert.ok(roles.includes("user"), `expected a user role, got [${roles.join(",")}]`); + const last = result.messages[result.messages.length - 1]; + assert.equal(last.role, "user"); + assert.ok(typeof last.content === "string" && last.content.trim().length > 0); +}); + +test("RED: without the flag the body stays unchanged (no user injected)", () => { + const result = claudeToOpenAIRequest("gpt-4o", TOOL_LOOP_BODY, false, null); + const roles = result.messages.map((m) => m.role); + assert.ok(!roles.includes("user"), `flag absent must not inject user, got [${roles.join(",")}]`); +}); + +test("RED: existing user turns are preserved untouched (no duplicate injection)", () => { + const body = { + system: "You are helpful.", + messages: [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content: [{ type: "text", text: "hi" }] }, + ], + }; + const result = claudeToOpenAIRequest("glm-5.2", body, false, { _ensureUserTurn: true }); + const users = result.messages.filter((m) => m.role === "user"); + assert.equal(users.length, 1, "must not add a synthetic user when one exists"); +});