diff --git a/src/proxy/responses-handler.test.ts b/src/proxy/responses-handler.test.ts index f08a025..67581c1 100644 --- a/src/proxy/responses-handler.test.ts +++ b/src/proxy/responses-handler.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from "bun:test"; import { handleResponses } from "./responses-handler.js"; import { ResponseStore } from "../responses/store.js"; import type { ProxyConfig } from "../config/types.js"; +import type * as CaptchaExports from "./captcha.js"; + +type CaptchaModule = typeof CaptchaExports; const CONFIG: ProxyConfig = { server: { port: 0, host: "127.0.0.1" }, @@ -156,3 +159,120 @@ describe("handleResponses", () => { expect(continuationUpstreamBody).toContain("second turn"); }); }); + +// /v1/responses used to skip captcha entirely (it passed `undefined` where +// handler.ts passes captcha headers), so every start-plan request came back as +// HTTP 400 {"code":3007,"msg":"captcha verify failed"} while the OpenAI and +// Anthropic routes worked. +describe("handleResponses captcha (start-plan)", () => { + const START_PLAN: ProxyConfig = { ...CONFIG, plan: "start-plan", provider: "bigmodel" }; + const PARAM_HEADER = "x-aliyun-captcha-verify-param"; + const REGION_HEADER = "x-aliyun-captcha-verify-region"; + + /** Fake captcha module: mints sequential tokens, records how many were asked for. */ + function fakeCaptcha(): { module: CaptchaModule; minted: () => number } { + let n = 0; + const module = { + RETRY_HEADERS: { PARAM: PARAM_HEADER, REGION: REGION_HEADER }, + getCaptchaToken: async () => { + n += 1; + return { verifyParam: `token-${n}`, region: "cn" }; + }, + detectCaptchaChallenge: (resp: Response) => resp.headers.get(PARAM_HEADER), + } as unknown as CaptchaModule; + return { module, minted: () => n }; + } + + it("sends a captcha token upstream on start-plan", async () => { + const seen: (string | null)[] = []; + const fetchImpl = (async (request: Request): Promise => { + seen.push(request.headers.get(PARAM_HEADER)); + return new Response(anthropicMsg("ok"), { status: 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch; + + const captcha = fakeCaptcha(); + const resp = await handleResponses(makeReq({ model: "glm-5.2", input: "hi" }), { + config: START_PLAN, auth, fetchImpl, captcha: captcha.module, + }); + expect(resp.status).toBe(200); + expect(seen).toEqual(["token-1"]); + expect(captcha.minted()).toBe(1); + }); + + it("retries once with a fresh token when upstream answers in-body 3007", async () => { + const seen: (string | null)[] = []; + const fetchImpl = (async (request: Request): Promise => { + seen.push(request.headers.get(PARAM_HEADER)); + if (seen.length === 1) { + return new Response(JSON.stringify({ code: 3007, msg: "captcha verify failed" }), { + status: 400, headers: { "content-type": "application/json" }, + }); + } + return new Response(anthropicMsg("recovered"), { status: 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch; + + const captcha = fakeCaptcha(); + const resp = await handleResponses(makeReq({ model: "glm-5.2", input: "hi" }), { + config: START_PLAN, auth, fetchImpl, captcha: captcha.module, + }); + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.output[0].content[0].text).toBe("recovered"); + // The challenged token is already spent, so the retry must carry a NEW one. + expect(seen).toEqual(["token-1", "token-2"]); + }); + + it("retries when the challenge arrives as a response header", async () => { + const seen: (string | null)[] = []; + const fetchImpl = (async (request: Request): Promise => { + seen.push(request.headers.get(PARAM_HEADER)); + if (seen.length === 1) { + return new Response("denied", { status: 403, headers: { [PARAM_HEADER]: "challenge-abc" } }); + } + return new Response(anthropicMsg("ok"), { status: 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch; + + const captcha = fakeCaptcha(); + const resp = await handleResponses(makeReq({ model: "glm-5.2", input: "hi" }), { + config: START_PLAN, auth, fetchImpl, captcha: captcha.module, + }); + expect(resp.status).toBe(200); + expect(seen).toEqual(["token-1", "token-2"]); + }); + + it("gives up after one retry instead of looping", async () => { + let calls = 0; + const fetchImpl = (async (): Promise => { + calls += 1; + return new Response(JSON.stringify({ code: 3007, msg: "captcha verify failed" }), { + status: 400, headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const captcha = fakeCaptcha(); + const resp = await handleResponses(makeReq({ model: "glm-5.2", input: "hi" }), { + config: START_PLAN, auth, fetchImpl, captcha: captcha.module, + }); + expect(calls).toBe(2); + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error.type).toBe("upstream_error"); + expect(body.error.message).toContain("3007"); + }); + + it("does not touch captcha on coding-plan", async () => { + const seen: (string | null)[] = []; + const fetchImpl = (async (request: Request): Promise => { + seen.push(request.headers.get(PARAM_HEADER)); + return new Response(anthropicMsg("ok"), { status: 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch; + + const captcha = fakeCaptcha(); + const resp = await handleResponses(makeReq({ model: "glm-5.2", input: "hi" }), { + config: CONFIG, auth, fetchImpl, captcha: captcha.module, + }); + expect(resp.status).toBe(200); + expect(seen).toEqual([null]); + expect(captcha.minted()).toBe(0); + }); +}); diff --git a/src/proxy/responses-handler.ts b/src/proxy/responses-handler.ts index 190af28..83ba8da 100644 --- a/src/proxy/responses-handler.ts +++ b/src/proxy/responses-handler.ts @@ -21,7 +21,18 @@ import { transformRequestBody } from "./body-transformer.js"; import { getProvider } from "../provider/providers.js"; import type { ProxyConfig } from "../config/types.js"; import type { AuthManager } from "../auth/manager.js"; -import { buildUpstreamRequest, buildUpstreamHeaderPairs } from "./upstream.js"; +import { buildUpstreamRequest, buildUpstreamHeaderPairs, type UpstreamHeaderPair } from "./upstream.js"; +import type * as CaptchaExports from "./captcha.js"; + +// Lazy, runtime-gated module load (exception to the static-import rule, same +// as handler.ts): pulling captcha.ts eagerly drags in the happy-dom solver, so +// only start-plan — the one plan whose upstream is captcha-gated — pays for it. +type CaptchaModule = typeof CaptchaExports; +let captchaModule: CaptchaModule | null = null; +async function loadCaptcha(): Promise { + if (!captchaModule) captchaModule = await import("./captcha.js"); + return captchaModule; +} import { getDefaultEndpointRouting, type EndpointRoutingService } from "./endpoint-routing.js"; import { getDefaultClientSigning, sendWithClientSigning, type ClientSigningManager } from "./client-signing.js"; import { credentialString } from "../auth/types.js"; @@ -64,6 +75,8 @@ export interface ResponsesHandlerOptions { endpointRouting?: EndpointRoutingService | null; /** Override the process-wide client signing manager (for testing). `null` disables. */ clientSigning?: ClientSigningManager | null; + /** Override the lazily-imported captcha module (for testing). */ + captcha?: CaptchaModule; } /** Handle POST /v1/responses. */ @@ -166,22 +179,35 @@ export async function handleResponses( const transformedBody = upstreamRequestBody; // ── 6. POST upstream ── - const upstreamHeaders = buildUpstreamHeaderPairs(clientReq, upstreamFormat, cred, opts.config.identity, opts.config.plan, undefined, undefined); - const upstreamReq = buildUpstreamRequest(clientReq, upstreamFormat, providerDef, cred, transformedBody, opts.config.identity, opts.config.plan, undefined, undefined); + // start-plan gates every upstream call behind an Aliyun captcha token. The + // Anthropic/OpenAI routes mint one in handler.ts; /v1/responses did not, so + // start-plan users got {"code":3007,"msg":"captcha verify failed"} surfaced + // as HTTP 400 upstream_error on every request. + let captchaHeaders: Record | undefined; + if (startPlan) { + try { + const captcha = opts.captcha ?? (await loadCaptcha()); + const token = await captcha.getCaptchaToken(opts.config.identity.appVersion); + captchaHeaders = { [captcha.RETRY_HEADERS.PARAM]: token.verifyParam, [captcha.RETRY_HEADERS.REGION]: token.region }; + } catch { + // Fall through: the 3007 retry below solves on demand. + } + } + const upstreamHeaders = buildUpstreamHeaderPairs(clientReq, upstreamFormat, cred, opts.config.identity, opts.config.plan, captchaHeaders, undefined); + const upstreamReq = buildUpstreamRequest(clientReq, upstreamFormat, providerDef, cred, transformedBody, opts.config.identity, opts.config.plan, captchaHeaders, undefined); if (debug) console.log(`[responses] → POST ${upstreamReq.url}`); - let upstreamResp: Response; - try { - const routing = opts.endpointRouting !== undefined ? opts.endpointRouting : getDefaultEndpointRouting(opts.config); + const routing = opts.endpointRouting !== undefined ? opts.endpointRouting : getDefaultEndpointRouting(opts.config); + const signer = opts.clientSigning !== undefined ? opts.clientSigning : getDefaultClientSigning(opts.config); + const dispatch = async (pairs: UpstreamHeaderPair[]): Promise => { const routed = routing ? await routing.resolve(upstreamReq.url, credentialString(cred)) : null; const sendUrl = routed?.routed ? routed.url : upstreamReq.url; if (debug && routed?.routed) console.log(`[responses] endpoint routing: ${upstreamReq.url} -> ${sendUrl}`); - const signer = opts.clientSigning !== undefined ? opts.clientSigning : getDefaultClientSigning(opts.config); // signing decisions run against the PRE-routing provider URL (mirrors the // client, whose signer wraps the routing transport) - upstreamResp = await sendWithClientSigning(signer, { + return sendWithClientSigning(signer, { url: upstreamReq.url, - headerPairs: upstreamHeaders, + headerPairs: pairs, credential: credentialString(cred), appVersion: opts.config.identity.appVersion, debug: debug ? (message) => console.log(`[responses] ${message}`) : undefined, @@ -194,10 +220,44 @@ export async function handleResponses( return fetchImpl(req, { method: "POST", headers: Object.fromEntries(finalPairs), body: transformedBody ?? undefined, signal: clientReq.signal }); }, }); + }; + + let upstreamResp: Response; + try { + upstreamResp = await dispatch(upstreamHeaders); } catch (err) { return errorResponse(502, "upstream_unreachable", (err as Error).message); } + // Captcha challenge retry (mirrors handler.ts): the gateway signals it either + // through the captcha response header or as HTTP 400 with {"code":3007} in + // the body. The challenged token is already spent, so retry once with a fresh + // pooled one. + if (startPlan && !upstreamResp.ok) { + const captcha = opts.captcha ?? (await loadCaptcha()); + let challenged = captcha.detectCaptchaChallenge(upstreamResp) !== null; + if (!challenged && !upstreamResp.headers.get("content-type")?.includes("text/event-stream")) { + const peek = await upstreamResp.clone().text().catch(() => ""); + challenged = peek.includes('"code":3007') || peek.includes('"code": 3007'); + } + if (challenged) { + if (debug) console.log("[responses] captcha challenge — re-solving and retrying once"); + try { await upstreamResp.body?.cancel(); } catch { /* already drained */ } + try { + const fresh = await captcha.getCaptchaToken(opts.config.identity.appVersion); + const retryHeaders = { + [captcha.RETRY_HEADERS.PARAM]: fresh.verifyParam, + [captcha.RETRY_HEADERS.REGION]: fresh.region, + }; + upstreamResp = await dispatch( + buildUpstreamHeaderPairs(clientReq, upstreamFormat, cred, opts.config.identity, opts.config.plan, retryHeaders, undefined), + ); + } catch (err) { + return errorResponse(503, "captcha_solver_failed", (err as Error).message); + } + } + } + if (!upstreamResp.ok) { const errText = await upstreamResp.text().catch(() => ""); return errorResponse(upstreamResp.status, "upstream_error", errText.slice(0, 500) || `upstream returned ${upstreamResp.status}`);