@link-assistant/hive-mind 1.52.0 → 1.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.52.1
4
+
5
+ ### Patch Changes
6
+
7
+ - d5d3762: Fix calculation bugs and format unification for budget stats using decimal.js-light for precision.
8
+
3
9
  ## 1.52.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.52.0",
3
+ "version": "1.52.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -70,6 +70,7 @@
70
70
  "@sentry/node": "^10.15.0",
71
71
  "@sentry/profiling-node": "^10.15.0",
72
72
  "dayjs": "^1.11.19",
73
+ "decimal.js-light": "^2.5.1",
73
74
  "secretlint": "^11.2.5",
74
75
  "semver": "^7.7.3"
75
76
  },
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+
3
+ import Decimal from 'decimal.js-light';
4
+ import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
5
+
6
+ export const createTokenFieldAvailability = () => ({
7
+ inputTokens: false,
8
+ outputTokens: false,
9
+ reasoningTokens: false,
10
+ cacheReadTokens: false,
11
+ cacheWriteTokens: false,
12
+ });
13
+
14
+ export const createAgentTokenUsage = () => ({
15
+ inputTokens: 0,
16
+ outputTokens: 0,
17
+ reasoningTokens: 0,
18
+ cacheReadTokens: 0,
19
+ cacheWriteTokens: 0,
20
+ totalCost: 0,
21
+ stepCount: 0,
22
+ requestedModelId: null,
23
+ respondedModelId: null,
24
+ contextLimit: null,
25
+ outputLimit: null,
26
+ peakContextUsage: 0,
27
+ tokenFieldAvailability: createTokenFieldAvailability(),
28
+ });
29
+
30
+ const addObservedTokenValue = (usage, source, sourceFieldName, targetFieldName) => {
31
+ if (!source || !Object.hasOwn(source, sourceFieldName)) return;
32
+ usage.tokenFieldAvailability ||= createTokenFieldAvailability();
33
+ usage.tokenFieldAvailability[targetFieldName] = true;
34
+ const value = source[sourceFieldName];
35
+ if (Number.isFinite(value)) usage[targetFieldName] = (usage[targetFieldName] || 0) + value;
36
+ };
37
+
38
+ const getTokenCount = value => (Number.isFinite(value) ? value : 0);
39
+
40
+ export const accumulateAgentStepFinishUsage = (usage, data) => {
41
+ if (!usage || data?.type !== 'step_finish' || !data.part?.tokens) return false;
42
+
43
+ const tokens = data.part.tokens;
44
+ usage.stepCount = (usage.stepCount || 0) + 1;
45
+ usage.tokenFieldAvailability ||= createTokenFieldAvailability();
46
+
47
+ addObservedTokenValue(usage, tokens, 'input', 'inputTokens');
48
+ addObservedTokenValue(usage, tokens, 'output', 'outputTokens');
49
+ addObservedTokenValue(usage, tokens, 'reasoning', 'reasoningTokens');
50
+ if (tokens.cache) {
51
+ addObservedTokenValue(usage, tokens.cache, 'read', 'cacheReadTokens');
52
+ addObservedTokenValue(usage, tokens.cache, 'write', 'cacheWriteTokens');
53
+ }
54
+
55
+ if (Number.isFinite(data.part.cost)) {
56
+ usage.totalCost = new Decimal(usage.totalCost || 0).plus(data.part.cost).toNumber();
57
+ }
58
+
59
+ if (data.part.model) {
60
+ if (data.part.model.requestedModelID) usage.requestedModelId = data.part.model.requestedModelID;
61
+ if (data.part.model.respondedModelID) usage.respondedModelId = data.part.model.respondedModelID;
62
+ }
63
+
64
+ if (data.part.context) {
65
+ if (data.part.context.contextLimit) usage.contextLimit = data.part.context.contextLimit;
66
+ if (data.part.context.outputLimit) usage.outputLimit = data.part.context.outputLimit;
67
+ const stepContextUsage = getTokenCount(tokens.input) + getTokenCount(tokens.cache?.read);
68
+ if (stepContextUsage > (usage.peakContextUsage || 0)) {
69
+ usage.peakContextUsage = stepContextUsage;
70
+ }
71
+ }
72
+
73
+ return true;
74
+ };
75
+
76
+ /**
77
+ * Parse Agent/OpenCode NDJSON output to extract token usage from step_finish events.
78
+ * @param {string} output - Raw JSONL output from the command
79
+ * @returns {Object} Aggregated token usage and cost data
80
+ */
81
+ export const parseAgentTokenUsage = output => {
82
+ const usage = createAgentTokenUsage();
83
+
84
+ for (const rawLine of output.split('\n')) {
85
+ const line = rawLine.trim();
86
+ if (!line || !line.startsWith('{')) continue;
87
+
88
+ try {
89
+ accumulateAgentStepFinishUsage(usage, sanitizeObjectStrings(JSON.parse(line)));
90
+ } catch {
91
+ continue;
92
+ }
93
+ }
94
+
95
+ return usage;
96
+ };
package/src/agent.lib.mjs CHANGED
@@ -18,93 +18,17 @@ import { reportError } from './sentry.lib.mjs';
18
18
  import { timeouts } from './config.lib.mjs';
19
19
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
20
20
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
21
+ import Decimal from 'decimal.js-light';
21
22
  import { agentModels, defaultModels, freeToBaseModelMap } from './models/index.mjs';
23
+ import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage } from './agent-token-usage.lib.mjs';
24
+
25
+ export { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage };
22
26
 
23
27
  // Import pricing functions from claude.lib.mjs
24
28
  // We reuse fetchModelInfo and checkModelVisionCapability to get data from models.dev API
25
29
  const claudeLib = await import('./claude.lib.mjs');
26
30
  const { fetchModelInfo, checkModelVisionCapability } = claudeLib;
27
31
 
