diff --git a/README.md b/README.md index 93f0ebe..a3aa31f 100644 --- a/README.md +++ b/README.md @@ -238,22 +238,22 @@ Client Request Proxy API Key Auth (shared secret) │ ▼ -Route Detection + Plan-aware Routing (v2.3: coding-plan mirrors the real ZCode client) +Route Detection + Plan-aware Routing (both plans post Anthropic upstream, v2.4+) /v1/chat/completions (OpenAI client format) ├─ coding-plan → TRANSLATE OpenAI→Anthropic → provider's anthropic endpoint │ (remapped to zcode.z.ai ultra endpoints via server-controlled mapping) - └─ start-plan → zcode.z.ai OpenAI-compatible gateway (JWT + captcha), passthrough + └─ start-plan → TRANSLATE OpenAI→Anthropic → zcode.z.ai + /api/v1/zcode-plan/anthropic/v1/messages (JWT + captcha) /v1/messages (Anthropic client format) ├─ coding-plan → NATIVE PASSTHROUGH to the provider's anthropic endpoint (same format) - └─ start-plan → TRANSLATE Anthropic→OpenAI → zcode.z.ai gateway + └─ start-plan → NATIVE PASSTHROUGH → zcode.z.ai + /api/v1/zcode-plan/anthropic/v1/messages (JWT + captcha) /v1/responses (Responses client format) - ├─ coding-plan → TRANSLATE Responses→Chat→Anthropic → anthropic endpoint - └─ start-plan → TRANSLATE Responses→Chat → gateway + ├─ both plans → TRANSLATE Responses→Chat→Anthropic → plan's anthropic endpoint │ ▼ Body Transformation (ZCode-equivalent mutations) Anthropic upstream → cache_control on last message + metadata.user_id (oauth) - OpenAI streaming → inject stream_options.include_usage start-plan → prepend ZCode system messages │ ▼ diff --git a/src/provider/models.ts b/src/provider/models.ts index 682051f..1360db1 100644 --- a/src/provider/models.ts +++ b/src/provider/models.ts @@ -22,4 +22,7 @@ export const MODELS: ModelDef[] = [ { id: "glm-5.1", name: "GLM 5.1", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true }, { id: "glm-5.2", name: "GLM 5.2", contextWindow: 1_000_000, maxOutputTokens: 128_000, reasoning: true }, { id: "glm-5.3", name: "GLM 5.3", contextWindow: 1_000_000, maxOutputTokens: 128_000, reasoning: true }, + // start-plan gateway serves the -flash variant (used by claimed trial plans + // like the weekend package); advertised so client-side discovery lists it. + { id: "glm-5.3-flash", name: "GLM 5.3 Flash", contextWindow: 1_000_000, maxOutputTokens: 128_000, reasoning: true }, ]; diff --git a/src/provider/providers.test.ts b/src/provider/providers.test.ts index ba52403..7eb3ff7 100644 --- a/src/provider/providers.test.ts +++ b/src/provider/providers.test.ts @@ -37,12 +37,13 @@ describe("providers", () => { }); describe("models", () => { - it("MODELS contains exactly the 10 pinned coding-plan models", () => { - expect(MODELS).toHaveLength(10); + it("MODELS contains exactly the 11 pinned coding-plan models", () => { + expect(MODELS).toHaveLength(11); const ids = MODELS.map((m) => m.id); expect(ids).toEqual([ "glm-4.5-air", "glm-4.6", "glm-4.6v", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-5.1", "glm-5.2", "glm-5.3", + "glm-5.3-flash", ]); }); @@ -55,20 +56,23 @@ describe("models", () => { } }); - it("all models except glm-5.2/glm-5.3 have 200k context", () => { + it("all models except glm-5.2/glm-5.3/glm-5.3-flash have 200k context", () => { for (const m of MODELS) { - if (m.id === "glm-5.2" || m.id === "glm-5.3") continue; + if (m.id === "glm-5.2" || m.id === "glm-5.3" || m.id === "glm-5.3-flash") continue; expect(m.contextWindow).toBe(200_000); } }); - it("glm-5.2 and glm-5.3 have 1M context", () => { + it("glm-5.2, glm-5.3 and glm-5.3-flash have 1M context", () => { const glm52 = MODELS.find((m) => m.id === "glm-5.2"); expect(glm52).toBeDefined(); expect(glm52!.contextWindow).toBe(1_000_000); const glm53 = MODELS.find((m) => m.id === "glm-5.3"); expect(glm53).toBeDefined(); expect(glm53!.contextWindow).toBe(1_000_000); + const glm53f = MODELS.find((m) => m.id === "glm-5.3-flash"); + expect(glm53f).toBeDefined(); + expect(glm53f!.contextWindow).toBe(1_000_000); }); it("includes key GLM models", () => { diff --git a/src/proxy/captcha-happy.ts b/src/proxy/captcha-happy.ts index 5c1034f..d904ff7 100644 --- a/src/proxy/captcha-happy.ts +++ b/src/proxy/captcha-happy.ts @@ -1751,7 +1751,14 @@ async function createDom(region, prefix) { const HOST_CRITICAL_GLOBALS = new Set([ "process", "Bun", "console", "performance", "crypto", "fetch", "queueMicrotask", "structuredClone", "TextEncoder", "TextDecoder", - "requestAnimationFrame", "cancelAnimationFrame", "print", + "requestAnimationFrame", "cancelAnimationFrame", + // NOTE: `print` was removed from this list (2026-08-29). The polyfill + // defines a harmless no-op on the window, but the alias pass skipped it, + // so under Bun (guest scripts run in the HOST realm) the Aliyun pe risk + // engine hit a bare `print` reference → ReferenceError → broken + // fingerprint chain → degraded solve success rate (711 WINDOW-ERRORs in + // one day). Bun's host global has no native `print`, so aliasing the + // stub shadows nothing critical. "URL", "URLSearchParams", "AbortController", "AbortSignal", "ReadableStream", "WritableStream", "TransformStream", "Blob", "File", "FormData", "Headers", "Request", "Response", "Event", "EventTarget", diff --git a/src/proxy/handler-resilience.test.ts b/src/proxy/handler-resilience.test.ts new file mode 100644 index 0000000..371314b --- /dev/null +++ b/src/proxy/handler-resilience.test.ts @@ -0,0 +1,154 @@ +/** + * Tests for the two resilience behaviors added after the PR #34 review: + * + * 1. In-body captcha challenge detection: a start-plan upstream response with + * HTTP 400 + {"code":3007,...} in the JSON body (no captcha header) must + * be treated as a captcha challenge and retried with a fresh token. + * 2. Connect-retry freshness: after a connect-level failure, the retried + * dispatch must receive a FRESH Request (body not marked used). A mock + * fetch that throws-then-succeeds does NOT catch the bug (mocks don't + * enforce bodyUsed), so the mock asserts `req.bodyUsed === false` on + * every call per the review's test note. + * + * Both tests use the start-plan path with an injected captcha module via + * `mock.module("./captcha.js")` (same technique as captcha-pool.test.ts). + */ +import { describe, it, expect, mock } from "bun:test"; +import { proxyRequest } from "./handler.js"; +import type { ProxyConfig, ProxyIdentity } from "../config/types.js"; +import { AuthManager } from "../auth/manager.js"; + +const IDENTITY: ProxyIdentity = { + appVersion: "test-1.0.0", + sourceTitle: "cli", + refererOrigin: "https://zcode.z.ai", +}; + +const TEST_CONFIG: ProxyConfig = { + server: { port: 8080, host: "0.0.0.0" }, + auth: { mode: "apikey", apiKey: "testk...ret" }, + provider: "zai", + plan: "start-plan", + providers: { + zai: { anthropicBase: "https://api.z.ai/api/anthropic", openaiBase: "https://api.z.ai/api/coding/paas/v4" }, + bigmodel: { anthropicBase: "https://open.bigmodel.cn/api/anthropic", openaiBase: "https://open.bigmodel.cn/api/coding/paas/v4" }, + }, + defaultModel: "glm-4.6", + models: ["glm-4.6"], + identity: IDENTITY, + clientIdentity: { mode: "observe", ttlSeconds: 900, maxSessions: 1024 }, + responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, + endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, + clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + async: { enabled: false, origin: "https://zcode.z.ai", pollIntervalMs: 5000, keepAliveIntervalMs: 3000, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 8000, controlTimeoutMs: 15000, defaultModel: "" }, + claim: { enabled: false, auto: true, origin: "https://zcode.z.ai", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, + logging: { level: "info" }, +}; + +const GATEWAY_URL = "https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages"; + +const ANTHROPIC_OK = JSON.stringify({ + id: "msg_resilience", + type: "message", + role: "assistant", + model: "glm-4.6", + content: [{ type: "text", text: "resilience reply" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 3 }, +}); + +describe("proxyRequest — start-plan resilience (PR #34 review P1/P3)", () => { + it("retries an in-body 3007 captcha challenge with a fresh token", async () => { + // Mock the captcha module: config enabled, token take returns distinct + // tokens per call so we can assert the retry used a FRESH token. + let tokenSeq = 0; + mock.module("./captcha.js", () => ({ + detectCaptchaChallenge: (resp: Response): string | null => { + const v = resp.headers.get("x-aliyun-captcha-verify-param"); + return v && v.trim().length > 0 ? v.trim() : null; + }, + getCaptchaToken: async (_appVersion: string) => { + tokenSeq += 1; + return { verifyParam: `tok-${tokenSeq}`, region: "sgp" }; + }, + RETRY_HEADERS: { PARAM: "x-aliyun-captcha-verify-param", REGION: "x-aliyun-captcha-verify-region" }, + })); + + // Upstream: first call = HTTP 400 with {"code":3007} in the body (no + // captcha header), second call = success. The mock also records the + // captcha header of each call so we can assert the retry used a FRESH + // token (tok-2, not the consumed tok-1). + const seenCaptchaHeaders: (string | null)[] = []; + let calls = 0; + const fetchMock = mock(async (req: Request): Promise => { + if (req.url.includes("/client/configs")) { + return new Response(JSON.stringify({ data: { configs: { captcha: { enabled: true } } } }), { + status: 200, headers: { "content-type": "application/json" }, + }); + } + calls += 1; + seenCaptchaHeaders.push(req.headers.get("x-aliyun-captcha-verify-param")); + if (calls === 1) { + return new Response(JSON.stringify({ code: 3007, msg: "captcha verify failed" }), { + status: 400, headers: { "content-type": "application/json" }, + }); + } + return new Response(ANTHROPIC_OK, { status: 200, headers: { "content-type": "application/json" } }); + }); + + const auth = new AuthManager({ mode: "oauth", provider: "zai" }); + auth.setOAuthCredential({ apiKey: "key-mock", provider: "zai", jwt: "jwt-mock" }); + const clientReq = new Request("http://localhost:8080/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"model":"glm-4.6","messages":[{"role":"user","content":"hi"}]}', + }); + + const resp = await proxyRequest(clientReq, "openai", { config: TEST_CONFIG, auth, fetchImpl: fetchMock as any }); + + // The in-body 3007 challenge was detected and retried with a fresh token. + expect(calls).toBe(2); + expect(seenCaptchaHeaders[0]).toBe("tok-1"); + expect(seenCaptchaHeaders[1]).toBe("tok-2"); + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.choices[0].message.content).toBe("resilience reply"); + }); + + it("retries a connect failure with a FRESH Request (body not marked used)", async () => { + // Upstream: first call = connect-level failure (the bug shape), second + // call = success. The mock asserts req.bodyUsed === false on EVERY call + // per the review's test note: mocks don't enforce bodyUsed the way real + // fetch does, so the freshness assertion must be explicit. + let calls = 0; + const fetchMock = mock(async (req: Request): Promise => { + calls += 1; + // The freshness assertion: if the handler re-dispatched the SAME + // Request object, this would be true on the second call. + expect(req.bodyUsed).toBe(false); + if (calls === 1) { + throw new Error("Unable to connect. Is the computer able to access the url?"); + } + return new Response(ANTHROPIC_OK, { status: 200, headers: { "content-type": "application/json" } }); + }); + + const auth = new AuthManager({ mode: "oauth", provider: "zai" }); + auth.setOAuthCredential({ apiKey: "key-mock", provider: "zai", jwt: "jwt-mock" }); + const clientReq = new Request("http://localhost:8080/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"model":"glm-4.6","messages":[{"role":"user","content":"hi"}]}', + }); + + const resp = await proxyRequest(clientReq, "openai", { config: TEST_CONFIG, auth, fetchImpl: fetchMock as any }); + + // The connect failure was retried with a FRESH Request (bodyUsed false + // on the second call — the freshness assertion inside the mock passed). + expect(calls).toBe(2); + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.choices[0].message.content).toBe("resilience reply"); + }); +}); diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index a43e675..648a015 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -122,13 +122,16 @@ export async function proxyRequest( return errorResponse(503, "credential_unavailable", (err as Error).message); } - // v2.3 shape alignment: coding-plan mirrors the real ZCode client — Anthropic - // upstream (api.z.ai/api/anthropic → ultra via endpoint routing). start-plan - // stays on the zcode.z.ai OpenAI gateway. + // v2.6: both plans use the Anthropic upstream. coding-plan mirrors the real + // ZCode client (api.z.ai/api/anthropic → ultra via endpoint routing); + // start-plan's old OpenAI gateway (/api/v1/zcode-plan/chat/completions) was + // retired server-side (404 as of 2026-08-28) — the live desktop client now + // posts Anthropic messages to /api/v1/zcode-plan/anthropic/v1/messages with + // the start-plan JWT, so we do the same (no OpenAI translation either way). const startPlan = config.plan === "start-plan"; - const translateAnthropicToOpenAI = format === "anthropic" && startPlan; - const translateOpenAIToAnthropic = format === "openai" && !startPlan; - const upstreamFormat: Format = startPlan ? "openai" : "anthropic"; + const translateAnthropicToOpenAI = false; + const translateOpenAIToAnthropic = format === "openai"; + const upstreamFormat: Format = "anthropic"; const clientSession = resolveSessionContext({ clientReq, body, upstreamFormat, model: meta.model, config }); if (debug && clientSession) { const shortSession = clientSession.sessionId ? clientSession.sessionId.slice(0, 10) : "-"; @@ -223,7 +226,34 @@ export async function proxyRequest( let upstreamResp: Response; try { - upstreamResp = await dispatch(upstreamReq, upstreamHeaderPairs); + // Transient connect failures (DNS blip, TLS reset, Bun "Unable to + // connect") happen a few times a day against the gateway. Retry the + // CONNECT twice with a short backoff before surfacing a 502 — the + // request never reached upstream, so resending is side-effect-free. + // Guard rails (review P1/P2 + nits): skip retry when the client already + // aborted, and re-dispatch a FRESH Request each attempt — a reused + // Request has its body stream marked used after the first fetch, so + // attempt 2+ would throw "Request body already used" before any I/O + // (start-plan hits the plain pass-through path where dispatch does NOT + // rebuild the Request). + const maxConnectAttempts = 3; + for (let attempt = 1; ; attempt++) { + if (clientReq.signal.aborted) throw new Error("client aborted before upstream connect"); + try { + upstreamResp = await dispatch(upstreamReq, upstreamHeaderPairs); + break; + } catch (err) { + if (attempt >= maxConnectAttempts) throw err; + const backoffMs = 500 * attempt; + if (debug) debugError(reqId, "upstream_connect_retry", `attempt ${attempt}/${maxConnectAttempts - 1} failed (${(err as Error).message}), retrying in ${backoffMs}ms`); + console.log(`${reqId} upstream connect failed (${(err as Error).message}), retry ${attempt + 1}/${maxConnectAttempts} in ${backoffMs}ms`); + await new Promise((r) => setTimeout(r, backoffMs)); + // Rebuild the Request so the body stream is fresh for the next + // dispatch (mirrors the captcha retry, which already constructs a + // new Request per attempt). + upstreamReq = buildUpstreamRequest(clientReq, upstreamFormat, provider, cred, transformedBody, config.identity, config.plan, captchaHeaders, clientSession); + } + } } catch (err) { if (debug) debugError(reqId, "upstream_unreachable", (err as Error).message); printRow(reqId, format, meta, 502, started, Date.now(), 0, 0, 0); @@ -257,7 +287,24 @@ export async function proxyRequest( // getCaptchaToken takes the next pre-solved one). `&& captcha` looks // redundant but is required for TS null-narrowing. const captcha = startPlan ? await loadCaptcha() : null; - const captchaChallenge = captcha ? captcha.detectCaptchaChallenge(upstreamResp) : null; + let captchaChallenge = captcha ? captcha.detectCaptchaChallenge(upstreamResp) : null; + // In-body challenge variant (observed 2026-08-29): the gateway sometimes + // returns the challenge as HTTP 400 with {"code":3007,...} in the JSON body + // instead of the captcha response header, so detectCaptchaChallenge never + // fires and the client just gets a raw 502. Peek a clone of the body (error + // responses are small; the original body stays untouched for the retry or + // the error path) and treat it as a challenge too. + if (!captchaChallenge && captcha && !upstreamResp.ok) { + const ctype = upstreamResp.headers.get("content-type") ?? ""; + if (!ctype.includes("text/event-stream")) { + try { + const peek = await upstreamResp.clone().text(); + if (peek.includes('"code":3007') || peek.includes('"code": 3007')) { + captchaChallenge = "in-body-3007"; + } + } catch {} + } + } if (captchaChallenge && captcha) { if (debug) debugLine(reqId, "captcha challenge — re-solving and retrying once"); try { upstreamResp.body?.cancel(); } catch {} diff --git a/src/proxy/responses-handler.ts b/src/proxy/responses-handler.ts index 778f348..190af28 100644 --- a/src/proxy/responses-handler.ts +++ b/src/proxy/responses-handler.ts @@ -144,13 +144,13 @@ export async function handleResponses( const providerDef = resolveProviderDef(opts.config); // ── 5. body transform (start-plan system / anthropic cache_control + user_id) ── + // Both plans post Anthropic upstream (mirrors handler.ts): the start-plan + // OpenAI gateway was retired server-side (404 as of 2026-08-28), so the + // Responses → Chat → Anthropic translator chain runs unconditionally. const startPlan = opts.config.plan === "start-plan"; - const upstreamFormat: "openai" | "anthropic" = startPlan ? "openai" : "anthropic"; + const upstreamFormat: "openai" | "anthropic" = "anthropic"; let upstreamRequestBody: string; - if (upstreamFormat === "anthropic") { - // v2.3: coding-plan upstream is Anthropic (mirrors the real client). - // Chain the existing translators: Responses → Chat → Anthropic request; - // Anthropic → Chat → Responses on the way back. + { let anthropicReq: AnthropicMessagesRequest; try { anthropicReq = translateRequestOpenAIToAnthropic(chatRequest); @@ -160,14 +160,8 @@ export async function handleResponses( upstreamRequestBody = transformRequestBody(JSON.stringify(anthropicReq), { format: "anthropic", userId: cred.userId, - startPlan: false, - }) ?? JSON.stringify(anthropicReq); - } else { - upstreamRequestBody = transformRequestBody(JSON.stringify(chatRequest), { - format: "openai", - userId: undefined, startPlan, - }) ?? JSON.stringify(chatRequest); + }) ?? JSON.stringify(anthropicReq); } const transformedBody = upstreamRequestBody; diff --git a/src/proxy/upstream.test.ts b/src/proxy/upstream.test.ts index adecc4a..d1201f6 100644 --- a/src/proxy/upstream.test.ts +++ b/src/proxy/upstream.test.ts @@ -1044,7 +1044,7 @@ describe("proxyRequest — Anthropic compatibility mode (coding-plan)", () => { expect(body.content[0]).toEqual({ type: "text", text: "Hi" }); }); - it("start-plan OpenAI request uses ZCode OpenAI-compatible gateway", async () => { + it("start-plan OpenAI request translates to the live Anthropic gateway (Bearer JWT)", async () => { const startPlanConfig: ProxyConfig = { ...testConfig, plan: "start-plan", @@ -1062,20 +1062,22 @@ describe("proxyRequest — Anthropic compatibility mode (coding-plan)", () => { try { const fetchMock = mock(async (req: Request): Promise => { - expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/chat/completions"); + expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages"); expect(req.headers.get("authorization")).toBe("Bearer jwt-mock"); - expect(req.headers.get("anthropic-version")).toBeNull(); + expect(req.headers.get("anthropic-version")).toBe("2023-06-01"); const reqBody = JSON.parse(await req.text()); - expect(reqBody.messages[0].role).toBe("system"); - expect(reqBody.messages[0].content).toBe("You are ZCode, an interactive coding agent"); - expect(reqBody.messages.at(-1)).toEqual({ role: "user", content: "hi" }); + expect(reqBody.system[0].text).toBe("You are ZCode, an interactive coding agent"); + expect(reqBody.messages).toEqual([{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }]); + expect(reqBody.max_tokens).toBe(4096); return new Response(JSON.stringify({ - id: "chatcmpl_sp", - object: "chat.completion", - created: 1, + id: "msg_sp", + type: "message", + role: "assistant", model: "glm-4.6", - choices: [{ index: 0, message: { role: "assistant", content: "start-plan reply" }, finish_reason: "stop" }], - usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + content: [{ type: "text", text: "start-plan reply" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 3 }, }), { status: 200, headers: { "content-type": "application/json" } }); }); @@ -1117,18 +1119,20 @@ describe("proxyRequest — Anthropic compatibility mode (coding-plan)", () => { try { const fetchMock = mock(async (req: Request): Promise => { - expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/chat/completions"); + expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages"); expect(req.headers.get("x-request-id")).toBe("req_start_1"); expect(req.headers.get("x-zcode-trace-id")).toBe("trace_start_1"); expect(req.headers.get("x-query-id")).toBe("start_query_1"); expect(req.headers.get("x-session-id")).toBe("start_session_1"); return new Response(JSON.stringify({ - id: "chatcmpl_sp", - object: "chat.completion", - created: 1, + id: "msg_sp", + type: "message", + role: "assistant", model: "glm-4.6", - choices: [{ index: 0, message: { role: "assistant", content: "start-plan reply" }, finish_reason: "stop" }], - usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + content: [{ type: "text", text: "start-plan reply" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 3 }, }), { status: 200, headers: { "content-type": "application/json" } }); }); @@ -1177,15 +1181,17 @@ describe("proxyRequest — Anthropic compatibility mode (coding-plan)", () => { try { const seenSessions: string[] = []; const fetchMock = mock(async (req: Request): Promise => { - expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/chat/completions"); + expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages"); seenSessions.push(req.headers.get("x-session-id") ?? ""); return new Response(JSON.stringify({ - id: "chatcmpl_sp", - object: "chat.completion", - created: 1, + id: "msg_sp", + type: "message", + role: "assistant", model: "glm-4.6", - choices: [{ index: 0, message: { role: "assistant", content: "start-plan reply" }, finish_reason: "stop" }], - usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + content: [{ type: "text", text: "start-plan reply" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 3 }, }), { status: 200, headers: { "content-type": "application/json" } }); }); @@ -1213,7 +1219,7 @@ describe("proxyRequest — Anthropic compatibility mode (coding-plan)", () => { } }); - it("start-plan Anthropic request translates through ZCode OpenAI-compatible gateway", async () => { + it("start-plan Anthropic request passes through to the live gateway with start-plan system blocks", async () => { const startPlanConfig: ProxyConfig = { ...testConfig, plan: "start-plan", @@ -1232,18 +1238,21 @@ describe("proxyRequest — Anthropic compatibility mode (coding-plan)", () => { try { const fetchMock: typeof fetch = Object.assign(async (req: RequestInfo | URL): Promise => { if (!(req instanceof Request)) throw new Error("expected Request"); - expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/chat/completions"); + expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages"); expect(req.headers.get("authorization")).toBe("Bearer jwt-mock"); const reqBody = JSON.parse(await req.text()); - expect(reqBody.messages[0].role).toBe("system"); - expect(reqBody.messages.at(-1)).toEqual({ role: "user", content: "hi" }); + expect(reqBody.system[0].text).toBe("You are ZCode, an interactive coding agent"); + expect(reqBody.messages).toEqual([{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }]); + expect(reqBody.max_tokens).toBe(1024); return new Response(JSON.stringify({ - id: "chatcmpl_sp", - object: "chat.completion", - created: 1, + id: "msg_sp", + type: "message", + role: "assistant", model: "glm-4.6", - choices: [{ index: 0, message: { role: "assistant", content: "start-plan reply" }, finish_reason: "stop" }], - usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + content: [{ type: "text", text: "start-plan reply" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 3 }, }), { status: 200, headers: { "content-type": "application/json" } }); }, { preconnect: originalFetch.preconnect }); @@ -1299,7 +1308,12 @@ describe("proxyRequest — Anthropic compatibility mode (coding-plan)", () => { const resp = await proxyRequest(clientReq, "openai", { config: startPlanConfig, auth, fetchImpl: fetchMock as any }); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(resp.status).toBe(403); + // Upstream errors are wrapped: openai→anthropic translation mode reports + // them as 502 translation_failed (same convention as coding-plan). + expect(resp.status).toBe(502); + const body = await resp.json(); + expect(body.error.type).toBe("translation_failed"); + expect(body.error.message).toContain("403"); } finally { globalThis.fetch = originalFetch; } @@ -1564,24 +1578,6 @@ describe("proxyRequest — thinking endpoint matrix", () => { }); } - function openAIThinkingResponse(): string { - return JSON.stringify({ - id: "chatcmpl_thinking", - object: "chat.completion", - created: 1, - model: "glm-4.6", - choices: [{ - index: 0, - message: { - role: "assistant", - reasoning_content: "First step. Second step.", - content: "Final answer.", - }, - finish_reason: "stop", - }], - usage: { prompt_tokens: 10, completion_tokens: 7, total_tokens: 17 }, - }); - } function anthropicThinkingSse(): string { return [ @@ -1621,22 +1617,6 @@ describe("proxyRequest — thinking endpoint matrix", () => { ].join("\n"); } - function openAIThinkingSse(): string { - return [ - 'data: {"id":"chatcmpl_thinking","object":"chat.completion.chunk","created":1,"model":"glm-4.6","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}', - '', - 'data: {"id":"chatcmpl_thinking","object":"chat.completion.chunk","created":1,"model":"glm-4.6","choices":[{"index":0,"delta":{"reasoning_content":"First step. "},"finish_reason":null}]}', - '', - 'data: {"id":"chatcmpl_thinking","object":"chat.completion.chunk","created":1,"model":"glm-4.6","choices":[{"index":0,"delta":{"reasoning_content":"Second step."},"finish_reason":null}]}', - '', - 'data: {"id":"chatcmpl_thinking","object":"chat.completion.chunk","created":1,"model":"glm-4.6","choices":[{"index":0,"delta":{"content":"Final answer."},"finish_reason":null}]}', - '', - 'data: {"id":"chatcmpl_thinking","object":"chat.completion.chunk","created":1,"model":"glm-4.6","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', - '', - 'data: [DONE]', - '', - ].join("\n"); - } function openAIReasoningDeltas(sse: string): string[] { return sse @@ -1753,11 +1733,11 @@ describe("proxyRequest — thinking endpoint matrix", () => { expect(text).toContain('"text":"Final answer."'); }); - it("start-plan OpenAI endpoint non-streaming passes through OpenAI upstream reasoning_content", async () => { + it("start-plan OpenAI endpoint non-streaming surfaces Anthropic upstream thinking as OpenAI reasoning_content", async () => { await withDisabledCaptcha(async () => { const fetchMock = mock(async (req: Request): Promise => { - expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/chat/completions"); - return new Response(openAIThinkingResponse(), { + expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages"); + return new Response(anthropicThinkingResponse(), { status: 200, headers: { "content-type": "application/json" }, }); @@ -1776,11 +1756,11 @@ describe("proxyRequest — thinking endpoint matrix", () => { }); }); - it("start-plan OpenAI endpoint streaming passes through OpenAI upstream reasoning_content deltas", async () => { + it("start-plan OpenAI endpoint streaming surfaces Anthropic upstream thinking deltas as reasoning_content", async () => { await withDisabledCaptcha(async () => { const fetchMock = mock(async (req: Request): Promise => { - expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/chat/completions"); - return new Response(openAIThinkingSse(), { + expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages"); + return new Response(anthropicThinkingSse(), { status: 200, headers: { "content-type": "text/event-stream" }, }); @@ -1797,11 +1777,11 @@ describe("proxyRequest — thinking endpoint matrix", () => { }); }); - it("start-plan Anthropic endpoint non-streaming translates OpenAI upstream reasoning_content into thinking blocks", async () => { + it("start-plan Anthropic endpoint passes Anthropic upstream thinking through natively (non-streaming)", async () => { await withDisabledCaptcha(async () => { const fetchMock = mock(async (req: Request): Promise => { - expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/chat/completions"); - return new Response(openAIThinkingResponse(), { + expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages"); + return new Response(anthropicThinkingResponse(), { status: 200, headers: { "content-type": "application/json" }, }); @@ -1815,16 +1795,16 @@ describe("proxyRequest — thinking endpoint matrix", () => { expect(resp.status).toBe(200); const body = await resp.json(); - expect(body.content[0]).toEqual({ type: "thinking", thinking: "First step. Second step." }); + expect(body.content[0]).toEqual({ type: "thinking", thinking: "First step. Second step.", signature: "sig_real" }); expect(body.content[1]).toEqual({ type: "text", text: "Final answer." }); }); }); - it("start-plan Anthropic endpoint streaming translates OpenAI upstream reasoning_content into one thinking block", async () => { + it("start-plan Anthropic endpoint passes Anthropic upstream thinking through natively (streaming)", async () => { await withDisabledCaptcha(async () => { const fetchMock = mock(async (req: Request): Promise => { - expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/chat/completions"); - return new Response(openAIThinkingSse(), { + expect(req.url).toBe("https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages"); + return new Response(anthropicThinkingSse(), { status: 200, headers: { "content-type": "text/event-stream" }, }); diff --git a/src/proxy/upstream.ts b/src/proxy/upstream.ts index 8198d6d..173a402 100644 --- a/src/proxy/upstream.ts +++ b/src/proxy/upstream.ts @@ -32,7 +32,7 @@ export type UpstreamHeaderPair = [string, string]; const ANTHROPIC_VERSION = "2023-06-01"; -const STARTPLAN_OPENAI_BASE = "https://zcode.z.ai/api/v1/zcode-plan"; +const STARTPLAN_ANTHROPIC_BASE = "https://zcode.z.ai/api/v1/zcode-plan"; const STRIP_HEADERS = new Set([ "host", @@ -70,7 +70,12 @@ const STRIP_HEADERS = new Set([ */ export function buildUpstreamURL(format: Format, provider: ProviderDef, plan: "coding-plan" | "start-plan" = "coding-plan"): string { if (plan === "start-plan") { - return `${STARTPLAN_OPENAI_BASE}/chat/completions`; + // Anthropic-messages gateway used by the live ZCode desktop client for + // start-plan (incl. claimed trial plans like the weekend package). + // The legacy OpenAI route `${STARTPLAN_ANTHROPIC_BASE}/chat/completions` + // returns "404 page not found" since the server-side rollout of this + // endpoint (observed 2026-08-28). + return `${STARTPLAN_ANTHROPIC_BASE}/anthropic/v1/messages`; } if (format === "anthropic") { return `${provider.anthropicBaseURL}/v1/messages`;