@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/llm",
3
- "version": "0.4.19",
3
+ "version": "0.4.20",
4
4
  "description": "Generic LLM service infrastructure — provider abstraction, retry logic, JSON extraction",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -64,42 +64,96 @@ afterEach(() => {
64
64
  vi.unstubAllGlobals();
65
65
  });
66
66
 
67
- describe('the token-limit parameter', () => {
68
- it.each(['gpt-5', 'gpt-5.1', 'gpt-5.2'])(
69
- '%s gets max_completion_tokens and never max_tokens',
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('an UNRELEASED gpt-5 model is handled by family, not by a hardcoded list', async () => {
79
- // bd startsim-768w.38.26. The day this id ships, a literal deny-list sends
80
- // it max_tokens and every call 400s — silently, from the caller's view.
81
- await provider('gpt-5.3').generate('u', 's');
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
- expect(sent[0]!.max_completion_tokens).toBeGreaterThan(0);
84
- expect(sent[0]!.max_tokens).toBeUndefined();
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('gpt-4o keeps max_tokens, which it accepts', async () => {
88
- // The fix must not break the models that were working.
89
- await provider('gpt-4o').generate('u', 's');
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
- expect(sent[0]!.max_tokens).toBeGreaterThan(0);
92
- expect(sent[0]!.max_completion_tokens).toBeUndefined();
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('honours an explicit maxTokens on whichever parameter the model takes', async () => {
96
- // For the gpt-5 family maxTokens is the ANSWER budget; reasoning headroom is
97
- // added on top (see the reasoning describe below). none = no reasoning.
98
- await provider('gpt-5.2').generate('u', 's', { maxTokens: 123, reasoningEffort: 'none' });
99
- await provider('gpt-4o').generate('u', 's', { maxTokens: 123 });
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
- expect(sent[0]!.max_completion_tokens).toBe(123);
102
- expect(sent[1]!.max_tokens).toBe(123);
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
- * minimal -> 400 "does not support 'minimal' with this model"
113
- * crochet's assembly pipeline asked for 256 tokens per connection call and got
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 (gpt-5 family)', () => {
118
- it('sends reasoning_effort when one is given, to gpt-5 models only', async () => {
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-4o').generate('u', 's', { reasoningEffort: 'low' });
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 means the API's default effort (medium), which reasons the most of these
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
 
@@ -32,43 +32,97 @@ interface OpenAIRequest {
32
32
  model: string;
33
33
  messages: OpenAIMessage[];
34
34
  temperature?: number;
35
- /** Pre-gpt-5 models. Rejected outright by the gpt-5 family. */
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
- /** gpt-5 family only. */
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
- * Is this the gpt-5 FAMILY? Matched by prefix, deliberately, not by a list of
46
- * ids (bd startsim-768w.38.26 filed the literal-id deny-list as a bug: it is
47
- * "correct only until OpenAI ships gpt-5.3", and the failure is a silent 400).
48
- * `ft:gpt-5…` fine-tunes are still gpt-5 models on the wire.
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
- function isGpt5Family(model: string): boolean {
51
- return /^gpt-5/.test(model.startsWith('ft:') ? model.slice(3) : model);
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
- * The token-limit parameter this model accepts. MEASURED against api.openai.com
56
- * on 2026-08-19: gpt-5.2 rejects `max_tokens` with "Unsupported parameter ...
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 tokenLimitParam(model: string, maxTokens: number, effort?: ReasoningEffort): Partial<OpenAIRequest> {
61
- return isGpt5Family(model)
62
- ? { max_completion_tokens: maxTokens + reasoningHeadroom(effort) }
63
- : { max_tokens: maxTokens };
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 the gpt-5 family bills against
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 reasoned
70
- * ~90 tokens and medium ~380 on a small structured call; large outputs reason
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
- /** reasoning_effort for a gpt-5 model, when one is configured. */
86
- function reasoningParam(model: string, effort?: ReasoningEffort): Partial<OpenAIRequest> {
87
- return isGpt5Family(model) && effort ? { reasoning_effort: effort } : {};
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
- // Base gpt-5 models require temperature=1.0, but fine-tuned models use configured temperature
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 requestBody: OpenAIRequest = {
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
- temperature,
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 response = await fetch(`${this.config.baseUrl}/chat/completions`, {
231
- method: 'POST',
232
- headers: {
233
- 'Content-Type': 'application/json',
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 isFineTuned = model.startsWith('ft:');
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 requestBody = {
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 response = await fetch(`${this.config.baseUrl}/chat/completions`, {
370
- method: 'POST',
371
- headers: {
372
- 'Content-Type': 'application/json',
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) => {