diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index fa1bb383323..de0dc848117 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -574,6 +574,7 @@ export async function executeResponsesRunTurn( if (preflight.replayUnsafe || preflight.error?.status !== 429 || preflight.error.code === SEND_BUDGET_EXHAUSTED_CODE) return; const { httpStatus, error } = adapterFailureFromEvent(preflight.error); + transportState.bindKeyUsageFromBridge(preflight.error.usage); cancelResponseCompletion(); runTurnAbort.abort(); queue.close(); diff --git a/structure/transports/responses-spend.md b/structure/transports/responses-spend.md index 8f81cfe15a3..6bfc03a8938 100644 --- a/structure/transports/responses-spend.md +++ b/structure/transports/responses-spend.md @@ -109,7 +109,9 @@ during this process's lifetime can still be released for free. Settlement follows what the request learned. The terminal usage belongs to the last send that left, so that one settles with the real figure; every earlier send failed without reporting usage of its own and may still have been billed, so it becomes unresolved spend rather than free. A -request that reports no usage at all leaves all of them unresolved. If deferred settlement reaches a tracker with reserved sends after its ledger lease ends, only `SPEND_LEDGER_OWNER_NOT_HELD` is dropped with the discarded ledger. Other owner and storage failures propagate with pending send IDs intact so settlement can be retried. +request that reports no usage at all leaves all of them unresolved. A pre-output Grok/Devin 429 +binds usage carried by its error event before returning the HTTP refusal, just as the ordinary +streaming and buffered bridges bind terminal usage. If deferred settlement reaches a tracker with reserved sends after its ledger lease ends, only `SPEND_LEDGER_OWNER_NOT_HELD` is dropped with the discarded ledger. Other owner and storage failures propagate with pending send IDs intact so settlement can be retried. Replay resolves what nobody is left to settle, and resolves it as unresolved spend whatever state it was in. Giving an undispatched one its tokens back would assume the journal is complete up to diff --git a/tests/responses/responses-grok-devin-preflight.test.ts b/tests/responses/responses-grok-devin-preflight.test.ts index 78d3bc5740d..f291ddfd131 100644 --- a/tests/responses/responses-grok-devin-preflight.test.ts +++ b/tests/responses/responses-grok-devin-preflight.test.ts @@ -1,4 +1,6 @@ import { afterAll, afterEach, beforeEach, expect, mock, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import type { ProviderAdapter } from "../../src/adapters/base"; import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../../src/types"; import { saveCredential } from "../../src/oauth/store"; @@ -34,6 +36,8 @@ mock.module("../../src/server/adapter-resolve", () => ({ ...resolver, }, })); const { handleResponses } = await import("../../src/server/responses"); +const { addFinalRequestLog } = await import("../../src/server/request-log"); +let requestLog: Parameters[2]; let home: ReturnType; let release: (() => void) | undefined; beforeEach(async () => { @@ -73,10 +77,11 @@ function run({ stallTimeoutSec, providers: { devin: { adapter: "devin", authMode: "oauth", baseUrl: "https://server.codeium.com", models: ["swe-2"] } }, } as OcxConfig; + requestLog = { model: "", provider: "", surface }; return handleResponses(new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "devin/swe-2", input: "answer", stream }), - }), config, { model: "", provider: "", surface }, { comboAttempt, abortSignal }); + }), config, requestLog, { comboAttempt, abortSignal }); } async function waitForPreflightResponse(pending: Promise, started: Promise, resume: () => void) { @@ -113,6 +118,28 @@ test.each([ expect(calls).toBe(1); }); +test.each([true, false])("pre-output 429 preserves metered usage (stream=%s)", async stream => { + const usage = { inputTokens: 23, outputTokens: 5, totalTokens: 28 }; + events = [{ ...limit, usage }]; + expect((await run({ stream })).status).toBe(429); + expect(requestLog.usage).toEqual(usage); +}); + +test.each([true, false])("pre-output 429 persists bound usage in the usage journal (stream=%s)", async stream => { + const usage = { inputTokens: 23, outputTokens: 5, totalTokens: 28 }; + events = [{ ...limit, usage }]; + expect((await run({ stream })).status).toBe(429); + // Exercise usage-journal finalization with this request's bound metering context. + // The durable row proves usage survives logging, not that a separate API-key budget + // reservation was settled. This preflight refusal returns HTTP before starting SSE. + addFinalRequestLog(`preflight-usage-${stream}`, Date.now(), requestLog, 429, { closeReason: "non_stream" }); + const journalPath = join(home.root, "usage.jsonl"); + expect(existsSync(journalPath)).toBe(true); + const rows = readFileSync(journalPath, "utf8").trim().split("\n").filter(Boolean) + .map(line => JSON.parse(line) as { usage?: typeof usage }); + expect(rows.at(-1)?.usage).toMatchObject({ inputTokens: 23, outputTokens: 5 }); +}); + test("buffered cooldown heartbeat keeps a final refusal as HTTP 429", async () => { events = [{ type: "heartbeat", preflightReady: true }, limit];