@startsimpli/llm 0.4.18 → 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 +63 -1
- 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 +41 -5
- 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 = {
|
|
@@ -92,7 +93,9 @@ describe('the token-limit parameter', () => {
|
|
|
92
93
|
});
|
|
93
94
|
|
|
94
95
|
it('honours an explicit maxTokens on whichever parameter the model takes', async () => {
|
|
95
|
-
|
|
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' });
|
|
96
99
|
await provider('gpt-4o').generate('u', 's', { maxTokens: 123 });
|
|
97
100
|
|
|
98
101
|
expect(sent[0]!.max_completion_tokens).toBe(123);
|
|
@@ -100,6 +103,57 @@ describe('the token-limit parameter', () => {
|
|
|
100
103
|
});
|
|
101
104
|
});
|
|
102
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
|
+
|
|
103
157
|
describe('temperature', () => {
|
|
104
158
|
it.each(['gpt-5', 'gpt-5.1', 'gpt-5.2', 'gpt-5.3'])(
|
|
105
159
|
'%s is sent temperature 1.0, whatever the deployment configured',
|
|
@@ -124,3 +178,11 @@ describe('temperature', () => {
|
|
|
124
178
|
expect(sent[0]!.temperature).toBe(0.2);
|
|
125
179
|
});
|
|
126
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';
|
|
@@ -35,6 +36,8 @@ interface OpenAIRequest {
|
|
|
35
36
|
max_tokens?: number;
|
|
36
37
|
/** The gpt-5 family's replacement for `max_tokens`. */
|
|
37
38
|
max_completion_tokens?: number;
|
|
39
|
+
/** gpt-5 family only. */
|
|
40
|
+
reasoning_effort?: ReasoningEffort;
|
|
38
41
|
response_format?: { type: 'json_object' | 'text' };
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -54,12 +57,36 @@ function isGpt5Family(model: string): boolean {
|
|
|
54
57
|
* Use 'max_completion_tokens' instead", while gpt-4o accepts `max_tokens`.
|
|
55
58
|
* Sending the wrong one is a total outage of that model, not a degraded call.
|
|
56
59
|
*/
|
|
57
|
-
function tokenLimitParam(model: string, maxTokens: number): Partial<OpenAIRequest> {
|
|
60
|
+
function tokenLimitParam(model: string, maxTokens: number, effort?: ReasoningEffort): Partial<OpenAIRequest> {
|
|
58
61
|
return isGpt5Family(model)
|
|
59
|
-
? { max_completion_tokens: maxTokens }
|
|
62
|
+
? { max_completion_tokens: maxTokens + reasoningHeadroom(effort) }
|
|
60
63
|
: { max_tokens: maxTokens };
|
|
61
64
|
}
|
|
62
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
|
+
|
|
63
90
|
interface OpenAIResponse {
|
|
64
91
|
id: string;
|
|
65
92
|
object: string;
|
|
@@ -139,7 +166,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
139
166
|
readonly availableModels = OPENAI_MODELS;
|
|
140
167
|
readonly defaultModel: string;
|
|
141
168
|
|
|
142
|
-
private readonly config: Required<ProviderConfig>;
|
|
169
|
+
private readonly config: Required<Omit<ProviderConfig, 'defaultReasoningEffort'>> & Pick<ProviderConfig, 'defaultReasoningEffort'>;
|
|
143
170
|
|
|
144
171
|
constructor(config: ProviderConfig) {
|
|
145
172
|
this.config = {
|
|
@@ -149,6 +176,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
149
176
|
defaultTemperature: config.defaultTemperature ?? DEFAULTS.temperature,
|
|
150
177
|
defaultMaxTokens: config.defaultMaxTokens ?? DEFAULTS.maxTokens,
|
|
151
178
|
timeoutMs: config.timeoutMs ?? DEFAULTS.timeoutMs,
|
|
179
|
+
defaultReasoningEffort: config.defaultReasoningEffort,
|
|
152
180
|
};
|
|
153
181
|
this.defaultModel = this.config.defaultModel;
|
|
154
182
|
}
|
|
@@ -180,6 +208,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
180
208
|
? 1.0
|
|
181
209
|
: (options?.temperature ?? this.config.defaultTemperature);
|
|
182
210
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
211
|
+
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
183
212
|
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
184
213
|
|
|
185
214
|
const requestBody: OpenAIRequest = {
|
|
@@ -189,7 +218,8 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
189
218
|
{ role: 'user', content: userPrompt },
|
|
190
219
|
],
|
|
191
220
|
temperature,
|
|
192
|
-
...tokenLimitParam(model, maxTokens),
|
|
221
|
+
...tokenLimitParam(model, maxTokens, effort),
|
|
222
|
+
...reasoningParam(model, effort),
|
|
193
223
|
response_format: { type: 'json_object' },
|
|
194
224
|
};
|
|
195
225
|
|
|
@@ -236,6 +266,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
236
266
|
totalTokens: data.usage.total_tokens,
|
|
237
267
|
},
|
|
238
268
|
model: data.model,
|
|
269
|
+
provider: this.name,
|
|
239
270
|
responseId: data.id,
|
|
240
271
|
finishReason: data.choices[0].finish_reason,
|
|
241
272
|
};
|
|
@@ -295,6 +326,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
295
326
|
? 1.0
|
|
296
327
|
: (options?.temperature ?? this.config.defaultTemperature);
|
|
297
328
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
329
|
+
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
298
330
|
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
299
331
|
|
|
300
332
|
const oaiMessages: OpenAIToolMessage[] = [
|
|
@@ -322,7 +354,8 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
322
354
|
model,
|
|
323
355
|
messages: oaiMessages,
|
|
324
356
|
temperature,
|
|
325
|
-
...tokenLimitParam(model, maxTokens),
|
|
357
|
+
...tokenLimitParam(model, maxTokens, effort),
|
|
358
|
+
...reasoningParam(model, effort),
|
|
326
359
|
tools: tools.map((t) => ({
|
|
327
360
|
type: 'function' as const,
|
|
328
361
|
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
@@ -459,5 +492,8 @@ export function createOpenAIProvider(): OpenAIProvider | null {
|
|
|
459
492
|
// deployment's timeout and the other ignoring it is the kind of asymmetry
|
|
460
493
|
// that only shows up as a provider-dependent bug at 3am.
|
|
461
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,
|
|
462
498
|
});
|
|
463
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;
|