@providerkit/core 0.8.1 → 0.10.0

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 (60) hide show
  1. package/dist/capability.d.ts +41 -0
  2. package/dist/capability.d.ts.map +1 -1
  3. package/dist/capability.js +94 -0
  4. package/dist/capability.js.map +1 -1
  5. package/dist/errors.d.ts +30 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +171 -13
  8. package/dist/errors.js.map +1 -1
  9. package/dist/presets.d.ts +173 -28
  10. package/dist/presets.d.ts.map +1 -1
  11. package/dist/presets.js +172 -26
  12. package/dist/presets.js.map +1 -1
  13. package/dist/providers/anthropic.js +2 -2
  14. package/dist/providers/anthropic.js.map +1 -1
  15. package/dist/providers/factory.d.ts.map +1 -1
  16. package/dist/providers/factory.js +1 -0
  17. package/dist/providers/factory.js.map +1 -1
  18. package/dist/providers/gemini.d.ts.map +1 -1
  19. package/dist/providers/gemini.js +28 -10
  20. package/dist/providers/gemini.js.map +1 -1
  21. package/dist/providers/openai.d.ts +6 -0
  22. package/dist/providers/openai.d.ts.map +1 -1
  23. package/dist/providers/openai.js +58 -4
  24. package/dist/providers/openai.js.map +1 -1
  25. package/dist/rate-limit.d.ts +2 -0
  26. package/dist/rate-limit.d.ts.map +1 -1
  27. package/dist/rate-limit.js +91 -34
  28. package/dist/rate-limit.js.map +1 -1
  29. package/dist/retry.d.ts.map +1 -1
  30. package/dist/retry.js +2 -2
  31. package/dist/retry.js.map +1 -1
  32. package/dist/schema.d.ts +20 -0
  33. package/dist/schema.d.ts.map +1 -1
  34. package/dist/schema.js +112 -0
  35. package/dist/schema.js.map +1 -1
  36. package/dist/tool-args.d.ts +13 -1
  37. package/dist/tool-args.d.ts.map +1 -1
  38. package/dist/tool-args.js +50 -1
  39. package/dist/tool-args.js.map +1 -1
  40. package/dist/transport.d.ts.map +1 -1
  41. package/dist/transport.js +3 -0
  42. package/dist/transport.js.map +1 -1
  43. package/dist/usage.d.ts +6 -0
  44. package/dist/usage.d.ts.map +1 -1
  45. package/dist/usage.js +20 -0
  46. package/dist/usage.js.map +1 -1
  47. package/package.json +3 -1
  48. package/src/capability.ts +157 -0
  49. package/src/errors.ts +181 -13
  50. package/src/presets.ts +177 -28
  51. package/src/providers/anthropic.ts +2 -2
  52. package/src/providers/factory.ts +1 -0
  53. package/src/providers/gemini.ts +30 -10
  54. package/src/providers/openai.ts +64 -4
  55. package/src/rate-limit.ts +98 -36
  56. package/src/retry.ts +8 -2
  57. package/src/schema.ts +127 -0
  58. package/src/tool-args.ts +48 -1
  59. package/src/transport.ts +4 -0
  60. package/src/usage.ts +21 -0
package/src/rate-limit.ts CHANGED
@@ -39,6 +39,7 @@ export interface RateLimitReset {
39
39
  }
40
40
 
41
41
  const RETRY_AFTER = "retry-after";
42
+ const RETRY_AFTER_MS = "retry-after-ms";
42
43
  const UNIFIED_5H = "anthropic-ratelimit-unified-5h-";
43
44
  const UNIFIED_7D = "anthropic-ratelimit-unified-7d-";
44
45
 
@@ -74,27 +75,50 @@ function unixSecondsToMs(value: string | null): number | undefined {
74
75
  return n === undefined ? undefined : n * 1000;
75
76
  }
76
77
 
