@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/usage.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage utilities for extracting cost and token stats from AI provider metadata.
|
|
3
|
+
*
|
|
4
|
+
* Supports provider-specific cost extraction (e.g., OpenRouter)
|
|
5
|
+
* and aggregation of usage across messages or streaming steps.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Types
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export interface UsageData {
|
|
13
|
+
inputTokens?: number;
|
|
14
|
+
outputTokens?: number;
|
|
15
|
+
reasoningTokens?: number;
|
|
16
|
+
totalTokens?: number;
|
|
17
|
+
/**
|
|
18
|
+
* AI SDK normalizes cache token counts across providers via
|
|
19
|
+
* usage.inputTokenDetails — populated identically by the anthropic,
|
|
20
|
+
* openai, google and openrouter adapters. `cachedInputTokens` is the
|
|
21
|
+
* convenience shorthand the AI SDK also surfaces (= cacheReadTokens).
|
|
22
|
+
*/
|
|
23
|
+
cachedInputTokens?: number;
|
|
24
|
+
inputTokenDetails?: {
|
|
25
|
+
cacheReadTokens?: number;
|
|
26
|
+
cacheWriteTokens?: number;
|
|
27
|
+
noCacheTokens?: number;
|
|
28
|
+
};
|
|
29
|
+
providerMetadata?: {
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UsageStats {
|
|
35
|
+
inputTokens: number;
|
|
36
|
+
outputTokens: number;
|
|
37
|
+
reasoningTokens: number;
|
|
38
|
+
totalTokens: number;
|
|
39
|
+
cost: number;
|
|
40
|
+
/** Tokens read from prompt cache (anthropic / openrouter / openai / google). */
|
|
41
|
+
cacheReadTokens: number;
|
|
42
|
+
/** Tokens written to prompt cache (Anthropic only — others auto-cache without separate billing). */
|
|
43
|
+
cacheWriteTokens: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type ProviderCostExtractor = (
|
|
47
|
+
providerMetadata: NonNullable<UsageData["providerMetadata"]>,
|
|
48
|
+
) => number | null;
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Provider-specific cost extractors
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Registry of provider-specific cost extractors.
|
|
56
|
+
* Each extractor attempts to get the cost from provider metadata.
|
|
57
|
+
*/
|
|
58
|
+
const PROVIDER_COST_EXTRACTORS: Record<string, ProviderCostExtractor> = {
|
|
59
|
+
openrouter: (providerMetadata) => {
|
|
60
|
+
const openrouter = providerMetadata?.openrouter;
|
|
61
|
+
if (
|
|
62
|
+
typeof openrouter === "object" &&
|
|
63
|
+
openrouter !== null &&
|
|
64
|
+
"usage" in openrouter &&
|
|
65
|
+
typeof openrouter.usage === "object" &&
|
|
66
|
+
openrouter.usage !== null &&
|
|
67
|
+
"cost" in openrouter.usage &&
|
|
68
|
+
typeof openrouter.usage.cost === "number"
|
|
69
|
+
) {
|
|
70
|
+
return openrouter.usage.cost;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// Cost extraction
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extract cost from usage metadata by checking all known provider formats.
|
|
82
|
+
*/
|
|
83
|
+
export function getCostFromUsage(usage: UsageData | null | undefined): number {
|
|
84
|
+
if (!usage?.providerMetadata) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const extractor of Object.values(PROVIDER_COST_EXTRACTORS)) {
|
|
89
|
+
const cost = extractor(usage.providerMetadata);
|
|
90
|
+
if (cost !== null) {
|
|
91
|
+
return cost;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Provider metadata sanitization
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
const ALLOWED_PROVIDER_FIELDS = ["usage", "cost", "model"] as const;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Sanitize provider metadata to prevent leaking sensitive data.
|
|
106
|
+
* Only allows whitelisted fields: usage, cost, model.
|
|
107
|
+
*/
|
|
108
|
+
export function sanitizeProviderMetadata(
|
|
109
|
+
metadata: Record<string, unknown> | undefined,
|
|
110
|
+
): Record<string, unknown> | undefined {
|
|
111
|
+
if (!metadata) return undefined;
|
|
112
|
+
|
|
113
|
+
const sanitized: Record<string, unknown> = {};
|
|
114
|
+
for (const provider in metadata) {
|
|
115
|
+
const providerData = metadata[provider];
|
|
116
|
+
if (typeof providerData === "object" && providerData !== null) {
|
|
117
|
+
const safeData: Record<string, unknown> = {};
|
|
118
|
+
for (const field of ALLOWED_PROVIDER_FIELDS) {
|
|
119
|
+
if (field in providerData) {
|
|
120
|
+
safeData[field] = (providerData as Record<string, unknown>)[field];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
sanitized[provider] = safeData;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// Usage accumulation
|
|
131
|
+
// ============================================================================
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create an empty UsageStats object.
|
|
135
|
+
*/
|
|
136
|
+
export function emptyUsageStats(): UsageStats {
|
|
137
|
+
return {
|
|
138
|
+
inputTokens: 0,
|
|
139
|
+
outputTokens: 0,
|
|
140
|
+
reasoningTokens: 0,
|
|
141
|
+
totalTokens: 0,
|
|
142
|
+
cost: 0,
|
|
143
|
+
cacheReadTokens: 0,
|
|
144
|
+
cacheWriteTokens: 0,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Accumulate a step's usage into an existing UsageStats total.
|
|
150
|
+
* Returns a new UsageStats object (immutable).
|
|
151
|
+
*/
|
|
152
|
+
export function addUsage(
|
|
153
|
+
accumulated: UsageStats,
|
|
154
|
+
stepUsage: UsageData | null | undefined,
|
|
155
|
+
): UsageStats {
|
|
156
|
+
if (!stepUsage) return accumulated;
|
|
157
|
+
|
|
158
|
+
const cacheRead =
|
|
159
|
+
stepUsage.inputTokenDetails?.cacheReadTokens ??
|
|
160
|
+
stepUsage.cachedInputTokens ??
|
|
161
|
+
0;
|
|
162
|
+
const cacheWrite = stepUsage.inputTokenDetails?.cacheWriteTokens ?? 0;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
inputTokens: accumulated.inputTokens + (stepUsage.inputTokens ?? 0),
|
|
166
|
+
outputTokens: accumulated.outputTokens + (stepUsage.outputTokens ?? 0),
|
|
167
|
+
reasoningTokens:
|
|
168
|
+
accumulated.reasoningTokens + (stepUsage.reasoningTokens ?? 0),
|
|
169
|
+
totalTokens: accumulated.totalTokens + (stepUsage.totalTokens ?? 0),
|
|
170
|
+
cost: accumulated.cost + getCostFromUsage(stepUsage),
|
|
171
|
+
cacheReadTokens: accumulated.cacheReadTokens + cacheRead,
|
|
172
|
+
cacheWriteTokens: accumulated.cacheWriteTokens + cacheWrite,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Calculate aggregated usage stats from an array of messages.
|
|
178
|
+
* Each message is expected to have an optional `metadata.usage` field.
|
|
179
|
+
*/
|
|
180
|
+
export function calculateUsageStats(
|
|
181
|
+
messages: Array<{ metadata?: { usage?: UsageData } }>,
|
|
182
|
+
): UsageStats {
|
|
183
|
+
return messages.reduce<UsageStats>(
|
|
184
|
+
(acc, message) => addUsage(acc, message.metadata?.usage),
|
|
185
|
+
emptyUsageStats(),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Plugin context provider and hook
|
|
2
|
+
export {
|
|
3
|
+
PluginContextProvider,
|
|
4
|
+
usePluginContext,
|
|
5
|
+
type PluginContextProviderProps,
|
|
6
|
+
type UsePluginContextOptions,
|
|
7
|
+
} from "./plugin-context-provider";
|
|
8
|
+
|
|
9
|
+
// Topbar portal system
|
|
10
|
+
export {
|
|
11
|
+
TopbarPortal,
|
|
12
|
+
TopbarPortalProvider,
|
|
13
|
+
useTopbarPortalTargets,
|
|
14
|
+
type TopbarSide,
|
|
15
|
+
} from "./topbar-portal";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Context Provider
|
|
3
|
+
*
|
|
4
|
+
* React context provider and hook for accessing plugin context.
|
|
5
|
+
* Moved from @decocms/bindings to @decocms/mesh-sdk to consolidate
|
|
6
|
+
* all plugin-facing React components in one package.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
10
|
+
import type {
|
|
11
|
+
Binder,
|
|
12
|
+
PluginContext,
|
|
13
|
+
PluginContextPartial,
|
|
14
|
+
} from "@decocms/bindings";
|
|
15
|
+
|
|
16
|
+
// Internal context stores the partial version (nullable connection fields)
|
|
17
|
+
// The hook return type depends on the options passed
|
|
18
|
+
const PluginContextInternal = createContext<PluginContextPartial | null>(null);
|
|
19
|
+
|
|
20
|
+
export interface PluginContextProviderProps<TBinding extends Binder> {
|
|
21
|
+
value: PluginContext<TBinding> | PluginContextPartial<TBinding>;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Provider component for plugin context.
|
|
27
|
+
* Used by the mesh app layout to provide context to plugin routes.
|
|
28
|
+
*/
|
|
29
|
+
export function PluginContextProvider<TBinding extends Binder>({
|
|
30
|
+
value,
|
|
31
|
+
children,
|
|
32
|
+
}: PluginContextProviderProps<TBinding>) {
|
|
33
|
+
return (
|
|
34
|
+
<PluginContextInternal.Provider value={value as PluginContextPartial}>
|
|
35
|
+
{children}
|
|
36
|
+
</PluginContextInternal.Provider>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Options for usePluginContext hook.
|
|
42
|
+
*/
|
|
43
|
+
export interface UsePluginContextOptions {
|
|
44
|
+
/**
|
|
45
|
+
* Set to true when calling from an empty state component.
|
|
46
|
+
* This returns nullable connection fields since no valid connection exists.
|
|
47
|
+
*/
|
|
48
|
+
partial?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Hook to access the plugin context with typed tool caller.
|
|
53
|
+
*
|
|
54
|
+
* @template TBinding - The binding type for typed tool calls
|
|
55
|
+
* @param options - Optional settings
|
|
56
|
+
* @param options.partial - Set to true in empty state components where connection may not exist
|
|
57
|
+
* @throws Error if used outside of PluginContextProvider
|
|
58
|
+
* @throws Error if connection is null but partial option is not set
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```tsx
|
|
62
|
+
* // In route component (connection guaranteed by layout)
|
|
63
|
+
* const { toolCaller, connection } = usePluginContext<typeof REGISTRY_APP_BINDING>();
|
|
64
|
+
* const result = await toolCaller("COLLECTION_REGISTRY_APP_LIST", { limit: 20 });
|
|
65
|
+
*
|
|
66
|
+
* // In empty state component (no connection available)
|
|
67
|
+
* const { session, org } = usePluginContext<typeof REGISTRY_APP_BINDING>({ partial: true });
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function usePluginContext<TBinding extends Binder = Binder>(options: {
|
|
71
|
+
partial: true;
|
|
72
|
+
}): PluginContextPartial<TBinding>;
|
|
73
|
+
export function usePluginContext<
|
|
74
|
+
TBinding extends Binder = Binder,
|
|
75
|
+
>(): PluginContext<TBinding>;
|
|
76
|
+
export function usePluginContext<TBinding extends Binder = Binder>(
|
|
77
|
+
options?: UsePluginContextOptions,
|
|
78
|
+
): PluginContext<TBinding> | PluginContextPartial<TBinding> {
|
|
79
|
+
const context = useContext(PluginContextInternal);
|
|
80
|
+
if (!context) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
"usePluginContext must be used within a PluginContextProvider",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If partial mode, return as-is with nullable fields
|
|
87
|
+
if (options?.partial) {
|
|
88
|
+
return context as PluginContextPartial<TBinding>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Otherwise, assert that connection exists (routes should always have one)
|
|
92
|
+
if (!context.connectionId || !context.connection || !context.toolCaller) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"usePluginContext requires a valid connection. Use { partial: true } in empty state components.",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return context as PluginContext<TBinding>;
|
|
99
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topbar Portal
|
|
3
|
+
*
|
|
4
|
+
* A React portal-based system for rendering content into the project topbar
|
|
5
|
+
* from anywhere in the component tree (including plugin routes).
|
|
6
|
+
*
|
|
7
|
+
* Uses createPortal so that portaled content preserves the source tree's
|
|
8
|
+
* React context -- plugin context, query client, etc. all work naturally.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
*
|
|
12
|
+
* 1. The app wraps its layout with <TopbarPortalProvider>
|
|
13
|
+
* 2. ProjectTopbar calls useTopbarPortalTargets() and attaches callback refs to slot divs
|
|
14
|
+
* 3. Plugin components render <TopbarPortal side="right">...</TopbarPortal>
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createContext, useContext, useState, type ReactNode } from "react";
|
|
18
|
+
import { createPortal } from "react-dom";
|
|
19
|
+
|
|
20
|
+
export type TopbarSide = "left" | "center" | "right";
|
|
21
|
+
|
|
22
|
+
interface TopbarPortalContextValue {
|
|
23
|
+
/** Current DOM elements for each slot (null until ProjectTopbar mounts) */
|
|
24
|
+
leftEl: HTMLDivElement | null;
|
|
25
|
+
centerEl: HTMLDivElement | null;
|
|
26
|
+
rightEl: HTMLDivElement | null;
|
|
27
|
+
/** Callback refs for ProjectTopbar to register slot elements */
|
|
28
|
+
setLeftEl: (el: HTMLDivElement | null) => void;
|
|
29
|
+
setCenterEl: (el: HTMLDivElement | null) => void;
|
|
30
|
+
setRightEl: (el: HTMLDivElement | null) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TopbarPortalContext = createContext<TopbarPortalContextValue | null>(
|
|
34
|
+
null,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Provider that manages the portal target DOM elements for the three topbar slots.
|
|
39
|
+
* Place this in the layout tree so it wraps both the topbar and the content area.
|
|
40
|
+
*/
|
|
41
|
+
export function TopbarPortalProvider({ children }: { children: ReactNode }) {
|
|
42
|
+
const [leftEl, setLeftEl] = useState<HTMLDivElement | null>(null);
|
|
43
|
+
const [centerEl, setCenterEl] = useState<HTMLDivElement | null>(null);
|
|
44
|
+
const [rightEl, setRightEl] = useState<HTMLDivElement | null>(null);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<TopbarPortalContext.Provider
|
|
48
|
+
value={{ leftEl, centerEl, rightEl, setLeftEl, setCenterEl, setRightEl }}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</TopbarPortalContext.Provider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Hook used by the ProjectTopbar component to get callback refs for the portal target divs.
|
|
57
|
+
* Returns stable callback refs that register/unregister the DOM elements in the provider.
|
|
58
|
+
*/
|
|
59
|
+
export function useTopbarPortalTargets() {
|
|
60
|
+
const ctx = useContext(TopbarPortalContext);
|
|
61
|
+
|
|
62
|
+
const leftRef = (el: HTMLDivElement | null) => ctx?.setLeftEl(el);
|
|
63
|
+
const centerRef = (el: HTMLDivElement | null) => ctx?.setCenterEl(el);
|
|
64
|
+
const rightRef = (el: HTMLDivElement | null) => ctx?.setRightEl(el);
|
|
65
|
+
|
|
66
|
+
if (!ctx) return null;
|
|
67
|
+
|
|
68
|
+
return { leftRef, centerRef, rightRef };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Portal component that renders children into one of the topbar slots.
|
|
73
|
+
*
|
|
74
|
+
* Because this uses React createPortal, the children maintain the React context
|
|
75
|
+
* of the component that renders <TopbarPortal> -- not the topbar's context.
|
|
76
|
+
* This means plugin context (connection, toolCaller, etc.) is available.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```tsx
|
|
80
|
+
* import { TopbarPortal, usePluginContext } from "@decocms/mesh-sdk/plugins";
|
|
81
|
+
*
|
|
82
|
+
* function MyPluginPage() {
|
|
83
|
+
* const { toolCaller } = usePluginContext<typeof MY_BINDING>();
|
|
84
|
+
*
|
|
85
|
+
* return (
|
|
86
|
+
* <>
|
|
87
|
+
* <TopbarPortal side="right">
|
|
88
|
+
* <Button onClick={() => toolCaller("SOME_TOOL", {})}>
|
|
89
|
+
* Action
|
|
90
|
+
* </Button>
|
|
91
|
+
* </TopbarPortal>
|
|
92
|
+
* <div>Page content...</div>
|
|
93
|
+
* </>
|
|
94
|
+
* );
|
|
95
|
+
* }
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export function TopbarPortal({
|
|
99
|
+
side,
|
|
100
|
+
children,
|
|
101
|
+
}: {
|
|
102
|
+
side: TopbarSide;
|
|
103
|
+
children: ReactNode;
|
|
104
|
+
}) {
|
|
105
|
+
const ctx = useContext(TopbarPortalContext);
|
|
106
|
+
if (!ctx) return null;
|
|
107
|
+
|
|
108
|
+
const el =
|
|
109
|
+
side === "left"
|
|
110
|
+
? ctx.leftEl
|
|
111
|
+
: side === "center"
|
|
112
|
+
? ctx.centerEl
|
|
113
|
+
: ctx.rightEl;
|
|
114
|
+
|
|
115
|
+
if (!el) return null;
|
|
116
|
+
|
|
117
|
+
return createPortal(children, el);
|
|
118
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// AI Provider Types — shared between server tool output and client hooks
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
export const PROVIDER_IDS = [
|
|
6
|
+
"deco",
|
|
7
|
+
"anthropic",
|
|
8
|
+
"openrouter",
|
|
9
|
+
"google",
|
|
10
|
+
"claude-code",
|
|
11
|
+
"codex",
|
|
12
|
+
"openai-compatible",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type ProviderId = (typeof PROVIDER_IDS)[number];
|
|
16
|
+
|
|
17
|
+
/** All known model capability tokens. Sourced from OpenRouter modality strings. */
|
|
18
|
+
export const MODEL_CAPABILITIES = [
|
|
19
|
+
"text",
|
|
20
|
+
"image",
|
|
21
|
+
"vision",
|
|
22
|
+
"audio",
|
|
23
|
+
"video",
|
|
24
|
+
"file",
|
|
25
|
+
"reasoning",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export type ModelCapability = (typeof MODEL_CAPABILITIES)[number];
|
|
29
|
+
|
|
30
|
+
export interface AiProviderModelLimits {
|
|
31
|
+
contextWindow: number;
|
|
32
|
+
/** Null means the provider does not advertise a specific cap. */
|
|
33
|
+
maxOutputTokens: number | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AiProviderModelCosts {
|
|
37
|
+
input: number;
|
|
38
|
+
output: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AiProviderModel {
|
|
42
|
+
providerId: ProviderId;
|
|
43
|
+
modelId: string;
|
|
44
|
+
title: string;
|
|
45
|
+
description: string | null;
|
|
46
|
+
logo: string | null;
|
|
47
|
+
capabilities: ModelCapability[];
|
|
48
|
+
limits: AiProviderModelLimits | null;
|
|
49
|
+
costs: AiProviderModelCosts | null;
|
|
50
|
+
/** When true the upstream provider has flagged this model as deprecated. */
|
|
51
|
+
deprecated?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* When true, this model can ONLY be used through the provider's
|
|
54
|
+
* `AsyncResearchProvider` capability (e.g. Gemini Deep Research via the
|
|
55
|
+
* Interactions API). It is unusable as a Thinking/Coding/Fast/Image model
|
|
56
|
+
* because `streamText` / `generateContent` will reject it. UIs should
|
|
57
|
+
* restrict it to the deep-research slot.
|
|
58
|
+
*/
|
|
59
|
+
asyncResearch?: boolean;
|
|
60
|
+
/** Client-side only — the credential key ID used to fetch this model. */
|
|
61
|
+
keyId?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AiProviderKey {
|
|
65
|
+
id: string;
|
|
66
|
+
providerId: ProviderId;
|
|
67
|
+
label: string;
|
|
68
|
+
/**
|
|
69
|
+
* Frontend preset id (e.g. "litellm", "ollama") for openai-compatible keys
|
|
70
|
+
* that were created from a branded preset card. Null otherwise.
|
|
71
|
+
*/
|
|
72
|
+
presetId: string | null;
|
|
73
|
+
createdBy: string;
|
|
74
|
+
createdAt: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AiProviderInfo {
|
|
78
|
+
id: ProviderId;
|
|
79
|
+
name: string;
|
|
80
|
+
description: string;
|
|
81
|
+
logo?: string | null;
|
|
82
|
+
supportedMethods: ("api-key" | "oauth-pkce" | "cli-activate")[];
|
|
83
|
+
supportsTopUp?: boolean;
|
|
84
|
+
supportsCredits?: boolean;
|
|
85
|
+
supportsProvision?: boolean;
|
|
86
|
+
}
|
package/src/types/connection.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Uses snake_case field names matching the database schema directly.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -23,13 +24,26 @@ const OAuthConfigSchema = z.object({
|
|
|
23
24
|
export type OAuthConfig = z.infer<typeof OAuthConfigSchema>;
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
+
* JSON-Schema-safe JSON Schema object.
|
|
28
|
+
*
|
|
29
|
+
* The MCP SDK's ToolSchema uses z.custom() (AssertObjectSchema) for property
|
|
30
|
+
* values inside inputSchema/outputSchema. Zod 4's toJSONSchema() cannot
|
|
31
|
+
* represent z.custom(), throwing "Custom types cannot be represented in JSON
|
|
32
|
+
* Schema". We override only these two fields with a safe equivalent so that
|
|
33
|
+
* all other ToolSchema fields (name, annotations, execution, icons, _meta, …)
|
|
34
|
+
* still flow through automatically when the MCP SDK is updated.
|
|
27
35
|
*/
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
const JsonSchemaObjectSchema = z
|
|
37
|
+
.object({
|
|
38
|
+
type: z.literal("object"),
|
|
39
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
40
|
+
required: z.array(z.string()).optional(),
|
|
41
|
+
})
|
|
42
|
+
.catchall(z.unknown());
|
|
43
|
+
|
|
44
|
+
const ToolDefinitionSchema = ToolSchema.extend({
|
|
45
|
+
inputSchema: JsonSchemaObjectSchema,
|
|
46
|
+
outputSchema: JsonSchemaObjectSchema.optional(),
|
|
33
47
|
});
|
|
34
48
|
|
|
35
49
|
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>;
|
|
@@ -88,6 +102,11 @@ export const ConnectionEntitySchema = z.object({
|
|
|
88
102
|
icon: z.string().nullable().describe("Icon URL for the connection"),
|
|
89
103
|
app_name: z.string().nullable().describe("Associated app name"),
|
|
90
104
|
app_id: z.string().nullable().describe("Associated app ID"),
|
|
105
|
+
slug: z
|
|
106
|
+
.string()
|
|
107
|
+
.nullable()
|
|
108
|
+
.optional()
|
|
109
|
+
.describe("URL-safe slug derived from app_name, connection_url, or title"),
|
|
91
110
|
|
|
92
111
|
connection_type: z
|
|
93
112
|
.enum(["HTTP", "SSE", "Websocket", "STDIO", "VIRTUAL"])
|
|
@@ -152,20 +171,24 @@ export const ConnectionCreateDataSchema = ConnectionEntitySchema.omit({
|
|
|
152
171
|
tools: true,
|
|
153
172
|
bindings: true,
|
|
154
173
|
status: true,
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
174
|
+
})
|
|
175
|
+
.partial({
|
|
176
|
+
id: true,
|
|
177
|
+
description: true,
|
|
178
|
+
icon: true,
|
|
179
|
+
app_name: true,
|
|
180
|
+
app_id: true,
|
|
181
|
+
connection_url: true,
|
|
182
|
+
connection_token: true,
|
|
183
|
+
connection_headers: true,
|
|
184
|
+
oauth_config: true,
|
|
185
|
+
configuration_state: true,
|
|
186
|
+
configuration_scopes: true,
|
|
187
|
+
metadata: true,
|
|
188
|
+
})
|
|
189
|
+
.extend({
|
|
190
|
+
icon: z.string().nullish(),
|
|
191
|
+
});
|
|
169
192
|
|
|
170
193
|
export type ConnectionCreateData = z.infer<typeof ConnectionCreateDataSchema>;
|
|
171
194
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createDecopilotThreadStatusEvent,
|
|
4
|
+
DECOPILOT_EVENTS,
|
|
5
|
+
} from "./decopilot-events";
|
|
6
|
+
|
|
7
|
+
describe("createDecopilotThreadStatusEvent", () => {
|
|
8
|
+
test("carries virtualMcpId, createdBy, and triggerId on data", () => {
|
|
9
|
+
const e = createDecopilotThreadStatusEvent("task-1", "in_progress", {
|
|
10
|
+
virtualMcpId: "vm-1",
|
|
11
|
+
createdBy: "user-1",
|
|
12
|
+
triggerId: "trig-1",
|
|
13
|
+
});
|
|
14
|
+
expect(e.type).toBe(DECOPILOT_EVENTS.THREAD_STATUS);
|
|
15
|
+
expect(e.subject).toBe("task-1");
|
|
16
|
+
expect(e.data.status).toBe("in_progress");
|
|
17
|
+
expect(e.data.virtual_mcp_id).toBe("vm-1");
|
|
18
|
+
expect(e.data.created_by).toBe("user-1");
|
|
19
|
+
expect(e.data.trigger_id).toBe("trig-1");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("omits optional fields when not provided", () => {
|
|
23
|
+
const e = createDecopilotThreadStatusEvent("task-1", "completed");
|
|
24
|
+
expect(e.data.status).toBe("completed");
|
|
25
|
+
expect(e.data.virtual_mcp_id).toBeUndefined();
|
|
26
|
+
expect(e.data.created_by).toBeUndefined();
|
|
27
|
+
expect(e.data.trigger_id).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("preserves explicit null trigger_id (human-initiated thread)", () => {
|
|
31
|
+
const e = createDecopilotThreadStatusEvent("task-1", "completed", {
|
|
32
|
+
triggerId: null,
|
|
33
|
+
});
|
|
34
|
+
expect(e.data.trigger_id).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("works with only virtualMcpId provided (migration shape)", () => {
|
|
38
|
+
const e = createDecopilotThreadStatusEvent("task-1", "in_progress", {
|
|
39
|
+
virtualMcpId: "vm-1",
|
|
40
|
+
});
|
|
41
|
+
expect(e.data.virtual_mcp_id).toBe("vm-1");
|
|
42
|
+
expect(e.data.created_by).toBeUndefined();
|
|
43
|
+
expect(e.data.trigger_id).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("createDecopilotThreadStatusEvent — enriched fields", () => {
|
|
48
|
+
test("round-trips title, branch, createdAt, updatedAt", () => {
|
|
49
|
+
const e = createDecopilotThreadStatusEvent("task-1", "in_progress", {
|
|
50
|
+
virtualMcpId: "vm-1",
|
|
51
|
+
title: "Refactor login",
|
|
52
|
+
branch: "feature/login",
|
|
53
|
+
createdAt: "2026-05-19T00:00:00.000Z",
|
|
54
|
+
updatedAt: "2026-05-19T00:05:00.000Z",
|
|
55
|
+
});
|
|
56
|
+
expect(e.data.title).toBe("Refactor login");
|
|
57
|
+
expect(e.data.branch).toBe("feature/login");
|
|
58
|
+
expect(e.data.created_at).toBe("2026-05-19T00:00:00.000Z");
|
|
59
|
+
expect(e.data.updated_at).toBe("2026-05-19T00:05:00.000Z");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("omits the new fields when not provided", () => {
|
|
63
|
+
const e = createDecopilotThreadStatusEvent("task-1", "in_progress", {
|
|
64
|
+
virtualMcpId: "vm-1",
|
|
65
|
+
});
|
|
66
|
+
expect(e.data.title).toBeUndefined();
|
|
67
|
+
expect(e.data.branch).toBeUndefined();
|
|
68
|
+
expect(e.data.created_at).toBeUndefined();
|
|
69
|
+
expect(e.data.updated_at).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("explicit null branch is preserved", () => {
|
|
73
|
+
const e = createDecopilotThreadStatusEvent("task-1", "in_progress", {
|
|
74
|
+
branch: null,
|
|
75
|
+
});
|
|
76
|
+
expect(e.data.branch).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|