smoltalk 0.3.1 → 0.4.1

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.
@@ -29,12 +29,14 @@ export declare const AssistantMessageJSONSchema: z.ZodObject<{
29
29
  inputTokens: z.ZodNumber;
30
30
  outputTokens: z.ZodNumber;
31
31
  cachedInputTokens: z.ZodOptional<z.ZodNumber>;
32
+ cacheCreationInputTokens: z.ZodOptional<z.ZodNumber>;
32
33
  totalTokens: z.ZodOptional<z.ZodNumber>;
33
34
  }, z.core.$strip>>;
34
35
  cost: z.ZodOptional<z.ZodObject<{
35
36
  inputCost: z.ZodNumber;
36
37
  outputCost: z.ZodNumber;
37
38
  cachedInputCost: z.ZodOptional<z.ZodNumber>;
39
+ cacheCreationInputCost: z.ZodOptional<z.ZodNumber>;
38
40
  totalCost: z.ZodNumber;
39
41
  currency: z.ZodString;
40
42
  }, z.core.$strip>>;
@@ -12,6 +12,13 @@ export declare class SmolAnthropic extends BaseClient implements SmolClient {
12
12
  getModel(): ModelName;
13
13
  private calculateUsageAndCost;
14
14
  private buildRequest;
15
+ /**
16
+ * Translate `thinking` / `reasoningEffort` into the correct Anthropic
17
+ * parameters for this model. Callers shouldn't have to know whether a model
18
+ * speaks the legacy `budget_tokens` API or the newer adaptive + effort API —
19
+ * that's exactly the vendor difference this package smooths over.
20
+ */
21
+ private resolveThinking;
15
22
  private rethrowAsSmolError;
16
23
  _textSync(config: SmolConfig): Promise<Result<PromptResult>>;
17
24
  _textStream(config: SmolConfig): AsyncGenerator<StreamChunk>;
@@ -6,8 +6,98 @@ import { success, } from "../types.js";
6
6
  import { zodToAnthropicTool } from "../util/tool.js";
7
7
  import { SmolContentPolicyError, SmolContextWindowExceededError, } from "../smolError.js";
8
8
  import { BaseClient } from "./baseClient.js";
9
+ import { getModel, isTextModel } from "../models.js";
9
10
  import { Model } from "../model.js";
10
11
  const DEFAULT_MAX_TOKENS = 4096;
12
+ /**
13
+ * Which thinking API a model speaks. New flagship Anthropic models (Opus 4.7+)
14
+ * reject the legacy `{type: "enabled", budget_tokens}` form with a 400, so we
15
+ * default unknown/unregistered models to "adaptive" (the forward-looking shape)
16
+ * rather than the form that's being phased out.
17
+ */
18
+ function thinkingStyleFor(modelName) {
19
+ const model = getModel(modelName);
20
+ if (model && isTextModel(model) && model.reasoning?.thinkingStyle) {
21
+ return model.reasoning.thinkingStyle;
22
+ }
23
+ return "adaptive";
24
+ }
25
+ /**
26
+ * Attach ephemeral cache_control breakpoints to (up to) three places:
27
+ * 1. the last tool definition
28
+ * 2. the last system block (promoting system from string to array form)
29
+ * 3. the last block of the last user message
30
+ *
31
+ * Anthropic enforces minimum prefix sizes; smaller prefixes silently no-op.
32
+ */
33
+ function applyCacheBreakpoints(req) {
34
+ const cc = { type: "ephemeral" };
35
+ // Tools: mark the last tool.
36
+ let tools = req.tools;
37
+ if (tools && tools.length > 0) {
38
+ const lastIdx = tools.length - 1;
39
+ const marked = [];
40
+ for (let i = 0; i < tools.length; i++) {
41
+ if (i === lastIdx) {
42
+ marked.push({ ...tools[i], cache_control: cc });
43
+ }
44
+ else {
45
+ marked.push(tools[i]);
46
+ }
47
+ }
48
+ tools = marked;
49
+ }
50
+ // System: promote string to array form so the last block can be marked.
51
+ let system = req.system;
52
+ if (typeof system === "string" && system.length > 0) {
53
+ system = [{ type: "text", text: system, cache_control: cc }];
54
+ }
55
+ else if (Array.isArray(system) && system.length > 0) {
56
+ const lastIdx = system.length - 1;
57
+ const marked = [];
58
+ for (let i = 0; i < system.length; i++) {
59
+ if (i === lastIdx) {
60
+ marked.push({ ...system[i], cache_control: cc });
61
+ }
62
+ else {
63
+ marked.push(system[i]);
64
+ }
65
+ }
66
+ system = marked;
67
+ }
68
+ // Messages: mark the last block of the last user message.
69
+ let messages = req.messages;
70
+ for (let i = messages.length - 1; i >= 0; i--) {
71
+ const m = messages[i];
72
+ if (m.role !== "user")
73
+ continue;
74
+ let blocks;
75
+ if (typeof m.content === "string") {
76
+ blocks = [{ type: "text", text: m.content }];
77
+ }
78
+ else {
79
+ blocks = [...m.content];
80
+ }
81
+ if (blocks.length === 0)
82
+ break;
83
+ blocks[blocks.length - 1] = {
84
+ ...blocks[blocks.length - 1],
85
+ cache_control: cc,
86
+ };
87
+ const rebuilt = [];
88
+ for (let j = 0; j < messages.length; j++) {
89
+ if (j === i) {
90
+ rebuilt.push({ ...m, content: blocks });
91
+ }
92
+ else {
93
+ rebuilt.push(messages[j]);
94
+ }
95
+ }
96
+ messages = rebuilt;
97
+ break;
98
+ }
99
+ return { system, messages, tools };
100
+ }
11
101
  export class SmolAnthropic extends BaseClient {
12
102
  client;
13
103
  logger;
@@ -22,11 +112,22 @@ export class SmolAnthropic extends BaseClient {
22
112
  return this.model.getModel();
23
113
  }
24
114
  calculateUsageAndCost(usageData) {
115
+ const cacheRead = usageData.cache_read_input_tokens ?? 0;
116
+ const cacheCreation = usageData.cache_creation_input_tokens ?? 0;
25
117
  const usage = {
26
118
  inputTokens: usageData.input_tokens,
27
119
  outputTokens: usageData.output_tokens,
28
- totalTokens: usageData.input_tokens + usageData.output_tokens,
120
+ totalTokens: usageData.input_tokens +
121
+ cacheRead +
122
+ cacheCreation +
123
+ usageData.output_tokens,
29
124
  };
125
+ if (cacheRead > 0) {
126
+ usage.cachedInputTokens = cacheRead;
127
+ }
128
+ if (cacheCreation > 0) {
129
+ usage.cacheCreationInputTokens = cacheCreation;
130
+ }
30
131
  const cost = this.model.calculateCost(usage) ?? undefined;
31
132
  return { usage, cost };
32
133
  }
@@ -65,23 +166,49 @@ export class SmolAnthropic extends BaseClient {
65
166
  description: tool.description,
66
167
  }))
