@startsimpli/llm 0.4.18 → 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/README.md +1 -0
- package/package.json +1 -1
- package/src/__tests__/openai-provider.test.ts +111 -38
- package/src/index.ts +1 -0
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/mock.ts +3 -0
- package/src/providers/openai.ts +174 -74
- package/src/types.ts +14 -1
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ The factories read from `process.env` and return `null` when nothing is configur
|
|
|
60
60
|
| `OPENAI_API_KEY` | `createOpenAIProvider()` | unset → provider disabled |
|
|
61
61
|
| `OPENAI_BASE_URL` | `createOpenAIProvider()` | `https://api.openai.com/v1` |
|
|
62
62
|
| `OPENAI_MODEL` | `createOpenAIProvider()` | `gpt-4o-mini` (`LLM_DEFAULTS.openaiModel`) |
|
|
63
|
+
| `OPENAI_REASONING_EFFORT` | `createOpenAIProvider()` | unset (API default). gpt-5 family only: `none`, `low`, `medium`, `high` (gpt-5.5 rejects `minimal`). `maxTokens` stays the answer budget; reasoning headroom is added on top. |
|
|
63
64
|
| `ANTHROPIC_API_KEY` | `createAnthropicProvider()` | unset → provider disabled |
|
|
64
65
|
| `ANTHROPIC_BASE_URL` | `createAnthropicProvider()` | `https://api.anthropic.com/v1` |
|
|
65
66
|
| `ANTHROPIC_MODEL` | `createAnthropicProvider()` | `claude-sonnet-4-20250514` (`LLM_DEFAULTS.anthropicModel`) |
|
package/package.json
CHANGED
|
@@ -32,6 +32,7 @@ interface Sent {
|
|
|
32
32
|
temperature?: number;
|
|
33
33
|
max_tokens?: number;
|
|
34
34
|
max_completion_tokens?: number;
|
|
35
|
+
reasoning_effort?: string;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
const OK_BODY = {
|
|
@@ -63,64 +64,136 @@ afterEach(() => {
|
|
|
63
64
|
vi.unstubAllGlobals();
|
|
64
65
|
});
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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',
|
|
69
78
|
async (model) => {
|
|
70
79
|
await provider(model).generate('u', 's');
|
|
71
|
-
|
|
72
80
|
expect(sent[0]!.max_completion_tokens).toBeGreaterThan(0);
|
|
73
81
|
expect(sent[0]!.max_tokens).toBeUndefined();
|
|
74
82
|
},
|
|
75
83
|
);
|
|
76
84
|
|
|
77
|
-
it('
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
expect(sent[0]!.max_completion_tokens).toBeGreaterThan(0);
|
|
83
|
-
expect(sent[0]!.max_tokens).toBeUndefined();
|
|
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]);
|
|
84
89
|
});
|
|
90
|
+
});
|
|
85
91
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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();
|
|
105
|
+
});
|
|
89
106
|
|
|
90
|
-
|
|
91
|
-
|
|
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');
|
|
92
116
|
});
|
|
93
117
|
|
|
94
|
-
it('
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
124
|
+
});
|
|
97
125
|
|
|
98
|
-
|
|
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);
|
|
100
131
|
});
|
|
101
|
-
});
|
|
102
132
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
});
|
|
109
146
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
113
159
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
160
|
+
/*
|
|
161
|
+
* MEASURED 2026-10-02 against gpt-5.5-2026-04-23, a 256-token structured call:
|
|
162
|
+
* effort unset (API default) -> empty content: reasoning ate the whole cap
|
|
163
|
+
* none -> 2.3s, 35 completion tokens, 0 reasoning
|
|
164
|
+
* low -> 2.8s, 130 completion tokens, 91 reasoning
|
|
165
|
+
* medium -> 42.8s, 412 completion tokens, 377 reasoning
|
|
166
|
+
* Hidden reasoning is billed against max_completion_tokens, so a caller's
|
|
167
|
+
* answer budget gets headroom.
|
|
168
|
+
*/
|
|
169
|
+
describe('reasoning effort and headroom', () => {
|
|
170
|
+
it('sends reasoning_effort when one is given, and omits it otherwise', async () => {
|
|
171
|
+
await provider('gpt-5.5').generate('u', 's', { reasoningEffort: 'low' });
|
|
172
|
+
await provider('gpt-5.5').generate('u', 's');
|
|
173
|
+
expect(sent[0]!.reasoning_effort).toBe('low');
|
|
174
|
+
expect(sent[1]!.reasoning_effort).toBeUndefined();
|
|
175
|
+
});
|
|
117
176
|
|
|
118
|
-
|
|
177
|
+
it('takes the default effort from the provider config', async () => {
|
|
178
|
+
await new OpenAIProvider({ apiKey: 'k', defaultModel: 'gpt-5.5', defaultReasoningEffort: 'low' }).generate('u', 's');
|
|
179
|
+
expect(sent[0]!.reasoning_effort).toBe('low');
|
|
119
180
|
});
|
|
120
181
|
|
|
121
|
-
it('
|
|
122
|
-
await provider('gpt-
|
|
182
|
+
it('adds reasoning headroom on top of the answer budget, scaled to the effort', async () => {
|
|
183
|
+
await provider('gpt-5.5').generate('u', 's', { maxTokens: 256, reasoningEffort: 'none' });
|
|
184
|
+
await provider('gpt-5.5').generate('u', 's', { maxTokens: 256, reasoningEffort: 'low' });
|
|
185
|
+
await provider('gpt-5.5').generate('u', 's', { maxTokens: 256 });
|
|
186
|
+
const [none, low, unset] = sent.map((b) => b.max_completion_tokens!);
|
|
187
|
+
expect(none).toBe(256);
|
|
188
|
+
expect(low).toBeGreaterThanOrEqual(256 + 1000);
|
|
189
|
+
expect(unset).toBeGreaterThan(low); // unset = the API default (medium)
|
|
190
|
+
});
|
|
191
|
+
});
|
|
123
192
|
|
|
124
|
-
|
|
193
|
+
describe('response provenance', () => {
|
|
194
|
+
it('reports the provider and the model the API says answered', async () => {
|
|
195
|
+
const res = await provider('gpt-5.5').generate('u', 's');
|
|
196
|
+
expect(res.provider).toBe('openai');
|
|
197
|
+
expect(res.model).toBe('server-reported-model');
|
|
125
198
|
});
|
|
126
199
|
});
|
package/src/index.ts
CHANGED
|
@@ -151,7 +151,7 @@ export class AnthropicProvider implements ILLMProvider {
|
|
|
151
151
|
readonly availableModels = ANTHROPIC_MODELS;
|
|
152
152
|
readonly defaultModel: string;
|
|
153
153
|
|
|
154
|
-
private readonly config: Required<ProviderConfig
|
|
154
|
+
private readonly config: Required<Omit<ProviderConfig, 'defaultReasoningEffort'>>;
|
|
155
155
|
private readonly apiVersion: string;
|
|
156
156
|
|
|
157
157
|
constructor(config: ProviderConfig, apiVersion?: string) {
|
|
@@ -246,6 +246,7 @@ Please respond with a valid JSON object only. Do not include any text before or
|
|
|
246
246
|
totalTokens: data.usage.input_tokens + data.usage.output_tokens,
|
|
247
247
|
},
|
|
248
248
|
model: data.model,
|
|
249
|
+
provider: this.name,
|
|
249
250
|
responseId: data.id,
|
|
250
251
|
finishReason: data.stop_reason ?? undefined,
|
|
251
252
|
};
|
package/src/providers/mock.ts
CHANGED
|
@@ -88,6 +88,7 @@ export class MockLLMProvider implements ILLMProvider {
|
|
|
88
88
|
content: mockBehavior.customResponse,
|
|
89
89
|
usage: { promptTokens: 100, completionTokens: 500, totalTokens: 600 },
|
|
90
90
|
model: this.defaultModel,
|
|
91
|
+
provider: this.name,
|
|
91
92
|
responseId: `mock-${this.callCount}`,
|
|
92
93
|
finishReason: 'stop',
|
|
93
94
|
};
|
|
@@ -98,6 +99,7 @@ export class MockLLMProvider implements ILLMProvider {
|
|
|
98
99
|
content: getInvalidResponse(),
|
|
99
100
|
usage: { promptTokens: 100, completionTokens: 200, totalTokens: 300 },
|
|
100
101
|
model: this.defaultModel,
|
|
102
|
+
provider: this.name,
|
|
101
103
|
responseId: `mock-${this.callCount}`,
|
|
102
104
|
finishReason: 'stop',
|
|
103
105
|
};
|
|
@@ -107,6 +109,7 @@ export class MockLLMProvider implements ILLMProvider {
|
|
|
107
109
|
content: getDefaultResponse(),
|
|
108
110
|
usage: { promptTokens: 150, completionTokens: 800, totalTokens: 950 },
|
|
109
111
|
model: this.defaultModel,
|
|
112
|
+
provider: this.name,
|
|
110
113
|
responseId: `mock-${this.callCount}`,
|
|
111
114
|
finishReason: 'stop',
|
|
112
115
|
};
|
package/src/providers/openai.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
NativeToolSpec,
|
|
18
18
|
NativeChatMessage,
|
|
19
19
|
NativeToolResponse,
|
|
20
|
+
ReasoningEffort,
|
|
20
21
|
} from '../types';
|
|
21
22
|
import { LLM_DEFAULTS } from '../types';
|
|
22
23
|
import { LLMProviderError } from '../errors';
|
|
@@ -31,35 +32,129 @@ interface OpenAIRequest {
|
|
|
31
32
|
model: string;
|
|
32
33
|
messages: OpenAIMessage[];
|
|
33
34
|
temperature?: number;
|
|
34
|
-
/**
|
|
35
|
-
max_tokens?: number;
|
|
36
|
-
/** The gpt-5 family's replacement for `max_tokens`. */
|
|
35
|
+
/** Accepted by every current chat model; `max_tokens` is rejected by reasoning models. */
|
|
37
36
|
max_completion_tokens?: number;
|
|
37
|
+
/** Reasoning models only; dropped automatically for models that reject it. */
|
|
38
|
+
reasoning_effort?: ReasoningEffort;
|
|
38
39
|
response_format?: { type: 'json_object' | 'text' };
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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.
|
|
46
66
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
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];
|
|
49
83
|
}
|
|
50
84
|
|
|
51
85
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* Use 'max_completion_tokens' instead", while gpt-4o accepts `max_tokens`.
|
|
55
|
-
* 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.
|
|
56
88
|
*/
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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;
|
|
61
117
|
}
|
|
62
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Tokens to allow for hidden reasoning, which reasoning models bill against
|
|
121
|
+
* max_completion_tokens: a caller's maxTokens is the size of the ANSWER. Unset
|
|
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.
|
|
126
|
+
*/
|
|
127
|
+
function reasoningHeadroom(effort?: ReasoningEffort): number {
|
|
128
|
+
switch (effort) {
|
|
129
|
+
case 'none': return 0;
|
|
130
|
+
case 'minimal': return 1024;
|
|
131
|
+
case 'low': return 4096;
|
|
132
|
+
case 'high': return 24576;
|
|
133
|
+
case 'xhigh': return 32768;
|
|
134
|
+
case 'medium':
|
|
135
|
+
default: return 12288;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
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
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const MAX_ADAPTIVE_RETRIES = 3;
|
|
157
|
+
|
|
63
158
|
interface OpenAIResponse {
|
|
64
159
|
id: string;
|
|
65
160
|
object: string;
|
|
@@ -139,7 +234,15 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
139
234
|
readonly availableModels = OPENAI_MODELS;
|
|
140
235
|
readonly defaultModel: string;
|
|
141
236
|
|
|
142
|
-
private readonly config: Required<ProviderConfig>;
|
|
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
|
+
}
|
|
143
246
|
|
|
144
247
|
constructor(config: ProviderConfig) {
|
|
145
248
|
this.config = {
|
|
@@ -149,10 +252,42 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
149
252
|
defaultTemperature: config.defaultTemperature ?? DEFAULTS.temperature,
|
|
150
253
|
defaultMaxTokens: config.defaultMaxTokens ?? DEFAULTS.maxTokens,
|
|
151
254
|
timeoutMs: config.timeoutMs ?? DEFAULTS.timeoutMs,
|
|
255
|
+
defaultReasoningEffort: config.defaultReasoningEffort,
|
|
152
256
|
};
|
|
153
257
|
this.defaultModel = this.config.defaultModel;
|
|
154
258
|
}
|
|
155
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
|
+
|
|
156
291
|
isAvailable(): boolean {
|
|
157
292
|
return Boolean(this.config.apiKey);
|
|
158
293
|
}
|
|
@@ -173,51 +308,31 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
173
308
|
}
|
|
174
309
|
|
|
175
310
|
const model = options?.model ?? this.config.defaultModel;
|
|
176
|
-
|
|
177
|
-
const isFineTuned = model.startsWith('ft:');
|
|
178
|
-
const isBaseGpt5 = isGpt5Family(model) && !isFineTuned;
|
|
179
|
-
const temperature = isBaseGpt5
|
|
180
|
-
? 1.0
|
|
181
|
-
: (options?.temperature ?? this.config.defaultTemperature);
|
|
311
|
+
const temperature = options?.temperature ?? this.config.defaultTemperature;
|
|
182
312
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
313
|
+
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
183
314
|
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
184
315
|
|
|
185
|
-
const
|
|
316
|
+
const base = {
|
|
186
317
|
model,
|
|
187
318
|
messages: [
|
|
188
|
-
{ role: 'system', content: systemPrompt },
|
|
189
|
-
{ role: 'user', content: userPrompt },
|
|
319
|
+
{ role: 'system' as const, content: systemPrompt },
|
|
320
|
+
{ role: 'user' as const, content: userPrompt },
|
|
190
321
|
],
|
|
191
|
-
|
|
192
|
-
...tokenLimitParam(model, maxTokens),
|
|
193
|
-
response_format: { type: 'json_object' },
|
|
322
|
+
response_format: { type: 'json_object' as const },
|
|
194
323
|
};
|
|
195
324
|
|
|
196
325
|
const controller = new AbortController();
|
|
197
326
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
198
327
|
|
|
199
328
|
try {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
Authorization: `Bearer ${this.config.apiKey}`,
|
|
205
|
-
},
|
|
206
|
-
body: JSON.stringify(requestBody),
|
|
207
|
-
signal: controller.signal,
|
|
208
|
-
});
|
|
329
|
+
const data = await this.postAdaptively<OpenAIResponse>(model, (q) => ({
|
|
330
|
+
...base,
|
|
331
|
+
...samplingParams(q, temperature, maxTokens, effort),
|
|
332
|
+
}), { effort, maxTokens }, controller.signal);
|
|
209
333
|
|
|
210
334
|
clearTimeout(timeoutId);
|
|
211
335
|
|
|
212
|
-
if (!response.ok) {
|
|
213
|
-
const errorData = (await response.json().catch(() => ({
|
|
214
|
-
error: { message: 'Unknown error' },
|
|
215
|
-
}))) as OpenAIErrorResponse;
|
|
216
|
-
throw this.handleApiError(response.status, errorData);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const data = (await response.json()) as OpenAIResponse;
|
|
220
|
-
|
|
221
336
|
if (!data.choices?.[0]?.message?.content) {
|
|
222
337
|
throw new LLMProviderError(
|
|
223
338
|
'Empty response from OpenAI',
|
|
@@ -236,6 +351,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
236
351
|
totalTokens: data.usage.total_tokens,
|
|
237
352
|
},
|
|
238
353
|
model: data.model,
|
|
354
|
+
provider: this.name,
|
|
239
355
|
responseId: data.id,
|
|
240
356
|
finishReason: data.choices[0].finish_reason,
|
|
241
357
|
};
|
|
@@ -289,12 +405,9 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
289
405
|
}
|
|
290
406
|
|
|
291
407
|
const model = options?.model ?? this.config.defaultModel;
|
|
292
|
-
const
|
|
293
|
-
const isBaseGpt5 = isGpt5Family(model) && !isFineTuned;
|
|
294
|
-
const temperature = isBaseGpt5
|
|
295
|
-
? 1.0
|
|
296
|
-
: (options?.temperature ?? this.config.defaultTemperature);
|
|
408
|
+
const temperature = options?.temperature ?? this.config.defaultTemperature;
|
|
297
409
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
410
|
+
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
298
411
|
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
299
412
|
|
|
300
413
|
const oaiMessages: OpenAIToolMessage[] = [
|
|
@@ -318,11 +431,9 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
318
431
|
}),
|
|
319
432
|
];
|
|
320
433
|
|
|
321
|
-
const
|
|
434
|
+
const base = {
|
|
322
435
|
model,
|
|
323
436
|
messages: oaiMessages,
|
|
324
|
-
temperature,
|
|
325
|
-
...tokenLimitParam(model, maxTokens),
|
|
326
437
|
tools: tools.map((t) => ({
|
|
327
438
|
type: 'function' as const,
|
|
328
439
|
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
@@ -333,25 +444,11 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
333
444
|
const controller = new AbortController();
|
|
334
445
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
335
446
|
try {
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
Authorization: `Bearer ${this.config.apiKey}`,
|
|
341
|
-
},
|
|
342
|
-
body: JSON.stringify(requestBody),
|
|
343
|
-
signal: controller.signal,
|
|
344
|
-
});
|
|
447
|
+
const data = await this.postAdaptively<OpenAIToolsResponse>(model, (q) => ({
|
|
448
|
+
...base,
|
|
449
|
+
...samplingParams(q, temperature, maxTokens, effort),
|
|
450
|
+
}), { effort, maxTokens }, controller.signal);
|
|
345
451
|
clearTimeout(timeoutId);
|
|
346
|
-
|
|
347
|
-
if (!response.ok) {
|
|
348
|
-
const errorData = (await response.json().catch(() => ({
|
|
349
|
-
error: { message: 'Unknown error' },
|
|
350
|
-
}))) as OpenAIErrorResponse;
|
|
351
|
-
throw this.handleApiError(response.status, errorData);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const data = (await response.json()) as OpenAIToolsResponse;
|
|
355
452
|
const choice = data.choices?.[0];
|
|
356
453
|
const msg = choice?.message;
|
|
357
454
|
const toolCalls = (msg?.tool_calls ?? []).map((tc) => {
|
|
@@ -459,5 +556,8 @@ export function createOpenAIProvider(): OpenAIProvider | null {
|
|
|
459
556
|
// deployment's timeout and the other ignoring it is the kind of asymmetry
|
|
460
557
|
// that only shows up as a provider-dependent bug at 3am.
|
|
461
558
|
timeoutMs: Number(process.env.LLM_TIMEOUT_MS) || undefined,
|
|
559
|
+
// gpt-5 family only; unset leaves the API's default (medium, the slowest
|
|
560
|
+
// that answers small structured calls: see reasoningHeadroom).
|
|
561
|
+
defaultReasoningEffort: (process.env.OPENAI_REASONING_EFFORT || undefined) as ReasoningEffort | undefined,
|
|
462
562
|
});
|
|
463
563
|
}
|
package/src/types.ts
CHANGED
|
@@ -17,8 +17,10 @@ export interface LLMResponse {
|
|
|
17
17
|
content: string;
|
|
18
18
|
/** Token usage statistics */
|
|
19
19
|
usage: TokenUsage;
|
|
20
|
-
/** Model identifier used for generation */
|
|
20
|
+
/** Model identifier used for generation, as the API reports it (e.g. the dated snapshot `gpt-5.5-2026-04-23`). */
|
|
21
21
|
model: string;
|
|
22
|
+
/** Which provider answered (`openai`, `anthropic`, `mock`). */
|
|
23
|
+
provider?: string;
|
|
22
24
|
/** Response ID for debugging/logging */
|
|
23
25
|
responseId?: string;
|
|
24
26
|
/** Finish reason (e.g., 'stop', 'length', 'content_filter') */
|
|
@@ -37,6 +39,12 @@ export interface GenerateOptions {
|
|
|
37
39
|
maxTokens?: number;
|
|
38
40
|
/** Request timeout in milliseconds */
|
|
39
41
|
timeout?: number;
|
|
42
|
+
/**
|
|
43
|
+
* How much a reasoning model (OpenAI gpt-5 family) thinks before answering.
|
|
44
|
+
* Unset = the API's default. `maxTokens` stays the answer budget; the
|
|
45
|
+
* provider adds headroom for the reasoning on top.
|
|
46
|
+
*/
|
|
47
|
+
reasoningEffort?: ReasoningEffort;
|
|
40
48
|
/** Arbitrary extra data the caller wants to thread through (e.g. domain preferences) */
|
|
41
49
|
extra?: Record<string, unknown>;
|
|
42
50
|
}
|
|
@@ -82,8 +90,13 @@ export interface NativeToolResponse {
|
|
|
82
90
|
/**
|
|
83
91
|
* Provider configuration — everything needed to construct a provider instance.
|
|
84
92
|
*/
|
|
93
|
+
/** OpenAI reasoning effort. Not every model takes every value (gpt-5.5 rejects 'minimal'). */
|
|
94
|
+
export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
95
|
+
|
|
85
96
|
export interface ProviderConfig {
|
|
86
97
|
apiKey: string;
|
|
98
|
+
/** Default reasoning effort for reasoning models (see GenerateOptions.reasoningEffort). */
|
|
99
|
+
defaultReasoningEffort?: ReasoningEffort;
|
|
87
100
|
baseUrl?: string;
|
|
88
101
|
defaultModel?: string;
|
|
89
102
|
defaultTemperature?: number;
|