@link-assistant/hive-mind 1.54.2 → 1.54.3

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.54.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 5030b04: Fix Codex pricing display by calculating OpenAI public estimates from models.dev token rates, passing Codex totals into shared budget stats, and avoiding duplicate raw token usage lines when a Total line is already shown.
8
+
3
9
  ## 1.54.2
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.54.2",
3
+ "version": "1.54.3",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "hive-telegram-bot": "./src/telegram-bot.mjs"
14
14
  },
15
15
  "scripts": {
16
- "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-telegram-bot-launcher.mjs",
16
+ "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-telegram-bot-launcher.mjs",
17
17
  "test:queue": "node tests/solve-queue.test.mjs",
18
18
  "test:limits-display": "node tests/limits-display.test.mjs",
19
19
  "test:usage-limit": "node tests/test-usage-limit.mjs",
@@ -21,7 +21,9 @@ import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; //
21
21
  import { CLAUDE_MODELS as availableModels } from './models/index.mjs'; // Issue #1221
22
22
  import { buildMcpConfigWithoutPlaywright } from './playwright-mcp.lib.mjs';
23
23
  import { resolveClaudeSessionToolFlags } from './useless-tools.lib.mjs';
24
+ import { fetchModelInfo } from './model-info.lib.mjs';
24
25
  export { availableModels }; // Re-export for backward compatibility
26
+ export { fetchModelInfo };
25
27
  const showResumeCommand = async (sessionId, tempDir, claudePath, model, log) => {
26
28
  if (!sessionId || !tempDir) return;
27
29
  const cmd = buildClaudeResumeCommand({ tempDir, sessionId, claudePath, model });
@@ -370,58 +372,6 @@ export const executeClaude = async params => {
370
372
  prNumber,
371
373
  });
372
374
  };
373
- /**
374
- * Fetches model information from pricing API
375
- * @param {string} modelId - The model ID (e.g., "claude-sonnet-4-5-20250929")
376
- * @returns {Promise<Object|null>} Model information or null if not found
377
- */
378
- export const fetchModelInfo = async modelId => {
379
- try {
380
- const https = (await use('https')).default;
381
- return new Promise((resolve, reject) => {
382
- https
383
- .get('https://models.dev/api.json', res => {
384
- let data = '';
385
- res.on('data', chunk => {
386
- data += chunk;
387
- });
388
- res.on('end', () => {
389
- try {
390
- const apiData = JSON.parse(data);
391
- // For public pricing calculation, prefer Anthropic provider for Claude models
392
- // Check Anthropic provider first
393
- if (apiData.anthropic?.models?.[modelId]) {
394
- const modelInfo = apiData.anthropic.models[modelId];
395
- modelInfo.provider = apiData.anthropic.name || 'Anthropic';
396
- resolve(modelInfo);
397
- return;
398
- }
399
- // Search for the model across all other providers
400
- for (const provider of Object.values(apiData)) {
401
- if (provider.models && provider.models[modelId]) {
402
- const modelInfo = provider.models[modelId];
403
- // Add provider info
404
- modelInfo.provider = provider.name || provider.id;
405
- resolve(modelInfo);
406
- return;
407
- }
408
- }
409
- // Model not found
410
- resolve(null);
411
- } catch (parseError) {
412
- reject(parseError);
413
- }
414
- });
415
- })
416
- .on('error', err => {
417
- reject(err);
418
- });
419
- });
420
- } catch {
421
- // If we can't fetch model info, return null and continue without it
422
- return null;
423
- }
424
- };
425
375
  /** Check if a model supports vision (image input) using models.dev API @returns {Promise<boolean>} */
