@link-assistant/agent 0.8.2 → 0.8.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/session/index.ts +202 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -18,6 +18,64 @@ import { Snapshot } from '../snapshot';
18
18
  export namespace Session {
19
19
  const log = Log.create({ service: 'session' });
20
20
 
21
+ /**
22
+ * Safely converts a value to a Decimal instance.
23
+ * Attempts to create a Decimal from the input value (supports numbers, strings, etc.)
24
+ * and returns Decimal(NaN) only if the Decimal constructor throws an error.
25
+ *
26
+ * Logs input data in verbose mode at all stages to help identify data issues.
27
+ *
28
+ * This is necessary because AI providers may return unexpected token usage data
29
+ * that would crash the Decimal.js constructor with "[DecimalError] Invalid argument".
30
+ *
31
+ * @param value - The value to convert to Decimal (number, string, etc.)
32
+ * @param context - Optional context string for debugging (e.g., "inputTokens")
33
+ * @returns A Decimal instance, or Decimal(NaN) if the Decimal constructor fails
34
+ * @see https://github.com/link-assistant/agent/issues/119
35
+ */
36
+ export const toDecimal = (value: unknown, context?: string): Decimal => {
37
+ // Log input data in verbose mode to help identify issues
38
+ if (Flag.OPENCODE_VERBOSE) {
39
+ log.debug(() => ({
40
+ message: 'toDecimal input',
41
+ context,
42
+ valueType: typeof value,
43
+ value:
44
+ typeof value === 'object' ? JSON.stringify(value) : String(value),
45
+ }));
46
+ }
47
+
48
+ try {
49
+ // Let Decimal handle the conversion - it supports numbers, strings, and more
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ const result = new Decimal(value as any);
52
+
53
+ // Log successful conversion in verbose mode
54
+ if (Flag.OPENCODE_VERBOSE) {
55
+ log.debug(() => ({
56
+ message: 'toDecimal success',
57
+ context,
58
+ result: result.toString(),
59
+ }));
60
+ }
61
+
62
+ return result;
63
+ } catch (error) {
64
+ // Log the error and return Decimal(NaN)
65
+ if (Flag.OPENCODE_VERBOSE) {
66
+ log.debug(() => ({
67
+ message: 'toDecimal error - returning Decimal(NaN)',
68
+ context,
69
+ valueType: typeof value,
70
+ value:
71
+ typeof value === 'object' ? JSON.stringify(value) : String(value),
72
+ error: error instanceof Error ? error.message : String(error),
73
+ }));
74
+ }
75
+ return new Decimal(NaN);
76
+ }
77
+ };
78
+
21
79
  const parentTitlePrefix = 'New session - ';
22
80
  const childTitlePrefix = 'Child session - ';
23
81
 
@@ -323,6 +381,73 @@ export namespace Session {
323
381
  return part;
324
382
  });
325
383
 
