@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/src/coerce.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * with a console.warn for visibility.
7
7
  *
8
8
  * Uses the merged WIRE_KEYS config from config.js for range information.
9
+ * Also supports model-specific overrides via the overrides parameter.
9
10
  */
10
11
 
11
12
  import { getWireMap } from './config.js'
@@ -14,27 +15,82 @@ import { getWireMap } from './config.js'
14
15
  * @typedef {import('./registry.js').ProviderId} ProviderId
15
16
  */
16
17
 
18
+ /**
19
+ * @typedef {Object} ParamOverride
20
+ * @property {number} [fixedValue] - Force param to this value
21
+ * @property {number[]} [supportedValues] - Only allow these discrete values
22
+ * @property {{min: number, max: number}} [range] - Override the default range
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} CoerceOptions
27
+ * @property {string} modelId - Model identifier for override lookup
28
+ * @property {Record<string, ParamOverride>} [overrides] - Model-specific param overrides
29
+ */
30
+
17
31
  const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
18
32
 
19
33
  /**
20
34
  * Coerce config values to provider's acceptable ranges.
21
- * Logs warnings for clamped values.
35
+ * Logs warnings for clamped or dropped values.
22
36
  *
23
37
  * @param {Record<string, unknown>} config
24
38
  * @param {ProviderId} providerId
25
- * @returns {Record<string, unknown>}
39
+ * @param {CoerceOptions} [options]
40
+ * @returns {{ coerced: Record<string, unknown>, dropped: string[] }}
26
41
  */