426
376
  export const checkModelVisionCapability = async modelId => {
427
377
  try {
package/src/codex.lib.mjs CHANGED
@@ -22,8 +22,11 @@ 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
  import { getCodexPlaywrightMcpDisableConfigArgs } from './playwright-mcp.lib.mjs';
25
+ import { fetchModelInfo } from './model-info.lib.mjs';
26
+ import Decimal from 'decimal.js-light';
25
27
 
26
28
  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'];
29
+ const CODEX_LONG_CONTEXT_PRICE_THRESHOLD = 272000;
27
30
  const getCodexExecEnv = (verbose = false) => (verbose ? { ...process.env, RUST_LOG: 'debug' } : { ...process.env });
28
31
  const CODEX_MODEL_DIAGNOSTIC_PATHS = [
29
32
  ['model', data => data?.model],
@@ -77,6 +80,9 @@ export const createCodexTokenUsage = requestedModelId => ({
77
80
  stepCount: 0,
78
81
  requestedModelId: requestedModelId || null,
79
82
  respondedModelId: requestedModelId || null,
83
+ contextLimit: null,
84
+ outputLimit: null,
85
+ peakContextUsage: 0,
80
86
  tokenFieldAvailability: createCodexTokenFieldAvailability(),
81
87
  });
82
88
 
@@ -262,6 +268,10 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
262
268
  nextState.tokenUsage.reasoningTokens += reasoningTokens;
263
269
  nextState.tokenUsage.totalTokens = nextState.tokenUsage.inputTokens + nextState.tokenUsage.cacheReadTokens + nextState.tokenUsage.outputTokens + nextState.tokenUsage.cacheWriteTokens;
264
270
  nextState.tokenUsage.stepCount += 1;
271
+ const turnContextUsage = inputTokens + cacheWriteTokens;
272
+ if (turnContextUsage > (nextState.tokenUsage.peakContextUsage || 0)) {
273
+ nextState.tokenUsage.peakContextUsage = turnContextUsage;
274
+ }
265
275
 
266
276
  const usageFieldSet = CODEX_USAGE_FIELD_NAMES.filter(fieldName => hasOwnPath(data.usage, fieldName));
267
277
  if (usageFieldSet.length > 0) nextState.observedUsageFieldSets.push(usageFieldSet);
@@ -323,12 +333,80 @@ export const buildCodexResultModelUsage = (modelId, tokenUsage, pricingInfo = nu
323
333
  outputTokens: tokenUsage.outputTokens || 0,
324
334
  modelName: pricingInfo?.modelName || modelId,
325
335
  modelInfo: pricingInfo?.modelInfo || null,
326
- peakContextUsage: 0,
336
+ peakContextUsage: tokenUsage.peakContextUsage || 0,
327
337
  costUSD: pricingInfo?.totalCostUSD ?? null,
328
338
  },
329
339
  };
330
340
  };
331
341
 
342
+ const toCost = (tokens, pricePerMillion) => {
343
+ if (!Number.isFinite(tokens) || !Number.isFinite(pricePerMillion)) return 0;
344
+ return new Decimal(tokens).mul(pricePerMillion).div(1_000_000).toNumber();
345
+ };
346
+
347
+ const buildCodexPricingFallback = (modelId, tokenUsage, error = null) => ({
348
+ modelId,
349
+ modelName: modelId,
350
+ provider: 'OpenAI',
351
+ tokenUsage,
352
+ modelInfo: null,
353
+ totalCostUSD: null,
354
+ error,
355
+ });
356
+
357
+ export const calculateCodexPricingFromModelInfo = (modelId, tokenUsage, modelInfo) => {
358
+ if (!modelId) return null;
359
+ if (!tokenUsage) return buildCodexPricingFallback(modelId, null);
360
+ if (!modelInfo?.cost) return buildCodexPricingFallback(modelId, tokenUsage, 'Model pricing not found in models.dev API');
361
+
362
+ const standardCost = modelInfo.cost;
363
+ const usesLongContextPricing = !!standardCost.context_over_200k && (tokenUsage.peakContextUsage || 0) > CODEX_LONG_CONTEXT_PRICE_THRESHOLD;
364
+ const cost = usesLongContextPricing ? { ...standardCost, ...standardCost.context_over_200k } : standardCost;
365
+
366
+ const pricing = {
367
+ inputPerMillion: cost.input || 0,
368
+ outputPerMillion: cost.output || 0,
369
+ cacheReadPerMillion: cost.cache_read || 0,
370
+ cacheWritePerMillion: cost.cache_write ?? cost.input ?? 0,
371
+ reasoningPerMillion: cost.reasoning || 0,
372
+ };
373
+
374
+ const breakdown = {
375
+ input: toCost(tokenUsage.inputTokens || 0, pricing.inputPerMillion),
376
+ output: toCost(tokenUsage.outputTokens || 0, pricing.outputPerMillion),
377
+ cacheRead: toCost(tokenUsage.cacheReadTokens || 0, pricing.cacheReadPerMillion),
378
+ cacheWrite: toCost(tokenUsage.cacheWriteTokens || 0, pricing.cacheWritePerMillion),
379
+ reasoning: toCost(tokenUsage.reasoningTokens || 0, pricing.reasoningPerMillion),
380
+ };
381
+ const totalCostUSD = Object.values(breakdown).reduce((sum, value) => new Decimal(sum).plus(value).toNumber(), 0);
382
+
383
+ tokenUsage.contextLimit = tokenUsage.contextLimit || modelInfo.limit?.context || null;
384
+ tokenUsage.outputLimit = tokenUsage.outputLimit || modelInfo.limit?.output || null;
385
+
386
+ return {
387
+ modelId,
388
+ modelName: modelInfo.name || modelId,
389
+ provider: modelInfo.provider || 'OpenAI',
390
+ tokenUsage,
391
+ modelInfo,
392
+ pricing,
393
+ breakdown,
394
+ totalCostUSD,
395
+ usesLongContextPricing,
396
+ longContextThreshold: usesLongContextPricing ? CODEX_LONG_CONTEXT_PRICE_THRESHOLD : null,
397
+ };
398
+ };
399
+
400
+ export const calculateCodexPricing = async (modelId, tokenUsage) => {
401
+ if (!modelId) return null;
402
+ try {
403
+ const modelInfo = await fetchModelInfo(modelId, { preferredProviderIds: ['openai'] });
404
+ return calculateCodexPricingFromModelInfo(modelId, tokenUsage, modelInfo);
405
+ } catch (error) {
406
+ return buildCodexPricingFallback(modelId, tokenUsage, error.message);
407
+ }
408
+ };
409
+
332
410
  // Function to validate Codex CLI connection
333
411
  export const validateCodexConnection = async (model = 'gpt-5.4', verbose = false) => {
334
412
  // Map model alias to full ID
@@ -773,14 +851,15 @@ export const executeCodexCommand = async params => {
773
851
  }
774
852
 
775
853
  const firstActualModelId = mappedModel;
776
- const pricingInfo = firstActualModelId
777
- ? {
778
- modelId: firstActualModelId,
779
- modelName: firstActualModelId,
780
- provider: 'OpenAI',
781
- tokenUsage: codexJsonState.tokenUsage.stepCount > 0 ? codexJsonState.tokenUsage : null,
782
- }
783
- : null;
854
+ const pricingInfo = firstActualModelId ? await calculateCodexPricing(firstActualModelId, codexJsonState.tokenUsage.stepCount > 0 ? codexJsonState.tokenUsage : null) : null;
855
+ if (pricingInfo?.totalCostUSD !== null && pricingInfo?.totalCostUSD !== undefined) {
856
+ await log(`💰 Codex public pricing estimate: $${new Decimal(pricingInfo.totalCostUSD).toFixed(6)}`, { verbose: true });
857
+ if (pricingInfo.usesLongContextPricing) {
858
+ await log(` Long-context pricing applied because peak prompt exceeded ${pricingInfo.longContextThreshold.toLocaleString()} input tokens`, { verbose: true });
859
+ }
860
+ } else if (pricingInfo?.error) {
861
+ await log(`⚠️ Codex public pricing estimate unavailable: ${pricingInfo.error}`, { level: 'warning', verbose: true });
862
+ }
784
863
  const resultModelUsage = pricingInfo?.tokenUsage ? buildCodexResultModelUsage(firstActualModelId, pricingInfo.tokenUsage, pricingInfo) : null;
785
864
 
786
865
  // Check for authentication errors first - these should never be retried
@@ -831,6 +910,7 @@ export const executeCodexCommand = async params => {
831
910
  limitReached,
832
911
  limitResetTime,
833
912
  pricingInfo,
913
+ publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
834
914
  resultModelUsage,
835
915
  subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
836
916
  codexJsonDetails: codexJsonState,
@@ -853,6 +933,7 @@ export const executeCodexCommand = async params => {
853
933
  limitReached,
854
934
  limitResetTime,
855
935
  pricingInfo,
936
+ publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
856
937
  resultModelUsage,
857
938
  subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
858
939
  codexJsonDetails: codexJsonState,
@@ -882,6 +963,7 @@ export const executeCodexCommand = async params => {
882
963
  limitReached: false,
883
964
  limitResetTime: null,
884
965
  pricingInfo: null,
966
+ publicPricingEstimate: null,
885
967
  resultSummary: null, // Issue #1263: No result summary available on error
886
968
  };
887
969
  } finally {
@@ -21,7 +21,8 @@ const buildTokenUsageString = tokenUsage => {
21
21
  };
22
22
 
23
23
  /** Build cost estimation string for log comments (Issue #1250, Issue #1557, Issue #1600: Decimal precision) */
24
- export const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) => {
24
+ export const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo, options = {}) => {
25
+ const includeTokenUsage = options.includeTokenUsage !== false;
25
26
  const hasPublic = totalCostUSD !== null && totalCostUSD !== undefined;
26
27
  const hasAnthropic = anthropicTotalCostUSD !== null && anthropicTotalCostUSD !== undefined;
27
28
  const hasPricing = pricingInfo && (pricingInfo.modelName || pricingInfo.tokenUsage || pricingInfo.isFreeModel || pricingInfo.isOpencodeFreeModel);
@@ -57,7 +58,7 @@ export const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricing
57
58
  costInfo += `\n- Calculated by OpenCode Zen: $${new Decimal(pricingInfo.opencodeCost).toFixed(6)}`;
58
59
  }
59
60
  }
60
- if (pricingInfo?.tokenUsage) costInfo += buildTokenUsageString(pricingInfo.tokenUsage);
61
+ if (includeTokenUsage && pricingInfo?.tokenUsage) costInfo += buildTokenUsageString(pricingInfo.tokenUsage);
61
62
  if (hasAnthropic) {
62
63
  costInfo += `\n- Calculated by Anthropic: $${anthropicDec.toFixed(6)}`;
63
64
  if (hasPublic) {
@@ -501,7 +501,7 @@ ${logContent}
501
501
  *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
502
502
  } else if (errorDuringExecution) {
503
503
  // Issue #1088: "Finished with errors" format - work may have been completed but errors occurred
504
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
504
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
505
505
  logComment = `## ⚠️ ${SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER}
506
506
  This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}
507
507
 
@@ -519,7 +519,7 @@ ${logContent}
519
519
  ---
520
520
  *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
521
521
  } else {
522
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
522
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
523
523
  // Determine title based on session type (Issue #1152)
524
524
  // Issue #1625: Every title variant embeds SOLUTION_DRAFT_LOG_MARKER so
525
525
  // the filter in checkForAiCreatedComments matches every variant with a
@@ -684,7 +684,7 @@ ${errorMessage}
684
684
  *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
685
685
  } else if (errorDuringExecution) {
686
686
  // Issue #1088: "Finished with errors" format - work may have been completed but errors occurred
687
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
687
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
688
688
  logUploadComment = `## ⚠️ ${SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER}
689
689
  This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}
690
690
 
@@ -697,7 +697,7 @@ This log file contains the complete execution trace of the AI ${targetType === '
697
697
  *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
698
698
  } else {
699
699
  // Success log format - use helper function for cost info
700
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
700
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
701
701
  // Determine title based on session type
702
702
  // See: https://github.com/link-assistant/hive-mind/issues/1152
703
703
  // Issue #1625: titles embed SOLUTION_DRAFT_LOG_MARKER (single source).
@@ -0,0 +1,66 @@
1
+ import https from 'node:https';
2
+
3
+ const buildLookupIds = modelId => (modelId?.includes('/') ? [modelId.split('/').pop(), modelId] : [modelId]);
4
+
5
+ const buildProviderPriority = (modelId, preferredProviderIds = []) => {
6
+ const inferredProviderIds = [];
7
+ if (modelId?.startsWith('claude-')) inferredProviderIds.push('anthropic');
8
+ if (modelId?.startsWith('gpt-') || modelId?.startsWith('chatgpt-')) inferredProviderIds.push('openai');
9
+ return [...new Set([...preferredProviderIds, ...inferredProviderIds])];
10
+ };
11
+
12
+ const findProviderModel = (apiData, providerIds, lookupIds) => {
13
+ for (const providerId of providerIds) {
14
+ const provider = apiData[providerId];
15
+ if (!provider?.models) continue;
16
+ for (const lookupId of lookupIds) {
17
+ if (provider.models[lookupId]) {
18
+ return {
19
+ ...provider.models[lookupId],
20
+ provider: provider.name || providerId,
21
+ };
22
+ }
23
+ }
24
+ }
25
+ return null;
26
+ };
27
+
28
+ const fetchModelsDevApi = () =>
29
+ new Promise((resolve, reject) => {
30
+ https
31
+ .get('https://models.dev/api.json', res => {
32
+ let data = '';
33
+ res.on('data', chunk => {
34
+ data += chunk;
35
+ });
36
+ res.on('end', () => {
37
+ try {
38
+ resolve(JSON.parse(data));
39
+ } catch (error) {
40
+ reject(error);
41
+ }
42
+ });
43
+ })
44
+ .on('error', reject);
45
+ });
46
+
47
+ /**
48
+ * Fetches model information from models.dev.
49
+ * @param {string} modelId - The model ID (e.g., "claude-sonnet-4-5-20250929")
50
+ * @param {Object} [options]
51
+ * @param {string[]} [options.preferredProviderIds] Provider IDs to check before the default search order.
52
+ * @returns {Promise<Object|null>} Model information or null if not found
53
+ */
54
+ export const fetchModelInfo = async (modelId, options = {}) => {
55
+ if (!modelId) return null;
56
+ try {
57
+ const apiData = await fetchModelsDevApi();
58
+ const lookupIds = buildLookupIds(modelId);
59
+ const preferredProviderIds = Array.isArray(options.preferredProviderIds) ? options.preferredProviderIds : [];
60
+ const providerPriority = buildProviderPriority(modelId, preferredProviderIds);
61
+ return findProviderModel(apiData, providerPriority, lookupIds) || findProviderModel(apiData, Object.keys(apiData), lookupIds);
62
+ } catch {
63
+ // If we can't fetch model info, return null and continue without it.
64
+ return null;
65
+ }
66
+ };