28
- /**
29
- * Parse agent JSON output to extract token usage from step_finish events
30
- * Agent outputs NDJSON (newline-delimited JSON) with step_finish events containing token data
31
- * @param {string} output - Raw stdout output from agent command
32
- * @returns {Object} Aggregated token usage and cost data
33
- */
34
- export const parseAgentTokenUsage = output => {
35
- const usage = {
36
- inputTokens: 0,
37
- outputTokens: 0,
38
- reasoningTokens: 0,
39
- cacheReadTokens: 0,
40
- cacheWriteTokens: 0,
41
- totalCost: 0,
42
- stepCount: 0,
43
- // Issue #1526: Track model and context info from step_finish events
44
- requestedModelId: null,
45
- respondedModelId: null,
46
- contextLimit: null,
47
- outputLimit: null,
48
- peakContextUsage: 0, // Track peak context usage across steps
49
- };
50
-
51
- // Try to parse each line as JSON (agent outputs NDJSON format)
52
- const lines = output.split('\n');
53
- for (const line of lines) {
54
- const trimmedLine = line.trim();
55
- if (!trimmedLine || !trimmedLine.startsWith('{')) continue;
56
-
57
- try {
58
- const parsed = sanitizeObjectStrings(JSON.parse(trimmedLine));
59
-
60
- // Look for step_finish events which contain token usage
61
- if (parsed.type === 'step_finish' && parsed.part?.tokens) {
62
- const tokens = parsed.part.tokens;
63
- usage.stepCount++;
64
-
65
- // Add token counts
66
- if (tokens.input) usage.inputTokens += tokens.input;
67
- if (tokens.output) usage.outputTokens += tokens.output;
68
- if (tokens.reasoning) usage.reasoningTokens += tokens.reasoning;
69
-
70
- // Handle cache tokens (can be in different formats)
71
- if (tokens.cache) {
72
- if (tokens.cache.read) usage.cacheReadTokens += tokens.cache.read;
73
- if (tokens.cache.write) usage.cacheWriteTokens += tokens.cache.write;
74
- }
75
-
76
- // Add cost from step_finish (usually 0 for free models like grok-code)
77
- if (parsed.part.cost !== undefined) {
78
- usage.totalCost += parsed.part.cost;
79
- }
80
-
81
- // Issue #1526: Extract model info from step_finish events
82
- if (parsed.part.model) {
83
- if (parsed.part.model.requestedModelID) usage.requestedModelId = parsed.part.model.requestedModelID;
84
- if (parsed.part.model.respondedModelID) usage.respondedModelId = parsed.part.model.respondedModelID;
85
- }
86
-
87
- // Issue #1526: Extract context limits and track peak context usage
88
- if (parsed.part.context) {
89
- if (parsed.part.context.contextLimit) usage.contextLimit = parsed.part.context.contextLimit;
90
- if (parsed.part.context.outputLimit) usage.outputLimit = parsed.part.context.outputLimit;
91
- // Track peak context usage: input_tokens (current request) is the context usage for this step
92
- // The actual context used per request = input tokens + cache_read tokens for that request
93
- const stepContextUsage = (tokens.input || 0) + (tokens.cache?.read || 0);
94
- if (stepContextUsage > usage.peakContextUsage) {
95
- usage.peakContextUsage = stepContextUsage;
96
- }
97
- }
98
- }
99
- } catch {
100
- // Skip lines that aren't valid JSON
101
- continue;
102
- }
103
- }
104
-
105
- return usage;
106
- };
107
-
108
32
  /**
109
33
  * Helper function to get original provider name from provider identifier
110
34
  * Used for calculating public pricing estimates based on original provider prices
@@ -221,13 +145,29 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
221
145
  // Calculate public pricing estimate based on original provider prices
222
146
  // Prices are per 1M tokens, so divide by 1,000,000
223
147
  // All priced components from models.dev: input, output, cache_read, cache_write, reasoning
224
- const inputCost = (tokenUsage.inputTokens * (cost.input || 0)) / 1_000_000;
225
- const outputCost = (tokenUsage.outputTokens * (cost.output || 0)) / 1_000_000;
226
- const cacheReadCost = (tokenUsage.cacheReadTokens * (cost.cache_read || 0)) / 1_000_000;
227
- const cacheWriteCost = (tokenUsage.cacheWriteTokens * (cost.cache_write || 0)) / 1_000_000;
228
- const reasoningCost = (tokenUsage.reasoningTokens * (cost.reasoning || 0)) / 1_000_000;
229
-
230
- const totalCost = inputCost + outputCost + cacheReadCost + cacheWriteCost + reasoningCost;
148
+ const million = new Decimal(1_000_000);
149
+ const inputCost = new Decimal(tokenUsage.inputTokens)
150
+ .mul(cost.input || 0)
151
+ .div(million)
152
+ .toNumber();
153
+ const outputCost = new Decimal(tokenUsage.outputTokens)
154
+ .mul(cost.output || 0)
155
+ .div(million)
156
+ .toNumber();
157
+ const cacheReadCost = new Decimal(tokenUsage.cacheReadTokens)
158
+ .mul(cost.cache_read || 0)
159
+ .div(million)
160
+ .toNumber();
161
+ const cacheWriteCost = new Decimal(tokenUsage.cacheWriteTokens)
162
+ .mul(cost.cache_write || 0)
163
+ .div(million)
164
+ .toNumber();
165
+ const reasoningCost = new Decimal(tokenUsage.reasoningTokens)
166
+ .mul(cost.reasoning || 0)
167
+ .div(million)
168
+ .toNumber();
169
+
170
+ const totalCost = new Decimal(inputCost).plus(outputCost).plus(cacheReadCost).plus(cacheWriteCost).plus(reasoningCost).toNumber();
231
171
 
232
172
  // Determine if this is a free model from OpenCode Zen or Kilo Gateway
233
173
  // Models accessed via OpenCode Zen or Kilo Gateway are free, regardless of original provider pricing
@@ -576,52 +516,8 @@ export const executeAgentCommand = async params => {
576
516
  let agentCompletedSuccessfully = false;
577
517
  // Issue #1250: Accumulate token usage during streaming instead of parsing fullOutput later
578
518
  // This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
579
- const streamingTokenUsage = {
580
- inputTokens: 0,
581
- outputTokens: 0,
582
- reasoningTokens: 0,
583
- cacheReadTokens: 0,
584
- cacheWriteTokens: 0,
585
- totalCost: 0,
586
- stepCount: 0,
587
- // Issue #1526: Track model and context info from step_finish events
588
- requestedModelId: null,
589
- respondedModelId: null,
590
- contextLimit: null,
591
- outputLimit: null,
592
- peakContextUsage: 0,
593
- };
594
- // Helper to accumulate tokens from step_finish events during streaming
595
- const accumulateTokenUsage = data => {
596
- if (data.type === 'step_finish' && data.part?.tokens) {
597
- const tokens = data.part.tokens;
598
- streamingTokenUsage.stepCount++;
599
- if (tokens.input) streamingTokenUsage.inputTokens += tokens.input;
600
- if (tokens.output) streamingTokenUsage.outputTokens += tokens.output;
601
- if (tokens.reasoning) streamingTokenUsage.reasoningTokens += tokens.reasoning;
602
- if (tokens.cache) {
603
- if (tokens.cache.read) streamingTokenUsage.cacheReadTokens += tokens.cache.read;
604
- if (tokens.cache.write) streamingTokenUsage.cacheWriteTokens += tokens.cache.write;
605
- }
606
- if (data.part.cost !== undefined) {
607
- streamingTokenUsage.totalCost += data.part.cost;
608
- }
609
- // Issue #1526: Extract model info from step_finish events
610
- if (data.part.model) {
611
- if (data.part.model.requestedModelID) streamingTokenUsage.requestedModelId = data.part.model.requestedModelID;
612
- if (data.part.model.respondedModelID) streamingTokenUsage.respondedModelId = data.part.model.respondedModelID;
613
- }
614
- // Issue #1526: Extract context limits and track peak context usage
615
- if (data.part.context) {
616
- if (data.part.context.contextLimit) streamingTokenUsage.contextLimit = data.part.context.contextLimit;
617
- if (data.part.context.outputLimit) streamingTokenUsage.outputLimit = data.part.context.outputLimit;
618
- const stepContextUsage = (tokens.input || 0) + (tokens.cache?.read || 0);
619
- if (stepContextUsage > streamingTokenUsage.peakContextUsage) {
620
- streamingTokenUsage.peakContextUsage = stepContextUsage;
621
- }
622
- }
623
- }
624
- };
519
+ const streamingTokenUsage = createAgentTokenUsage();
520
+ const accumulateTokenUsage = data => accumulateAgentStepFinishUsage(streamingTokenUsage, data);
625
521
 
626
522
  for await (const chunk of execCommand.stream()) {
627
523
  if (chunk.type === 'stdout') {
@@ -980,8 +876,10 @@ export const executeAgentCommand = async params => {
980
876
  if (tokenUsage.reasoningTokens > 0) {
981
877
  await log(` Reasoning tokens: ${tokenUsage.reasoningTokens.toLocaleString()}`);
982
878
  }
983
- if (tokenUsage.cacheReadTokens > 0 || tokenUsage.cacheWriteTokens > 0) {
879
+ if (tokenUsage.cacheReadTokens > 0 || tokenUsage.tokenFieldAvailability?.cacheReadTokens) {
984
880
  await log(` Cache read: ${tokenUsage.cacheReadTokens.toLocaleString()}`);
881
+ }
882
+ if (tokenUsage.cacheWriteTokens > 0 || tokenUsage.tokenFieldAvailability?.cacheWriteTokens) {
985
883
  await log(` Cache write: ${tokenUsage.cacheWriteTokens.toLocaleString()}`);
986
884
  }
987
885
 
@@ -3,6 +3,7 @@
3
3
  // Extracted from claude.lib.mjs to maintain file line limits
4
4
 
5
5
  import { formatNumber } from './claude.lib.mjs';
6
+ import Decimal from 'decimal.js-light';
6
7
 
7
8
  /**
8
9
  * Helper: creates a fresh sub-session usage object for tracking tokens between compactification events
@@ -105,11 +106,11 @@ export const displayModelUsage = async (usage, log) => {
105
106
  ];
106
107
  for (const { key, label } of types) {
107
108
  if (breakdown[key].tokens > 0) {
108
- await log(` ${label}: ${formatNumber(breakdown[key].tokens)} tokens Ɨ $${breakdown[key].costPerMillion}/M = $${breakdown[key].cost.toFixed(6)}`);
109
+ await log(` ${label}: ${formatNumber(breakdown[key].tokens)} tokens Ɨ $${breakdown[key].costPerMillion}/M = $${new Decimal(breakdown[key].cost).toFixed(6)}`);
109
110
  }
110
111
  }
111
112
  await log(' ─────────────────────────────────');
112
- await log(` Total: $${usage.costUSD.toFixed(6)}`);
113
+ await log(` Total: $${new Decimal(usage.costUSD).toFixed(6)}`);
113
114
  } else if (usage.modelInfo === null) {
114
115
  await log('');
115
116
  await log(' Cost: Not available (could not fetch pricing)');
@@ -126,18 +127,19 @@ export const displayModelUsage = async (usage, log) => {
126
127
  export const displayCostComparison = async (publicCost, anthropicCost, log) => {
127
128
  const hasPublic = publicCost !== null && publicCost !== undefined;
128
129
  const hasAnthropic = anthropicCost !== null && anthropicCost !== undefined;
129
- // Issue #1557: When both costs match, show simplified format
130
- if (hasPublic && hasAnthropic && publicCost.toFixed(6) === anthropicCost.toFixed(6)) {
131
- await log(`\n šŸ’° Cost: $${anthropicCost.toFixed(6)}`);
130
+ const publicDec = hasPublic ? new Decimal(publicCost) : null;
131
+ const anthropicDec = hasAnthropic ? new Decimal(anthropicCost) : null;
132
+ if (publicDec && anthropicDec && publicDec.toFixed(6) === anthropicDec.toFixed(6)) {
133
+ await log(`\n šŸ’° Cost: $${anthropicDec.toFixed(6)}`);
132
134
  return;
133
135
  }
134
136
  await log('\n šŸ’° Cost estimation:');
135
- await log(` Public pricing estimate: ${hasPublic ? `$${publicCost.toFixed(6)}` : 'unknown'}`);
136
- await log(` Calculated by Anthropic: ${hasAnthropic ? `$${anthropicCost.toFixed(6)}` : 'unknown'}`);
137
- if (hasPublic && hasAnthropic) {
138
- const difference = anthropicCost - publicCost;
139
- const percentDiff = publicCost > 0 ? (difference / publicCost) * 100 : 0;
140
- await log(` Difference: $${difference.toFixed(6)} (${percentDiff > 0 ? '+' : ''}${percentDiff.toFixed(2)}%)`);
137
+ await log(` Public pricing estimate: ${publicDec ? `$${publicDec.toFixed(6)}` : 'unknown'}`);
138
+ await log(` Calculated by Anthropic: ${anthropicDec ? `$${anthropicDec.toFixed(6)}` : 'unknown'}`);
139
+ if (publicDec && anthropicDec) {
140
+ const difference = anthropicDec.minus(publicDec);
141
+ const percentDiff = publicDec.gt(0) ? difference.div(publicDec).mul(100) : new Decimal(0);
142
+ await log(` Difference: $${difference.toFixed(6)} (${percentDiff.gt(0) ? '+' : ''}${percentDiff.toFixed(2)}%)`);
141
143
  } else {
142
144
  await log(' Difference: unknown');
143
145
  }
@@ -169,11 +171,10 @@ export const displayBudgetStats = async (usage, tokenUsage, log) => {
169
171
  const peakContext = usage.peakContextUsage || 0;
170
172
 
171
173
  if (hasMultipleSubSessions) {
174
+ // Issue #1600: Unified format — numbered list without "Context window:" prefix
172
175
  for (let i = 0; i < subSessions.length; i++) {
173
176
  const sub = subSessions[i];
174
177
  const subPeak = sub.peakContextUsage || 0;
175
- // Issue #1539: Only use peak per-request context for context window display.
176
- // Issue #1547: Percentage before unit label: X / Y (Z%) input tokens
177
178
  const parts = [];
178
179
  if (contextLimit && subPeak > 0) {
179
180
  const pct = ((subPeak / contextLimit) * 100).toFixed(0);
@@ -184,12 +185,10 @@ export const displayBudgetStats = async (usage, tokenUsage, log) => {
184
185
  parts.push(`${formatNumber(sub.outputTokens)} / ${formatNumber(outputLimit)} (${outPct}%) output tokens`);
185
186
  }
186
187
  if (parts.length > 0) {
187
- await log(` ${i + 1}. Context window: ${parts.join(', ')}`);
188
+ await log(` ${i + 1}. ${parts.join(', ')}`);
188
189
  }
189
190
  }
190
191
  } else if (peakContext > 0) {
191
- // Single sub-session with known peak: single-line format
192
- // Issue #1547: Percentage before unit label
193
192
  const parts = [];
194
193
  if (contextLimit) {
195
194
  const pct = ((peakContext / contextLimit) * 100).toFixed(0);
@@ -200,11 +199,9 @@ export const displayBudgetStats = async (usage, tokenUsage, log) => {
200
199
  parts.push(`${formatNumber(usage.outputTokens)} / ${formatNumber(outputLimit)} (${outPct}%) output tokens`);
201
200
  }
202
201
  if (parts.length > 0) {
203
- await log(` Context window: ${parts.join(', ')}`);
202
+ await log(` - ${parts.join(', ')}`);
204
203
  }
205
204
  }
206
- // Issue #1539: When peakContextUsage is unknown, skip context window line entirely.
207
- // Cumulative totals are shown on the Total line below — no duplication needed.
208
205
 
209
206
  // Cumulative totals — single line
210
207
  // Issue #1547: Parenthesized cached format and consistent output format
@@ -303,14 +300,13 @@ const formatTokensCompact = tokens => {
303
300
  * @returns {string} Formatted sub-sessions string
304
301
  */
