@startsimpli/llm 0.4.14 → 0.4.15

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.14",
3
+ "version": "0.4.15",
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,171 @@
1
+ /**
2
+ * What the Anthropic provider actually PUTS ON THE WIRE (bd startsim-768w.38).
3
+ *
4
+ * These assert the request body, not the response, because the bug they exist
5
+ * for was a 400 from a parameter we sent — `temperature` is rejected outright by
6
+ * the Claude 5 family, so a provider that always sends it can never call the
7
+ * models we want to be on.
8
+ *
9
+ * The acceptance boundary below is not invented. It was measured against
10
+ * api.anthropic.com with a real key on 2026-08-10:
11
+ *
12
+ * claude-sonnet-5 temperature -> 400 `temperature` is deprecated for this model.
13
+ * claude-opus-5 temperature -> 400 (same; also top_p, top_k)
14
+ * claude-fable-5 temperature -> 400
15
+ * claude-opus-4-8 temperature -> 400
16
+ * claude-opus-4-7 temperature -> 400
17
+ * claude-opus-4-6 temperature -> 200
18
+ * claude-sonnet-4-6 temperature -> 200
19
+ * claude-sonnet-4-5-* temperature -> 200
20
+ */
21
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
+
23
+ import { AnthropicProvider } from '../providers/anthropic';
24
+ import { LLMProviderError } from '../errors';
25
+
26
+ interface Sent {
27
+ model: string;
28
+ temperature?: number;
29
+ top_p?: number;
30
+ max_tokens: number;
31
+ }
32
+
33
+ const OK_BODY = {
34
+ id: 'msg_1',
35
+ type: 'message',
36
+ role: 'assistant',
37
+ content: [{ type: 'text', text: '{"ok":true}' }],
38
+ model: 'server-reported-model',
39
+ stop_reason: 'end_turn',
40
+ usage: { input_tokens: 10, output_tokens: 4 },
41
+ };
42
+
43
+ function ok() {
44
+ return {
45
+ ok: true,
46
+ status: 200,
47
+ json: async () => OK_BODY,
48
+ } as unknown as Response;
49
+ }
50
+
51
+ function deprecated(param: string) {
52
+ return {
53
+ ok: false,
54
+ status: 400,
55
+ json: async () => ({
56
+ type: 'error',
57
+ error: {
58
+ type: 'invalid_request_error',
59
+ message: `\`${param}\` is deprecated for this model.`,
60
+ },
61
+ }),
62
+ } as unknown as Response;
63
+ }
64
+
65
+ let sent: Sent[];
66
+ let fetchMock: ReturnType<typeof vi.fn>;
67
+
68
+ function provider(defaultModel: string) {
69
+ return new AnthropicProvider({ apiKey: 'sk-ant-test', defaultModel, defaultTemperature: 0.2 });
70
+ }
71
+
72
+ beforeEach(() => {
73
+ sent = [];
74
+ fetchMock = vi.fn(async (_url: unknown, init: RequestInit) => {
75
+ sent.push(JSON.parse(String(init.body)) as Sent);
76
+ return ok();
77
+ });
78
+ vi.stubGlobal('fetch', fetchMock);
79
+ });
80
+
81
+ afterEach(() => {
82
+ vi.unstubAllGlobals();
83
+ });
84
+
85
+ describe('AnthropicProvider request body — sampling parameters', () => {
86
+ it('omits temperature for a Claude 5 model that rejects it', async () => {
87
+ await provider('claude-sonnet-5').generate('hi', 'sys', { temperature: 0.2 });
88
+
89
+ expect(sent).toHaveLength(1);
90
+ expect(sent[0].model).toBe('claude-sonnet-5');
91
+ expect(sent[0]).not.toHaveProperty('temperature');
92
+ // The parameters that ARE required still go, or the call is malformed.
93
+ expect(sent[0].max_tokens).toBeGreaterThan(0);
94
+ });
95
+
96
+ it.each(['claude-opus-5', 'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7'])(
97
+ 'omits temperature for %s',
98
+ async (model) => {
99
+ await provider(model).generate('hi', 'sys', { temperature: 0.2 });
100
+ expect(sent[0]).not.toHaveProperty('temperature');
101
+ },
102
+ );
103
+
104
+ it('still sends temperature for a 4.6-era model, which accepts it', async () => {
105
+ await provider('claude-sonnet-4-6').generate('hi', 'sys', { temperature: 0.2 });
106
+
107
+ expect(sent[0].model).toBe('claude-sonnet-4-6');
108
+ expect(sent[0].temperature).toBe(0.2);
109
+ });
110
+
111
+ it.each(['claude-opus-4-6', 'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001'])(
112
+ 'still sends temperature for %s',
113
+ async (model) => {
114
+ await provider(model).generate('hi', 'sys', { temperature: 0.35 });
115
+ expect(sent[0].temperature).toBe(0.35);
116
+ },
117
+ );
118
+
119
+ it('omits temperature for a model id it has never heard of', async () => {
120
+ // The whole point of the allowlist shape: an id that does not exist yet gets
121
+ // the request that CANNOT 400, not the one that historically could.
122
+ await provider('claude-sonnet-7-hypothetical').generate('hi', 'sys', { temperature: 0.2 });
123
+ expect(sent[0]).not.toHaveProperty('temperature');
124
+ });
125
+
126
+ it('names a model that is actually callable as the package default', async () => {
127
+ // The old default, claude-sonnet-4-20250514, was retired and 404s. A default
128
+ // nobody can call is a broken default.
129
+ const p = new AnthropicProvider({ apiKey: 'sk-ant-test' });
130
+ await p.generate('hi', 'sys');
131
+ expect(sent[0].model).toBe(p.defaultModel);
132
+ expect(p.availableModels).toContain(p.defaultModel);
133
+ });
134
+ });
135
+
136
+ describe('AnthropicProvider — recovering from a parameter the model rejects', () => {
137
+ it('drops the named parameter and retries once when the API calls it deprecated', async () => {
138
+ // A model on the allowlist that the API has since changed its mind about:
139
+ // the safety net must un-break it without waiting for a release.
140
+ fetchMock.mockImplementationOnce(async (_url: unknown, init: RequestInit) => {
141
+ sent.push(JSON.parse(String(init.body)) as Sent);
142
+ return deprecated('temperature');
143
+ });
144
+
145
+ const response = await provider('claude-opus-4-6').generate('hi', 'sys', { temperature: 0.2 });
146
+
147
+ expect(sent).toHaveLength(2);
148
+ expect(sent[0].temperature).toBe(0.2);
149
+ expect(sent[1]).not.toHaveProperty('temperature');
150
+ expect(response.content).toBe('{"ok":true}');
151
+ });
152
+
153
+ it('does not retry a 400 that is not about a deprecated parameter', async () => {
154
+ fetchMock.mockImplementation(async (_url: unknown, init: RequestInit) => {
155
+ sent.push(JSON.parse(String(init.body)) as Sent);
156
+ return {
157
+ ok: false,
158
+ status: 400,
159
+ json: async () => ({
160
+ type: 'error',
161
+ error: { type: 'invalid_request_error', message: 'messages: at least one message required' },
162
+ }),
163
+ } as unknown as Response;
164
+ });
165
+
166
+ await expect(provider('claude-sonnet-4-6').generate('hi', 'sys')).rejects.toBeInstanceOf(
167
+ LLMProviderError,
168
+ );
169
+ expect(sent).toHaveLength(1);
170
+ });
171
+ });
package/src/index.ts CHANGED
@@ -23,7 +23,11 @@ export type { ILLMProvider } from './providers/interface';
23
23
 
