@effect-uai/anthropic 0.11.0 → 0.12.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.
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,46 @@
1
+ import { emptyAccumulator, mergeUsage } from "./codec.mjs";
2
+ import { applyEvent } from "./streamEvents.mjs";
3
+ import { n as describe, r as it, t as globalExpect } from "./test.DNmyFkvJ-N3QqMRwZ.mjs";
4
+ //#region src/usage.test.ts
5
+ const wire = {
6
+ input_tokens: 94,
7
+ output_tokens: 210,
8
+ cache_read_input_tokens: 31851,
9
+ cache_creation_input_tokens: 604
10
+ };
11
+ describe("mergeUsage", () => {
12
+ it("keeps all three input buckets, not just cache reads", () => {
13
+ const usage = mergeUsage(emptyAccumulator, wire).usage;
14
+ globalExpect(usage.input_tokens).toBe(94);
15
+ globalExpect(usage.input_tokens_details?.cached_tokens).toBe(31851);
16
+ globalExpect(usage.input_tokens_details?.cache_write_tokens).toBe(604);
17
+ });
18
+ it("totals every input bucket plus output", () => {
19
+ const usage = mergeUsage(emptyAccumulator, wire).usage;
20
+ globalExpect(usage.total_tokens).toBe(32759);
21
+ });
22
+ });
23
+ describe("streamed usage", () => {
24
+ it("computes a non-stale total across message_start and message_delta", () => {
25
+ const usage = applyEvent(applyEvent(emptyAccumulator, {
26
+ type: "message_start",
27
+ message: { usage: {
28
+ input_tokens: 94,
29
+ output_tokens: 1,
30
+ cache_read_input_tokens: 31851,
31
+ cache_creation_input_tokens: 604
32
+ } }
33
+ }), {
34
+ type: "message_delta",
35
+ delta: { stop_reason: "end_turn" },
36
+ usage: { output_tokens: 210 }
37
+ }).usage;
38
+ globalExpect(usage.output_tokens).toBe(210);
39
+ globalExpect(usage.input_tokens_details?.cache_write_tokens).toBe(604);
40
+ globalExpect(usage.total_tokens).toBe(32759);
41
+ });
42
+ });
43
+ //#endregion
44
+ export {};
45
+
46
+ //# sourceMappingURL=usage.test.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usage.test.mjs","names":[],"sources":["../src/usage.test.ts"],"sourcesContent":["import { describe, expect, it } from \"vitest\"\nimport { type WireUsage, emptyAccumulator, mergeUsage } from \"./codec.js\"\nimport { type ProviderEvent, applyEvent } from \"./streamEvents.js\"\n\n// A completed cached turn: input split across post-breakpoint tokens, cache\n// reads, and cache writes, all billed separately.\nconst wire: WireUsage = {\n input_tokens: 94,\n output_tokens: 210,\n cache_read_input_tokens: 31851,\n cache_creation_input_tokens: 604,\n}\n\ndescribe(\"mergeUsage\", () => {\n it(\"keeps all three input buckets, not just cache reads\", () => {\n const usage = mergeUsage(emptyAccumulator, wire).usage\n expect(usage.input_tokens).toBe(94)\n expect(usage.input_tokens_details?.cached_tokens).toBe(31851)\n expect(usage.input_tokens_details?.cache_write_tokens).toBe(604)\n })\n\n it(\"totals every input bucket plus output\", () => {\n const usage = mergeUsage(emptyAccumulator, wire).usage\n expect(usage.total_tokens).toBe(94 + 31851 + 604 + 210)\n })\n})\n\ndescribe(\"streamed usage\", () => {\n // Anthropic sends input + cache on message_start and the final output_tokens\n // on message_delta. total_tokens must reflect the accumulated buckets, not\n // the (input + initial output) figure frozen at message_start.\n it(\"computes a non-stale total across message_start and message_delta\", () => {\n const start: ProviderEvent = {\n type: \"message_start\",\n message: {\n usage: {\n input_tokens: 94,\n output_tokens: 1,\n cache_read_input_tokens: 31851,\n cache_creation_input_tokens: 604,\n },\n },\n }\n const delta: ProviderEvent = {\n type: \"message_delta\",\n delta: { stop_reason: \"end_turn\" },\n usage: { output_tokens: 210 },\n }\n const usage = applyEvent(applyEvent(emptyAccumulator, start), delta).usage\n expect(usage.output_tokens).toBe(210)\n expect(usage.input_tokens_details?.cache_write_tokens).toBe(604)\n expect(usage.total_tokens).toBe(94 + 31851 + 604 + 210)\n })\n})\n"],"mappings":";;;;AAMA,MAAM,OAAkB;CACtB,cAAc;CACd,eAAe;CACf,yBAAyB;CACzB,6BAA6B;AAC/B;AAEA,SAAS,oBAAoB;CAC3B,GAAG,6DAA6D;EAC9D,MAAM,QAAQ,WAAW,kBAAkB,IAAI,CAAC,CAAC;EACjD,aAAO,MAAM,YAAY,CAAC,CAAC,KAAK,EAAE;EAClC,aAAO,MAAM,sBAAsB,aAAa,CAAC,CAAC,KAAK,KAAK;EAC5D,aAAO,MAAM,sBAAsB,kBAAkB,CAAC,CAAC,KAAK,GAAG;CACjE,CAAC;CAED,GAAG,+CAA+C;EAChD,MAAM,QAAQ,WAAW,kBAAkB,IAAI,CAAC,CAAC;EACjD,aAAO,MAAM,YAAY,CAAC,CAAC,KAAK,KAAsB;CACxD,CAAC;AACH,CAAC;AAED,SAAS,wBAAwB;CAI/B,GAAG,2EAA2E;EAiB5E,MAAM,QAAQ,WAAW,WAAW,kBAAkB;GAfpD,MAAM;GACN,SAAS,EACP,OAAO;IACL,cAAc;IACd,eAAe;IACf,yBAAyB;IACzB,6BAA6B;GAC/B,EACF;EAOwD,CAAC,GAAG;GAJ5D,MAAM;GACN,OAAO,EAAE,aAAa,WAAW;GACjC,OAAO,EAAE,eAAe,IAAI;EAEoC,CAAC,CAAC,CAAC;EACrE,aAAO,MAAM,aAAa,CAAC,CAAC,KAAK,GAAG;EACpC,aAAO,MAAM,sBAAsB,kBAAkB,CAAC,CAAC,KAAK,GAAG;EAC/D,aAAO,MAAM,YAAY,CAAC,CAAC,KAAK,KAAsB;CACxD,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/anthropic",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Anthropic Messages API provider for @effect-uai/core.",
5
5
  "keywords": [
6
6
  "ai",
@@ -43,13 +43,13 @@
43
43
  "access": "public"
44
44
  },