305
302
  /**
306
- * Issue #1526: Format sub-sessions list using numbered single-line format.
307
- * Each sub-session gets: "N. Context window: X / Y input tokens (Z%), A / B output tokens (W%)"
303
+ * Issue #1600: Format sub-sessions list using numbered single-line format.
304
+ * Each sub-session gets: "N. X / Y (Z%) input tokens, A / B (W%) output tokens"
308
305
  */
309
306
  const formatSubSessionsList = (subSessions, contextLimit, outputLimit) => {
310
307
  let result = '';
311
308
  for (let i = 0; i < subSessions.length; i++) {
312
309
  const sub = subSessions[i];
313
- // Issue #1539: Only use peak per-request context; skip context display when unknown
314
310
  const subPeakContext = sub.peakContextUsage || 0;
315
311
  result += formatContextOutputLine(subPeakContext, contextLimit, sub.outputTokens, outputLimit, `${i + 1}. `);
316
312
  }
@@ -318,10 +314,7 @@ const formatSubSessionsList = (subSessions, contextLimit, outputLimit) => {
318
314
  };
319
315
 
320
316
  /**
321
- * Issue #1526: Build a single-line context window + output tokens string.
322
- * Issue #1539: Only show context window when peakContext > 0 (per-request peak known).
323
- * When peakContext is 0 (unknown), context part is omitted to avoid misleading percentages.
324
- * Format: "- Context window: X / Y input tokens (Z%), A / B output tokens (W%)"
317
+ * Issue #1600: Build a single-line context + output tokens string (unified format, no "Context window:" prefix).
325
318
  * @param {number} peakContext - Peak context usage (0 if unknown — context display skipped)
326
319
  * @param {number} contextLimit - Context window limit (null if unknown)
327
320
  * @param {number} outputTokens - Output tokens used
@@ -331,22 +324,16 @@ const formatSubSessionsList = (subSessions, contextLimit, outputLimit) => {
331
324
  */
332
325
  const formatContextOutputLine = (peakContext, contextLimit, outputTokens, outputLimit, prefix = '- ') => {
333
326
  const parts = [];
334
- if (contextLimit) {
335
- // Issue #1539: Only use peak per-request context for context window display.
336
- // When peak is unknown (e.g., model only from result JSON, not in JSONL),
337
- // skip context display. Cumulative totals across all requests are not valid
338
- // context window metrics and produce impossible percentages (e.g. 250%).
339
- if (peakContext > 0) {
340
- const pct = ((peakContext / contextLimit) * 100).toFixed(0);
341
- parts.push(`${formatTokensCompact(peakContext)} / ${formatTokensCompact(contextLimit)} (${pct}%) input tokens`);
342
- }
327
+ if (contextLimit && peakContext > 0) {
328
+ const pct = ((peakContext / contextLimit) * 100).toFixed(0);
329
+ parts.push(`${formatTokensCompact(peakContext)} / ${formatTokensCompact(contextLimit)} (${pct}%) input tokens`);
343
330
  }
344
331
  if (outputLimit) {
345
332
  const outPct = ((outputTokens / outputLimit) * 100).toFixed(0);
346
333
  parts.push(`${formatTokensCompact(outputTokens)} / ${formatTokensCompact(outputLimit)} (${outPct}%) output tokens`);
347
334
  }
348
335
  if (parts.length === 0) return '';
349
- return `\n${prefix}Context window: ${parts.join(', ')}`;
336
+ return `\n${prefix}${parts.join(', ')}`;
350
337
  };
351
338
 
352
339
  /**
@@ -445,31 +432,37 @@ export const buildBudgetStatsString = (tokenUsage, subAgentCalls = null) => {
445
432
 
446
433
  // Issue #1590: Check if this model was used as a sub-agent
447
434
  const callCount = getSubAgentCallCount(modelId, subAgentCallCounts);
435
+ const isPrimaryModel = !isMultiModel || modelId === modelIds[0];
436
+ const showSubSessions = hasMultipleSubSessions && isPrimaryModel;
448
437
 
449
438
  if (isMultiModel) {
450
439
  // Issue #1590: Show sub-agent call count alongside model name
440
+ // Issue #1600: Show session segment count for primary model
451
441
  if (callCount > 1) {
452
442
  stats += `\n\n**${modelName}:** (${callCount} sub-agent calls)`;
443
+ } else if (showSubSessions) {
444
+ stats += `\n\n**${modelName}:** (${subSessions.length} session segments)`;
453
445
  } else {
454
446
  stats += `\n\n**${modelName}:**`;
455
447
  }
448
+ } else if (showSubSessions) {
449
+ stats += `\n\n**${modelName}:** (${subSessions.length} session segments)`;
456
450
  }
457
451
 
458
452
  const peakContext = usage.peakContextUsage || 0;
459
453
 
460
- if (hasMultipleSubSessions && (!isMultiModel || modelId === modelIds[0])) {
461
- // Issue #1547: Show sub-sessions under the primary model heading (not globally).
462
- // For single-model sessions, show under that model. For multi-model, under the first model.
454
+ if (showSubSessions) {
455
+ // Issue #1600: Unified format — no "Context window:" prefix, same format as sub-agent calls
463
456
  stats += formatSubSessionsList(subSessions, contextLimit, outputLimit);
464
457
  } else if (peakContext > 0) {
465
- // Issue #1526: Single line format for context window + output tokens
466
458
  stats += formatContextOutputLine(peakContext, contextLimit, usage.outputTokens, outputLimit, '- ');
459
+ } else if (outputLimit && callCount <= 1) {
460
+ // Issue #1600: Show output-only detalization for sub-agent single sessions
461
+ const outPct = ((usage.outputTokens / outputLimit) * 100).toFixed(0);
462
+ stats += `\n- ${formatTokensCompact(usage.outputTokens)} / ${formatTokensCompact(outputLimit)} (${outPct}%) output tokens`;
467
463
  }
468
- // Issue #1539: When peakContextUsage is unknown, skip context window line entirely.
469
- // Cumulative totals are shown on the Total line below — no duplication needed.
470
464
 
471
465
  // Cumulative totals per model: input tokens + cached shown separately
472
- // Issue #1547: Parenthesized cached format: (X + Y cached) input tokens
473
466
  const totalInputNonCached = usage.inputTokens + usage.cacheCreationTokens;
474
467
  const cachedTokens = usage.cacheReadTokens;
475
468
  let totalLine;
@@ -479,36 +472,25 @@ export const buildBudgetStatsString = (tokenUsage, subAgentCalls = null) => {
479
472
  totalLine = `${formatTokensCompact(totalInputNonCached)} input tokens`;
480
473
  }
481
474
 
482
- // Issue #1547: Consistent output format — use X / Y (Z%) output tokens when limit known
483
- // Issue #1590: When multiple sub-agent calls exist, show total output without misleading
484
- // per-call percentage (e.g., 530% is sum across 12 calls, not a single call)
485
- if (peakContext === 0 && outputLimit) {
486
- if (callCount > 1) {
487
- // Show total output without percentage (percentage is misleading for aggregated sub-agent calls)
488
- totalLine += `, ${formatTokensCompact(usage.outputTokens)} output tokens`;
489
- } else {
490
- const outPct = ((usage.outputTokens / outputLimit) * 100).toFixed(0);
491
- totalLine += `, ${formatTokensCompact(usage.outputTokens)} / ${formatTokensCompact(outputLimit)} (${outPct}%) output tokens`;
492
- }
475
+ // Issue #1600: Output tokens on Total line — skip percentage if already shown above or aggregated
476
+ if (callCount > 1) {
477
+ totalLine += `, ${formatTokensCompact(usage.outputTokens)} output tokens`;
493
478
  } else {
494
479
  totalLine += `, ${formatTokensCompact(usage.outputTokens)} output tokens`;
495
480
  }
496
481
 
497
- // Issue #1508: Show per-model cost when available
482
+ // Issue #1600: Use Decimal for cost display precision
498
483
  if (usage.costUSD !== null && usage.costUSD !== undefined) {
499
- totalLine += `, $${usage.costUSD.toFixed(6)} cost`;
484
+ totalLine += `, $${new Decimal(usage.costUSD).toFixed(6)} cost`;
500
485
  }
501
486
 
502
487
  // Issue #1590: Show individual sub-agent call list when multiple calls exist
503
- // Total line appears AFTER the sub-agent calls list (not before)
504
488
  if (callCount > 1) {
505
489
  const matchingCalls = getSubAgentCallsForModel(modelId, validSubAgentCalls);
506
- // Issue #1590: Check if actual per-call usage data is available from parent_tool_use_id tracking
507
490
  const hasActualUsage = matchingCalls.some(c => c.usage && (c.usage.inputTokens > 0 || c.usage.outputTokens > 0 || c.usage.cacheReadTokens > 0 || c.usage.cacheCreationTokens > 0));
508
491
 
509
492
  stats += `\n\nSub-agent calls:`;
510
493
  if (hasActualUsage) {
511
- // Show actual per-call usage with limits and percentages (same format as sub-sessions)
512
494
  for (let i = 0; i < matchingCalls.length; i++) {
513
495
  const call = matchingCalls[i];
514
496
  const cu = call.usage || {};
@@ -530,7 +512,6 @@ export const buildBudgetStatsString = (tokenUsage, subAgentCalls = null) => {
530
512
  stats += `\n${i + 1}. ${parts.join(', ')}`;
531
513
  }
532
514
  } else {
533
- // Fallback: show estimates with limits and percentages when actual per-call data is not available
534
515
  const avgInput = Math.round((totalInputNonCached + cachedTokens) / callCount);
535
516
  const avgOutput = Math.round(usage.outputTokens / callCount);
536
517
  for (let i = 0; i < matchingCalls.length; i++) {
@@ -549,7 +530,6 @@ export const buildBudgetStatsString = (tokenUsage, subAgentCalls = null) => {
549
530
  }
550
531
  stats += `\n${i + 1}. ${parts.join(', ')}`;
551
532
  }
552
- // Note about estimates only when using fallback
553
533
  stats += `\n\n_Per-call values are estimates (total Ć· ${callCount}). Exact per-call breakdown requires [upstream support](https://github.com/anthropics/claude-code/issues/46520)._`;
554
534
  }
555
535
  }
@@ -13,6 +13,7 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
13
13
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
14
14
  import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
15
15
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
16
+ import Decimal from 'decimal.js-light';
16
17
  import { displayBudgetStats, createEmptySubSessionUsage, accumulateModelUsage, displayModelUsage, displayCostComparison, mergeResultModelUsage, createSubAgentCallEntry, accumulateSubAgentUsage } from './claude.budget-stats.lib.mjs';
17
18
  import { buildClaudeResumeCommand } from './claude.command-builder.lib.mjs';
18
19
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
@@ -429,51 +430,48 @@ export const checkModelVisionCapability = async modelId => {
429
430
  return false;
430
431
  }
431
432
  };
432
- /** Calculate USD cost for a model's usage with detailed breakdown */
433
+ /** Calculate USD cost for a model's usage with detailed breakdown (Issue #1600: uses Decimal for precision) */
433
434
  export const calculateModelCost = (usage, modelInfo, includeBreakdown = false) => {
434
435
  if (!modelInfo || !modelInfo.cost) {
435
436
  return includeBreakdown ? { total: 0, breakdown: null } : 0;
436
437
  }
437
438
  const cost = modelInfo.cost;
439
+ const million = new Decimal(1000000);
438
440
  const breakdown = {
439
441
  input: { tokens: 0, costPerMillion: 0, cost: 0 },
440
442
  cacheWrite: { tokens: 0, costPerMillion: 0, cost: 0 },
441
443
  cacheRead: { tokens: 0, costPerMillion: 0, cost: 0 },
442
444
  output: { tokens: 0, costPerMillion: 0, cost: 0 },
443
445
  };
444
- // Input tokens cost (per million tokens)
445
446
  if (usage.inputTokens && cost.input) {
446
447
  breakdown.input = {
447
448
  tokens: usage.inputTokens,
448
449
  costPerMillion: cost.input,
449
- cost: (usage.inputTokens / 1000000) * cost.input,
450
+ cost: new Decimal(usage.inputTokens).div(million).mul(new Decimal(cost.input)).toNumber(),
450
451
  };
451
452
  }
452
- // Cache creation tokens cost
453
453
  if (usage.cacheCreationTokens && cost.cache_write) {
454
454
  breakdown.cacheWrite = {
455
455
  tokens: usage.cacheCreationTokens,
456
456
  costPerMillion: cost.cache_write,
457
- cost: (usage.cacheCreationTokens / 1000000) * cost.cache_write,
457
+ cost: new Decimal(usage.cacheCreationTokens).div(million).mul(new Decimal(cost.cache_write)).toNumber(),
458
458
  };
459
459
  }
460
- // Cache read tokens cost
461
460
  if (usage.cacheReadTokens && cost.cache_read) {
462
461
  breakdown.cacheRead = {
463
462
  tokens: usage.cacheReadTokens,
464
463
  costPerMillion: cost.cache_read,
465
- cost: (usage.cacheReadTokens / 1000000) * cost.cache_read,
464
+ cost: new Decimal(usage.cacheReadTokens).div(million).mul(new Decimal(cost.cache_read)).toNumber(),
466
465
  };
467
466
  }
468
- // Output tokens cost
469
467
  if (usage.outputTokens && cost.output) {
470
468
  breakdown.output = {
471
469
  tokens: usage.outputTokens,
472
470
  costPerMillion: cost.output,
473
- cost: (usage.outputTokens / 1000000) * cost.output,
471
+ cost: new Decimal(usage.outputTokens).div(million).mul(new Decimal(cost.output)).toNumber(),
474
472
  };
475
473
  }
476
- const totalCost = breakdown.input.cost + breakdown.cacheWrite.cost + breakdown.cacheRead.cost + breakdown.output.cost;
474
+ const totalCost = new Decimal(breakdown.input.cost).plus(breakdown.cacheWrite.cost).plus(breakdown.cacheRead.cost).plus(breakdown.output.cost).toNumber();
477
475
  if (includeBreakdown) {
478
476
  return {
479
477
  total: totalCost,
@@ -619,7 +617,7 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
619
617
  let totalCacheCreationTokens = 0;
620
618
  let totalCacheReadTokens = 0;
621
619
  let totalOutputTokens = 0;
622
- let totalCostUSD = 0;
620
+ let totalCostDecimal = new Decimal(0);
623
621
  let hasCostData = false;
624
622
  for (const usage of Object.values(modelUsage)) {
625
623
  totalInputTokens += usage.inputTokens;
@@ -627,7 +625,7 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
627
625
  totalCacheReadTokens += usage.cacheReadTokens;
628
626
  totalOutputTokens += usage.outputTokens;
629
627
  if (usage.costUSD !== null) {
630
- totalCostUSD += usage.costUSD;
628
+ totalCostDecimal = totalCostDecimal.plus(new Decimal(usage.costUSD));
631
629
  hasCostData = true;
632
630
  }
633
631
  }
@@ -642,7 +640,7 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
642
640
  cacheReadTokens: totalCacheReadTokens,
643
641
  outputTokens: totalOutputTokens,
644
642
  totalTokens,
645
- totalCostUSD: hasCostData ? totalCostUSD : null,
643
+ totalCostUSD: hasCostData ? totalCostDecimal.toNumber() : null,
646
644
  // Issue #1501: Peak context usage (max single-request fill) and dedup stats
647
645
  peakContextUsage: globalPeakContext,
648
646
  duplicateEntriesSkipped: duplicateCount,
package/src/codex.lib.mjs CHANGED
@@ -22,7 +22,7 @@ import { mapModelToId, resolveCodexReasoningEffort } from './codex.options.lib.m
22
22
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
23
23
  import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
24
24
 
25
- const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens'];
25
+ const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
26
26
  const getCodexExecEnv = (verbose = false) => (verbose ? { ...process.env, RUST_LOG: 'debug' } : { ...process.env });
27
27
  const CODEX_MODEL_DIAGNOSTIC_PATHS = [
28
28
  ['model', data => data?.model],
@@ -32,6 +32,40 @@ const CODEX_MODEL_DIAGNOSTIC_PATHS = [
32
32
  ['message.model', data => data?.message?.model],
33
33
  ];
34
34
 
35
+ const createCodexTokenFieldAvailability = () => ({
36
+ inputTokens: false,
37
+ outputTokens: false,
38
+ reasoningTokens: false,
39
+ cacheReadTokens: false,
40
+ cacheWriteTokens: false,
41
+ });
42
+
43
+ const hasOwnPath = (object, pathName) => {
44
+ let cursor = object;
45
+ for (const part of pathName.split('.')) {
46
+ if (!cursor || typeof cursor !== 'object' || !Object.hasOwn(cursor, part)) return false;
47
+ cursor = cursor[part];
48
+ }
49
+ return true;
50
+ };
51
+
52
+ const getPathValue = (object, pathName) => pathName.split('.').reduce((cursor, part) => cursor?.[part], object);
53
+
54
+ const getFirstObservedNumber = (object, pathNames) => {
55
+ for (const pathName of pathNames) {
56
+ if (!hasOwnPath(object, pathName)) continue;
57
+ const value = getPathValue(object, pathName);
58
+ return Number.isFinite(value) ? value : 0;
59
+ }
60
+ return 0;
61
+ };
62
+
63
+ const hasAnyObservedPath = (object, pathNames) => pathNames.some(pathName => hasOwnPath(object, pathName));
64
+
65
+ const CODEX_CACHE_READ_USAGE_PATHS = ['cached_input_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens'];
66
+ const CODEX_CACHE_WRITE_USAGE_PATHS = ['cache_write_tokens', 'cache_creation_input_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens'];
67
+ const CODEX_REASONING_USAGE_PATHS = ['reasoning_tokens', 'output_tokens_details.reasoning_tokens'];
68
+
35
69
  export const createCodexTokenUsage = requestedModelId => ({
36
70
  inputTokens: 0,
37
71
  outputTokens: 0,
@@ -42,6 +76,7 @@ export const createCodexTokenUsage = requestedModelId => ({
42
76
  stepCount: 0,
43
77
  requestedModelId: requestedModelId || null,
44
78
  respondedModelId: requestedModelId || null,
79
+ tokenFieldAvailability: createCodexTokenFieldAvailability(),
45
80
  });
46
81
 
47
82
  const createEmptyCodexItemUsage = () => ({
@@ -162,6 +197,7 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
162
197
  observedModelDiagnosticPaths: state.observedModelDiagnosticPaths || [],
163
198
  };
164
199
 
200
+ nextState.tokenUsage.tokenFieldAvailability ||= createCodexTokenFieldAvailability();
165
201
  const observedModelPaths = new Set(nextState.observedModelDiagnosticPaths);
166
202
 
167
203
  for (const rawLine of output.split('\n')) {
@@ -205,17 +241,28 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
205
241
  }
206
242
 
207
243
  if (eventType === 'turn.completed' && data.usage && typeof data.usage === 'object') {
208
- const inputTokens = Number.isFinite(data.usage.input_tokens) ? data.usage.input_tokens : 0;
209
- const cachedInputTokens = Number.isFinite(data.usage.cached_input_tokens) ? data.usage.cached_input_tokens : 0;
210
- const outputTokens = Number.isFinite(data.usage.output_tokens) ? data.usage.output_tokens : 0;
244
+ const inputTokens = getFirstObservedNumber(data.usage, ['input_tokens']);
245
+ const cachedInputTokens = getFirstObservedNumber(data.usage, CODEX_CACHE_READ_USAGE_PATHS);
246
+ const cacheWriteTokens = getFirstObservedNumber(data.usage, CODEX_CACHE_WRITE_USAGE_PATHS);
247
+ const outputTokens = getFirstObservedNumber(data.usage, ['output_tokens']);
248
+ const reasoningTokens = getFirstObservedNumber(data.usage, CODEX_REASONING_USAGE_PATHS);
249
+
250
+ if (hasOwnPath(data.usage, 'input_tokens')) nextState.tokenUsage.tokenFieldAvailability.inputTokens = true;
251
+ if (hasAnyObservedPath(data.usage, CODEX_CACHE_READ_USAGE_PATHS)) nextState.tokenUsage.tokenFieldAvailability.cacheReadTokens = true;
252
+ if (hasAnyObservedPath(data.usage, CODEX_CACHE_WRITE_USAGE_PATHS)) nextState.tokenUsage.tokenFieldAvailability.cacheWriteTokens = true;
253
+ if (hasOwnPath(data.usage, 'output_tokens')) nextState.tokenUsage.tokenFieldAvailability.outputTokens = true;
254
+ if (hasAnyObservedPath(data.usage, CODEX_REASONING_USAGE_PATHS)) nextState.tokenUsage.tokenFieldAvailability.reasoningTokens = true;
255
+
211
256
  const nonCachedInputTokens = Math.max(0, inputTokens - cachedInputTokens);
212
257
  nextState.tokenUsage.inputTokens += nonCachedInputTokens;
213
258
  nextState.tokenUsage.cacheReadTokens += cachedInputTokens;
259
+ nextState.tokenUsage.cacheWriteTokens += cacheWriteTokens;
214
260
  nextState.tokenUsage.outputTokens += outputTokens;
261
+ nextState.tokenUsage.reasoningTokens += reasoningTokens;
215
262
  nextState.tokenUsage.totalTokens = nextState.tokenUsage.inputTokens + nextState.tokenUsage.cacheReadTokens + nextState.tokenUsage.outputTokens + nextState.tokenUsage.cacheWriteTokens;
216
263
  nextState.tokenUsage.stepCount += 1;
217
264
 
218
- const usageFieldSet = CODEX_USAGE_FIELD_NAMES.filter(fieldName => Object.hasOwn(data.usage, fieldName));
265
+ const usageFieldSet = CODEX_USAGE_FIELD_NAMES.filter(fieldName => hasOwnPath(data.usage, fieldName));
219
266
  if (usageFieldSet.length > 0) nextState.observedUsageFieldSets.push(usageFieldSet);
220
267
  }
221
268
 
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+
3
+ import Decimal from 'decimal.js-light';
4
+
5
+ const formatTokenCount = value => (Number.isFinite(value) ? value : 0).toLocaleString();
6
+
7
+ const isObservedTokenField = (usage, fieldName) => {
8
+ const value = usage?.[fieldName];
9
+ if (Number.isFinite(value) && value > 0) return true;
10
+ if (usage?.tokenFieldAvailability?.[fieldName] === true) return true;
11
+ if (Array.isArray(usage?.availableTokenFields) && usage.availableTokenFields.includes(fieldName)) return true;
12
+ return false;
13
+ };
14
+
15
+ const buildTokenUsageString = tokenUsage => {
16
+ const parts = [`${formatTokenCount(tokenUsage.inputTokens)} input`, `${formatTokenCount(tokenUsage.outputTokens)} output`];
17
+ if (isObservedTokenField(tokenUsage, 'reasoningTokens')) parts.push(`${formatTokenCount(tokenUsage.reasoningTokens)} reasoning`);
18
+ if (isObservedTokenField(tokenUsage, 'cacheReadTokens')) parts.push(`${formatTokenCount(tokenUsage.cacheReadTokens)} cache read`);
19
+ if (isObservedTokenField(tokenUsage, 'cacheWriteTokens')) parts.push(`${formatTokenCount(tokenUsage.cacheWriteTokens)} cache write`);
20
+ return `\n- Token usage: ${parts.join(', ')}`;
21
+ };
22
+
23
+ /** Build cost estimation string for log comments (Issue #1250, Issue #1557, Issue #1600: Decimal precision) */
24
+ export const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) => {
25
+ const hasPublic = totalCostUSD !== null && totalCostUSD !== undefined;
26
+ const hasAnthropic = anthropicTotalCostUSD !== null && anthropicTotalCostUSD !== undefined;
27
+ const hasPricing = pricingInfo && (pricingInfo.modelName || pricingInfo.tokenUsage || pricingInfo.isFreeModel || pricingInfo.isOpencodeFreeModel);
28
+ const hasOpencodeCost = pricingInfo?.opencodeCost !== null && pricingInfo?.opencodeCost !== undefined;
29
+ if (!hasPublic && !hasAnthropic && !hasPricing && !hasOpencodeCost) return '';
30
+ const publicDec = hasPublic ? new Decimal(totalCostUSD) : null;
31
+ const anthropicDec = hasAnthropic ? new Decimal(anthropicTotalCostUSD) : null;
32
+ if (publicDec && anthropicDec && publicDec.toFixed(6) === anthropicDec.toFixed(6)) return `\n\n### šŸ’° Cost: **$${anthropicDec.toFixed(6)}**`;
33
+ let costInfo = '\n\n### šŸ’° **Cost estimation:**';
34
+ if (pricingInfo?.modelName) {
35
+ costInfo += `\n- Model: ${pricingInfo.modelName}`;
36
+ if (pricingInfo.provider) costInfo += `\n- Provider: ${pricingInfo.provider}`;
37
+ }
38
+ if (hasPublic) {
39
+ if (pricingInfo?.isFreeModel && publicDec.eq(0) && !pricingInfo?.baseModelName) {
40
+ costInfo += '\n- Public pricing estimate: $0.00 (Free model)';
41
+ } else {
42
+ let pricingRef = '';
43
+ if (pricingInfo?.baseModelName && pricingInfo?.originalProvider) {
44
+ pricingRef = ` (based on ${pricingInfo.originalProvider} ${pricingInfo.baseModelName} prices)`;
45
+ } else if (pricingInfo?.originalProvider) {
46
+ pricingRef = ` (based on ${pricingInfo.originalProvider} prices)`;
47
+ }
48
+ costInfo += `\n- Public pricing estimate: $${publicDec.toFixed(6)}${pricingRef}`;
49
+ }
50
+ } else if (hasPricing) {
51
+ costInfo += '\n- Public pricing estimate: unknown';
52
+ }
53
+ if (hasOpencodeCost) {
54
+ if (pricingInfo.isOpencodeFreeModel) {
55
+ costInfo += '\n- Calculated by OpenCode Zen: $0.00 (Free model)';
56
+ } else {
57
+ costInfo += `\n- Calculated by OpenCode Zen: $${new Decimal(pricingInfo.opencodeCost).toFixed(6)}`;
58
+ }
59
+ }
60
+ if (pricingInfo?.tokenUsage) costInfo += buildTokenUsageString(pricingInfo.tokenUsage);
61
+ if (hasAnthropic) {
62
+ costInfo += `\n- Calculated by Anthropic: $${anthropicDec.toFixed(6)}`;
63
+ if (hasPublic) {
64
+ const diff = anthropicDec.minus(publicDec);
65
+ const pct = publicDec.gt(0) ? diff.div(publicDec).mul(100) : new Decimal(0);
66
+ costInfo += `\n- Difference: $${diff.toFixed(6)} (${pct.gt(0) ? '+' : ''}${pct.toFixed(2)}%)`;
67
+ }
68
+ }
69
+ return costInfo;
70
+ };
@@ -14,60 +14,8 @@ import { formatResetTimeWithRelative } from './usage-limit.lib.mjs'; // See: htt
14
14
  import { getToolDisplayName, getModelInfoForComment } from './models/index.mjs';
15
15
  export { getToolDisplayName }; // Re-export for use by other modules
16
16
  import { buildBudgetStatsString } from './claude.budget-stats.lib.mjs';
17
-
18
- /** Build cost estimation string for log comments (Issue #1250, Issue #1557) */
19
- const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) => {
20
- const hasPublic = totalCostUSD !== null && totalCostUSD !== undefined;
21
- const hasAnthropic = anthropicTotalCostUSD !== null && anthropicTotalCostUSD !== undefined;
22
- const hasPricing = pricingInfo && (pricingInfo.modelName || pricingInfo.tokenUsage || pricingInfo.isFreeModel || pricingInfo.isOpencodeFreeModel);
23
- const hasOpencodeCost = pricingInfo?.opencodeCost !== null && pricingInfo?.opencodeCost !== undefined;
24
- if (!hasPublic && !hasAnthropic && !hasPricing && !hasOpencodeCost) return '';
25
- // Issue #1557: Simplified display when public and Anthropic costs match
26
- if (hasPublic && hasAnthropic && totalCostUSD.toFixed(6) === anthropicTotalCostUSD.toFixed(6)) return `\n\n### šŸ’° Cost: **$${anthropicTotalCostUSD.toFixed(6)}**`;
27
- let costInfo = '\n\n### šŸ’° **Cost estimation:**';
28
- if (pricingInfo?.modelName) {
29
- costInfo += `\n- Model: ${pricingInfo.modelName}`;
30
- if (pricingInfo.provider) costInfo += `\n- Provider: ${pricingInfo.provider}`;
31
- }
32
- if (hasPublic) {
33
- if (pricingInfo?.isFreeModel && totalCostUSD === 0 && !pricingInfo?.baseModelName) {
34
- costInfo += '\n- Public pricing estimate: $0.00 (Free model)';
35
- } else {
36
- let pricingRef = '';
37
- if (pricingInfo?.baseModelName && pricingInfo?.originalProvider) {
38
- pricingRef = ` (based on ${pricingInfo.originalProvider} ${pricingInfo.baseModelName} prices)`;
39
- } else if (pricingInfo?.originalProvider) {
40
- pricingRef = ` (based on ${pricingInfo.originalProvider} prices)`;
41
- }
42
- costInfo += `\n- Public pricing estimate: $${totalCostUSD.toFixed(6)}${pricingRef}`;
43
- }
44
- } else if (hasPricing) {
45
- costInfo += '\n- Public pricing estimate: unknown';
46
- }
47
- if (hasOpencodeCost) {
48
- if (pricingInfo.isOpencodeFreeModel) {
49
- costInfo += '\n- Calculated by OpenCode Zen: $0.00 (Free model)';
50
- } else {
51
- costInfo += `\n- Calculated by OpenCode Zen: $${pricingInfo.opencodeCost.toFixed(6)}`;
52
- }
53
- }
54
- if (pricingInfo?.tokenUsage) {
55
- const u = pricingInfo.tokenUsage;
56
- let tokenInfo = `\n- Token usage: ${u.inputTokens?.toLocaleString() || 0} input, ${u.outputTokens?.toLocaleString() || 0} output`;
57
- if (u.reasoningTokens > 0) tokenInfo += `, ${u.reasoningTokens.toLocaleString()} reasoning`;
58
- if (u.cacheReadTokens > 0 || u.cacheWriteTokens > 0) tokenInfo += `, ${u.cacheReadTokens?.toLocaleString() || 0} cache read, ${u.cacheWriteTokens?.toLocaleString() || 0} cache write`;
59
- costInfo += tokenInfo;
60
- }
61
- if (hasAnthropic) {
62
- costInfo += `\n- Calculated by Anthropic: $${anthropicTotalCostUSD.toFixed(6)}`;
63
- if (hasPublic) {
64
- const diff = anthropicTotalCostUSD - totalCostUSD;
65
- const pct = totalCostUSD > 0 ? (diff / totalCostUSD) * 100 : 0;
66
- costInfo += `\n- Difference: $${diff.toFixed(6)} (${pct > 0 ? '+' : ''}${pct.toFixed(2)}%)`;
67
- }
68
- }
69
- return costInfo;
70
- };
17
+ import { buildCostInfoString } from './github-cost-info.lib.mjs';
18
+ export { buildCostInfoString };
71
19
  export const maskGitHubToken = maskToken; // Alias for backward compatibility
72
20
  export const escapeCodeBlocksInLog = logContent => logContent.replace(/```/g, '\\`\\`\\`'); // Escape ``` in logs
73
21
  export const checkFileInBranch = async (owner, repo, fileName, branchName) => {
@@ -19,6 +19,10 @@ import { timeouts } from './config.lib.mjs';
19
19
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
20
20
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
21
21
  import { opencodeModels, defaultModels } from './models/index.mjs';
22
+ import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage as parseOpenCodeTokenUsage } from './agent-token-usage.lib.mjs';
23
+ import { calculateAgentPricing } from './agent.lib.mjs';
24
+
25
+ export { parseOpenCodeTokenUsage };
22
26
 
23
27
  // Model mapping to translate aliases to full model IDs for OpenCode
24
28
  // Issue #1473: Uses centralized opencodeModels from models/index.mjs (single source of truth)
@@ -242,6 +246,7 @@ export const executeOpenCodeCommand = async params => {
242
246
 
243
247
  // Map model alias to full ID
244
248
  const mappedModel = mapModelToId(argv.model);
249
+ const streamingTokenUsage = createAgentTokenUsage();
245
250
 
246
251
  // Build opencode command arguments
247
252
  let opencodeArgs = `run --format json --model ${mappedModel}`;
@@ -268,6 +273,15 @@ export const executeOpenCodeCommand = async params => {
268
273
  await log(`${fullCommand}`);
269
274
  await log('');
270
275
 
276
+ const buildPricingInfo = async () => {
277
+ const tokenUsage = streamingTokenUsage;
278
+ if (tokenUsage.stepCount === 0) {
279
+ return { tokenUsage, pricingInfo: null, publicPricingEstimate: null };
280
+ }
281
+ const pricingInfo = await calculateAgentPricing(mappedModel, tokenUsage);
282
+ return { tokenUsage, pricingInfo, publicPricingEstimate: pricingInfo?.totalCostUSD ?? null };
283
+ };
284
+
271
285
  try {
272
286
  // Pipe the prompt file to opencode via stdin
273
287
  if (argv.resume) {
@@ -313,6 +327,7 @@ export const executeOpenCodeCommand = async params => {
313
327
  for (const line of lines) {
314
328
  if (!line.trim()) continue;
315
329
  const data = sanitizeObjectStrings(JSON.parse(line));
330
+ accumulateAgentStepFinishUsage(streamingTokenUsage, data);
316
331
  // Track text content for result summary
317
332
  // OpenCode outputs text via 'text', 'assistant', 'message', or 'result' type events
318
333
  if (data.type === 'text' && data.text) {
@@ -355,6 +370,7 @@ export const executeOpenCodeCommand = async params => {
355
370
  for (const line of lines) {
356
371
  if (!line.trim()) continue;
357
372
  const data = sanitizeObjectStrings(JSON.parse(line));
373
+ accumulateAgentStepFinishUsage(streamingTokenUsage, data);
358
374
  if (data.type === 'text' && data.text) {
359
375
  lastTextContent = data.text;
360
376
  } else if (data.type === 'assistant' && data.message?.content) {
@@ -427,12 +443,14 @@ export const executeOpenCodeCommand = async params => {
427
443
  await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
428
444
  await log(` Load: ${resourcesAfter.load}`, { verbose: true });
429
445
 
446
+ const pricingResult = await buildPricingInfo();
430
447
  return {
431
448
  success: false,
432
449
  sessionId,
433
450
  limitReached: false,
434
451
  limitResetTime: null,
435
452
  permissionPromptDetected: true,
453
+ ...pricingResult,
436
454
  resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
437
455
  };
438
456
  }
@@ -466,17 +484,41 @@ export const executeOpenCodeCommand = async params => {
466
484
  await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
467
485
  await log(` Load: ${resourcesAfter.load}`, { verbose: true });
468
486
 
487
+ const pricingResult = await buildPricingInfo();
469
488
  return {
470
489
  success: false,
471
490
  sessionId,
472
491
  limitReached,
473
492
  limitResetTime,
493
+ ...pricingResult,
474
494
  resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
475
495
  };
476
496
  }
477
497
 
478
498
  await log('\n\nāœ… OpenCode command completed');
479
499
 
500
+ const pricingResult = await buildPricingInfo();
501
+ if (pricingResult.tokenUsage.stepCount > 0) {
502
+ await log('\nšŸ’° Token Usage Summary:');
503
+ await log(` šŸ“Š ${pricingResult.pricingInfo?.modelName || mappedModel} (${pricingResult.tokenUsage.stepCount} steps):`);
504
+ await log(` Input tokens: ${pricingResult.tokenUsage.inputTokens.toLocaleString()}`);
505
+ await log(` Output tokens: ${pricingResult.tokenUsage.outputTokens.toLocaleString()}`);
506
+ if (pricingResult.tokenUsage.reasoningTokens > 0 || pricingResult.tokenUsage.tokenFieldAvailability?.reasoningTokens) {
507
+ await log(` Reasoning tokens: ${pricingResult.tokenUsage.reasoningTokens.toLocaleString()}`);
508
+ }
509
+ if (pricingResult.tokenUsage.cacheReadTokens > 0 || pricingResult.tokenUsage.tokenFieldAvailability?.cacheReadTokens) {
510
+ await log(` Cache read: ${pricingResult.tokenUsage.cacheReadTokens.toLocaleString()}`);
511
+ }
512
+ if (pricingResult.tokenUsage.cacheWriteTokens > 0 || pricingResult.tokenUsage.tokenFieldAvailability?.cacheWriteTokens) {
513
+ await log(` Cache write: ${pricingResult.tokenUsage.cacheWriteTokens.toLocaleString()}`);
514
+ }
515
+ if (pricingResult.pricingInfo?.totalCostUSD !== null && pricingResult.pricingInfo?.totalCostUSD !== undefined) {
516
+ await log(` Public pricing estimate: $${pricingResult.pricingInfo.totalCostUSD.toFixed(6)}`);
517
+ } else {
518
+ await log(' Cost: Not available (could not fetch pricing)');
519
+ }
520
+ }
521
+
480
522
  // Issue #1263: Log if result summary was captured
481
523
  if (lastTextContent) {
482
524
  await log('šŸ“ Captured result summary from OpenCode output', { verbose: true });
@@ -487,6 +529,7 @@ export const executeOpenCodeCommand = async params => {
487
529
  sessionId,
488
530
  limitReached,
489
531
  limitResetTime,
532
+ ...pricingResult,
490
533
  resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
491
534
  };
492
535
  } catch (error) {
@@ -510,6 +553,9 @@ export const executeOpenCodeCommand = async params => {
510
553
  sessionId: null,
511
554
  limitReached: false,
512
555
  limitResetTime: null,
556
+ tokenUsage: streamingTokenUsage.stepCount > 0 ? streamingTokenUsage : null,
557
+ pricingInfo: null,
558
+ publicPricingEstimate: null,
513
559
  resultSummary: null, // Issue #1263: No result summary available on error
514
560
  };
515
561
  }
@@ -610,4 +656,5 @@ export default {
610
656
  executeOpenCode,
611
657
  executeOpenCodeCommand,
612
658
  checkForUncommittedChanges,
659
+ parseOpenCodeTokenUsage,
613
660
  };