@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/README.md +307 -52
- package/index.d.ts +60 -9
- package/package.json +8 -6
- package/src/coerce.js +77 -5
- package/src/coerce.test.js +216 -0
- package/src/config.js +43 -0
- package/src/config.test.js +142 -0
- package/src/errors.js +53 -6
- package/src/index.js +125 -25
- package/src/index.test.js +859 -0
- package/src/logger.js +48 -0
- package/src/models.js +61 -0
- package/src/providers.js +88 -8
- package/src/registry.js +227 -41
- package/src/registry.test.js +314 -0
- package/src/security.js +114 -0
- package/src/validation.js +4 -4
- package/src/validation.test.js +411 -0
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
*
|
|
126
|
-
*
|
|
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 {
|
|
129
|
-
* @returns {
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
314
|
+
const [provider, name] = parts
|
|
145
315
|
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
362
|
+
const normalized = normalizeModelRecord(model)
|
|
363
|
+
REGISTRY.set(normalized.id, normalized)
|
|
179
364
|
})
|
|
180
365
|
}
|
|
181
366
|
|
|
182
367
|
/**
|
|
183
|
-
* Replaces the entire
|
|
184
|
-
*
|
|
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) =>
|
|
382
|
+
REGISTRY = new Map(models.map((model) => {
|
|
383
|
+
const normalized = normalizeModelRecord(model)
|
|
384
|
+
return [normalized.id, normalized]
|
|
385
|
+
}))
|
|
200
386
|
}
|