67
168
  : undefined;
169
+ // Normalize the user's provider-agnostic thinking/effort config into the
170
+ // shape this specific model accepts. Both `thinking.enabled` and
171
+ // `reasoningEffort` are treated as a request to think.
172
+ const { thinking, outputConfig } = this.resolveThinking(config);
173
+ const cachingEnabled = config.caching?.enabled !== false;
174
+ const baseRequest = { system, messages: anthropicMessages, tools };
175
+ const finalRequest = cachingEnabled
176
+ ? applyCacheBreakpoints(baseRequest)
177
+ : baseRequest;
178
+ return { ...finalRequest, thinking, outputConfig };
179
+ }
180
+ /**
181
+ * Translate `thinking` / `reasoningEffort` into the correct Anthropic
182
+ * parameters for this model. Callers shouldn't have to know whether a model
183
+ * speaks the legacy `budget_tokens` API or the newer adaptive + effort API —
184
+ * that's exactly the vendor difference this package smooths over.
185
+ */
186
+ resolveThinking(config) {
187
+ const wantsThinking = config.thinking?.enabled === true;
188
+ const effort = config.reasoningEffort;
189
+ if (!wantsThinking && !effort) {
190
+ return { thinking: undefined, outputConfig: undefined };
191
+ }
192
+ if (thinkingStyleFor(this.getModel()) === "adaptive") {
193
+ // `output_config.effort` controls depth; the budget is irrelevant here.
194
+ return {
195
+ thinking: { type: "adaptive" },
196
+ outputConfig: effort ? { effort } : undefined,
197
+ };
198
+ }
199
+ // Legacy budget-based thinking (Haiku 4.5 and older models).
68
200
  const reasoningBudgetMap = {
69
201
  low: 2048,
70
202
  medium: 5000,
71
203
  high: 10000,
72
204
  };
73
- const thinking = config.thinking?.enabled
74
- ? {
75
- type: "enabled",
76
- budget_tokens: config.thinking.budgetTokens ?? 5000,
77
- }
78
- : config.reasoningEffort
79
- ? {
80
- type: "enabled",
81
- budget_tokens: reasoningBudgetMap[config.reasoningEffort],
82
- }
83
- : undefined;
84
- return { system, messages: anthropicMessages, tools, thinking };
205
+ const budget_tokens = wantsThinking
206
+ ? (config.thinking?.budgetTokens ?? 5000)
207
+ : reasoningBudgetMap[effort];
208
+ return {
209
+ thinking: { type: "enabled", budget_tokens },
210
+ outputConfig: undefined,
211
+ };
85
212
  }
