@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.
- package/package.json +1 -1
- package/src/session/index.ts +202 -35
package/package.json
CHANGED
package/src/session/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
?
|
|
339
|
-
:
|
|
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
|
|
344
|
-
reasoning:
|
|
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:
|
|
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
|
|
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
|
}
|