@pwshub/aisdk 0.0.4 → 0.0.6

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/src/logger.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @fileoverview Configurable logger for @pwshub/aisdk
3
+ *
4
+ * Provides a default console logger with the ability to set custom
5
+ * or no-op loggers for production environments.
6
+ */
7
+
8
+ /**
9
+ * @typedef {Object} Logger
10
+ * @property {(message: string) => void} warn
11
+ * @property {(message: string) => void} error
12
+ * @property {(message: string) => void} debug
13
+ */
14
+
15
+ const defaultLogger = {
16
+ warn: (message) => console.warn(message),
17
+ error: (message) => console.error(message),
18
+ debug: (message) => console.debug(message),
19
+ }
20
+
21
+ let currentLogger = defaultLogger
22
+
23
+ /**
24
+ * Sets a custom logger instance
25
+ * @param {Logger} logger
26
+ * @throws {Error} When logger doesn't implement required methods
27
+ */
28
+ export const setLogger = (logger) => {
29
+ if (!logger || typeof logger.warn !== 'function' || typeof logger.error !== 'function') {
30
+ throw new Error('Logger must implement warn() and error() methods')
31
+ }
32
+ currentLogger = logger
33
+ }
34
+
35
+ /**
36
+ * Returns the current logger instance
37
+ * @returns {Logger}
38
+ */
39
+ export const getLogger = () => currentLogger
40
+
41
+ /**
42
+ * No-op logger for silencing all output
43
+ */
44
+ export const noopLogger = {
45
+ warn: () => {},
46
+ error: () => {},
47
+ debug: () => {},
48
+ }
package/src/models.js CHANGED
@@ -198,6 +198,11 @@ export const DEFAULT_MODELS = [
198
198
  max_in: 400000,
199
199
  max_out: 128000,
200
200
  enable: true,
201
+ // gpt-5-nano only supports default values for temperature and top_p
202
+ paramOverrides: {
203
+ temperature: { fixedValue: 1 },
204
+ topP: { fixedValue: 1 },
205
+ },
201
206
  },
202
207
  {
203
208
  id: 'gpt-5.1',
@@ -342,4 +347,60 @@ export const DEFAULT_MODELS = [
342
347
  max_out: 65536,
343
348
  enable: true,
344
349
  },
350
+ // Mistral models
351
+ {
352
+ id: 'mistral-large-latest',
353
+ name: 'mistral-large-latest',
354
+ provider: 'mistral',
355
+ input_price: 0.5,
356
+ output_price: 1.5,
357
+ cache_price: 0,
358
+ max_in: 128000,
359
+ max_out: 128000,
360
+ enable: true,
361
+ },
362
+ {
363
+ id: 'mistral-medium-latest',
364
+ name: 'mistral-medium-latest',
365
+ provider: 'mistral',
366
+ input_price: 0.4,
367
+ output_price: 2,
368
+ cache_price: 0,
369
+ max_in: 64000,
370
+ max_out: 64000,
371
+ enable: true,
372
+ },
373
+ {
374
+ id: 'mistral-small-latest',
375
+ name: 'mistral-small-latest',
376
+ provider: 'mistral',
377
+ input_price: 0.15,
378
+ output_price: 0.6,
379
+ cache_price: 0,
380
+ max_in: 128000,
381
+ max_out: 128000,
382
+ enable: true,
383
+ },
384
+ {
385
+ id: 'magistral-medium-latest',
386
+ name: 'magistral-medium-latest',
387
+ provider: 'mistral',
388
+ input_price: 2,
389
+ output_price: 5,
390
+ cache_price: 0,
391
+ max_in: 64000,
392
+ max_out: 64000,
393
+ enable: true,
394
+ },
395
+ {
396
+ id: 'magistral-small-latest',
397
+ name: 'magistral-small-latest',
398
+ provider: 'mistral',
399
+ input_price: 0.5,
400
+ output_price: 1.5,
401
+ cache_price: 0,
402
+ max_in: 64000,
403
+ max_out: 64000,
404
+ enable: true,
405
+ },
345
406
  ]
