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
120 changes: 120 additions & 0 deletions src/proxy/responses-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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<Response> => {
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<Response> => {
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<Response> => {
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<Response> => {
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<Response> => {
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);
});
});
78 changes: 69 additions & 9 deletions src/proxy/responses-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CaptchaModule> {
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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, string> | 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<Response> => {
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,
Expand All @@ -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}`);
Expand Down