mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
fix(runner): account for complete Grok prompt receipts
Bind terminal Grok usage to its prompt response, preserve per-turn cost across the sidecar contract, and identify direct xAI billing without changing managed credentials. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8b5a0ebe1f
commit
c469b9c218
14 files changed
+300
-15
No files matched your search
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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})];
|
||||
|
||||
@@ -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") {');
|
||||
|
||||
@@ -1199,6 +1199,7 @@ function safeUsage(cost: unknown, breakdown: unknown): Record<string, unknown> {
|
||||
? null
|
||||
: {
|
||||
amount: safeNonNegativeNumber(nativeCost.amount),
|
||||
...(nativeCost.basis === "turn" ? { basis: "turn" } : {}),
|
||||
currency: safeText(text(nativeCost.currency)).slice(0, 16) || null,
|
||||
},
|
||||
breakdown:
|
||||
|
||||
@@ -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(() => {}));
|
||||
|
||||
@@ -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<typeof createGrokPromptUsageCollector>,
|
||||
): AcpxRuntimePort {
|
||||
extensionBoundary.sessionIds = new Set([identity.backendSessionId]);
|
||||
let extensionControls: Promise<void> = 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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
type RecordValue = Record<string, unknown>;
|
||||
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<TerminalPromptUsage, "requestId"> | 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,10 @@ export function persistedAcpxTurnUsage(
|
||||
): Record<string, unknown> | 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(
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user