24
24
  // Provider implementations
25
25
  export { OpenAIProvider, createOpenAIProvider } from './providers/openai';
26
- export { AnthropicProvider, createAnthropicProvider } from './providers/anthropic';
26
+ export {
27
+ AnthropicProvider,
28
+ createAnthropicProvider,
29
+ supportsSamplingParams,
30
+ } from './providers/anthropic';
27
31
  export { MockLLMProvider, setMockBehavior, resetMockBehavior } from './providers/mock';
28
32
  export type { MockBehavior } from './providers/mock';
29
33
 
@@ -2,7 +2,7 @@
2
2
  * Anthropic Provider Implementation
3
3
  *
4
4
  * Uses native fetch to minimize dependencies.
5
- * Supports Claude 4 and Claude 3.x models.
5
+ * Supports the Claude 5 family and the Claude 4.x models still served.
6
6
  */
7
7
 
8
8
  import type { ILLMProvider } from './interface';
@@ -21,6 +21,10 @@ interface AnthropicRequest {
21
21
  max_tokens: number;
22
22
  messages: AnthropicMessage[];
23
23
  system?: string;
24
+ /**
25
+ * Sent ONLY for models known to accept it — see `supportsSamplingParams`.
26
+ * The Claude 5 family rejects it outright.
27
+ */
24
28
  temperature?: number;
25
29
  }
