@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.
@@ -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
+ })
@@ -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
  }