@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.
@@ -5,7 +5,10 @@
5
5
  * This module provides constants and factory functions for creating standard MCP connections.
6
6
  */
7
7
 
8
- import type { ConnectionCreateData } from "../types/connection";
8
+ import type {
9
+ ConnectionCreateData,
10
+ ConnectionEntity,
11
+ } from "../types/connection";
9
12
  import type { VirtualMCPEntity } from "../types/virtual-mcp";
10
13
 
11
14
  /**
@@ -21,6 +24,10 @@ export const WellKnownOrgMCPId = {
21
24
  REGISTRY: (org: string) => `${org}_registry`,
22
25
  /** Community MCP registry */
23
26
  COMMUNITY_REGISTRY: (org: string) => `${org}_community-registry`,
27
+ /** Dev Assets MCP - local file storage for development */
28
+ DEV_ASSETS: (org: string) => `${org}_dev-assets`,
29
+ /** Site Diagnostics agent (note: prefix-first format, not org-first) */
30
+ SITE_DIAGNOSTICS: (org: string) => `site-diagnostics_${org}`,
24
31
  };
25
32
 
26
33
  /**
@@ -30,6 +37,13 @@ export const WellKnownOrgMCPId = {
30
37
  */
31
38
  export const SELF_MCP_ALIAS_ID = "self";
32
39
 
40
+ /**
41
+ * Frontend connection ID for the dev-assets MCP endpoint.
42
+ * Use this constant when calling object storage tools from the frontend in dev mode.
43
+ * The endpoint is exposed at /mcp/dev-assets.
44
+ */
45
+ export const DEV_ASSETS_MCP_ALIAS_ID = "dev-assets";
46
+
33
47
  /**
34
48
  * Get well-known connection definition for the Deco Store registry.
35
49
  * This can be used by both frontend and backend to create registry connections.
@@ -44,7 +58,7 @@ export function getWellKnownRegistryConnection(
44
58
  title: "Deco Store",
45
59
  description: "Official deco MCP registry with curated integrations",
46
60
  connection_type: "HTTP",
47
- connection_url: "https://api.decocms.com/mcp/registry",
61
+ connection_url: "https://studio.decocms.com/org/deco/registry/mcp",
48
62
  icon: "https://assets.decocache.com/decocms/00ccf6c3-9e13-4517-83b0-75ab84554bb9/596364c63320075ca58483660156b6d9de9b526e.png",
49
63
  app_name: "deco-registry",
50
64
  app_id: null,
@@ -72,7 +86,7 @@ export function getWellKnownCommunityRegistryConnection(): ConnectionCreateData
72
86
  title: "MCP Registry",
73
87
  description: "Community MCP registry with thousands of handy MCPs",
74
88
  connection_type: "HTTP",
75
- connection_url: "https://sites-registry.decocache.com/mcp",
89
+ connection_url: "https://sites-registry.deco.site/mcp",
76
90
  icon: "https://assets.decocache.com/decocms/cd7ca472-0f72-463a-b0de-6e44bdd0f9b4/mcp.png",
77
91
  app_name: "mcp-registry",
78
92
  app_id: null,
@@ -101,8 +115,8 @@ export function getWellKnownSelfConnection(
101
115
  ): ConnectionCreateData {
102
116
  return {
103
117
  id: WellKnownOrgMCPId.SELF(orgId),
104
- title: "Mesh MCP",
105
- description: "The MCP for the mesh API",
118
+ title: "Deco CMS",
119
+ description: "The MCP for the CMS API",
106
120
  connection_type: "HTTP",
107
121
  // Custom url for targeting this mcp. It's a standalone endpoint that exposes all management tools.
108
122
  connection_url: `${baseUrl}/mcp/${SELF_MCP_ALIAS_ID}`,
@@ -121,33 +135,38 @@ export function getWellKnownSelfConnection(
121
135
  }
122
136
 
123
137
  /**
124
- * Get well-known connection definition for OpenRouter.
125
- * Used by the chat UI to offer a one-click install when no model provider is connected.
138
+ * Get well-known connection definition for Dev Assets MCP.
139
+ * This is a dev-only MCP that provides local file storage at /data/assets/<org_id>/.
140
+ * It implements the OBJECT_STORAGE_BINDING interface.
141
+ *
142
+ * @param baseUrl - The base URL for the MCP server (e.g., "http://localhost:3000")
143
+ * @param orgId - The organization ID
144
+ * @returns ConnectionCreateData for the Dev Assets MCP
126
145
  */
