@decocms/mesh-sdk 1.2.1 → 1.2.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.
@@ -35,6 +35,47 @@ function hashServerUrl(url: string): string {
35
35
  return Math.abs(hash).toString(16);
36
36
  }
37
37
 
38
+ /**
39
+ * Override origin for OAuth redirect URIs.
40
+ * Set via `setOAuthRedirectOrigin()` when the browser runs behind a proxy
41
+ * (e.g. tokyo.localhost) that external OAuth servers may not accept.
42
+ */
43
+ let _oauthRedirectOrigin: string | null = null;
44
+
45
+ /**
46
+ * Set a custom origin for OAuth redirect URIs.
47
+ * Call this at app init with the server's internal URL (e.g. http://localhost:3000)
48
+ * so that external OAuth servers accept the redirect URI.
49
+ */
50
+ export function setOAuthRedirectOrigin(origin: string): void {
51
+ _oauthRedirectOrigin = origin;
52
+ }
53
+
54
+ /**
55
+ * Get the origin to use for OAuth redirect URIs.
56
+ * Returns the override if set, otherwise falls back to window.location.origin.
57
+ */
58
+ function getOAuthRedirectOrigin(): string {
59
+ return _oauthRedirectOrigin ?? window.location.origin;
60
+ }
61
+
62
+ /**
63
+ * Check if we're in a local dev environment (localhost or .localhost subdomain).
64
+ */
65
+ function isLocalDev(): boolean {
66
+ try {
67
+ const hostname = window.location.hostname;
68
+ return (
69
+ hostname === "localhost" ||
70
+ hostname.endsWith(".localhost") ||
71
+ hostname === "127.0.0.1" ||
72
+ hostname === "::1"
73
+ );
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
38
79
  /**
39
80
  * Global in-memory store for active OAuth sessions.
40
81
  */
@@ -89,7 +130,7 @@ class McpOAuthProvider implements OAuthClientProvider {
89
130
  constructor(options: McpOAuthProviderOptions) {
90
131
  this.serverUrl = options.serverUrl;
91
132
  this._redirectUrl =
92
- options.callbackUrl ?? `${window.location.origin}/oauth/callback`;
133
+ options.callbackUrl ?? `${getOAuthRedirectOrigin()}/oauth/callback`;
93
134
  this._windowMode = options.windowMode ?? "popup";
94
135
 
95
136
  // Build scope string if provided
@@ -153,8 +194,7 @@ class McpOAuthProvider implements OAuthClientProvider {
153
194
  // Open in new tab - uses localStorage for cross-tab communication
154
195
  const tab = window.open(authorizationUrl.toString(), "_blank");
155
196
  if (!tab) {
156
- // Fallback: navigate current window (will lose state, but works)
157
- window.location.href = authorizationUrl.toString();
197
+ throw new Error("Tab was blocked");
158
198
  }
159
199
  } else {
160
200
  // Open in popup (default)
@@ -170,9 +210,11 @@ class McpOAuthProvider implements OAuthClientProvider {
170
210
  );
171
211
 
172
212
  if (!popup) {
173
- throw new Error(
174
- "OAuth popup was blocked. Please allow popups for this site and try again.",
175
- );
213
+ // Popup was blocked - fallback to new tab (uses localStorage for communication)
214
+ const tab = window.open(authorizationUrl.toString(), "_blank");
215
+ if (!tab) {
216
+ throw new Error("Popup was blocked");
217
+ }
176
218
  }
177
219
  }
178
220
  }
@@ -216,6 +258,10 @@ export interface OAuthTokenInfo {
216
258
  clientId: string | null;
217
259
  clientSecret: string | null;
218
260
  tokenEndpoint: string | null;
261
+ /** OIDC ID token (JWT) returned by some providers (e.g. Google). Contains user identity claims like email. */
262
+ idToken: string | null;
263
+ /** OIDC userinfo endpoint URL from authorization server metadata. Can be called with the access token to retrieve user identity. */
264
+ userinfoEndpoint: string | null;
219
265
  }
220
266
 
221
267
  /**
@@ -236,6 +282,7 @@ interface FullTokenResult {
236
282
  clientId: string | null;
237
283
  clientSecret: string | null;
238
284
  tokenEndpoint: string | null;
285
+ userinfoEndpoint: string | null;
239
286
  }
240
287
 
241
288
  /**
@@ -251,6 +298,8 @@ interface FullTokenResult {
251
298
  */
252
299
  export async function authenticateMcp(params: {
253
300
  connectionId: string;
301
+ /** Organization slug — used to build the org-scoped /api/:org/mcp/... URL. */
302
+ orgSlug?: string;
254
303
  /** Mesh server URL - optional, defaults to window.location.origin (for external apps, provide your Mesh server URL) */
255
304
  meshUrl?: string;
256
305
  clientName?: string;
@@ -263,7 +312,10 @@ export async function authenticateMcp(params: {
263
312
  windowMode?: OAuthWindowMode;
264
313
  }): Promise<AuthenticateMcpResult> {
265
314
  const baseUrl = params.meshUrl ?? window.location.origin;
266
- const serverUrl = new URL(`/mcp/${params.connectionId}`, baseUrl);
315
+ const path = params.orgSlug
316
+ ? `/api/${encodeURIComponent(params.orgSlug)}/mcp/${params.connectionId}`
317
+ : `/mcp/${params.connectionId}`;
318
+ const serverUrl = new URL(path, baseUrl);
267
319
  const provider = new McpOAuthProvider({
268
320
  serverUrl: serverUrl.href,
269
321
  clientName: params.clientName,
@@ -273,6 +325,10 @@ export async function authenticateMcp(params: {
273
325
  windowMode: params.windowMode,
274
326
  });
275
327
 
328
+ // Object to hold the abort function - using an object wrapper so TypeScript
329
+ // properly tracks mutations inside closures
330
+ const oauthAbort: { fn: ((error: Error) => void) | null } = { fn: null };
331
+
276
332
  try {
277
333
  // Wait for OAuth callback message from popup and handle token exchange
278
334
  // Uses both postMessage (primary) and localStorage (fallback for when opener is lost)
@@ -300,6 +356,14 @@ export async function authenticateMcp(params: {
300
356
  }
301
357
  };
302
358
 
359
+ // Expose abort function so we can clean up if auth() throws
360
+ oauthAbort.fn = (error: Error) => {
361
+ if (resolved) return;
362
+ resolved = true;
363
+ cleanup();
364
+ reject(error);
365
+ };
366
+
303
367
  const processCallback = async (data: {
304
368
  success: boolean;
305
369
  code?: string;
@@ -370,6 +434,11 @@ export async function authenticateMcp(params: {
370
434
  ? (clientInfo.client_secret as string)
371
435
  : null,
372
436
  tokenEndpoint: authServerMetadata?.token_endpoint ?? null,
437
+ userinfoEndpoint:
438
+ (authServerMetadata?.userinfo_endpoint as
439
+ | string
440
+ | null
441
+ | undefined) ?? null,
373
442
  });
374
443
  } catch (err) {
375
444
  cleanup();
@@ -379,7 +448,9 @@ export async function authenticateMcp(params: {
379
448
 
380
449
  // Primary: Listen for postMessage from popup
381
450
  const handleMessage = async (event: MessageEvent) => {
382
- if (event.origin !== window.location.origin) return;
451
+ // In local dev, accept messages from any origin because the popup
452
+ // runs at localhost:PORT while the opener may be at *.localhost (proxy)
453
+ if (!isLocalDev() && event.origin !== window.location.origin) return;
383
454
  if (event.data?.type === "mcp:oauth:callback") {
384
455
  await processCallback(event.data);
385
456
  }
@@ -408,11 +479,16 @@ export async function authenticateMcp(params: {
408
479
  },
409
480
  );
410
481
 
482
+ // Attach a no-op catch to prevent unhandled rejection if auth() throws
483
+ // (we'll abort the promise properly in the catch block, but this is a safety net)
484
+ oauthCompletePromise.catch(() => {});
485
+
411
486
  // Start the auth flow
412
487
  const result: AuthResult = await auth(provider, { serverUrl });
413
488
 
414
489
  if (result === "REDIRECT") {
415
490
  const fullResult = await oauthCompletePromise;
491
+ const rawTokens = fullResult.tokens as unknown as Record<string, unknown>;
416
492
  return {
417
493
  token: fullResult.tokens.access_token,
418
494
  tokenInfo: {
@@ -423,6 +499,9 @@ export async function authenticateMcp(params: {
423
499
  clientId: fullResult.clientId,
424
500
  clientSecret: fullResult.clientSecret,
425
501
  tokenEndpoint: fullResult.tokenEndpoint,
502
+ userinfoEndpoint: fullResult.userinfoEndpoint,
503
+ idToken:
504
+ typeof rawTokens.id_token === "string" ? rawTokens.id_token : null,
426
505
  },
427
506
  error: null,
428
507
  };
@@ -431,6 +510,7 @@ export async function authenticateMcp(params: {
431
510
  // If we got here without redirect, check for tokens
432
511
  const tokens = provider.tokens();
433
512
  const clientInfo = provider.clientInformation();
513
+ const rawTokens = tokens as unknown as Record<string, unknown> | null;
434
514
  return {
435
515
  token: tokens?.access_token || null,
436
516
  tokenInfo: tokens
@@ -445,11 +525,21 @@ export async function authenticateMcp(params: {
445
525
  ? (clientInfo.client_secret as string)
446
526
  : null,
447
527
  tokenEndpoint: null, // Would need to be passed through
528
+ userinfoEndpoint: null,
529
+ idToken:
530
+ rawTokens && typeof rawTokens.id_token === "string"
531
+ ? rawTokens.id_token
532
+ : null,
448
533
  }
449
534
  : null,
450
535
  error: null,
451
536
  };
452
537
  } catch (error) {
538
+ // Abort the OAuth promise to trigger cleanup (clear timeout, remove event listeners)
539
+ // This prevents unhandled promise rejections and lingering listeners
540
+ if (oauthAbort.fn) {
541
+ oauthAbort.fn(error instanceof Error ? error : new Error(String(error)));
542
+ }
453
543
  return {
454
544
  token: null,
455
545
  tokenInfo: null,
@@ -477,7 +567,10 @@ function sendCallbackData(
477
567
  ): boolean {
478
568
  // Try postMessage first (primary method)
479
569
  if (window.opener && !window.opener.closed) {
480
- window.opener.postMessage(data, window.location.origin);
570
+ // In local dev, use "*" because the popup (localhost:PORT) and opener
571
+ // (*.localhost proxy) are different origins — targeted postMessage would be silently dropped
572
+ const targetOrigin = isLocalDev() ? "*" : window.location.origin;
573
+ window.opener.postMessage(data, targetOrigin);
481
574
  return true;
482
575
  }
483
576
 
@@ -612,14 +705,32 @@ function getCurrentOrigin(): string | undefined {
612
705
  }
613
706
 
614
707
  /**
615
- * Extract connection ID from MCP proxy URL
708
+ * Extract connection ID from MCP proxy URL.
709
+ * Supports both legacy `/mcp/:id` and org-scoped `/api/:org/mcp/:id` paths.
616
710
  */
617
711
  function extractConnectionIdFromUrl(url: string): string | null {
618
712
  try {
619
713
  // Use current origin as base for relative URLs (browser only)
620
714
  const base = getCurrentOrigin();
621
715
  const urlObj = base ? new URL(url, base) : new URL(url);
622
- const match = urlObj.pathname.match(/^\/mcp\/([^/]+)/);
716
+ const orgScoped = urlObj.pathname.match(/^\/api\/[^/]+\/mcp\/([^/]+)/);
717
+ if (orgScoped) return orgScoped[1] ?? null;
718
+ const legacy = urlObj.pathname.match(/^\/mcp\/([^/]+)/);
719
+ return legacy?.[1] ?? null;
720
+ } catch {
721
+ return null;
722
+ }
723
+ }
724
+
725
+ /**
726
+ * Extract org slug from an org-scoped MCP proxy URL (`/api/:org/mcp/...`).
727
+ * Returns null for legacy `/mcp/...` URLs.
728
+ */
729
+ function extractOrgSlugFromUrl(url: string): string | null {
730
+ try {
731
+ const base = getCurrentOrigin();
732
+ const urlObj = base ? new URL(url, base) : new URL(url);
733
+ const match = urlObj.pathname.match(/^\/api\/([^/]+)\/mcp\//);
623
734
  return match?.[1] ?? null;
624
735
  } catch {
625
736
  return null;
@@ -629,17 +740,22 @@ function extractConnectionIdFromUrl(url: string): string | null {
629
740
  /**
630
741
  * Check if connection has a stored OAuth token
631
742
  * @param connectionId - The connection ID to check
743
+ * @param orgSlug - Organization slug used to build the org-scoped path
632
744
  * @param apiBaseUrl - Base URL for the API call (optional, defaults to relative path)
633
745
  */
634
746
  async function checkOAuthTokenStatus(
635
747
  connectionId: string,
748
+ orgSlug: string,
636
749
  apiBaseUrl?: string,
637
750
  ): Promise<{ hasToken: boolean }> {
638
751
  try {
639
- const path = `/api/connections/${connectionId}/oauth-token/status`;
752
+ const path = `/api/${encodeURIComponent(orgSlug)}/connections/${connectionId}/oauth-token/status`;
640
753
  const url = apiBaseUrl ? new URL(path, apiBaseUrl).href : path;
754
+ const currentOrigin = getCurrentOrigin();
755
+ const isSameOrigin =
756
+ !apiBaseUrl || new URL(apiBaseUrl).origin === currentOrigin;
641
757
  const response = await fetch(url, {
642
- credentials: apiBaseUrl ? "omit" : "include", // Don't send cookies for cross-origin
758
+ credentials: isSameOrigin ? "include" : "omit", // Don't send cookies for cross-origin
643
759
  });
644
760
  if (!response.ok) {
645
761
  return { hasToken: false };
@@ -653,17 +769,21 @@ async function checkOAuthTokenStatus(
653
769
 
654
770
  /**
655
771
  * Check if an MCP connection is authenticated and whether it supports OAuth
656
- * @param params.url - The MCP URL to check
772
+ * @param params.url - The org-scoped MCP URL to check (`/api/:org/mcp/...`)
657
773
  * @param params.token - Authorization token (optional)
774
+ * @param params.orgId - Organization ID (deprecated; org is now resolved from the URL path)
658
775
  * @param params.meshUrl - Mesh server URL for API calls (optional, defaults to URL origin)
659
776
  */
660
777
  export async function isConnectionAuthenticated({
661
778
  url,
662
779
  token,
780
+ orgId: _orgId,
663
781
  meshUrl,
664
782
  }: {
665
783
  url: string;
666
784
  token: string | null;
785
+ /** @deprecated Org is resolved from the URL path; this is kept for call-site compatibility. */
786
+ orgId?: string;
667
787
  /** Mesh server URL for API calls - optional, defaults to extracting from url parameter */
668
788
  meshUrl?: string;
669
789
  }): Promise<McpAuthStatus> {
@@ -695,6 +815,7 @@ export async function isConnectionAuthenticated({
695
815
 
696
816
  // Extract connection ID for OAuth token status check
697
817
  const connectionId = extractConnectionIdFromUrl(url);
818
+ const orgSlug = extractOrgSlugFromUrl(url);
698
819
  // Determine base URL for API calls (meshUrl > URL origin > current origin)
699
820
  // Use current origin as base for relative URLs (browser only)
700
821
  const base = getCurrentOrigin();
@@ -703,9 +824,10 @@ export async function isConnectionAuthenticated({
703
824
 
704
825
  if (response.ok) {
705
826
  // Check if we have an OAuth token stored for this connection
706
- const oauthStatus = connectionId
707
- ? await checkOAuthTokenStatus(connectionId, apiBaseUrl)
708
- : { hasToken: false };
827
+ const oauthStatus =
828
+ connectionId && orgSlug
829
+ ? await checkOAuthTokenStatus(connectionId, orgSlug, apiBaseUrl)
830
+ : { hasToken: false };
709
831
 
710
832
  return {
711
833
  isAuthenticated: true,
@@ -15,10 +15,10 @@ export const KEYS = {
15
15
  threads: (locator: string) => ["threads", locator] as const,
16
16
  virtualMcpThreads: (locator: string, virtualMcpId: string) =>
17
17
  ["threads", locator, "virtual-mcp", virtualMcpId] as const,
18
- thread: (locator: string, threadId: string) =>
19
- ["thread", locator, threadId] as const,
20
- threadMessages: (locator: string, threadId: string) =>
21
- ["thread-messages", locator, threadId] as const,
18
+ thread: (locator: string, taskId: string) =>
19
+ ["thread", locator, taskId] as const,
20
+ threadMessages: (locator: string, taskId: string) =>
21
+ ["thread-messages", locator, taskId] as const,
22
22
  messages: (locator: string) => ["messages", locator] as const,
23
23
 
24
24
  // Organizations list
@@ -57,6 +57,7 @@ export const KEYS = {
57
57
  ) => ["mcp", "client", orgId, connectionId, token, meshUrl] as const,
58
58
 
59
59
  // MCP client-based queries (scoped by client instance)
60
+ // Note: client can be null/undefined for skip queries that shouldn't execute
60
61
  mcpToolsList: (client: unknown) =>
61
62
  ["mcp", "client", client, "tools"] as const,
62
63
  mcpResourcesList: (client: unknown) =>
@@ -80,6 +81,10 @@ export const KEYS = {
80
81
  // Models list (scoped by organization)
81
82
  modelsList: (orgId: string) => ["models-list", orgId] as const,
82
83
 
84
+ // Virtual MCP last-used info (most recent thread per agent)
85
+ virtualMcpLastUsed: (orgId: string, ids: string[]) =>
86
+ ["virtual-mcp", "last-used", orgId, ids] as const,
87
+
83
88
  // Collections (scoped by connection)
84
89
  connectionCollections: (connectionId: string) =>
85
90
  [connectionId, "collections", "discovery"] as const,
@@ -148,6 +153,13 @@ export const KEYS = {
148
153
  owner: string | null | undefined,
149
154
  repo: string | null | undefined,
150
155
  ) => ["github-readme", owner, repo] as const,
156
+ githubBranches: (
157
+ orgId: string,
158
+ orgSlug: string,
159
+ connectionId: string | null | undefined,
160
+ owner: string,
161
+ repo: string,
162
+ ) => ["github-branches", orgId, orgSlug, connectionId, owner, repo] as const,
151
163
 
152
164
  // Monitoring queries
153
165
  monitoringStats: () => ["monitoring", "stats"] as const,
@@ -175,4 +187,8 @@ export const KEYS = {
175
187
 
176
188
  // User data
177
189
  user: (userId: string) => ["user", userId] as const,
190
+
191
+ // Registry app lookup (by app ID)
192
+ registryApp: (orgId: string, appId: string) =>
193
+ ["registry-app", orgId, appId] as const,
178
194
  } as const;
@@ -0,0 +1,4 @@
1
+ export {
2
+ createServerFromClient,
3
+ type ServerFromClientOptions,
4
+ } from "@decocms/mcp-utils";
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Usage utilities tests
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ addUsage,
8
+ calculateUsageStats,
9
+ emptyUsageStats,
10
+ sanitizeProviderMetadata,
11
+ } from "./usage";
12
+
13
+ describe("sanitizeProviderMetadata", () => {
14
+ test("allows only safe fields", () => {
15
+ const metadata = {
16
+ openrouter: {
17
+ usage: { inputTokens: 10, outputTokens: 20, cost: 0.001 },
18
+ cost: 0.001,
19
+ model: "gpt-4",
20
+ internal_id: "should-be-stripped",
21
+ debug_info: "should-be-stripped",
22
+ },
23
+ };
24
+
25
+ const result = sanitizeProviderMetadata(metadata);
26
+
27
+ expect(result).toEqual({
28
+ openrouter: {
29
+ usage: { inputTokens: 10, outputTokens: 20, cost: 0.001 },
30
+ cost: 0.001,
31
+ model: "gpt-4",
32
+ },
33
+ });
34
+ });
35
+
36
+ test("strips sensitive fields", () => {
37
+ const metadata = {
38
+ provider: {
39
+ api_key: "secret",
40
+ user_id: "user_123",
41
+ usage: { totalTokens: 100 },
42
+ },
43
+ };
44
+
45
+ const result = sanitizeProviderMetadata(metadata);
46
+
47
+ expect(result).toEqual({
48
+ provider: {
49
+ usage: { totalTokens: 100 },
50
+ },
51
+ });
52
+ });
53
+
54
+ test("handles nested objects", () => {
55
+ const metadata = {
56
+ openrouter: {
57
+ usage: { inputTokens: 5, outputTokens: 10 },
58
+ nested: { sensitive: "data" },
59
+ },
60
+ };
61
+
62
+ const result = sanitizeProviderMetadata(metadata);
63
+
64
+ expect(result).toEqual({
65
+ openrouter: {
66
+ usage: { inputTokens: 5, outputTokens: 10 },
67
+ },
68
+ });
69
+ });
70
+
71
+ test("returns undefined for empty input", () => {
72
+ expect(sanitizeProviderMetadata(undefined)).toBeUndefined();
73
+ expect(sanitizeProviderMetadata({})).toBeUndefined();
74
+ });
75
+
76
+ test("handles non-object provider data", () => {
77
+ const metadata = {
78
+ provider: "string-value",
79
+ other: null,
80
+ };
81
+
82
+ const result = sanitizeProviderMetadata(metadata);
83
+
84
+ expect(result).toBeUndefined();
85
+ });
86
+ });
87
+
88
+ describe("calculateUsageStats", () => {
89
+ test("sums message-level usage correctly", () => {
90
+ const messages = [
91
+ {
92
+ metadata: {
93
+ usage: { totalTokens: 1000, inputTokens: 500, outputTokens: 500 },
94
+ },
95
+ },
96
+ {
97
+ metadata: {
98
+ usage: { totalTokens: 500, inputTokens: 200, outputTokens: 300 },
99
+ },
100
+ },
101
+ ];
102
+
103
+ const result = calculateUsageStats(messages);
104
+
105
+ expect(result.totalTokens).toBe(1500);
106
+ expect(result.inputTokens).toBe(700);
107
+ expect(result.outputTokens).toBe(800);
108
+ });
109
+
110
+ test("handles missing metadata gracefully", () => {
111
+ const messages = [
112
+ { metadata: { usage: { totalTokens: 100 } } },
113
+ { metadata: {} },
114
+ { metadata: undefined },
115
+ {},
116
+ ];
117
+
118
+ const result = calculateUsageStats(messages);
119
+
120
+ expect(result.totalTokens).toBe(100);
121
+ });
122
+
123
+ test("returns empty stats for empty messages", () => {
124
+ const result = calculateUsageStats([]);
125
+
126
+ expect(result).toEqual(emptyUsageStats());
127
+ });
128
+ });
129
+
130
+ describe("addUsage", () => {
131
+ test("adds usage fields correctly", () => {
132
+ const acc = emptyUsageStats();
133
+ const step = {
134
+ inputTokens: 100,
135
+ outputTokens: 200,
136
+ reasoningTokens: 50,
137
+ totalTokens: 350,
138
+ };
139
+
140
+ const result = addUsage(acc, step);
141
+
142
+ expect(result.inputTokens).toBe(100);
143
+ expect(result.outputTokens).toBe(200);
144
+ expect(result.reasoningTokens).toBe(50);
145
+ expect(result.totalTokens).toBe(350);
146
+ });
147
+
148
+ test("handles undefined fields", () => {
149
+ const acc = { ...emptyUsageStats(), inputTokens: 10 };
150
+ const step = { outputTokens: 20 };
151
+
152
+ const result = addUsage(acc, step);
153
+
154
+ expect(result.inputTokens).toBe(10);
155
+ expect(result.outputTokens).toBe(20);
156
+ });
157
+
158
+ test("returns accumulated when step is null", () => {
159
+ const acc = { ...emptyUsageStats(), totalTokens: 100 };
160
+ const result = addUsage(acc, null);
161
+ expect(result).toBe(acc);
162
+ });
163
+
164
+ test("accumulates cache tokens from inputTokenDetails", () => {
165
+ const acc = emptyUsageStats();
166
+ const step = {
167
+ inputTokens: 1000,
168
+ outputTokens: 50,
169
+ inputTokenDetails: {
170
+ cacheReadTokens: 800,
171
+ cacheWriteTokens: 100,
172
+ },
173
+ };
174
+ const result = addUsage(acc, step);
175
+ expect(result.cacheReadTokens).toBe(800);
176
+ expect(result.cacheWriteTokens).toBe(100);
177
+ });
178
+
179
+ test("falls back to cachedInputTokens when inputTokenDetails is absent", () => {
180
+ const acc = emptyUsageStats();
181
+ const step = {
182
+ inputTokens: 1000,
183
+ outputTokens: 50,
184
+ cachedInputTokens: 600,
185
+ };
186
+ const result = addUsage(acc, step);
187
+ expect(result.cacheReadTokens).toBe(600);
188
+ expect(result.cacheWriteTokens).toBe(0);
189
+ });
190
+
191
+ test("inputTokenDetails takes precedence over cachedInputTokens", () => {
192
+ const acc = emptyUsageStats();
193
+ const step = {
194
+ inputTokens: 1000,
195
+ cachedInputTokens: 999, // would be wrong
196
+ inputTokenDetails: { cacheReadTokens: 800, cacheWriteTokens: 0 },
197
+ };
198
+ const result = addUsage(acc, step);
199
+ expect(result.cacheReadTokens).toBe(800);
200
+ });
201
+ });
202
+
203
+ describe("calculateUsageStats — cache fields", () => {
204
+ test("sums cache read/write across messages", () => {
205
+ const messages = [
206
+ {
207
+ metadata: {
208
+ usage: {
209
+ inputTokens: 100,
210
+ outputTokens: 50,
211
+ inputTokenDetails: { cacheReadTokens: 80, cacheWriteTokens: 10 },
212
+ },
213
+ },
214
+ },
215
+ {
216
+ metadata: {
217
+ usage: {
218
+ inputTokens: 200,
219
+ outputTokens: 25,
220
+ inputTokenDetails: { cacheReadTokens: 150 },
221
+ },
222
+ },
223
+ },
224
+ ];
225
+ const result = calculateUsageStats(messages);
226
+ expect(result.cacheReadTokens).toBe(230);
227
+ expect(result.cacheWriteTokens).toBe(10);
228
+ });
229
+ });