@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.
- package/README.md +10 -10
- package/package.json +7 -4
- package/src/context/index.ts +5 -1
- package/src/context/project-context.tsx +68 -29
- package/src/hooks/index.ts +10 -0
- package/src/hooks/use-collections.ts +179 -63
- package/src/hooks/use-connection.ts +50 -4
- package/src/hooks/use-mcp-client.ts +81 -11
- package/src/hooks/use-mcp-prompts.ts +16 -6
- package/src/hooks/use-mcp-resources.ts +15 -5
- package/src/hooks/use-virtual-mcp.ts +64 -0
- package/src/index.ts +119 -4
- package/src/lib/bridge-transport.test.ts +368 -0
- package/src/lib/bridge-transport.ts +6 -0
- package/src/lib/constants.test.ts +26 -0
- package/src/lib/constants.ts +193 -36
- package/src/lib/default-model.ts +281 -0
- package/src/lib/mcp-oauth.ts +139 -17
- package/src/lib/query-keys.ts +20 -4
- package/src/lib/server-client-bridge.ts +4 -0
- package/src/lib/usage.test.ts +229 -0
- package/src/lib/usage.ts +187 -0
- package/src/plugins/index.ts +15 -0
- package/src/plugins/plugin-context-provider.tsx +99 -0
- package/src/plugins/topbar-portal.tsx +118 -0
- package/src/types/ai-providers.ts +86 -0
- package/src/types/connection.ts +43 -20
- package/src/types/decopilot-events.test.ts +78 -0
- package/src/types/decopilot-events.ts +171 -0
- package/src/types/index.ts +48 -1
- package/src/types/virtual-mcp.test.ts +202 -0
- package/src/types/virtual-mcp.ts +514 -109
package/src/lib/mcp-oauth.ts
CHANGED
|
@@ -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 ?? `${
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
707
|
-
|
|
708
|
-
|
|
827
|
+
const oauthStatus =
|
|
828
|
+
connectionId && orgSlug
|
|
829
|
+
? await checkOAuthTokenStatus(connectionId, orgSlug, apiBaseUrl)
|
|
830
|
+
: { hasToken: false };
|
|
709
831
|
|
|
710
832
|
return {
|
|
711
833
|
isAuthenticated: true,
|
package/src/lib/query-keys.ts
CHANGED
|
@@ -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,
|
|
19
|
-
["thread", locator,
|
|
20
|
-
threadMessages: (locator: string,
|
|
21
|
-
["thread-messages", locator,
|
|
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,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
|
+
});
|