@pwshub/aisdk 0.0.5 → 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 +181 -1
- package/index.d.ts +43 -1
- package/package.json +2 -2
- package/src/coerce.js +77 -5
- package/src/coerce.test.js +114 -40
- package/src/config.js +13 -0
- package/src/errors.js +52 -5
- package/src/index.js +117 -17
- package/src/index.test.js +859 -0
- package/src/logger.js +48 -0
- package/src/models.js +5 -0
- package/src/providers.js +19 -9
- package/src/registry.js +169 -24
- package/src/security.js +114 -0
- package/src/validation.js +4 -4
- package/src/validation.test.js +7 -6
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',
|
package/src/providers.js
CHANGED
|
@@ -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,
|
|
@@ -305,7 +309,7 @@ const mistral = {
|
|
|
305
309
|
'Content-Type': 'application/json',
|
|
306
310
|
Accept: 'application/json',
|
|
307
311
|
}),
|
|
308
|
-
url: () => 'https://api.mistral.ai/v1/chat/completions',
|
|
312
|
+
url: (modelName, apikey, gatewayUrl) => gatewayUrl || 'https://api.mistral.ai/v1/chat/completions',
|
|
309
313
|
buildBody: (modelName, messages, config, providerOptions) => ({
|
|
310
314
|
model: modelName,
|
|
311
315
|
messages,
|
|
@@ -333,8 +337,14 @@ const ollama = {
|
|
|
333
337
|
'Content-Type': 'application/json',
|
|
334
338
|
...(apikey && { Authorization: `Bearer ${apikey}` }),
|
|
335
339
|
}),
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
},
|
|
338
348
|
buildBody: (modelName, messages, config, providerOptions) => {
|
|
339
349
|
// Ollama uses snake_case options
|
|
340
350
|
const options = {}
|
package/src/registry.js
CHANGED
|
@@ -30,6 +30,7 @@ import { DEFAULT_MODELS } from './models.js'
|
|
|
30
30
|
* @property {number} max_out - Max output tokens
|
|
31
31
|
* @property {boolean} enable
|
|
32
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
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
36
|
/**
|
|
@@ -51,11 +52,116 @@ export const PROVIDER_DEFAULT_PARAMS = {
|
|
|
51
52
|
/** @type {ProviderId[]} */
|
|
52
53
|
const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'dashscope', 'deepseek', 'mistral', 'ollama']
|
|
53
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
|
+
}
|
|
159
|
+
|
|
54
160
|
/** @type {Map<string, ModelRecord>} */
|
|
55
161
|
let REGISTRY = new Map()
|
|
56
162
|
|
|
57
163
|
/**
|
|
58
|
-
* Initializes the registry with default models.
|
|
164
|
+
* Initializes the global registry with default models.
|
|
59
165
|
* Called automatically at module import.
|
|
60
166
|
*/
|
|
61
167
|
const initRegistry = () => {
|
|
@@ -114,6 +220,50 @@ const validateModelRecord = (model, index) => {
|
|
|
114
220
|
}
|
|
115
221
|
}
|
|
116
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
|
+
|
|
117
267
|
if (errors.length > 0) {
|
|
118
268
|
throw new Error(`Invalid model record at index ${index}: ${errors.join('; ')}`)
|
|
119
269
|
}
|
|
@@ -138,55 +288,55 @@ const normalizeModelRecord = (model) => {
|
|
|
138
288
|
max_out: model.max_out ?? 8000,
|
|
139
289
|
enable: model.enable ?? true,
|
|
140
290
|
...(model.supportedParams !== undefined && { supportedParams: model.supportedParams }),
|
|
291
|
+
...(model.paramOverrides !== undefined && { paramOverrides: model.paramOverrides }),
|
|
141
292
|
}
|
|
142
293
|
}
|
|
143
294
|
|
|
144
295
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
296
|
+
* Gets a model from the global registry (for backward compatibility).
|
|
297
|
+
* For isolated registries, use the getModel from createRegistry() instead.
|
|
147
298
|
*
|
|
148
|
-
* @param {string} modelId - Model in 'provider/name' format
|
|
149
|
-
* @returns {{ record: ModelRecord, supportedParams: string[] }}
|
|
150
|
-
* @throws {Error} When the model is not found or is disabled
|
|
299
|
+
* @param {string} modelId - Model in 'provider/name' format
|
|
300
|
+
* @returns {{ record: ModelRecord, supportedParams: string[], paramOverrides: Record<string, import('./coerce.js').ParamOverride> }}
|
|
151
301
|
*/
|
|
152
302
|
export const getModel = (modelId) => {
|
|
153
|
-
// Require provider/name format
|
|
154
303
|
if (!modelId.includes('/')) {
|
|
155
304
|
const available = [...REGISTRY.values()].map(m => `${m.provider}/${m.name}`).join(', ')
|
|
156
305
|
throw new Error(`Model must be in 'provider/name' format. Got: "${modelId}". Available: ${available}`)
|
|
157
306
|
}
|
|
158
|
-
|
|
307
|
+
|
|
159
308
|
const parts = modelId.split('/')
|
|
160
309
|
if (parts.length !== 2) {
|
|
161
310
|
const available = [...REGISTRY.values()].map(m => `${m.provider}/${m.name}`).join(', ')
|
|
162
311
|
throw new Error(`Model must be in 'provider/name' format. Got: "${modelId}". Available: ${available}`)
|
|
163
312
|
}
|
|
164
|
-
|
|
313
|
+
|
|
165
314
|
const [provider, name] = parts
|
|
166
|
-
|
|
167
|
-
// Search for model by name and provider
|
|
315
|
+
|
|
168
316
|
for (const m of REGISTRY.values()) {
|
|
169
317
|
if (m.name === name && m.provider === provider) {
|
|
170
318
|
const record = m
|
|
171
|
-
|
|
319
|
+
|
|
172
320
|
if (!record.enable) {
|
|
173
321
|
throw new Error(`Model "${record.provider}/${record.name}" is currently disabled.`)
|
|
174
322
|
}
|
|
175
323
|
|
|
176
324
|
const supportedParams = record.supportedParams ?? PROVIDER_DEFAULT_PARAMS[record.provider]
|
|
325
|
+
const paramOverrides = record.paramOverrides ?? {}
|
|
177
326
|
|
|
178
327
|
return {
|
|
179
|
-
record, supportedParams,
|
|
328
|
+
record, supportedParams, paramOverrides,
|
|
180
329
|
}
|
|
181
330
|
}
|
|
182
331
|
}
|
|
183
|
-
|
|
332
|
+
|
|
184
333
|
const available = [...REGISTRY.values()].map(m => `${m.provider}/${m.name}`).join(', ')
|
|
185
334
|
throw new Error(`Unknown model "${modelId}". Available: ${available}`)
|
|
186
335
|
}
|
|
187
336
|
|
|
188
337
|
/**
|
|
189
|
-
* 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.
|
|
190
340
|
*
|
|
191
341
|
* @returns {ModelRecord[]}
|
|
192
342
|
*/
|
|
@@ -194,23 +344,20 @@ export const listModels = () =>
|
|
|
194
344
|
[...REGISTRY.values()].filter((m) => m.enable)
|
|
195
345
|
|
|
196
346
|
/**
|
|
197
|
-
* Adds one or more models to the registry.
|
|
198
|
-
*
|
|
347
|
+
* Adds one or more models to the global registry.
|
|
348
|
+
* For isolated registries, use the addModels from createRegistry() instead.
|
|
199
349
|
*
|
|
200
350
|
* @param {ModelRecord[]} models - Array of model records to add
|
|
201
|
-
* @throws {Error} When models is not an array or contains invalid records
|
|
202
351
|
*/
|
|
203
352
|
export const addModels = (models) => {
|
|
204
353
|
if (!Array.isArray(models)) {
|
|
205
354
|
throw new Error(`addModels expects an array. Got: ${typeof models}`)
|
|
206
355
|
}
|
|
207
356
|
|
|
208
|
-
// Validate and normalize each model record
|
|
209
357
|
models.forEach((model, index) => {
|
|
210
358
|
validateModelRecord(model, index)
|
|
211
359
|
})
|
|
212
360
|
|
|
213
|
-
// Add normalized models to the registry
|
|
214
361
|
models.forEach((model) => {
|
|
215
362
|
const normalized = normalizeModelRecord(model)
|
|
216
363
|
REGISTRY.set(normalized.id, normalized)
|
|
@@ -218,18 +365,16 @@ export const addModels = (models) => {
|
|
|
218
365
|
}
|
|
219
366
|
|
|
220
367
|
/**
|
|
221
|
-
* Replaces the entire
|
|
222
|
-
*
|
|
368
|
+
* Replaces the entire global registry with a new list of models.
|
|
369
|
+
* For isolated registries, use the setModels from createRegistry() instead.
|
|
223
370
|
*
|
|
224
371
|
* @param {ModelRecord[]} models - Array of model records
|
|
225
|
-
* @throws {Error} When models is not an array or contains invalid records
|
|
226
372
|
*/
|
|
227
373
|
export const setModels = (models) => {
|
|
228
374
|
if (!Array.isArray(models)) {
|
|
229
375
|
throw new Error(`setModels expects an array. Got: ${typeof models}`)
|
|
230
376
|
}
|
|
231
377
|
|
|
232
|
-
// Validate and normalize each model record
|
|
233
378
|
models.forEach((model, index) => {
|
|
234
379
|
validateModelRecord(model, index)
|
|
235
380
|
})
|
package/src/security.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Security utilities for @pwshub/aisdk
|
|
3
|
+
*
|
|
4
|
+
* Provides API key validation, sanitization for logging,
|
|
5
|
+
* and other security-related functions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { InputError } from './errors.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sensitive field patterns to redact from logs
|
|
12
|
+
* @type {string[]}
|
|
13
|
+
*/
|
|
14
|
+
const SENSITIVE_KEYS = [
|
|
15
|
+
'apikey',
|
|
16
|
+
'api_key',
|
|
17
|
+
'apiKey',
|
|
18
|
+
'authorization',
|
|
19
|
+
'key',
|
|
20
|
+
'token',
|
|
21
|
+
'secret',
|
|
22
|
+
'password',
|
|
23
|
+
'credential',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sanitizes an object for safe logging by removing/masking sensitive fields
|
|
28
|
+
* @param {unknown} obj - The object to sanitize
|
|
29
|
+
* @returns {unknown} Sanitized object safe for logging
|
|
30
|
+
*/
|
|
31
|
+
export const sanitizeForLogging = (obj) => {
|
|
32
|
+
if (typeof obj !== 'object' || obj === null) {
|
|
33
|
+
return obj
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (Array.isArray(obj)) {
|
|
37
|
+
return obj.map(sanitizeForLogging)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sanitized = {}
|
|
41
|
+
|
|
42
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
43
|
+
const lowerKey = key.toLowerCase()
|
|
44
|
+
if (SENSITIVE_KEYS.some((s) => lowerKey.includes(s))) {
|
|
45
|
+
sanitized[key] = '[REDACTED]'
|
|
46
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
47
|
+
sanitized[key] = sanitizeForLogging(value)
|
|
48
|
+
} else {
|
|
49
|
+
sanitized[key] = value
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return sanitized
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Provider-specific API key format patterns for validation warnings
|
|
58
|
+
* @type {Record<string, {pattern: RegExp, message: string}>}
|
|
59
|
+
*/
|
|
60
|
+
const API_KEY_PATTERNS = {
|
|
61
|
+
openai: {
|
|
62
|
+
pattern: /^sk-[a-zA-Z0-9]{20,}/,
|
|
63
|
+
message: 'OpenAI API key format looks incorrect (should start with sk-)',
|
|
64
|
+
},
|
|
65
|
+
anthropic: {
|
|
66
|
+
pattern: /^sk-ant-[a-zA-Z0-9_-]{20,}/i,
|
|
67
|
+
message: 'Anthropic API key format looks incorrect (should start with sk-ant-)',
|
|
68
|
+
},
|
|
69
|
+
google: {
|
|
70
|
+
pattern: /^AIza[0-9A-Za-z_-]{35}/,
|
|
71
|
+
message: 'Google API key format looks incorrect (should start with AIza)',
|
|
72
|
+
},
|
|
73
|
+
deepseek: {
|
|
74
|
+
pattern: /^sk-[a-zA-Z0-9]{20,}/,
|
|
75
|
+
message: 'DeepSeek API key format looks incorrect (should start with sk-)',
|
|
76
|
+
},
|
|
77
|
+
mistral: {
|
|
78
|
+
pattern: /^[a-zA-Z0-9]{32,}/,
|
|
79
|
+
message: 'Mistral API key format looks incorrect',
|
|
80
|
+
},
|
|
81
|
+
dashscope: {
|
|
82
|
+
pattern: /^sk-[a-zA-Z0-9]{20,}/,
|
|
83
|
+
message: 'DashScope API key format looks incorrect (should start with sk-)',
|
|
84
|
+
},
|
|
85
|
+
ollama: {
|
|
86
|
+
// Ollama doesn't require API keys, so no validation
|
|
87
|
+
pattern: /.*/,
|
|
88
|
+
message: '',
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validates API key format per provider
|
|
94
|
+
* @param {string} apikey - The API key to validate
|
|
95
|
+
* @param {string} provider - The provider ID
|
|
96
|
+
* @param {import('./logger.js').Logger} logger - Logger instance for warnings
|
|
97
|
+
* @throws {InputError} When API key is missing or empty
|
|
98
|
+
*/
|
|
99
|
+
export const validateApiKey = (apikey, provider, logger) => {
|
|
100
|
+
// Check if API key is provided and not empty
|
|
101
|
+
if (!apikey || typeof apikey !== 'string' || apikey.trim() === '') {
|
|
102
|
+
throw new InputError('API key is required', {
|
|
103
|
+
status: 401,
|
|
104
|
+
provider,
|
|
105
|
+
model: 'unknown',
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Provider-specific format warnings (not throwing, just warning)
|
|
110
|
+
const providerWarning = API_KEY_PATTERNS[provider]
|
|
111
|
+
if (providerWarning && providerWarning.message && !providerWarning.pattern.test(apikey)) {
|
|
112
|
+
logger.warn(`[ai-client] ${providerWarning.message}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/validation.js
CHANGED
|
@@ -48,8 +48,8 @@ export const validateAskOptions = (params) => {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// When using messages, system can still be provided (will be prepended)
|
|
51
|
-
if (params.prompt !== undefined && typeof params.prompt !== 'string') {
|
|
52
|
-
errors.push('"prompt" must be a string')
|
|
51
|
+
if (params.prompt !== undefined && (typeof params.prompt !== 'string' || params.prompt.trim() === '')) {
|
|
52
|
+
errors.push('"prompt" must be a non-empty string')
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
// Optional string fields
|
|
@@ -75,8 +75,8 @@ export const validateAskOptions = (params) => {
|
|
|
75
75
|
errors.push(`messages[${i}] must be an object`)
|
|
76
76
|
} else if (!['user', 'assistant', 'system'].includes(msg.role)) {
|
|
77
77
|
errors.push(`messages[${i}].role must be 'user', 'assistant', or 'system'`)
|
|
78
|
-
} else if (typeof msg.content !== 'string') {
|
|
79
|
-
errors.push(`messages[${i}].content must be a string`)
|
|
78
|
+
} else if (typeof msg.content !== 'string' || msg.content.trim() === '') {
|
|
79
|
+
errors.push(`messages[${i}].content must be a non-empty string`)
|
|
80
80
|
}
|
|
81
81
|
})
|
|
82
82
|
}
|
package/src/validation.test.js
CHANGED
|
@@ -81,11 +81,12 @@ describe('validateAskOptions', () => {
|
|
|
81
81
|
)
|
|
82
82
|
})
|
|
83
83
|
|
|
84
|
-
it('should
|
|
85
|
-
assert.
|
|
84
|
+
it('should throw when prompt is empty string', () => {
|
|
85
|
+
assert.throws(
|
|
86
86
|
() => validateAskOptions({
|
|
87
87
|
model: 'gpt-4o', apikey: 'test-key', prompt: '',
|
|
88
|
-
})
|
|
88
|
+
}),
|
|
89
|
+
/"prompt" must be a non-empty string/
|
|
89
90
|
)
|
|
90
91
|
})
|
|
91
92
|
|
|
@@ -94,7 +95,7 @@ describe('validateAskOptions', () => {
|
|
|
94
95
|
() => validateAskOptions({
|
|
95
96
|
model: 'gpt-4o', apikey: 'test-key', prompt: 123,
|
|
96
97
|
}),
|
|
97
|
-
/"prompt" must be a string/
|
|
98
|
+
/"prompt" must be a non-empty string/
|
|
98
99
|
)
|
|
99
100
|
})
|
|
100
101
|
|
|
@@ -138,7 +139,7 @@ describe('validateAskOptions', () => {
|
|
|
138
139
|
apikey: 'test-key',
|
|
139
140
|
messages: [{ role: 'user' }],
|
|
140
141
|
}),
|
|
141
|
-
/messages\[0\]\.content must be a string/
|
|
142
|
+
/messages\[0\]\.content must be a non-empty string/
|
|
142
143
|
)
|
|
143
144
|
})
|
|
144
145
|
})
|
|
@@ -280,7 +281,7 @@ describe('validateAskOptions', () => {
|
|
|
280
281
|
role: 'user', content: 123,
|
|
281
282
|
}],
|
|
282
283
|
}),
|
|
283
|
-
/messages\[0\].content must be a string/
|
|
284
|
+
/messages\[0\].content must be a non-empty string/
|
|
284
285
|
)
|
|
285
286
|
})
|
|
286
287
|
})
|