@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 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.18",
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",
@@ -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
- await provider('gpt-5.2').generate('u', 's', { maxTokens: 123 });
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
@@ -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';
@@ -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;