-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix(responses): fallback to routed compaction on 404 and enable quota failover on incomplete terminal #3769
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1533,6 +1533,23 @@ export function codexForwardTerminalOutcomeRecorder( | |
| if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined; | ||
| return (status, httpStatusOverride) => { | ||
| if (status === "incomplete") { | ||
| const isQuotaOrRateLimit = Boolean( | ||
| (logCtx?.upstreamError && isRateLimitOrQuotaFailureMessage(logCtx.upstreamError)) | ||
| || logCtx?.terminalHttpStatus === 429 | ||
| || httpStatusOverride === 429 | ||
| ); | ||
| if (isQuotaOrRateLimit) { | ||
| recordCodexUpstreamOutcome(config, authCtx.accountId, 429, { | ||
| threadId: authCtx.affinityKey, | ||
| fixedAccount: authCtx.fixedAccount, | ||
| modelId, | ||
| probeLeaseId: codexProbeLeaseId(authCtx), | ||
| probeQuotaScope: codexProbeQuotaScope(authCtx), | ||
| writerGeneration: authCtx.writerGeneration, | ||
| ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}), | ||
| }); | ||
| return; | ||
| } | ||
| // Normal limit/content-filter/stall terminal — the account served the | ||
| // request. Don't penalize account health; record success to clear any | ||
| // prior soft-avoid so a healthy account isn't stuck avoided. | ||
|
|
@@ -5143,11 +5160,12 @@ async function handleResponsesInner( | |
| if (terminalBodyWillRecord) { | ||
| options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => { | ||
| terminalRecorder(status, httpStatusOverride); | ||
| if (status === "failed") { | ||
| if (status === "failed" || status === "incomplete") { | ||
| const quotaFailureMessage = httpStatusOverride === 429 || httpStatusOverride === 402 | ||
| || logCtx.terminalHttpStatus === 429 | ||
| || logCtx.terminalHttpStatus === 402 | ||
| ? (httpStatusOverride ?? logCtx.terminalHttpStatus) | ||
| || (logCtx.upstreamError && isRateLimitOrQuotaFailureMessage(logCtx.upstreamError)) | ||
| ? (httpStatusOverride ?? logCtx.terminalHttpStatus ?? 429) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 15 '\brecordSubagentQuotaFailureForThreadSpawn\b' src tests
rg -n -C 8 'quotaFailureMessage|httpStatusOverride|usage limit' src testsRepository: lidge-jun/opencodex Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '520,635p' src/codex/subagent-model-fallback.ts
rg -n -C 12 '\bnoteSubagentModelFailure\b|\bsubagentModelFailures\b|failure.*cooldown|pollIntervalMs' src/codex/subagent-model-fallback.ts src/codex src/serverRepository: lidge-jun/opencodex Length of output: 18253 🤖 get_repo_knowledge executed:
Length of output: 10831 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path("src/codex/subagent-model-fallback.ts")
lines = p.read_text().splitlines()
for start, end in [(430, 525), (600, 618)]:
print(f"--- {p}:{start}-{end} ---")
for i in range(start, min(end, len(lines)) + 1):
print(f"{i}:{lines[i-1]}")
PYRepository: lidge-jun/opencodex Length of output: 4283 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '405,430p' src/codex/subagent-model-fallback.tsRepository: lidge-jun/opencodex Length of output: 828 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- src/codex/subagent-model-fallback.ts:405-430 ---'
sed -n '405,430p' src/codex/subagent-model-fallback.ts
printf '%s\n' '--- src/lib/errors.ts:320-355 ---'
sed -n '320,355p' src/lib/errors.tsRepository: lidge-jun/opencodex Length of output: 2454 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '330,355p' src/lib/errors.ts
sed -n '405,425p' src/codex/subagent-model-fallback.tsRepository: lidge-jun/opencodex Length of output: 1930 Normalize message-based quota matches before recording them. When 🤖 Prompt for AI Agents |
||
| : undefined; | ||
| if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { | ||
| recordSubagentQuotaFailureForThreadSpawn( | ||
|
|
@@ -5354,11 +5372,12 @@ async function handleResponsesInner( | |
| const reportNativeTerminal = recordTerminalOutcomes | ||
| ? (status: ResponsesTerminalStatus, httpStatusOverride?: number) => { | ||
| terminalRecorder?.(status, httpStatusOverride); | ||
| if (status === "failed") { | ||
| if (status === "failed" || status === "incomplete") { | ||
| const quotaFailureMessage = httpStatusOverride === 429 || httpStatusOverride === 402 | ||
| || logCtx.terminalHttpStatus === 429 | ||
| || logCtx.terminalHttpStatus === 402 | ||
| ? (httpStatusOverride ?? logCtx.terminalHttpStatus) | ||
| || (logCtx.upstreamError && isRateLimitOrQuotaFailureMessage(logCtx.upstreamError)) | ||
| ? (httpStatusOverride ?? logCtx.terminalHttpStatus ?? 429) | ||
| : undefined; | ||
| if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { | ||
| recordSubagentQuotaFailureForThreadSpawn( | ||
|
|
@@ -5447,11 +5466,12 @@ async function handleResponsesInner( | |
| // client-cancel (no terminal seen) is finalized separately via consumeForInspection's onCancel. | ||
| const reportNativeTerminal = (status: ResponsesTerminalStatus, httpStatusOverride?: number) => { | ||
| terminalRecorder?.(status, httpStatusOverride); | ||
| if (status === "failed") { | ||
| if (status === "failed" || status === "incomplete") { | ||
| const quotaFailureMessage = httpStatusOverride === 429 || httpStatusOverride === 402 | ||
| || logCtx.terminalHttpStatus === 429 | ||
| || logCtx.terminalHttpStatus === 402 | ||
| ? (httpStatusOverride ?? logCtx.terminalHttpStatus) | ||
| || (logCtx.upstreamError && isRateLimitOrQuotaFailureMessage(logCtx.upstreamError)) | ||
| ? (httpStatusOverride ?? logCtx.terminalHttpStatus ?? 429) | ||
| : undefined; | ||
| if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { | ||
| recordSubagentQuotaFailureForThreadSpawn( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| import { describe, expect, test, beforeEach } from "bun:test"; | ||
| import { captureTerminalHttpStatus } from "../../src/server/request-log"; | ||
| import { codexForwardTerminalOutcomeRecorder } from "../../src/server/responses/core"; | ||
| import { | ||
| clearCodexUpstreamHealth, | ||
| getCodexAccountCooldownUntil, | ||
| } from "../../src/codex/routing"; | ||
| import type { CodexAuthContext } from "../../src/codex/auth-context"; | ||
| import type { OcxConfig, OcxProviderConfig } from "../../src/types"; | ||
|
|
||
| describe("forward incomplete quota failover handling", () => { | ||
| beforeEach(() => { | ||
| clearCodexUpstreamHealth(); | ||
| }); | ||
|
|
||
| test("captureTerminalHttpStatus records 429 when response.incomplete has quota error message", () => { | ||
| const logCtx: Record<string, unknown> = {}; | ||
| captureTerminalHttpStatus(logCtx as any, { | ||
| type: "response.incomplete", | ||
| response: { | ||
| incomplete_details: { | ||
| reason: "usage_limit_reached", | ||
| message: "The usage limit has been reached", | ||
| }, | ||
| }, | ||
| }); | ||
| expect(logCtx.terminalHttpStatus).toBe(429); | ||
| }); | ||
|
|
||
| test("codexForwardTerminalOutcomeRecorder trips cooldown on incomplete quota terminal", () => { | ||
| const config = { | ||
| codexAccounts: [ | ||
| { id: "pool-a", email: "pool-a@example.com", isMain: false }, | ||
| { id: "pool-b", email: "pool-b@example.com", isMain: false }, | ||
| ], | ||
| activeCodexAccountId: "pool-a", | ||
| } as unknown as OcxConfig; | ||
|
|
||
| const authCtx: CodexAuthContext = { | ||
| kind: "pool", | ||
| accountId: "pool-a", | ||
| generation: 1, | ||
| affinityKey: "thread_123", | ||
| fixedAccount: false, | ||
| } as unknown as CodexAuthContext; | ||
|
|
||
| const provider: OcxProviderConfig = { | ||
| adapter: "openai-responses", | ||
| baseUrl: "https://chatgpt.com/backend-api/codex", | ||
| authMode: "forward", | ||
| }; | ||
|
|
||
| const logCtx = { | ||
| upstreamError: "The usage limit has been reached", | ||
| }; | ||
|
|
||
| const recorder = codexForwardTerminalOutcomeRecorder( | ||
| config, | ||
| authCtx, | ||
| provider, | ||
| "gpt-5.6", | ||
| logCtx as any, | ||
| ); | ||
| expect(recorder).toBeDefined(); | ||
|
|
||
| recorder!("incomplete"); | ||
|
|
||
| // The account should now be on cooldown due to 429 | ||
| const cooldownUntil = getCodexAccountCooldownUntil("pool-a"); | ||
| expect(cooldownUntil).toBeGreaterThan(Date.now()); | ||
| }); | ||
|
|
||
| test("codexForwardTerminalOutcomeRecorder records 200 on standard incomplete (e.g. max tokens)", () => { | ||
| const config = { | ||
| codexAccounts: [ | ||
| { id: "pool-a", email: "pool-a@example.com", isMain: false }, | ||
| ], | ||
| activeCodexAccountId: "pool-a", | ||
| } as unknown as OcxConfig; | ||
|
|
||
| const authCtx: CodexAuthContext = { | ||
| kind: "pool", | ||
| accountId: "pool-a", | ||
| generation: 1, | ||
| affinityKey: "thread_123", | ||
| fixedAccount: false, | ||
| } as unknown as CodexAuthContext; | ||
|
|
||
| const provider: OcxProviderConfig = { | ||
| adapter: "openai-responses", | ||
| baseUrl: "https://chatgpt.com/backend-api/codex", | ||
| authMode: "forward", | ||
| }; | ||
|
|
||
| const logCtx = { | ||
| terminalIncompleteReason: "max_output_tokens", | ||
| }; | ||
|
|
||
| const recorder = codexForwardTerminalOutcomeRecorder( | ||
| config, | ||
| authCtx, | ||
| provider, | ||
| "gpt-5.6", | ||
| logCtx as any, | ||
| ); | ||
| expect(recorder).toBeDefined(); | ||
|
|
||
| recorder!("incomplete"); | ||
|
|
||
| // Normal incomplete terminal does not penalize account health | ||
| const cooldownUntil = getCodexAccountCooldownUntil("pool-a"); | ||
| expect(cooldownUntil).toBeNull(); | ||
| }); | ||
|
|
||
| test("handleResponsesCompact falls through to routed synthetic compaction when upstream returns 404 on /responses/compact", async () => { | ||
| const { handleResponsesCompact } = await import("../../src/server/responses/compact"); | ||
| const originalFetch = globalThis.fetch; | ||
| const requestedUrls: string[] = []; | ||
| globalThis.fetch = (async (url: string | URL | Request) => { | ||
| const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : (url as Request).url; | ||
| requestedUrls.push(urlStr); | ||
| if (urlStr.includes("/responses/compact")) { | ||
| return new Response(JSON.stringify({ detail: "Not Found" }), { | ||
| status: 404, | ||
| headers: { "content-type": "application/json" }, | ||
| }); | ||
| } | ||
| const payload = { | ||
| id: "resp_1", | ||
| status: "completed", | ||
| output: [{ type: "compaction", encrypted_content: "opaque_blob_xyz" }], | ||
| }; | ||
| return new Response(JSON.stringify(payload), { | ||
| status: 200, | ||
| headers: { "content-type": "application/json" }, | ||
| }); | ||
| }) as typeof fetch; | ||
|
|
||
| try { | ||
| const config = { | ||
| defaultProvider: "openai-apikey", | ||
| providers: { | ||
| "openai-apikey": { | ||
| adapter: "openai-responses", | ||
| baseUrl: "https://api.openai.com/v1", | ||
| authMode: "key", | ||
| apiKey: "test-key", | ||
| }, | ||
|
Comment on lines
+143
to
+148
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Add a canonical-forward streaming fallback test. This provider uses Add a canonical forward-provider case. Inspect the fallback As per path instructions, “A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.” 🤖 Prompt for AI AgentsSource: Path instructions |
||
| }, | ||
| } as unknown as OcxConfig; | ||
|
|
||
| const req = new Request("http://localhost/v1/responses/compact", { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| model: "openai-apikey/gpt-5.6", | ||
| input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], | ||
| }), | ||
| }); | ||
|
|
||
| const res = await handleResponsesCompact(req, config, {} as any); | ||
| expect(res.status).toBe(200); | ||
| expect(requestedUrls.some(u => u.includes("/responses/compact"))).toBe(true); | ||
| expect(requestedUrls.some(u => u.endsWith("/responses") || u.includes("/v1/responses"))).toBe(true); | ||
| const json = await res.json() as any; | ||
| expect(json.output).toBeDefined(); | ||
| expect(json.output[0].type).toBe("compaction"); | ||
| } finally { | ||
| globalThis.fetch = originalFetch; | ||
| } | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Handle HTTP 402 in the incomplete terminal recorder.
When
httpStatusOverrideis402, this predicate remains false because it checks only429. The code then records the incomplete terminal as200. The pool account can remain eligible after an insufficient-quota terminal, so cooldown and alternate-account failover do not run.Treat both
402and429as quota statuses here, or share the quota-status helper used by the native reporters.Proposed fix
const isQuotaOrRateLimit = Boolean( (logCtx?.upstreamError && isRateLimitOrQuotaFailureMessage(logCtx.upstreamError)) || logCtx?.terminalHttpStatus === 429 + || logCtx?.terminalHttpStatus === 402 || httpStatusOverride === 429 + || httpStatusOverride === 402 );📝 Committable suggestion
🤖 Prompt for AI Agents