Repository navigation
fix(start-plan): route to the live Anthropic gateway #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fa336dd
d7f549a
1646a4c
0b0a4e0
8b273aa
673b498
98590af
ad4af31
15d756e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Response> => { | ||
| 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<Response> => { | ||
| 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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Comment on lines
+245
to
+246
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an explicit or enforced session selects Useful? React with 👍 / 👎. |
||
| 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 {} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the normal unsigned and unrouted path—particularly every start-plan request—
dispatchpasses the sameupstreamReqobject tofetchon every attempt. Bun marks that request's body as used even whenfetchrejects withUnable to connect, so attempts 2 and 3 fail immediately withRequest body already usedinstead of reconnecting, after unnecessarily waiting through both backoffs. Construct a freshRequestfromtransformedBodyfor each attempt, as the captcha retry already does.Useful? React with 👍 / 👎.