diff --git a/.changeset/driver-usage-omit-cost.md b/.changeset/driver-usage-omit-cost.md new file mode 100644 index 00000000..80dafc24 --- /dev/null +++ b/.changeset/driver-usage-omit-cost.md @@ -0,0 +1,5 @@ +--- +'@gemstack/framework': patch +--- + +Fix the Claude Code driver reporting `costUsd: 0` when a result line carries token usage but no price. `DriverUsage.costUsd` is documented as omitted (never `0`) when there is no price, because the budget cap reads `0` as "this turn was free" rather than "the price is unknown" (#540). The driver now omits the field in that case, matching the Codex driver and the type's own contract. Claude runs that do report a price are unchanged. diff --git a/packages/framework/src/driver/claude-code.test.ts b/packages/framework/src/driver/claude-code.test.ts index 64b1c5de..07191087 100644 --- a/packages/framework/src/driver/claude-code.test.ts +++ b/packages/framework/src/driver/claude-code.test.ts @@ -49,6 +49,24 @@ test('StreamJsonParser leaves usage off when the result line reports none (#322) assert.deepEqual(p.result(), { text: 'done', sessionId: 's' }) }) +test('StreamJsonParser omits costUsd (never 0) when tokens report but no price (#540)', () => { + const p = new StreamJsonParser() + p.push( + JSON.stringify({ + type: 'result', + subtype: 'success', + result: 'done', + session_id: 's', + usage: { input_tokens: 100, output_tokens: 40, cache_read_input_tokens: 900, cache_creation_input_tokens: 50 }, + }), + ) + const { usage } = p.result() + // Tokens surface; costUsd is absent (unknown), not 0 (which the budget gate reads as free). + assert.equal(usage?.costUsd, undefined) + assert.equal(Object.prototype.hasOwnProperty.call(usage, 'costUsd'), false) + assert.deepEqual(usage, { inputTokens: 100, outputTokens: 40, cacheReadTokens: 900, cacheCreationTokens: 50 }) +}) + test('StreamJsonParser ignores non-JSON noise and falls back to assistant text', () => { const p = new StreamJsonParser() assert.deepEqual(p.push('some banner line'), []) diff --git a/packages/framework/src/driver/claude-code.ts b/packages/framework/src/driver/claude-code.ts index 2a7d9b41..4c71a863 100644 --- a/packages/framework/src/driver/claude-code.ts +++ b/packages/framework/src/driver/claude-code.ts @@ -265,8 +265,10 @@ function parseUsage(obj: Record): DriverUsage | undefined { if (typeof cost !== 'number' && !hasUsage) return undefined const usage = (hasUsage ? raw : {}) as Record const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0) + // Omit costUsd when there is no price, never 0: the budget gate reads 0 as "free" + // and undefined as "unknown" (#540), and Codex reports tokens without a price. return { - costUsd: typeof cost === 'number' && Number.isFinite(cost) ? cost : 0, + ...(typeof cost === 'number' && Number.isFinite(cost) ? { costUsd: cost } : {}), inputTokens: num(usage['input_tokens']), outputTokens: num(usage['output_tokens']), cacheReadTokens: num(usage['cache_read_input_tokens']),