package/src/providers.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  /**
13
- * @typedef {'openai'|'anthropic'|'google'|'dashscope'|'deepseek'} ProviderId
13
+ * @typedef {'openai'|'anthropic'|'google'|'dashscope'|'deepseek'|'mistral'|'ollama'} ProviderId
14
14
  */
15
15
 
16
16
  /**
@@ -42,7 +42,7 @@ const openai = {
42
42
  Authorization: `Bearer ${apikey}`,
43
43
  'Content-Type': 'application/json',
44
44
  }),
45
- url: () => 'https://api.openai.com/v1/chat/completions',
45
+ url: (modelName, apikey, gatewayUrl) => gatewayUrl || 'https://api.openai.com/v1/chat/completions',
46
46
  buildBody: (modelName, messages, config, providerOptions) => ({
47
47
  model: modelName,
48
48
  messages,
@@ -96,7 +96,7 @@ const anthropic = {
96
96
  'anthropic-version': '2023-06-01',
97
97
  'Content-Type': 'application/json',
98
98
  }),
99
- url: () => 'https://api.anthropic.com/v1/messages',
99
+ url: (modelName, apikey, gatewayUrl) => gatewayUrl || 'https://api.anthropic.com/v1/messages',
100
100
  buildBody: (modelName, messages, config, providerOptions) => {
101
101
  const system = messages.find((m) => m.role === 'system')?.content
102
102
  const filtered = messages.filter((m) => m.role !== 'system')
@@ -129,8 +129,12 @@ const anthropic = {
129
129
  /** @type {ProviderAdapter} */
