@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/config.js
CHANGED
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
* @property {string} wireKey
|
|
36
36
|
* @property {'root'|'generationConfig'} [scope] - Google nests some params
|
|
37
37
|
* @property {ParamRange} [range] - Valid range for the param (for clamping)
|
|
38
|
+
* @property {number[]} [supportedValues] - Discrete allowed values (e.g. [1] for fixed temp)
|
|
39
|
+
* @property {number} [fixedValue] - Force param to this value regardless of input
|
|
38
40
|
*/
|
|
39
41
|
|
|
40
42
|
/**
|
|
@@ -51,6 +53,8 @@ const WIRE_KEYS = {
|
|
|
51
53
|
wireKey: 'temperature', range: {
|
|
52
54
|
min: 0, max: 2,
|
|
53
55
|
},
|
|
56
|
+
// Note: Some OpenAI models (e.g. gpt-5-nano) only support fixedValue: 1
|
|
57
|
+
// Model-specific overrides are handled in coerce.js via registry overrides
|
|
54
58
|
},
|
|
55
59
|
topP: {
|
|
56
60
|
wireKey: 'top_p', range: {
|
|
@@ -67,6 +71,8 @@ const WIRE_KEYS = {
|
|
|
67
71
|
min: -2, max: 2,
|
|
68
72
|
},
|
|
69
73
|
},
|
|
74
|
+
stop: { wireKey: 'stop' },
|
|
75
|
+
seed: { wireKey: 'seed' },
|
|
70
76
|
// https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create
|
|
71
77
|
},
|
|
72
78
|
anthropic: {
|
|
@@ -86,6 +92,7 @@ const WIRE_KEYS = {
|
|
|
86
92
|
min: 1, max: 100,
|
|
87
93
|
},
|
|
88
94
|
},
|
|
95
|
+
stop: { wireKey: 'stop_sequences' },
|
|
89
96
|
},
|
|
90
97
|
google: {
|
|
91
98
|
temperature: {
|
|
@@ -106,6 +113,8 @@ const WIRE_KEYS = {
|
|
|
106
113
|
min: 1, max: 100,
|
|
107
114
|
},
|
|
108
115
|
},
|
|
116
|
+
stop: { wireKey: 'stopSequences', scope: 'generationConfig' },
|
|
117
|
+
seed: { wireKey: 'seed', scope: 'generationConfig' },
|
|
109
118
|
},
|
|
110
119
|
dashscope: {
|
|
111
120
|
temperature: {
|
|
@@ -124,6 +133,7 @@ const WIRE_KEYS = {
|
|
|
124
133
|
min: 1, max: 100,
|
|
125
134
|
},
|
|
126
135
|
},
|
|
136
|
+
stop: { wireKey: 'stop' },
|
|
127
137
|
},
|
|
128
138
|
deepseek: {
|
|
129
139
|
temperature: {
|
|
@@ -147,6 +157,7 @@ const WIRE_KEYS = {
|
|
|
147
157
|
min: -2, max: 2,
|
|
148
158
|
},
|
|
149
159
|
},
|
|
160
|
+
stop: { wireKey: 'stop' },
|
|
150
161
|
},
|
|
151
162
|
mistral: {
|
|
152
163
|
temperature: {
|
|
@@ -161,6 +172,7 @@ const WIRE_KEYS = {
|
|
|
161
172
|
},
|
|
162
173
|
},
|
|
163
174
|
randomSeed: { wireKey: 'random_seed' },
|
|
175
|
+
stop: { wireKey: 'stop' },
|
|
164
176
|
},
|
|
165
177
|
ollama: {
|
|
166
178
|
temperature: {
|
|
@@ -176,6 +188,7 @@ const WIRE_KEYS = {
|
|
|
176
188
|
},
|
|
177
189
|
topK: { wireKey: 'top_k' },
|
|
178
190
|
seed: { wireKey: 'seed' },
|
|
191
|
+
stop: { wireKey: 'stop' },
|
|
179
192
|
},
|
|
180
193
|
}
|
|
181
194
|
|
package/src/errors.js
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
* }
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { sanitizeForLogging } from './security.js'
|
|
28
|
+
import { getLogger } from './logger.js'
|
|
29
|
+
|
|
27
30
|
/**
|
|
28
31
|
* Thrown when the provider returns a transient or server-side error.
|
|
29
32
|
* HTTP 429 (rate limit) and 5xx responses produce this error.
|
|
@@ -37,9 +40,10 @@ export class ProviderError extends Error {
|
|
|
37
40
|
* @param {string} meta.provider - Provider ID
|
|
38
41
|
* @param {string} meta.model - Model name that was called
|
|
39
42
|
* @param {string} [meta.raw] - Raw response body from provider
|
|
43
|
+
* @param {number} [meta.retryAfter] - Milliseconds to wait before retrying
|
|
40
44
|
*/
|
|
41
45
|
constructor(message, {
|
|
42
|
-
status, provider, model, raw,
|
|
46
|
+
status, provider, model, raw, retryAfter,
|
|
43
47
|
} = {}) {
|
|
44
48
|
super(message)
|
|
45
49
|
this.name = 'ProviderError'
|
|
@@ -47,6 +51,7 @@ export class ProviderError extends Error {
|
|
|
47
51
|
this.provider = provider
|
|
48
52
|
this.model = model
|
|
49
53
|
this.raw = raw
|
|
54
|
+
this.retryAfter = retryAfter
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
|
|
@@ -83,21 +88,63 @@ export class InputError extends Error {
|
|
|
83
88
|
*/
|
|
84
89
|
export const PROVIDER_ERROR_STATUSES = new Set([429, 500, 502, 503, 504])
|
|
85
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Parses Retry-After header value to milliseconds
|
|
93
|
+
* @param {string} value - Retry-After header value (seconds or HTTP date)
|
|
94
|
+
* @returns {number|undefined} Milliseconds to wait, or undefined if unparseable
|
|
95
|
+
*/
|
|
96
|
+
const parseRetryAfter = (value) => {
|
|
97
|
+
if (!value) return undefined
|
|
98
|
+
|
|
99
|
+
// Try parsing as seconds (number)
|
|
100
|
+
const seconds = parseInt(value, 10)
|
|
101
|
+
if (!isNaN(seconds)) {
|
|
102
|
+
return seconds * 1000 // Return milliseconds
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Try parsing as HTTP date
|
|
106
|
+
const date = new Date(value)
|
|
107
|
+
if (!isNaN(date.getTime())) {
|
|
108
|
+
return date.getTime() - Date.now()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
/**
|
|
87
115
|
* Classifies an HTTP response into ProviderError or InputError and throws it.
|
|
88
116
|
*
|
|
89
117
|
* @param {Response} res
|
|
90
118
|
* @param {string} provider
|
|
91
119
|
* @param {string} model
|
|
120
|
+
* @param {import('./logger.js').Logger} logger
|
|
92
121
|
* @returns {Promise<never>}
|
|
93
122
|
*/
|
|
94
|
-
export const throwHttpError = async (res, provider, model) => {
|
|
123
|
+
export const throwHttpError = async (res, provider, model, logger = getLogger()) => {
|
|
95
124
|
const raw = await res.text()
|
|
125
|
+
const sanitizedRaw = sanitizeForLogging(raw)
|
|
126
|
+
const retryAfterHeader = res.headers.get('retry-after')
|
|
127
|
+
|
|
96
128
|
const meta = {
|
|
97
|
-
status: res.status,
|
|
129
|
+
status: res.status,
|
|
130
|
+
provider,
|
|
131
|
+
model,
|
|
132
|
+
raw: sanitizedRaw,
|
|
133
|
+
retryAfter: retryAfterHeader ? parseRetryAfter(retryAfterHeader) : undefined,
|
|
98
134
|
}
|
|
99
|
-
const message = `${
|
|
100
|
-
|
|
135
|
+
const message = `${model} responded with HTTP ${res.status}`
|
|
136
|
+
|
|
137
|
+
// Only log if status is not a client error (avoid logging bad API keys, etc.)
|
|
138
|
+
if (res.status >= 500 || res.status === 429) {
|
|
139
|
+
logger.error(`[ai-client] ${message}`)
|
|
140
|
+
if (sanitizedRaw) {
|
|
141
|
+
logger.error(sanitizedRaw)
|
|
142
|
+
}
|
|
143
|
+
if (meta.retryAfter) {
|
|
144
|
+
logger.error(`[ai-client] Retry-After: ${meta.retryAfter}ms`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
101
148
|
if (PROVIDER_ERROR_STATUSES.has(res.status)) {
|
|
102
149
|
throw new ProviderError(message, meta)
|
|
103
150
|
}
|
package/src/index.js
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
54
|
import {
|
|
55
|
-
getModel,
|
|
55
|
+
getModel, createRegistry,
|
|
56
56
|
} from './registry.js'
|
|
57
57
|
import { normalizeConfig } from './config.js'
|
|
58
58
|
import { coerceConfig } from './coerce.js'
|
|
@@ -61,14 +61,43 @@ import {
|
|
|
61
61
|
ProviderError, InputError, throwHttpError,
|
|
62
62
|
} from './errors.js'
|
|
63
63
|
import { validateAskOptions } from './validation.js'
|
|
64
|
+
import { getLogger, setLogger, noopLogger } from './logger.js'
|
|
65
|
+
import { validateApiKey } from './security.js'
|
|
64
66
|
|
|
65
67
|
export {
|
|
66
68
|
ProviderError, InputError,
|
|
69
|
+
setLogger, noopLogger, getLogger,
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
export { addModels, setModels, listModels, createRegistry } from './registry.js'
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {Object} HookContext
|
|
75
|
+
* @property {string} model - Model identifier
|
|
76
|
+
* @property {string} provider - Provider ID
|
|
77
|
+
* @property {string} url - Request URL
|
|
78
|
+
* @property {Record<string, string>} headers - Request headers
|
|
79
|
+
* @property {Record<string, unknown>} body - Request body
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @typedef {Object} ResponseHookContext
|
|
84
|
+
* @property {string} model - Model identifier
|
|
85
|
+
* @property {string} provider - Provider ID
|
|
86
|
+
* @property {string} url - Request URL
|
|
87
|
+
* @property {Record<string, string>} headers - Request headers
|
|
88
|
+
* @property {Record<string, unknown>} body - Request body
|
|
89
|
+
* @property {number} status - Response status code
|
|
90
|
+
* @property {unknown} data - Response data
|
|
91
|
+
* @property {number} duration - Request duration in milliseconds
|
|
92
|
+
*/
|
|
93
|
+
|
|
69
94
|
/**
|
|
70
95
|
* @typedef {Object} AiOptions
|
|
71
96
|
* @property {string} [gatewayUrl] - Optional AI gateway URL override
|
|
97
|
+
* @property {number} [timeout] - Request timeout in milliseconds (default: 30000)
|
|
98
|
+
* @property {import('./models.js').ModelRecord[]} [models] - Custom model registry
|
|
99
|
+
* @property {(context: HookContext) => void | Promise<void>} [onRequest] - Hook called before each request
|
|
100
|
+
* @property {(context: ResponseHookContext) => void | Promise<void>} [onResponse] - Hook called after each response
|
|
72
101
|
*/
|
|
73
102
|
|
|
74
103
|
/**
|
|
@@ -110,7 +139,7 @@ export {
|
|
|
110
139
|
* @returns {import('./config.js').GenerationConfig}
|
|
111
140
|
*/
|
|
112
141
|
const extractGenConfig = (params) => {
|
|
113
|
-
const keys = ['temperature', 'maxTokens', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty']
|
|
142
|
+
const keys = ['temperature', 'maxTokens', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'stop', 'seed']
|
|
114
143
|
return Object.fromEntries(
|
|
115
144
|
keys.filter((k) => params[k] !== undefined).map((k) => [k, params[k]])
|
|
116
145
|
)
|
|
@@ -126,7 +155,9 @@ const extractGenConfig = (params) => {
|
|
|
126
155
|
const calcCost = (usage, record) => {
|
|
127
156
|
const M = 1_000_000
|
|
128
157
|
const inputCost = (usage.inputTokens / M) * record.input_price
|
|
129
|
-
|
|
158
|
+
// Don't add reasoningTokens - they're already included in outputTokens
|
|
159
|
+
// reasoningTokens is for informational/tracking purposes only
|
|
160
|
+
const outputCost = (usage.outputTokens / M) * record.output_price
|
|
130
161
|
const cacheCost = (usage.cacheTokens / M) * record.cache_price
|
|
131
162
|
|
|
132
163
|
// Round to 8 decimal places to avoid floating point noise
|
|
@@ -144,21 +175,34 @@ const calcCost = (usage, record) => {
|
|
|
144
175
|
* @throws {ProviderError} On 429 / 5xx — safe to retry or fallback
|
|
145
176
|
* @throws {InputError} On 4xx — do not retry, fix the input
|
|
146
177
|
*/
|
|
147
|
-
const callModel = async (modelId, params, gatewayUrl) => {
|
|
178
|
+
const callModel = async (modelId, params, gatewayUrl, registry = null, timeout = 30000, hooks = {}) => {
|
|
179
|
+
const logger = getLogger()
|
|
180
|
+
const { onRequest, onResponse } = hooks
|
|
181
|
+
|
|
182
|
+
// Use provided registry instance or fall back to global getModel
|
|
183
|
+
const modelLookup = registry ? registry.getModel : getModel
|
|
148
184
|
const {
|
|
149
|
-
record, supportedParams,
|
|
150
|
-
} =
|
|
185
|
+
record, supportedParams, paramOverrides,
|
|
186
|
+
} = modelLookup(modelId)
|
|
151
187
|
const {
|
|
152
188
|
provider: providerId, name: modelName,
|
|
153
189
|
} = record
|
|
154
190
|
|
|
155
191
|
const { apikey } = params
|
|
192
|
+
|
|
193
|
+
// Validate API key before making request
|
|
194
|
+
validateApiKey(apikey, providerId, logger)
|
|
195
|
+
|
|
156
196
|
const adapter = getAdapter(providerId)
|
|
157
197
|
|
|
158
198
|
const genConfig = extractGenConfig(params)
|
|
159
199
|
|
|
160
200
|
// Coerce values to provider's acceptable ranges (clamp, don't throw)
|
|
161
|
-
|
|
201
|
+
// Pass model-specific param overrides
|
|
202
|
+
const { coerced } = coerceConfig(genConfig, providerId, {
|
|
203
|
+
modelId,
|
|
204
|
+
overrides: paramOverrides,
|
|
205
|
+
})
|
|
162
206
|
|
|
163
207
|
// Normalize to wire format
|
|
164
208
|
const normalizedConfig = normalizeConfig(coerced, providerId, supportedParams, modelId)
|
|
@@ -177,30 +221,79 @@ const callModel = async (modelId, params, gatewayUrl) => {
|
|
|
177
221
|
},
|
|
178
222
|
]
|
|
179
223
|
|
|
180
|
-
const url =
|
|
224
|
+
const url = adapter.url(modelName, apikey, gatewayUrl)
|
|
225
|
+
const requestHeaders = adapter.headers(apikey)
|
|
181
226
|
const body = adapter.buildBody(modelName, messageList, normalizedConfig, providerOptions)
|
|
182
227
|
|
|
228
|
+
// Invoke onRequest hook
|
|
229
|
+
if (onRequest) {
|
|
230
|
+
await onRequest({
|
|
231
|
+
model: modelId,
|
|
232
|
+
provider: providerId,
|
|
233
|
+
url,
|
|
234
|
+
headers: requestHeaders,
|
|
235
|
+
body,
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
183
239
|
let res
|
|
240
|
+
const controller = new AbortController()
|
|
241
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
242
|
+
const startTime = Date.now()
|
|
243
|
+
|
|
184
244
|
try {
|
|
185
245
|
res = await fetch(url, {
|
|
186
246
|
method: 'POST',
|
|
187
|
-
headers:
|
|
247
|
+
headers: requestHeaders,
|
|
188
248
|
body: JSON.stringify(body),
|
|
249
|
+
signal: controller.signal,
|
|
189
250
|
})
|
|
190
251
|
} catch (networkErr) {
|
|
252
|
+
clearTimeout(timeoutId)
|
|
253
|
+
|
|
191
254
|
// Network-level failure (DNS, connection refused) — treat as provider error
|
|
192
|
-
|
|
255
|
+
logger.warn(
|
|
256
|
+
`[ai-client] Network error calling ${providerId}/${modelId}: ${networkErr.message}`
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if (networkErr.name === 'AbortError') {
|
|
260
|
+
throw new ProviderError(`Request timeout after ${timeout}ms`, {
|
|
261
|
+
status: 408,
|
|
262
|
+
provider: providerId,
|
|
263
|
+
model: modelId,
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
throw new ProviderError(`Network error calling ${providerId}/${modelId}`, {
|
|
193
268
|
status: 0,
|
|
194
269
|
provider: providerId,
|
|
195
270
|
model: modelId,
|
|
196
271
|
})
|
|
197
272
|
}
|
|
198
273
|
|
|
274
|
+
clearTimeout(timeoutId)
|
|
275
|
+
|
|
199
276
|
if (!res.ok) {
|
|
200
|
-
await throwHttpError(res, providerId, modelId)
|
|
277
|
+
await throwHttpError(res, providerId, modelId, logger)
|
|
201
278
|
}
|
|
202
279
|
|
|
203
280
|
const data = await res.json()
|
|
281
|
+
const duration = Date.now() - startTime
|
|
282
|
+
|
|
283
|
+
// Invoke onResponse hook
|
|
284
|
+
if (onResponse) {
|
|
285
|
+
await onResponse({
|
|
286
|
+
model: modelId,
|
|
287
|
+
provider: providerId,
|
|
288
|
+
url,
|
|
289
|
+
headers: requestHeaders,
|
|
290
|
+
body,
|
|
291
|
+
status: res.status,
|
|
292
|
+
data,
|
|
293
|
+
duration,
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
204
297
|
const rawUsage = adapter.extractUsage(data)
|
|
205
298
|
|
|
206
299
|
/** @type {Usage} */
|
|
@@ -228,7 +321,11 @@ const callModel = async (modelId, params, gatewayUrl) => {
|
|
|
228
321
|
* @returns {{ ask: (params: AskParams) => Promise<AskResult>, listModels: () => import('./registry.js').ModelRecord[] }}
|
|
229
322
|
*/
|
|
230
323
|
export const createAi = (opts = {}) => {
|
|
231
|
-
const { gatewayUrl } = opts
|
|
324
|
+
const { gatewayUrl, models, timeout, onRequest, onResponse } = opts
|
|
325
|
+
// Create isolated registry instance for this AI client
|
|
326
|
+
const registry = models
|
|
327
|
+
? createRegistry(models)
|
|
328
|
+
: createRegistry()
|
|
232
329
|
|
|
233
330
|
/**
|
|
234
331
|
* Sends a text generation request, with optional fallback chain.
|
|
@@ -240,6 +337,8 @@ export const createAi = (opts = {}) => {
|
|
|
240
337
|
* @throws {InputError} Immediately, without trying fallbacks
|
|
241
338
|
*/
|
|
242
339
|
const ask = async (params) => {
|
|
340
|
+
const logger = getLogger()
|
|
341
|
+
|
|
243
342
|
// Validate input structure and types
|
|
244
343
|
try {
|
|
245
344
|
validateAskOptions(params)
|
|
@@ -254,17 +353,18 @@ export const createAi = (opts = {}) => {
|
|
|
254
353
|
|
|
255
354
|
const chain = [params.model, ...(params.fallbacks ?? [])]
|
|
256
355
|
let lastProviderError
|
|
356
|
+
const hooks = { onRequest, onResponse }
|
|
257
357
|
|
|
258
358
|
for (const modelId of chain) {
|
|
259
359
|
try {
|
|
260
|
-
return await callModel(modelId, params, gatewayUrl)
|
|
360
|
+
return await callModel(modelId, params, gatewayUrl, registry, timeout, hooks)
|
|
261
361
|
} catch (err) {
|
|
262
362
|
if (err instanceof InputError) {
|
|
263
363
|
// Input errors are not fallback-able — rethrow immediately
|
|
264
364
|
throw err
|
|
265
365
|
}
|
|
266
366
|
// ProviderError — log and try next model in chain
|
|
267
|
-
|
|
367
|
+
logger.warn(
|
|
268
368
|
`[ai-client] ${err.message}. ${modelId === chain.at(-1) ? 'No more fallbacks.' : 'Trying next fallback...'}`
|
|
269
369
|
)
|
|
270
370
|
lastProviderError = err
|
|
@@ -275,8 +375,8 @@ export const createAi = (opts = {}) => {
|
|
|
275
375
|
}
|
|
276
376
|
|
|
277
377
|
return {
|
|
278
|
-
ask,
|
|
378
|
+
ask,
|
|
379
|
+
listModels: () => registry.listModels(),
|
|
380
|
+
addModels: (m) => registry.addModels(m),
|
|
279
381
|
}
|
|
280
382
|
}
|
|
281
|
-
|
|
282
|
-
export { addModels, setModels, listModels }
|