86
213
  rethrowAsSmolError(error) {
87
214
  if (error instanceof Anthropic.APIError) {
@@ -102,7 +229,7 @@ export class SmolAnthropic extends BaseClient {
102
229
  throw error;
103
230
  }
104
231
  async _textSync(config) {
105
- const { system, messages, tools, thinking } = this.buildRequest(config);
232
+ const { system, messages, tools, thinking, outputConfig } = this.buildRequest(config);
106
233
  let debugData = {
107
234
  model: this.getModel(),
108
235
  max_tokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
@@ -110,6 +237,7 @@ export class SmolAnthropic extends BaseClient {
110
237
  system,
111
238
  tools,
112
239
  thinking,
240
+ output_config: outputConfig,
113
241
  };
114
242
  this.logger.debug("Sending request to Anthropic:", debugData);
115
243
  this.statelogClient?.promptRequest(debugData);
@@ -123,6 +251,7 @@ export class SmolAnthropic extends BaseClient {
123
251
  ...(system && { system }),
124
252
  ...(tools && { tools }),
125
253
  ...(thinking && { thinking }),
254
+ ...(outputConfig && { output_config: outputConfig }),
126
255
  ...(config.temperature !== undefined && {
127
256
  temperature: config.temperature,
128
257
  }),
@@ -161,7 +290,7 @@ export class SmolAnthropic extends BaseClient {
161
290
  });
162
291
  }
163
292
  async *_textStream(config) {
164
- const { system, messages, tools, thinking } = this.buildRequest(config);
293
+ const { system, messages, tools, thinking, outputConfig } = this.buildRequest(config);
165
294
  const streamDebugData = {
166
295
  model: this.model,
167
296
  max_tokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
@@ -169,6 +298,7 @@ export class SmolAnthropic extends BaseClient {
169
298
  system,
170
299
  tools,
171
300
  thinking,
301
+ output_config: outputConfig,
172
302
  };
173
303
  this.logger.debug("Sending streaming request to Anthropic:", streamDebugData);
174
304
  this.statelogClient?.promptRequest(streamDebugData);
@@ -182,6 +312,7 @@ export class SmolAnthropic extends BaseClient {
182
312
  ...(system && { system }),
183
313
  ...(tools && { tools }),
184
314
  ...(thinking && { thinking }),
315
+ ...(outputConfig && { output_config: outputConfig }),
185
316
  ...(config.temperature !== undefined && {
186
317
  temperature: config.temperature,
187
318
  }),
@@ -198,10 +329,15 @@ export class SmolAnthropic extends BaseClient {
198
329
  // Track thinking blocks by index: index -> { text, signature }
199
330
  const thinkingBlockMap = new Map();
200
331
  let inputTokens = 0;
332
+ let cacheReadTokens = 0;
333
+ let cacheCreationTokens = 0;
201
334
  let outputTokens = 0;
202
335
  for await (const event of stream) {
203
336
  if (event.type === "message_start") {
204
- inputTokens = event.message.usage.input_tokens;
337
+ const u = event.message.usage;
338
+ inputTokens = u.input_tokens;
339
+ cacheReadTokens = u.cache_read_input_tokens ?? 0;
340
+ cacheCreationTokens = u.cache_creation_input_tokens ?? 0;
205
341
  }
206
342
  else if (event.type === "content_block_start") {
207
343
  if (event.content_block.type === "tool_use") {
@@ -252,6 +388,15 @@ export class SmolAnthropic extends BaseClient {
252
388
  }
253
389
  else if (event.type === "message_delta") {
254
390
  outputTokens = event.usage.output_tokens;
391
+ // Defensive: in practice Anthropic only sends cache fields on
392
+ // message_start, but read them here too so we don't miss an
393
+ // update if the SDK changes.
394
+ if (event.usage.cache_read_input_tokens != null) {
395
+ cacheReadTokens = event.usage.cache_read_input_tokens;
396
+ }
397
+ if (event.usage.cache_creation_input_tokens != null) {
398
+ cacheCreationTokens = event.usage.cache_creation_input_tokens;
399
+ }
255
400
  }
256
401
  }
257
402
  this.logger.debug("Streaming response completed from Anthropic");
@@ -269,8 +414,14 @@ export class SmolAnthropic extends BaseClient {
269
414
  const usage = {
270
415
  inputTokens,
271
416
  outputTokens,
272
- totalTokens: inputTokens + outputTokens,
417
+ totalTokens: inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens,
273
418
  };
419
+ if (cacheReadTokens > 0) {
420
+ usage.cachedInputTokens = cacheReadTokens;
421
+ }
422
+ if (cacheCreationTokens > 0) {
423
+ usage.cacheCreationInputTokens = cacheCreationTokens;
424
+ }
274
425
  const cost = this.model.calculateCost(usage) ?? undefined;
275
426
  yield {
276
427
  type: "done",
@@ -31,12 +31,15 @@ export class SmolGoogle extends BaseClient {
31
31
  let usage;
32
32
  let cost;
33
33
  if (usageMetadata) {
34
+ const cached = usageMetadata.cachedContentTokenCount ?? 0;
34
35
  usage = {
35
- inputTokens: usageMetadata.promptTokenCount || 0,
36
+ inputTokens: Math.max(0, (usageMetadata.promptTokenCount || 0) - cached),
36
37
  outputTokens: usageMetadata.candidatesTokenCount || 0,
37
- cachedInputTokens: usageMetadata.cachedContentTokenCount,
38
38
  totalTokens: usageMetadata.totalTokenCount,
39
39
  };
40
+ if (cached > 0) {
41
+ usage.cachedInputTokens = cached;
42
+ }
40
43
  const calculatedCost = this.model.calculateCost(usage);
41
44
  if (calculatedCost) {
42
45
  cost = calculatedCost;
@@ -30,12 +30,15 @@ export class SmolOpenAi extends BaseClient {
30
30
  let usage;
31
31
  let cost;
32
32
  if (usageData) {
33
+ const cached = usageData.prompt_tokens_details?.cached_tokens ?? 0;
33
34
  usage = {
34
- inputTokens: usageData.prompt_tokens || 0,
35
+ inputTokens: Math.max(0, (usageData.prompt_tokens || 0) - cached),
35
36
  outputTokens: usageData.completion_tokens || 0,
36
- cachedInputTokens: usageData.prompt_tokens_details?.cached_tokens,
37
37
  totalTokens: usageData.total_tokens,
38
38
  };
39
+ if (cached > 0) {
40
+ usage.cachedInputTokens = cached;
41
+ }
39
42
  const calculatedCost = this.model.calculateCost(usage);
40
43
  if (calculatedCost) {
41
44
  cost = calculatedCost;
@@ -89,12 +89,15 @@ export class SmolOpenAiResponses extends BaseClient {
89
89
  let usage;
90
90
  let cost;
91
91
  if (usageData) {
92
+ const cached = usageData.input_tokens_details?.cached_tokens ?? 0;
92
93
  usage = {
93
- inputTokens: usageData.input_tokens || 0,
94
+ inputTokens: Math.max(0, (usageData.input_tokens || 0) - cached),
94
95
  outputTokens: usageData.output_tokens || 0,
95
- cachedInputTokens: usageData.input_tokens_details?.cached_tokens,
96
96
  totalTokens: usageData.total_tokens,
97
97
  };
98
+ if (cached > 0) {
99
+ usage.cachedInputTokens = cached;
100
+ }
98
101
  const calculatedCost = this.model.calculateCost(usage);
99
102
  if (calculatedCost) {
100
103
  cost = calculatedCost;
package/dist/model.d.ts CHANGED
@@ -11,10 +11,12 @@ export declare class Model {
11
11
  inputTokens: number;
12
12
  outputTokens: number;
13
13
  cachedInputTokens?: number;
14
+ cacheCreationInputTokens?: number;
14
15
  }): {
15
16
  inputCost: number;
16
17
  outputCost: number;
17
18
  cachedInputCost?: number;
19
+ cacheCreationInputCost?: number;
18
20
  totalCost: number;
19
21
  currency: string;
20
22
  } | null;
package/dist/model.js CHANGED
@@ -26,16 +26,49 @@ export class Model {
26
26
  if (!model || !isTextModel(model)) {
27
27
  return null;
28
28
  }
29
+ const cachedTokens = usage.cachedInputTokens ?? 0;
30
+ const cacheCreationTokens = usage.cacheCreationInputTokens ?? 0;
31
+ // Disjoint buckets. If a discount price isn't defined for this model,
32
+ // the tokens were still billed by the provider — charge them at the
33
+ // full input rate so totalCost stays honest.
34
+ const cachedRate = model.cachedInputTokenCost ?? model.inputTokenCost ?? 0;
35
+ const cacheCreationRate = model.cacheCreationInputTokenCost ?? model.inputTokenCost ?? 0;
29
36
  const inputCost = round((usage.inputTokens * (model.inputTokenCost || 0)) / 1_000_000, 6);
30
37
  const outputCost = round((usage.outputTokens * (model.outputTokenCost || 0)) / 1_000_000, 6);
31
- const cachedInputCost = usage.cachedInputTokens && model.cachedInputTokenCost
32
- ? round((usage.cachedInputTokens * model.cachedInputTokenCost) / 1_000_000, 6)
33
- : undefined;
34
- const totalCost = round(inputCost + outputCost + (cachedInputCost || 0), 6);
38
+ // Only expose cachedInputCost / cacheCreationInputCost when the model
39
+ // actually has a distinct discount price. Otherwise, fold those dollars
40
+ // into inputCost so the user isn't misled by a $0 cached field.
41
+ let cachedInputCost;
42
+ let cacheCreationInputCost;
43
+ let foldedInputDollars = 0;
44
+ if (cachedTokens > 0) {
45
+ const dollars = (cachedTokens * cachedRate) / 1_000_000;
46
+ if (model.cachedInputTokenCost != null) {
47
+ cachedInputCost = round(dollars, 6);
48
+ }
49
+ else {
50
+ foldedInputDollars += dollars;
51
+ }
52
+ }
53
+ if (cacheCreationTokens > 0) {
54
+ const dollars = (cacheCreationTokens * cacheCreationRate) / 1_000_000;
55
+ if (model.cacheCreationInputTokenCost != null) {
56
+ cacheCreationInputCost = round(dollars, 6);
57
+ }
58
+ else {
59
+ foldedInputDollars += dollars;
60
+ }
61
+ }
62
+ const finalInputCost = round(inputCost + foldedInputDollars, 6);
63
+ const totalCost = round(finalInputCost +
64
+ outputCost +
65
+ (cachedInputCost || 0) +
66
+ (cacheCreationInputCost || 0), 6);
35
67
  return {
36
- inputCost,
68
+ inputCost: finalInputCost,
37
69
  outputCost,
38
70
  cachedInputCost,
71
+ cacheCreationInputCost,
39
72
  totalCost,
40
73
  currency: "USD",
41
74
  };