78
+ /** Matches Go-style duration strings like "2m59.56s", "7.66s", "500ms", "1h30m". */
79
+ const DURATION_PATTERN =
80
+ /^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m(?!s))?(?:(\d+(?:\.\d+)?)m?s)?$/i;
81
+
82
+ function parseDurationMs(text: string): number | undefined {
83
+ const match = DURATION_PATTERN.exec(text.trim());
84
+ if (!match || !match.slice(1).some(Boolean)) return undefined;
85
+ const hours = Number(match[1]) || 0;
86
+ const minutes = Number(match[2]) || 0;
87
+ const seconds = Number(match[3]) || 0;
88
+ const isMs = text.toLowerCase().endsWith("ms");
89
+ const secMs = isMs ? seconds : seconds * 1000;
90
+ const total = Math.round(hours * 3_600_000 + minutes * 60_000 + secMs);
91
+ return total >= 0 ? total : undefined;
92
+ }
93
+
77
94
  /** Absolute reset time from a header that spells it either way: epoch seconds
78
- * (the unified reset, most gateways) or an RFC 3339 / HTTP-date string (the
79
- * API-key reset headers). The digit test runs first because `Date.parse("120")`
80
- * is not a rejection — it is the year 120, two millennia in the past. */
95
+ * (the unified reset, most gateways), Go-style duration ("2m59s" from OpenAI),
96
+ * or an RFC 3339 / HTTP-date string (the API-key reset headers). */
81
97
  function resetHeaderMs(value: string | null, now: number): number | undefined {
82
98
  if (!value) return undefined;
83
- if (/^\d+$/.test(value)) {
84
- const seconds = Number(value);
99
+ const trimmed = value.trim();
100
+ if (/^\d+$/.test(trimmed)) {
101
+ const seconds = Number(trimmed);
85
102
  return seconds > EPOCH_SECONDS_FLOOR ? seconds * 1000 : now + seconds * 1000;
86
103
  }
87
- const at = Date.parse(value);
104
+ const duration = parseDurationMs(trimmed);
105
+ if (duration !== undefined) {
106
+ return now + duration;
107
+ }
108
+ const at = Date.parse(trimmed);
88
109
  return Number.isNaN(at) ? undefined : at;
89
110
  }
90
111
 
91
- /** `retry-after` is delay-seconds, but RFC 9110 also allows an absolute date,
92
- * and both forms occur across the providers this package speaks to. */
112
+ /** `retry-after` is delay-seconds, Go-style duration ("60s"), or an absolute
113
+ * HTTP date, and all three occur across providers this package speaks to. */
93
114
  function parseRetryAfter(value: string | null, now: number): number | undefined {
94
115
  if (!value) return undefined;
95
- const n = Number(value);
116
+ const trimmed = value.trim();
117
+ const n = Number(trimmed);
96
118
  if (Number.isFinite(n) && n >= 0) return n * 1000;
97
- const at = Date.parse(value);
119
+ const duration = parseDurationMs(trimmed);
120
+ if (duration !== undefined) return duration;
121
+ const at = Date.parse(trimmed);
98
122
  return Number.isNaN(at) ? undefined : Math.max(0, at - now);
99
123
  }
100
124
 
@@ -110,7 +134,10 @@ function parseRetryAfter(value: string | null, now: number): number | undefined
110
134
  export function parseRateLimitReset(headers: Headers, now = Date.now()): RateLimitReset {
111
135
  const result: RateLimitReset = {};
112
136
 
113
- const after = parseRetryAfter(headers.get(RETRY_AFTER), now);
137
+ // `retry-after-ms` is Anthropic's millisecond spelling of the same header;
138
+ // when both ride a response the coarser one is the same deadline rounded.
139
+ const afterMs = numeric(headers.get(RETRY_AFTER_MS));
140
+ const after = afterMs ?? parseRetryAfter(headers.get(RETRY_AFTER), now);
114
141
  if (after !== undefined) {
115
142
  result.retryAfterMs = after;
116
143
  result.resetAtMs = now + after;
@@ -176,9 +203,31 @@ function jsonSeconds(value: unknown): number | undefined {
176
203
  return Number.isFinite(n) ? n : undefined;
177
204
  }
178
205
 
206
+ const PROSE_RETRY_PATTERNS = [
207
+ /try again in (\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)/i,
208
+ /retry after (\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)/i,
209
+ /resets? (?:in|after) (\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)/i,
210
+ /retry[- ]after[:\s]+(\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)?/i,
211
+ ];
212
+
213
+ function parseProseRetryMs(text: string): number | undefined {
214
+ for (const pattern of PROSE_RETRY_PATTERNS) {
215
+ const match = text.match(pattern);
216
+ if (!match?.[1]) continue;
217
+ const val = Number.parseFloat(match[1]);
218
+ if (!Number.isFinite(val) || val <= 0) continue;
219
+ const unit = (match[2] ?? "s").toLowerCase();
220
+ const multiplier = unit.startsWith("h") ? 3_600_000 : unit.startsWith("m") ? 60_000 : 1_000;
221
+ return Math.ceil(val * multiplier);
222
+ }
223
+ return undefined;
224
+ }
225
+
179
226
  /**
180
227
  * ChatGPT's codex backend puts the reset IN THE 429 BODY, not the headers:
181
228
  * `{"error":{"type":"usage_limit_reached","resets_at":1788801754,"resets_in_seconds":2501465}}`.
229
+ * Other providers and gateways (LiteLLM, Cloudflare, Ollama) report countdowns in prose:
230
+ * `Rate limit exceeded. Try again in 20s.`
182
231
  * The window name is inferred from the wait itself, and only past the floor
183
232
  * above — a sub-minute retry is a throttle, not a subscription window.
184
233
  */
@@ -187,33 +236,46 @@ export function parseUsageLimitBody(bodyText: string, now = Date.now()): RateLim
187
236
  try {
188
237
  body = JSON.parse(bodyText);
189
238
  } catch {
190
- return {};
239
+ body = undefined;
191
240
  }
192
- if (typeof body !== "object" || body === null) return {};
193
- // The fields sit under `error` on the documented shape and at the top level
194
- // on some gateway relays of it. Both are read rather than guessed between.
195
- const source = (body as Record<string, unknown>).error;
196
- const error =
197
- typeof source === "object" && source !== null
198
- ? (source as Record<string, unknown>)
199
- : (body as Record<string, unknown>);
200
-
201
- const inSeconds = jsonSeconds(error.resets_in_seconds);
202
- const atSeconds = jsonSeconds(error.resets_at);
203
- const waitMs =
204
- inSeconds !== undefined && inSeconds >= 0
205
- ? inSeconds * 1000
206
- : atSeconds !== undefined && atSeconds > 0
207
- ? Math.max(0, atSeconds * 1000 - now)
208
- : undefined;
209
- if (waitMs === undefined) return {};
210
-
211
- const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
212
- if (waitMs > WINDOW_FLOOR_MS) {
213
- result.window =
214
- waitMs <= FIVE_HOUR_MAX_MS ? "5h" : waitMs <= WEEKLY_MAX_MS ? "weekly" : "monthly";
241
+
242
+ if (typeof body === "object" && body !== null) {
243
+ const source = (body as Record<string, unknown>).error;
244
+ const error =
245
+ typeof source === "object" && source !== null
246
+ ? (source as Record<string, unknown>)
247
+ : (body as Record<string, unknown>);
248
+
249
+ const inSeconds = jsonSeconds(error.resets_in_seconds);
250
+ const atSeconds = jsonSeconds(error.resets_at);
251
+ const waitMs =
252
+ inSeconds !== undefined && inSeconds >= 0
253
+ ? inSeconds * 1000
254
+ : atSeconds !== undefined && atSeconds > 0
255
+ ? Math.max(0, atSeconds * 1000 - now)
256
+ : undefined;
257
+ if (waitMs !== undefined) {
258
+ const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
259
+ if (waitMs > WINDOW_FLOOR_MS) {
260
+ result.window =
261
+ waitMs <= FIVE_HOUR_MAX_MS ? "5h" : waitMs <= WEEKLY_MAX_MS ? "weekly" : "monthly";
262
+ }
263
+ return result;
264
+ }
215
265
  }
216
- return result;
266
+
267
+ // If structured JSON did not yield a reset, check prose wording
268
+ const waitMs = parseProseRetryMs(bodyText);
269
+ if (waitMs !== undefined) {
270
+ const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
271
+ if (waitMs > WINDOW_FLOOR_MS) {
272
+ result.window =
273
+ waitMs <= FIVE_HOUR_MAX_MS ? "5h" : waitMs <= WEEKLY_MAX_MS ? "weekly" : "monthly";
274
+ }
275
+ return result;
276
+ }
277
+
278
+ return {};
217
279
  }
218
280
 
219
281
  /** Read both places an endpoint can report its reset. A short Retry-After is
package/src/retry.ts CHANGED
@@ -13,7 +13,13 @@
13
13
  //
14
14
  // 3. Honour the provider's own number. When it says `Retry-After: 30`, a
15
15
  // one-second backoff is three wasted attempts before the same wait.
16
- import { classify, isBackupEligible, isTransient, parseRetryAfterMs, ProviderError } from "./errors.ts";
16
+ import {
17
+ classify,
18
+ isBackupEligible,
19
+ isRetryable,
20
+ parseRetryAfterMs,
21
+ ProviderError,
22
+ } from "./errors.ts";
17
23
 
18
24
  export interface RetryOptions {
19
25
  /** Total attempts including the first. Default 3. */
@@ -83,7 +89,7 @@ function delayFor(err: unknown, attempt: number, opts: RetryOptions): number | n
83
89
  return backoffMs(attempt, opts.baseDelayMs ?? DEFAULT_BASE_DELAY_MS, cap);
84
90
  }
85
91
 
86
- const defaultShouldRetry = (err: unknown): boolean => isTransient(classify(err));
92
+ const defaultShouldRetry = (err: unknown, _attempt: number): boolean => isRetryable(err);
87
93
 
88
94
  /**
89
95
  * Run `fn`, retrying transient failures with backoff. For one-shot calls —
package/src/schema.ts CHANGED
@@ -133,3 +133,130 @@ export function isStrictSchema(node: unknown): boolean {
133
133
  ([key, value]) => required.has(key) && isStrictSchema(value),
134
134
  );
135
135
  }
136
+
137
+ /**
138
+ * Sanitize a JSON Schema for Google Gemini's REST API.
139
+ *
140
+ * Gemini's functionDeclarations schema parser only accepts a strict OpenAPI 3.0 subset.
141
+ * It rejects:
142
+ * - Nullable type arrays (`type: ["string", "null"]`), requiring `{ type: "string", nullable: true }`.
143
+ * - Union types with null (`anyOf: [{ type: "string" }, { type: "null" }]`), requiring `{ type: "string", nullable: true }`.
144
+ * - Numeric enums (`enum: [1, 2, 3]`), requiring string enums.
145
+ * - `const: "val"`, requiring `enum: ["val"]`.
146
+ * - Arrays without `items`, requiring an explicit items schema.
147
+ * - Unsupported keywords ($schema, definitions, $defs, patternProperties, additionalProperties: true).
148
+ */
149
+ export function toGeminiToolSchema(node: unknown): Record<string, unknown> {
150
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
151
+ return { type: "object", properties: {} };
152
+ }
153
+ const schema = { ...(node as Record<string, unknown>) };
154
+
155
+ delete schema.$schema;
156
+ delete schema.$id;
157
+ delete schema.$ref;
158
+ delete schema.definitions;
159
+ delete schema.$defs;
160
+ delete schema.patternProperties;
161
+
162
+ if (schema.additionalProperties === true) {
163
+ delete schema.additionalProperties;
164
+ }
165
+
166
+ if ("const" in schema && !("enum" in schema)) {
167
+ schema.enum = [String(schema.const)];
168
+ delete schema.const;
169
+ }
170
+
171
+ const union = schema.anyOf ?? schema.oneOf;
172
+ if (Array.isArray(union)) {
173
+ const nonNullBranches = union.filter(
174
+ (b) => typeof b === "object" && b !== null && (b as Record<string, unknown>).type !== "null",
175
+ );
176
+ const hasNull = union.some(
177
+ (b) => typeof b === "object" && b !== null && (b as Record<string, unknown>).type === "null",
178
+ );
179
+ if (nonNullBranches.length === 1 && typeof nonNullBranches[0] === "object") {
180
+ const merged = toGeminiToolSchema(nonNullBranches[0]);
181
+ if (hasNull) merged.nullable = true;
182
+ return merged;
183
+ }
184
+ }
185
+
186
+ if (Array.isArray(schema.type)) {
187
+ const types = schema.type as string[];
188
+ const hasNull = types.includes("null");
189
+ const nonNull = types.filter((t) => t !== "null");
190
+ schema.type = nonNull[0] ?? "string";
191
+ if (hasNull) schema.nullable = true;
192
+ }
193
+
194
+ if (Array.isArray(schema.enum)) {
195
+ schema.enum = schema.enum.map((val) => String(val));
196
+ }
197
+
198
+ if (schema.type === "array" && !schema.items) {
199
+ schema.items = { type: "string" };
200
+ } else if (schema.items && typeof schema.items === "object") {
201
+ schema.items = toGeminiToolSchema(schema.items);
202
+ }
203
+
204
+ if (schema.properties && typeof schema.properties === "object") {
205
+ const props = schema.properties as Record<string, unknown>;
206
+ const sanitizedProps: Record<string, unknown> = {};
207
+ for (const [key, val] of Object.entries(props)) {
208
+ sanitizedProps[key] = toGeminiToolSchema(val);
209
+ }
210
+ schema.properties = sanitizedProps;
211
+ }
212
+
213
+ return schema;
214
+ }
215
+
216
+ /**
217
+ * Sanitize a tool's JSON Schema for Anthropic's Messages API.
218
+ *
219
+ * Anthropic rejects schemas where the root is an `anyOf` / `oneOf` / `allOf` union,
220
+ * requiring a root object schema (`{ type: "object", properties: ... }`).
221
+ */
222
+ export function toAnthropicToolSchema(node: unknown): Record<string, unknown> {
223
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
224
+ return { type: "object", properties: {} };
225
+ }
226
+ const schema = { ...(node as Record<string, unknown>) };
227
+
228
+ delete schema.$schema;
229
+ delete schema.$id;
230
+
231
+ const union = schema.anyOf ?? schema.oneOf;
232
+ if (Array.isArray(union)) {
233
+ const mergedProperties: Record<string, unknown> = {};
234
+ const mergedRequired = new Set<string>();
235
+ for (const branch of union) {
236
+ if (typeof branch === "object" && branch !== null) {
237
+ const b = branch as Record<string, unknown>;
238
+ if (b.properties && typeof b.properties === "object") {
239
+ Object.assign(mergedProperties, b.properties);
240
+ }
241
+ if (Array.isArray(b.required)) {
242
+ for (const req of b.required) {
243
+ if (typeof req === "string") mergedRequired.add(req);
244
+ }
245
+ }
246
+ }
247
+ }
248
+ delete schema.anyOf;
249
+ delete schema.oneOf;
250
+ schema.type = "object";
251
+ schema.properties = mergedProperties;
252
+ if (mergedRequired.size > 0 && !schema.required) {
253
+ schema.required = Array.from(mergedRequired);
254
+ }
255
+ }
256
+
257
+ if (!schema.type) {
258
+ schema.type = "object";
259
+ }
260
+
261
+ return schema;
262
+ }
package/src/tool-args.ts CHANGED
@@ -85,9 +85,54 @@ function salvageStringFields(raw: string): Record<string, unknown> {
85
85
  return out;
86
86
  }
