smoltalk 0.8.2 → 0.8.4

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
@@ -57,7 +57,9 @@ This is functionality that other packages allow.
57
57
  totalCost: 0.00026,
58
58
  currency: 'USD'
59
59
  },
60
- model: 'gpt-5.4'
60
+ model: 'gpt-5.4',
61
+ stopReason: 'stop',
62
+ rawStopReason: 'stop'
61
63
  }
62
64
  }
63
65
  ```
@@ -152,6 +154,49 @@ A couple of design decisions to note:
152
154
  - The schema for tools and structured outputs is defined using Zod.
153
155
  - Parameter names are camel case, as that is the naming convention in TypeScript. They are converted to snake case for you if required by the APIs.
154
156
 
157
+ > **`z.any()` in a structured-output schema.** Providers reject an unconstrained
158
+ > ("any") schema, since the point of structured output is that it's structured. A
159
+ > nested `z.any()` field is therefore coerced to a **string** (`{type:"string"}`) —
160
+ > so `z.object({ data: z.any() })` will have the model emit a string for `data`, not
161
+ > an arbitrary object. If the *entire* `responseFormat` is `z.any()`/`z.unknown()`,
162
+ > structured output is dropped and the model returns free text. Use a concrete Zod
163
+ > shape when you need a specific structure.
164
+
165
+ ## Stop reason
166
+
167
+ Any result from a model response carries why the turn ended, normalized across
168
+ providers:
169
+
170
+ - `stopReason` — a unified value: `"stop"` (natural completion), `"length"` (hit
171
+ max tokens), `"tool_use"` (model wants to call a tool), `"content_filter"`
172
+ (safety/policy/refusal), `"stop_sequence"`, `"pause"`, or `"other"`.
173
+ - `rawStopReason` — the untouched provider value (e.g. `end_turn`, `MAX_TOKENS`,
174
+ `tool_calls`), for when you need provider-specific nuance.
175
+
176
+ ```ts
177
+ import { textSync, userMessage } from "smoltalk";
178
+
179
+ const r = await textSync({
180
+ model: "claude-sonnet-4-6",
181
+ maxTokens: 100,
182
+ messages: [userMessage("Write a long essay about otters.")],
183
+ });
184
+ if (r.success && r.value.stopReason === "length") {
185
+ // response was truncated — raise maxTokens or continue the turn
186
+ }
187
+ ```
188
+
189
+ Both fields appear on the non-streaming result and on the streaming `done` chunk's
190
+ result. They are optional: a result produced without calling a provider (e.g. a
191
+ tool-loop halt) has neither.
192
+
193
+ Two provider notes:
194
+ - Google reports `STOP` even for tool-call turns, so `stopReason` is normalized to
195
+ `"tool_use"` there when tool calls are present (`rawStopReason` still shows `STOP`).
196
+ - The OpenAI Responses API has no single finish-reason field, so its `rawStopReason`
197
+ is the response *status* (`"completed"`) or the incomplete reason
198
+ (`"max_output_tokens"`), rather than a chat-style `"stop"`.
199
+
155
200
  ## Configuration Options
156
201
 
157
202
  `SmolConfig` is a single config type passed to `text()`. It contains everything: API keys, model selection, request parameters, hooks, and observability options.
@@ -290,6 +335,78 @@ a `hostedTools` catalog (`getHostedTools()`); the published file is kept current
290
335
  by a daily CI job that translates [models.dev](https://models.dev) into
291
336
  smoltalk's shape.
292
337
 
338
+ ## Custom models & pricing
339
+
340
+ If you use a model that isn't in smoltalk's baked-in catalog (a self-hosted
341
+ model, a brand-new release, an OpenAI-compatible endpoint), smoltalk has no
342
+ pricing for it and the `cost` field is simply omitted from the result — nothing
343
+ errors, you just get `usage` without `cost`. Teach it the price and cost
344
+ tracking starts working.
345
+
346
+ **One model — `registerTextModel` (recommended).** Register once at startup:
347
+
348
+ ```ts
349
+ import { registerTextModel, textSync } from "smoltalk";
350
+
351
+ registerTextModel({
352
+ modelName: "my-model",
353
+ provider: "openai-compat", // must match the provider you call with (see below)
354
+ inputTokenCost: 0.5, // USD per 1M input tokens
355
+ outputTokenCost: 1.5, // USD per 1M output tokens
356
+ cachedInputTokenCost: 0.05, // optional
357
+ cacheCreationInputTokenCost: 0.625, // optional
358
+ maxInputTokens: 128000, // required by the type, even if you only want pricing
359
+ maxOutputTokens: 8192,
360
+ });
361
+
362
+ const res = await textSync({
363
+ model: "my-model",
364
+ provider: "openai-compat",
365
+ baseUrl: { openAiCompat: "https://my-endpoint/v1" },
366
+ messages,
367
+ });
368
+ // res.value.cost is now populated from the rates above.
369
+ ```
370
+
371
+ **Per-call only — `config.modelData`.** When you can't register globally (e.g.
372
+ per-tenant rates), pass a minimal blob for a single call. It layers over the
373
+ baseline exactly like a refresh blob:
374
+
375
+ ```ts
376
+ import { textSync, type ModelDataBlob } from "smoltalk";
377
+
378
+ const modelData: ModelDataBlob = {
379
+ schemaVersion: 1,
380
+ generatedAt: new Date().toISOString(),
381
+ hostedTools: [],
382
+ models: [
383
+ {
384
+ type: "text",
385
+ modelName: "my-model",
386
+ provider: "openai-compat",
387
+ maxInputTokens: 128000,
388
+ maxOutputTokens: 8192,
389
+ inputTokenCost: 0.5,
390
+ outputTokenCost: 1.5,
391
+ },
392
+ ],
393
+ };
394
+
395
+ await textSync({ model: "my-model", provider: "openai-compat", messages, modelData });
396
+ ```
397
+
398
+ **The provider must match.** The registry is keyed by `provider:modelName`, so
399
+ the `provider` you register (or put in the blob) has to equal the `provider` you
400
+ pass at call time. Registering `my-model` under `"openai-compat"` but calling it
401
+ with `provider: "openrouter"` looks up a different key, finds no price, and
402
+ silently drops the `cost` field. When in doubt, register under the same provider
403
+ string you call with.
404
+
405
+ Overriding an entry that *is* in the catalog works the same way — merges are
406
+ field-by-field (see "Refreshing model data" above), so registering just
407
+ `inputTokenCost` / `outputTokenCost` for a known model updates only those fields
408
+ and leaves its limits and capabilities intact.
409
+
293
410
  ## Hosted tools catalog
294
411
 
295
412
  Each cloud provider offers server-side "hosted" tools (web search, code
@@ -6,6 +6,8 @@ import { redactAttachments } from "../util/redact.js";
6
6
  import { success, } from "../types.js";
7
7
  import { WEB_SEARCH, webSearchResult, applyHostedToolCost } from "../util/hostedTools.js";
8
8
  import { zodToAnthropicTool } from "../util/tool.js";
9
+ import { responseFormatToJsonSchema } from "../util/jsonSchema.js";
10
+ import { normalizeAnthropicStopReason } from "../util/stopReason.js";
9
11
  import { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
10
12
  import { extractHttpErrorFields } from "../util/httpError.js";
11
13
  import { BaseClient } from "./baseClient.js";
@@ -269,7 +271,7 @@ export class SmolAnthropic extends BaseClient {
269
271
  // + retry recover a prompt-shaped reply.
270
272
  const format = config.responseFormat &&
271
273
  anthropicSupportsStructuredOutput(this.getModel())
272
- ? { type: "json_schema", schema: config.responseFormat.toJSONSchema() }
274
+ ? { type: "json_schema", schema: responseFormatToJsonSchema(config.responseFormat) }
273
275
  : undefined;
274
276
  // Merge effort + format into one output_config (both optional, independent).
275
277
  const outputConfig = effortConfig || format
@@ -389,13 +391,18 @@ export class SmolAnthropic extends BaseClient {
389
391
  const { usage, cost } = this.calculateUsageAndCost(response.usage);
390
392
  const parsed = parseAnthropicHostedTools(response, "anthropic");
391
393
  const { results: hostedToolResults, cost: finalCost } = applyHostedToolCost(parsed, cost, this.getModel(), this.config.modelData);
394
+ const rawStopReason = response.stop_reason ?? undefined;
392
395
  const result = {
393
396
  output,
394
397
  toolCalls,
395
398
  usage,
396
399
  cost: finalCost,
397
400
  model: this.getModel(),
401
+ stopReason: normalizeAnthropicStopReason(rawStopReason),
398
402
  };
403
+ if (rawStopReason) {
404
+ result.rawStopReason = rawStopReason;
405
+ }
399
406
  if (thinkingBlocks.length > 0) {
400
407
  result.thinkingBlocks = thinkingBlocks;
401
408
  }
@@ -459,6 +466,7 @@ export class SmolAnthropic extends BaseClient {
459
466
  let cacheReadTokens = 0;
460
467
  let cacheCreationTokens = 0;
461
468
  let outputTokens = 0;
469
+ let rawStopReason;
462
470
  for await (const event of stream) {
463
471
  if (event.type === "message_start") {
464
472
  const u = event.message.usage;
@@ -569,6 +577,9 @@ export class SmolAnthropic extends BaseClient {
569
577
  }
570
578
  }
571
579
  else if (event.type === "message_delta") {
580
+ if (event.delta?.stop_reason) {
581
+ rawStopReason = event.delta.stop_reason;
582
+ }
572
583
  outputTokens = event.usage.output_tokens;
573
584
  // Defensive: in practice Anthropic only sends cache fields on
574
585
  // message_start, but read them here too so we don't miss an
@@ -630,17 +641,19 @@ export class SmolAnthropic extends BaseClient {
630
641
  hostedToolResults = applied.results;
631
642
  cost = applied.cost;
632
643
  }
633
- yield {
634
- type: "done",
635
- result: {
636
- output: content || null,
637
- toolCalls,
638
- ...(thinkingBlocks.length > 0 && { thinkingBlocks }),
639
- usage,
640
- cost,
641
- model: this.getModel(),
642
- ...(hostedToolResults.length > 0 && { hostedToolResults }),
643
- },
644
+ const result = {
645
+ output: content || null,
646
+ toolCalls,
647
+ ...(thinkingBlocks.length > 0 && { thinkingBlocks }),
648
+ usage,
649
+ cost,
650
+ model: this.getModel(),
651
+ stopReason: normalizeAnthropicStopReason(rawStopReason),
652
+ ...(hostedToolResults.length > 0 && { hostedToolResults }),
644
653
  };
654
+ if (rawStopReason) {
655
+ result.rawStopReason = rawStopReason;
656
+ }
657
+ yield { type: "done", result };
645
658
  }
646
659
  }
@@ -20,6 +20,16 @@ export declare class BaseClient implements SmolClient {
20
20
  * by textSync and textStream so the two paths can't diverge.
21
21
  */
22
22
  protected prepareAttachments(config: SmolConfig): Promise<Result<SmolConfig>>;
23
+ /**
24
+ * If the entire `responseFormat` schema is unconstrained (`z.any()` /
25
+ * `z.unknown()`), structured output is meaningless — every provider would
26
+ * reject it and there is nothing to validate against. Strip it so the call
27
+ * behaves exactly as if no `responseFormat` were set: no provider sends a
28
+ * structured-output request (each gates on `config.responseFormat`), and the
29
+ * strict parse/retry loop in `textWithRetry` is skipped, returning free text.
30
+ * A *nested* `any` is left alone here — the schema conversion sanitizes it.
31
+ */
32
+ protected normalizeResponseFormat(config: SmolConfig): SmolConfig;
23
33
  textSync(promptConfig: SmolConfig): Promise<Result<PromptResult>>;
24
34
  checkForToolLoops(promptConfig: SmolConfig): {
25
35
  continue: boolean;
@@ -8,6 +8,7 @@ import { validateHostedTools } from "../util/hostedTools.js";
8
8
  import { resolveMessageAttachments, messagesHaveAttachments, DEFAULT_MAX_ATTACHMENT_BYTES } from "./resolveAttachments.js";
9
9
  import { validateModalities } from "../util/modalities.js";
10
10
  import { resolveProvider } from "../util/provider.js";
11
+ import { isUnconstrainedSchema } from "../util/jsonSchema.js";
11
12
  import { z } from "zod";
12
13
  const DEFAULT_NUM_RETRIES = 2;
13
14
  export class BaseClient {
@@ -80,7 +81,25 @@ export class BaseClient {
80
81
  }
81
82
  return success({ ...config, messages: resolved.value });
82
83
  }
84
+ /**
85
+ * If the entire `responseFormat` schema is unconstrained (`z.any()` /
86
+ * `z.unknown()`), structured output is meaningless — every provider would
87
+ * reject it and there is nothing to validate against. Strip it so the call
88
+ * behaves exactly as if no `responseFormat` were set: no provider sends a
89
+ * structured-output request (each gates on `config.responseFormat`), and the
90
+ * strict parse/retry loop in `textWithRetry` is skipped, returning free text.
91
+ * A *nested* `any` is left alone here — the schema conversion sanitizes it.
92
+ */
93
+ normalizeResponseFormat(config) {
94
+ if (config.responseFormat &&
95
+ isUnconstrainedSchema(config.responseFormat.toJSONSchema())) {
96
+ const { responseFormat, ...rest } = config;
97
+ return rest;
98
+ }
99
+ return config;
100
+ }
83
101
  async textSync(promptConfig) {
102
+ promptConfig = this.normalizeResponseFormat(promptConfig);
84
103
  const messageLimitResult = this.checkMessageLimit(promptConfig);
85
104
  if (messageLimitResult)
86
105
  return messageLimitResult;
@@ -297,6 +316,7 @@ export class BaseClient {
297
316
  throw new Error("Method not implemented.");
298
317
  }
299
318
  async *textStream(config) {
319
+ config = this.normalizeResponseFormat(config);
300
320
  const messageLimitResult = this.checkMessageLimit(config);
301
321
  if (messageLimitResult) {
302
322
  yield {
@@ -4,6 +4,8 @@ import { getLogger } from "../util/logger.js";
4
4
  import { redactAttachments } from "../util/redact.js";
5
5
  import { addCosts, addTokenUsage, success, } from "../types.js";
6
6
  import { zodToGoogleTool } from "../util/tool.js";
7
+ import { responseFormatToJsonSchema } from "../util/jsonSchema.js";
8
+ import { normalizeGoogleStopReason } from "../util/stopReason.js";
7
9
  import { SmolError, SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
8
10
  import { extractHttpErrorFields } from "../util/httpError.js";
9
11
  import { sanitizeAttributes } from "../util/util.js";
@@ -178,7 +180,7 @@ export class SmolGoogle extends BaseClient {
178
180
  }
179
181
  if (config.responseFormat) {
180
182
  genConfig.responseMimeType = "application/json";
181
- genConfig.responseJsonSchema = config.responseFormat.toJSONSchema();
183
+ genConfig.responseJsonSchema = responseFormatToJsonSchema(config.responseFormat);
182
184
  }
183
185
  if (config.thinking?.enabled) {
184
186
  // Gemini only returns thought-summary parts (parts with `thought: true`,
@@ -292,7 +294,7 @@ export class SmolGoogle extends BaseClient {
292
294
  ...(toolResult.value.thinkingBlocks || []),
293
295
  ...(responseResult.value.thinkingBlocks || []),
294
296
  ];
295
- return success({
297
+ const structuredResult = {
296
298
  output: responseResult.value.output,
297
299
  // if there were tool calls, we would have returned already, so we know these are empty
298
300
  toolCalls: [],
@@ -300,7 +302,13 @@ export class SmolGoogle extends BaseClient {
300
302
  usage: addTokenUsage(toolResult.value.usage, responseResult.value.usage),
301
303
  cost: addCosts(toolResult.value.cost, responseResult.value.cost),
302
304
  model: request.model,
303
- });
305
+ // The structured-output request is the final turn, so its stop reason wins.
306
+ stopReason: responseResult.value.stopReason,
307
+ };
308
+ if (responseResult.value.rawStopReason) {
309
+ structuredResult.rawStopReason = responseResult.value.rawStopReason;
310
+ }
311
+ return success(structuredResult);
304
312
  }
305
313
  async __textSync(request) {
306
314
  this.logger.debug("Sending request to Google Gemini:", JSON.stringify(redactAttachments(request), null, 2));
@@ -360,6 +368,7 @@ export class SmolGoogle extends BaseClient {
360
368
  const { usage, cost } = this.calculateUsageAndCost(result.usageMetadata);
361
369
  const parsed = parseGoogleHostedTools(result, "google", request.model);
362
370
  const { results: hostedToolResults, cost: finalCost } = applyHostedToolCost(parsed, cost, request.model, this.config.modelData);
371
+ const rawStopReason = result.candidates?.[0]?.finishReason ?? undefined;
363
372
  // Return the response, updating the chat history
364
373
  const promptResult = {
365
374
  output,
@@ -367,7 +376,11 @@ export class SmolGoogle extends BaseClient {
367
376
  usage,
368
377
  cost: finalCost,
369
378
  model: request.model,
379
+ stopReason: normalizeGoogleStopReason(rawStopReason, toolCalls.length > 0),
370
380
  };
381
+ if (rawStopReason) {
382
+ promptResult.rawStopReason = rawStopReason;
383
+ }
371
384
  if (thinkingBlocks.length > 0) {
372
385
  promptResult.thinkingBlocks = thinkingBlocks;
373
386
  }
@@ -404,6 +417,7 @@ export class SmolGoogle extends BaseClient {
404
417
  const thinkingBlocks = [];
405
418
  let usage;
406
419
  let cost;
420
+ let rawStopReason;
407
421
  for await (const chunk of stream) {
408
422
  // Extract usage metadata from chunks
409
423
  if (chunk.usageMetadata) {
@@ -413,6 +427,8 @@ export class SmolGoogle extends BaseClient {
413
427
  }
414
428
  // Iterate raw parts to capture thought signatures and regular content
415
429
  for (const candidate of chunk.candidates || []) {
430
+ if (candidate?.finishReason)
431
+ rawStopReason = candidate.finishReason;
416
432
  for (const part of candidate?.content?.parts || []) {
417
433
  const p = part;
418
434
  // Check functionCall first: Gemini 3 attaches the thought signature to
@@ -482,16 +498,18 @@ export class SmolGoogle extends BaseClient {
482
498
  toolCalls.push(toolCall);
483
499
  yield { type: "tool_call", toolCall };
484
500
  }
485
- yield {
486
- type: "done",
487
- result: {
488
- output: content || null,
489
- toolCalls,
490
- ...(thinkingBlocks.length > 0 && { thinkingBlocks }),
491
- usage,
492
- cost,
493
- model: request.model,
494
- },
501
+ const result = {
502
+ output: content || null,
503
+ toolCalls,
504
+ ...(thinkingBlocks.length > 0 && { thinkingBlocks }),
505
+ usage,
506
+ cost,
507
+ model: request.model,
508
+ stopReason: normalizeGoogleStopReason(rawStopReason, toolCalls.length > 0),
495
509
  };
510
+ if (rawStopReason) {
511
+ result.rawStopReason = rawStopReason;
512
+ }
513
+ yield { type: "done", result };
496
514
  }
497
515
  }
@@ -4,6 +4,8 @@ import { getLogger } from "../util/logger.js";
4
4
  import { redactAttachments } from "../util/redact.js";
5
5
  import { success, } from "../types.js";
6
6
  import { zodToGoogleTool } from "../util/tool.js";
7
+ import { responseFormatToJsonSchema } from "../util/jsonSchema.js";
8
+ import { normalizeOllamaStopReason } from "../util/stopReason.js";
7
9
  import { sanitizeAttributes } from "../util/util.js";
8
10
  import { resolveBaseUrl } from "../util/provider.js";
9
11
  import { BaseClient } from "./baseClient.js";
@@ -81,7 +83,7 @@ export class SmolOllama extends BaseClient {
81
83
  request.tools = tools.map((t) => ({ type: "function", function: t }));
82
84
  }
83
85
  if (config.responseFormat) {
84
- request.format = config.responseFormat.toJSONSchema();
86
+ request.format = responseFormatToJsonSchema(config.responseFormat);
85
87
  }
86
88
  Object.assign(request, sanitizeAttributes(config.rawAttributes));
87
89
  this.logger.debug("Sending request to Ollama:", JSON.stringify(redactAttachments(request), null, 2));
@@ -116,8 +118,20 @@ export class SmolOllama extends BaseClient {
116
118
  }
117
119
  // Extract usage and calculate cost
118
120
  const { usage, cost } = this.calculateUsageAndCost(result);
121
+ const rawStopReason = result.done_reason ?? undefined;
119
122
  // Return the response, updating the chat history
120
- return success({ output, toolCalls, usage, cost, model: this.getModel() });
123
+ const promptResult = {
124
+ output,
125
+ toolCalls,
126
+ usage,
127
+ cost,
128
+ model: this.getModel(),
129
+ stopReason: normalizeOllamaStopReason(rawStopReason),
130
+ };
131
+ if (rawStopReason) {
132
+ promptResult.rawStopReason = rawStopReason;
133
+ }
134
+ return success(promptResult);
121
135
  }
122
136
  async *_textStream(config) {
123
137
  const messages = config.messages.map((msg) => msg.toOllamaMessage());
@@ -135,7 +149,7 @@ export class SmolOllama extends BaseClient {
135
149
  request.tools = tools.map((t) => ({ type: "function", function: t }));
136
150
  }
137
151
  if (config.responseFormat) {
138
- request.format = config.responseFormat.toJSONSchema();
152
+ request.format = responseFormatToJsonSchema(config.responseFormat);
139
153
  }
140
154
  Object.assign(request, sanitizeAttributes(config.rawAttributes));
141
155
  this.logger.debug("Sending streaming request to Ollama:", JSON.stringify(redactAttachments(request), null, 2));
@@ -201,16 +215,19 @@ export class SmolOllama extends BaseClient {
201
215
  toolCalls.push(toolCall);
202
216
  yield { type: "tool_call", toolCall };
203
217
  }
204
- yield {
205
- type: "done",
206
- result: {
207
- output: content || null,
208
- toolCalls,
209
- usage,
210
- cost,
211
- model: this.getModel(),
212
- },
218
+ const rawStopReason = lastChunk?.done_reason ?? undefined;
219
+ const result = {
220
+ output: content || null,
221
+ toolCalls,
222
+ usage,
223
+ cost,
224
+ model: this.getModel(),
225
+ stopReason: normalizeOllamaStopReason(rawStopReason),
213
226
  };
227
+ if (rawStopReason) {
228
+ result.rawStopReason = rawStopReason;
229
+ }
230
+ yield { type: "done", result };
214
231
  }
215
232
  catch (error) {
216
233
  this.rethrowAsSmolError(error);
@@ -8,6 +8,8 @@ import { BaseClient } from "./baseClient.js";
8
8
  import { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
9
9
  import { extractHttpErrorFields } from "../util/httpError.js";
10
10
  import { zodToOpenAITool } from "../util/tool.js";
11
+ import { responseFormatToJsonSchema } from "../util/jsonSchema.js";
12
+ import { normalizeOpenAIStopReason } from "../util/stopReason.js";
11
13
  import { Model } from "../model.js";
12
14
  export class SmolOpenAi extends BaseClient {
13
15
  client;
@@ -120,7 +122,7 @@ export class SmolOpenAi extends BaseClient {
120
122
  type: "json_schema",
121
123
  json_schema: {
122
124
  name: config.responseFormatOptions?.name || "response",
123
- schema: config.responseFormat.toJSONSchema(),
125
+ schema: responseFormatToJsonSchema(config.responseFormat),
124
126
  },
125
127
  };
126
128
  }
@@ -185,14 +187,22 @@ export class SmolOpenAi extends BaseClient {
185
187
  // response headers (e.g. LiteLLM's x-litellm-response-cost).
186
188
  const { usage, cost } = this.calculateUsageAndCost(completion.usage, rawResponse);
187
189
  const hostedToolResults = this.parseHostedToolResults(completion, config);
188
- return success({
190
+ const rawStopReason = completion.choices[0]?.finish_reason ?? undefined;
191
+ const result = {
189
192
  output,
190
193
  toolCalls,
191
194
  usage,
192
195
  cost,
193
196
  model: this.getModel(),
194
- ...(hostedToolResults.length > 0 ? { hostedToolResults } : {}),
195
- });
197
+ stopReason: normalizeOpenAIStopReason(rawStopReason),
198
+ };
199
+ if (rawStopReason) {
200
+ result.rawStopReason = rawStopReason;
201
+ }
202
+ if (hostedToolResults.length > 0) {
203
+ result.hostedToolResults = hostedToolResults;
204
+ }
205
+ return success(result);
196
206
  }
197
207
  async *_textStream(config) {
198
208
  const request = this.buildRequest(config);
@@ -214,7 +224,11 @@ export class SmolOpenAi extends BaseClient {
214
224
  const toolCallsMap = new Map();
215
225
  let usage;
216
226
  let cost;
227
+ let rawStopReason;
217
228
  for await (const chunk of completion) {
229
+ const chunkFinish = chunk.choices?.[0]?.finish_reason;
230
+ if (chunkFinish)
231
+ rawStopReason = chunkFinish;
218
232
  // Extract usage from the final chunk
219
233
  if (chunk.usage) {
220
234
  // Header-based cost (LiteLLM) is unsupported while streaming.
@@ -266,15 +280,17 @@ export class SmolOpenAi extends BaseClient {
266
280
  toolCalls.push(toolCall);
267
281
  yield { type: "tool_call", toolCall };
268
282
  }
269
- yield {
270
- type: "done",
271
- result: {
272
- output: content || null,
273
- toolCalls,
274
- usage,
275
- cost,
276
- model: this.getModel(),
277
- },
283
+ const result = {
284
+ output: content || null,
285
+ toolCalls,
286
+ usage,
287
+ cost,
288
+ model: this.getModel(),
289
+ stopReason: normalizeOpenAIStopReason(rawStopReason),
278
290
  };
291
+ if (rawStopReason) {
292
+ result.rawStopReason = rawStopReason;
293
+ }
294
+ yield { type: "done", result };
279
295
  }
280
296
  }
@@ -5,6 +5,8 @@ import { getLogger } from "../util/logger.js";
5
5
  import { redactAttachments } from "../util/redact.js";
6
6
  import { BaseClient } from "./baseClient.js";
7
7
  import { zodToOpenAIResponsesTool } from "../util/tool.js";
8
+ import { responseFormatToJsonSchema } from "../util/jsonSchema.js";
9
+ import { normalizeOpenAIResponsesStopReason } from "../util/stopReason.js";
8
10
  import { sanitizeAttributes } from "../util/util.js";
9
11
  import { WEB_SEARCH, webSearchResult, applyHostedToolCost } from "../util/hostedTools.js";
10
12
  import { Model } from "../model.js";
@@ -122,7 +124,7 @@ export class SmolOpenAiResponses extends BaseClient {
122
124
  format: {
123
125
  type: "json_schema",
124
126
  name: config.responseFormatOptions?.name || "response",
125
- schema: config.responseFormat.toJSONSchema(),
127
+ schema: responseFormatToJsonSchema(config.responseFormat),
126
128
  },
127
129
  };
128
130
  }
@@ -192,13 +194,19 @@ export class SmolOpenAiResponses extends BaseClient {
192
194
  const { usage, cost } = this.calculateUsageAndCost(response.usage);
193
195
  const parsed = parseOpenAIResponsesHostedTools(response, "openai-responses");
194
196
  const { results: hostedToolResults, cost: finalCost } = applyHostedToolCost(parsed, cost, this.getModel(), this.config.modelData);
197
+ const incompleteReason = response.incomplete_details?.reason;
198
+ const rawStopReason = incompleteReason ?? response.status ?? undefined;
195
199
  const result = {
196
200
  output,
197
201
  toolCalls,
198
202
  usage,
199
203
  cost: finalCost,
200
204
  model: this.getModel(),
205
+ stopReason: normalizeOpenAIResponsesStopReason(response.status, incompleteReason, toolCalls.length > 0),
201
206
  };
207
+ if (rawStopReason) {
208
+ result.rawStopReason = rawStopReason;
209
+ }
202
210
  if (hostedToolResults.length > 0) {
203
211
  result.hostedToolResults = hostedToolResults;
204
212
  }
@@ -222,7 +230,12 @@ export class SmolOpenAiResponses extends BaseClient {
222
230
  const functionCalls = new Map();
223
231
  let usage;
224
232
  let cost;
233
+ let finalResponse;
225
234
  for await (const event of stream) {
235
+ if (event.type === "response.completed" ||
236
+ event.type === "response.incomplete") {
237
+ finalResponse = event.response;
238
+ }
226
239
  switch (event.type) {
227
240
  case "response.output_text.delta": {
228
241
  content += event.delta;
@@ -291,15 +304,19 @@ export class SmolOpenAiResponses extends BaseClient {
291
304
  toolCalls.push(toolCall);
292
305
  yield { type: "tool_call", toolCall };
293
306
  }
294
- yield {
295
- type: "done",
296
- result: {
297
- output: content || null,
298
- toolCalls,
299
- usage,
300
- cost,
301
- model: this.getModel(),
302
- },
307
+ const incompleteReason = finalResponse?.incomplete_details?.reason;
308
+ const rawStopReason = incompleteReason ?? finalResponse?.status ?? undefined;
309
+ const result = {
310
+ output: content || null,
311
+ toolCalls,
312
+ usage,
313
+ cost,
314
+ model: this.getModel(),
315
+ stopReason: normalizeOpenAIResponsesStopReason(finalResponse?.status, incompleteReason, toolCalls.length > 0),
303
316
  };
317
+ if (rawStopReason) {
318
+ result.rawStopReason = rawStopReason;
319
+ }
320
+ yield { type: "done", result };
304
321
  }
305
322
  }
package/dist/models.d.ts CHANGED
@@ -1228,6 +1228,35 @@ export declare const textModels: readonly [{
1228
1228
  readonly costUnit: "characters";
1229
1229
  readonly disabled: true;
1230
1230
  readonly provider: "google";
1231
+ }, {
1232
+ readonly type: "text";
1233
+ readonly modelName: "claude-fable-5";
1234
+ readonly description: "Anthropic's most capable widely released model, for the most demanding reasoning and long-horizon agentic work. Thinking is always on (cannot be disabled); the raw chain of thought is never returned. Requires 30-day data retention (not available under zero data retention). 1M context window, 128K max output.";
1235
+ readonly maxInputTokens: 1000000;
1236
+ readonly maxOutputTokens: 128000;
1237
+ readonly inputTokenCost: 10;
1238
+ readonly cachedInputTokenCost: 1;
1239
+ readonly cacheCreationInputTokenCost: 12.5;
1240
+ readonly outputTokenCost: 50;
1241
+ readonly reasoning: {
1242
+ readonly thinkingStyle: "adaptive";
1243
+ readonly levels: readonly ["low", "medium", "high", "xhigh", "max"];
1244
+ readonly defaultLevel: "high";
1245
+ readonly canDisable: false;
1246
+ readonly outputsThinking: true;
1247
+ readonly outputsSignatures: true;
1248
+ };
1249
+ readonly modalities: {
1250
+ readonly input: readonly ["text", "image", "pdf"];
1251
+ readonly output: readonly ["text"];
1252
+ };
1253
+ readonly knowledge: "2026-01";
1254
+ readonly releaseDate: "2026-06-09";
1255
+ readonly lastUpdated: "2026-06-09";
1256
+ readonly family: "claude-fable";
1257
+ readonly openWeights: false;
1258
+ readonly temperatureSupported: false;
1259
+ readonly provider: "anthropic";
1231
1260
  }, {
1232
1261
  readonly type: "text";
1233
1262
  readonly modelName: "claude-opus-4-8";
@@ -1310,6 +1339,35 @@ export declare const textModels: readonly [{
1310
1339
  readonly openWeights: false;
1311
1340
  readonly temperatureSupported: true;
1312
1341
  readonly provider: "anthropic";
1342
+ }, {
1343
+ readonly type: "text";
1344
+ readonly modelName: "claude-sonnet-5";
1345
+ readonly description: "The best combination of speed and intelligence in the Sonnet tier, with near-Opus quality on coding and agentic work. Adaptive thinking on by default; supports the full low/medium/high/xhigh/max effort range. New tokenizer (~30% more tokens for the same text vs Sonnet 4.6). Standard pricing $3/$15; introductory $2/$10 per MTok through 2026-08-31. 1M context window, 128K max output.";
1346
+ readonly maxInputTokens: 1000000;
1347
+ readonly maxOutputTokens: 128000;
1348
+ readonly inputTokenCost: 3;
1349
+ readonly cachedInputTokenCost: 0.3;
1350
+ readonly cacheCreationInputTokenCost: 3.75;
1351
+ readonly outputTokenCost: 15;
1352
+ readonly reasoning: {
1353
+ readonly thinkingStyle: "adaptive";
1354
+ readonly levels: readonly ["low", "medium", "high", "xhigh", "max"];
1355
+ readonly defaultLevel: "high";
1356
+ readonly canDisable: true;
1357
+ readonly outputsThinking: true;
1358
+ readonly outputsSignatures: true;
1359
+ };
1360
+ readonly modalities: {
1361
+ readonly input: readonly ["text", "image", "pdf"];
1362
+ readonly output: readonly ["text"];
1363
+ };
1364
+ readonly knowledge: "2026-01";
1365
+ readonly releaseDate: "2026-06-30";
1366
+ readonly lastUpdated: "2026-06-30";
1367
+ readonly family: "claude-sonnet";
1368
+ readonly openWeights: false;
1369
+ readonly temperatureSupported: false;
1370
+ readonly provider: "anthropic";
1313
1371
  }, {
1314
1372
  readonly type: "text";
1315
1373
  readonly modelName: "claude-sonnet-4-6";
package/dist/models.js CHANGED
@@ -1206,6 +1206,36 @@ export const textModels = [
1206
1206
  disabled: true,
1207
1207
  provider: "google",
1208
1208
  },
1209
+ {
1210
+ type: "text",
1211
+ modelName: "claude-fable-5",
1212
+ description: "Anthropic's most capable widely released model, for the most demanding reasoning and long-horizon agentic work. Thinking is always on (cannot be disabled); the raw chain of thought is never returned. Requires 30-day data retention (not available under zero data retention). 1M context window, 128K max output.",
1213
+ maxInputTokens: 1000000,
1214
+ maxOutputTokens: 128000,
1215
+ inputTokenCost: 10,
1216
+ cachedInputTokenCost: 1,
1217
+ cacheCreationInputTokenCost: 12.5,
1218
+ outputTokenCost: 50,
1219
+ reasoning: {
1220
+ thinkingStyle: "adaptive",
1221
+ levels: ["low", "medium", "high", "xhigh", "max"],
1222
+ defaultLevel: "high",
1223
+ canDisable: false,
1224
+ outputsThinking: true,
1225
+ outputsSignatures: true,
1226
+ },
1227
+ modalities: {
1228
+ input: ["text", "image", "pdf"],
1229
+ output: ["text"],
1230
+ },
1231
+ knowledge: "2026-01",
1232
+ releaseDate: "2026-06-09",
1233
+ lastUpdated: "2026-06-09",
1234
+ family: "claude-fable",
1235
+ openWeights: false,
1236
+ temperatureSupported: false,
1237
+ provider: "anthropic",
1238
+ },
1209
1239
  {
1210
1240
  type: "text",
1211
1241
  modelName: "claude-opus-4-8",
@@ -1291,6 +1321,36 @@ export const textModels = [
1291
1321
  temperatureSupported: true,
1292
1322
  provider: "anthropic",
1293
1323
  },
1324
+ {
1325
+ type: "text",
1326
+ modelName: "claude-sonnet-5",
1327
+ description: "The best combination of speed and intelligence in the Sonnet tier, with near-Opus quality on coding and agentic work. Adaptive thinking on by default; supports the full low/medium/high/xhigh/max effort range. New tokenizer (~30% more tokens for the same text vs Sonnet 4.6). Standard pricing $3/$15; introductory $2/$10 per MTok through 2026-08-31. 1M context window, 128K max output.",
1328
+ maxInputTokens: 1000000,
1329
+ maxOutputTokens: 128000,
1330
+ inputTokenCost: 3,
1331
+ cachedInputTokenCost: 0.3,
1332
+ cacheCreationInputTokenCost: 3.75,
1333
+ outputTokenCost: 15,
1334
+ reasoning: {
1335
+ thinkingStyle: "adaptive",
1336
+ levels: ["low", "medium", "high", "xhigh", "max"],
1337
+ defaultLevel: "high",
1338
+ canDisable: true,
1339
+ outputsThinking: true,
1340
+ outputsSignatures: true,
1341
+ },
1342
+ modalities: {
1343
+ input: ["text", "image", "pdf"],
1344
+ output: ["text"],
1345
+ },
1346
+ knowledge: "2026-01",
1347
+ releaseDate: "2026-06-30",
1348
+ lastUpdated: "2026-06-30",
1349
+ family: "claude-sonnet",
1350
+ openWeights: false,
1351
+ temperatureSupported: false,
1352
+ provider: "anthropic",
1353
+ },
1294
1354
  {
1295
1355
  type: "text",
1296
1356
  modelName: "claude-sonnet-4-6",
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Normalized reason a generation turn ended, unified across providers. The
3
+ * untouched provider value is available separately as `PromptResult.rawStopReason`.
4
+ */
5
+ export type StopReason =
6
+ /** Natural completion (OpenAI `stop`, Anthropic `end_turn`, Google `STOP`, Ollama `stop`). */
7
+ "stop"
8
+ /** Hit the max-tokens limit (`length` / `max_tokens` / `MAX_TOKENS`). */
9
+ | "length"
10
+ /** Model wants to call a tool (`tool_calls` / `tool_use`). */
11
+ | "tool_use"
12
+ /** Blocked by a safety/policy filter or refusal (`content_filter` / `refusal` / `SAFETY`). */
13
+ | "content_filter"
14
+ /** Hit a caller-supplied stop sequence (Anthropic `stop_sequence`). */
15
+ | "stop_sequence"
16
+ /** Provider paused a long-running turn (Anthropic `pause_turn`). */
17
+ | "pause"
18
+ /** Anything unmapped or unknown. */
19
+ | "other";
@@ -0,0 +1 @@
1
+ export {};
package/dist/types.d.ts CHANGED
@@ -10,8 +10,10 @@ import type { ModelDataBlob } from "./modelData.js";
10
10
  import { Result } from "./types/result.js";
11
11
  import { TokenUsage } from "./types/tokenUsage.js";
12
12
  import { CostEstimate } from "./types/costEstimate.js";
13
+ import { StopReason } from "./types/stopReason.js";
13
14
  export * from "./types/costEstimate.js";
14
15
  export * from "./types/tokenUsage.js";
16
+ export * from "./types/stopReason.js";
15
17
  export type SmolConfig = {
16
18
  /** The model to use. */
17
19
  model: ModelName;
@@ -150,8 +152,12 @@ export type PromptResult = {
150
152
  cost?: CostEstimate;
151
153
  model?: ModelName;
152
154
  hostedToolResults?: HostedToolResult[];
155
+ /** Normalized reason the turn ended, unified across providers. */
156
+ stopReason?: StopReason;
157
+ /** The untouched provider finish/stop-reason value (e.g. `end_turn`, `MAX_TOKENS`). */
158
+ rawStopReason?: string;
153
159
  };
154
- export declare function promptResult({ output, toolCalls, thinkingBlocks, usage, cost, model, hostedToolResults, }: Partial<PromptResult>): PromptResult;
160
+ export declare function promptResult({ output, toolCalls, thinkingBlocks, usage, cost, model, hostedToolResults, stopReason, rawStopReason, }: Partial<PromptResult>): PromptResult;
155
161
  export type StreamChunk = {
156
162
  type: "text";
157
163
  text: string;
package/dist/types.js CHANGED
@@ -3,7 +3,8 @@ export * from "./classes/message/contentParts.js";
3
3
  import z from "zod";
4
4
  export * from "./types/costEstimate.js";
5
5
  export * from "./types/tokenUsage.js";
6
- export function promptResult({ output, toolCalls, thinkingBlocks, usage, cost, model, hostedToolResults, }) {
6
+ export * from "./types/stopReason.js";
7
+ export function promptResult({ output, toolCalls, thinkingBlocks, usage, cost, model, hostedToolResults, stopReason, rawStopReason, }) {
7
8
  return {
8
9
  output: output || null,
9
10
  toolCalls: toolCalls || [],
@@ -12,6 +13,8 @@ export function promptResult({ output, toolCalls, thinkingBlocks, usage, cost, m
12
13
  cost,
13
14
  model,
14
15
  hostedToolResults,
16
+ stopReason,
17
+ rawStopReason,
15
18
  };
16
19
  }
17
20
  export const ThinkingBlockSchema = z.object({
@@ -0,0 +1,38 @@
1
+ /**
2
+ * JSON Schema sanitization for structured output.
3
+ *
4
+ * Zod converts `z.any()` / `z.unknown()` to an unconstrained schema — a bare
5
+ * `{}` (nested), `{"$schema":…}` (top-level), or the boolean `true`. That is
6
+ * valid JSON Schema ("accept any value"), but every provider rejects an
7
+ * unconstrained node inside a structured-output or strict-tool schema, since the
8
+ * whole point of structured output is that it is structured. These helpers map
9
+ * such nodes to `{"type":"string"}` (the safe universal container) and let
10
+ * callers detect the whole-schema-is-`any` case so they can drop structured
11
+ * output entirely and return free text.
12
+ */
13
+ /**
14
+ * True if `node` accepts any value: the boolean `true`, or a plain object whose
15
+ * every own key is a pure annotation (`{}`, `{$schema:…}`,
16
+ * `{$schema:…, description:…}`). `false` and any object with a structural or
17
+ * validation keyword are constrained.
18
+ */
19
+ export declare function isUnconstrainedSchema(node: unknown): boolean;
20
+ /**
21
+ * Convert a Zod `responseFormat` schema to a sanitized JSON Schema for a
22
+ * provider's structured-output request: any nested unconstrained node becomes
23
+ * `{"type":"string"}`. (The whole-schema-is-`any` case is handled upstream in
24
+ * `BaseClient.normalizeResponseFormat`, which drops structured output entirely.)
25
+ */
26
+ export declare function responseFormatToJsonSchema(schema: {
27
+ toJSONSchema: () => unknown;
28
+ }): object;
29
+ /**
30
+ * Returns a new JSON Schema with every unconstrained node replaced by
31
+ * `{"type":"string"}` (annotations preserved), recursing through all subschema
32
+ * positions. Idempotent — a node that already has `type` is left untouched.
33
+ *
34
+ * `additionalProperties` and `items` may be a boolean (`true`/`false`), which is
35
+ * a legitimate provider-accepted flag, not an any-typed value slot; booleans
36
+ * there are left as-is and only an object subschema is sanitized.
37
+ */
38
+ export declare function sanitizeJsonSchema(node: unknown): unknown;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * JSON Schema sanitization for structured output.
3
+ *
4
+ * Zod converts `z.any()` / `z.unknown()` to an unconstrained schema — a bare
5
+ * `{}` (nested), `{"$schema":…}` (top-level), or the boolean `true`. That is
6
+ * valid JSON Schema ("accept any value"), but every provider rejects an
7
+ * unconstrained node inside a structured-output or strict-tool schema, since the
8
+ * whole point of structured output is that it is structured. These helpers map
9
+ * such nodes to `{"type":"string"}` (the safe universal container) and let
10
+ * callers detect the whole-schema-is-`any` case so they can drop structured
11
+ * output entirely and return free text.
12
+ */
13
+ /**
14
+ * Pure-annotation keywords. A node carrying *only* these constrains nothing, so
15
+ * it is treated as unconstrained. Everything else — `type`, `properties`,
16
+ * `enum`, `$ref`, any validation keyword — counts as constraining. This is an
17
+ * allowlist, not a denylist of structural keywords, so unknown/future keywords
18
+ * are constraining by default (the safe direction: never rewrite a schema that
19
+ * means something).
20
+ */
21
+ const ANNOTATION_KEYS = new Set([
22
+ "$schema",
23
+ "$id",
24
+ "$anchor",
25
+ "$comment",
26
+ "description",
27
+ "title",
28
+ "default",
29
+ "examples",
30
+ "readOnly",
31
+ "deprecated",
32
+ ]);
33
+ /**
34
+ * True if `node` accepts any value: the boolean `true`, or a plain object whose
35
+ * every own key is a pure annotation (`{}`, `{$schema:…}`,
36
+ * `{$schema:…, description:…}`). `false` and any object with a structural or
37
+ * validation keyword are constrained.
38
+ */
39
+ export function isUnconstrainedSchema(node) {
40
+ if (node === true)
41
+ return true;
42
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
43
+ return false;
44
+ }
45
+ return Object.keys(node).every((key) => ANNOTATION_KEYS.has(key));
46
+ }
47
+ /**
48
+ * Convert a Zod `responseFormat` schema to a sanitized JSON Schema for a
49
+ * provider's structured-output request: any nested unconstrained node becomes
50
+ * `{"type":"string"}`. (The whole-schema-is-`any` case is handled upstream in
51
+ * `BaseClient.normalizeResponseFormat`, which drops structured output entirely.)
52
+ */
53
+ export function responseFormatToJsonSchema(schema) {
54
+ // Zod's top-level toJSONSchema() is always an object, and the whole-schema-is-
55
+ // `any` case is stripped upstream, so sanitize always yields an object here.
56
+ return sanitizeJsonSchema(schema.toJSONSchema());
57
+ }
58
+ /**
59
+ * Subschema positions holding a single nested value schema (recursed into).
60
+ *
61
+ * `not` and `contains` are deliberately excluded: they are *assertions*, not
62
+ * value slots, so an unconstrained schema there is meaningful and must not be
63
+ * rewritten — `not: {}` means "reject everything" and would silently become
64
+ * "reject only strings" if mapped to `{type:"string"}`. Zod never emits either,
65
+ * so leaving them untouched is both correct and zero-impact in practice.
66
+ */
67
+ const SCHEMA_KEYS = ["items", "propertyNames"];
68
+ /** Subschema positions holding an object map of schemas. */
69
+ const SCHEMA_MAP_KEYS = ["properties", "patternProperties", "$defs", "definitions"];
70
+ /** Subschema positions holding an array of schemas. */
71
+ const SCHEMA_ARRAY_KEYS = ["anyOf", "oneOf", "allOf", "prefixItems"];
72
+ /**
73
+ * Returns a new JSON Schema with every unconstrained node replaced by
74
+ * `{"type":"string"}` (annotations preserved), recursing through all subschema
75
+ * positions. Idempotent — a node that already has `type` is left untouched.
76
+ *
77
+ * `additionalProperties` and `items` may be a boolean (`true`/`false`), which is
78
+ * a legitimate provider-accepted flag, not an any-typed value slot; booleans
79
+ * there are left as-is and only an object subschema is sanitized.
80
+ */
81
+ export function sanitizeJsonSchema(node) {
82
+ if (node === true)
83
+ return { type: "string" };
84
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
85
+ return node;
86
+ }
87
+ if (isUnconstrainedSchema(node)) {
88
+ return { ...node, type: "string" };
89
+ }
90
+ const src = node;
91
+ const out = { ...src };
92
+ for (const key of SCHEMA_KEYS) {
93
+ if (key in out) {
94
+ out[key] = sanitizeSubschema(out[key]);
95
+ }
96
+ }
97
+ for (const key of SCHEMA_MAP_KEYS) {
98
+ const map = out[key];
99
+ if (map && typeof map === "object" && !Array.isArray(map)) {
100
+ // Object.create(null): a property literally named "__proto__" (a legal Zod
101
+ // key) would otherwise reassign the prototype instead of setting an own key.
102
+ const sanitized = Object.create(null);
103
+ for (const [name, sub] of Object.entries(map)) {
104
+ sanitized[name] = sanitizeJsonSchema(sub);
105
+ }
106
+ out[key] = sanitized;
107
+ }
108
+ }
109
+ for (const key of SCHEMA_ARRAY_KEYS) {
110
+ const arr = out[key];
111
+ if (Array.isArray(arr)) {
112
+ out[key] = arr.map((sub) => sanitizeJsonSchema(sub));
113
+ }
114
+ }
115
+ // additionalProperties: leave booleans as-is, sanitize an object subschema.
116
+ if ("additionalProperties" in out) {
117
+ out.additionalProperties = sanitizeSubschema(out.additionalProperties);
118
+ }
119
+ return out;
120
+ }
121
+ /**
122
+ * Sanitize a value at a single-schema position (`items`, `propertyNames`,
123
+ * `additionalProperties`). These positions may legitimately hold a boolean:
124
+ * `additionalProperties: false`/`true` and `items: false` are provider-accepted
125
+ * flags, not any-typed value slots, so booleans pass through untouched and only
126
+ * an object subschema is recursively sanitized. (Zod does not emit a boolean at
127
+ * these positions, so `items: true` etc. are theoretical.)
128
+ */
129
+ function sanitizeSubschema(value) {
130
+ if (typeof value === "boolean")
131
+ return value;
132
+ return sanitizeJsonSchema(value);
133
+ }
@@ -0,0 +1,11 @@
1
+ import type { StopReason } from "../types/stopReason.js";
2
+ export declare function normalizeOpenAIStopReason(raw: string | null | undefined): StopReason;
3
+ export declare function normalizeAnthropicStopReason(raw: string | null | undefined): StopReason;
4
+ export declare function normalizeGoogleStopReason(raw: string | null | undefined, hasToolCalls: boolean): StopReason;
5
+ export declare function normalizeOllamaStopReason(raw: string | null | undefined): StopReason;
6
+ /**
7
+ * The Responses API has no single finish-reason field: a `completed` response
8
+ * is a normal stop (or tool use, if it carries tool calls), while an
9
+ * `incomplete` one carries the reason in `incomplete_details.reason`.
10
+ */
11
+ export declare function normalizeOpenAIResponsesStopReason(status: string | null | undefined, incompleteReason: string | null | undefined, hasToolCalls: boolean): StopReason;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Pure mappers from each provider's raw finish/stop-reason vocabulary to the
3
+ * unified {@link StopReason}. Clients set `PromptResult.stopReason` from these
4
+ * and keep the untouched provider value in `PromptResult.rawStopReason`.
5
+ */
6
+ const OPENAI_MAP = {
7
+ stop: "stop",
8
+ length: "length",
9
+ tool_calls: "tool_use",
10
+ function_call: "tool_use",
11
+ content_filter: "content_filter",
12
+ };
13
+ export function normalizeOpenAIStopReason(raw) {
14
+ return (raw && OPENAI_MAP[raw]) || "other";
15
+ }
16
+ const ANTHROPIC_MAP = {
17
+ end_turn: "stop",
18
+ max_tokens: "length",
19
+ // Ran out of context window — a length-class stop, same bucket as max_tokens.
20
+ model_context_window_exceeded: "length",
21
+ tool_use: "tool_use",
22
+ stop_sequence: "stop_sequence",
23
+ refusal: "content_filter",
24
+ pause_turn: "pause",
25
+ };
26
+ export function normalizeAnthropicStopReason(raw) {
27
+ return (raw && ANTHROPIC_MAP[raw]) || "other";
28
+ }
29
+ const GOOGLE_MAP = {
30
+ STOP: "stop",
31
+ MAX_TOKENS: "length",
32
+ SAFETY: "content_filter",
33
+ PROHIBITED_CONTENT: "content_filter",
34
+ RECITATION: "content_filter",
35
+ BLOCKLIST: "content_filter",
36
+ SPII: "content_filter",
37
+ IMAGE_SAFETY: "content_filter",
38
+ LANGUAGE: "content_filter",
39
+ };
40
+ export function normalizeGoogleStopReason(raw, hasToolCalls) {
41
+ // Gemini reports `STOP` even for tool-call turns, so infer `tool_use` when
42
+ // tool calls are present — otherwise the unified field couldn't detect tool
43
+ // use on Google the way it does on OpenAI/Anthropic.
44
+ if (raw === "STOP" && hasToolCalls) {
45
+ return "tool_use";
46
+ }
47
+ return (raw && GOOGLE_MAP[raw]) || "other";
48
+ }
49
+ const OLLAMA_MAP = {
50
+ stop: "stop",
51
+ length: "length",
52
+ };
53
+ export function normalizeOllamaStopReason(raw) {
54
+ return (raw && OLLAMA_MAP[raw]) || "other";
55
+ }
56
+ const RESPONSES_INCOMPLETE_MAP = {
57
+ max_output_tokens: "length",
58
+ content_filter: "content_filter",
59
+ };
60
+ /**
61
+ * The Responses API has no single finish-reason field: a `completed` response
62
+ * is a normal stop (or tool use, if it carries tool calls), while an
63
+ * `incomplete` one carries the reason in `incomplete_details.reason`.
64
+ */
65
+ export function normalizeOpenAIResponsesStopReason(status, incompleteReason, hasToolCalls) {
66
+ if (incompleteReason) {
67
+ return RESPONSES_INCOMPLETE_MAP[incompleteReason] || "other";
68
+ }
69
+ // Tool-call turns report a generic terminal status, so infer tool_use from the
70
+ // presence of tool calls. Also treat a missing status (e.g. a stream that ended
71
+ // without a completed/incomplete event) as "unknown but has tools" rather than
72
+ // letting a tool-call turn silently degrade to "other".
73
+ if (hasToolCalls && (status === "completed" || status == null)) {
74
+ return "tool_use";
75
+ }
76
+ if (status === "completed") {
77
+ return "stop";
78
+ }
79
+ return "other";
80
+ }
package/dist/util/tool.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { validateToolName } from "./util.js";
2
+ import { sanitizeJsonSchema } from "./jsonSchema.js";
2
3
  export function zodToOpenAITool(name, schema, options = {}) {
3
4
  validateToolName(name);
4
5
  // Convert Zod schema to JSON Schema
5
- const jsonSchema = schema.toJSONSchema();
6
+ const jsonSchema = sanitizeJsonSchema(schema.toJSONSchema());
6
7
  let description = "";
7
8
  if (options?.description) {
8
9
  description = options.description;
@@ -37,7 +38,7 @@ export function zodToOpenAITool(name, schema, options = {}) {
37
38
  }
38
39
  export function zodToOpenAIResponsesTool(name, schema, options = {}) {
39
40
  validateToolName(name);
40
- const jsonSchema = schema.toJSONSchema();
41
+ const jsonSchema = sanitizeJsonSchema(schema.toJSONSchema());
41
42
  const strict = options?.strict ?? false;
42
43
  const parameters = {
43
44
  type: "object",
@@ -67,7 +68,7 @@ export function zodToOpenAIResponsesTool(name, schema, options = {}) {
67
68
  }
68
69
  export function zodToAnthropicTool(name, schema, options = {}) {
69
70
  validateToolName(name);
70
- const jsonSchema = schema.toJSONSchema();
71
+ const jsonSchema = sanitizeJsonSchema(schema.toJSONSchema());
71
72
  let description;
72
73
  if (options?.description) {
73
74
  description = options.description;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoltalk",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "A common interface for LLM APIs",
5
5
  "homepage": "https://github.com/egonSchiele/smoltalk",
6
6
  "files": [