diff --git a/packages/paperclip-runner/docs/runner-protocol-live-evals.md b/packages/paperclip-runner/docs/runner-protocol-live-evals.md index 1ef927f5825..3760e60f40c 100644 --- a/packages/paperclip-runner/docs/runner-protocol-live-evals.md +++ b/packages/paperclip-runner/docs/runner-protocol-live-evals.md @@ -659,6 +659,19 @@ billable usage. These receipt corrections also apply to non-eval ACPX sessions. The durable redactor preserves these known numeric token-counter fields; string/object values under the same names remain redacted as sensitive data. +Grok 1.0.13 reports whole-prompt usage in `session/prompt` response +`_meta.usage`. The sibling `_meta` counters describe only the last model call. +Runner binds the nested receipt to the outbound JSON-RPC request and session, +then emits it before the terminal event. Replayed, conflicting, malformed, or +incomplete receipts cannot supply complete accounting. ACP input includes cache +reads and cache creation; Runner separates those buckets and does not add the +reasoning subset to output again. A complete `costUsdTicks` uses 10 billion ticks +per USD. Missing or partial cost remains unknown. The sidecar's `cost.basis` +distinguishes a `turn` total from the default session-cumulative cost, so later +turns do not inherit or subtract an earlier turn's bill. +These semantics follow the upstream [prompt usage contract](https://github.com/xai-org/grok-build/blob/2bdd1d6a6369de0e8c68132ea4539e9abd9e14a8/crates/codegen/xai-grok-shell/src/extensions/notification.rs) +and [prompt response tests](https://github.com/xai-org/grok-build/blob/2bdd1d6a6369de0e8c68132ea4539e9abd9e14a8/crates/codegen/xai-grok-shell/src/agent/mvp_agent/prompt_response_meta_tests.rs). + `all` means the maintained enabled campaign, not every matching file in the roster directory. A missing campaign file fails closed. Within an enabled lane, a missing remote profile or unavailable provider is retained as an diff --git a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json index bd1717d102f..7578065f002 100644 --- a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json +++ b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json @@ -225,6 +225,14 @@ }, "additionalProperties": false, "allOf": [ + { + "if": { "properties": { "eventType": { "const": "runtime.event" }, "payload": { + "type": "object", + "properties": { "type": { "const": "status" }, "tag": { "const": "usage_update" } }, + "required": ["type", "tag"] + } } }, + "then": { "properties": { "payload": { "type": "object", "properties": { "cost": { "$ref": "#/$defs/usageCost" } } } } } + }, { "if": { "properties": { "eventType": { "const": "runtime.rich_event" } } }, "then": { @@ -237,6 +245,16 @@ } ] }, + "usageCost": { + "description": "Provider cost is session-cumulative unless basis explicitly identifies a complete turn total. A turn total is never a session counter.", + "type": ["object", "null"], + "properties": { + "amount": { "type": ["number", "null"], "minimum": 0 }, + "currency": { "type": ["string", "null"], "maxLength": 16 }, + "basis": { "enum": ["turn", "session_cumulative"] } + }, + "additionalProperties": false + }, "richEvent": { "type": "object", "required": [ diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index b1d76fef47a..dd674180e5d 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -1208,9 +1208,11 @@ fn normalize_acpx_status( Some(Value::String(currency)) => currency.eq_ignore_ascii_case("USD"), Some(_) => false, }; - // ACPX 0.13.1 documents breakdown as per-turn usage while cost is - // session-cumulative. Keep those authorities separate so consumers do - // not add the same tokens twice or treat cumulative cost as a delta. + let turn_cost = payload.pointer("/cost/basis").and_then(Value::as_str) == Some("turn"); + let provider_cost = if cost_is_usd { + payload.pointer("/cost/amount").and_then(Value::as_f64) + .filter(|value| value.is_finite() && *value >= 0.0).unwrap_or(0.0) + } else { 0.0 }; let cumulative = json!({ "inputTokens": 0, "outputTokens": 0, @@ -1218,15 +1220,7 @@ fn normalize_acpx_status( "cacheWriteTokens": 0, "activeSeconds": 0.0, "requests": provider_requests, - "providerCostUsd": if cost_is_usd { - payload - .pointer("/cost/amount") - .and_then(Value::as_f64) - .filter(|value| value.is_finite() && *value >= 0.0) - .unwrap_or(0.0) - } else { - 0.0 - }, + "providerCostUsd": if turn_cost { 0.0 } else { provider_cost }, }); let run_delta = json!({ "inputTokens": nonnegative_u64(breakdown.get("inputTokens")), @@ -1243,7 +1237,7 @@ fn normalize_acpx_status( ), "activeSeconds": 0.0, "requests": 1, - "providerCostUsd": 0.0, + "providerCostUsd": if turn_cost { provider_cost } else { 0.0 }, }); return vec![NormalizedProviderEvent { event_type: "usage.reported".to_owned(), @@ -1499,6 +1493,28 @@ fn has_rfc_uri_scheme_prefix(value: &str) -> bool { mod tests { use super::*; + #[test] + fn acpx_turn_cost_is_not_a_cumulative_session_price() { + let base = json!({"tag": "usage_update", "breakdown": { + "inputTokens": 30787, "outputTokens": 5763, "cachedReadTokens": 475008, + "cachedWriteTokens": 0, "thoughtTokens": 0 + }, "cost": {"amount": 0.336842, "currency": "USD", "basis": "turn"}}); + let first = normalize_acpx_status(&base, "item", "turn-1", 1); + assert_eq!(first[0].payload["runDeltaAvailable"], true); + assert_eq!(first[0].payload["runDelta"]["providerCostUsd"], 0.336842); + assert_eq!(first[0].payload["cumulative"]["providerCostUsd"], 0.0); + assert_eq!(first[0].payload["runDelta"]["inputTokens"], 30787); + assert_eq!(first[0].payload["runDelta"]["outputTokens"], 5763); + let mut second = base.clone(); + second["cost"]["amount"] = json!(0.001); + let next = normalize_acpx_status(&second, "next", "turn-2", 2); + assert_eq!(next[0].payload["runDelta"]["providerCostUsd"], 0.001); + second["cost"].as_object_mut().unwrap().remove("basis"); + let cumulative = normalize_acpx_status(&second, "legacy", "turn-3", 3); + assert_eq!(cumulative[0].payload["cumulative"]["providerCostUsd"], 0.001); + assert_eq!(cumulative[0].payload["runDelta"]["providerCostUsd"], 0.0); + } + #[test] fn performance_diagnostics_keep_only_bounded_content_free_measurements() { let mut spans = vec![json!({"name":"private prompt","startedAtMs":1,"endedAtMs":2})]; diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index 5a96c9aebea..9741e14641b 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -8,7 +8,7 @@ import { appendSemanticToolReceipt, readNativeSemanticReceipt, semanticInputSha2 import { startRunnerToolBridge, type RunnerToolCall } from "../drivers/runner-tool-bridge.js"; import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js"; import { resolveCompletionContinuation } from "../drivers/completion-continuation.js"; -import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "../drivers/acpx/usage-accounting.js"; +import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice, qualifiedAcpxUsageBreakdown } from "../drivers/acpx/usage-accounting.js"; import { stripTypeScriptTypes } from "node:module"; import { cursorPlanToolIdentity, cursorToolIdentity } from "../drivers/acpx/cursor-plan-tool-identity.js"; import { createHash, randomUUID } from "node:crypto"; @@ -21,6 +21,22 @@ import { createKimiMessageProjection } from "../drivers/acpx/kimi-message-bounda import { afterEach, describe, expect, it, vi } from "vitest"; describe("live sidecar pending request snapshots", () => { + it("preserves explicit turn cost through the sidecar's closed usage projection", () => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf("function safeUsage("); + const end = source.indexOf("function safeOutput(", start); + const project = new Function("record", "qualifiedAcpxUsageBreakdown", "initializedAgent", "safeText", "text", + `${stripTypeScriptTypes(source.slice(start, end))}; return safeUsage;`)( + (value: unknown) => value && typeof value === "object" ? value : {}, qualifiedAcpxUsageBreakdown, "grok", + (value: string) => value, (value: unknown) => typeof value === "string" ? value : "", + ); + expect(project({ amount: 0.336842, currency: "USD", basis: "turn", rawSecret: "excluded" }, null)).toEqual({ + cost: { amount: 0.336842, currency: "USD", basis: "turn" }, breakdown: null, + }); + expect(project({ amount: 0.7, currency: "USD" }, null)).toEqual({ cost: { amount: 0.7, currency: "USD" }, breakdown: null }); + expect(project(undefined, null)).toEqual({ cost: null, breakdown: null }); + }); + it("attests current callback identities after the live status barrier without exposing forms", async () => { const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); const start = source.indexOf(' if (request.command === "session.snapshot") {'); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 155f963fd99..8510323ef77 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1199,6 +1199,7 @@ function safeUsage(cost: unknown, breakdown: unknown): Record { ? null : { amount: safeNonNegativeNumber(nativeCost.amount), + ...(nativeCost.basis === "turn" ? { basis: "turn" } : {}), currency: safeText(text(nativeCost.currency)).slice(0, 16) || null, }, breakdown: diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index 0df913dbe3d..b0ea480a234 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -19,6 +19,7 @@ import { describe, expect, it, vi } from "vitest"; import type { VerifiedAcpxCommandLease } from "./installation-integrity.js"; import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js"; import { cursorInstructionBinding } from "./cursor-instructions.js"; +import { persistedAcpxTurnUsage } from "./usage-accounting.js"; import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js"; import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; import type { AcpxRuntimePortOpenOptions } from "./runtime-host.js"; @@ -913,6 +914,44 @@ describe("Codex ACPX runtime adapter", () => { expect(assertWorkspaceHeld).toHaveBeenCalledOnce(); expect(command.spawn).not.toHaveBeenCalled(); }); + it("exposes only the active Grok prompt response as terminal usage across consecutive turns", async () => { + const runtime = fakeRuntime(); + const durableRecord = { acpxRecordId: "record-1", acpSessionId: "backend-1", agentSessionId: "agent-1", lastRequestId: "previous" }; + const durableStore: AcpSessionStore = { load: vi.fn(async () => structuredClone(durableRecord) as never), save: vi.fn() }; + let created!: AcpRuntimeOptions; + const options = openOptions(fakeCommand()); + options.profile = { ...options.profile, agent: "grok" }; + const port = await openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => durableStore, + createRuntime: value => { created = value; return runtime; } }); + let sequence = 0; + vi.mocked(runtime.startTurn).mockImplementation(input => { + const wireId = ++sequence; + durableRecord.lastRequestId = input.requestId; + created.onAcpMessage?.("outbound", { id: wireId, method: "session/prompt", params: { sessionId: "backend-1" } }); + created.onAcpMessage?.("inbound", { id: wireId, result: { _meta: { + sessionId: "backend-1", promptId: `prompt-${wireId}`, requestId: `prompt-${wireId}`, + usage: { inputTokens: 50, outputTokens: 5, totalTokens: 55, cachedReadTokens: 20, cacheCreationTokens: 0, + reasoningTokens: 2, modelCalls: 1, costUsdTicks: wireId === 1 ? 10000000 : 5000000 }, + } } }); + return { requestId: input.requestId, promptStarted: Promise.resolve(), events: (async function* () {})(), + result: Promise.resolve({ status: "completed" as const }), cancel: async () => undefined, closeStream: async () => undefined }; + }); + try { + for (const [requestId, amount] of [["runner-1", 0.001], ["runner-2", 0.0005]] as const) { + const before = await port.getStatus(); + const turn = port.startTurn({ requestId, text: "fixture" }); + await turn.result; + const after = await port.getStatus(); + expect(persistedAcpxTurnUsage(before, after, requestId)).toMatchObject({ + cost: { amount, currency: "USD", basis: "turn" }, + breakdown: { inputTokens: 30, outputTokens: 5, cachedReadTokens: 20, thoughtTokens: 0 }, + }); + created.onAcpMessage?.("inbound", { id: sequence, error: { message: "late conflicting response" } }); + expect(await port.getStatus()).toEqual(after); + } + } finally { await port.close({ reason: "fixture done" }); } + }); + it("reads verified status from durable state without draining live updates", async () => { const runtime = fakeRuntime(); vi.mocked(runtime.getStatus!).mockReturnValue(new Promise(() => {})); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 580bf58453d..ba1e97ce2ac 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -1,6 +1,7 @@ import { admitAcpxSessionContext } from "./session-context-admission.js"; import { createKimiInstructionAdmission } from "./kimi-instructions.js"; import { kimiReadOnlyPermissionAllowed } from "./kimi-config.js"; +import { createGrokPromptUsageCollector } from "./grok-usage.js"; import { resolveGrokDisableWebSearch } from "./grok-web-search.js"; import { resolveGrokEffort, createGrokEffortAdmission } from "./grok-effort.js"; import { resolveClaudeEffort, createClaudeEffortAdmission } from "./claude-effort.js"; @@ -295,6 +296,7 @@ export async function openQualifiedAcpxRuntime( const extensionProfile = ACPX_CAPABILITY_PROFILES[options.profile.agent]; const extensionRequests = new Set(extensionProfile.extensionRequests); const extensionNotifications = new Set(extensionProfile.extensionNotifications); + const promptUsage = options.profile.agent === "grok" ? createGrokPromptUsageCollector() : undefined; const extensionBoundary: AcpxRuntimeExtensionBoundary = { active: null, sessionIds: new Set(), steering: null, }; @@ -466,6 +468,10 @@ export async function openQualifiedAcpxRuntime( : null; }, onSessionNotification: acceptGoalNotification, + onAcpMessage: (direction, message) => { + const active = extensionBoundary.active; + if (active && !active.signal.aborted) promptUsage?.observe(direction, message); + }, spawnEnvironment: () => ({ ...definedEnvironment(options.launchEnvironment), ...(options.profile.agent === "claude" @@ -685,6 +691,7 @@ export async function openQualifiedAcpxRuntime( extensionBoundary, options.profile.agent === "copilot", options.profile.agent === "copilot" ? assertCopilotPromptPolicy : undefined, + promptUsage, ); } catch (error) { const cleanupReason = "ACPX runtime identity validation failed"; @@ -1117,6 +1124,7 @@ function runtimePort( extensionBoundary: AcpxRuntimeExtensionBoundary, monitorProviderDeath: boolean, assertPromptPolicy?: (text: string) => void, + promptUsage?: ReturnType, ): AcpxRuntimePort { extensionBoundary.sessionIds = new Set([identity.backendSessionId]); let extensionControls: Promise = Promise.resolve(); @@ -1375,7 +1383,8 @@ function runtimePort( return structuredClone(identity); }, async getStatus() { - return await persistedRuntimeStatus(sessionStore, handle, identity); + const status = await persistedRuntimeStatus(sessionStore, handle, identity); + return promptUsage ? { ...status, terminalPromptUsage: promptUsage.snapshot() } : status; }, steeringCapability() { return extensionBoundary.steering === null ? null : structuredClone(extensionBoundary.steering); @@ -1488,6 +1497,7 @@ function runtimePort( onRequest: input.onExtensionRequest, onNotification: input.onExtensionNotification, }; extensionBoundary.active = extensionTurn; + promptUsage?.begin(input.requestId, identity.backendSessionId); // A guardian can outlive the provider while ACP awaits a callback. // Native exit or a typed ACP agent disconnect retires this turn; losing // the controller connection must preserve deliverable input instead. diff --git a/packages/paperclip-runner/src/drivers/acpx/grok-usage.test.ts b/packages/paperclip-runner/src/drivers/acpx/grok-usage.test.ts new file mode 100644 index 00000000000..d056c688153 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/grok-usage.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { createGrokPromptUsageCollector, grokPromptUsage } from "./grok-usage.js"; +import { persistedAcpxTurnUsage } from "./usage-accounting.js"; + +const usage = { inputTokens: 505795, outputTokens: 5763, totalTokens: 511558, cachedReadTokens: 475008, + cacheCreationTokens: 0, reasoningTokens: 531, modelCalls: 20, costUsdTicks: 3368420000 }; +const meta = { sessionId: "session-1", promptId: "prompt-1", requestId: "prompt-1", usage, + inputTokens: 999, outputTokens: 999, totalTokens: 999 }; +const request = { jsonrpc: "2.0", id: 7, method: "session/prompt", params: { sessionId: "session-1" } }; +const response = { jsonrpc: "2.0", id: 7, result: { stopReason: "end_turn", _meta: meta } }; + +describe("Grok terminal prompt usage", () => { + it("uses the complete prompt ledger with disjoint cache buckets and exact USD tick conversion", () => { + expect(grokPromptUsage({ ...meta, usage: { ...usage, cacheCreationTokens: 100 } }, "session-1")).toEqual({ + promptId: "prompt-1", event: { type: "status", tag: "usage_update", text: "terminal prompt usage", + cost: { amount: 0.336842, currency: "USD", basis: "turn" }, + breakdown: { inputTokens: 30687, outputTokens: 5763, totalTokens: 511558, cachedReadTokens: 475008, + cachedWriteTokens: 100, thoughtTokens: 0 } }, + }); + }); + + it.each([ + { usageIsIncomplete: true }, { usageIsIncomplete: "false" }, { cachedReadTokens: 505796 }, + { inputTokens: -1 }, { outputTokens: 1.5 }, { reasoningTokens: 5764 }, { totalTokens: 1 }, + { modelCalls: undefined }, { inputTokens: Number.MAX_SAFE_INTEGER + 1 }, + ])("refuses incomplete or invalid token authority: %j", patch => { + expect(grokPromptUsage({ ...meta, usage: { ...usage, ...patch } }, "session-1")).toBeNull(); + }); + + it.each([{ costUsdTicks: undefined }, { costUsdTicks: -1 }, { costIsPartial: true }, { costIsPartial: "false" }])( + "preserves complete tokens without inventing cost: %j", patch => { + const result = grokPromptUsage({ ...meta, usage: { ...usage, ...patch } }, "session-1"); + expect(result?.event.breakdown).toMatchObject({ inputTokens: 30787, outputTokens: 5763 }); + expect(result?.event).not.toHaveProperty("cost"); + }); + + it("binds only the matching prompt response, rejects replay and leaves the next turn's price independent", () => { + const collector = createGrokPromptUsageCollector(); + collector.begin("runner-1", "session-1"); + collector.observe("inbound", response); + expect(collector.snapshot()).toBeNull(); + collector.observe("outbound", request); + collector.observe("inbound", { ...response, id: 6 }); + collector.observe("inbound", { ...response, method: "_x.ai/session/update" }); + expect(collector.snapshot()).toBeNull(); + collector.observe("inbound", response); + const first = collector.snapshot(); + collector.observe("inbound", response); + expect(collector.snapshot()).toEqual(first); + const before = { lastRequestId: "runner-1", terminalPromptUsage: first }; + expect(persistedAcpxTurnUsage({}, before, "runner-1")?.cost).toEqual({ amount: 0.336842, currency: "USD", basis: "turn" }); + expect(persistedAcpxTurnUsage(before, before, "runner-1")).toBeNull(); + expect(persistedAcpxTurnUsage({}, before, "foreign")).toBeNull(); + collector.begin("runner-2", "session-1"); + collector.observe("inbound", response); + expect(collector.snapshot()).toBeNull(); + collector.observe("outbound", { ...request, id: 8 }); + collector.observe("inbound", { ...response, id: 8, result: { _meta: { ...meta, promptId: "prompt-2", requestId: "prompt-2", + usage: { ...usage, costUsdTicks: 10000000 } } } }); + const after = { lastRequestId: "runner-2", terminalPromptUsage: collector.snapshot() }; + expect(persistedAcpxTurnUsage(before, after, "runner-2")?.cost).toEqual({ amount: 0.001, currency: "USD", basis: "turn" }); + }); + + it.each(["foreign-session", "conflicting-receipt", "second-prompt", "rpc-error", "reused-prompt"])("rejects %s", kind => { + const collector = createGrokPromptUsageCollector(); + collector.begin("runner-1", "session-1"); + collector.observe("outbound", request); + collector.observe("inbound", response); + if (kind === "reused-prompt") { collector.begin("runner-2", "session-1"); collector.observe("outbound", request); } + if (kind === "second-prompt") collector.observe("outbound", request); + else collector.observe("inbound", kind === "rpc-error" ? { ...response, error: {} } : { ...response, result: { _meta: { + ...meta, ...(kind === "foreign-session" ? { sessionId: "session-2" } : {}), + ...(kind === "conflicting-receipt" ? { usage: { ...usage, costUsdTicks: 1 } } : {}), + } } }); + expect(collector.snapshot()).toBeNull(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/grok-usage.ts b/packages/paperclip-runner/src/drivers/acpx/grok-usage.ts new file mode 100644 index 00000000000..7552f44f1eb --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/grok-usage.ts @@ -0,0 +1,63 @@ +type RecordValue = Record; +const record = (value: unknown): RecordValue => value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {}; +const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0; +const id = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$/.test(value); + +export interface TerminalPromptUsage { + requestId: string; + promptId: string; + event: RecordValue; +} + +export function grokPromptUsage(metaValue: unknown, sessionId: string): Omit | null { + const meta = record(metaValue), usage = record(meta.usage); + if (meta.sessionId !== sessionId || !id(meta.promptId) || meta.requestId !== meta.promptId + || (usage.usageIsIncomplete !== undefined && usage.usageIsIncomplete !== false)) return null; + const { inputTokens, outputTokens, totalTokens, cachedReadTokens, cacheCreationTokens, reasoningTokens, modelCalls } = usage; + if (![inputTokens, outputTokens, totalTokens, cachedReadTokens, cacheCreationTokens, reasoningTokens, modelCalls].every(integer) + || Number(inputTokens) + Number(outputTokens) !== totalTokens + || Number(cachedReadTokens) + Number(cacheCreationTokens) > Number(inputTokens) + || Number(reasoningTokens) > Number(outputTokens)) return null; + const costKnown = integer(usage.costUsdTicks) && (usage.costIsPartial === undefined || usage.costIsPartial === false); + return { + promptId: meta.promptId, + event: { + type: "status", tag: "usage_update", text: "terminal prompt usage", + ...(costKnown ? { cost: { amount: Number(usage.costUsdTicks) / 10_000_000_000, currency: "USD", basis: "turn" } } : {}), + breakdown: { + inputTokens: Number(inputTokens) - Number(cachedReadTokens) - Number(cacheCreationTokens), + outputTokens, totalTokens, cachedReadTokens, cachedWriteTokens: cacheCreationTokens, thoughtTokens: 0, + }, + }, + }; +} + +export function createGrokPromptUsageCollector() { + let active: { requestId: string; sessionId: string; wireId?: string | number; invalid: boolean; receipt: TerminalPromptUsage | null } | null = null; + let previousPromptId: string | undefined; + return { + begin(requestId: string, sessionId: string) { + previousPromptId = active?.receipt?.promptId ?? previousPromptId; + active = { requestId, sessionId, invalid: false, receipt: null }; + }, + observe(direction: "inbound" | "outbound", value: unknown) { + if (!active || active.invalid) return; + const message = record(value); + if (direction === "outbound") { + if (message.method !== "session/prompt" || record(message.params).sessionId !== active.sessionId) return; + if (active.wireId !== undefined || !(id(message.id) || integer(message.id))) { active.invalid = true; return; } + active.wireId = message.id; + return; + } + if (active.wireId === undefined || message.id !== active.wireId || message.method !== undefined) return; + const receipt = message.error === undefined ? grokPromptUsage(record(message.result)._meta, active.sessionId) : null; + if (!receipt || receipt.promptId === previousPromptId) { active.invalid = true; return; } + const bound = { ...receipt, requestId: active.requestId }; + if (active.receipt && JSON.stringify(active.receipt) !== JSON.stringify(bound)) { active.invalid = true; return; } + active.receipt = bound; + }, + snapshot(): TerminalPromptUsage | null { + return active && !active.invalid ? structuredClone(active.receipt) : null; + }, + }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts index 4143f655a68..6640ce97efa 100644 --- a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts +++ b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts @@ -43,6 +43,10 @@ export function persistedAcpxTurnUsage( ): Record | null { const current = record(after); if (current.lastRequestId !== requestId) return null; + const terminal = record(current.terminalPromptUsage); + const priorTerminal = record(record(before).terminalPromptUsage); + if (terminal.requestId === requestId && terminal.promptId !== priorTerminal.promptId + && priorTerminal.requestId !== requestId) return record(terminal.event); const previousReceipts = record(record(before).requestTokenUsage); const receipts = record(current.requestTokenUsage); const added = Object.keys(receipts).filter( diff --git a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs index 2b262d59641..7de94511896 100644 --- a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs @@ -53,6 +53,18 @@ test("the ACPX sidecar schema accepts each versioned message family", () => { } }); +test("usage prices declare turn or session scope without changing legacy cumulative prices", () => { + const event = cost => ({ protocolVersion, sequence: 1, eventType: "runtime.event", runId: "run-1", turnId: "turn-1", + payload: { type: "status", tag: "usage_update", cost } }); + for (const cost of [null, { amount: 0.1, currency: "USD" }, { amount: 0.1, currency: "USD", basis: "turn" }, + { amount: 0.1, currency: "USD", basis: "session_cumulative" }]) { + assert.equal(validate(event(cost)), true, JSON.stringify(validate.errors)); + } + for (const cost of [{ amount: -1 }, { amount: 0.1, basis: "estimated" }, { amount: 0.1, rawProviderReceipt: {} }]) { + assert.equal(validate(event(cost)), false); + } +}); + test("failed responses accept the bounded provider cause and reject extra diagnostic fields", () => { const failure = { schema: "paperclip.provider-failure.v1", provider: "anthropic", phase: "startup", category: "rate_limit", message: "Account rate limit reached.", httpStatus: 429, retryability: "unknown" }; diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index e3611e34991..2de774f90ea 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1632,6 +1632,18 @@ describe("native provider usage normalization", () => { it("does not treat a protocol-default price as free usage", () => { expect(nativeUsageCostUsd({ runDelta: { inputTokens: 20, outputTokens: 10, providerCostUsd: 0 } })).toBeUndefined(); }); + it("attributes Grok API usage while preserving managed billing identity", () => { + const provider = { kind: "acpx", agent: "grok", model: "grok-4.7" } as NativeExecutionInput["provider"]; + expect(nativeUsageBiller(provider)).toBe("xai"); + expect(resolveNativeBilling(provider)).toEqual({ provider: "xai", biller: "xai", billingType: "unknown" }); + expect(resolveNativeBilling(provider, { XAI_API_KEY: "fixture" })) + .toEqual({ provider: "xai", biller: "xai", billingType: "metered_api" }); + const subscription = { provider: "xai", biller: "xai", billingType: "subscription_included" as const }; + expect(resolveNativeBilling(provider, { XAI_API_KEY: "fixture" }, subscription)).toEqual(subscription); + expect(nativeUsageCostUsd({ runDelta: { inputTokens: 30787, outputTokens: 5763, cacheReadTokens: 475008, + cacheWriteTokens: 0, providerCostUsd: 0.336842 } }, provider)).toBe(0.336842); + }); + it("reports the actual runtime billing identity", () => { expect(resolveNativeBilling({ kind: "opencode", model: "openrouter/deepseek/test", permissionMode: "allow" })) .toEqual({ provider: "deepseek", biller: "openrouter", billingType: "unknown" }); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 939e0415066..7c85342d3bf 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -11011,6 +11011,9 @@ export function resolveNativeBilling(provider: NativeExecutionInput["provider"], const biller = nativeUsageBiller(provider); return { provider: biller, biller, billingType: "unknown" }; } + if (provider.kind === "acpx" && provider.agent === "grok") { + return { provider: "xai", biller: "xai", billingType: env.XAI_API_KEY ? "metered_api" : "unknown" }; + } if (provider.kind === "acpx" && provider.agent === "claude") { if (env.CLAUDE_CODE_USE_BEDROCK === "1") return { provider: "anthropic", biller: "aws_bedrock", billingType: "metered_api" }; return { provider: "anthropic", biller: "anthropic", billingType: env.ANTHROPIC_API_KEY ? "metered_api" : env.CLAUDE_CODE_OAUTH_TOKEN ? "subscription_included" : "unknown" }; diff --git a/server/src/services/native-runtime/native-usage-accounting.ts b/server/src/services/native-runtime/native-usage-accounting.ts index 8ef04b1da17..e4e95f12489 100644 --- a/server/src/services/native-runtime/native-usage-accounting.ts +++ b/server/src/services/native-runtime/native-usage-accounting.ts @@ -51,6 +51,7 @@ export function nativeUsageBiller(provider: NativeExecutionInput["provider"]): s if (provider.agent === "cursor") return "cursor"; if (provider.agent === "copilot") return "github"; if (provider.agent === "pi") return "openrouter"; + if (provider.agent === "grok") return "xai"; } return "openai"; }