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" }, 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); + } + }); });