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!
This commit is contained in:
Harvey Doan
2026-08-23 19:47:07 +07:00
committed by GitHub
parent 7af14d2696
commit e66181b182
4 changed files with 101 additions and 1 deletions

View File

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

View File

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

View File

@@ -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",

View File

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