From 75eb28e0a46ee78d3166035b7fe6f22a5bcc01be Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 06:00:13 +0900 Subject: [PATCH 1/2] fix(combos): classify a hopping 413 when the combo exhausts its targets\n\n#4127 removed the clientRequestedStream gate from the provider-413 mappings and\nfrom the combo loop's own stop/hop branches, but the mapping that runs after the\nloop exhausts every target still required stream === true, so a non-streaming\nrequest whose targets all refuse with a hopping 413 fell through to the raw\nupstream response instead of a classified context-overflow reply.\n\nThat site sits outside the loop, where failure.upstreamCode is gone, so it cannot\nre-derive the loop's classification from the status alone. Carry the loop's own\nclassifyOverflow decision forward instead of weakening the test. A local\ninput_admission_refused therefore still keeps its own diagnostic on the\nnon-streaming path rather than being relabelled as an upstream overflow.\n\nCloses #4149. --- src/server/responses/core.ts | 11 +++- .../responses-context-overflow.test.ts | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 0ebdd11fb3..e41c2e6239 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2791,6 +2791,10 @@ export async function handleComboResponses( logCtx.routeDecision = comboRouteDecisionTrace(config, comboId, pick, requestedModel); let lastFailure: Response | null = null; + // The exhausted-combo mapping below runs outside the loop, where `failure.upstreamCode` + // is gone, so carry the loop's own classification decision instead of re-deriving a + // weaker one from the status alone (#4149). + let lastFailureClassifiesOverflow = false; while (pick) { if (options.abortSignal?.aborted) return clientCancelledResponse(); const childLog: RequestLogContext = { @@ -3003,6 +3007,7 @@ export async function handleComboResponses( const classifyOverflow = failure.response.status === 413 && (wantsStream || (failure.upstreamCode !== "outbound_body_too_large" && failure.upstreamCode !== "translation_buffer_limit")); + lastFailureClassifiesOverflow = classifyOverflow; if (storedPool401ReplayDispatched) { if (failureDecision === "hop" && unreadableEncryptedAgentTask && !comboPayloadReadable) { const recoveredTarget = await pickWithWait({ @@ -3089,9 +3094,11 @@ export async function handleComboResponses( } if ( lastFailure?.status === 413 - && (rawBody as { stream?: unknown } | null)?.stream === true + && lastFailureClassifiesOverflow ) { - return streamingContextOverflowResponse(requestedModel, options.translatorBudget); + return (rawBody as { stream?: unknown } | null)?.stream === true + ? streamingContextOverflowResponse(requestedModel, options.translatorBudget) + : jsonContextOverflowResponse(); } return lastFailure!; } diff --git a/tests/responses/responses-context-overflow.test.ts b/tests/responses/responses-context-overflow.test.ts index f580d27b69..24be2316ee 100644 --- a/tests/responses/responses-context-overflow.test.ts +++ b/tests/responses/responses-context-overflow.test.ts @@ -44,6 +44,26 @@ function upstream413(onHit?: () => void): ReturnType { return upstreamStatus(413, onHit); } +/** + * A 413 whose body carries a per-request free-tier cap. `comboFailureDecision` reads that + * as target-local and hops, so a combo tries every target and then exhausts, which is the + * mapping site this fixture exercises. + */ +function freePromptCap413(onHit?: () => void): ReturnType { + const upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + onHit?.(); + return Response.json({ + detail: "err_free_prompt_cap: prompt exceeds this tier; echoed private request marker should-not-reach-client", + }, { status: 413 }); + }, + }); + upstreams.push(upstream); + return upstream; +} + function provider( adapter: "openai-responses" | "openai-chat" | "anthropic", upstream: ReturnType, @@ -268,4 +288,49 @@ describe("Responses provider input overflow", () => { await server.stop(true); } }); + // A 413 carrying `err_free_prompt_cap` is a per-request free-tier cap, so + // `comboFailureDecision` hops instead of stopping. Every target then refuses and the + // combo falls out of its loop, which is a different mapping site from the "stop" case + // above and was still gated on `stream === true` after #4127 (#4149). + test.each([true, false])("an exhausted combo classifies a hopping 413 (stream=%s)", async stream => { + let firstHits = 0; + let secondHits = 0; + const first = freePromptCap413(() => { firstHits += 1; }); + const second = freePromptCap413(() => { secondHits += 1; }); + const next = config({ + first: provider("openai-chat", first), + second: provider("openai-chat", second), + }); + next.combos = { + fallback: { + strategy: "failover", + targets: [ + { provider: "first", model: "kimi-k3" }, + { provider: "second", model: "kimi-k3" }, + ], + }, + }; + saveConfig(next); + const server = startServer(0); + try { + const response = await request(String(server.url), "combo/fallback", stream); + if (stream) { + const failed = await responseFailed(response); + expect((failed.error as { code?: string }).code).toBe("context_length_exceeded"); + } else { + expect(response.status).toBe(413); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(await response.json()).toEqual({ error: { + message: PROVIDER_INPUT_TOO_LARGE_MESSAGE, + type: "invalid_request_error", + code: "context_length_exceeded", + } }); + } + // Both targets were tried: this is the exhausted path, not the stop path. + expect(firstHits).toBe(1); + expect(secondHits).toBe(1); + } finally { + await server.stop(true); + } + }); }); From 5095f1b982e9a6663d1f4bcbd730563b9b53e658 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 06:13:03 +0900 Subject: [PATCH 2/2] fix(management): repoint two route ownerDocs at their _fin locations\n\nThe post-2.49 devlog reconciliation moved 260904_priority65_closeout and\n260903_bug_drawdown_bcda into devlog/_fin, but three deferred-verb route\nexemptions still named their old devlog/_plan paths, so\n"route exemptions stay honest > a deferred-verb exemption names an owner phase\nand a TRACKED doc that exists" went red on dev.\n\nThe remaining _plan ownerDoc (260828_ocx_agentic_control) is correct: that unit\nis genuinely still open. --- src/server/management/route-registry.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 8743677bd6..d8f81de5ec 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -149,9 +149,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" }, { method: "PUT", path: "/api/client-integrations/aside/profiles/{profileId}", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" }, { method: "GET", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" }, - { method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, + { method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" }, - { method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, + { method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, { method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" }, // server/management/codex-prompt-routes { method: "GET", path: "/api/codex-prompt", module: "server/management/codex-prompt-routes", mutates: false }, @@ -187,7 +187,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ // server/management/integration-routes { method: "GET", path: "/api/client-integrations", module: "server/management/integration-routes", mutates: false }, { method: "GET", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: false }, - { method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, + { method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, { method: "POST", path: "/api/client-integrations/restore", module: "server/management/integration-routes", mutates: true }, // server/management/lab-automation-routes { method: "GET", path: "/api/lab/automation", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } }, @@ -293,7 +293,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true }, { method: "POST", path: "/api/providers", module: "server/management/provider-routes", mutates: true }, { method: "POST", path: "/api/providers/test", module: "server/management/provider-routes", mutates: true }, - { method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/_plan/260903_bug_drawdown_bcda/050_phase5.md" } }, + { method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/_fin/260903_bug_drawdown_bcda/050_phase5.md" } }, { method: "PUT", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: true }, // server/management/quota-reset-routes { method: "GET", path: "/api/quota-resets", module: "server/management/quota-reset-routes", mutates: false, mechanism: "negated-guard" },