27
- export const coerceConfig = (config, providerId) => {
42
+ export const coerceConfig = (config, providerId, options = {}) => {
43
+ const { modelId, overrides = {} } = options
28
44
  const wireMap = getWireMap(providerId)
29
45
  if (!wireMap) {
30
- return config
46
+ return { coerced: config, dropped: [] }
31
47
  }
32
48
 
33
49
  const result = { ...config }
50
+ const dropped = []
34
51
 
35
52
  for (const [key, value] of Object.entries(config)) {
36
53
  const descriptor = wireMap[key]
37
- const range = descriptor?.range
54
+ const override = overrides[key]
55
+ if (!descriptor && !override) {
56
+ continue
57
+ }
58
+
59
+ // Merge descriptor and override (override takes precedence)
60
+ const effectiveDescriptor = {
61
+ ...descriptor,
62
+ ...override,
63
+ range: override?.range || descriptor?.range,
64
+ }
65
+
66
+ // Handle fixedValue: force param to specific value
67
+ if (effectiveDescriptor.fixedValue !== undefined && typeof value === 'number') {
68
+ if (value !== effectiveDescriptor.fixedValue) {
69
+ console.warn(
70
+ `[ai-client] "${key}" value ${value} not supported by model "${modelId}", forced to ${effectiveDescriptor.fixedValue}`
71
+ )
72
+ result[key] = effectiveDescriptor.fixedValue
73
+ }
74
+ continue
75
+ }
76
+
77
+ // Handle supportedValues: only allow discrete values
78
+ if (effectiveDescriptor.supportedValues?.length && typeof value === 'number') {
79
+ if (!effectiveDescriptor.supportedValues.includes(value)) {
80
+ // Clamp to nearest supported value
81
+ const nearest = effectiveDescriptor.supportedValues.reduce((prev, curr) =>
82
+ Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev
83
+ )
84
+ console.warn(
85
+ `[ai-client] "${key}" value ${value} not supported by model "${modelId}", clamped to nearest allowed ${nearest}`
86
+ )
87
+ result[key] = nearest
88
+ }
89
+ continue
90
+ }
91
+
92
+ // Handle range clamping (existing behavior)
93
+ const range = effectiveDescriptor.range
38
94
  if (!range || typeof value !== 'number') {
39
95
  continue
40
96
  }
@@ -48,5 +104,21 @@ export const coerceConfig = (config, providerId) => {
48
104
  }
49
105
  }
50
106
 
107
+ return { coerced: result, dropped }
108
+ }
109
+
110
+ /**
111
+ * Drop params that are not supported by a model.
112
+ * Used when provider returns an "unsupported_value" error.
113
+ *
114
+ * @param {Record<string, unknown>} config
115
+ * @param {string[]} paramsToDrop
116
+ * @returns {Record<string, unknown>}
117
+ */
118
+ export const dropParams = (config, paramsToDrop) => {
119
+ const result = { ...config }
120
+ for (const param of paramsToDrop) {
121
+ delete result[param]
122
+ }
51
123
  return result
52
124
  }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * @fileoverview Tests for coerce module.
3
+ */
4
+
5
+ import {
6
+ describe, it,
7
+ } from 'node:test'
8
+ import assert from 'node:assert'
9
+ import { coerceConfig } from '../src/coerce.js'
10
+
11
+ describe('coerceConfig', () => {
12
+ describe('openai', () => {
13
+ it('should clamp temperature to valid range [0, 2]', () => {
14
+ const config = { temperature: 3 }
15
+ const { coerced } = coerceConfig(config, 'openai')
16
+ assert.strictEqual(coerced.temperature, 2)
17
+ })
18
+
19
+ it('should clamp temperature below range', () => {
20
+ const config = { temperature: -1 }
21
+ const { coerced } = coerceConfig(config, 'openai')
22
+ assert.strictEqual(coerced.temperature, 0)
23
+ })
24
+
25
+ it('should not clamp temperature within range', () => {
26
+ const config = { temperature: 1 }
27
+ const { coerced } = coerceConfig(config, 'openai')
28
+ assert.strictEqual(coerced.temperature, 1)
29
+ })
30
+
31
+ it('should clamp topP to valid range [0, 1]', () => {
32
+ const config = { topP: 1.5 }
33
+ const { coerced } = coerceConfig(config, 'openai')
34
+ assert.strictEqual(coerced.topP, 1)
35
+ })
36
+
37
+ it('should clamp frequencyPenalty to valid range [-2, 2]', () => {
38
+ const config = { frequencyPenalty: 3 }
39
+ const { coerced } = coerceConfig(config, 'openai')
40
+ assert.strictEqual(coerced.frequencyPenalty, 2)
41
+ })
42
+
43
+ it('should clamp presencePenalty to valid range [-2, 2]', () => {
44
+ const config = { presencePenalty: -3 }
45
+ const { coerced } = coerceConfig(config, 'openai')
46
+ assert.strictEqual(coerced.presencePenalty, -2)
47
+ })
48
+ })
49
+
50
+ describe('anthropic', () => {
51
+ it('should clamp temperature to valid range [0, 1]', () => {
52
+ const config = { temperature: 1.5 }
53
+ const { coerced } = coerceConfig(config, 'anthropic')
54
+ assert.strictEqual(coerced.temperature, 1)
55
+ })
56
+
57
+ it('should clamp topK to valid range [1, 100]', () => {
58
+ const config = { topK: 150 }
59
+ const { coerced } = coerceConfig(config, 'anthropic')
60
+ assert.strictEqual(coerced.topK, 100)
61
+ })
62
+
63
+ it('should clamp topK below range', () => {
64
+ const config = { topK: 0 }
65
+ const { coerced } = coerceConfig(config, 'anthropic')
66
+ assert.strictEqual(coerced.topK, 1)
67
+ })
68
+ })
69
+
70
+ describe('google', () => {
71
+ it('should clamp temperature to valid range [0, 2]', () => {
72
+ const config = { temperature: 3 }
73
+ const { coerced } = coerceConfig(config, 'google')
74
+ assert.strictEqual(coerced.temperature, 2)
75
+ })
76
+
77
+ it('should clamp topK to valid range [1, 100]', () => {
78
+ const config = { topK: 200 }
79
+ const { coerced } = coerceConfig(config, 'google')
80
+ assert.strictEqual(coerced.topK, 100)
81
+ })
82
+ })
83
+
84
+ describe('dashscope', () => {
85
+ it('should clamp temperature to valid range [0, 2]', () => {
86
+ const config = { temperature: 5 }
87
+ const { coerced } = coerceConfig(config, 'dashscope')
88
+ assert.strictEqual(coerced.temperature, 2)
89
+ })
90
+
91
+ it('should clamp topP to valid range [0, 1]', () => {
92
+ const config = { topP: 2 }
93
+ const { coerced } = coerceConfig(config, 'dashscope')
94
+ assert.strictEqual(coerced.topP, 1)
95
+ })
96
+ })
97
+
98
+ describe('deepseek', () => {
99
+ it('should clamp temperature to valid range [0, 2]', () => {
100
+ const config = { temperature: 3 }
101
+ const { coerced } = coerceConfig(config, 'deepseek')
102
+ assert.strictEqual(coerced.temperature, 2)
103
+ })
104
+
105
+ it('should clamp frequencyPenalty to valid range [-2, 2]', () => {
106
+ const config = { frequencyPenalty: 5 }
107
+ const { coerced } = coerceConfig(config, 'deepseek')
108
+ assert.strictEqual(coerced.frequencyPenalty, 2)
109
+ })
110
+ })
111
+
112
+ describe('edge cases', () => {
113
+ it('should return config unchanged for unknown provider', () => {
114
+ const config = { temperature: 100 }
115
+ const { coerced } = coerceConfig(config, 'unknown')
116
+ assert.strictEqual(coerced.temperature, 100)
117
+ })
118
+
119
+ it('should not clamp non-numeric values', () => {
120
+ const config = { temperature: 'hot' }
121
+ const { coerced } = coerceConfig(config, 'openai')
122
+ assert.strictEqual(coerced.temperature, 'hot')
123
+ })
124
+
125
+ it('should handle empty config', () => {
126
+ const { coerced, dropped } = coerceConfig({}, 'openai')
127
+ assert.deepStrictEqual({ coerced, dropped }, { coerced: {}, dropped: [] })
128
+ })
129
+
130
+ it('should clamp multiple values at once', () => {
131
+ const config = {
132
+ temperature: 5,
133
+ topP: 2,
134
+ maxTokens: 100,
135
+ }
136
+ const { coerced } = coerceConfig(config, 'openai')
137
+ assert.strictEqual(coerced.temperature, 2)
138
+ assert.strictEqual(coerced.topP, 1)
139
+ assert.strictEqual(coerced.maxTokens, 100) // maxTokens has no range
140
+ })
141
+ })
142
+
143
+ describe('fixedValue overrides', () => {
144
+ it('should force temperature to fixedValue', () => {
145
+ const config = { temperature: 0.5 }
146
+ const { coerced } = coerceConfig(config, 'openai', {
147
+ modelId: 'openai/gpt-5-nano',
148
+ overrides: {
149
+ temperature: { fixedValue: 1 },
150
+ },
151
+ })
152
+ assert.strictEqual(coerced.temperature, 1)
153
+ })
154
+
155
+ it('should not change value if it already matches fixedValue', () => {
156
+ const config = { temperature: 1 }
157
+ const { coerced } = coerceConfig(config, 'openai', {
158
+ modelId: 'openai/gpt-5-nano',
159
+ overrides: {
160
+ temperature: { fixedValue: 1 },
161
+ },
162
+ })
163
+ assert.strictEqual(coerced.temperature, 1)
164
+ })
165
+
166
+ it('should force multiple params to fixed values', () => {
167
+ const config = { temperature: 0.5, topP: 0.8 }
168
+ const { coerced } = coerceConfig(config, 'openai', {
169
+ modelId: 'openai/gpt-5-nano',
170
+ overrides: {
171
+ temperature: { fixedValue: 1 },
172
+ topP: { fixedValue: 1 },
173
+ },
174
+ })
175
+ assert.strictEqual(coerced.temperature, 1)
176
+ assert.strictEqual(coerced.topP, 1)
177
+ })
178
+ })
179
+
180
+ describe('supportedValues (discrete values)', () => {
181
+ it('should clamp to nearest supported value', () => {
182
+ const config = { temperature: 0.3 }
183
+ const { coerced } = coerceConfig(config, 'openai', {
184
+ modelId: 'openai/some-model',
185
+ overrides: {
186
+ temperature: { supportedValues: [0, 0.5, 1] },
187
+ },
188
+ })
189
+ assert.strictEqual(coerced.temperature, 0.5)
190
+ })
191
+
192
+ it('should not change value if it matches a supported value', () => {
193
+ const config = { temperature: 0.5 }
194
+ const { coerced } = coerceConfig(config, 'openai', {
195
+ modelId: 'openai/some-model',
196
+ overrides: {
197
+ temperature: { supportedValues: [0, 0.5, 1] },
198
+ },
199
+ })
200
+ assert.strictEqual(coerced.temperature, 0.5)
201
+ })
202
+ })
203
+
204
+ describe('range overrides', () => {
205
+ it('should use overridden range instead of default', () => {
206
+ const config = { temperature: 3 }
207
+ const { coerced } = coerceConfig(config, 'openai', {
208
+ modelId: 'openai/some-model',
209
+ overrides: {
210
+ temperature: { range: { min: 0, max: 0.5 } },
211
+ },
212
+ })
213
+ assert.strictEqual(coerced.temperature, 0.5)
214
+ })
215
+ })
216
+ })
package/src/config.js CHANGED
@@ -21,6 +21,7 @@
21
21
  * @property {number} [topK]
22
22
  * @property {number} [frequencyPenalty]
23
23
  * @property {number} [presencePenalty]
24
+ * @property {number} [randomSeed]
24
25
  */
25
26
 
26
27
  /**
@@ -34,6 +35,8 @@
34
35
  * @property {string} wireKey
35
36
  * @property {'root'|'generationConfig'} [scope] - Google nests some params
36
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
37
40
  */
38
41
 
39
42
  /**
@@ -50,6 +53,8 @@ const WIRE_KEYS = {
50
53
  wireKey: 'temperature', range: {
51
54
  min: 0, max: 2,
52
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
53
58
  },
54
59
  topP: {
55
60
  wireKey: 'top_p', range: {
@@ -66,6 +71,8 @@ const WIRE_KEYS = {
66
71
  min: -2, max: 2,
67
72
  },
68
73
  },
74
+ stop: { wireKey: 'stop' },
75
+ seed: { wireKey: 'seed' },
69
76
  // https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create
70
77
  },
71
78
  anthropic: {
@@ -85,6 +92,7 @@ const WIRE_KEYS = {
85
92
  min: 1, max: 100,
86
93
  },
87
94
  },
95
+ stop: { wireKey: 'stop_sequences' },
88
96
  },
89
97
  google: {
90
98
  temperature: {
@@ -105,6 +113,8 @@ const WIRE_KEYS = {
105
113
  min: 1, max: 100,
106
114
  },
107
115
  },
116
+ stop: { wireKey: 'stopSequences', scope: 'generationConfig' },
117
+ seed: { wireKey: 'seed', scope: 'generationConfig' },
108
118
  },
109
119
  dashscope: {
110
120
  temperature: {
@@ -123,6 +133,7 @@ const WIRE_KEYS = {
123
133
  min: 1, max: 100,
124
134
  },
125
135
  },
136
+ stop: { wireKey: 'stop' },
126
137
  },
127
138
  deepseek: {
128
139
  temperature: {
@@ -146,6 +157,38 @@ const WIRE_KEYS = {
146
157
  min: -2, max: 2,
147
158
  },
148
159
  },
160
+ stop: { wireKey: 'stop' },
161
+ },
162
+ mistral: {
163
+ temperature: {
164
+ wireKey: 'temperature', range: {
165
+ min: 0, max: 2,
166
+ },
167
+ },
168
+ maxTokens: { wireKey: 'max_tokens' },
169
+ topP: {
170
+ wireKey: 'top_p', range: {
171
+ min: 0, max: 1,
172
+ },
173
+ },
174
+ randomSeed: { wireKey: 'random_seed' },
175
+ stop: { wireKey: 'stop' },
176
+ },
177
+ ollama: {
178
+ temperature: {
179
+ wireKey: 'temperature', range: {
180
+ min: 0, max: 2,
181
+ },
182
+ },
183
+ maxTokens: { wireKey: 'num_predict' },
184
+ topP: {
185
+ wireKey: 'top_p', range: {
186
+ min: 0, max: 1,
187
+ },
188
+ },
189
+ topK: { wireKey: 'top_k' },
190
+ seed: { wireKey: 'seed' },
191
+ stop: { wireKey: 'stop' },
149
192
  },
150
193
  }
151
194
 
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @fileoverview Tests for config module.
3
+ */
4
+
5
+ import {
6
+ describe, it,
7
+ } from 'node:test'
8
+ import assert from 'node:assert'
9
+ import {
10
+ normalizeConfig, getWireMap,
11
+ } from '../src/config.js'
12
+
13
+ describe('config', () => {
14
+ describe('getWireMap', () => {
15
+ it('should return wire map for openai', () => {
16
+ const wireMap = getWireMap('openai')
17
+ assert.ok(wireMap)
18
+ assert.ok(wireMap.temperature)
19
+ assert.strictEqual(wireMap.temperature.wireKey, 'temperature')
20
+ })
21
+
22
+ it('should return wire map for anthropic', () => {
23
+ const wireMap = getWireMap('anthropic')
24
+ assert.ok(wireMap)
25
+ assert.ok(wireMap.temperature)
26
+ assert.strictEqual(wireMap.temperature.wireKey, 'temperature')
27
+ })
28
+
29
+ it('should return wire map for google', () => {
30
+ const wireMap = getWireMap('google')
31
+ assert.ok(wireMap)
32
+ assert.ok(wireMap.temperature)
33
+ assert.strictEqual(wireMap.temperature.wireKey, 'temperature')
34
+ assert.strictEqual(wireMap.temperature.scope, 'generationConfig')
35
+ })
36
+
37
+ it('should return wire map for dashscope', () => {
38
+ const wireMap = getWireMap('dashscope')
39
+ assert.ok(wireMap)
40
+ assert.ok(wireMap.temperature)
41
+ assert.strictEqual(wireMap.temperature.wireKey, 'temperature')
42
+ })
43
+
44
+ it('should return wire map for deepseek', () => {
45
+ const wireMap = getWireMap('deepseek')
46
+ assert.ok(wireMap)
47
+ assert.ok(wireMap.temperature)
48
+ assert.strictEqual(wireMap.temperature.wireKey, 'temperature')
49
+ })
50
+
51
+ it('should return undefined for unknown provider', () => {
52
+ const wireMap = getWireMap('unknown')
53
+ assert.strictEqual(wireMap, undefined)
54
+ })
55
+ })
56
+
57
+ describe('normalizeConfig', () => {
58
+ it('should normalize openai config', () => {
59
+ const config = {
60
+ temperature: 0.5,
61
+ maxTokens: 100,
62
+ topP: 0.9,
63
+ }
64
+ const supportedParams = ['temperature', 'maxTokens', 'topP']
65
+ const result = normalizeConfig(config, 'openai', supportedParams, 'gpt-4o')
66
+
67
+ assert.strictEqual(result.temperature, 0.5)
68
+ assert.strictEqual(result.max_completion_tokens, 100)
69
+ assert.strictEqual(result.top_p, 0.9)
70
+ })
71
+
72
+ it('should normalize anthropic config', () => {
73
+ const config = {
74
+ temperature: 0.5,
75
+ maxTokens: 100,
76
+ topK: 50,
77
+ }
78
+ const supportedParams = ['temperature', 'maxTokens', 'topK']
79
+ const result = normalizeConfig(config, 'anthropic', supportedParams, 'claude-sonnet')
80
+
81
+ assert.strictEqual(result.temperature, 0.5)
82
+ assert.strictEqual(result.max_tokens, 100)
83
+ assert.strictEqual(result.top_k, 50)
84
+ })
85
+
86
+ it('should normalize google config with generationConfig nesting', () => {
87
+ const config = {
88
+ temperature: 0.5,
89
+ maxTokens: 100,
90
+ topP: 0.9,
91
+ }
92
+ const supportedParams = ['temperature', 'maxTokens', 'topP']
93
+ const result = normalizeConfig(config, 'google', supportedParams, 'gemini-2.0-flash')
94
+
95
+ assert.strictEqual(result.temperature, undefined)
96
+ assert.strictEqual(result.maxTokens, undefined)
97
+ assert.strictEqual(result.topP, undefined)
98
+ assert.ok(result.generationConfig)
99
+ assert.strictEqual(result.generationConfig.temperature, 0.5)
100
+ assert.strictEqual(result.generationConfig.maxOutputTokens, 100)
101
+ assert.strictEqual(result.generationConfig.topP, 0.9)
102
+ })
103
+
104
+ it('should skip unsupported params', () => {
105
+ const config = {
106
+ temperature: 0.5,
107
+ topK: 50, // openai doesn't support topK
108
+ }
109
+ const supportedParams = ['temperature']
110
+ const result = normalizeConfig(config, 'openai', supportedParams, 'gpt-4o')
111
+
112
+ assert.strictEqual(result.temperature, 0.5)
113
+ assert.strictEqual(result.top_k, undefined)
114
+ })
115
+
116
+ it('should skip null/undefined values', () => {
117
+ const config = {
118
+ temperature: 0.5,
119
+ maxTokens: null,
120
+ topP: undefined,
121
+ }
122
+ const supportedParams = ['temperature', 'maxTokens', 'topP']
123
+ const result = normalizeConfig(config, 'openai', supportedParams, 'gpt-4o')
124
+
125
+ assert.strictEqual(result.temperature, 0.5)
126
+ assert.strictEqual(result.max_completion_tokens, undefined)
127
+ assert.strictEqual(result.top_p, undefined)
128
+ })
129
+
130
+ it('should return empty object when no config provided', () => {
131
+ const result = normalizeConfig({}, 'openai', ['temperature'], 'gpt-4o')
132
+ assert.deepStrictEqual(result, {})
133
+ })
134
+
135
+ it('should return empty object when no supported params match', () => {
136
+ const config = { unknownParam: 123 }
137
+ const supportedParams = ['temperature']
138
+ const result = normalizeConfig(config, 'openai', supportedParams, 'gpt-4o')
139
+ assert.deepStrictEqual(result, {})
140
+ })
141
+ })
142
+ })
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.
@@ -35,11 +38,12 @@ export class ProviderError extends Error {
35
38
  * @param {object} meta
36
39
  * @param {number} meta.status - HTTP status code
37
40
  * @param {string} meta.provider - Provider ID
38
- * @param {string} meta.model - Model ID that was called
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
  }