87
87
 
88
+ /**
89
+ * Attempt to recover the last valid JSON object from concatenated or prepended JSON
90
+ * (e.g. `{}{"query":"hello"}` or `{"a":1}{"b":2}`).
91
+ */
92
+ export function findLastValidJsonObject(raw: string): Record<string, unknown> | null {
93
+ const trimmed = raw.trim();
94
+ if (!trimmed.includes("{")) return null;
95
+
96
+ let startIdx = trimmed.lastIndexOf("{");
97
+ while (startIdx >= 0) {
98
+ const candidate = trimmed.slice(startIdx);
99
+ try {
100
+ const parsed: unknown = JSON.parse(candidate);
101
+ if (isRecord(parsed)) return parsed;
102
+ } catch {
103
+ // Keep searching earlier '{'
104
+ }
105
+ if (startIdx === 0) break;
106
+ startIdx = trimmed.lastIndexOf("{", startIdx - 1);
107
+ }
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * Normalizes tool call IDs:
113
+ * 1. Synthesizes a fallback if empty/whitespace.
114
+ * 2. Clamps oversized IDs (> 64 chars) to avoid ChatGPT Responses 400 errors.
115
+ */
116
+ export function normalizeToolId(id: string | undefined | null, prefix = "call_pk_"): string {
117
+ const clean = id?.trim();
118
+ if (!clean) {
119
+ return `${prefix}${Math.random().toString(36).slice(2, 10)}`;
120
+ }
121
+ if (clean.length > 64) {
122
+ let hash = 0;
123
+ for (let i = 0; i < clean.length; i++) {
124
+ hash = (Math.imul(31, hash) + clean.charCodeAt(i)) | 0;
125
+ }
126
+ const hashHex = (hash >>> 0).toString(16).padStart(8, "0");
127
+ return `${clean.slice(0, 48)}_${hashHex}`;
128
+ }
129
+ return clean;
130
+ }
131
+
88
132
  /**
89
133
  * Parse a tool call's raw argument string into an object, salvaging what a
90
- * truncated stream left behind and healing double-escaped text either way.
134
+ * truncated stream left behind, resolving concatenated JSON, and healing
135
+ * double-escaped text either way.
91
136
  *
92
137
  * Never throws: a tool call the model malformed is data the caller decides
93
138
  * about, not an exception in the transport.
@@ -98,6 +143,8 @@ export function parseToolArgs(raw: string): Record<string, unknown> {
98
143
  try {
99
144
  parsed = JSON.parse(raw);
100
145
  } catch {
146
+ const lastObj = findLastValidJsonObject(raw);
147
+ if (lastObj !== null) return healArgs(lastObj);
101
148
  return healArgs(salvageStringFields(raw));
102
149
  }
103
150
  // A non-object payload is a protocol violation, not a value to pass on.
package/src/transport.ts CHANGED
@@ -68,9 +68,13 @@ async function errorFor(provider: string, res: Response): Promise<ProviderError>
68
68
  const message = text
69
69
  ? `${provider} ${res.status}: ${text.slice(0, 500)}`
70
70
  : `${provider} ${res.status} ${res.statusText}`;
71
+ const shouldRetryHeader = res.headers.get("x-should-retry");
72
+ const shouldRetry =
73
+ shouldRetryHeader === "false" ? false : shouldRetryHeader === "true" ? true : undefined;
71
74
  return new ProviderError(provider, kind, message, {
72
75
  status: res.status,
73
76
  ...reset,
77
+ shouldRetry,
74
78
  retryAfterMs: retryAfterFromHeaders(res.headers) ?? parseRetryAfterMs({}, text),
75
79
  body: text.slice(0, 2_000) || undefined,
76
80
  });
package/src/usage.ts CHANGED
@@ -36,6 +36,19 @@ export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
36
36
  };
37
37
  }
38
38
 
39
+ /**
40
+ * Subtract usage `b` from `a`, clamping each field at zero.
41
+ * Used when an optimistic turn or speculative stream is rolled back.
42
+ */
43
+ export function subtractUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
44
+ return {
45
+ inputTokens: Math.max(0, a.inputTokens - b.inputTokens),
46
+ cachedInputTokens: Math.max(0, a.cachedInputTokens - b.cachedInputTokens),
47
+ cacheWriteTokens: Math.max(0, (a.cacheWriteTokens ?? 0) - (b.cacheWriteTokens ?? 0)),
48
+ outputTokens: Math.max(0, a.outputTokens - b.outputTokens),
49
+ };
50
+ }
51
+
39
52
  /**
40
53
  * USD for one call.
41
54
  *
@@ -79,6 +92,14 @@ export class UsageTracker {
79
92
  this.saved += (cached * (rate.input - rate.cacheRead)) / 1_000_000;
80
93
  }
81
94
 
95
+ subtract(usage: TokenUsage, rate?: ModelRate): void {
96
+ this.usage = subtractUsage(this.usage, usage);
97
+ if (!rate) return;
98
+ this.cost = Math.max(0, this.cost - costUsd(usage, rate));
99
+ const cached = Math.min(Math.max(0, usage.cachedInputTokens), Math.max(0, usage.inputTokens));
100
+ this.saved = Math.max(0, this.saved - (cached * (rate.input - rate.cacheRead)) / 1_000_000);
101
+ }
102
+
82
103
  get totals(): TokenUsage {
83
104
  return { ...this.usage };
84
105
  }