@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/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, provider, model, raw,
129
+ status: res.status,
130
+ provider,
131
+ model,
132
+ raw: sanitizedRaw,
133
+ retryAfter: retryAfterHeader ? parseRetryAfter(retryAfterHeader) : undefined,
98
134
  }
99
- const message = `${provider}/${model} responded with HTTP ${res.status}`
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, listModels, setModels, addModels,
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
- const outputCost = ((usage.outputTokens + usage.reasoningTokens) / M) * record.output_price
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
- } = getModel(modelId)
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
- const coerced = coerceConfig(genConfig, providerId)
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 = gatewayUrl ?? adapter.url(modelName, apikey)
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: adapter.headers(apikey),
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
- throw new ProviderError(`Network error calling ${providerId}/${modelId}: ${networkErr.message}`, {
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
- console.warn(
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, listModels,
378
+ ask,
379
+ listModels: () => registry.listModels(),
380
+ addModels: (m) => registry.addModels(m),
279
381
  }
280
382
  }
281
-
282
- export { addModels, setModels, listModels }