mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 08:32:11 +03:00
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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
70
tests/unit/claude-to-openai-glm-user-turn.test.ts
Normal file
70
tests/unit/claude-to-openai-glm-user-turn.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user