26
30
 
@@ -45,30 +49,94 @@ interface AnthropicErrorResponse {
45
49
  };
46
50
  }
47
51
 
52
+ /**
53
+ * The models this provider advertises. Verified callable against
54
+ * api.anthropic.com on 2026-08-10 — every id in the previous version of this
55
+ * list except the two 4.6 entries had been retired and returned 404, which is
56
+ * the same staleness failure the temperature handling below is designed around.
57
+ */
48
58
  const ANTHROPIC_MODELS = [
59
+ 'claude-opus-5',
60
+ 'claude-sonnet-5',
61
+ 'claude-fable-5',
62
+ 'claude-opus-4-8',
63
+ 'claude-opus-4-7',
49
64
  'claude-opus-4-6',
50
- 'claude-opus-4-20250514',
51
65
  'claude-sonnet-4-6',
52
- 'claude-sonnet-4-20250514',
53
- 'claude-3-5-sonnet-20241022',
54
- 'claude-3-5-haiku-20241022',
55
- 'claude-3-opus-20240229',
56
- 'claude-3-sonnet-20240229',
57
- 'claude-3-haiku-20240307',
66
+ 'claude-opus-4-5-20251101',
67
+ 'claude-sonnet-4-5-20250929',
68
+ 'claude-haiku-4-5-20251001',
58
69
  ] as const;
59
70
 
71
+ /** USD per 1K tokens (list prices per million, divided by 1000). */
60
72
  const MODEL_COSTS: Record<string, { input: number; output: number }> = {
61
- 'claude-opus-4-6': { input: 0.015, output: 0.075 },
62
- 'claude-opus-4-20250514': { input: 0.015, output: 0.075 },
73
+ 'claude-fable-5': { input: 0.01, output: 0.05 },
74
+ 'claude-opus-5': { input: 0.005, output: 0.025 },
75
+ 'claude-opus-4-8': { input: 0.005, output: 0.025 },
76
+ 'claude-opus-4-7': { input: 0.005, output: 0.025 },
77
+ 'claude-opus-4-6': { input: 0.005, output: 0.025 },
78
+ 'claude-opus-4-5-20251101': { input: 0.005, output: 0.025 },
79
+ 'claude-sonnet-5': { input: 0.003, output: 0.015 },
63
80
  'claude-sonnet-4-6': { input: 0.003, output: 0.015 },
64
- 'claude-sonnet-4-20250514': { input: 0.003, output: 0.015 },
65
- 'claude-3-5-sonnet-20241022': { input: 0.003, output: 0.015 },
66
- 'claude-3-5-haiku-20241022': { input: 0.0008, output: 0.004 },
67
- 'claude-3-opus-20240229': { input: 0.015, output: 0.075 },
68
- 'claude-3-sonnet-20240229': { input: 0.003, output: 0.015 },
69
- 'claude-3-haiku-20240307': { input: 0.00025, output: 0.00125 },
81
+ 'claude-sonnet-4-5-20250929': { input: 0.003, output: 0.015 },
82
+ 'claude-haiku-4-5-20251001': { input: 0.001, output: 0.005 },
70
83
  };
71
84
 
