diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 762dbe3eec9..4565fef48ce 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -519,6 +519,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 c749375a83b..936a9ebe254 100644 --- a/structure/transports/responses-spend.md +++ b/structure/transports/responses-spend.md @@ -94,7 +94,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 9ba3704e42b..dee59b7d28b 100644 --- a/tests/responses/responses-grok-devin-preflight.test.ts +++ b/tests/responses/responses-grok-devin-preflight.test.ts @@ -1,4 +1,6 @@ import { 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"; @@ -31,6 +33,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 () => { @@ -63,10 +67,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) { @@ -103,6 +108,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 settles durable spend in usage.jsonl (stream=%s)", async stream => { + const usage = { inputTokens: 23, outputTokens: 5, totalTokens: 28 }; + events = [{ ...limit, usage }]; + expect((await run({ stream })).status).toBe(429); + // handleResponses only populates the log context; the route layer's addFinalRequestLog is the + // seam that settles the spend tracker and appends usage.jsonl. Drive it with this request's + // context so the durable row proves the bound usage survives finalization. + 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.each([false, true])("first text is replayed once and later errors stay SSE (failure=%s)", async failure => { events = [{ type: "heartbeat" }, { type: "text_delta", text: "answer" }, failure ? limit : { type: "done" }]; const response = await run();