45
45
  "devDependencies": {
46
- "effect": "4.0.0-beta.94",
47
- "typescript": "^6.0.3",
48
- "@effect-uai/core": "0.11.0"
46
+ "effect": "4.0.0-rc.111",
47
+ "typescript": "^7.0.2",
48
+ "@effect-uai/core": "0.12.1"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@effect-uai/core": ">=0.2.0 <1",
52
- "effect": ">=4.0.0-beta.94 <5.0.0"
52
+ "effect": ">=4.0.0-rc.111 <5.0.0"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "tsdown",
package/src/Anthropic.ts CHANGED
@@ -24,6 +24,8 @@ import { type Turn, TurnEvent } from "@effect-uai/core/Turn"
24
24
  import { renderProviderTools } from "./AnthropicTools.js"
25
25
  import {
26
26
  type Accumulator,
27
+ type CacheControl,
28
+ type CacheTtl,
27
29
  type ThinkingConfig,
28
30
  accumulatorToTurn,
29
31
  buildRequestBody,
@@ -119,6 +121,21 @@ export type Config = {
119
121
  * 4096 if neither is set.
120
122
  */
121
123
  readonly defaultMaxTokens?: number
124
+ /**
125
+ * Prompt caching. Off by default, so the library stays unopinionated about
126
+ * a setting that changes billing. `true` is `{}`: cache with the 5-minute
127
+ * default TTL.
128
+ *
129
+ * Anthropic caches the `tools` → `system` → `messages` prefix and moves the
130
+ * breakpoint forward as the conversation grows, so a long system prompt and
131
+ * tool definitions are paid for once rather than on every turn. Read
132
+ * `usage.input_tokens_details.cached_tokens` to see it working.
133
+ *
134
+ * Mutating earlier history (rewriting an old message, compaction) breaks
135
+ * the prefix from that point on. Anthropic falls back to the longest
136
+ * unchanged prefix, so this costs savings but never correctness.
137
+ */
138
+ readonly promptCaching?: boolean | { readonly ttl?: CacheTtl }
122
139
  }
123
140
 
124
141
  const ANTHROPIC_VERSION = "2023-06-01"
@@ -139,6 +156,24 @@ const outputConfig = (request: AnthropicRequest): Option.Option<Record<string, u
139
156
  const resolvedMaxTokens = (cfg: Config, request: AnthropicRequest): number =>
140
157
  request.maxOutputTokens ?? cfg.defaultMaxTokens ?? FALLBACK_MAX_TOKENS
141
158
 
159
+ /**
160
+ * One request-level breakpoint when caching is on. Anthropic places it on the
161
+ * last cacheable block itself, so nothing has to be marked per-block and the
162
+ * breakpoint stays correct as history grows.
163
+ */
164
+ const cacheControlOf = (cfg: Config): Option.Option<CacheControl> =>
165
+ pipe(
166
+ Option.fromUndefinedOr(cfg.promptCaching),
167
+ Option.flatMap((opt) =>
168
+ opt === false
169
+ ? Option.none<CacheControl>()
170
+ : Option.some<CacheControl>({
171
+ type: "ephemeral",
172
+ ...(opt !== true && opt.ttl !== undefined && { ttl: opt.ttl }),
173
+ }),
174
+ ),
175
+ )
176
+
142
177
  const functionToolDescriptors = (
143
178
  request: AnthropicRequest,
144
179
  ): ReadonlyArray<Record<string, unknown>> =>
@@ -302,6 +337,7 @@ const buildNativeStream = (cfg: Config) => {
302
337
  toolChoice: toolChoiceWire(request),
303
338
  userId: Option.fromUndefinedOr(request.user),
304
339
  outputConfig: structured,
340
+ cacheControl: cacheControlOf(cfg),
305
341
  })
306
342
 
307
343
  const body = yield* Result.match(bodyResult, {
@@ -409,13 +445,10 @@ export const layer = (
409
445
  const typed = Layer.effect(Anthropic, make(cfg))
410
446
  const generic = Layer.effect(
411
447
  LanguageModel,
412
- Effect.map(
413
- make(cfg),
414
- (s): LanguageModelService => ({
415
- streamTurn: (request) => s.streamTurn(request as AnthropicRequest),
416
- turn: (request) => s.turn(request as AnthropicRequest),
417
- }),
418
- ),
448
+ Effect.map(make(cfg), (s): LanguageModelService => ({
449
+ streamTurn: (request) => s.streamTurn(request as AnthropicRequest),
450
+ turn: (request) => s.turn(request as AnthropicRequest),
451
+ })),
419
452
  )
420
453
  return Layer.merge(typed, generic)
421
454
  }
@@ -109,12 +109,10 @@ const renderOne = (
109
109
  })
110
110
  : decodeConfig(tool.config).pipe(
111
111
  Result.map(configToWire),
112
- Result.mapError(
113
- (): ToolRenderError => ({
114
- capability: "providerTool",
115
- reason: `unrecognized Anthropic provider tool config on "${tool.name}"`,
116
- }),
117
- ),
112
+ Result.mapError((): ToolRenderError => ({
113
+ capability: "providerTool",
114
+ reason: `unrecognized Anthropic provider tool config on "${tool.name}"`,
115
+ })),
118
116
  )
119
117
 
120
118
  /**
@@ -42,6 +42,7 @@ const buildWith = (
42
42
  toolChoice,
43
43
  userId: Option.none(),
44
44
  outputConfig: Option.none(),
45
+ cacheControl: Option.none(),
45
46
  }),
46
47
  )
47
48
  }
package/src/codec.ts CHANGED
@@ -195,11 +195,9 @@ const itemToAssistantBlocks = (
195
195
  function_call: (f) =>
196
196
  pipe(
197
197
  parseJson(f.arguments),
198
- Result.map(
199
- (input): ReadonlyArray<RequestAssistantContentBlock> => [
200
- { type: "tool_use", id: f.call_id, name: f.name, input },
201
- ],
202
- ),
198
+ Result.map((input): ReadonlyArray<RequestAssistantContentBlock> => [
199
+ { type: "tool_use", id: f.call_id, name: f.name, input },
200
+ ]),
203
201
  ),
204
202
  function_call_output: (): Result.Result<
205
203
  ReadonlyArray<RequestAssistantContentBlock>,
@@ -324,6 +322,20 @@ export type ThinkingConfig = {
324
322
  readonly budget_tokens: number
325
323
  }
326
324
 
325
+ /** Cache lifetime. `1h` writes cost 2x base input; `5m` costs 1.25x. */
326
+ export type CacheTtl = "5m" | "1h"
327
+
328
+ /**
329
+ * Request-level cache breakpoint. Anthropic applies it to the last cacheable
330
+ * block and moves it forward as the conversation grows, so the cached prefix
331
+ * (`tools` → `system` → `messages`) follows the conversation without the
332
+ * caller re-placing anything.
333
+ */
334
+ export type CacheControl = {
335
+ readonly type: "ephemeral"
336
+ readonly ttl?: CacheTtl
337
+ }
338
+
327
339
  export type RequestBody = {
328
340
  readonly model: string
329
341
  readonly messages: ReadonlyArray<RequestMessage>
@@ -338,6 +350,7 @@ export type RequestBody = {
338
350
  readonly tool_choice?: Record<string, unknown>
339
351
  readonly metadata?: { readonly user_id: string }
340
352
  readonly output_config?: Record<string, unknown>
353
+ readonly cache_control?: CacheControl
341
354
  readonly stream: true
342
355
  }
343
356
 
@@ -354,57 +367,60 @@ export const buildRequestBody = (params: {
354
367
  readonly toolChoice: Option.Option<Record<string, unknown>>
355
368
  readonly userId: Option.Option<string>
356
369
  readonly outputConfig: Option.Option<Record<string, unknown>>
370
+ readonly cacheControl: Option.Option<CacheControl>
357
371
  }): Result.Result<RequestBody, JsonParseError> =>
358
372
  pipe(
359
373
  groupedMessages(params.history),
360
- Result.map(
361
- (messages): RequestBody => ({
362
- model: params.model,
363
- messages,
364
- max_tokens: params.maxTokens,
365
- ...Option.match(systemFromHistory(params.history), {
366
- onNone: () => ({}),
367
- onSome: (system) => ({ system }),
368
- }),
369
- ...Option.match(params.temperature, {
370
- onNone: () => ({}),
371
- onSome: (temperature) => ({ temperature }),
372
- }),
373
- ...Option.match(params.topP, {
374
- onNone: () => ({}),
375
- onSome: (top_p) => ({ top_p }),
376
- }),
377
- ...Option.match(params.topK, {
378
- onNone: () => ({}),
379
- onSome: (top_k) => ({ top_k }),
380
- }),
381
- ...Option.match(params.stopSequences, {
382
- onNone: () => ({}),
383
- onSome: (stop_sequences) => ({ stop_sequences }),
384
- }),
385
- ...Option.match(params.thinking, {
386
- onNone: () => ({}),
387
- onSome: (thinking) => ({ thinking }),
388
- }),
389
- ...Option.match(params.tools, {
390
- onNone: () => ({}),
391
- onSome: (tools) => ({ tools }),
392
- }),
393
- ...Option.match(params.toolChoice, {
394
- onNone: () => ({}),
395
- onSome: (tool_choice) => ({ tool_choice }),
396
- }),
397
- ...Option.match(params.userId, {
398
- onNone: () => ({}),
399
- onSome: (user_id) => ({ metadata: { user_id } }),
400
- }),
401
- ...Option.match(params.outputConfig, {
402
- onNone: () => ({}),
403
- onSome: (output_config) => ({ output_config }),
404
- }),
405
- stream: true,
374
+ Result.map((messages): RequestBody => ({
375
+ model: params.model,
376
+ messages,
377
+ max_tokens: params.maxTokens,
378
+ ...Option.match(systemFromHistory(params.history), {
379
+ onNone: () => ({}),
380
+ onSome: (system) => ({ system }),
406
381
  }),
407
- ),
382
+ ...Option.match(params.temperature, {
383
+ onNone: () => ({}),
384
+ onSome: (temperature) => ({ temperature }),
385
+ }),
386
+ ...Option.match(params.topP, {
387
+ onNone: () => ({}),
388
+ onSome: (top_p) => ({ top_p }),
389
+ }),
390
+ ...Option.match(params.topK, {
391
+ onNone: () => ({}),
392
+ onSome: (top_k) => ({ top_k }),
393
+ }),
394
+ ...Option.match(params.stopSequences, {
395
+ onNone: () => ({}),
396
+ onSome: (stop_sequences) => ({ stop_sequences }),
397
+ }),
398
+ ...Option.match(params.thinking, {
399
+ onNone: () => ({}),
400
+ onSome: (thinking) => ({ thinking }),
401
+ }),
402
+ ...Option.match(params.tools, {
403
+ onNone: () => ({}),
404
+ onSome: (tools) => ({ tools }),
405
+ }),
406
+ ...Option.match(params.toolChoice, {
407
+ onNone: () => ({}),
408
+ onSome: (tool_choice) => ({ tool_choice }),
409
+ }),
410
+ ...Option.match(params.userId, {
411
+ onNone: () => ({}),
412
+ onSome: (user_id) => ({ metadata: { user_id } }),
413
+ }),
414
+ ...Option.match(params.outputConfig, {
415
+ onNone: () => ({}),
416
+ onSome: (output_config) => ({ output_config }),
417
+ }),
418
+ ...Option.match(params.cacheControl, {
419
+ onNone: () => ({}),
420
+ onSome: (cache_control) => ({ cache_control }),
421
+ }),
422
+ stream: true,
423
+ })),
408
424
  )
409
425
 
410
426
  // ---------------------------------------------------------------------------
@@ -503,23 +519,34 @@ export const setStopReason = (acc: Accumulator, reason: string): Accumulator =>
503
519
  stopReason: Option.some(reason),
504
520
  })
505
521
 
506
- const cachedFromWire = (wire: WireUsage): Option.Option<number> =>
507
- Option.fromNullishOr(wire.cache_read_input_tokens)
508
-
522
+ // Anthropic splits input across three billed buckets: `input_tokens`
523
+ // (post-breakpoint), cache reads, and cache writes. All three are kept;
524
+ // `input_tokens` is not the input total. Each field carries over from the
525
+ // prior value since a streamed turn spreads usage across events (input +
526
+ // cache on message_start, output_tokens on message_delta), so total_tokens is
527
+ // summed from the accumulated buckets, not one event.
509
528
  export const mergeUsage = (acc: Accumulator, wire: WireUsage): Accumulator => {
510
- const cached = cachedFromWire(wire)
529
+ const prev = acc.usage
530
+ const input_tokens = wire.input_tokens ?? prev.input_tokens
531
+ const output_tokens = wire.output_tokens ?? prev.output_tokens
532
+ const cached_tokens = wire.cache_read_input_tokens ?? prev.input_tokens_details?.cached_tokens
533
+ const cache_write_tokens =
534
+ wire.cache_creation_input_tokens ?? prev.input_tokens_details?.cache_write_tokens
535
+
536
+ const buckets = [input_tokens, cached_tokens, cache_write_tokens, output_tokens]
537
+ const total_tokens = buckets.every((v) => v === undefined)
538
+ ? undefined
539
+ : buckets.reduce<number>((sum, v) => sum + (v ?? 0), 0)
540
+
541
+ const details: Items.InputTokensDetails = {
542
+ ...(cached_tokens !== undefined && { cached_tokens }),
543
+ ...(cache_write_tokens !== undefined && { cache_write_tokens }),
544
+ }
511
545
  const usage: Items.Usage = {
512
- ...acc.usage,
513
- ...(wire.input_tokens !== undefined && { input_tokens: wire.input_tokens }),
514
- ...(wire.output_tokens !== undefined && { output_tokens: wire.output_tokens }),
515
- ...(wire.input_tokens !== undefined &&
516
- wire.output_tokens !== undefined && {
517
- total_tokens: wire.input_tokens + wire.output_tokens,
518
- }),
519
- ...Option.match(cached, {
520
- onNone: () => ({}),
521
- onSome: (cached_tokens) => ({ input_tokens_details: { cached_tokens } }),
522
- }),
546
+ ...(input_tokens !== undefined && { input_tokens }),
547
+ ...(output_tokens !== undefined && { output_tokens }),
548
+ ...(total_tokens !== undefined && { total_tokens }),
549
+ ...(Object.keys(details).length > 0 && { input_tokens_details: details }),
523
550
  }
524
551
  return { ...acc, usage }
525
552
  }
@@ -545,52 +572,41 @@ const blocksByIndex = (acc: Accumulator): ReadonlyArray<BlockBuffer> =>
545
572
 
546
573
  const blockToItems = (block: BlockBuffer): ReadonlyArray<Items.HistoryItem> =>
547
574
  Match.value(block.type).pipe(
548
- Match.when(
549
- "text",
550
- (): ReadonlyArray<Items.HistoryItem> =>
551
- block.text.length === 0
552
- ? []
553
- : [
554
- {
555
- type: "message",
556
- role: "assistant",
557
- content: [{ type: "output_text", text: block.text }],
558
- },
559
- ],
560
- ),
561
- Match.when(
562
- "tool_use",
563
- (): ReadonlyArray<Items.HistoryItem> => [
564
- {
565
- type: "function_call",
566
- call_id: Option.getOrElse(block.id, () => ""),
567
- name: Option.getOrElse(block.name, () => ""),
568
- arguments: block.inputJson,
569
- },
570
- ],
571
- ),
572
- Match.when(
573
- "thinking",
574
- (): ReadonlyArray<Items.HistoryItem> => [
575
- {
576
- type: "reasoning",
577
- ...(block.thinking.length > 0 && { summary: block.thinking }),
578
- ...(block.signature.length > 0 && { signature: block.signature }),
579
- },
580
- ],
581
- ),
582
- Match.when(
583
- "redacted_thinking",
584
- (): ReadonlyArray<Items.HistoryItem> => [
585
- {
586
- type: "reasoning",
587
- ...Option.match(block.redactedData, {
588
- onNone: () => ({}),
589
- onSome: (signature) => ({ signature }),
590
- }),
591
- },
592
- ],
575
+ Match.when("text", (): ReadonlyArray<Items.HistoryItem> =>
576
+ block.text.length === 0
577
+ ? []
578
+ : [
579
+ {
580
+ type: "message",
581
+ role: "assistant",
582
+ content: [{ type: "output_text", text: block.text }],
583
+ },
584
+ ],
593
585
  ),
586
+ Match.when("tool_use", (): ReadonlyArray<Items.HistoryItem> => [
587
+ {
588
+ type: "function_call",
589
+ call_id: Option.getOrElse(block.id, () => ""),
590
+ name: Option.getOrElse(block.name, () => ""),
591
+ arguments: block.inputJson,
592
+ },
593
+ ]),
594
+ Match.when("thinking", (): ReadonlyArray<Items.HistoryItem> => [
595
+ {
596
+ type: "reasoning",
597
+ ...(block.thinking.length > 0 && { summary: block.thinking }),
598
+ ...(block.signature.length > 0 && { signature: block.signature }),
599
+ },
600
+ ]),
601
+ Match.when("redacted_thinking", (): ReadonlyArray<Items.HistoryItem> => [
602
+ {
603
+ type: "reasoning",
604
+ ...Option.match(block.redactedData, {
605
+ onNone: () => ({}),
606
+ onSome: (signature) => ({ signature }),
607
+ }),
608
+ },
609
+ ]),
594
610
  Match.exhaustive,
595
611
  )
596
612
 
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { type WireUsage, emptyAccumulator, mergeUsage } from "./codec.js"
3
+ import { type ProviderEvent, applyEvent } from "./streamEvents.js"
4
+
5
+ // A completed cached turn: input split across post-breakpoint tokens, cache
6
+ // reads, and cache writes, all billed separately.
7
+ const wire: WireUsage = {
8
+ input_tokens: 94,
9
+ output_tokens: 210,
10
+ cache_read_input_tokens: 31851,
11
+ cache_creation_input_tokens: 604,
12
+ }
13
+
14
+ describe("mergeUsage", () => {
15
+ it("keeps all three input buckets, not just cache reads", () => {
16
+ const usage = mergeUsage(emptyAccumulator, wire).usage
17
+ expect(usage.input_tokens).toBe(94)
18
+ expect(usage.input_tokens_details?.cached_tokens).toBe(31851)
19
+ expect(usage.input_tokens_details?.cache_write_tokens).toBe(604)
20
+ })
21
+
22
+ it("totals every input bucket plus output", () => {
23
+ const usage = mergeUsage(emptyAccumulator, wire).usage
24
+ expect(usage.total_tokens).toBe(94 + 31851 + 604 + 210)
25
+ })
26
+ })
27
+
28
+ describe("streamed usage", () => {
29
+ // Anthropic sends input + cache on message_start and the final output_tokens
30
+ // on message_delta. total_tokens must reflect the accumulated buckets, not
31
+ // the (input + initial output) figure frozen at message_start.
32
+ it("computes a non-stale total across message_start and message_delta", () => {
33
+ const start: ProviderEvent = {
34
+ type: "message_start",
35
+ message: {
36
+ usage: {
37
+ input_tokens: 94,
38
+ output_tokens: 1,
39
+ cache_read_input_tokens: 31851,
40
+ cache_creation_input_tokens: 604,
41
+ },
42
+ },
43
+ }
44
+ const delta: ProviderEvent = {
45
+ type: "message_delta",
46
+ delta: { stop_reason: "end_turn" },
47
+ usage: { output_tokens: 210 },
48
+ }
49
+ const usage = applyEvent(applyEvent(emptyAccumulator, start), delta).usage
50
+ expect(usage.output_tokens).toBe(210)
51
+ expect(usage.input_tokens_details?.cache_write_tokens).toBe(604)
52
+ expect(usage.total_tokens).toBe(94 + 31851 + 604 + 210)
53
+ })
54
+ })