diff --git a/src/preflight.ts b/src/preflight.ts index b901f4f..2a7c4e7 100644 --- a/src/preflight.ts +++ b/src/preflight.ts @@ -216,7 +216,7 @@ function splitChunks( return chunks; } -function summaryPayload(protocol: PreflightProtocol, model: string, system: string, content: string, stream: boolean): Record { +function summaryPayload(protocol: PreflightProtocol, model: string, system: string, content: string, stream: boolean, includeMaxOutputTokens: boolean): Record { if (protocol === "anthropic") { return { model, max_tokens: MAX_SUMMARY_OUTPUT_TOKENS, system, messages: [{ role: "user", content }], stream }; } @@ -224,7 +224,12 @@ function summaryPayload(protocol: PreflightProtocol, model: string, system: stri return { model, max_tokens: MAX_SUMMARY_OUTPUT_TOKENS, messages: [{ role: "system", content: system }, { role: "user", content }], stream }; } // #488: codex relays reject Responses calls without store:false ("Store must be set to false"). - return { model, max_output_tokens: MAX_SUMMARY_OUTPUT_TOKENS, instructions: system, input: [{ role: "user", content }], stream, store: false }; + // #663: max_output_tokens is optional — omit it once the upstream has + // rejected the parameter (learned per URL+model); the model's default + // output cap then applies. + const payload: Record = { model, instructions: system, input: [{ role: "user", content }], stream, store: false }; + if (includeMaxOutputTokens) payload.max_output_tokens = MAX_SUMMARY_OUTPUT_TOKENS; + return payload; } // #626: some upstreams (ChatGPT-login codex backend) reject non-stream calls @@ -233,6 +238,54 @@ function summaryPayload(protocol: PreflightProtocol, model: string, system: stri // mentioning neither word never triggers a pointless stream retry. const STREAM_REQUIRED_RE = /\bstream\b[^\n]{0,60}\btrue\b/i; +// #663: the same ChatGPT-login codex backend rejects the Responses +// max_output_tokens parameter outright with 400 {"detail":"Unsupported +// parameter: max_output_tokens"}. Matching the parameter name in a 400 body +// is narrow enough — a 400 that names the parameter is about the parameter — +// and robust to phrasing variants; omitting an optional parameter is always +// a safe fallback (the model's default output cap applies). +const MAX_OUTPUT_TOKENS_REJECTED_RE = /\bmax_output_tokens\b/i; + +// #663: per-endpoint learning of the max_output_tokens rejection. Keyed by +// upstream URL + model (persisted with the session metadata, like #626's +// stream flag) because the rejection is per-endpoint: a session can switch +// models mid-conversation, and a model that accepts the limit must keep the +// 8192 cap. +function noMaxOutputTokensKey(deps: PreflightDeps): string { + return `${deps.url}\u0000${deps.model}`; +} + +function hasLearnedNoMaxOutputTokens(deps: PreflightDeps): boolean { + const learned = deps.session.metadata.preflightNoMaxOutputTokens; + return typeof learned === "object" && learned !== null && (learned as Record)[noMaxOutputTokensKey(deps)] === true; +} + +function rememberNoMaxOutputTokens(deps: PreflightDeps): void { + const learned = deps.session.metadata.preflightNoMaxOutputTokens; + const map = (typeof learned === "object" && learned !== null ? learned : {}) as Record; + map[noMaxOutputTokensKey(deps)] = true; + deps.session.metadata.preflightNoMaxOutputTokens = map; +} + +// #663: request-shape-specific headers that must NOT ride the independently +// constructed summary call. The main Codex request carries +// x-openai-internal-codex-responses-lite, which the backend only accepts when +// the body has reasoning.context: all_turns. The summary body is built +// independently (no reasoning field), so carrying the header over makes the +// backend reject it (400 "…requires `reasoning.context` to be `all_turns`"). +// The original model request keeps the header and its reasoning fields; only +// the side summary call drops it. Auth/routing headers are preserved. +const SUMMARY_STRIP_HEADERS = new Set(["x-openai-internal-codex-responses-lite"]); + +function summaryHeaders(deps: PreflightDeps): Record { + const headers: Record = {}; + for (const [k, v] of Object.entries(deps.headers)) { + if (SUMMARY_STRIP_HEADERS.has(k.toLowerCase())) continue; + headers[k] = v; + } + return headers; +} + // Extract the summary text from a buffered SSE body (the streaming twin of // extractSummaryText). For Responses, prefer the response.completed event's // full response object (reuses the JSON extractor); otherwise accumulate @@ -312,27 +365,44 @@ async function summarizeRange(deps: PreflightDeps, content: string, startRef: st `\n\nTASK: The conversation segment below (messages ${startRef}–${endRef}) must be compressed because the session context exceeds the current model's window. Write a tier-1 compression summary of the segment following every rule above. Output ONLY the summary text — no preamble, no closing remarks, no tool calls.`; // #626: the session remembers upstreams that require stream:true, so the // extra 400 round-trip is paid at most once per session (persisted with - // the session metadata). - const learned = deps.session.metadata.preflightStreamSummary === true; - try { - return await requestSummary(deps, system, content, learned); - } catch (err) { - if (err instanceof UpstreamHttpError && err.status === 400 && !learned && STREAM_REQUIRED_RE.test(err.body)) { - deps.session.metadata.preflightStreamSummary = true; - deps.log("info", "[preflight] upstream requires stream for summaries; retrying with SSE (learned for this session)"); - return await requestSummary(deps, system, content, true); + // the session metadata). #663: likewise, per URL+model, upstreams that + // reject the max_output_tokens parameter. Each capability is learned at + // most once (guarded below), so the compatibility retries are bounded: + // at most one extra attempt per capability, in either rejection order. + let stream = deps.session.metadata.preflightStreamSummary === true; + let includeMaxOutputTokens = !(deps.protocol === "responses" && hasLearnedNoMaxOutputTokens(deps)); + for (;;) { + try { + return await requestSummary(deps, system, content, stream, includeMaxOutputTokens); + } catch (err) { + if (err instanceof UpstreamHttpError && err.status === 400) { + let adapted = false; + if (!stream && STREAM_REQUIRED_RE.test(err.body)) { + deps.session.metadata.preflightStreamSummary = true; + stream = true; + adapted = true; + deps.log("info", "[preflight] upstream requires stream for summaries; retrying with SSE (learned for this session)"); + } + if (deps.protocol === "responses" && includeMaxOutputTokens && MAX_OUTPUT_TOKENS_REJECTED_RE.test(err.body)) { + rememberNoMaxOutputTokens(deps); + includeMaxOutputTokens = false; + adapted = true; + deps.log("info", `[preflight] upstream rejects max_output_tokens for summaries (model=${deps.model}); retrying without it (learned for this session+upstream+model)`); + } + if (adapted) continue; + } + throw err; } - throw err; } } -async function requestSummary(deps: PreflightDeps, system: string, content: string, stream: boolean): Promise { +async function requestSummary(deps: PreflightDeps, system: string, content: string, stream: boolean, includeMaxOutputTokens: boolean): Promise { const { response, clearTimer } = await fetchWithRetry( deps.url, { method: "POST", - headers: { "content-type": "application/json", ...deps.headers }, - body: JSON.stringify(summaryPayload(deps.protocol, deps.model, system, content, stream)), + headers: { "content-type": "application/json", ...summaryHeaders(deps) }, + body: JSON.stringify(summaryPayload(deps.protocol, deps.model, system, content, stream, includeMaxOutputTokens)), dispatcher: proxyDispatcher(deps.proxyUrl), }, undefined, diff --git a/tests/preflight-max-output-tokens.test.ts b/tests/preflight-max-output-tokens.test.ts new file mode 100644 index 0000000..dfee33b --- /dev/null +++ b/tests/preflight-max-output-tokens.test.ts @@ -0,0 +1,540 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import { once } from "node:events"; +import test from "node:test"; + +process.env.NODE_ENV = "test"; +// Fail fast on 4xx retries so the #663 learn path exercises immediately +// instead of burning the default replay attempts. +process.env.BILI_REPLAY_RETRY_MAX = "1"; + +import { defaultConfig } from "acp-kernel"; +import { startServer, type ProxyOptions } from "../src/server.ts"; +import { SessionStore, _setStoreForTest } from "../src/persist.ts"; +import { _setForTest as setRegistryForTest } from "../src/registry.ts"; +import { listSessions } from "../src/session.ts"; + +// #663 regression: the ChatGPT-login codex backend rejects the Responses +// max_output_tokens parameter outright (400 {"detail":"Unsupported parameter: +// max_output_tokens"}), which killed preflight summaries with a 502 after the +// #626 stream fix. The proxy must detect the rejection, retry the +// summarization without the optional parameter, and remember it per +// session+upstream+model — so a model that accepts the limit keeps the 8192 +// cap. Both rejection orders (stream-first, max_output_tokens-first) must +// recover, and standard providers must be untouched. + +const SUMMARY_TEXT = + "MAX-TOKENS SUMMARY: the segment held a deterministic load-growth payload across a dozen turns; every raw marker is derivable from the seed and none carries unique state, so the folded view loses nothing of value for continued work."; + +type Call = { stream: boolean; summary: boolean; maxOutputTokens?: number; model?: string }; + +function sse(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +function summarySse(res: http.ServerResponse): void { + for (const part of [SUMMARY_TEXT.slice(0, 40), SUMMARY_TEXT.slice(40)]) { + res.write(sse("response.output_text.delta", { type: "response.output_text.delta", delta: part })); + } + res.write(sse("response.completed", { type: "response.completed", response: { id: "resp_sum", status: "completed", output: [], usage: { input_tokens: 100, output_tokens: 5 } } })); + res.end(); +} + +function forwardSse(res: http.ServerResponse): void { + res.write(sse("response.completed", { + type: "response.completed", + response: { + id: "resp_fwd", + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }], + usage: { input_tokens: 800, output_tokens: 4 }, + }, + })); + res.end(); +} + +// A real upstream answers a non-stream call with plain JSON and a stream call +// with SSE — the proxy extracts the summary differently per shape, so the +// mock must honor the request's stream flag or non-stream summaries come back +// empty. +function respondSummary(res: http.ServerResponse, stream: boolean): void { + if (stream) { + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + summarySse(res); + } else { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ id: "resp_sum", status: "completed", output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: SUMMARY_TEXT }] }] })); + } +} + +// Order A — mirrors the real ChatGPT codex backend: the stream check fires +// before the parameter check. Non-stream → 400 stream; stream + max_output_tokens +// → 400 unsupported parameter; stream without it → 200. +function makeStreamFirstUpstream(calls: Call[]): http.Server { + return makePickyUpstream(calls, (p) => { + if (p.stream !== true) return { detail: "Stream must be set to true" }; + if (p.max_output_tokens !== undefined) return { detail: "Unsupported parameter: max_output_tokens" }; + return null; + }); +} + +// Order B — a hypothetical backend that validates parameters before the +// stream flag: any request carrying max_output_tokens → 400 unsupported +// parameter; then non-stream → 400 stream; then 200. +function makeParamFirstUpstream(calls: Call[]): http.Server { + return makePickyUpstream(calls, (p) => { + if (p.max_output_tokens !== undefined) return { detail: "Unsupported parameter: max_output_tokens" }; + if (p.stream !== true) return { detail: "Stream must be set to true" }; + return null; + }); +} + +function makePickyUpstream(calls: Call[], reject: (p: ParsedBody) => { detail: string } | null): http.Server { + return http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + const parsed = parseBody(raw); + calls.push({ stream: parsed.stream === true, summary: isSummaryCall(parsed), maxOutputTokens: parsed.max_output_tokens, model: parsed.model }); + const rejection = reject(parsed); + if (rejection) { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify(rejection)); + return; + } + if (isSummaryCall(parsed)) respondSummary(res, parsed.stream === true); + else forwardSse(res); + }); + }); +} + +type ParsedBody = { stream?: boolean; instructions?: unknown; input?: unknown; max_output_tokens?: number; model?: string }; + +function parseBody(raw: string): ParsedBody { + try { + return JSON.parse(raw) as ParsedBody; + } catch { + return {}; + } +} + +function isSummaryCall(parsed: ParsedBody): boolean { + return typeof parsed.instructions === "string" && Array.isArray(parsed.input) && parsed.input.length === 1; +} + +function longResponsesInput(count: number) { + const input: { type: string; role: string; content: string }[] = []; + for (let i = 0; i < count; i++) { + input.push({ type: "message", role: i % 2 === 0 ? "user" : "assistant", content: `Message ${i} of the long conversation. ` + `MARKER_${i}_content_`.repeat(250) }); + } + return input; +} + +function startProxy(upstreamPort: number, models: Record): Promise { + _setStoreForTest(new SessionStore({ enabled: false })); + setRegistryForTest({}); + return startServer({ + port: 0, + host: "127.0.0.1", + upstream: "http://127.0.0.1", + routes: { [`http://127.0.0.1:${upstreamPort}`]: { models } }, + modelContextLimit: 10_000, + kernelConfig: defaultConfig(10_000), + compress: { injectTool: true, injectNudge: true }, + promptCache: { routing: "auto" }, + sessionHeader: "x-acp-session", + log: false, + debug: false, + passthrough: false, + autoUpdate: false, + mitm: { enabled: false, domains: [] }, + } as ProxyOptions); +} + +async function driveResponsesPreflight(proxyPort: number, upstreamPort: number, session: string, model: string, input: unknown, extraHeaders: Record = {}): Promise { + return await fetch(`http://127.0.0.1:${proxyPort}/bili/http://127.0.0.1:${upstreamPort}/responses`, { + method: "POST", + headers: { "content-type": "application/json", "x-acp-session": session, ...extraHeaders }, + body: JSON.stringify({ model, stream: true, input }), + }); +} + +function learnKey(url: string, model: string): string { + return `${url}\u0000${model}`; +} + +test("e2e #663 (order A, stream-first): learn both rejections, summary recovers, fold + forward OK, second request first-shot compatible", async () => { + const calls: Call[] = []; + const upstream = makeStreamFirstUpstream(calls); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const upstreamPort = (upstream.address() as { port: number }).port; + + const proxy = await startProxy(upstreamPort, { "gpt-6-astra": { context: 10_000 } }); + await once(proxy, "listening"); + const proxyPort = (proxy.address() as { port: number }).port; + const upstreamUrl = `http://127.0.0.1:${upstreamPort}/responses`; + + try { + const r = await driveResponsesPreflight(proxyPort, upstreamPort, "s663-resp-a", "gpt-6-astra", longResponsesInput(12)); + assert.equal(r.status, 200, `first request must succeed, got ${r.status}`); + + const summaries = calls.filter((c) => c.summary); + // Learning sequence: non-stream + max (400 stream) → stream + max + // (400 unsupported parameter) → stream, no max (200). The payload + // needs multiple folds to fit, so further summaries follow — every + // one after the two rejections must already be first-shot compatible. + assert.ok(summaries.length >= 3, `expected at least 3 summary attempts, got ${JSON.stringify(summaries)}`); + assert.deepEqual( + summaries.slice(0, 3).map((c) => [c.stream, c.maxOutputTokens !== undefined]), + [ + [false, true], + [true, true], + [true, false], + ], + `unexpected learning sequence: ${JSON.stringify(summaries)}`, + ); + assert.ok( + summaries.slice(2).every((c) => c.stream && c.maxOutputTokens === undefined), + `every summary after the two rejections must be first-shot compatible, got ${JSON.stringify(summaries)}`, + ); + assert.ok(calls.some((c) => !c.summary), "the folded payload was forwarded"); + + const sess = listSessions().find((s) => s.id.includes("s663-resp-a")); + assert.ok(sess, "session recorded"); + assert.equal(sess?.metadata?.preflightStreamSummary, true, "stream preference learned on the session"); + const learned = sess?.metadata?.preflightNoMaxOutputTokens as Record | undefined; + assert.ok(learned && typeof learned === "object", "max_output_tokens rejection learned on the session"); + assert.equal(learned?.[learnKey(upstreamUrl, "gpt-6-astra")], true, "learning scoped to upstream URL + model"); + + // Second request (grown over-window again): every summary call is + // first-shot compatible — stream, no max_output_tokens. + const callsBefore = calls.length; + const r2 = await driveResponsesPreflight(proxyPort, upstreamPort, "s663-resp-a", "gpt-6-astra", longResponsesInput(24)); + assert.equal(r2.status, 200, `second request must succeed, got ${r2.status}`); + const newSummaries = calls.slice(callsBefore).filter((c) => c.summary); + assert.ok(newSummaries.length >= 1, "second request triggered preflight summaries"); + assert.ok( + newSummaries.every((c) => c.stream && c.maxOutputTokens === undefined), + `second-request summaries must be first-shot compatible, got ${JSON.stringify(newSummaries)}`, + ); + } finally { + proxy.close(); + upstream.close(); + await new Promise((resolve, reject) => { + void Promise.allSettled([once(proxy, "close"), once(upstream, "close")]).then(() => resolve(), reject); + }); + } +}); + +test("e2e #663 (order B, param-first): max_output_tokens rejected before stream — both learned, recovery OK", async () => { + const calls: Call[] = []; + const upstream = makeParamFirstUpstream(calls); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const upstreamPort = (upstream.address() as { port: number }).port; + + const proxy = await startProxy(upstreamPort, { "gpt-6-astra": { context: 10_000 } }); + await once(proxy, "listening"); + const proxyPort = (proxy.address() as { port: number }).port; + + try { + const r = await driveResponsesPreflight(proxyPort, upstreamPort, "s663-resp-b", "gpt-6-astra", longResponsesInput(12)); + assert.equal(r.status, 200, `request must succeed, got ${r.status}`); + + const summaries = calls.filter((c) => c.summary); + // Learning sequence: non-stream + max (400 unsupported parameter) → + // non-stream, no max (400 stream) → stream, no max (200). Further + // folds follow (see order A) — all first-shot compatible. + assert.ok(summaries.length >= 3, `expected at least 3 summary attempts, got ${JSON.stringify(summaries)}`); + assert.deepEqual( + summaries.slice(0, 3).map((c) => [c.stream, c.maxOutputTokens !== undefined]), + [ + [false, true], + [false, false], + [true, false], + ], + `unexpected learning sequence: ${JSON.stringify(summaries)}`, + ); + assert.ok( + summaries.slice(2).every((c) => c.stream && c.maxOutputTokens === undefined), + `every summary after the two rejections must be first-shot compatible, got ${JSON.stringify(summaries)}`, + ); + assert.ok(calls.some((c) => !c.summary), "the folded payload was forwarded"); + } finally { + proxy.close(); + upstream.close(); + await new Promise((resolve, reject) => { + void Promise.allSettled([once(proxy, "close"), once(upstream, "close")]).then(() => resolve(), reject); + }); + } +}); + +// Standard Responses provider: accepts max_output_tokens — the 8192 cap must +// be retained on every summary call, and NO capability may be learned. +function makeStandardUpstream(calls: Call[]): http.Server { + return http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const parsed = parseBody(Buffer.concat(chunks).toString("utf8")); + calls.push({ stream: parsed.stream === true, summary: isSummaryCall(parsed), maxOutputTokens: parsed.max_output_tokens, model: parsed.model }); + if (isSummaryCall(parsed)) respondSummary(res, parsed.stream === true); + else forwardSse(res); + }); + }); +} + +test("e2e #663 (standard provider): max_output_tokens retained, no capability learned", async () => { + const calls: Call[] = []; + const upstream = makeStandardUpstream(calls); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const upstreamPort = (upstream.address() as { port: number }).port; + + const proxy = await startProxy(upstreamPort, { "gpt-6-astra": { context: 10_000 } }); + await once(proxy, "listening"); + const proxyPort = (proxy.address() as { port: number }).port; + + try { + const r = await driveResponsesPreflight(proxyPort, upstreamPort, "s663-resp-c", "gpt-6-astra", longResponsesInput(12)); + assert.equal(r.status, 200, `request must succeed, got ${r.status}`); + + const summaries = calls.filter((c) => c.summary); + assert.ok(summaries.length >= 1, "preflight summaries happened"); + assert.ok( + summaries.every((c) => c.maxOutputTokens === 8192), + `every summary must keep the 8192 output limit, got ${JSON.stringify(summaries)}`, + ); + assert.ok(calls.some((c) => !c.summary), "the folded payload was forwarded"); + + const sess = listSessions().find((s) => s.id.includes("s663-resp-c")); + assert.ok(sess, "session recorded"); + assert.notEqual(sess?.metadata?.preflightNoMaxOutputTokens, true, "no max_output_tokens learning for a standard provider"); + assert.equal((sess?.metadata?.preflightNoMaxOutputTokens as Record | undefined)?.[learnKey(`http://127.0.0.1:${upstreamPort}/responses`, "gpt-6-astra")], undefined, "no per-endpoint learning recorded"); + assert.equal(sess?.metadata?.preflightStreamSummary, undefined, "no stream learning for a standard provider"); + } finally { + proxy.close(); + upstream.close(); + await new Promise((resolve, reject) => { + void Promise.allSettled([once(proxy, "close"), once(upstream, "close")]).then(() => resolve(), reject); + }); + } +}); + +// Model scoping: model A (same endpoint) rejects max_output_tokens, model B +// accepts it. Learning for A must NOT strip the cap from B's summaries. +function makeModelSplitUpstream(calls: Call[]): http.Server { + return makePickyUpstream(calls, (p) => { + if (p.model === "gpt-6-astra" && p.max_output_tokens !== undefined) return { detail: "Unsupported parameter: max_output_tokens" }; + return null; + }); +} + +test("e2e #663 (model scoping): rejection learned for model A keeps model B's 8192 cap", async () => { + const calls: Call[] = []; + const upstream = makeModelSplitUpstream(calls); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const upstreamPort = (upstream.address() as { port: number }).port; + + const proxy = await startProxy(upstreamPort, { "gpt-6-astra": { context: 10_000 }, "gpt-6-standard": { context: 10_000 } }); + await once(proxy, "listening"); + const proxyPort = (proxy.address() as { port: number }).port; + const upstreamUrl = `http://127.0.0.1:${upstreamPort}/responses`; + + try { + const r = await driveResponsesPreflight(proxyPort, upstreamPort, "s663-resp-d", "gpt-6-astra", longResponsesInput(12)); + assert.equal(r.status, 200, `first request (model A) must succeed, got ${r.status}`); + const astraSummaries = calls.filter((c) => c.summary && c.model === "gpt-6-astra"); + // Model A accepts non-stream, so the first attempt is non-stream + + // cap (rejected), then every retry — however many folds the payload + // needs — is non-stream without the cap. + assert.ok(astraSummaries.length >= 2, `model A: one rejected attempt + at least one retry, got ${JSON.stringify(astraSummaries)}`); + assert.equal(astraSummaries[0].maxOutputTokens, 8192, "model A first attempt carries the cap"); + assert.ok( + astraSummaries.slice(1).every((c) => c.maxOutputTokens === undefined), + `every model A retry must drop the cap, got ${JSON.stringify(astraSummaries)}`, + ); + + // Same session, different model: preflight must fire again (grown + // over-window) and model B's summary keeps the cap. + const callsBefore = calls.length; + const r2 = await driveResponsesPreflight(proxyPort, upstreamPort, "s663-resp-d", "gpt-6-standard", longResponsesInput(24)); + assert.equal(r2.status, 200, `second request (model B) must succeed, got ${r2.status}`); + const standardSummaries = calls.slice(callsBefore).filter((c) => c.summary); + assert.ok(standardSummaries.length >= 1, "second request triggered preflight summaries"); + assert.ok( + standardSummaries.every((c) => c.model === "gpt-6-standard" && c.maxOutputTokens === 8192), + `model B summaries must keep the 8192 cap, got ${JSON.stringify(standardSummaries)}`, + ); + + const sess = listSessions().find((s) => s.id.includes("s663-resp-d")); + const learned = sess?.metadata?.preflightNoMaxOutputTokens as Record | undefined; + assert.ok(learned && typeof learned === "object", "learning map recorded"); + assert.equal(learned?.[learnKey(upstreamUrl, "gpt-6-astra")], true, "model A key learned"); + assert.equal(learned?.[learnKey(upstreamUrl, "gpt-6-standard")], undefined, "model B key NOT learned"); + } finally { + proxy.close(); + upstream.close(); + await new Promise((resolve, reject) => { + void Promise.allSettled([once(proxy, "close"), once(upstream, "close")]).then(() => resolve(), reject); + }); + } +}); + +// #663 (follow-up): the main Codex request carries the request-shape-specific +// x-openai-internal-codex-responses-lite header (its body has +// reasoning.context: all_turns). requestSummary used to copy ALL main-request +// headers into its independently-built summary body (no reasoning field), so +// the backend rejected the summary: 400 "…requires `reasoning.context` to be +// `all_turns`". The fix strips that one header from the summary call only — the +// main request keeps it. These tests record the exact headers the proxy sends +// on each upstream call and assert the separation. +type HeaderCall = { summary: boolean; headers: http.IncomingHttpHeaders }; + +// Records req.headers per upstream call; always succeeds, so the assertions +// are purely about which headers ride each call. +function makeHeaderRecordingUpstream(calls: HeaderCall[]): http.Server { + return http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const parsed = parseBody(Buffer.concat(chunks).toString("utf8")); + calls.push({ summary: isSummaryCall(parsed), headers: req.headers }); + if (isSummaryCall(parsed)) respondSummary(res, parsed.stream === true); + else forwardSse(res); + }); + }); +} + +test("e2e #663 (codex lite header): summary drops the Lite header, main request keeps it; auth preserved", async () => { + const calls: HeaderCall[] = []; + const upstream = makeHeaderRecordingUpstream(calls); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const upstreamPort = (upstream.address() as { port: number }).port; + + const proxy = await startProxy(upstreamPort, { "gpt-6-astra": { context: 10_000 } }); + await once(proxy, "listening"); + const proxyPort = (proxy.address() as { port: number }).port; + + try { + const r = await driveResponsesPreflight(proxyPort, upstreamPort, "s663-lite", "gpt-6-astra", longResponsesInput(12), { + "x-openai-internal-codex-responses-lite": "true", + "authorization": "Bearer test-token", + }); + assert.equal(r.status, 200, `request must succeed, got ${r.status}`); + + const summaries = calls.filter((c) => c.summary); + const forwards = calls.filter((c) => !c.summary); + assert.ok(summaries.length >= 1, "preflight summaries happened"); + assert.ok(forwards.length >= 1, "the folded payload was forwarded"); + + // The summary body is built independently (no reasoning.context), so it + // must NOT carry the request-shape-specific Lite header — but it must + // keep auth so the side call can authenticate. + for (const c of summaries) { + assert.equal(c.headers["x-openai-internal-codex-responses-lite"], undefined, `summary call must drop the Lite header, got ${JSON.stringify(Object.keys(c.headers))}`); + assert.equal(c.headers["authorization"], "Bearer test-token", "summary call must keep the auth header"); + } + // The main forwarded request keeps the Lite header (its body has + // reasoning.context: all_turns) and auth. + for (const c of forwards) { + assert.equal(c.headers["x-openai-internal-codex-responses-lite"], "true", "main request must keep the Lite header"); + assert.equal(c.headers["authorization"], "Bearer test-token", "main request must keep the auth header"); + } + } finally { + proxy.close(); + upstream.close(); + await new Promise((resolve, reject) => { + void Promise.allSettled([once(proxy, "close"), once(upstream, "close")]).then(() => resolve(), reject); + }); + } +}); + +// #663 (follow-up): the strip is keyed on the Codex Lite header, so it must be +// a no-op for protocols that never send it. Anthropic preflight stays +// untouched: the summary call keeps its auth header and the fold + forward +// still succeed. +type AnthHeaderCall = { summary: boolean; headers: http.IncomingHttpHeaders }; + +function makeAnthropicHeaderUpstream(calls: AnthHeaderCall[]): http.Server { + return http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + let parsed: { stream?: boolean; messages?: unknown[] } = {}; + try { parsed = JSON.parse(raw); } catch { /* keep {} */ } + const isSummary = Array.isArray(parsed.messages) && parsed.messages.length === 1; + calls.push({ summary: isSummary, headers: req.headers }); + const text = isSummary ? SUMMARY_TEXT : "ok"; + if (parsed.stream === true) { + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + res.write(`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "m1", role: "assistant", usage: { input_tokens: 100 } } })}\n\n`); + res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`); + res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } })}\n\n`); + res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`); + res.write(`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 5 } })}\n\n`); + res.write(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`); + res.end(); + } else { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ content: [{ type: "text", text }] })); + } + }); + }); +} + +function longAnthropicMessages() { + const msgs: Array<{ role: string; content: string }> = []; + for (let i = 0; i < 12; i++) { + const role = i % 2 === 0 ? "user" : "assistant"; + msgs.push({ role, content: `Message ${i} of the long conversation. ` + `MARKER_${i}_content_`.repeat(250) }); + } + return msgs; +} + +test("e2e #663 (anthropic): header strip is a no-op — summary keeps auth, fold + forward OK", async () => { + const calls: AnthHeaderCall[] = []; + const upstream = makeAnthropicHeaderUpstream(calls); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const upstreamPort = (upstream.address() as { port: number }).port; + + const proxy = await startProxy(upstreamPort, { "claude-test": { context: 10_000 } }); + await once(proxy, "listening"); + const proxyPort = (proxy.address() as { port: number }).port; + + try { + const r = await fetch(`http://127.0.0.1:${proxyPort}/bili/http://127.0.0.1:${upstreamPort}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json", "x-acp-session": "s663-anth", "x-api-key": "test-key" }, + body: JSON.stringify({ model: "claude-test", stream: true, max_tokens: 1024, system: "You are a test assistant.", messages: longAnthropicMessages() }), + }); + assert.equal(r.status, 200, `request must succeed, got ${r.status}`); + + const summaries = calls.filter((c) => c.summary); + const forwards = calls.filter((c) => !c.summary); + assert.ok(summaries.length >= 1, "preflight summaries happened"); + assert.ok(forwards.length >= 1, "the folded payload was forwarded"); + + // Anthropic never sends the Codex Lite header, so the strip is a no-op: + // the summary call keeps its auth header untouched. + for (const c of summaries) { + assert.equal(c.headers["x-api-key"], "test-key", "anthropic summary call must keep its auth header"); + assert.equal(c.headers["x-openai-internal-codex-responses-lite"], undefined, "no Lite header present for anthropic"); + } + for (const c of forwards) { + assert.equal(c.headers["x-api-key"], "test-key", "anthropic main request keeps its auth header"); + } + } finally { + proxy.close(); + upstream.close(); + await new Promise((resolve, reject) => { + void Promise.allSettled([once(proxy, "close"), once(upstream, "close")]).then(() => resolve(), reject); + }); + } +});