127
- export function getWellKnownOpenRouterConnection(
128
- opts: { id?: string } = {},
146
+ export function getWellKnownDevAssetsConnection(
147
+ baseUrl: string,
148
+ orgId: string,
129
149
  ): ConnectionCreateData {
130
150
  return {
131
- id: opts.id,
132
- title: "OpenRouter",
133
- description: "Access hundreds of LLM models from a single API",
134
- icon: "https://openrouter.ai/favicon.ico",
135
- app_name: "openrouter",
136
- app_id: "openrouter",
151
+ id: WellKnownOrgMCPId.DEV_ASSETS(orgId),
152
+ title: "Local Files",
153
+ description:
154
+ "Local file storage for development. Files are stored in /data/assets/.",
137
155
  connection_type: "HTTP",
138
- connection_url: "https://sites-openrouter.decocache.com/mcp",
156
+ connection_url: `${baseUrl}/mcp/${DEV_ASSETS_MCP_ALIAS_ID}`,
157
+ // Folder icon
158
+ icon: "https://api.iconify.design/lucide:folder.svg?color=%23888",
159
+ app_name: "@deco/dev-assets-mcp",
160
+ app_id: null,
139
161
  connection_token: null,
140
162
  connection_headers: null,
141
163
  oauth_config: null,
142
164
  configuration_state: null,
143
165
  configuration_scopes: null,
144
166
  metadata: {
145
- source: "chat",
146
- verified: false,
147
- scopeName: "deco",
148
- toolsCount: 0,
149
- publishedAt: null,
150
- repository: null,
167
+ isFixed: true,
168
+ devOnly: true,
169
+ type: "dev-assets",
151
170
  },
152
171
  };
153
172
  }
@@ -178,27 +197,165 @@ export function getWellKnownMcpStudioConnection(): ConnectionCreateData {
178
197
  }
179
198
 
180
199
  /**
181
- * Get well-known Decopilot Agent virtual MCP entity.
182
- * This is the default agent that aggregates ALL org connections.
200
+ * Build a paired `{ is, get }` helper for an org-scoped well-known agent id
201
+ * of shape `${prefix}${orgId}`. Centralizes the trivial check/slice/format
202
+ * logic that every well-known agent used to hand-roll.
183
203
  *
184
- * @param organizationId - Organization ID
185
- * @returns VirtualMCPEntity representing the Decopilot agent
204
+ * `is(id)` returns the orgId when `id` matches the prefix; `null` otherwise.
205
+ * `get(orgId)` mints the well-known id.
186
206
  */
187
- export function getWellKnownDecopilotAgent(
188
- organizationId: string,
189
- ): VirtualMCPEntity {
207
+ function createWellKnownAgentPrefix(prefix: string): {
208
+ is: (id: string | null | undefined) => string | null;
209
+ get: (organizationId: string) => string;
210
+ } {
190
211
  return {
191
- id: `decopilot-${organizationId}`,
192
- organization_id: organizationId,
193
- title: "Decopilot",
194
- description: "Default agent that aggregates all organization connections",
195
- icon: "https://assets.decocache.com/decocms/fd07a578-6b1c-40f1-bc05-88a3b981695d/f7fc4ffa81aec04e37ae670c3cd4936643a7b269.png",
196
- tool_selection_mode: "exclusion",
212
+ is(id) {
213
+ if (!id) return null;
214
+ if (!id.startsWith(prefix)) return null;
215
+ return id.slice(prefix.length) || null;
216
+ },
217
+ get(organizationId) {
218
+ return `${prefix}${organizationId}`;
219
+ },
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Build a well-known agent VirtualMCPEntity with sensible defaults
225
+ * (status active, system creator, empty connections, etc.). Only the
226
+ * id/title/description/icon and optional instructions vary across agents.
227
+ */
228
+ function defineWellKnownAgentVMCP(opts: {
229
+ id: string;
230
+ organizationId: string;
231
+ title: string;
232
+ description: string;
233
+ icon: string;
234
+ instructions?: string | null;
235
+ }): VirtualMCPEntity {
236
+ return {
237
+ id: opts.id,
238
+ organization_id: opts.organizationId,
239
+ title: opts.title,
240
+ description: opts.description,
241
+ icon: opts.icon,
197
242
  status: "active",
198
243
  created_at: new Date().toISOString(),
199
244
  updated_at: new Date().toISOString(),
200
245
  created_by: "system",
201
246
  updated_by: undefined,
202
- connections: [], // Empty = no exclusions, include all connections
247
+ metadata: { instructions: opts.instructions ?? null },
248
+ pinned: false,
249
+ connections: [],
250
+ };
251
+ }
252
+
253
+ // ---- Decopilot ----
254
+ // Default agent that aggregates ALL org connections. Gateway populates
255
+ // the connections array at lookup time.
256
+ const decopilotPrefix = createWellKnownAgentPrefix("decopilot_");
257
+ export const isDecopilot = decopilotPrefix.is;
258
+ export const getDecopilotId = decopilotPrefix.get;
259
+
260
+ export function getWellKnownDecopilotVirtualMCP(
261
+ organizationId: string,
262
+ ): VirtualMCPEntity {
263
+ return defineWellKnownAgentVMCP({
264
+ id: getDecopilotId(organizationId),
265
+ organizationId,
266
+ title: "Decopilot",
267
+ description: "Default agent that aggregates all organization connections",
268
+ icon: "https://assets.decocache.com/decocms/fd07a578-6b1c-40f1-bc05-88a3b981695d/f7fc4ffa81aec04e37ae670c3cd4936643a7b269.png",
269
+ });
270
+ }
271
+
272
+ // ---- Brand-Context Setup ----
273
+ // Guided-onboarding agent for the brand-context preset task. The
274
+ // `brand_context_setup` built-in is injected by `dispatchRun` when this
275
+ // id is seen; the system prompt lives in `metadata.instructions`.
276
+ const brandContextSetupPrefix = createWellKnownAgentPrefix(
277
+ "brand-context-setup_",
278
+ );
279
+ export const isBrandContextSetup = brandContextSetupPrefix.is;
280
+ export const getBrandContextSetupId = brandContextSetupPrefix.get;
281
+
282
+ const BRAND_CONTEXT_SETUP_INSTRUCTIONS = `
283
+ You are running the brand-context onboarding for the user's organization. Your only job in this thread is to set up the organization's brand context:
284
+
285
+ 1. If the user hasn't already given you a website URL, ask for it in one short message. Accept whatever URL they give — don't quibble about format.
286
+ 2. As soon as you have a URL, call the \`brand_context_setup\` tool exactly once with that URL.
287
+ 3. After the tool returns success, briefly confirm to the user what was captured (brand name + domain) in one or two sentences. Do not list every color or font.
288
+ 4. Do NOT call any other tools in this thread. Do NOT call \`brand_context_setup\` more than once.
289
+
290
+ If the tool returns an error, surface the error message to the user and ask whether they want to try a different URL.
291
+ `.trim();
292
+
293
+ export function getWellKnownBrandContextSetupVirtualMCP(
294
+ organizationId: string,
295
+ ): VirtualMCPEntity {
296
+ return defineWellKnownAgentVMCP({
297
+ id: getBrandContextSetupId(organizationId),
298
+ organizationId,
299
+ title: "Brand context setup",
300
+ description:
301
+ "Guided onboarding agent that extracts brand context from a website URL.",
302
+ icon: "https://assets.decocache.com/decocms/fd07a578-6b1c-40f1-bc05-88a3b981695d/f7fc4ffa81aec04e37ae670c3cd4936643a7b269.png",
303
+ instructions: BRAND_CONTEXT_SETUP_INSTRUCTIONS,
304
+ });
305
+ }
306
+
307
+ // ---- Site Diagnostics ----
308
+ const siteDiagnosticsPrefix = createWellKnownAgentPrefix("site-diagnostics_");
309
+ export const isSiteDiagnostics = siteDiagnosticsPrefix.is;
310
+ export const getSiteDiagnosticsId = siteDiagnosticsPrefix.get;
311
+
312
+ /**
313
+ * Studio Pack agent ID generators (org-scoped)
314
+ */
315
+ export const StudioPackAgentId = {
316
+ AGENT_MANAGER: (orgId: string) => `studio-agent-manager_${orgId}`,
317
+ AUTOMATION_MANAGER: (orgId: string) => `studio-automation-manager_${orgId}`,
318
+ CONNECTION_MANAGER: (orgId: string) => `studio-connection-manager_${orgId}`,
319
+ STORE_MANAGER: (orgId: string) => `studio-store-manager_${orgId}`,
320
+ BRAND_MANAGER: (orgId: string) => `studio-brand-manager_${orgId}`,
321
+ } as const;
322
+
323
+ /**
324
+ * Check if a connection or virtual MCP ID is a Studio Pack agent.
325
+ */
326
+ export function isStudioPackAgent(id: string | null | undefined): boolean {
327
+ if (!id) return false;
328
+ return (
329
+ id.startsWith("studio-agent-manager_") ||
330
+ id.startsWith("studio-automation-manager_") ||
331
+ id.startsWith("studio-connection-manager_") ||
332
+ id.startsWith("studio-store-manager_") ||
333
+ id.startsWith("studio-brand-manager_")
334
+ );
335
+ }
336
+
337
+ export function getWellKnownDecopilotConnection(
338
+ organizationId: string,
339
+ ): ConnectionEntity {
340
+ const virtual = getWellKnownDecopilotVirtualMCP(organizationId);
341
+
342
+ return {
343
+ ...virtual,
344
+ id: virtual.id!,
345
+ connection_type: "VIRTUAL",
346
+ connection_url: `virtual://${virtual.id}`,
347
+ app_name: "decopilot",
348
+ app_id: "decopilot",
349
+ connection_token: null,
350
+ connection_headers: null,
351
+ oauth_config: null,
352
+ configuration_state: null,
353
+ configuration_scopes: null,
354
+ metadata: {
355
+ isDefault: true,
356
+ type: "decopilot",
357
+ },
358
+ tools: [],
359
+ bindings: [],
203
360
  };
204
361
  }
@@ -0,0 +1,281 @@
1
+ import type {
2
+ AiProviderModel,
3
+ AiProviderKey,
4
+ ProviderId,
5
+ } from "../types/ai-providers";
6
+
7
+ /**
8
+ * Preferred default models for each well-known provider.
9
+ *
10
+ * Each entry is an ordered list of candidate model ID strings — lower indexes
11
+ * have higher priority. The selector first tries exact matches across the full
12
+ * list, then falls back to substring matches in the same priority order.
13
+ */
14
+ export const DEFAULT_MODEL_PREFERENCES: Partial<Record<ProviderId, string[]>> =
15
+ {
16
+ anthropic: ["claude-sonnet-4-6", "claude-sonnet", "claude"],
17
+ openrouter: [
18
+ "anthropic/claude-opus-4.8",
19
+ "anthropic/claude-sonnet-4-6",
20
+ "anthropic/claude-sonnet",
21
+ "anthropic/claude",
22
+ ],
23
+ deco: [
24
+ "anthropic/claude-haiku-4-5",
25
+ "anthropic/claude-haiku",
26
+ "anthropic/claude",
27
+ ],
28
+ google: ["gemini-3-flash"],
29
+ "claude-code": [
30
+ "claude-code:sonnet",
31
+ "claude-code:opus",
32
+ "claude-code:haiku",
33
+ ],
34
+ codex: ["codex:gpt-5.4"],
35
+ };
36
+
37
+ /**
38
+ * Preferred fast/cheap models per provider — used for lightweight tasks
39
+ * like title generation where latency and cost matter more than capability.
40
+ */
41
+ export const FAST_MODEL_PREFERENCES: Partial<Record<ProviderId, string[]>> = {
42
+ anthropic: ["claude-haiku-4-5", "claude-haiku"],
43
+ openrouter: [
44
+ "qwen/qwen3.5-flash",
45
+ "anthropic/claude-haiku-4.5",
46
+ "anthropic/claude-haiku",
47
+ "google/gemini-3-flash",
48
+ ],
49
+ deco: ["qwen/qwen3.5-flash", "anthropic/claude-haiku"],
50
+ google: ["gemini-2.5-flash", "gemini-3-flash"],
51
+ "claude-code": ["claude-code:haiku", "claude-code:sonnet"],
52
+ codex: ["codex:gpt-5.4-mini"],
53
+ };
54
+
55
+ /**
56
+ * Return the preferred fast model ID for a given provider.
57
+ * Returns the first candidate or `null` if no preference is configured.
58
+ */
59
+ export function getFastModel(providerId: ProviderId): string | null {
60
+ const candidates = FAST_MODEL_PREFERENCES[providerId];
61
+ return candidates?.[0] ?? null;
62
+ }
63
+
64
+ /**
65
+ * Preferred smart (balanced) models per provider — used as the "Smart" tier
66
+ * in Simple Model Mode.
67
+ */
68
+ export const SMART_MODEL_PREFERENCES: Partial<Record<ProviderId, string[]>> = {
69
+ anthropic: ["claude-sonnet-4-6", "claude-sonnet"],
70
+ openrouter: [
71
+ "anthropic/claude-sonnet-4.6",
72
+ "anthropic/claude-sonnet",
73
+ "anthropic/claude-opus-4.8",
74
+ "google/gemini-3-pro",
75
+ ],
76
+ deco: [
77
+ "anthropic/claude-sonnet-4.6",
78
+ "anthropic/claude-sonnet",
79
+ "anthropic/claude",
80
+ ],
81
+ google: ["gemini-3-pro", "gemini-3-flash"],
82
+ "claude-code": ["claude-code:sonnet"],
83
+ codex: ["codex:gpt-5.4"],
84
+ };
85
+
86
+ /**
87
+ * Preferred thinking/reasoning models per provider — used as the "Thinking" tier
88
+ * in Simple Model Mode.
89
+ */
90
+ export const THINKING_MODEL_PREFERENCES: Partial<Record<ProviderId, string[]>> =
91
+ {
92
+ anthropic: ["claude-opus-4-8", "claude-sonnet-4-6", "claude-sonnet"],
93
+ openrouter: [
94
+ "anthropic/claude-opus-4.8",
95
+ "anthropic/claude-sonnet-4.6:extended",
96
+ "anthropic/claude-sonnet-4.6",
97
+ "google/gemini-3-pro",
98
+ ],
99
+ deco: [
100
+ "anthropic/claude-opus",
101
+ "anthropic/claude-sonnet-4.6",
102
+ "anthropic/claude-sonnet",
103
+ ],
104
+ google: ["gemini-3-pro"],
105
+ "claude-code": ["claude-code:opus", "claude-code:sonnet"],
106
+ codex: ["codex:gpt-5.5"],
107
+ };
108
+
109
+ /**
110
+ * Preferred image generation models per provider.
111
+ * Falls back to first model with "image" capability.
112
+ */
113
+ export const IMAGE_MODEL_PREFERENCES: Partial<Record<ProviderId, string[]>> = {
114
+ openrouter: ["openai/gpt-image-1", "google/gemini-2.0-flash-image"],
115
+ deco: ["openai/gpt-image-1", "google/gemini-2.0-flash-image"],
116
+ google: ["gemini-2.0-flash-image"],
117
+ };
118
+
119
+ /**
120
+ * Preferred web research models per provider.
121
+ * Falls back to first model whose id includes "sonar" or "deepresearch".
122
+ */
123
+ export const WEB_RESEARCH_MODEL_PREFERENCES: Partial<
124
+ Record<ProviderId, string[]>
125
+ > = {
126
+ openrouter: [
127
+ "perplexity/sonar",
128
+ "perplexity/sonar-pro",
129
+ "perplexity/deep-research",
130
+ ],
131
+ deco: [
132
+ "perplexity/sonar",
133
+ "perplexity/sonar-pro",
134
+ "perplexity/deep-research",
135
+ ],
136
+ };
137
+
138
+ export interface SimpleModeModelSlot {
139
+ keyId: string;
140
+ modelId: string;
141
+ title?: string;
142
+ }
143
+
144
+ export interface SimpleModeDefaults {
145
+ chat: {
146
+ fast: SimpleModeModelSlot | null;
147
+ smart: SimpleModeModelSlot | null;
148
+ thinking: SimpleModeModelSlot | null;
149
+ };
150
+ image: SimpleModeModelSlot | null;
151
+ webResearch: SimpleModeModelSlot | null;
152
+ }
153
+
154
+ function resolveSlot(
155
+ models: AiProviderModel[],
156
+ keyId: string,
157
+ preferences: string[],
158
+ fallback?: (m: AiProviderModel) => boolean,
159
+ ): SimpleModeModelSlot | null {
160
+ for (const candidate of preferences) {
161
+ const exact = models.find((m) => m.modelId === candidate);
162
+ if (exact) return { keyId, modelId: exact.modelId, title: exact.title };
163
+ }
164
+ for (const candidate of preferences) {
165
+ const partial = models.find((m) => m.modelId.includes(candidate));
166
+ if (partial)
167
+ return { keyId, modelId: partial.modelId, title: partial.title };
168
+ }
169
+ if (fallback) {
170
+ const found = models.find(fallback);
171
+ if (found) return { keyId, modelId: found.modelId, title: found.title };
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * Compute sensible Simple Mode defaults from the currently-connected keys and
178
+ * their available models. Each slot picks the best candidate per the tier
179
+ * preference lists, falling back to capability-based detection for image/web.
180
+ *
181
+ * @param keys The org's connected AI provider keys.
182
+ * @param modelsByKeyId Map of keyId → available model list.
183
+ */
184
+ export function pickSimpleModeDefaults(
185
+ keys: AiProviderKey[],
186
+ modelsByKeyId: Record<string, AiProviderModel[]>,
187
+ ): SimpleModeDefaults {
188
+ const result: SimpleModeDefaults = {
189
+ chat: { fast: null, smart: null, thinking: null },
190
+ image: null,
191
+ webResearch: null,
192
+ };
193
+
194
+ for (const key of keys) {
195
+ const models = modelsByKeyId[key.id] ?? [];
196
+ const providerId = key.providerId as ProviderId;
197
+
198
+ if (!result.chat.fast) {
199
+ result.chat.fast = resolveSlot(
200
+ models,
201
+ key.id,
202
+ FAST_MODEL_PREFERENCES[providerId] ?? [],
203
+ );
204
+ }
205
+ if (!result.chat.smart) {
206
+ result.chat.smart = resolveSlot(
207
+ models,
208
+ key.id,
209
+ SMART_MODEL_PREFERENCES[providerId] ?? [],
210
+ );
211
+ }
212
+ if (!result.chat.thinking) {
213
+ result.chat.thinking = resolveSlot(
214
+ models,
215
+ key.id,
216
+ THINKING_MODEL_PREFERENCES[providerId] ?? [],
217
+ );
218
+ }
219
+ if (!result.image) {
220
+ result.image = resolveSlot(
221
+ models,
222
+ key.id,
223
+ IMAGE_MODEL_PREFERENCES[providerId] ?? [],
224
+ (m) => m.capabilities?.includes("image") === true,
225
+ );
226
+ }
227
+ if (!result.webResearch) {
228
+ result.webResearch = resolveSlot(
229
+ models,
230
+ key.id,
231
+ WEB_RESEARCH_MODEL_PREFERENCES[providerId] ?? [],
232
+ (m) => {
233
+ const n = m.modelId.toLowerCase().replace(/[^a-z0-9]/g, "");
234
+ return n.includes("sonar") || n.includes("deepresearch");
235
+ },
236
+ );
237
+ }
238
+ }
239
+
240
+ return result;
241
+ }
242
+
243
+ /**
244
+ * Select the best default model from a loaded list for a given provider.
245
+ *
246
+ * Resolution order:
247
+ * 1. Exact `modelId` match — walk candidates in priority order.
248
+ * 2. Substring match — walk candidates in priority order, return the first
249
+ * model whose `modelId` contains the candidate string.
250
+ * 3. First model in the list.
251
+ * 4. `null` if the list is empty.
252
+ *
253
+ * @param models Full model list returned by the provider for this key.
254
+ * @param providerId The provider that owns the key.
255
+ * @param keyId Credential key ID to attach — mirrors what
256
+ * `handleModelSelect` does on explicit user selection.
257
+ */
258
+ export function selectDefaultModel(
259
+ models: AiProviderModel[],
260
+ providerId: ProviderId,
261
+ keyId?: string,
262
+ ): AiProviderModel | null {
263
+ if (models.length === 0) return null;
264
+
265
+ const candidates = DEFAULT_MODEL_PREFERENCES[providerId] ?? [];
266
+
267
+ const withKey = (model: AiProviderModel): AiProviderModel =>
268
+ keyId !== undefined ? { ...model, keyId } : model;
269
+
270
+ for (const candidate of candidates) {
271
+ const exact = models.find((m) => m.modelId === candidate);
272
+ if (exact) return withKey(exact);
273
+ }
274
+
275
+ for (const candidate of candidates) {
276
+ const partial = models.find((m) => m.modelId.includes(candidate));
277
+ if (partial) return withKey(partial);
278
+ }
279
+
280
+ return withKey(models[0] as AiProviderModel);
281
+ }