@startsimpli/llm 0.4.19 → 0.4.21
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/package.json +1 -1
- package/src/__tests__/openai-provider.test.ts +97 -71
- package/src/providers/openai.ts +154 -83
package/package.json
CHANGED
|
@@ -64,42 +64,111 @@ afterEach(() => {
|
|
|
64
64
|
vi.unstubAllGlobals();
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
/** Make the next fetch answer 400 with an OpenAI error body (formats captured from the API 2026-10-02). */
|
|
68
|
+
function reject(error: { message: string; code?: string | null; param?: string | null }) {
|
|
69
|
+
fetchMock.mockImplementationOnce(async (_url: unknown, init: RequestInit) => {
|
|
70
|
+
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
71
|
+
return { ok: false, status: 400, json: async () => ({ error: { type: 'invalid_request_error', ...error } }) } as unknown as Response;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('any model the account can call works: no model-name rules', () => {
|
|
76
|
+
it.each(['gpt-4o', 'gpt-4o-mini', 'ft:gpt-4.1-mini-2025-04-14:org:x:1', 'gpt-5.5', 'gpt-6.1-sol', 'some-model-nobody-has-heard-of'])(
|
|
77
|
+
'%s gets max_completion_tokens (accepted by every chat model) and never max_tokens',
|
|
70
78
|
async (model) => {
|
|
71
79
|
await provider(model).generate('u', 's');
|
|
72
|
-
|
|
73
80
|
expect(sent[0]!.max_completion_tokens).toBeGreaterThan(0);
|
|
74
81
|
expect(sent[0]!.max_tokens).toBeUndefined();
|
|
75
82
|
},
|
|
76
83
|
);
|
|
77
84
|
|
|
78
|
-
it('
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
it('sends the configured temperature to every model until one refuses it', async () => {
|
|
86
|
+
await provider('gpt-6-sol').generate('u', 's');
|
|
87
|
+
await provider('gpt-4o').generate('u', 's');
|
|
88
|
+
expect(sent.map((b) => b.temperature)).toEqual([0.2, 0.2]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
82
91
|
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
describe('adapting to what a model refuses', () => {
|
|
93
|
+
it('drops temperature when the model only takes its default, and remembers', async () => {
|
|
94
|
+
const p = provider('gpt-6-sol');
|
|
95
|
+
reject({ code: 'unsupported_value', param: 'temperature', message: "Unsupported value: 'temperature' does not support 0.2 with this model. Only the default (1) value is supported." });
|
|
96
|
+
const res = await p.generate('u', 's');
|
|
97
|
+
expect(res.content).toBe('{"ok":true}');
|
|
98
|
+
expect(sent).toHaveLength(2);
|
|
99
|
+
expect(sent[0]!.temperature).toBe(0.2);
|
|
100
|
+
expect(sent[1]!.temperature).toBeUndefined();
|
|
101
|
+
// second call: no retry, temperature already known to be refused
|
|
102
|
+
await p.generate('u', 's');
|
|
103
|
+
expect(sent).toHaveLength(3);
|
|
104
|
+
expect(sent[2]!.temperature).toBeUndefined();
|
|
85
105
|
});
|
|
86
106
|
|
|
87
|
-
it('
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
it('moves to the nearest supported reasoning effort the API lists', async () => {
|
|
108
|
+
const p = provider('gpt-6.1-sol');
|
|
109
|
+
reject({ code: 'unsupported_value', param: 'reasoning_effort', message: "Unsupported value: 'reasoning_effort' does not support 'none' with this model. Supported values are: 'low', 'medium', 'high', and 'xhigh'." });
|
|
110
|
+
await p.generate('u', 's', { reasoningEffort: 'none', maxTokens: 256 });
|
|
111
|
+
expect(sent[0]!.reasoning_effort).toBe('none');
|
|
112
|
+
expect(sent[1]!.reasoning_effort).toBe('low');
|
|
113
|
+
expect(sent[1]!.max_completion_tokens).toBeGreaterThan(256); // headroom for the effort actually sent
|
|
114
|
+
await p.generate('u', 's', { reasoningEffort: 'none' });
|
|
115
|
+
expect(sent[2]!.reasoning_effort).toBe('low');
|
|
116
|
+
});
|
|
90
117
|
|
|
91
|
-
|
|
92
|
-
|
|
118
|
+
it('drops reasoning_effort for a model that does not reason', async () => {
|
|
119
|
+
const p = provider('gpt-4o');
|
|
120
|
+
reject({ code: null, param: null, message: 'Unrecognized request argument supplied: reasoning_effort' });
|
|
121
|
+
await p.generate('u', 's', { reasoningEffort: 'low', maxTokens: 300 });
|
|
122
|
+
expect(sent[1]!.reasoning_effort).toBeUndefined();
|
|
123
|
+
expect(sent[1]!.max_completion_tokens).toBe(300); // no reasoning, no headroom
|
|
93
124
|
});
|
|
94
125
|
|
|
95
|
-
it('
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
await
|
|
99
|
-
|
|
126
|
+
it('clamps the token cap to what the model says it supports', async () => {
|
|
127
|
+
const p = provider('gpt-4o');
|
|
128
|
+
reject({ code: 'invalid_value', param: 'max_tokens', message: 'max_tokens is too large: 20288. This model supports at most 16384 completion tokens, whereas you provided 20288.' });
|
|
129
|
+
await p.generate('u', 's', { maxTokens: 8000 });
|
|
130
|
+
expect(sent[1]!.max_completion_tokens).toBe(16384);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('does not loop: an error it cannot act on, or the same lesson twice, throws', async () => {
|
|
134
|
+
const p = provider('gpt-6-sol');
|
|
135
|
+
reject({ code: 'invalid_value', param: 'messages', message: 'Invalid messages.' });
|
|
136
|
+
await expect(p.generate('u', 's')).rejects.toThrow();
|
|
137
|
+
expect(sent).toHaveLength(1);
|
|
138
|
+
|
|
139
|
+
const q = provider('gpt-6-sol');
|
|
140
|
+
const tempErr = { code: 'unsupported_value', param: 'temperature', message: "Unsupported value: 'temperature' does not support 0.2 with this model." };
|
|
141
|
+
reject(tempErr);
|
|
142
|
+
reject(tempErr);
|
|
143
|
+
await expect(q.generate('u', 's')).rejects.toThrow();
|
|
144
|
+
expect(sent).toHaveLength(3);
|
|
145
|
+
});
|
|
100
146
|
|
|
101
|
-
|
|
102
|
-
|
|
147
|
+
it('parallel first calls all succeed: a lesson learned by one request applies to the others in flight', async () => {
|
|
148
|
+
// Regression: crochet's assembly pipeline fires its connection calls in
|
|
149
|
+
// parallel. All of them sent 'none' before any answer; the first 400 taught
|
|
150
|
+
// the provider 'low', and the others saw that lesson as already learned and
|
|
151
|
+
// threw instead of retrying (gpt-6-astra: 12 of 12 connections failed).
|
|
152
|
+
const p = provider('gpt-6.1-sol');
|
|
153
|
+
const effortErr = { code: 'unsupported_value', param: 'reasoning_effort', message: "Unsupported value: 'reasoning_effort' does not support 'none' with this model. Supported values are: 'low', 'medium', 'high', and 'xhigh'." };
|
|
154
|
+
reject(effortErr);
|
|
155
|
+
reject(effortErr);
|
|
156
|
+
reject(effortErr);
|
|
157
|
+
const results = await Promise.all([1, 2, 3].map(() => p.generate('u', 's', { reasoningEffort: 'none' })));
|
|
158
|
+
expect(results.every((r) => r.content === '{"ok":true}')).toBe(true);
|
|
159
|
+
expect(sent.slice(3).every((b) => b.reasoning_effort === 'low')).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('adapts on the tool-calling path too', async () => {
|
|
163
|
+
const p = provider('gpt-6-sol');
|
|
164
|
+
reject({ code: 'unsupported_value', param: 'temperature', message: "Unsupported value: 'temperature' does not support 0.2 with this model. Only the default (1) value is supported." });
|
|
165
|
+
fetchMock.mockImplementationOnce(async (_url: unknown, init: RequestInit) => {
|
|
166
|
+
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
167
|
+
return { ok: true, status: 200, json: async () => ({ ...OK_BODY, choices: [{ index: 0, message: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }] }) } as unknown as Response;
|
|
168
|
+
});
|
|
169
|
+
const res = await p.generateWithTools([{ role: 'user', content: 'hi' }], 's', []);
|
|
170
|
+
expect(res.content).toBe('done');
|
|
171
|
+
expect(sent[1]!.temperature).toBeUndefined();
|
|
103
172
|
});
|
|
104
173
|
});
|
|
105
174
|
|
|
@@ -109,24 +178,17 @@ describe('the token-limit parameter', () => {
|
|
|
109
178
|
* none -> 2.3s, 35 completion tokens, 0 reasoning
|
|
110
179
|
* low -> 2.8s, 130 completion tokens, 91 reasoning
|
|
111
180
|
* medium -> 42.8s, 412 completion tokens, 377 reasoning
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* an empty answer 11 times out of 11. Hidden reasoning is billed against
|
|
115
|
-
* max_completion_tokens, so a caller's answer budget must get headroom.
|
|
181
|
+
* Hidden reasoning is billed against max_completion_tokens, so a caller's
|
|
182
|
+
* answer budget gets headroom.
|
|
116
183
|
*/
|
|
117
|
-
describe('reasoning
|
|
118
|
-
it('sends reasoning_effort when one is given,
|
|
184
|
+
describe('reasoning effort and headroom', () => {
|
|
185
|
+
it('sends reasoning_effort when one is given, and omits it otherwise', async () => {
|
|
119
186
|
await provider('gpt-5.5').generate('u', 's', { reasoningEffort: 'low' });
|
|
120
|
-
await provider('gpt-
|
|
187
|
+
await provider('gpt-5.5').generate('u', 's');
|
|
121
188
|
expect(sent[0]!.reasoning_effort).toBe('low');
|
|
122
189
|
expect(sent[1]!.reasoning_effort).toBeUndefined();
|
|
123
190
|
});
|
|
124
191
|
|
|
125
|
-
it('omits reasoning_effort when none is configured (the API default applies)', async () => {
|
|
126
|
-
await provider('gpt-5.5').generate('u', 's');
|
|
127
|
-
expect(sent[0]!.reasoning_effort).toBeUndefined();
|
|
128
|
-
});
|
|
129
|
-
|
|
130
192
|
it('takes the default effort from the provider config', async () => {
|
|
131
193
|
await new OpenAIProvider({ apiKey: 'k', defaultModel: 'gpt-5.5', defaultReasoningEffort: 'low' }).generate('u', 's');
|
|
132
194
|
expect(sent[0]!.reasoning_effort).toBe('low');
|
|
@@ -139,43 +201,7 @@ describe('reasoning (gpt-5 family)', () => {
|
|
|
139
201
|
const [none, low, unset] = sent.map((b) => b.max_completion_tokens!);
|
|
140
202
|
expect(none).toBe(256);
|
|
141
203
|
expect(low).toBeGreaterThanOrEqual(256 + 1000);
|
|
142
|
-
// unset
|
|
143
|
-
expect(unset).toBeGreaterThan(low);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('applies to the tool-calling path too', async () => {
|
|
147
|
-
fetchMock.mockImplementationOnce(async (_url: unknown, init: RequestInit) => {
|
|
148
|
-
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
149
|
-
return { ok: true, status: 200, json: async () => ({ ...OK_BODY, choices: [{ index: 0, message: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }] }) } as unknown as Response;
|
|
150
|
-
});
|
|
151
|
-
await provider('gpt-5.5').generateWithTools([{ role: 'user', content: 'hi' }], 's', [], { maxTokens: 100, reasoningEffort: 'low' });
|
|
152
|
-
expect(sent[0]!.reasoning_effort).toBe('low');
|
|
153
|
-
expect(sent[0]!.max_completion_tokens).toBeGreaterThan(100);
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
describe('temperature', () => {
|
|
158
|
-
it.each(['gpt-5', 'gpt-5.1', 'gpt-5.2', 'gpt-5.3'])(
|
|
159
|
-
'%s is sent temperature 1.0, whatever the deployment configured',
|
|
160
|
-
async (model) => {
|
|
161
|
-
// Base gpt-5 models reject any other value.
|
|
162
|
-
await provider(model).generate('u', 's', { temperature: 0.2 });
|
|
163
|
-
|
|
164
|
-
expect(sent[0]!.temperature).toBe(1.0);
|
|
165
|
-
},
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
it('a FINE-TUNED gpt-5 keeps the configured temperature', async () => {
|
|
169
|
-
// The existing carve-out, preserved: ft: models do accept it.
|
|
170
|
-
await provider('ft:gpt-5.2:acme::abc123').generate('u', 's', { temperature: 0.2 });
|
|
171
|
-
|
|
172
|
-
expect(sent[0]!.temperature).toBe(0.2);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('gpt-4o keeps the configured temperature', async () => {
|
|
176
|
-
await provider('gpt-4o').generate('u', 's', { temperature: 0.2 });
|
|
177
|
-
|
|
178
|
-
expect(sent[0]!.temperature).toBe(0.2);
|
|
204
|
+
expect(unset).toBeGreaterThan(low); // unset = the API default (medium)
|
|
179
205
|
});
|
|
180
206
|
});
|
|
181
207
|
|
package/src/providers/openai.ts
CHANGED
|
@@ -32,43 +32,97 @@ interface OpenAIRequest {
|
|
|
32
32
|
model: string;
|
|
33
33
|
messages: OpenAIMessage[];
|
|
34
34
|
temperature?: number;
|
|
35
|
-
/**
|
|
36
|
-
max_tokens?: number;
|
|
37
|
-
/** The gpt-5 family's replacement for `max_tokens`. */
|
|
35
|
+
/** Accepted by every current chat model; `max_tokens` is rejected by reasoning models. */
|
|
38
36
|
max_completion_tokens?: number;
|
|
39
|
-
/**
|
|
37
|
+
/** Reasoning models only; dropped automatically for models that reject it. */
|
|
40
38
|
reasoning_effort?: ReasoningEffort;
|
|
41
39
|
response_format?: { type: 'json_object' | 'text' };
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
43
|
+
* WHAT A MODEL ACCEPTS IS LEARNED FROM THE API, NOT GUESSED FROM ITS NAME.
|
|
44
|
+
*
|
|
45
|
+
* The provider used to pick parameters by matching the model id (`gpt-5…` got
|
|
46
|
+
* max_completion_tokens and temperature 1.0). Every new family broke that:
|
|
47
|
+
* gpt-6* were treated as legacy and 400'd on every call. Any model the account
|
|
48
|
+
* can call must work, so requests are built in the modern shape and adapted
|
|
49
|
+
* when the API says why it refused (measured 2026-10-02):
|
|
50
|
+
*
|
|
51
|
+
* max_completion_tokens accepted by every chat model tried (gpt-4o,
|
|
52
|
+
* gpt-4o-mini, a gpt-4.1 fine-tune, gpt-5.x, gpt-6.x);
|
|
53
|
+
* `max_tokens` is rejected by all reasoning models.
|
|
54
|
+
* temperature 400 unsupported_value param=temperature on reasoning
|
|
55
|
+
* models ("Only the default (1) value is supported")
|
|
56
|
+
* -> drop it.
|
|
57
|
+
* reasoning_effort non-reasoning models: "Unrecognized request argument
|
|
58
|
+
* supplied: reasoning_effort" -> drop it. Unsupported
|
|
59
|
+
* level: "Supported values are: 'low', 'medium', ..."
|
|
60
|
+
* -> nearest supported at or above the one asked for.
|
|
61
|
+
* token cap 400 invalid_value "This model supports at most N
|
|
62
|
+
* completion tokens" -> clamp to N.
|
|
63
|
+
*
|
|
64
|
+
* Each lesson is remembered per model for the life of the provider, so only the
|
|
65
|
+
* first call to a model pays for the retry.
|
|
49
66
|
*/
|
|
50
|
-
|
|
51
|
-
|
|
67
|
+
interface ModelQuirks {
|
|
68
|
+
noTemperature?: boolean;
|
|
69
|
+
noReasoningEffort?: boolean;
|
|
70
|
+
/** Reasoning efforts this model accepts, when the API has told us. */
|
|
71
|
+
efforts?: ReasoningEffort[];
|
|
72
|
+
/** Largest completion-token cap this model accepts. */
|
|
73
|
+
maxOutput?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const EFFORT_ORDER: ReasoningEffort[] = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'];
|
|
77
|
+
|
|
78
|
+
/** The supported effort closest to the one asked for, preferring more reasoning over less. */
|
|
79
|
+
function nearestEffort(want: ReasoningEffort, supported: ReasoningEffort[]): ReasoningEffort | undefined {
|
|
80
|
+
const rank = EFFORT_ORDER.indexOf(want);
|
|
81
|
+
const sorted = [...supported].sort((a, b) => EFFORT_ORDER.indexOf(a) - EFFORT_ORDER.indexOf(b));
|
|
82
|
+
return sorted.find((e) => EFFORT_ORDER.indexOf(e) >= rank) ?? sorted[sorted.length - 1];
|
|
52
83
|
}
|
|
53
84
|
|
|
54
85
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* Use 'max_completion_tokens' instead", while gpt-4o accepts `max_tokens`.
|
|
58
|
-
* Sending the wrong one is a total outage of that model, not a degraded call.
|
|
86
|
+
* Update `q` from a 400. Returns true when the request can be retried with
|
|
87
|
+
* different parameters, false when the error is not about parameters.
|
|
59
88
|
*/
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
function learnFromError(q: ModelQuirks, err: OpenAIErrorResponse['error'] | undefined, sent: { effort?: ReasoningEffort; maxTokens: number }): boolean {
|
|
90
|
+
if (!err?.message) return false;
|
|
91
|
+
const msg = err.message;
|
|
92
|
+
if (err.param === 'temperature' || /'temperature'/.test(msg)) {
|
|
93
|
+
if (q.noTemperature) return false;
|
|
94
|
+
q.noTemperature = true;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (err.param === 'reasoning_effort' || /reasoning_effort/.test(msg)) {
|
|
98
|
+
const listed = [...msg.matchAll(/'([a-z]+)'/g)].map((m) => m[1]).filter((v): v is ReasoningEffort => (EFFORT_ORDER as string[]).includes(v));
|
|
99
|
+
const supported = listed.filter((v) => v !== sent.effort);
|
|
100
|
+
if (/Supported values/i.test(msg) && supported.length) {
|
|
101
|
+
if (q.efforts && q.efforts.join() === supported.join()) return false;
|
|
102
|
+
q.efforts = supported;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (q.noReasoningEffort) return false;
|
|
106
|
+
q.noReasoningEffort = true;
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
const most = /at most (\d+) completion tokens/i.exec(msg);
|
|
110
|
+
if (most) {
|
|
111
|
+
const max = Number(most[1]);
|
|
112
|
+
if (!(max > 0) || (q.maxOutput !== undefined && q.maxOutput <= max)) return false;
|
|
113
|
+
q.maxOutput = max;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
64
117
|
}
|
|
65
118
|
|
|
66
119
|
/**
|
|
67
|
-
* Tokens to allow for hidden reasoning, which
|
|
120
|
+
* Tokens to allow for hidden reasoning, which reasoning models bill against
|
|
68
121
|
* max_completion_tokens: a caller's maxTokens is the size of the ANSWER. Unset
|
|
69
|
-
* means the API's default effort (medium). Measured on gpt-5.5: low
|
|
70
|
-
* ~90 tokens and medium ~380 on a small structured call; large outputs
|
|
71
|
-
* far more, so these are generous ceilings, not estimates.
|
|
122
|
+
* means the API's default effort (medium on gpt-5.5). Measured on gpt-5.5: low
|
|
123
|
+
* reasoned ~90 tokens and medium ~380 on a small structured call; large outputs
|
|
124
|
+
* reason far more, so these are generous ceilings, not estimates. A cap is not
|
|
125
|
+
* a charge, and a model that rejects a cap this large gets it clamped.
|
|
72
126
|
*/
|
|
73
127
|
function reasoningHeadroom(effort?: ReasoningEffort): number {
|
|
74
128
|
switch (effort) {
|
|
@@ -82,11 +136,25 @@ function reasoningHeadroom(effort?: ReasoningEffort): number {
|
|
|
82
136
|
}
|
|
83
137
|
}
|
|
84
138
|
|
|
85
|
-
/**
|
|
86
|
-
function
|
|
87
|
-
|
|
139
|
+
/** The sampling half of a request, shaped by what this model has told us it accepts. */
|
|
140
|
+
function samplingParams(
|
|
141
|
+
q: ModelQuirks,
|
|
142
|
+
temperature: number,
|
|
143
|
+
maxTokens: number,
|
|
144
|
+
effort?: ReasoningEffort,
|
|
145
|
+
): Pick<OpenAIRequest, 'temperature' | 'max_completion_tokens' | 'reasoning_effort'> {
|
|
146
|
+
const sentEffort = q.noReasoningEffort || !effort ? undefined : (q.efforts ? nearestEffort(effort, q.efforts) : effort);
|
|
147
|
+
const headroom = q.noReasoningEffort ? 0 : reasoningHeadroom(sentEffort);
|
|
148
|
+
const cap = maxTokens + headroom;
|
|
149
|
+
return {
|
|
150
|
+
...(q.noTemperature ? {} : { temperature }),
|
|
151
|
+
max_completion_tokens: q.maxOutput !== undefined ? Math.min(cap, q.maxOutput) : cap,
|
|
152
|
+
...(sentEffort ? { reasoning_effort: sentEffort } : {}),
|
|
153
|
+
};
|
|
88
154
|
}
|
|
89
155
|
|
|
156
|
+
const MAX_ADAPTIVE_RETRIES = 3;
|
|
157
|
+
|
|
90
158
|
interface OpenAIResponse {
|
|
91
159
|
id: string;
|
|
92
160
|
object: string;
|
|
@@ -167,6 +235,14 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
167
235
|
readonly defaultModel: string;
|
|
168
236
|
|
|
169
237
|
private readonly config: Required<Omit<ProviderConfig, 'defaultReasoningEffort'>> & Pick<ProviderConfig, 'defaultReasoningEffort'>;
|
|
238
|
+
/** What each model has told us it accepts (see learnFromError). */
|
|
239
|
+
private readonly quirks = new Map<string, ModelQuirks>();
|
|
240
|
+
|
|
241
|
+
private quirksFor(model: string): ModelQuirks {
|
|
242
|
+
let q = this.quirks.get(model);
|
|
243
|
+
if (!q) { q = {}; this.quirks.set(model, q); }
|
|
244
|
+
return q;
|
|
245
|
+
}
|
|
170
246
|
|
|
171
247
|
constructor(config: ProviderConfig) {
|
|
172
248
|
this.config = {
|
|
@@ -181,6 +257,44 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
181
257
|
this.defaultModel = this.config.defaultModel;
|
|
182
258
|
}
|
|
183
259
|
|
|
260
|
+
/**
|
|
261
|
+
* POST a chat completion, adapting the request when the API rejects a
|
|
262
|
+
* parameter this model doesn't take (see learnFromError). Non-parameter
|
|
263
|
+
* errors, and a parameter error we can't act on, throw as before.
|
|
264
|
+
*/
|
|
265
|
+
private async postAdaptively<T>(
|
|
266
|
+
model: string,
|
|
267
|
+
build: (q: ModelQuirks) => object,
|
|
268
|
+
sent: { effort?: ReasoningEffort; maxTokens: number },
|
|
269
|
+
signal: AbortSignal,
|
|
270
|
+
): Promise<T> {
|
|
271
|
+
const q = this.quirksFor(model);
|
|
272
|
+
for (let attempt = 0; ; attempt++) {
|
|
273
|
+
const body = JSON.stringify(build(q));
|
|
274
|
+
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
headers: {
|
|
277
|
+
'Content-Type': 'application/json',
|
|
278
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
279
|
+
},
|
|
280
|
+
body,
|
|
281
|
+
signal,
|
|
282
|
+
});
|
|
283
|
+
if (response.ok) return (await response.json()) as T;
|
|
284
|
+
const errorData = (await response.json().catch(() => ({
|
|
285
|
+
error: { message: 'Unknown error' },
|
|
286
|
+
}))) as OpenAIErrorResponse;
|
|
287
|
+
if (response.status === 400 && attempt < MAX_ADAPTIVE_RETRIES) {
|
|
288
|
+
const learned = learnFromError(q, errorData.error, sent);
|
|
289
|
+
// Calls to a new model often go out in parallel: by the time this one
|
|
290
|
+
// fails, another may already have learned the lesson, so retry whenever
|
|
291
|
+
// the request would now be built differently from the one just sent.
|
|
292
|
+
if (learned || JSON.stringify(build(q)) !== body) continue;
|
|
293
|
+
}
|
|
294
|
+
throw this.handleApiError(response.status, errorData);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
184
298
|
isAvailable(): boolean {
|
|
185
299
|
return Boolean(this.config.apiKey);
|
|
186
300
|
}
|
|
@@ -201,53 +315,31 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
201
315
|
}
|
|
202
316
|
|
|
203
317
|
const model = options?.model ?? this.config.defaultModel;
|
|
204
|
-
|
|
205
|
-
const isFineTuned = model.startsWith('ft:');
|
|
206
|
-
const isBaseGpt5 = isGpt5Family(model) && !isFineTuned;
|
|
207
|
-
const temperature = isBaseGpt5
|
|
208
|
-
? 1.0
|
|
209
|
-
: (options?.temperature ?? this.config.defaultTemperature);
|
|
318
|
+
const temperature = options?.temperature ?? this.config.defaultTemperature;
|
|
210
319
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
211
320
|
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
212
321
|
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
213
322
|
|
|
214
|
-
const
|
|
323
|
+
const base = {
|
|
215
324
|
model,
|
|
216
325
|
messages: [
|
|
217
|
-
{ role: 'system', content: systemPrompt },
|
|
218
|
-
{ role: 'user', content: userPrompt },
|
|
326
|
+
{ role: 'system' as const, content: systemPrompt },
|
|
327
|
+
{ role: 'user' as const, content: userPrompt },
|
|
219
328
|
],
|
|
220
|
-
|
|
221
|
-
...tokenLimitParam(model, maxTokens, effort),
|
|
222
|
-
...reasoningParam(model, effort),
|
|
223
|
-
response_format: { type: 'json_object' },
|
|
329
|
+
response_format: { type: 'json_object' as const },
|
|
224
330
|
};
|
|
225
331
|
|
|
226
332
|
const controller = new AbortController();
|
|
227
333
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
228
334
|
|
|
229
335
|
try {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
Authorization: `Bearer ${this.config.apiKey}`,
|
|
235
|
-
},
|
|
236
|
-
body: JSON.stringify(requestBody),
|
|
237
|
-
signal: controller.signal,
|
|
238
|
-
});
|
|
336
|
+
const data = await this.postAdaptively<OpenAIResponse>(model, (q) => ({
|
|
337
|
+
...base,
|
|
338
|
+
...samplingParams(q, temperature, maxTokens, effort),
|
|
339
|
+
}), { effort, maxTokens }, controller.signal);
|
|
239
340
|
|
|
240
341
|
clearTimeout(timeoutId);
|
|
241
342
|
|
|
242
|
-
if (!response.ok) {
|
|
243
|
-
const errorData = (await response.json().catch(() => ({
|
|
244
|
-
error: { message: 'Unknown error' },
|
|
245
|
-
}))) as OpenAIErrorResponse;
|
|
246
|
-
throw this.handleApiError(response.status, errorData);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const data = (await response.json()) as OpenAIResponse;
|
|
250
|
-
|
|
251
343
|
if (!data.choices?.[0]?.message?.content) {
|
|
252
344
|
throw new LLMProviderError(
|
|
253
345
|
'Empty response from OpenAI',
|
|
@@ -320,11 +412,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
320
412
|
}
|
|
321
413
|
|
|
322
414
|
const model = options?.model ?? this.config.defaultModel;
|
|
323
|
-
const
|
|
324
|
-
const isBaseGpt5 = isGpt5Family(model) && !isFineTuned;
|
|
325
|
-
const temperature = isBaseGpt5
|
|
326
|
-
? 1.0
|
|
327
|
-
: (options?.temperature ?? this.config.defaultTemperature);
|
|
415
|
+
const temperature = options?.temperature ?? this.config.defaultTemperature;
|
|
328
416
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
329
417
|
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
330
418
|
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
@@ -350,12 +438,9 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
350
438
|
}),
|
|
351
439
|
];
|
|
352
440
|
|
|
353
|
-
const
|
|
441
|
+
const base = {
|
|
354
442
|
model,
|
|
355
443
|
messages: oaiMessages,
|
|
356
|
-
temperature,
|
|
357
|
-
...tokenLimitParam(model, maxTokens, effort),
|
|
358
|
-
...reasoningParam(model, effort),
|
|
359
444
|
tools: tools.map((t) => ({
|
|
360
445
|
type: 'function' as const,
|
|
361
446
|
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
@@ -366,25 +451,11 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
366
451
|
const controller = new AbortController();
|
|
367
452
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
368
453
|
try {
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
Authorization: `Bearer ${this.config.apiKey}`,
|
|
374
|
-
},
|
|
375
|
-
body: JSON.stringify(requestBody),
|
|
376
|
-
signal: controller.signal,
|
|
377
|
-
});
|
|
454
|
+
const data = await this.postAdaptively<OpenAIToolsResponse>(model, (q) => ({
|
|
455
|
+
...base,
|
|
456
|
+
...samplingParams(q, temperature, maxTokens, effort),
|
|
457
|
+
}), { effort, maxTokens }, controller.signal);
|
|
378
458
|
clearTimeout(timeoutId);
|
|
379
|
-
|
|
380
|
-
if (!response.ok) {
|
|
381
|
-
const errorData = (await response.json().catch(() => ({
|
|
382
|
-
error: { message: 'Unknown error' },
|
|
383
|
-
}))) as OpenAIErrorResponse;
|
|
384
|
-
throw this.handleApiError(response.status, errorData);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const data = (await response.json()) as OpenAIToolsResponse;
|
|
388
459
|
const choice = data.choices?.[0];
|
|
389
460
|
const msg = choice?.message;
|
|
390
461
|
const toolCalls = (msg?.tool_calls ?? []).map((tc) => {
|