@decocms/mesh-sdk 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/src/context/index.ts +6 -1
- package/src/context/project-context.tsx +78 -29
- package/src/hooks/index.ts +7 -0
- package/src/hooks/use-collections.ts +160 -51
- package/src/hooks/use-connection.ts +39 -4
- package/src/hooks/use-mcp-client.ts +55 -2
- package/src/hooks/use-mcp-prompts.ts +16 -6
- package/src/hooks/use-mcp-resources.ts +15 -5
- package/src/index.ts +82 -3
- package/src/lib/bridge-transport.test.ts +368 -0
- package/src/lib/bridge-transport.ts +434 -0
- package/src/lib/constants.ts +113 -10
- package/src/lib/default-model.ts +96 -0
- package/src/lib/mcp-oauth.ts +80 -9
- package/src/lib/query-keys.ts +1 -0
- package/src/lib/server-client-bridge.ts +146 -0
- package/src/lib/usage.test.ts +163 -0
- package/src/lib/usage.ts +161 -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 +68 -0
- package/src/types/connection.ts +38 -20
- package/src/types/decopilot-events.ts +128 -0
- package/src/types/index.ts +30 -1
- package/src/types/virtual-mcp.ts +107 -109
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { AiProviderModel, ProviderId } from "../types/ai-providers";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Preferred default models for each well-known provider.
|
|
5
|
+
*
|
|
6
|
+
* Each entry is an ordered list of candidate model ID strings — lower indexes
|
|
7
|
+
* have higher priority. The selector first tries exact matches across the full
|
|
8
|
+
* list, then falls back to substring matches in the same priority order.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_MODEL_PREFERENCES: Partial<Record<ProviderId, string[]>> =
|
|
11
|
+
{
|
|
12
|
+
anthropic: ["claude-sonnet-4-6", "claude-sonnet", "claude"],
|
|
13
|
+
openrouter: [
|
|
14
|
+
"anthropic/claude-opus-4.6",
|
|
15
|
+
"anthropic/claude-4.6-opus",
|
|
16
|
+
"anthropic/claude-sonnet-4-6",
|
|
17
|
+
"anthropic/claude-sonnet",
|
|
18
|
+
"anthropic/claude",
|
|
19
|
+
],
|
|
20
|
+
deco: [
|
|
21
|
+
"anthropic/claude-haiku-4-5",
|
|
22
|
+
"anthropic/claude-haiku",
|
|
23
|
+
"anthropic/claude",
|
|
24
|
+
],
|
|
25
|
+
google: ["gemini-3-flash"],
|
|
26
|
+
"claude-code": [
|
|
27
|
+
"claude-code:sonnet",
|
|
28
|
+
"claude-code:opus",
|
|
29
|
+
"claude-code:haiku",
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Preferred fast/cheap models per provider — used for lightweight tasks
|
|
35
|
+
* like title generation where latency and cost matter more than capability.
|
|
36
|
+
*/
|
|
37
|
+
export const FAST_MODEL_PREFERENCES: Partial<Record<ProviderId, string[]>> = {
|
|
38
|
+
anthropic: ["claude-haiku-4-5", "claude-haiku"],
|
|
39
|
+
openrouter: [
|
|
40
|
+
"qwen/qwen3.5-flash",
|
|
41
|
+
"anthropic/claude-haiku-4.5",
|
|
42
|
+
"anthropic/claude-haiku",
|
|
43
|
+
"google/gemini-3-flash",
|
|
44
|
+
],
|
|
45
|
+
deco: ["qwen/qwen3.5-flash", "anthropic/claude-haiku"],
|
|
46
|
+
google: ["gemini-2.5-flash", "gemini-3-flash"],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Return the preferred fast model ID for a given provider.
|
|
51
|
+
* Returns the first candidate or `null` if no preference is configured.
|
|
52
|
+
*/
|
|
53
|
+
export function getFastModel(providerId: ProviderId): string | null {
|
|
54
|
+
const candidates = FAST_MODEL_PREFERENCES[providerId];
|
|
55
|
+
return candidates?.[0] ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Select the best default model from a loaded list for a given provider.
|
|
60
|
+
*
|
|
61
|
+
* Resolution order:
|
|
62
|
+
* 1. Exact `modelId` match — walk candidates in priority order.
|
|
63
|
+
* 2. Substring match — walk candidates in priority order, return the first
|
|
64
|
+
* model whose `modelId` contains the candidate string.
|
|
65
|
+
* 3. First model in the list.
|
|
66
|
+
* 4. `null` if the list is empty.
|
|
67
|
+
*
|
|
68
|
+
* @param models Full model list returned by the provider for this key.
|
|
69
|
+
* @param providerId The provider that owns the key.
|
|
70
|
+
* @param keyId Credential key ID to attach — mirrors what
|
|
71
|
+
* `handleModelSelect` does on explicit user selection.
|
|
72
|
+
*/
|
|
73
|
+
export function selectDefaultModel(
|
|
74
|
+
models: AiProviderModel[],
|
|
75
|
+
providerId: ProviderId,
|
|
76
|
+
keyId?: string,
|
|
77
|
+
): AiProviderModel | null {
|
|
78
|
+
if (models.length === 0) return null;
|
|
79
|
+
|
|
80
|
+
const candidates = DEFAULT_MODEL_PREFERENCES[providerId] ?? [];
|
|
81
|
+
|
|
82
|
+
const withKey = (model: AiProviderModel): AiProviderModel =>
|
|
83
|
+
keyId !== undefined ? { ...model, keyId } : model;
|
|
84
|
+
|
|
85
|
+
for (const candidate of candidates) {
|
|
86
|
+
const exact = models.find((m) => m.modelId === candidate);
|
|
87
|
+
if (exact) return withKey(exact);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const candidate of candidates) {
|
|
91
|
+
const partial = models.find((m) => m.modelId.includes(candidate));
|
|
92
|
+
if (partial) return withKey(partial);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return withKey(models[0] as AiProviderModel);
|
|
96
|
+
}
|
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
|
}
|
|
@@ -273,6 +315,10 @@ export async function authenticateMcp(params: {
|
|
|
273
315
|
windowMode: params.windowMode,
|
|
274
316
|
});
|
|
275
317
|
|
|
318
|
+
// Object to hold the abort function - using an object wrapper so TypeScript
|
|
319
|
+
// properly tracks mutations inside closures
|
|
320
|
+
const oauthAbort: { fn: ((error: Error) => void) | null } = { fn: null };
|
|
321
|
+
|
|
276
322
|
try {
|
|
277
323
|
// Wait for OAuth callback message from popup and handle token exchange
|
|
278
324
|
// Uses both postMessage (primary) and localStorage (fallback for when opener is lost)
|
|
@@ -300,6 +346,14 @@ export async function authenticateMcp(params: {
|
|
|
300
346
|
}
|
|
301
347
|
};
|
|
302
348
|
|
|
349
|
+
// Expose abort function so we can clean up if auth() throws
|
|
350
|
+
oauthAbort.fn = (error: Error) => {
|
|
351
|
+
if (resolved) return;
|
|
352
|
+
resolved = true;
|
|
353
|
+
cleanup();
|
|
354
|
+
reject(error);
|
|
355
|
+
};
|
|
356
|
+
|
|
303
357
|
const processCallback = async (data: {
|
|
304
358
|
success: boolean;
|
|
305
359
|
code?: string;
|
|
@@ -379,7 +433,9 @@ export async function authenticateMcp(params: {
|
|
|
379
433
|
|
|
380
434
|
// Primary: Listen for postMessage from popup
|
|
381
435
|
const handleMessage = async (event: MessageEvent) => {
|
|
382
|
-
|
|
436
|
+
// In local dev, accept messages from any origin because the popup
|
|
437
|
+
// runs at localhost:PORT while the opener may be at *.localhost (proxy)
|
|
438
|
+
if (!isLocalDev() && event.origin !== window.location.origin) return;
|
|
383
439
|
if (event.data?.type === "mcp:oauth:callback") {
|
|
384
440
|
await processCallback(event.data);
|
|
385
441
|
}
|
|
@@ -408,6 +464,10 @@ export async function authenticateMcp(params: {
|
|
|
408
464
|
},
|
|
409
465
|
);
|
|
410
466
|
|
|
467
|
+
// Attach a no-op catch to prevent unhandled rejection if auth() throws
|
|
468
|
+
// (we'll abort the promise properly in the catch block, but this is a safety net)
|
|
469
|
+
oauthCompletePromise.catch(() => {});
|
|
470
|
+
|
|
411
471
|
// Start the auth flow
|
|
412
472
|
const result: AuthResult = await auth(provider, { serverUrl });
|
|
413
473
|
|
|
@@ -450,6 +510,11 @@ export async function authenticateMcp(params: {
|
|
|
450
510
|
error: null,
|
|
451
511
|
};
|
|
452
512
|
} catch (error) {
|
|
513
|
+
// Abort the OAuth promise to trigger cleanup (clear timeout, remove event listeners)
|
|
514
|
+
// This prevents unhandled promise rejections and lingering listeners
|
|
515
|
+
if (oauthAbort.fn) {
|
|
516
|
+
oauthAbort.fn(error instanceof Error ? error : new Error(String(error)));
|
|
517
|
+
}
|
|
453
518
|
return {
|
|
454
519
|
token: null,
|
|
455
520
|
tokenInfo: null,
|
|
@@ -477,7 +542,10 @@ function sendCallbackData(
|
|
|
477
542
|
): boolean {
|
|
478
543
|
// Try postMessage first (primary method)
|
|
479
544
|
if (window.opener && !window.opener.closed) {
|
|
480
|
-
|
|
545
|
+
// In local dev, use "*" because the popup (localhost:PORT) and opener
|
|
546
|
+
// (*.localhost proxy) are different origins — targeted postMessage would be silently dropped
|
|
547
|
+
const targetOrigin = isLocalDev() ? "*" : window.location.origin;
|
|
548
|
+
window.opener.postMessage(data, targetOrigin);
|
|
481
549
|
return true;
|
|
482
550
|
}
|
|
483
551
|
|
|
@@ -638,8 +706,11 @@ async function checkOAuthTokenStatus(
|
|
|
638
706
|
try {
|
|
639
707
|
const path = `/api/connections/${connectionId}/oauth-token/status`;
|
|
640
708
|
const url = apiBaseUrl ? new URL(path, apiBaseUrl).href : path;
|
|
709
|
+
const currentOrigin = getCurrentOrigin();
|
|
710
|
+
const isSameOrigin =
|
|
711
|
+
!apiBaseUrl || new URL(apiBaseUrl).origin === currentOrigin;
|
|
641
712
|
const response = await fetch(url, {
|
|
642
|
-
credentials:
|
|
713
|
+
credentials: isSameOrigin ? "include" : "omit", // Don't send cookies for cross-origin
|
|
643
714
|
});
|
|
644
715
|
if (!response.ok) {
|
|
645
716
|
return { hasToken: false };
|
package/src/lib/query-keys.ts
CHANGED
|
@@ -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) =>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Client Bridge
|
|
3
|
+
*
|
|
4
|
+
* Creates an MCP Server that delegates all requests to an MCP Client.
|
|
5
|
+
* This allows using a Client as if it were a Server, useful for proxying
|
|
6
|
+
* or bridging between different transport layers.
|
|
7
|
+
*
|
|
8
|
+
* ## Usage
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { createServerFromClient } from "@decocms/mesh-sdk";
|
|
12
|
+
* import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
13
|
+
* import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
14
|
+
*
|
|
15
|
+
* const client = new Client(...);
|
|
16
|
+
* await client.connect(clientTransport);
|
|
17
|
+
*
|
|
18
|
+
* const server = createServerFromClient(
|
|
19
|
+
* client,
|
|
20
|
+
* { name: "proxy-server", version: "1.0.0" }
|
|
21
|
+
* );
|
|
22
|
+
*
|
|
23
|
+
* const transport = new WebStandardStreamableHTTPServerTransport({});
|
|
24
|
+
* await server.connect(transport);
|
|
25
|
+
*
|
|
26
|
+
* // Handle requests via transport.handleRequest(req)
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
31
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
32
|
+
import type {
|
|
33
|
+
Implementation,
|
|
34
|
+
ServerCapabilities,
|
|
35
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
36
|
+
import {
|
|
37
|
+
CallToolRequestSchema,
|
|
38
|
+
GetPromptRequestSchema,
|
|
39
|
+
ListPromptsRequestSchema,
|
|
40
|
+
ListResourcesRequestSchema,
|
|
41
|
+
ListResourceTemplatesRequestSchema,
|
|
42
|
+
ListToolsRequestSchema,
|
|
43
|
+
ReadResourceRequestSchema,
|
|
44
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Options for creating a server from a client
|
|
48
|
+
*/
|
|
49
|
+
export interface ServerFromClientOptions {
|
|
50
|
+
/**
|
|
51
|
+
* Server capabilities. If not provided, uses client.getServerCapabilities()
|
|
52
|
+
*/
|
|
53
|
+
capabilities?: ServerCapabilities;
|
|
54
|
+
/**
|
|
55
|
+
* Server instructions. If not provided, uses client.getInstructions()
|
|
56
|
+
*/
|
|
57
|
+
instructions?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Timeout in milliseconds for tool calls forwarded to the client.
|
|
60
|
+
* If not provided, the MCP SDK default (60s) is used.
|
|
61
|
+
*/
|
|
62
|
+
toolCallTimeoutMs?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Creates an MCP Server that delegates all requests to the provided Client.
|
|
67
|
+
*
|
|
68
|
+
* @param client - The MCP Client to delegate requests to
|
|
69
|
+
* @param serverInfo - Server metadata (ImplementationSchema-compatible: name, version, title, description, icons, websiteUrl)
|
|
70
|
+
* @param options - Optional server configuration (capabilities and instructions)
|
|
71
|
+
* @returns An MCP Server instance configured to delegate to the client
|
|
72
|
+
*/
|
|
73
|
+
export function createServerFromClient(
|
|
74
|
+
client: Client,
|
|
75
|
+
serverInfo: Implementation,
|
|
76
|
+
options?: ServerFromClientOptions,
|
|
77
|
+
): McpServer {
|
|
78
|
+
// Get capabilities from client if not provided
|
|
79
|
+
const capabilities = options?.capabilities ?? client.getServerCapabilities();
|
|
80
|
+
|
|
81
|
+
// Get instructions from client if not provided
|
|
82
|
+
const instructions = options?.instructions ?? client.getInstructions();
|
|
83
|
+
|
|
84
|
+
// Create MCP server with capabilities and instructions
|
|
85
|
+
const server = new McpServer(serverInfo, {
|
|
86
|
+
capabilities,
|
|
87
|
+
instructions,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Set up request handlers that delegate to client methods
|
|
91
|
+
|
|
92
|
+
// Tools handlers
|
|
93
|
+
// Strip outputSchema from tools so downstream clients (e.g. the browser's
|
|
94
|
+
// MCP Client) don't cache validators and reject structuredContent that
|
|
95
|
+
// doesn't perfectly match the downstream server's declared schema.
|
|
96
|
+
// A proxy should pass through responses as-is — validation is the
|
|
97
|
+
// responsibility of the originating server, not intermediaries.
|
|
98
|
+
server.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
99
|
+
const result = await client.listTools();
|
|
100
|
+
return {
|
|
101
|
+
...result,
|
|
102
|
+
tools: result.tools.map(({ outputSchema: _, ...tool }) => tool),
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
server.server.setRequestHandler(CallToolRequestSchema, (request) =>
|
|
107
|
+
client.callTool(
|
|
108
|
+
request.params,
|
|
109
|
+
undefined,
|
|
110
|
+
options?.toolCallTimeoutMs
|
|
111
|
+
? { timeout: options.toolCallTimeoutMs }
|
|
112
|
+
: undefined,
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Resources handlers (only if capabilities include resources)
|
|
117
|
+
if (capabilities?.resources) {
|
|
118
|
+
server.server.setRequestHandler(ListResourcesRequestSchema, () =>
|
|
119
|
+
client.listResources(),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
server.server.setRequestHandler(ReadResourceRequestSchema, (request) =>
|
|
123
|
+
client.readResource(request.params),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () =>
|
|
127
|
+
client.listResourceTemplates(),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Prompts handlers (only if capabilities include prompts)
|
|
132
|
+
if (capabilities?.prompts) {
|
|
133
|
+
server.server.setRequestHandler(ListPromptsRequestSchema, () =>
|
|
134
|
+
client.listPrompts(),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
server.server.setRequestHandler(GetPromptRequestSchema, (request) =>
|
|
138
|
+
client.getPrompt({
|
|
139
|
+
...request.params,
|
|
140
|
+
arguments: request.params.arguments ?? {},
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return server;
|
|
146
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
});
|