130
130
  const google = {
131
131
  headers: () => ({ 'Content-Type': 'application/json' }),
132
- url: (modelName, apikey) =>
133
- `https://generativelanguage.googleapis.com/v1/models/${modelName}:generateContent?key=${apikey}`,
132
+ url: (modelName, apikey, gatewayUrl) => {
133
+ if (gatewayUrl) {
134
+ return gatewayUrl
135
+ }
136
+ return `https://generativelanguage.googleapis.com/v1/models/${modelName}:generateContent?key=${apikey}`
137
+ },
134
138
  buildBody: (modelName, messages, config, providerOptions) => {
135
139
  const system = messages.find((m) => m.role === 'system')?.content
136
140
  const contents = messages
@@ -243,7 +247,7 @@ const dashscope = {
243
247
  }),
244
248
  // International users should use dashscope-intl.aliyuncs.com
245
249
  // China users can use dashscope.aliyuncs.com
246
- url: () => 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions',
250
+ url: (modelName, apikey, gatewayUrl) => gatewayUrl || 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions',
247
251
  buildBody: (modelName, messages, config, providerOptions) => ({
248
252
  model: modelName,
249
253
  messages,
@@ -276,7 +280,7 @@ const deepseek = {
276
280
  Authorization: `Bearer ${apikey}`,
277
281
  'Content-Type': 'application/json',
278
282
  }),
279
- url: () => 'https://api.deepseek.com/chat/completions',
283
+ url: (modelName, apikey, gatewayUrl) => gatewayUrl || 'https://api.deepseek.com/chat/completions',
280
284
  buildBody: (modelName, messages, config, providerOptions) => ({
281
285
  model: modelName,
282
286
  messages,
@@ -298,9 +302,85 @@ const deepseek = {
298
302
  }),
299
303
  }
300
304
 
305
+ /** @type {ProviderAdapter} */
306
+ const mistral = {
307
+ headers: (apikey) => ({
308
+ Authorization: `Bearer ${apikey}`,
309
+ 'Content-Type': 'application/json',
310
+ Accept: 'application/json',
311
+ }),
312
+ url: (modelName, apikey, gatewayUrl) => gatewayUrl || 'https://api.mistral.ai/v1/chat/completions',
313
+ buildBody: (modelName, messages, config, providerOptions) => ({
314
+ model: modelName,
315
+ messages,
316
+ ...config,
317
+ ...providerOptions,
318
+ }),
319
+ extractText: (data) => {
320
+ const content = data.choices?.[0]?.message?.content
321
+ if (!content) {
322
+ throw new Error('Mistral response missing content')
323
+ }
324
+ return content
325
+ },
326
+ extractUsage: (data) => ({
327
+ inputTokens: data.usage?.prompt_tokens ?? 0,
328
+ outputTokens: data.usage?.completion_tokens ?? 0,
329
+ cacheTokens: 0,
330
+ reasoningTokens: 0,
331
+ }),
332
+ }
333
+
334
+ /** @type {ProviderAdapter} */
335
+ const ollama = {
336
+ headers: (apikey) => ({
337
+ 'Content-Type': 'application/json',
338
+ ...(apikey && { Authorization: `Bearer ${apikey}` }),
339
+ }),
340
+ url: (modelName, apikey, gatewayUrl) => {
341
+ if (gatewayUrl) {
342
+ return gatewayUrl
343
+ }
344
+ // Warn about localhost default in development
345
+ console.warn('[ai-client] Ollama using default localhost URL (http://localhost:11434). Set gatewayUrl in createAi() for production.')
346
+ return 'http://localhost:11434/api/chat'
347
+ },
348
+ buildBody: (modelName, messages, config, providerOptions) => {
349
+ // Ollama uses snake_case options
350
+ const options = {}
351
+ if (config.temperature !== undefined) options.temperature = config.temperature
352
+ if (config.top_p !== undefined) options.top_p = config.top_p
353
+ if (config.top_k !== undefined) options.top_k = config.top_k
354
+ if (config.num_predict !== undefined) options.num_predict = config.num_predict
355
+ if (config.seed !== undefined) options.seed = config.seed
356
+ if (config.stop !== undefined) options.stop = config.stop
357
+
358
+ return {
359
+ model: modelName,
360
+ messages,
361
+ stream: false,
362
+ ...providerOptions,
363
+ ...(Object.keys(options).length > 0 && { options }),
364
+ }
365
+ },
366
+ extractText: (data) => {
367
+ const content = data.message?.content
368
+ if (!content) {
369
+ throw new Error('Ollama response missing content')
370
+ }
371
+ return content
372
+ },
373
+ extractUsage: (data) => ({
374
+ inputTokens: data.prompt_eval_count ?? 0,
375
+ outputTokens: data.eval_count ?? 0,
376
+ cacheTokens: 0,
377
+ reasoningTokens: 0,
378
+ }),
379
+ }
380
+
301
381
  /** @type {Record<string, ProviderAdapter>} */
302
382
  const ADAPTERS = {
303
- openai, anthropic, google, dashscope, deepseek,
383
+ openai, anthropic, google, dashscope, deepseek, mistral, ollama,
304
384
  }
305
385
 
306
386
  /**
package/src/registry.js CHANGED
@@ -4,7 +4,8 @@
4
4
  * Default models are loaded automatically from ./models.js at import time.
5
5
  * Users can modify the registry via addModels() and setModels().
6
6
  *
7
- * This module provides O(1) lookups at runtime via a Map indexed by model ID.
7
+ * This module provides O(1) lookups at runtime via a Map.
8
+ * Models can be looked up by name, or by provider/name format.
8
9
  *
9
10
  * `supportedParams` is optional per record. When absent, the provider's
10
11
  * default param set is used.
@@ -29,6 +30,7 @@ import { DEFAULT_MODELS } from './models.js'
29
30
  * @property {number} max_out - Max output tokens
30
31
  * @property {boolean} enable
31
32
  * @property {string[]} [supportedParams] - Canonical param names; falls back to provider default
33
+ * @property {Record<string, import('./coerce.js').ParamOverride>} [paramOverrides] - Model-specific param overrides
32
34
  */
33
35
 
34
36
  /**
@@ -43,16 +45,123 @@ export const PROVIDER_DEFAULT_PARAMS = {
43
45
  google: ['temperature', 'maxTokens', 'topP', 'topK', 'seed', 'stop'],
44
46
  dashscope: ['temperature', 'maxTokens', 'topP', 'topK', 'stop'],
45
47
  deepseek: ['temperature', 'maxTokens', 'topP', 'frequencyPenalty', 'presencePenalty', 'stop'],
48
+ mistral: ['temperature', 'maxTokens', 'topP', 'randomSeed', 'stop'],
49
+ ollama: ['temperature', 'maxTokens', 'topP', 'topK', 'seed', 'stop'],
46
50
  }
47
51
 
48
52
  /** @type {ProviderId[]} */
49
- const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'dashscope', 'deepseek']
53
+ const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'dashscope', 'deepseek', 'mistral', 'ollama']
54
+
55
+ /**
56
+ * Creates a new registry instance with the given models
57
+ * @param {import('./models.js').ModelRecord[]} [initialModels] - Initial models to load (defaults to DEFAULT_MODELS)
58
+ * @returns {{
59
+ * getModel: (modelId: string) => { record: ModelRecord, supportedParams: string[], paramOverrides: Record<string, import('./coerce.js').ParamOverride> },
60
+ * listModels: () => ModelRecord[],
61
+ * addModels: (models: ModelRecord[]) => void,
62
+ * setModels: (models: ModelRecord[]) => void
63
+ * }}
64
+ */
65
+ export const createRegistry = (initialModels = DEFAULT_MODELS) => {
66
+ /** @type {Map<string, ModelRecord>} */
67
+ let REGISTRY = new Map(initialModels.map((model) => {
68
+ const normalized = normalizeModelRecord(model)
69
+ return [normalized.id, normalized]
70
+ }))
71
+
72
+ /**
73
+ * Looks up a model by provider/name format.
74
+ * @param {string} modelId - Model in 'provider/name' format
75
+ * @returns {{ record: ModelRecord, supportedParams: string[], paramOverrides: Record<string, import('./coerce.js').ParamOverride> }}
76
+ */
77
+ const getModel = (modelId) => {
78
+ if (!modelId.includes('/')) {
79
+ const available = [...REGISTRY.values()].map(m => `${m.provider}/${m.name}`).join(', ')
80
+ throw new Error(`Model must be in 'provider/name' format. Got: "${modelId}". Available: ${available}`)
81
+ }
82
+
83
+ const parts = modelId.split('/')
84
+ if (parts.length !== 2) {
85
+ const available = [...REGISTRY.values()].map(m => `${m.provider}/${m.name}`).join(', ')
86
+ throw new Error(`Model must be in 'provider/name' format. Got: "${modelId}". Available: ${available}`)
87
+ }
88
+
89
+ const [provider, name] = parts
90
+
91
+ for (const m of REGISTRY.values()) {
92
+ if (m.name === name && m.provider === provider) {
93
+ const record = m
94
+
95
+ if (!record.enable) {
96
+ throw new Error(`Model "${record.provider}/${record.name}" is currently disabled.`)
97
+ }
98
+
99
+ const supportedParams = record.supportedParams ?? PROVIDER_DEFAULT_PARAMS[record.provider]
100
+ const paramOverrides = record.paramOverrides ?? {}
101
+
102
+ return {
103
+ record, supportedParams, paramOverrides,
104
+ }
105
+ }
106
+ }
107
+
108
+ const available = [...REGISTRY.values()].map(m => `${m.provider}/${m.name}`).join(', ')
109
+ throw new Error(`Unknown model "${modelId}". Available: ${available}`)
110
+ }
111
+
112
+ /**
113
+ * Returns all enabled model records.
114
+ * @returns {ModelRecord[]}
115
+ */
116
+ const listModels = () =>
117
+ [...REGISTRY.values()].filter((m) => m.enable)
118
+
119
+ /**
120
+ * Adds one or more models to the registry.
121
+ * @param {ModelRecord[]} models - Array of model records to add
122
+ */
123
+ const addModels = (models) => {
124
+ if (!Array.isArray(models)) {
125
+ throw new Error(`addModels expects an array. Got: ${typeof models}`)
126
+ }
127
+
128
+ models.forEach((model, index) => {
129
+ validateModelRecord(model, index)
130
+ })
131
+
132
+ models.forEach((model) => {
133
+ const normalized = normalizeModelRecord(model)
134
+ REGISTRY.set(normalized.id, normalized)
135
+ })
136
+ }
137
+
138
+ /**
139
+ * Replaces the entire registry with a new list of models.
140
+ * @param {ModelRecord[]} models - Array of model records
141
+ */
142
+ const setModels = (models) => {
143
+ if (!Array.isArray(models)) {
144
+ throw new Error(`setModels expects an array. Got: ${typeof models}`)
145
+ }
146
+
147
+ models.forEach((model, index) => {
148
+ validateModelRecord(model, index)
149
+ })
150
+
151
+ REGISTRY = new Map(models.map((model) => {
152
+ const normalized = normalizeModelRecord(model)
153
+ return [normalized.id, normalized]
154
+ }))
155
+ }
156
+
157
+ return { getModel, listModels, addModels, setModels }
158
+ }
50
159
 
