@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/llm",
3
- "version": "0.4.19",
3
+ "version": "0.4.21",
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,111 @@ 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
+ });
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
- expect(sent[0]!.max_completion_tokens).toBe(123);
102
- expect(sent[1]!.max_tokens).toBe(123);
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
- * 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.
181
+ * Hidden reasoning is billed against max_completion_tokens, so a caller's
182
+ * answer budget gets headroom.
116
183
  */
117
- describe('reasoning (gpt-5 family)', () => {
118
- it('sends reasoning_effort when one is given, to gpt-5 models only', async () => {
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-4o').generate('u', 's', { reasoningEffort: 'low' });
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 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);
204
+ expect(unset).toBeGreaterThan(low); // unset = the API default (medium)
179
205
  });
180
206
  });
181
207
 
@@ -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,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
- // 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);
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 requestBody: OpenAIRequest = {
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
- temperature,
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 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
- });
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 isFineTuned = model.startsWith('ft:');
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 requestBody = {
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 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
- });
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) => {