@startsimpli/llm 0.4.17 → 0.4.19
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 +188 -0
- 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 +68 -7
- 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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the OpenAI provider actually PUTS ON THE WIRE.
|
|
3
|
+
*
|
|
4
|
+
* The sibling of `anthropic-provider.test.ts`, and it exists for the same class
|
|
5
|
+
* of bug: a 400 caused by a parameter WE send, which no amount of response
|
|
6
|
+
* handling can recover from.
|
|
7
|
+
*
|
|
8
|
+
* The acceptance boundary below is not invented. It was measured against
|
|
9
|
+
* api.openai.com with a real key on 2026-08-19, while debugging why a live
|
|
10
|
+
* translation on marketing-agents failed:
|
|
11
|
+
*
|
|
12
|
+
* gpt-5.2 max_tokens -> 400 Unsupported parameter: 'max_tokens' is not
|
|
13
|
+
* supported with this model. Use
|
|
14
|
+
* 'max_completion_tokens' instead.
|
|
15
|
+
* gpt-5.2 max_completion_tokens -> 200
|
|
16
|
+
* gpt-4o max_tokens -> 200
|
|
17
|
+
*
|
|
18
|
+
* So the provider as written could never call ANY gpt-5 model: it always sent
|
|
19
|
+
* `max_tokens`. That is a total outage of the newest family, not an edge case.
|
|
20
|
+
*
|
|
21
|
+
* The second thing pinned here is the FAMILY TEST. bd startsim-768w.38.26 filed
|
|
22
|
+
* the literal-id deny-list (`model === 'gpt-5' || 'gpt-5.1' || 'gpt-5.2'`) as a
|
|
23
|
+
* bug, because it is "correct only until OpenAI ships gpt-5.3". These tests use
|
|
24
|
+
* an unreleased id on purpose, so the rule has to be about the FAMILY.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
27
|
+
|
|
28
|
+
import { OpenAIProvider } from '../providers/openai';
|
|
29
|
+
|
|
30
|
+
interface Sent {
|
|
31
|
+
model: string;
|
|
32
|
+
temperature?: number;
|
|
33
|
+
max_tokens?: number;
|
|
34
|
+
max_completion_tokens?: number;
|
|
35
|
+
reasoning_effort?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const OK_BODY = {
|
|
39
|
+
id: 'chatcmpl-1',
|
|
40
|
+
object: 'chat.completion',
|
|
41
|
+
created: 0,
|
|
42
|
+
model: 'server-reported-model',
|
|
43
|
+
choices: [{ index: 0, message: { role: 'assistant', content: '{"ok":true}' }, finish_reason: 'stop' }],
|
|
44
|
+
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let sent: Sent[];
|
|
48
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
49
|
+
|
|
50
|
+
function provider(defaultModel: string) {
|
|
51
|
+
return new OpenAIProvider({ apiKey: 'sk-test', defaultModel, defaultTemperature: 0.2 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
sent = [];
|
|
56
|
+
fetchMock = vi.fn(async (_url: unknown, init: RequestInit) => {
|
|
57
|
+
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
58
|
+
return { ok: true, status: 200, json: async () => OK_BODY } as unknown as Response;
|
|
59
|
+
});
|
|
60
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
vi.unstubAllGlobals();
|
|
65
|
+
});
|
|
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',
|
|
70
|
+
async (model) => {
|
|
71
|
+
await provider(model).generate('u', 's');
|
|
72
|
+
|
|
73
|
+
expect(sent[0]!.max_completion_tokens).toBeGreaterThan(0);
|
|
74
|
+
expect(sent[0]!.max_tokens).toBeUndefined();
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
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');
|
|
82
|
+
|
|
83
|
+
expect(sent[0]!.max_completion_tokens).toBeGreaterThan(0);
|
|
84
|
+
expect(sent[0]!.max_tokens).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
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');
|
|
90
|
+
|
|
91
|
+
expect(sent[0]!.max_tokens).toBeGreaterThan(0);
|
|
92
|
+
expect(sent[0]!.max_completion_tokens).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
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 });
|
|
100
|
+
|
|
101
|
+
expect(sent[0]!.max_completion_tokens).toBe(123);
|
|
102
|
+
expect(sent[1]!.max_tokens).toBe(123);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/*
|
|
107
|
+
* MEASURED 2026-10-02 against gpt-5.5-2026-04-23, a 256-token structured call:
|
|
108
|
+
* effort unset (API default) -> empty content: reasoning ate the whole cap
|
|
109
|
+
* none -> 2.3s, 35 completion tokens, 0 reasoning
|
|
110
|
+
* low -> 2.8s, 130 completion tokens, 91 reasoning
|
|
111
|
+
* 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.
|
|
116
|
+
*/
|
|
117
|
+
describe('reasoning (gpt-5 family)', () => {
|
|
118
|
+
it('sends reasoning_effort when one is given, to gpt-5 models only', async () => {
|
|
119
|
+
await provider('gpt-5.5').generate('u', 's', { reasoningEffort: 'low' });
|
|
120
|
+
await provider('gpt-4o').generate('u', 's', { reasoningEffort: 'low' });
|
|
121
|
+
expect(sent[0]!.reasoning_effort).toBe('low');
|
|
122
|
+
expect(sent[1]!.reasoning_effort).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
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
|
+
it('takes the default effort from the provider config', async () => {
|
|
131
|
+
await new OpenAIProvider({ apiKey: 'k', defaultModel: 'gpt-5.5', defaultReasoningEffort: 'low' }).generate('u', 's');
|
|
132
|
+
expect(sent[0]!.reasoning_effort).toBe('low');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('adds reasoning headroom on top of the answer budget, scaled to the effort', async () => {
|
|
136
|
+
await provider('gpt-5.5').generate('u', 's', { maxTokens: 256, reasoningEffort: 'none' });
|
|
137
|
+
await provider('gpt-5.5').generate('u', 's', { maxTokens: 256, reasoningEffort: 'low' });
|
|
138
|
+
await provider('gpt-5.5').generate('u', 's', { maxTokens: 256 });
|
|
139
|
+
const [none, low, unset] = sent.map((b) => b.max_completion_tokens!);
|
|
140
|
+
expect(none).toBe(256);
|
|
141
|
+
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);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('response provenance', () => {
|
|
183
|
+
it('reports the provider and the model the API says answered', async () => {
|
|
184
|
+
const res = await provider('gpt-5.5').generate('u', 's');
|
|
185
|
+
expect(res.provider).toBe('openai');
|
|
186
|
+
expect(res.model).toBe('server-reported-model');
|
|
187
|
+
});
|
|
188
|
+
});
|
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,10 +32,61 @@ interface OpenAIRequest {
|
|
|
31
32
|
model: string;
|
|
32
33
|
messages: OpenAIMessage[];
|
|
33
34
|
temperature?: number;
|
|
35
|
+
/** Pre-gpt-5 models. Rejected outright by the gpt-5 family. */
|
|
34
36
|
max_tokens?: number;
|
|
37
|
+
/** The gpt-5 family's replacement for `max_tokens`. */
|
|
38
|
+
max_completion_tokens?: number;
|
|
39
|
+
/** gpt-5 family only. */
|
|
40
|
+
reasoning_effort?: ReasoningEffort;
|
|
35
41
|
response_format?: { type: 'json_object' | 'text' };
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
/**
|
|
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.
|
|
49
|
+
*/
|
|
50
|
+
function isGpt5Family(model: string): boolean {
|
|
51
|
+
return /^gpt-5/.test(model.startsWith('ft:') ? model.slice(3) : model);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
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.
|
|
59
|
+
*/
|
|
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 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Tokens to allow for hidden reasoning, which the gpt-5 family bills against
|
|
68
|
+
* 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.
|
|
72
|
+
*/
|
|
73
|
+
function reasoningHeadroom(effort?: ReasoningEffort): number {
|
|
74
|
+
switch (effort) {
|
|
75
|
+
case 'none': return 0;
|
|
76
|
+
case 'minimal': return 1024;
|
|
77
|
+
case 'low': return 4096;
|
|
78
|
+
case 'high': return 24576;
|
|
79
|
+
case 'xhigh': return 32768;
|
|
80
|
+
case 'medium':
|
|
81
|
+
default: return 12288;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
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 } : {};
|
|
88
|
+
}
|
|
89
|
+
|
|
38
90
|
interface OpenAIResponse {
|
|
39
91
|
id: string;
|
|
40
92
|
object: string;
|
|
@@ -114,7 +166,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
114
166
|
readonly availableModels = OPENAI_MODELS;
|
|
115
167
|
readonly defaultModel: string;
|
|
116
168
|
|
|
117
|
-
private readonly config: Required<ProviderConfig>;
|
|
169
|
+
private readonly config: Required<Omit<ProviderConfig, 'defaultReasoningEffort'>> & Pick<ProviderConfig, 'defaultReasoningEffort'>;
|
|
118
170
|
|
|
119
171
|
constructor(config: ProviderConfig) {
|
|
120
172
|
this.config = {
|
|
@@ -124,6 +176,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
124
176
|
defaultTemperature: config.defaultTemperature ?? DEFAULTS.temperature,
|
|
125
177
|
defaultMaxTokens: config.defaultMaxTokens ?? DEFAULTS.maxTokens,
|
|
126
178
|
timeoutMs: config.timeoutMs ?? DEFAULTS.timeoutMs,
|
|
179
|
+
defaultReasoningEffort: config.defaultReasoningEffort,
|
|
127
180
|
};
|
|
128
181
|
this.defaultModel = this.config.defaultModel;
|
|
129
182
|
}
|
|
@@ -149,12 +202,13 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
149
202
|
|
|
150
203
|
const model = options?.model ?? this.config.defaultModel;
|
|
151
204
|
// Base gpt-5 models require temperature=1.0, but fine-tuned models use configured temperature
|
|
152
|
-
const isBaseGpt5 = model === 'gpt-5' || model === 'gpt-5.1' || model === 'gpt-5.2';
|
|
153
205
|
const isFineTuned = model.startsWith('ft:');
|
|
154
|
-
const
|
|
206
|
+
const isBaseGpt5 = isGpt5Family(model) && !isFineTuned;
|
|
207
|
+
const temperature = isBaseGpt5
|
|
155
208
|
? 1.0
|
|
156
209
|
: (options?.temperature ?? this.config.defaultTemperature);
|
|
157
210
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
211
|
+
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
158
212
|
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
159
213
|
|
|
160
214
|
const requestBody: OpenAIRequest = {
|
|
@@ -164,7 +218,8 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
164
218
|
{ role: 'user', content: userPrompt },
|
|
165
219
|
],
|
|
166
220
|
temperature,
|
|
167
|
-
|
|
221
|
+
...tokenLimitParam(model, maxTokens, effort),
|
|
222
|
+
...reasoningParam(model, effort),
|
|
168
223
|
response_format: { type: 'json_object' },
|
|
169
224
|
};
|
|
170
225
|
|
|
@@ -211,6 +266,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
211
266
|
totalTokens: data.usage.total_tokens,
|
|
212
267
|
},
|
|
213
268
|
model: data.model,
|
|
269
|
+
provider: this.name,
|
|
214
270
|
responseId: data.id,
|
|
215
271
|
finishReason: data.choices[0].finish_reason,
|
|
216
272
|
};
|
|
@@ -264,12 +320,13 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
264
320
|
}
|
|
265
321
|
|
|
266
322
|
const model = options?.model ?? this.config.defaultModel;
|
|
267
|
-
const isBaseGpt5 = model === 'gpt-5' || model === 'gpt-5.1' || model === 'gpt-5.2';
|
|
268
323
|
const isFineTuned = model.startsWith('ft:');
|
|
269
|
-
const
|
|
324
|
+
const isBaseGpt5 = isGpt5Family(model) && !isFineTuned;
|
|
325
|
+
const temperature = isBaseGpt5
|
|
270
326
|
? 1.0
|
|
271
327
|
: (options?.temperature ?? this.config.defaultTemperature);
|
|
272
328
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
329
|
+
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
273
330
|
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
274
331
|
|
|
275
332
|
const oaiMessages: OpenAIToolMessage[] = [
|
|
@@ -297,7 +354,8 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
297
354
|
model,
|
|
298
355
|
messages: oaiMessages,
|
|
299
356
|
temperature,
|
|
300
|
-
|
|
357
|
+
...tokenLimitParam(model, maxTokens, effort),
|
|
358
|
+
...reasoningParam(model, effort),
|
|
301
359
|
tools: tools.map((t) => ({
|
|
302
360
|
type: 'function' as const,
|
|
303
361
|
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
@@ -434,5 +492,8 @@ export function createOpenAIProvider(): OpenAIProvider | null {
|
|
|
434
492
|
// deployment's timeout and the other ignoring it is the kind of asymmetry
|
|
435
493
|
// that only shows up as a provider-dependent bug at 3am.
|
|
436
494
|
timeoutMs: Number(process.env.LLM_TIMEOUT_MS) || undefined,
|
|
495
|
+
// gpt-5 family only; unset leaves the API's default (medium, the slowest
|
|
496
|
+
// that answers small structured calls: see reasoningHeadroom).
|
|
497
|
+
defaultReasoningEffort: (process.env.OPENAI_REASONING_EFFORT || undefined) as ReasoningEffort | undefined,
|
|
437
498
|
});
|
|
438
499
|
}
|
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;
|