384
+ /**
385
+ * Safely converts a value to a number.
386
+ * Attempts to parse/convert the input value and returns NaN if conversion fails.
387
+ *
388
+ * Logs input data in verbose mode at all stages to help identify data issues.
389
+ *
390
+ * This is necessary because AI providers may return unexpected token usage data
391
+ * that would cause issues if not handled properly.
392
+ *
393
+ * @param value - The value to convert to number (number, string, etc.)
394
+ * @param context - Optional context string for debugging (e.g., "inputTokens")
395
+ * @returns A number, or NaN if conversion fails
396
+ * @see https://github.com/link-assistant/agent/issues/119
397
+ */
398
+ export const toNumber = (value: unknown, context?: string): number => {
399
+ // Log input data in verbose mode to help identify issues
400
+ if (Flag.OPENCODE_VERBOSE) {
401
+ log.debug(() => ({
402
+ message: 'toNumber input',
403
+ context,
404
+ valueType: typeof value,
405
+ value:
406
+ typeof value === 'object' ? JSON.stringify(value) : String(value),
407
+ }));
408
+ }
409
+
410
+ try {
411
+ // Handle undefined/null explicitly - Number() would convert these to 0 or NaN
412
+ if (value === undefined || value === null) {
413
+ throw new Error(`Cannot convert ${value} to number`);
414
+ }
415
+
416
+ // Try to convert to number
417
+ const result = Number(value);
418
+
419
+ // Check if conversion produced a valid result
420
+ // Note: Number({}) returns NaN, Number([1]) returns 1, Number([1,2]) returns NaN
421
+ if (Number.isNaN(result)) {
422
+ throw new Error(`Conversion to number resulted in NaN`);
423
+ }
424
+
425
+ // Log successful conversion in verbose mode
426
+ if (Flag.OPENCODE_VERBOSE) {
427
+ log.debug(() => ({
428
+ message: 'toNumber success',
429
+ context,
430
+ result,
431
+ }));
432
+ }
433
+
434
+ return result;
435
+ } catch (error) {
436
+ // Log the error and return NaN
437
+ if (Flag.OPENCODE_VERBOSE) {
438
+ log.debug(() => ({
439
+ message: 'toNumber error - returning NaN',
440
+ context,
441
+ valueType: typeof value,
442
+ value:
443
+ typeof value === 'object' ? JSON.stringify(value) : String(value),
444
+ error: error instanceof Error ? error.message : String(error),
445
+ }));
446
+ }
447
+ return NaN;
448
+ }
449
+ };
450
+
326
451
  export const getUsage = fn(
327
452
  z.object({
328
453
  model: z.custom<ModelsDev.Model>(),
@@ -330,23 +455,50 @@ export namespace Session {
330
455
  metadata: z.custom<ProviderMetadata>().optional(),
331
456
  }),
332
457
  (input) => {
333
- const cachedInputTokens = input.usage.cachedInputTokens ?? 0;
458
+ // Log raw usage data in verbose mode for debugging
459
+ if (Flag.OPENCODE_VERBOSE) {
460
+ log.debug(() => ({
461
+ message: 'getUsage called with raw data',
462
+ rawUsage: JSON.stringify(input.usage),
463
+ rawMetadata: input.metadata ? JSON.stringify(input.metadata) : 'none',
464
+ }));
465
+ }
466
+
467
+ // Helper: convert toNumber result to 0 if NaN or not finite (for safe calculations)
468
+ const safeNum = (n: number): number =>
469
+ Number.isNaN(n) || !Number.isFinite(n) ? 0 : n;
470
+
471
+ const cachedInputTokens = safeNum(
472
+ toNumber(input.usage.cachedInputTokens, 'cachedInputTokens')
473
+ );
334
474
  const excludesCachedTokens = !!(
335
475
  input.metadata?.['anthropic'] || input.metadata?.['bedrock']
336
476
  );
477
+
478
+ const rawInputTokens = safeNum(
479
+ toNumber(input.usage.inputTokens, 'inputTokens')
480
+ );
337
481
  const adjustedInputTokens = excludesCachedTokens
338
- ? (input.usage.inputTokens ?? 0)
339
- : (input.usage.inputTokens ?? 0) - cachedInputTokens;
482
+ ? rawInputTokens
483
+ : rawInputTokens - cachedInputTokens;
484
+
485
+ const cacheWriteTokens = safeNum(
486
+ toNumber(
487
+ input.metadata?.['anthropic']?.['cacheCreationInputTokens'] ??
488
+ // @ts-expect-error - bedrock metadata structure may vary
489
+ input.metadata?.['bedrock']?.['usage']?.['cacheWriteInputTokens'],
490
+ 'cacheWriteTokens'
491
+ )
492
+ );
340
493
 
341
494
  const tokens = {
342
- input: adjustedInputTokens,
343
- output: input.usage.outputTokens ?? 0,
344
- reasoning: input.usage?.reasoningTokens ?? 0,
495
+ input: Math.max(0, adjustedInputTokens), // Ensure non-negative
496
+ output: safeNum(toNumber(input.usage.outputTokens, 'outputTokens')),
497
+ reasoning: safeNum(
498
+ toNumber(input.usage?.reasoningTokens, 'reasoningTokens')
499
+ ),
345
500
  cache: {
346
- write: (input.metadata?.['anthropic']?.['cacheCreationInputTokens'] ??
347
- // @ts-expect-error
348
- input.metadata?.['bedrock']?.['usage']?.['cacheWriteInputTokens'] ??
349
- 0) as number,
501
+ write: cacheWriteTokens,
350
502
  read: cachedInputTokens,
351
503
  },
352
504
  };
@@ -356,32 +508,47 @@ export namespace Session {
356
508
  tokens.input + tokens.cache.read > 200_000
357
509
  ? input.model.cost.context_over_200k
358
510
  : input.model.cost;
511
+
512
+ // Calculate cost using toDecimal for safe Decimal construction
513
+ const costDecimal = toDecimal(0, 'cost_base')
514
+ .add(
515
+ toDecimal(tokens.input, 'tokens.input')
516
+ .mul(toDecimal(costInfo?.input ?? 0, 'costInfo.input'))
517
+ .div(1_000_000)
518
+ )
519
+ .add(
520
+ toDecimal(tokens.output, 'tokens.output')
521
+ .mul(toDecimal(costInfo?.output ?? 0, 'costInfo.output'))
522
+ .div(1_000_000)
523
+ )
524
+ .add(
525
+ toDecimal(tokens.cache.read, 'tokens.cache.read')
526
+ .mul(toDecimal(costInfo?.cache_read ?? 0, 'costInfo.cache_read'))
527
+ .div(1_000_000)
528
+ )
529
+ .add(
530
+ toDecimal(tokens.cache.write, 'tokens.cache.write')
531
+ .mul(toDecimal(costInfo?.cache_write ?? 0, 'costInfo.cache_write'))
532
+ .div(1_000_000)
533
+ )
534
+ // TODO: update models.dev to have better pricing model, for now:
535
+ // charge reasoning tokens at the same rate as output tokens
536
+ .add(
537
+ toDecimal(tokens.reasoning, 'tokens.reasoning')
538
+ .mul(
539
+ toDecimal(costInfo?.output ?? 0, 'costInfo.output_for_reasoning')
540
+ )
541
+ .div(1_000_000)
542
+ );
543
+
544
+ // Convert to number, defaulting to 0 if result is NaN or not finite
545
+ const cost =
546
+ costDecimal.isNaN() || !costDecimal.isFinite()
547
+ ? 0
548
+ : costDecimal.toNumber();
549
+
359
550
  return {
360
- cost: new Decimal(0)
361
- .add(
362
- new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000)
363
- )
364
- .add(
365
- new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000)
366
- )
367
- .add(
368
- new Decimal(tokens.cache.read)
369
- .mul(costInfo?.cache_read ?? 0)
370
- .div(1_000_000)
371
- )
372
- .add(
373
- new Decimal(tokens.cache.write)
374
- .mul(costInfo?.cache_write ?? 0)
375
- .div(1_000_000)
376
- )
377
- // TODO: update models.dev to have better pricing model, for now:
378
- // charge reasoning tokens at the same rate as output tokens
379
- .add(
380
- new Decimal(tokens.reasoning)
381
- .mul(costInfo?.output ?? 0)
382
- .div(1_000_000)
383
- )
384
- .toNumber(),
551
+ cost,
385
552
  tokens,
386
553
  };
387
554
  }