@startsimpli/llm 0.4.19 → 0.4.20
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 +82 -71
- package/src/providers/openai.ts +147 -83
package/package.json
CHANGED
|
@@ -64,42 +64,96 @@ 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
|
+
});
|
|
100
132
|
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
});
|
|
146
|
+
|
|
147
|
+
it('adapts on the tool-calling path too', async () => {
|
|
148
|
+
const p = provider('gpt-6-sol');
|
|
149
|
+
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." });
|
|
150
|
+
fetchMock.mockImplementationOnce(async (_url: unknown, init: RequestInit) => {
|
|
151
|
+
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
152
|
+
return { ok: true, status: 200, json: async () => ({ ...OK_BODY, choices: [{ index: 0, message: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }] }) } as unknown as Response;
|
|
153
|
+
});
|
|
154
|
+
const res = await p.generateWithTools([{ role: 'user', content: 'hi' }], 's', []);
|
|
155
|
+
expect(res.content).toBe('done');
|
|
156
|
+
expect(sent[1]!.temperature).toBeUndefined();
|
|
103
157
|
});
|
|
104
158
|
});
|
|
105
159
|
|
|
@@ -109,24 +163,17 @@ describe('the token-limit parameter', () => {
|
|
|
109
163
|
* none -> 2.3s, 35 completion tokens, 0 reasoning
|
|
110
164
|
* low -> 2.8s, 130 completion tokens, 91 reasoning
|
|
111
165
|
* 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.
|
|
166
|
+
* Hidden reasoning is billed against max_completion_tokens, so a caller's
|
|
167
|
+
* answer budget gets headroom.
|
|
116
168
|
*/
|
|
117
|
-
describe('reasoning
|
|
118
|
-
it('sends reasoning_effort when one is given,
|
|
169
|
+
describe('reasoning effort and headroom', () => {
|
|
170
|
+
it('sends reasoning_effort when one is given, and omits it otherwise', async () => {
|
|
119
171
|
await provider('gpt-5.5').generate('u', 's', { reasoningEffort: 'low' });
|
|
120
|
-
await provider('gpt-
|
|
172
|
+
await provider('gpt-5.5').generate('u', 's');
|
|
121
173
|
expect(sent[0]!.reasoning_effort).toBe('low');
|
|
122
174
|
expect(sent[1]!.reasoning_effort).toBeUndefined();
|
|
123
175
|
});
|
|
124
176
|
|
|
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
177
|
it('takes the default effort from the provider config', async () => {
|
|
131
178
|
await new OpenAIProvider({ apiKey: 'k', defaultModel: 'gpt-5.5', defaultReasoningEffort: 'low' }).generate('u', 's');
|
|
132
179
|
expect(sent[0]!.reasoning_effort).toBe('low');
|
|
@@ -139,43 +186,7 @@ describe('reasoning (gpt-5 family)', () => {
|
|
|
139
186
|
const [none, low, unset] = sent.map((b) => b.max_completion_tokens!);
|
|
140
187
|
expect(none).toBe(256);
|
|
141
188
|
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);
|
|
189
|
+
expect(unset).toBeGreaterThan(low); // unset = the API default (medium)
|
|
179
190
|
});
|
|
180
191
|
});
|
|
181
192
|
|
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,37 @@ 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 response = await fetch(`${this.config.baseUrl}/chat/completions`, {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: {
|
|
276
|
+
'Content-Type': 'application/json',
|
|
277
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
278
|
+
},
|
|
279
|
+
body: JSON.stringify(build(q)),
|
|
280
|
+
signal,
|
|
281
|
+
});
|
|
282
|
+
if (response.ok) return (await response.json()) as T;
|
|
283
|
+
const errorData = (await response.json().catch(() => ({
|
|
284
|
+
error: { message: 'Unknown error' },
|
|
285
|
+
}))) as OpenAIErrorResponse;
|
|
286
|
+
if (response.status === 400 && attempt < MAX_ADAPTIVE_RETRIES && learnFromError(q, errorData.error, sent)) continue;
|
|
287
|
+
throw this.handleApiError(response.status, errorData);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
184
291
|
isAvailable(): boolean {
|
|
185
292
|
return Boolean(this.config.apiKey);
|
|
186
293
|
}
|
|
@@ -201,53 +308,31 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
201
308
|
}
|
|
202
309
|
|
|
203
310
|
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);
|
|
311
|
+
const temperature = options?.temperature ?? this.config.defaultTemperature;
|
|
210
312
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
211
313
|
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
212
314
|
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
213
315
|
|
|
214
|
-
const
|
|
316
|
+
const base = {
|
|
215
317
|
model,
|
|
216
318
|
messages: [
|
|
217
|
-
{ role: 'system', content: systemPrompt },
|
|
218
|
-
{ role: 'user', content: userPrompt },
|
|
319
|
+
{ role: 'system' as const, content: systemPrompt },
|
|
320
|
+
{ role: 'user' as const, content: userPrompt },
|
|
219
321
|
],
|
|
220
|
-
|
|
221
|
-
...tokenLimitParam(model, maxTokens, effort),
|
|
222
|
-
...reasoningParam(model, effort),
|
|
223
|
-
response_format: { type: 'json_object' },
|
|
322
|
+
response_format: { type: 'json_object' as const },
|
|
224
323
|
};
|
|
225
324
|
|
|
226
325
|
const controller = new AbortController();
|
|
227
326
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
228
327
|
|
|
229
328
|
try {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
Authorization: `Bearer ${this.config.apiKey}`,
|
|
235
|
-
},
|
|
236
|
-
body: JSON.stringify(requestBody),
|
|
237
|
-
signal: controller.signal,
|
|
238
|
-
});
|
|
329
|
+
const data = await this.postAdaptively<OpenAIResponse>(model, (q) => ({
|
|
330
|
+
...base,
|
|
331
|
+
...samplingParams(q, temperature, maxTokens, effort),
|
|
332
|
+
}), { effort, maxTokens }, controller.signal);
|
|
239
333
|
|
|
240
334
|
clearTimeout(timeoutId);
|
|
241
335
|
|
|
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
336
|
if (!data.choices?.[0]?.message?.content) {
|
|
252
337
|
throw new LLMProviderError(
|
|
253
338
|
'Empty response from OpenAI',
|
|
@@ -320,11 +405,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
320
405
|
}
|
|
321
406
|
|
|
322
407
|
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);
|
|
408
|
+
const temperature = options?.temperature ?? this.config.defaultTemperature;
|
|
328
409
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
329
410
|
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
330
411
|
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
@@ -350,12 +431,9 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
350
431
|
}),
|
|
351
432
|
];
|
|
352
433
|
|
|
353
|
-
const
|
|
434
|
+
const base = {
|
|
354
435
|
model,
|
|
355
436
|
messages: oaiMessages,
|
|
356
|
-
temperature,
|
|
357
|
-
...tokenLimitParam(model, maxTokens, effort),
|
|
358
|
-
...reasoningParam(model, effort),
|
|
359
437
|
tools: tools.map((t) => ({
|
|
360
438
|
type: 'function' as const,
|
|
361
439
|
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
@@ -366,25 +444,11 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
366
444
|
const controller = new AbortController();
|
|
367
445
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
368
446
|
try {
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
Authorization: `Bearer ${this.config.apiKey}`,
|
|
374
|
-
},
|
|
375
|
-
body: JSON.stringify(requestBody),
|
|
376
|
-
signal: controller.signal,
|
|
377
|
-
});
|
|
447
|
+
const data = await this.postAdaptively<OpenAIToolsResponse>(model, (q) => ({
|
|
448
|
+
...base,
|
|
449
|
+
...samplingParams(q, temperature, maxTokens, effort),
|
|
450
|
+
}), { effort, maxTokens }, controller.signal);
|
|
378
451
|
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
452
|
const choice = data.choices?.[0];
|
|
389
453
|
const msg = choice?.message;
|
|
390
454
|
const toolCalls = (msg?.tool_calls ?? []).map((tc) => {
|