diff --git a/src/proxy/body-transformer.test.ts b/src/proxy/body-transformer.test.ts index 839c538..58293a2 100644 --- a/src/proxy/body-transformer.test.ts +++ b/src/proxy/body-transformer.test.ts @@ -284,7 +284,7 @@ describe("transformRequestBody — start-plan system (Anthropic)", () => { expect(parsed.tools[1]).toEqual({ name: "read_file" }); }); - it("strips client cache_control from preserved system blocks", () => { + it("merges client system blocks into the third official block instead of adding a 4th", () => { const body = JSON.stringify({ model: "glm-5.2", system: [{ type: "text", text: "User rule", cache_control: { type: "ephemeral" } }], @@ -293,8 +293,17 @@ describe("transformRequestBody — start-plan system (Anthropic)", () => { const out = withEnvPromptVars(() => transformRequestBody(body, { format: "anthropic", startPlan: true, provider: "bigmodel" })); const parsed = JSON.parse(out as string); - expect(parsed.system).toHaveLength(4); - expect(parsed.system[3]).toEqual({ type: "text", text: "User rule" }); + + // 4 system blocks is the 3012 trigger; the official 3-block shape must stay. + expect(parsed.system).toHaveLength(3); + expect(parsed.system[0].cache_control).toBeUndefined(); + expect(parsed.system[1].cache_control).toEqual({ type: "ephemeral" }); + expect(parsed.system[2].cache_control).toEqual({ type: "ephemeral" }); + expect(parsed.system[2].text.endsWith("\n\nUser rule")).toBe(true); + // Conversation keeps the context prefix + the untouched client turn. + expect(parsed.messages[0].content[0].text).toMatch(/^/); + expect(parsed.messages[1].role).toBe("user"); + expect(parsed.messages[1].content[0].text).toBe("hi"); }); it("omits the powered-by line when body.model is missing", () => { @@ -337,7 +346,7 @@ describe("transformRequestBody — start-plan system (Anthropic)", () => { expect(parsed.system[2].text).not.toContain("powered by the model named"); }); - it("preserves client system text after ZCode's official blocks", () => { + it("merges a string client system prompt into the official block (Claude Code compatibility)", () => { const body = JSON.stringify({ model: "glm-5.2", system: "User rule", @@ -347,8 +356,23 @@ describe("transformRequestBody — start-plan system (Anthropic)", () => { const out = withEnvPromptVars(() => transformRequestBody(body, { format: "anthropic", startPlan: true, provider: "bigmodel" })); const parsed = JSON.parse(out as string); - expect(parsed.system).toHaveLength(4); - expect(parsed.system[3]).toEqual({ type: "text", text: "User rule" }); + expect(parsed.system).toHaveLength(3); + expect(parsed.system[2].text.endsWith("\n\nUser rule")).toBe(true); + expect(parsed.messages[1].role).toBe("user"); + expect(parsed.messages[1].content[0].text).toBe("hi"); + }); + + it("leaves system text untouched when startPlan is off", () => { + const body = JSON.stringify({ + model: "glm-5.2", + system: [{ type: "text", text: "User rule", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: "hi" }], + }); + + const out = transformRequestBody(body, { format: "anthropic" }); + const parsed = JSON.parse(out as string); + + expect(parsed.system).toEqual([{ type: "text", text: "User rule", cache_control: { type: "ephemeral" } }]); }); }); @@ -391,6 +415,59 @@ describe("transformRequestBody — start-plan system (OpenAI)", () => { expect(parsed.messages[2].content).not.toContain("powered by the model named"); expect(parsed.messages[3]).toEqual({ role: "user", content: "hi" }); }); + + it("merges client system messages into the third official system message", () => { + const body = JSON.stringify({ + model: "glm-5.2", + messages: [ + { role: "system", content: "You are Claude Code." }, + { role: "user", content: "hi" }, + ], + }); + + const out = withEnvPromptVars(() => transformRequestBody(body, { format: "openai", startPlan: true, provider: "bigmodel" })); + const parsed = JSON.parse(out as string); + + expect(parsed.messages.filter((m: { role: string }) => m.role === "system")).toHaveLength(3); + expect(parsed.messages[2].content.endsWith("\n\nYou are Claude Code.")).toBe(true); + expect(parsed.messages[3]).toEqual({ role: "user", content: "hi" }); + }); + + it("treats developer messages like system messages when merging (OpenAI)", () => { + const body = JSON.stringify({ + model: "glm-5.2", + messages: [ + { role: "developer", content: [{ type: "text", text: "dev rule" }] }, + { role: "user", content: "hi" }, + ], + }); + + const out = withEnvPromptVars(() => transformRequestBody(body, { format: "openai", startPlan: true, provider: "bigmodel" })); + const parsed = JSON.parse(out as string); + + expect(parsed.messages.filter((m: { role: string }) => m.role === "system")).toHaveLength(3); + expect(parsed.messages.some((m: { role: string }) => m.role === "developer")).toBe(false); + expect(parsed.messages[2].content.endsWith("\n\ndev rule")).toBe(true); + expect(parsed.messages[3]).toEqual({ role: "user", content: "hi" }); + }); + + it("keeps the user turn untouched when merging multiple client system parts", () => { + const body = JSON.stringify({ + model: "glm-5.2", + messages: [ + { role: "system", content: "Rule A" }, + { role: "system", content: [{ type: "text", text: "Rule B" }] }, + { role: "user", content: [{ type: "text", text: "hi" }] }, + ], + }); + + const out = withEnvPromptVars(() => transformRequestBody(body, { format: "openai", startPlan: true, provider: "bigmodel" })); + const parsed = JSON.parse(out as string); + + expect(parsed.messages.filter((m: { role: string }) => m.role === "system")).toHaveLength(3); + expect(parsed.messages[2].content.endsWith("\n\nRule A\n\nRule B")).toBe(true); + expect(parsed.messages[3]).toEqual({ role: "user", content: [{ type: "text", text: "hi" }] }); + }); }); describe("transformRequestBody — metadata.user_id (Anthropic)", () => { diff --git a/src/proxy/body-transformer.ts b/src/proxy/body-transformer.ts index ba2f89e..5683649 100644 --- a/src/proxy/body-transformer.ts +++ b/src/proxy/body-transformer.ts @@ -7,8 +7,12 @@ * Transformations applied: * 1. OpenAI + `stream: true` → inject `stream_options.include_usage: true` * (matches `@ai-sdk/openai-compatible` default in `_reverse/zcode.cjs`). - * 2. start-plan → prepend ZCode gateway system blocks. OpenAI upstream gets - * system messages; Anthropic-shaped input gets the Anthropic `system` field. + * 2. start-plan → replace the request's system prompt with the ZCode gateway + * identity blocks. OpenAI upstream gets system messages; Anthropic-shaped + * input gets the Anthropic `system` field. A client-supplied system prompt + * is merged into the third (dynamic) official block: the gateway + * fingerprints the 3-block shape and rejects a 4th block with + * 405 `{"code":3012,...unusual activity...}` (every Claude Code request). * 3. Anthropic format → clear existing `cache_control` markers on all * non-system messages, then add `{ type: "ephemeral" }` to the last * content block of the last non-system message (mirrors the bundle's @@ -22,7 +26,7 @@ * @see _reverse/NOTEPAD.md "How Credential is Used for LLM Calls" */ import type { Format } from "../translator/types.js"; -import { buildStartPlanSystem, buildContextPrefixMessage } from "./system-prompt.js"; +import { buildStartPlanSystem, buildContextPrefixMessage, clientSystemText } from "./system-prompt.js"; import { resolveEnvPromptInfo } from "./identity.js"; interface TransformContext { @@ -180,19 +184,38 @@ function applyAnthropicUserId(body: Record, userId: string): bo } /** - * start-plan: prepend ZCode gateway system blocks (ContextBuilder mirror — - * see system-prompt.ts). The gateway rejects requests without these identity - * blocks with 3012 "method not allowed". Forwards `body.model` so - * `buildStartPlanSystem` can emit the dynamic "You are powered by the model - * named ${model}." line inside the Environment section, and attaches the - * meta_user context_prefix (currentDate `` user turn) that - * every real client request carries. Client `cache_control` on tools is - * stripped: official blocks (3) + last-message marker (1) already fill + * start-plan: replace the request's `system` field with the ZCode gateway + * identity blocks (ContextBuilder mirror — see system-prompt.ts). + * + * The gateway rejects requests without those blocks with 3012 "method not + * allowed", and it ALSO rejects a fourth block: appending the client system + * prompt after the official trio produced + * `405 {"code":3012,"msg":"request has been blocked due to unusual activity."}` + * for every Claude Code request. The block COUNT is part of the fingerprint. + * + * So the client's system text is appended to the third (dynamic) official block + * instead — the block the real client already varies per environment. That + * keeps exactly three system blocks, keeps the instructions in the `system` + * role (a user-turn merge makes models distrust them as prompt injection), and + * leaves the text ahead of the conversation. + * + * Forwards `body.model` so `buildStartPlanSystem` can emit the dynamic "You are + * powered by the model named ${model}." line inside the Environment section, and + * attaches the meta_user context_prefix (currentDate `` user + * turn) that every real client request carries. Client `cache_control` on tools + * is stripped: the official system blocks + last-message marker already fill * Anthropic's 4-breakpoint cache budget. */ function applyStartPlanSystem(body: Record, provider?: "zai" | "bigmodel"): boolean { const model = typeof body.model === "string" ? body.model : undefined; - body.system = buildStartPlanSystem(body.system, model, resolveEnvPromptInfo(), provider); + const clientSystem = clientSystemText(body.system); + const official = buildStartPlanSystem(model, resolveEnvPromptInfo(), provider); + if (clientSystem) { + const dynamic = official[official.length - 1]; + dynamic.text = `${dynamic.text}\n\n${clientSystem}`; + } + body.system = official; + if (Array.isArray(body.messages) && body.messages.length > 0) { body.messages = [buildContextPrefixMessage() as unknown, ...body.messages]; } @@ -206,15 +229,48 @@ function applyStartPlanSystem(body: Record, provider?: "zai" | return true; } +/** + * start-plan (OpenAI shape): keep exactly the three official `system` messages + * and merge every client `system`/`developer` message into the third one for the + * same 3012 reason as {@link applyStartPlanSystem}. Forwarding the client turns + * as extra system messages is what trips the upstream risk control, so they are + * merged rather than duplicated. + */ function applyStartPlanOpenAISystem(body: Record, provider?: "zai" | "bigmodel"): boolean { const messages = body.messages; if (!Array.isArray(messages)) return false; + const clientParts: string[] = []; + const rest: unknown[] = []; + for (const message of messages) { + if (isPlainObject(message) && (message.role === "system" || message.role === "developer")) { + const text = openAIContentText(message.content); + if (text) clientParts.push(text); + continue; + } + rest.push(message); + } + const model = typeof body.model === "string" ? body.model : undefined; - const official = buildStartPlanSystem(undefined, model, resolveEnvPromptInfo(), provider).map((block) => ({ + const official = buildStartPlanSystem(model, resolveEnvPromptInfo(), provider).map((block) => ({ role: "system", - content: typeof block === "object" && block !== null && "text" in block ? String(block.text) : "", + content: block.text, })); - body.messages = [...official, ...messages]; + if (clientParts.length > 0) { + const dynamic = official[official.length - 1]; + dynamic.content = `${dynamic.content}\n\n${clientParts.join("\n\n")}`; + } + body.messages = [...official, ...rest]; return true; } + +/** Plain-text view of an OpenAI message content (string or `{type:"text"}` parts). */ +function openAIContentText(content: unknown): string { + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + return content + .map((part) => (isPlainObject(part) && part.type === "text" && typeof part.text === "string" ? part.text : "")) + .filter((text) => text.length > 0) + .join("\n\n") + .trim(); +} diff --git a/src/proxy/system-prompt.test.ts b/src/proxy/system-prompt.test.ts index 7a66239..d13c4fb 100644 --- a/src/proxy/system-prompt.test.ts +++ b/src/proxy/system-prompt.test.ts @@ -14,6 +14,7 @@ import { buildContextPrefixMessage, buildDesktopContextSection, buildEnvironmentSection, + clientSystemText, formatLocalIsoDate, } from "./system-prompt.js"; @@ -73,7 +74,7 @@ describe("buildEnvironmentSection (bundle T9o)", () => { describe("buildStartPlanSystem (bundle assembleSystemMessages)", () => { it("emits exactly 3 official blocks; cli_prefix plain, stable/dynamic ephemeral (3.15.1+ wire)", () => { - const blocks = buildStartPlanSystem(undefined, "glm-5.3", ENV, "zai", VIZ_DIR); + const blocks = buildStartPlanSystem("glm-5.3", ENV, "zai", VIZ_DIR); expect(blocks).toHaveLength(3); for (const b of blocks) { expect(b.type).toBe("text"); @@ -86,12 +87,12 @@ describe("buildStartPlanSystem (bundle assembleSystemMessages)", () => { }); it("block 1 is the bare CLI Prefix", () => { - const [cli] = buildStartPlanSystem(undefined, "glm-5.3", ENV, "zai", VIZ_DIR); + const [cli] = buildStartPlanSystem("glm-5.3", ENV, "zai", VIZ_DIR); expect(cli.text).toBe("You are ZCode, an interactive coding agent"); }); it("block 2 = Agent Identity + ZCode Desktop Context, \\n\\n-joined (stable group)", () => { - const [, stable] = buildStartPlanSystem(undefined, "glm-5.3", ENV, "zai", VIZ_DIR); + const [, stable] = buildStartPlanSystem("glm-5.3", ENV, "zai", VIZ_DIR); expect(stable.text.startsWith("\nYou are an interactive ZCode agent that helps users with software engineering tasks.")).toBe(true); expect(stable.text).toContain("# Harness"); // Current 3.7.7+ harness line (mid-conversation system turns) @@ -112,7 +113,7 @@ describe("buildStartPlanSystem (bundle assembleSystemMessages)", () => { }); it("default vizDir renders the 3-line Interactive views variant with a JSON-quoted dir", () => { - const [, stable] = buildStartPlanSystem(undefined, "glm-5.3", ENV, "zai", VIZ_DIR); + const [, stable] = buildStartPlanSystem("glm-5.3", ENV, "zai", VIZ_DIR); expect(stable.text).toContain("- This Desktop host supports Gen UI: completed assistant references to HTML fragments in the supplied session directory render inline, including on remote executors."); expect(stable.text).toContain(`- Writable, durable visualization directory for this session: ${JSON.stringify(VIZ_DIR)}. Save inline fragments here, outside the project. Other paths cannot be rendered.`); expect(stable.text).toContain("- Use the independent visualize skill for authoring instructions, styles and the restricted window.zcode bridge. Load the skill before generating an interactive view."); @@ -120,7 +121,7 @@ describe("buildStartPlanSystem (bundle assembleSystemMessages)", () => { }); it("block 3 = '\\n\\n' + Dynamic Behavior + Environment + Context Management (dynamic group)", () => { - const [, , dynamic] = buildStartPlanSystem(undefined, "glm-5.3", ENV, "zai", VIZ_DIR); + const [, , dynamic] = buildStartPlanSystem("glm-5.3", ENV, "zai", VIZ_DIR); expect(dynamic.text.startsWith("\n\n# Communicating with the user")).toBe(true); // Section order: dynamic behavior → environment → context management const envIdx = dynamic.text.indexOf("# Environment"); @@ -134,18 +135,18 @@ describe("buildStartPlanSystem (bundle assembleSystemMessages)", () => { }); it("byte-spot: single \\n between the code-style default and the comment rule (wTr join)", () => { - const [, , dynamic] = buildStartPlanSystem(undefined, "glm-5.3", ENV, "zai", VIZ_DIR); + const [, , dynamic] = buildStartPlanSystem("glm-5.3", ENV, "zai", VIZ_DIR); expect(dynamic.text).toContain("match its comment density, naming, and idiom.\nOnly write a code comment"); }); it("byte-spot: 'exhaustive survey' has no trailing period (xTr literal)", () => { - const [, , dynamic] = buildStartPlanSystem(undefined, "glm-5.3", ENV, "zai", VIZ_DIR); + const [, , dynamic] = buildStartPlanSystem("glm-5.3", ENV, "zai", VIZ_DIR); expect(dynamic.text).toContain("not an exhaustive survey\n\n"); expect(dynamic.text).not.toContain("not an exhaustive survey."); }); it("environment lines carry the resolved env values", () => { - const [, , dynamic] = buildStartPlanSystem(undefined, "glm-5.3", ENV, "zai", VIZ_DIR); + const [, , dynamic] = buildStartPlanSystem("glm-5.3", ENV, "zai", VIZ_DIR); expect(dynamic.text).toContain("- Primary working directory: /home/dev/project"); expect(dynamic.text).toContain("- Platform: linux"); expect(dynamic.text).toContain("- Shell: bash"); @@ -153,25 +154,26 @@ describe("buildStartPlanSystem (bundle assembleSystemMessages)", () => { expect(dynamic.text).toContain("- Is a git repository: no"); }); - it("appends client system blocks AFTER the official blocks, stripped of cache_control", () => { - const blocks = buildStartPlanSystem( - [ + it("always emits exactly 3 blocks — client system text must not be appended (3012 risk control)", () => { + // The gateway fingerprints the block count: a 4th block is what produced + // 405 {"code":3012,"msg":"request has been blocked due to unusual activity."} + // for every Claude Code request. Callers merge client text into the third + // block instead (see body-transformer.ts). + expect(buildStartPlanSystem("glm-5.3", ENV, "zai", VIZ_DIR)).toHaveLength(3); + expect(buildStartPlanSystem(undefined, ENV, undefined, VIZ_DIR)).toHaveLength(3); + }); + + it("clientSystemText flattens strings and text blocks, dropping empties and cache_control", () => { + expect(clientSystemText("User rule")).toBe("User rule"); + expect( + clientSystemText([ { type: "text", text: "User rule", cache_control: { type: "ephemeral" } }, { type: "text", text: "" }, // dropped "raw string block", // kept - ], - "glm-5.3", - ENV, - "zai", - ); - expect(blocks).toHaveLength(5); - expect(blocks[3]).toEqual({ type: "text", text: "User rule" }); - expect(blocks[4]).toEqual({ type: "text", text: "raw string block" }); - }); - - it("returns only the 3 official blocks for absent system", () => { - expect(buildStartPlanSystem(undefined, undefined, ENV, undefined, VIZ_DIR)).toHaveLength(3); - expect(buildStartPlanSystem(null, undefined, ENV, undefined, VIZ_DIR)).toHaveLength(3); + ]), + ).toBe("User rule\n\nraw string block"); + expect(clientSystemText(undefined)).toBe(""); + expect(clientSystemText([])).toBe(""); }); }); diff --git a/src/proxy/system-prompt.ts b/src/proxy/system-prompt.ts index 0b9206e..09b3abe 100644 --- a/src/proxy/system-prompt.ts +++ b/src/proxy/system-prompt.ts @@ -140,15 +140,20 @@ export function buildDesktopContextSection(vizDir: string | undefined): string { /** * Prepend the official ZCode gateway blocks to the request's `system` field, * mirroring `assembleSystemMessages` (3 blocks: cli_prefix plain, stable and - * dynamic blocks ephemeral, dynamic block `\n\n`-prefixed). Client system - * blocks (if any) are preserved AFTER the official blocks, with their - * `cache_control` stripped: the official blocks already consume cache - * breakpoints (the last-message marker applied by body-transformer takes the - * final one), so client markers would push the request over the cap — the - * real client never emits foreign markers because it owns the whole body. + * dynamic blocks ephemeral, dynamic block `\n\n`-prefixed). + * + * The block COUNT is part of the gateway's fingerprint: the real client emits + * exactly three `system` blocks. A fourth block (what appending a client system + * prompt used to produce) makes the upstream risk-control layer reject the + * request with + * `405 {"code":3012,"msg":"request has been blocked due to unusual activity."}` + * — which is exactly what every Claude Code request used to hit. + * + * Client system text therefore must NOT be appended here — callers merge it + * into the third block via {@link clientSystemText} (see `applyStartPlanSystem` + * in body-transformer.ts), keeping the three-block count the gateway expects. */ export function buildStartPlanSystem( - existingSystem: unknown, currentModel: string | undefined, env: StartPlanEnvInfo, provider?: "zai" | "bigmodel", @@ -160,12 +165,23 @@ export function buildStartPlanSystem( buildEnvironmentSection(env, currentModel, provider), data.dynamicSections.afterEnvironment, ].join("\n\n"); - const official: SystemBlock[] = [ + return [ { type: "text", text: data.cliPrefix }, { type: "text", text: stable, cache_control: { ...EPHEMERAL } }, { type: "text", text: `\n\n${dynamic}`, cache_control: { ...EPHEMERAL } }, ]; - return [...official, ...normalizeUserSystem(existingSystem)]; +} + +/** + * Flatten a client-supplied system prompt (string or Anthropic text-block + * array) into one plain-text string, dropping empty blocks and any + * `cache_control` markers (the official blocks already own the system cache + * breakpoints). Returns `""` when there is nothing to carry. + */ +export function clientSystemText(system: unknown): string { + return normalizeUserSystem(system) + .map((block) => block.text) + .join("\n\n"); } /**