@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
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for registry module.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
describe, it, before,
|
|
7
|
+
} from 'node:test'
|
|
8
|
+
import assert from 'node:assert'
|
|
9
|
+
import {
|
|
10
|
+
getModel, listModels, setModels, PROVIDER_DEFAULT_PARAMS,
|
|
11
|
+
} from '../src/registry.js'
|
|
12
|
+
import { DEFAULT_MODELS } from '../src/models.js'
|
|
13
|
+
|
|
14
|
+
// Use default models from src/models.js for testing
|
|
15
|
+
before(() => {
|
|
16
|
+
setModels(DEFAULT_MODELS)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('registry', () => {
|
|
20
|
+
describe('PROVIDER_DEFAULT_PARAMS', () => {
|
|
21
|
+
it('should have params for all providers', () => {
|
|
22
|
+
assert.ok(PROVIDER_DEFAULT_PARAMS.openai)
|
|
23
|
+
assert.ok(PROVIDER_DEFAULT_PARAMS.anthropic)
|
|
24
|
+
assert.ok(PROVIDER_DEFAULT_PARAMS.google)
|
|
25
|
+
assert.ok(PROVIDER_DEFAULT_PARAMS.dashscope)
|
|
26
|
+
assert.ok(PROVIDER_DEFAULT_PARAMS.deepseek)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('openai should include standard params', () => {
|
|
30
|
+
const params = PROVIDER_DEFAULT_PARAMS.openai
|
|
31
|
+
assert.ok(params.includes('temperature'))
|
|
32
|
+
assert.ok(params.includes('maxTokens'))
|
|
33
|
+
assert.ok(params.includes('topP'))
|
|
34
|
+
assert.ok(params.includes('frequencyPenalty'))
|
|
35
|
+
assert.ok(params.includes('presencePenalty'))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('anthropic should include standard params', () => {
|
|
39
|
+
const params = PROVIDER_DEFAULT_PARAMS.anthropic
|
|
40
|
+
assert.ok(params.includes('temperature'))
|
|
41
|
+
assert.ok(params.includes('maxTokens'))
|
|
42
|
+
assert.ok(params.includes('topP'))
|
|
43
|
+
assert.ok(params.includes('topK'))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('google should include standard params', () => {
|
|
47
|
+
const params = PROVIDER_DEFAULT_PARAMS.google
|
|
48
|
+
assert.ok(params.includes('temperature'))
|
|
49
|
+
assert.ok(params.includes('maxTokens'))
|
|
50
|
+
assert.ok(params.includes('topP'))
|
|
51
|
+
assert.ok(params.includes('topK'))
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('getModel', () => {
|
|
56
|
+
it('should return model record for valid model', () => {
|
|
57
|
+
const {
|
|
58
|
+
record, supportedParams,
|
|
59
|
+
} = getModel('openai/gpt-4.1-nano')
|
|
60
|
+
assert.ok(record)
|
|
61
|
+
assert.strictEqual(record.provider, 'openai')
|
|
62
|
+
assert.strictEqual(record.name, 'gpt-4.1-nano')
|
|
63
|
+
assert.ok(Array.isArray(supportedParams))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should throw when model is not in provider/name format', () => {
|
|
67
|
+
assert.throws(
|
|
68
|
+
() => getModel('gpt-4.1-nano'),
|
|
69
|
+
/Model must be in 'provider\/name' format/
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should throw for unknown model', () => {
|
|
74
|
+
assert.throws(
|
|
75
|
+
() => getModel('openai/nonexistent-model'),
|
|
76
|
+
/Unknown model/
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should throw for disabled model', () => {
|
|
81
|
+
// First find a disabled model or test the error path
|
|
82
|
+
// This depends on models.json content
|
|
83
|
+
assert.throws(
|
|
84
|
+
() => getModel('openai/disabled-model-test'),
|
|
85
|
+
/Unknown model/
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should use provider default params when model has no supportedParams', () => {
|
|
90
|
+
const { supportedParams } = getModel('openai/gpt-4.1-nano')
|
|
91
|
+
assert.ok(supportedParams.length > 0)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('listModels', () => {
|
|
96
|
+
it('should return array of models', () => {
|
|
97
|
+
const models = listModels()
|
|
98
|
+
assert.ok(Array.isArray(models))
|
|
99
|
+
assert.ok(models.length > 0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('should only return enabled models', () => {
|
|
103
|
+
const models = listModels()
|
|
104
|
+
models.forEach((model) => {
|
|
105
|
+
assert.strictEqual(model.enable, true)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('each model should have required fields', () => {
|
|
110
|
+
const models = listModels()
|
|
111
|
+
models.forEach((model) => {
|
|
112
|
+
assert.ok(model.id, 'model should have id')
|
|
113
|
+
assert.ok(model.name, 'model should have name')
|
|
114
|
+
assert.ok(model.provider, 'model should have provider')
|
|
115
|
+
assert.ok(typeof model.input_price === 'number', 'model should have input_price')
|
|
116
|
+
assert.ok(typeof model.output_price === 'number', 'model should have output_price')
|
|
117
|
+
assert.ok(typeof model.max_in === 'number', 'model should have max_in')
|
|
118
|
+
assert.ok(typeof model.max_out === 'number', 'model should have max_out')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('setModels', () => {
|
|
124
|
+
it('should throw when models is not an array', () => {
|
|
125
|
+
assert.throws(
|
|
126
|
+
() => setModels({}),
|
|
127
|
+
/setModels expects an array/
|
|
128
|
+
)
|
|
129
|
+
assert.throws(
|
|
130
|
+
() => setModels(null),
|
|
131
|
+
/setModels expects an array/
|
|
132
|
+
)
|
|
133
|
+
assert.throws(
|
|
134
|
+
() => setModels('string'),
|
|
135
|
+
/setModels expects an array/
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should throw when model record is missing required fields', () => {
|
|
140
|
+
// Only name and provider are required, other fields get defaults
|
|
141
|
+
assert.throws(
|
|
142
|
+
() => setModels([{ provider: 'openai' }]),
|
|
143
|
+
/"name" must be a non-empty string/
|
|
144
|
+
)
|
|
145
|
+
assert.throws(
|
|
146
|
+
() => setModels([{ name: 'Test' }]),
|
|
147
|
+
/"provider" must be a string/
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should throw when model has empty name', () => {
|
|
152
|
+
assert.throws(
|
|
153
|
+
() => setModels([{ name: '', provider: 'openai' }]),
|
|
154
|
+
/"name" must be a non-empty string/
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should throw when provider is invalid', () => {
|
|
159
|
+
assert.throws(
|
|
160
|
+
() => setModels([{ name: 'Test', provider: 'invalid' }]),
|
|
161
|
+
/"provider" must be one of:/
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('should throw when numeric fields are negative', () => {
|
|
166
|
+
assert.throws(
|
|
167
|
+
() => setModels([{ name: 'Test', provider: 'openai', input_price: -1 }]),
|
|
168
|
+
/"input_price" must be non-negative/
|
|
169
|
+
)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('should throw when supportedParams is invalid', () => {
|
|
173
|
+
assert.throws(
|
|
174
|
+
() => setModels([{ name: 'Test', provider: 'openai', supportedParams: 'not-array' }]),
|
|
175
|
+
/"supportedParams" must be an array/
|
|
176
|
+
)
|
|
177
|
+
assert.throws(
|
|
178
|
+
() => setModels([{ name: 'Test', provider: 'openai', supportedParams: [123] }]),
|
|
179
|
+
/"supportedParams\[0\]" must be a string/
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('should set models from array and override registry', () => {
|
|
184
|
+
const customModels = [
|
|
185
|
+
{
|
|
186
|
+
name: 'Custom Model 1',
|
|
187
|
+
provider: 'openai',
|
|
188
|
+
input_price: 0.5,
|
|
189
|
+
output_price: 1.5,
|
|
190
|
+
cache_price: 0.1,
|
|
191
|
+
max_in: 128000,
|
|
192
|
+
max_out: 4096,
|
|
193
|
+
enable: true,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'Custom Model 2',
|
|
197
|
+
provider: 'anthropic',
|
|
198
|
+
input_price: 3,
|
|
199
|
+
output_price: 15,
|
|
200
|
+
cache_price: 0,
|
|
201
|
+
max_in: 200000,
|
|
202
|
+
max_out: 8192,
|
|
203
|
+
enable: false,
|
|
204
|
+
},
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
setModels(customModels)
|
|
208
|
+
|
|
209
|
+
// Use provider/name format
|
|
210
|
+
const { record } = getModel('openai/Custom Model 1')
|
|
211
|
+
assert.strictEqual(record.name, 'Custom Model 1')
|
|
212
|
+
assert.strictEqual(record.provider, 'openai')
|
|
213
|
+
assert.strictEqual(record.input_price, 0.5)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('should throw for disabled model after setModels', () => {
|
|
217
|
+
const customModels = [
|
|
218
|
+
{
|
|
219
|
+
name: 'Disabled Custom',
|
|
220
|
+
provider: 'google',
|
|
221
|
+
input_price: 0.1,
|
|
222
|
+
output_price: 0.5,
|
|
223
|
+
cache_price: 0,
|
|
224
|
+
max_in: 100000,
|
|
225
|
+
max_out: 2048,
|
|
226
|
+
enable: false,
|
|
227
|
+
},
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
setModels(customModels)
|
|
231
|
+
|
|
232
|
+
assert.throws(
|
|
233
|
+
() => getModel('google/Disabled Custom'),
|
|
234
|
+
/currently disabled/
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('listModels should return only enabled models after setModels', () => {
|
|
239
|
+
const customModels = [
|
|
240
|
+
{
|
|
241
|
+
name: 'Enabled 1',
|
|
242
|
+
provider: 'openai',
|
|
243
|
+
input_price: 0.5,
|
|
244
|
+
output_price: 1.5,
|
|
245
|
+
cache_price: 0,
|
|
246
|
+
max_in: 128000,
|
|
247
|
+
max_out: 4096,
|
|
248
|
+
enable: true,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'Disabled 1',
|
|
252
|
+
provider: 'anthropic',
|
|
253
|
+
input_price: 3,
|
|
254
|
+
output_price: 15,
|
|
255
|
+
cache_price: 0,
|
|
256
|
+
max_in: 200000,
|
|
257
|
+
max_out: 8192,
|
|
258
|
+
enable: false,
|
|
259
|
+
},
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
setModels(customModels)
|
|
263
|
+
|
|
264
|
+
const models = listModels()
|
|
265
|
+
assert.strictEqual(models.length, 1)
|
|
266
|
+
assert.strictEqual(models[0].id, 'openai_Enabled 1')
|
|
267
|
+
assert.strictEqual(models[0].name, 'Enabled 1')
|
|
268
|
+
assert.strictEqual(models[0].provider, 'openai')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('should use provider default params when model has no supportedParams', () => {
|
|
272
|
+
const customModels = [
|
|
273
|
+
{
|
|
274
|
+
name: 'No Params Model',
|
|
275
|
+
provider: 'google',
|
|
276
|
+
input_price: 0.1,
|
|
277
|
+
output_price: 0.5,
|
|
278
|
+
cache_price: 0,
|
|
279
|
+
max_in: 100000,
|
|
280
|
+
max_out: 2048,
|
|
281
|
+
enable: true,
|
|
282
|
+
},
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
setModels(customModels)
|
|
286
|
+
|
|
287
|
+
const { supportedParams } = getModel('google/No Params Model')
|
|
288
|
+
assert.ok(supportedParams.length > 0)
|
|
289
|
+
assert.ok(supportedParams.includes('temperature'))
|
|
290
|
+
assert.ok(supportedParams.includes('maxTokens'))
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('should use model-level supportedParams when provided', () => {
|
|
294
|
+
const customModels = [
|
|
295
|
+
{
|
|
296
|
+
name: 'Custom Params Model',
|
|
297
|
+
provider: 'openai',
|
|
298
|
+
input_price: 0.5,
|
|
299
|
+
output_price: 1.5,
|
|
300
|
+
cache_price: 0,
|
|
301
|
+
max_in: 128000,
|
|
302
|
+
max_out: 4096,
|
|
303
|
+
enable: true,
|
|
304
|
+
supportedParams: ['temperature', 'maxTokens', 'customParam'],
|
|
305
|
+
},
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
setModels(customModels)
|
|
309
|
+
|
|
310
|
+
const { supportedParams } = getModel('openai/Custom Params Model')
|
|
311
|
+
assert.deepStrictEqual(supportedParams, ['temperature', 'maxTokens', 'customParam'])
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
})
|
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
|
}
|