85
+ /**
86
+ * Model-id prefixes KNOWN to accept the sampling parameters (`temperature`,
87
+ * `top_p`, `top_k`). Anything not on this list is sent WITHOUT them.
88
+ *
89
+ * WHY AN ALLOWLIST, NOT A LIST OF MODELS THAT REJECT IT. A deny-list of literal
90
+ * ids — the shape `OpenAIProvider` uses for its `gpt-5` temperature=1 special
91
+ * case — is correct exactly until the next model ships, and then it silently
92
+ * sends a parameter the API refuses. That is not hypothetical: it is the bug
93
+ * this list exists to fix, and the failure is total (every call 400s) rather
94
+ * than degraded. Inverting it makes an unknown id — i.e. every future model —
95
+ * get the request that CANNOT 400. The two error cases are not symmetric:
96
+ * omitting temperature from a model that would have accepted it costs some
97
+ * determinism; sending it to a model that rejects it costs the whole feature.
98
+ *
99
+ * WHY NOT A VERSION COMPARISON. Measured against api.anthropic.com 2026-08-10:
100
+ * 4.5 and 4.6 accept `temperature`; 4.7, 4.8 and the entire 5 family return
101
+ * `400 \`temperature\` is deprecated for this model.` The boundary sits between
102
+ * 4.6 and 4.7, which no plausible "removed in v5" or "removed in 4.8" rule
103
+ * predicts — so this is recorded from observation, not derived from a guess.
104
+ *
105
+ * The list is also closed in the direction that matters: it can only ever go
106
+ * stale by omitting a NEW model that would have accepted the parameter, never
107
+ * by naming one that rejects it. Should that happen, the retry in `generate()`
108
+ * covers it without a release.
109
+ */
110
+ const SAMPLING_PARAM_MODEL_PREFIXES = [
111
+ 'claude-3',
112
+ 'claude-opus-4-5',
113
+ 'claude-opus-4-6',
114
+ 'claude-sonnet-4-5',
115
+ 'claude-sonnet-4-6',
116
+ 'claude-haiku-4-5',
117
+ 'claude-haiku-4-6',
118
+ 'claude-opus-4-2025',
119
+ 'claude-sonnet-4-2025',
120
+ 'claude-opus-4-1',
121
+ ] as const;
122
+
123
+ /** True when `model` is known to accept `temperature` / `top_p` / `top_k`. */
124
+ export function supportsSamplingParams(model: string): boolean {
125
+ const id = model.trim().toLowerCase();
126
+ return SAMPLING_PARAM_MODEL_PREFIXES.some((prefix) => id.startsWith(prefix));
127
+ }
128
+
129
+ /**
130
+ * The parameter named by a "deprecated for this model" 400, if that is what the
131
+ * error is. Anthropic's message is `` `temperature` is deprecated for this model. ``
132
+ * and the same wording covers `top_p` and `top_k`, so one matcher handles every
133
+ * sampling parameter — including ones not yet deprecated when this was written.
134
+ */
135
+ export function deprecatedParameter(message: string): string | null {
136
+ const match = /`([a-z_]+)`\s+is\s+deprecated\s+for\s+this\s+model/i.exec(message);
137
+ return match ? match[1] : null;
138
+ }
139
+
72
140
  const DEFAULTS = {
73
141
  baseUrl: 'https://api.anthropic.com/v1',
74
142
  model: LLM_DEFAULTS.anthropicModel,
@@ -133,35 +201,32 @@ Please respond with a valid JSON object only. Do not include any text before or
133
201
  max_tokens: maxTokens,
134
202
  messages: [{ role: 'user', content: jsonWrappedPrompt }],
135
203
  system: systemPrompt,
136
- temperature,
137
204
  };
138
-
139
- const controller = new AbortController();
140
- const timeoutId = setTimeout(() => controller.abort(), timeout);
205
+ // Only for models known to take it. The Claude 5 family answers a request
206
+ // carrying `temperature` with a 400 and nothing else.
207
+ if (supportsSamplingParams(model)) {
208
+ requestBody.temperature = temperature;
209
+ }
141
210
 
