@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/llm",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
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",
@@ -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
@@ -7,6 +7,7 @@ export type {
7
7
  TokenUsage,
8
8
  LLMResponse,
9
9
  GenerateOptions,
10
+ ReasoningEffort,
10
11
  ProviderConfig,
11
12
  JsonExtractionResult,
12
13
  ValidationResult,
@@ -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
  };
@@ -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
  };
@@ -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 temperature = isBaseGpt5 && !isFineTuned
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
- max_tokens: maxTokens,
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 temperature = isBaseGpt5 && !isFineTuned
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
- max_tokens: maxTokens,
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;