@startsimpli/llm 0.4.17 → 0.4.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/llm",
3
- "version": "0.4.17",
3
+ "version": "0.4.18",
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,126 @@
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
+ }
36
+
37
+ const OK_BODY = {
38
+ id: 'chatcmpl-1',
39
+ object: 'chat.completion',
40
+ created: 0,
41
+ model: 'server-reported-model',
42
+ choices: [{ index: 0, message: { role: 'assistant', content: '{"ok":true}' }, finish_reason: 'stop' }],
43
+ usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
44
+ };
45
+
46
+ let sent: Sent[];
47
+ let fetchMock: ReturnType<typeof vi.fn>;
48
+
49
+ function provider(defaultModel: string) {
50
+ return new OpenAIProvider({ apiKey: 'sk-test', defaultModel, defaultTemperature: 0.2 });
51
+ }
52
+
53
+ beforeEach(() => {
54
+ sent = [];
55
+ fetchMock = vi.fn(async (_url: unknown, init: RequestInit) => {
56
+ sent.push(JSON.parse(String(init.body)) as Sent);
57
+ return { ok: true, status: 200, json: async () => OK_BODY } as unknown as Response;
58
+ });
59
+ vi.stubGlobal('fetch', fetchMock);
60
+ });
61
+
62
+ afterEach(() => {
63
+ vi.unstubAllGlobals();
64
+ });
65
+
66
+ describe('the token-limit parameter', () => {
67
+ it.each(['gpt-5', 'gpt-5.1', 'gpt-5.2'])(
68
+ '%s gets max_completion_tokens and never max_tokens',
69
+ async (model) => {
70
+ await provider(model).generate('u', 's');
71
+
72
+ expect(sent[0]!.max_completion_tokens).toBeGreaterThan(0);
73
+ expect(sent[0]!.max_tokens).toBeUndefined();
74
+ },
75
+ );
76
+
77
+ it('an UNRELEASED gpt-5 model is handled by family, not by a hardcoded list', async () => {
78
+ // bd startsim-768w.38.26. The day this id ships, a literal deny-list sends
79
+ // it max_tokens and every call 400s — silently, from the caller's view.
80
+ await provider('gpt-5.3').generate('u', 's');
81
+
82
+ expect(sent[0]!.max_completion_tokens).toBeGreaterThan(0);
83
+ expect(sent[0]!.max_tokens).toBeUndefined();
84
+ });
85
+
86
+ it('gpt-4o keeps max_tokens, which it accepts', async () => {
87
+ // The fix must not break the models that were working.
88
+ await provider('gpt-4o').generate('u', 's');
89
+
90
+ expect(sent[0]!.max_tokens).toBeGreaterThan(0);
91
+ expect(sent[0]!.max_completion_tokens).toBeUndefined();
92
+ });
93
+
94
+ it('honours an explicit maxTokens on whichever parameter the model takes', async () => {
95
+ await provider('gpt-5.2').generate('u', 's', { maxTokens: 123 });
96
+ await provider('gpt-4o').generate('u', 's', { maxTokens: 123 });
97
+
98
+ expect(sent[0]!.max_completion_tokens).toBe(123);
99
+ expect(sent[1]!.max_tokens).toBe(123);
100
+ });
101
+ });
102
+
103
+ describe('temperature', () => {
104
+ it.each(['gpt-5', 'gpt-5.1', 'gpt-5.2', 'gpt-5.3'])(
105
+ '%s is sent temperature 1.0, whatever the deployment configured',
106
+ async (model) => {
107
+ // Base gpt-5 models reject any other value.
108
+ await provider(model).generate('u', 's', { temperature: 0.2 });
109
+
110
+ expect(sent[0]!.temperature).toBe(1.0);
111
+ },
112
+ );
113
+
114
+ it('a FINE-TUNED gpt-5 keeps the configured temperature', async () => {
115
+ // The existing carve-out, preserved: ft: models do accept it.
116
+ await provider('ft:gpt-5.2:acme::abc123').generate('u', 's', { temperature: 0.2 });
117
+
118
+ expect(sent[0]!.temperature).toBe(0.2);
119
+ });
120
+
121
+ it('gpt-4o keeps the configured temperature', async () => {
122
+ await provider('gpt-4o').generate('u', 's', { temperature: 0.2 });
123
+
124
+ expect(sent[0]!.temperature).toBe(0.2);
125
+ });
126
+ });
@@ -31,10 +31,35 @@ interface OpenAIRequest {
31
31
  model: string;
32
32
  messages: OpenAIMessage[];
33
33
  temperature?: number;
34
+ /** Pre-gpt-5 models. Rejected outright by the gpt-5 family. */
34
35
  max_tokens?: number;
36
+ /** The gpt-5 family's replacement for `max_tokens`. */
37
+ max_completion_tokens?: number;
35
38
  response_format?: { type: 'json_object' | 'text' };
36
39
  }