51
160
  /** @type {Map<string, ModelRecord>} */
52
161
  let REGISTRY = new Map()
53
162
 
54
163
  /**
55
- * Initializes the registry with default models.
164
+ * Initializes the global registry with default models.
56
165
  * Called automatically at module import.
57
166
  */
58
167
  const initRegistry = () => {
@@ -72,34 +181,29 @@ initRegistry()
72
181
  const validateModelRecord = (model, index) => {
73
182
  const errors = []
74
183
 
75
- // Check required string fields
76
- if (!model.id || typeof model.id !== 'string') {
77
- errors.push('"id" must be a non-empty string')
78
- }
79
-
184
+ // Only name and provider are required
80
185
  if (!model.name || typeof model.name !== 'string') {
81
186
  errors.push('"name" must be a non-empty string')
82
187
  }
83
188
 
84
- // Check provider is valid
85
189
  if (!model.provider || typeof model.provider !== 'string') {
86
190
  errors.push('"provider" must be a string')
87
191
  } else if (!VALID_PROVIDERS.includes(model.provider)) {
88
192
  errors.push(`"provider" must be one of: ${VALID_PROVIDERS.join(', ')}. Got: "${model.provider}"`)
89
193
  }
90
194
 
91
- // Check required number fields (must be non-negative)
195
+ // Check optional number fields if present (must be non-negative)
92
196
  const numberFields = ['input_price', 'output_price', 'cache_price', 'max_in', 'max_out']
93
197
  for (const field of numberFields) {
94
- if (typeof model[field] !== 'number') {
198
+ if (model[field] !== undefined && typeof model[field] !== 'number') {
95
199
  errors.push(`"${field}" must be a number`)
96
- } else if (model[field] < 0) {
200
+ } else if (model[field] !== undefined && model[field] < 0) {
97
201
  errors.push(`"${field}" must be non-negative, got: ${model[field]}`)
98
202
  }
99
203
  }
100
204
 
101
- // Check enable is boolean
102
- if (typeof model.enable !== 'boolean') {
205
+ // Check optional enable if present
206
+ if (model.enable !== undefined && typeof model.enable !== 'boolean') {
103
207
  errors.push('"enable" must be a boolean')
104
208
  }
105
209
 
@@ -116,40 +220,123 @@ const validateModelRecord = (model, index) => {
116
220
  }
117
221
  }
118
222
 
223
+ // Check optional paramOverrides if present
224
+ if (model.paramOverrides !== undefined) {
225
+ if (typeof model.paramOverrides !== 'object' || model.paramOverrides === null || Array.isArray(model.paramOverrides)) {
226
+ errors.push('"paramOverrides" must be an object')
227
+ } else {
228
+ for (const [param, override] of Object.entries(model.paramOverrides)) {
229
+ if (typeof override !== 'object' || override === null) {
230
+ errors.push(`"paramOverrides.${param}" must be an object`)
231
+ } else {
232
+ // Check that override has at least one valid property
233
+ const validKeys = ['fixedValue', 'supportedValues', 'range']
234
+ const hasValidKey = Object.keys(override).some((k) => validKeys.includes(k))
235
+ if (!hasValidKey) {
236
+ errors.push(`"paramOverrides.${param}" must have at least one of: ${validKeys.join(', ')}`)
237
+ }
238
+ // Validate fixedValue type
239
+ if (override.fixedValue !== undefined && typeof override.fixedValue !== 'number') {
240
+ errors.push(`"paramOverrides.${param}.fixedValue" must be a number`)
241
+ }
242
+ // Validate supportedValues type
243
+ if (override.supportedValues !== undefined) {
244
+ if (!Array.isArray(override.supportedValues)) {
245
+ errors.push(`"paramOverrides.${param}.supportedValues" must be an array`)
246
+ } else {
247
+ override.supportedValues.forEach((v, i) => {
248
+ if (typeof v !== 'number') {
249
+ errors.push(`"paramOverrides.${param}.supportedValues[${i}]" must be a number`)
250
+ }
251
+ })
252
+ }
253
+ }
254
+ // Validate range type
255
+ if (override.range !== undefined) {
256
+ if (typeof override.range !== 'object' || override.range === null) {
257
+ errors.push(`"paramOverrides.${param}.range" must be an object`)
258
+ } else if (typeof override.range.min !== 'number' || typeof override.range.max !== 'number') {
259
+ errors.push(`"paramOverrides.${param}.range" must have numeric min and max`)
260
+ }
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
266
+
119
267
  if (errors.length > 0) {
120
268
  throw new Error(`Invalid model record at index ${index}: ${errors.join('; ')}`)
121
269
  }
122
270
  }
123
271
 
124
272
  /**
125
- * Looks up a model by ID, validates it is enabled, and resolves its
126
- * effective supported params (record-level override or provider default).
273
+ * Normalizes a model record by setting defaults for missing fields.
274
+ * Generates id from provider and name if not provided.
127
275
  *
128
- * @param {string} modelId
129
- * @returns {{ record: ModelRecord, supportedParams: string[] }}
130
- * @throws {Error} When the model is not found or is disabled
276
+ * @param {Object} model - The model record to normalize
277
+ * @returns {ModelRecord} Normalized model record
131
278
  */
132
- export const getModel = (modelId) => {
133
- const record = REGISTRY.get(modelId)
279
+ const normalizeModelRecord = (model) => {
280
+ return {
281
+ id: model.id || `${model.provider}_${model.name}`,
282
+ name: model.name,
283
+ provider: model.provider,
284
+ input_price: model.input_price ?? 0,
285
+ output_price: model.output_price ?? 0,
286
+ cache_price: model.cache_price ?? 0,
287
+ max_in: model.max_in ?? 32000,
288
+ max_out: model.max_out ?? 8000,
289
+ enable: model.enable ?? true,
290
+ ...(model.supportedParams !== undefined && { supportedParams: model.supportedParams }),
291
+ ...(model.paramOverrides !== undefined && { paramOverrides: model.paramOverrides }),
292
+ }
293
+ }
134
294
 
135
- if (!record) {
136
- const available = [...REGISTRY.keys()].join(', ')
137
- throw new Error(`Unknown model "${modelId}". Available: ${available}`)
295
+ /**
296
+ * Gets a model from the global registry (for backward compatibility).
297
+ * For isolated registries, use the getModel from createRegistry() instead.
298
+ *
299
+ * @param {string} modelId - Model in 'provider/name' format
300
+ * @returns {{ record: ModelRecord, supportedParams: string[], paramOverrides: Record<string, import('./coerce.js').ParamOverride> }}
301
+ */
302
+ export const getModel = (modelId) => {
303
+ if (!modelId.includes('/')) {
304
+ const available = [...REGISTRY.values()].map(m => `${m.provider}/${m.name}`).join(', ')
305
+ throw new Error(`Model must be in 'provider/name' format. Got: "${modelId}". Available: ${available}`)
138
306
  }
139
307
 
140
- if (!record.enable) {
141
- throw new Error(`Model "${modelId}" is currently disabled.`)
308
+ const parts = modelId.split('/')
309
+ if (parts.length !== 2) {
310
+ const available = [...REGISTRY.values()].map(m => `${m.provider}/${m.name}`).join(', ')
311
+ throw new Error(`Model must be in 'provider/name' format. Got: "${modelId}". Available: ${available}`)
142
312
  }
143
313
 
144
- const supportedParams = record.supportedParams ?? PROVIDER_DEFAULT_PARAMS[record.provider]
314
+ const [provider, name] = parts
145
315
 
146
- return {
147
- record, supportedParams,
316
+ for (const m of REGISTRY.values()) {
317
+ if (m.name === name && m.provider === provider) {
318
+ const record = m
319
+
320
+ if (!record.enable) {
321
+ throw new Error(`Model "${record.provider}/${record.name}" is currently disabled.`)
322
+ }
323
+
324
+ const supportedParams = record.supportedParams ?? PROVIDER_DEFAULT_PARAMS[record.provider]
325
+ const paramOverrides = record.paramOverrides ?? {}
326
+
327
+ return {
328
+ record, supportedParams, paramOverrides,
329
+ }
330
+ }
148
331
  }
332
+
333
+ const available = [...REGISTRY.values()].map(m => `${m.provider}/${m.name}`).join(', ')
334
+ throw new Error(`Unknown model "${modelId}". Available: ${available}`)
149
335
  }
150
336
 
151
337
  /**
152
- * Returns all enabled model records.
338
+ * Returns all enabled model records from the global registry.
339
+ * For isolated registries, use the listModels from createRegistry() instead.
153
340
  *
154
341
  * @returns {ModelRecord[]}
155
342
  */
@@ -157,44 +344,43 @@ export const listModels = () =>
157
344
  [...REGISTRY.values()].filter((m) => m.enable)
158
345
 
159
346
  /**
160
- * Adds one or more models to the registry.
161
- * Existing models with the same ID are overwritten.
347
+ * Adds one or more models to the global registry.
348
+ * For isolated registries, use the addModels from createRegistry() instead.
162
349
  *
163
350
  * @param {ModelRecord[]} models - Array of model records to add
164
- * @throws {Error} When models is not an array or contains invalid records
165
351
  */
166
352
  export const addModels = (models) => {
167
353
  if (!Array.isArray(models)) {
168
354
  throw new Error(`addModels expects an array. Got: ${typeof models}`)
169
355
  }
170
356
 
171
- // Validate each model record
172
357
  models.forEach((model, index) => {
173
358
  validateModelRecord(model, index)
174
359
  })
175
360
 
176
- // Add models to the registry
177
361
  models.forEach((model) => {
178
- REGISTRY.set(model.id, model)
362
+ const normalized = normalizeModelRecord(model)
363
+ REGISTRY.set(normalized.id, normalized)
179
364
  })
180
365
  }
181
366
 
182
367
  /**
183
- * Replaces the entire model registry with a new list of models.
184
- * Use this to load models from a CMS or other external source.
368
+ * Replaces the entire global registry with a new list of models.
369
+ * For isolated registries, use the setModels from createRegistry() instead.
185
370
  *
186
371
  * @param {ModelRecord[]} models - Array of model records
187
- * @throws {Error} When models is not an array or contains invalid records
188
372
  */
189
373
  export const setModels = (models) => {
190
374
  if (!Array.isArray(models)) {
191
375
  throw new Error(`setModels expects an array. Got: ${typeof models}`)
192
376
  }
193
377
 
194
- // Validate each model record strictly
195
378
  models.forEach((model, index) => {
196
379
  validateModelRecord(model, index)
197
380
  })
198
381
 
199
- REGISTRY = new Map(models.map((model) => [model.id, model]))
382
+ REGISTRY = new Map(models.map((model) => {
383
+ const normalized = normalizeModelRecord(model)
384
+ return [normalized.id, normalized]
385
+ }))
200
386
  }