142
211
  try {
143
- const response = await fetch(`${this.config.baseUrl}/messages`, {
144
- method: 'POST',
145
- headers: {
146
- 'Content-Type': 'application/json',
147
- 'x-api-key': this.config.apiKey,
148
- 'anthropic-version': this.apiVersion,
149
- },
150
- body: JSON.stringify(requestBody),
151
- signal: controller.signal,
152
- });
153
-
154
- clearTimeout(timeoutId);
155
-
156
- if (!response.ok) {
157
- const errorData = (await response.json().catch(() => ({
158
- error: { type: 'unknown', message: 'Unknown error' },
159
- }))) as AnthropicErrorResponse;
160
- throw this.handleApiError(response.status, errorData);
212
+ let data: AnthropicResponse;
213
+ try {
214
+ data = await this.post(requestBody, timeout);
215
+ } catch (error) {
216
+ // SAFETY NET for a parameter the API has since stopped accepting on a
217
+ // model the allowlist still vouches for. It also covers parameters this
218
+ // provider does not send today (`top_p`, `top_k`), so adding one later
219
+ // cannot reintroduce this failure. One retry, only when the API itself
220
+ // named the offending key — never a blind resend.
221
+ const param =
222
+ error instanceof LLMProviderError && error.statusCode === 400
223
+ ? deprecatedParameter(error.message)
224
+ : null;
225
+ if (!param || !(param in requestBody)) throw error;
226
+ delete (requestBody as unknown as Record<string, unknown>)[param];
227
+ data = await this.post(requestBody, timeout);
161
228
  }
162
229
 
163
- const data = (await response.json()) as AnthropicResponse;
164
-
165
230
  const textContent = data.content.find((c) => c.type === 'text');
166
231
  if (!textContent?.text) {
167
232
  throw new LLMProviderError(
@@ -185,8 +250,6 @@ Please respond with a valid JSON object only. Do not include any text before or
185
250
  finishReason: data.stop_reason ?? undefined,
186
251
  };
187
252
  } catch (error) {
188
- clearTimeout(timeoutId);
189
-
190
253
  if (error instanceof LLMProviderError) {
191
254
  throw error;
192
255
  }
@@ -211,6 +274,39 @@ Please respond with a valid JSON object only. Do not include any text before or
211
274
  }
212
275
  }
213
276
 
277
+ /**
278
+ * One POST to /messages. Each attempt gets its own timeout — a retry that
279
+ * strips a rejected parameter is a fresh request, and inheriting an
280
+ * already-consumed deadline would abort it before it could succeed.
281
+ */
282
+ private async post(body: AnthropicRequest, timeout: number): Promise<AnthropicResponse> {
283
+ const controller = new AbortController();
284
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
285
+ try {
286
+ const response = await fetch(`${this.config.baseUrl}/messages`, {
287
+ method: 'POST',
288
+ headers: {
289
+ 'Content-Type': 'application/json',
290
+ 'x-api-key': this.config.apiKey,
291
+ 'anthropic-version': this.apiVersion,
292
+ },
293
+ body: JSON.stringify(body),
294
+ signal: controller.signal,
295
+ });
296
+
297
+ if (!response.ok) {
298
+ const errorData = (await response.json().catch(() => ({
299
+ error: { type: 'unknown', message: 'Unknown error' },
300
+ }))) as AnthropicErrorResponse;
301
+ throw this.handleApiError(response.status, errorData);
302
+ }
303
+
304
+ return (await response.json()) as AnthropicResponse;
305
+ } finally {
306
+ clearTimeout(timeoutId);
307
+ }
308
+ }
309
+
214
310
  estimateCost(
215
311
  promptTokens: number,
216
312
  completionTokens: number,
package/src/types.ts CHANGED
@@ -133,11 +133,17 @@ export interface LLMServiceConfig {
133
133
  */
134
134
  export const LLM_DEFAULTS = {
135
135
  openaiModel: 'gpt-4o-mini',
136
- anthropicModel: 'claude-sonnet-4-20250514',
136
+ /**
137
+ * The previous default, `claude-sonnet-4-20250514`, was retired and returns
138
+ * 404 — a default nobody can call. Verified callable 2026-08-10.
139
+ */
140
+ anthropicModel: 'claude-sonnet-5',
137
141
  /**
138
142
  * Default sampling temperature.
139
143
  * Note: base gpt-5 models (gpt-5, gpt-5.1, gpt-5.2) require temperature=1.0
140
- * and the OpenAI provider enforces this automatically.
144
+ * and the OpenAI provider enforces this automatically. On Anthropic the
145
+ * parameter is not sent at all to models that reject it (Claude 4.7+),
146
+ * so this is a request, not a guarantee — see providers/anthropic.ts.
141
147
  */
142
148
  temperature: 0.7,
143
149
  maxTokens: 8192,