Skip to content
Closed
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
89 changes: 83 additions & 6 deletions src/proxy/body-transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }],
Expand All @@ -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(/^<system-reminder>/);
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", () => {
Expand Down Expand Up @@ -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",
Expand All @@ -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" } }]);
});
});

Expand Down Expand Up @@ -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)", () => {
Expand Down
86 changes: 71 additions & 15 deletions src/proxy/body-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -180,19 +184,38 @@ function applyAnthropicUserId(body: Record<string, unknown>, 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 `<system-reminder>` 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 `<system-reminder>` 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<string, unknown>, 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];
}
Expand All @@ -206,15 +229,48 @@ function applyStartPlanSystem(body: Record<string, unknown>, 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<string, unknown>, 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();
}
50 changes: 26 additions & 24 deletions src/proxy/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
buildContextPrefixMessage,
buildDesktopContextSection,
buildEnvironmentSection,
clientSystemText,
formatLocalIsoDate,
} from "./system-prompt.js";

Expand Down Expand Up @@ -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");
Expand All @@ -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)
Expand All @@ -112,15 +113,15 @@ 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.");
expect(stable.text).not.toContain("No visualization output directory is supplied");
});

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");
Expand All @@ -134,44 +135,45 @@ 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");
expect(dynamic.text).toContain("- OS Version: linux 6.8.0-49-generic x64");
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("");
});
});

Expand Down
Loading