37
40
 
41
+ /**
42
+ * Is this the gpt-5 FAMILY? Matched by prefix, deliberately, not by a list of
43
+ * ids (bd startsim-768w.38.26 filed the literal-id deny-list as a bug: it is
44
+ * "correct only until OpenAI ships gpt-5.3", and the failure is a silent 400).
45
+ * `ft:gpt-5…` fine-tunes are still gpt-5 models on the wire.
46
+ */
47
+ function isGpt5Family(model: string): boolean {
48
+ return /^gpt-5/.test(model.startsWith('ft:') ? model.slice(3) : model);
49
+ }
50
+
51
+ /**
52
+ * The token-limit parameter this model accepts. MEASURED against api.openai.com
53
+ * on 2026-08-19: gpt-5.2 rejects `max_tokens` with "Unsupported parameter ...
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.
56
+ */
57
+ function tokenLimitParam(model: string, maxTokens: number): Partial<OpenAIRequest> {
58
+ return isGpt5Family(model)
59
+ ? { max_completion_tokens: maxTokens }
60
+ : { max_tokens: maxTokens };
61
+ }
62
+
38
63
  interface OpenAIResponse {
39
64
  id: string;
40
65
  object: string;
@@ -149,9 +174,9 @@ export class OpenAIProvider implements ILLMProvider {
149
174
 
150
175
  const model = options?.model ?? this.config.defaultModel;
151
176
  // 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
177
  const isFineTuned = model.startsWith('ft:');
154
- const temperature = isBaseGpt5 && !isFineTuned
178
+ const isBaseGpt5 = isGpt5Family(model) && !isFineTuned;
179
+ const temperature = isBaseGpt5
155
180
  ? 1.0
156
181
  : (options?.temperature ?? this.config.defaultTemperature);
157
182
  const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
@@ -164,7 +189,7 @@ export class OpenAIProvider implements ILLMProvider {
164
189
  { role: 'user', content: userPrompt },
165
190
  ],
166
191
  temperature,
167
- max_tokens: maxTokens,
192
+ ...tokenLimitParam(model, maxTokens),
168
193
  response_format: { type: 'json_object' },
169
194
  };
170
195
 
@@ -264,9 +289,9 @@ export class OpenAIProvider implements ILLMProvider {
264
289
  }
265
290
 
266
291
  const model = options?.model ?? this.config.defaultModel;
267
- const isBaseGpt5 = model === 'gpt-5' || model === 'gpt-5.1' || model === 'gpt-5.2';
268
292
  const isFineTuned = model.startsWith('ft:');
269
- const temperature = isBaseGpt5 && !isFineTuned
293
+ const isBaseGpt5 = isGpt5Family(model) && !isFineTuned;
294
+ const temperature = isBaseGpt5
270
295
  ? 1.0
271
296
  : (options?.temperature ?? this.config.defaultTemperature);
272
297
  const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
@@ -297,7 +322,7 @@ export class OpenAIProvider implements ILLMProvider {
297
322
  model,
298
323
  messages: oaiMessages,
299
324
  temperature,
300
- max_tokens: maxTokens,
325
+ ...tokenLimitParam(model, maxTokens),
301
326
  tools: tools.map((t) => ({
302
327
  type: 'function' as const,
303
328
  function: { name: t.name, description: t.description, parameters: t.parameters },