Skip to content
Merged
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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
│
▼
Expand Down
3 changes: 3 additions & 0 deletions src/provider/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
];
14 changes: 9 additions & 5 deletions src/provider/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
});

Expand All @@ -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", () => {
Expand Down
9 changes: 8 additions & 1 deletion src/proxy/captcha-happy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
154 changes: 154 additions & 0 deletions src/proxy/handler-resilience.test.ts
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");
});
});
63 changes: 55 additions & 8 deletions src/proxy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) : "-";
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebuild the Request before each fetch retry

For the normal unsigned and unrouted path—particularly every start-plan request—dispatch passes the same upstreamReq object to fetch on every attempt. Bun marks that request's body as used even when fetch rejects with Unable to connect, so attempts 2 and 3 fail immediately with Request body already used instead of reconnecting, after unnecessarily waiting through both backoffs. Construct a fresh Request from transformedBody for each attempt, as the captcha retry already does.

Useful? React with 👍 / 👎.

break;
} catch (err) {
if (attempt >= maxConnectAttempts) throw err;
Comment on lines +245 to +246

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry only failures known to precede request writes

When an explicit or enforced session selects sendOrderedUpstreamRequest, dispatch can reject because the socket closed before response headers even though ordered-transport.ts has already written the complete request body; this unconditional catch then submits the completion again, potentially consuming quota up to three times. It also retries client AbortErrors and delays cancellation by the backoffs. Restrict retries to failures positively identified as occurring before the request was written and stop immediately when clientReq.signal is aborted.

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);
Expand Down Expand Up @@ -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 {}
Expand Down
18 changes: 6 additions & 12 deletions src/proxy/responses-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;

Expand Down
Loading