@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
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
|
-
* @
|
|
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
|
|
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
|
|
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
|
}
|