smoltalk 0.8.1 → 0.8.3

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.
@@ -3,6 +3,16 @@ import { PromptResult, Result, SmolClient, SmolConfig, StreamChunk, HostedToolRe
3
3
  import { BaseClient } from "./baseClient.js";
4
4
  import { ModelName } from "../models.js";
5
5
  export declare function mergeConsecutiveMessages(messages: MessageParam[]): MessageParam[];
6
+ /**
7
+ * Whether a model supports Anthropic's native structured-output request
8
+ * (`output_config.format` with a json_schema — the direct analog of the OpenAI
9
+ * client's `response_format`). GA on Claude 4.x+ (Opus 4.5+, Sonnet 4.5+, Haiku
10
+ * 4.5, and the 4.6/4.7/4.8 / Sonnet 5 / Fable families). The legacy claude-3.x /
11
+ * 2.x aliases do not support it; there we fall back to prompt-based output plus
12
+ * the base client's fence stripping. Unknown/future model names default to
13
+ * supported (forward-looking).
14
+ */
15
+ export declare function anthropicSupportsStructuredOutput(model: string): boolean;
6
16
  export declare function anthropicWebSearchEntries(hostedTools?: string[]): any[];
7
17
  export declare function parseAnthropicHostedTools(response: any, provider: string): HostedToolResult[];
8
18
  type EphemeralCacheControl = {
@@ -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";
@@ -46,6 +48,18 @@ export function mergeConsecutiveMessages(messages) {
46
48
  }
47
49
  return merged;
48
50
  }
51
+ /**
52
+ * Whether a model supports Anthropic's native structured-output request
53
+ * (`output_config.format` with a json_schema — the direct analog of the OpenAI
54
+ * client's `response_format`). GA on Claude 4.x+ (Opus 4.5+, Sonnet 4.5+, Haiku
55
+ * 4.5, and the 4.6/4.7/4.8 / Sonnet 5 / Fable families). The legacy claude-3.x /
56
+ * 2.x aliases do not support it; there we fall back to prompt-based output plus
57
+ * the base client's fence stripping. Unknown/future model names default to
58
+ * supported (forward-looking).
59
+ */
60
+ export function anthropicSupportsStructuredOutput(model) {
61
+ return !/^claude-(?:3(?:[-.]|$)|2(?:[-.]|$)|instant(?:[-.]|$))/i.test(model);
62
+ }
49
63
  export function anthropicWebSearchEntries(hostedTools) {
50
64
  if (hostedTools && hostedTools.includes(WEB_SEARCH)) {
51
65
  return [{ type: "web_search_20250305", name: "web_search" }];
@@ -247,7 +261,22 @@ export class SmolAnthropic extends BaseClient {
247
261
  // Normalize the user's provider-agnostic thinking/effort config into the
248
262
  // shape this specific model accepts. Both `thinking.enabled` and
249
263
  // `reasoningEffort` are treated as a request to think.
250
- const { thinking, outputConfig } = this.resolveThinking(config);
264
+ const { thinking, outputConfig: effortConfig } = this.resolveThinking(config);
265
+ // Provider-native structured output. Anthropic constrains the response to a
266
+ // JSON schema via `output_config.format` (grammar-constrained decoding) —
267
+ // the direct analog of the OpenAI client's `response_format` json_schema,
268
+ // and it composes with function tools (the model may call a tool OR emit the
269
+ // structured JSON). GA on Claude 4.x+; on the legacy claude-3.x/2.x aliases
270
+ // it isn't available, so we omit it and let the base client's fence stripper
271
+ // + retry recover a prompt-shaped reply.
272
+ const format = config.responseFormat &&
273
+ anthropicSupportsStructuredOutput(this.getModel())
274
+ ? { type: "json_schema", schema: responseFormatToJsonSchema(config.responseFormat) }
275
+ : undefined;
276
+ // Merge effort + format into one output_config (both optional, independent).
277
+ const outputConfig = effortConfig || format
278
+ ? { ...effortConfig, ...(format && { format }) }
279
+ : undefined;
251
280
  const cachingEnabled = config.caching?.enabled !== false;
252
281
  const baseRequest = { system, messages: anthropicMessages, tools };
253
282
  const finalRequest = cachingEnabled
@@ -362,13 +391,18 @@ export class SmolAnthropic extends BaseClient {
362
391
  const { usage, cost } = this.calculateUsageAndCost(response.usage);
363
392
  const parsed = parseAnthropicHostedTools(response, "anthropic");
364
393
  const { results: hostedToolResults, cost: finalCost } = applyHostedToolCost(parsed, cost, this.getModel(), this.config.modelData);
394
+ const rawStopReason = response.stop_reason ?? undefined;
365
395
  const result = {
366
396
  output,
367
397
  toolCalls,
368
398
  usage,
369
399
  cost: finalCost,
370
400
  model: this.getModel(),
401
+ stopReason: normalizeAnthropicStopReason(rawStopReason),
371
402
  };
403
+ if (rawStopReason) {
404
+ result.rawStopReason = rawStopReason;
405
+ }
372
406
  if (thinkingBlocks.length > 0) {
373
407
  result.thinkingBlocks = thinkingBlocks;
374
408
  }
@@ -416,10 +450,23 @@ export class SmolAnthropic extends BaseClient {
416
450
  const toolBlocks = new Map();
417
451
  // Track thinking blocks by index: index -> { text, signature }
418
452
  const thinkingBlockMap = new Map();
453
+ // Track server-side web_search blocks by index -> partial input JSON. The
454
+ // query streams in as input_json_delta just like a client tool call; we
455
+ // parse it once the block stops and emit a live `web_search` chunk.
456
+ const webSearchBlocks = new Map();
457
+ // Accumulated hosted-tool signal for the final `done` result (parity with
458
+ // the non-streaming path's `hostedToolResults`): queries, sources,
459
+ // citations, billed count, and the raw provider blocks.
460
+ const webSearchQueries = [];
461
+ const webSearchSources = [];
462
+ const webSearchCitations = [];
463
+ const webSearchRaw = [];
464
+ let webSearchRequests;
419
465
  let inputTokens = 0;
420
466
  let cacheReadTokens = 0;
421
467
  let cacheCreationTokens = 0;
422
468
  let outputTokens = 0;
469
+ let rawStopReason;
423
470
  for await (const event of stream) {
424
471
  if (event.type === "message_start") {
425
472
  const u = event.message.usage;
@@ -435,6 +482,25 @@ export class SmolAnthropic extends BaseClient {
435
482
  arguments: "",
436
483
  });
437
484
  }
485
+ else if (event.content_block.type === "server_tool_use" &&
486
+ event.content_block.name === "web_search") {
487
+ // Keep the start block so we can reconstruct the raw provider block
488
+ // (with its streamed `input`) at content_block_stop.
489
+ webSearchBlocks.set(event.index, {
490
+ arguments: "",
491
+ start: event.content_block,
492
+ });
493
+ }
494
+ else if (event.content_block.type === "web_search_tool_result") {
495
+ // Server tool results arrive whole (no deltas). Collect sources and
496
+ // keep the raw block so the final `done` result matches _textSync.
497
+ webSearchRaw.push(event.content_block);
498
+ for (const r of event.content_block.content || []) {
499
+ if (r && typeof r.url === "string") {
500
+ webSearchSources.push({ url: r.url, title: r.title });
501
+ }
502
+ }
503
+ }
438
504
  else if (event.content_block.type === "thinking") {
439
505
  thinkingBlockMap.set(event.index, { text: "", signature: "" });
440
506
  }
@@ -449,6 +515,10 @@ export class SmolAnthropic extends BaseClient {
449
515
  if (block) {
450
516
  block.arguments += event.delta.partial_json;
451
517
  }
518
+ const searchBlock = webSearchBlocks.get(event.index);
519
+ if (searchBlock) {
520
+ searchBlock.arguments += event.delta.partial_json;
521
+ }
452
522
  }
453
523
  else if (event.delta.type === "thinking_delta") {
454
524
  const block = thinkingBlockMap.get(event.index);
@@ -462,6 +532,15 @@ export class SmolAnthropic extends BaseClient {
462
532
  block.signature = event.delta.signature;
463
533
  }
464
534
  }
535
+ else if (event.delta.type === "citations_delta") {
536
+ // Citations stream in one per delta (whereas _textSync reads them
537
+ // from the assembled text block's `citations` array). Collect any
538
+ // with a url so the done result's hostedToolResults match sync.
539
+ const c = event.delta.citation;
540
+ if (c && typeof c.url === "string") {
541
+ webSearchCitations.push({ url: c.url, title: c.title });
542
+ }
543
+ }
465
544
  }
466
545
  else if (event.type === "content_block_stop") {
467
546
  // Emit thinking chunk once the block is fully assembled
@@ -473,8 +552,34 @@ export class SmolAnthropic extends BaseClient {
473
552
  signature: thinkingBlock.signature,
474
553
  };
475
554
  }
555
+ // Emit the web search query the moment its block completes, so
556
+ // consumers see the keywords live rather than only in the final result.
557
+ const searchBlock = webSearchBlocks.get(event.index);
558
+ if (searchBlock) {
559
+ // Delete first so a replayed/duplicate stop event can't emit the
560
+ // same query twice.
561
+ webSearchBlocks.delete(event.index);
562
+ let input;
563
+ try {
564
+ input = JSON.parse(searchBlock.arguments || "{}");
565
+ }
566
+ catch {
567
+ // Malformed/partial JSON — skip this block rather than throw.
568
+ }
569
+ // Reconstruct the raw provider block (start header + streamed input)
570
+ // so hostedToolResults.raw matches the non-streaming shape.
571
+ webSearchRaw.push({ ...searchBlock.start, input });
572
+ const query = input?.query;
573
+ if (typeof query === "string" && query.length > 0) {
574
+ webSearchQueries.push(query);
575
+ yield { type: "web_search", query };
576
+ }
577
+ }
476
578
  }
477
579
  else if (event.type === "message_delta") {
580
+ if (event.delta?.stop_reason) {
581
+ rawStopReason = event.delta.stop_reason;
582
+ }
478
583
  outputTokens = event.usage.output_tokens;
479
584
  // Defensive: in practice Anthropic only sends cache fields on
480
585
  // message_start, but read them here too so we don't miss an
@@ -485,6 +590,10 @@ export class SmolAnthropic extends BaseClient {
485
590
  if (event.usage.cache_creation_input_tokens != null) {
486
591
  cacheCreationTokens = event.usage.cache_creation_input_tokens;
487
592
  }
593
+ // Billable web-search count, for hosted-tool cost on the done result.
594
+ if (event.usage.server_tool_use?.web_search_requests != null) {
595
+ webSearchRequests = event.usage.server_tool_use.web_search_requests;
596
+ }
488
597
  }
489
598
  }
490
599
  this.logger.debug("Streaming response completed from Anthropic");
@@ -510,17 +619,41 @@ export class SmolAnthropic extends BaseClient {
510
619
  if (cacheCreationTokens > 0) {
511
620
  usage.cacheCreationInputTokens = cacheCreationTokens;
512
621
  }
513
- const cost = this.model.calculateCost(usage) ?? undefined;
514
- yield {
515
- type: "done",
516
- result: {
517
- output: content || null,
518
- toolCalls,
519
- ...(thinkingBlocks.length > 0 && { thinkingBlocks }),
520
- usage,
521
- cost,
522
- model: this.getModel(),
523
- },
622
+ let cost = this.model.calculateCost(usage) ?? undefined;
623
+ // Fold hosted-tool (web search) results into the done result so the
624
+ // streaming path reports the same `hostedToolResults` shape as _textSync.
625
+ // The live `web_search` chunks above already surfaced the queries; this is
626
+ // the durable record (queries + sources + citations + billed cost + raw).
627
+ let hostedToolResults = [];
628
+ const usedWebSearch = webSearchQueries.length > 0 ||
629
+ webSearchSources.length > 0 ||
630
+ (webSearchRequests ?? 0) > 0;
631
+ if (usedWebSearch) {
632
+ const applied = applyHostedToolCost([
633
+ webSearchResult("anthropic", {
634
+ queries: webSearchQueries,
635
+ sources: webSearchSources,
636
+ citations: webSearchCitations,
637
+ callCount: webSearchRequests,
638
+ raw: webSearchRaw,
639
+ }),
640
+ ], cost, this.getModel(), this.config.modelData);
641
+ hostedToolResults = applied.results;
642
+ cost = applied.cost;
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 }),
524
653
  };
654
+ if (rawStopReason) {
655
+ result.rawStopReason = rawStopReason;
656
+ }
657
+ yield { type: "done", result };
525
658
  }
526
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;
@@ -258,7 +277,12 @@ export class BaseClient {
258
277
  retries > 0) {
259
278
  const allowExtraKeys = promptConfig.responseFormatOptions?.allowExtraKeys ?? false;
260
279
  try {
261
- const parsed = JSON.parse(output);
280
+ // Strip any ```json … ``` fence before parsing. Models (notably
281
+ // Anthropic on the prompt-based path) routinely wrap structured
282
+ // output in a markdown fence; feeding that straight to JSON.parse
283
+ // throws and burns a retry. stripCodeFence is a no-op on unfenced
284
+ // JSON, so this is safe for every provider.
285
+ const parsed = JSON.parse(stripCodeFence(output));
262
286
  const parseResult = this.extractResponse(promptConfig, parsed, promptConfig.responseFormat);
263
287
  return success({
264
288
  ...result.value,
@@ -292,6 +316,7 @@ export class BaseClient {
292
316
  throw new Error("Method not implemented.");
293
317
  }
294
318
  async *textStream(config) {
319
+ config = this.normalizeResponseFormat(config);
295
320
  const messageLimitResult = this.checkMessageLimit(config);
296
321
  if (messageLimitResult) {
297
322
  yield {
@@ -5,6 +5,21 @@ import { ModelName } from "../models.js";
5
5
  import { HostedToolResult } from "../types.js";
6
6
  export type SmolGoogleConfig = SmolConfig;
7
7
  export declare function googleWebSearchEntries(hostedTools?: string[]): any[];
8
+ /**
9
+ * Whether a Gemini model can combine built-in tools (e.g. hosted web search)
10
+ * with function calling in a single request. Confirmed against the live API:
11
+ * - Gemini 3+ : supported, but ONLY when `toolConfig
12
+ * .includeServerSideToolInvocations` is set ("tool call context
13
+ * circulation"). Without it the API 400s asking you to enable it.
14
+ * - Gemini 2.5 and earlier: NOT supported by any means — the raw combination
15
+ * 400s with "Built-in tools and Function Calling cannot be combined", and
16
+ * the flag 400s with "Tool call context circulation is not enabled for
17
+ * <model>".
18
+ * Unknown / non-versioned model names default to supported (forward-looking):
19
+ * new models are expected to allow the combination.
20
+ * See egonSchiele/agency-lang#495.
21
+ */
22
+ export declare function geminiSupportsToolCirculation(model: string): boolean;
8
23
  export declare function parseGoogleHostedTools(result: any, provider: string, model: string): HostedToolResult[];
9
24
  type GeneratedRequest = {
10
25
  contents: Content[];
@@ -4,7 +4,9 @@ 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 { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
7
+ import { responseFormatToJsonSchema } from "../util/jsonSchema.js";
8
+ import { normalizeGoogleStopReason } from "../util/stopReason.js";
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";
10
12
  import { BaseClient } from "./baseClient.js";
@@ -17,6 +19,26 @@ export function googleWebSearchEntries(hostedTools) {
17
19
  }
18
20
  return [];
19
21
  }
22
+ /**
23
+ * Whether a Gemini model can combine built-in tools (e.g. hosted web search)
24
+ * with function calling in a single request. Confirmed against the live API:
25
+ * - Gemini 3+ : supported, but ONLY when `toolConfig
26
+ * .includeServerSideToolInvocations` is set ("tool call context
27
+ * circulation"). Without it the API 400s asking you to enable it.
28
+ * - Gemini 2.5 and earlier: NOT supported by any means — the raw combination
29
+ * 400s with "Built-in tools and Function Calling cannot be combined", and
30
+ * the flag 400s with "Tool call context circulation is not enabled for
31
+ * <model>".
32
+ * Unknown / non-versioned model names default to supported (forward-looking):
33
+ * new models are expected to allow the combination.
34
+ * See egonSchiele/agency-lang#495.
35
+ */
36
+ export function geminiSupportsToolCirculation(model) {
37
+ const m = /^gemini-(\d+)/.exec(model);
38
+ if (!m)
39
+ return true;
40
+ return parseInt(m[1], 10) >= 3;
41
+ }
20
42
  export function parseGoogleHostedTools(result, provider, model) {
21
43
  const queries = [];
22
44
  const sources = [];
@@ -134,9 +156,22 @@ export class SmolGoogle extends BaseClient {
134
156
  toolGroups.push(entry);
135
157
  }
136
158
  genConfig.tools = toolGroups;
137
- // Gemini rejects mixing built-in (server-side) tools with function calling
138
- // unless the caller opts in. Only required when both kinds coexist.
159
+ // Combining built-in tools (hosted web search) with function calling is a
160
+ // Gemini feature ("tool call context circulation") that must be opted
161
+ // into via includeServerSideToolInvocations AND is only supported on
162
+ // Gemini 3+. On older models the combination is impossible: sending the
163
+ // flag 400s ("circulation is not enabled for <model>") and omitting it
164
+ // 400s ("Built-in tools and Function Calling cannot be combined"). Fail
165
+ // fast with an actionable message instead of a cryptic provider 400.
166
+ // See egonSchiele/agency-lang#495.
139
167
  if (tools.length > 0 && hostedEntries.length > 0) {
168
+ if (!geminiSupportsToolCirculation(this.getModel())) {
169
+ throw new SmolError(`${this.getModel()} cannot use the hosted web_search tool together ` +
170
+ `with function tools in one request. Gemini only allows combining ` +
171
+ `built-in tools with function calling on Gemini 3+ models. Use a ` +
172
+ `Gemini 3+ model, switch to a client-side search tool instead of ` +
173
+ `the hosted web_search, or drop one of the two.`, { status: 400 });
174
+ }
140
175
  genConfig.toolConfig = {
141
176
  ...genConfig.toolConfig,
142
177
  includeServerSideToolInvocations: true,
@@ -145,9 +180,22 @@ export class SmolGoogle extends BaseClient {
145
180
  }
146
181
  if (config.responseFormat) {
147
182
  genConfig.responseMimeType = "application/json";
148
- genConfig.responseJsonSchema = config.responseFormat.toJSONSchema();
183
+ genConfig.responseJsonSchema = responseFormatToJsonSchema(config.responseFormat);
184
+ }
185
+ if (config.thinking?.enabled) {
186
+ // Gemini only returns thought-summary parts (parts with `thought: true`,
187
+ // which populate PromptResult.thinkingBlocks) when includeThoughts is set.
188
+ // Without it the model still reasons, but returns only the encrypted
189
+ // thoughtSignature on the answer part — no visible reasoning text — so
190
+ // thinkingBlocks would come back empty.
191
+ genConfig.thinkingConfig = {
192
+ includeThoughts: true,
193
+ ...(config.thinking.budgetTokens !== undefined && {
194
+ thinkingBudget: config.thinking.budgetTokens,
195
+ }),
196
+ };
149
197
  }
150
- if (!config.thinking?.enabled && config.reasoningEffort) {
198
+ else if (config.reasoningEffort) {
151
199
  const budgetMap = { low: 2048, medium: 8192, high: 16384 };
152
200
  genConfig.thinkingConfig = {
153
201
  thinkingBudget: budgetMap[config.reasoningEffort],
@@ -246,7 +294,7 @@ export class SmolGoogle extends BaseClient {
246
294
  ...(toolResult.value.thinkingBlocks || []),
247
295
  ...(responseResult.value.thinkingBlocks || []),
248
296
  ];
249
- return success({
297
+ const structuredResult = {
250
298
  output: responseResult.value.output,
251
299
  // if there were tool calls, we would have returned already, so we know these are empty
252
300
  toolCalls: [],
@@ -254,7 +302,13 @@ export class SmolGoogle extends BaseClient {
254
302
  usage: addTokenUsage(toolResult.value.usage, responseResult.value.usage),
255
303
  cost: addCosts(toolResult.value.cost, responseResult.value.cost),
256
304
  model: request.model,
257
- });
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);
258
312
  }
259
313
  async __textSync(request) {
260
314
  this.logger.debug("Sending request to Google Gemini:", JSON.stringify(redactAttachments(request), null, 2));
@@ -291,14 +345,19 @@ export class SmolGoogle extends BaseClient {
291
345
  thoughtSignature: part.thoughtSignature,
292
346
  }));
293
347
  }
294
- else if (part.thoughtSignature) {
295
- // Capture thought parts (thought: true indicates a thinking part)
348
+ else if (part.thought) {
349
+ // A thinking part is identified by `thought: true` — NOT merely by
350
+ // the presence of a thoughtSignature. Gemini 3 also rides a
351
+ // thoughtSignature on the final answer part (no `thought` flag) for
352
+ // stateless reasoning continuity; keying on the signature alone
353
+ // would misfile that answer text as a thinking block and leave the
354
+ // output empty. See egonSchiele/agency-lang.
296
355
  thinkingBlocks.push({
297
356
  text: part.text || "",
298
- signature: part.thoughtSignature,
357
+ signature: part.thoughtSignature || "",
299
358
  });
300
359
  }
301
- else if (typeof part.text === "string" && !part.thought) {
360
+ else if (typeof part.text === "string") {
302
361
  textContent += part.text;
303
362
  }
304
363
  });
@@ -309,6 +368,7 @@ export class SmolGoogle extends BaseClient {
309
368
  const { usage, cost } = this.calculateUsageAndCost(result.usageMetadata);
310
369
  const parsed = parseGoogleHostedTools(result, "google", request.model);
311
370
  const { results: hostedToolResults, cost: finalCost } = applyHostedToolCost(parsed, cost, request.model, this.config.modelData);
371
+ const rawStopReason = result.candidates?.[0]?.finishReason ?? undefined;
312
372
  // Return the response, updating the chat history
313
373
  const promptResult = {
314
374
  output,
@@ -316,7 +376,11 @@ export class SmolGoogle extends BaseClient {
316
376
  usage,
317
377
  cost: finalCost,
318
378
  model: request.model,
379
+ stopReason: normalizeGoogleStopReason(rawStopReason, toolCalls.length > 0),
319
380
  };
381
+ if (rawStopReason) {
382
+ promptResult.rawStopReason = rawStopReason;
383
+ }
320
384
  if (thinkingBlocks.length > 0) {
321
385
  promptResult.thinkingBlocks = thinkingBlocks;
322
386
  }
@@ -353,6 +417,7 @@ export class SmolGoogle extends BaseClient {
353
417
  const thinkingBlocks = [];
354
418
  let usage;
355
419
  let cost;
420
+ let rawStopReason;
356
421
  for await (const chunk of stream) {
357
422
  // Extract usage metadata from chunks
358
423
  if (chunk.usageMetadata) {
@@ -362,6 +427,8 @@ export class SmolGoogle extends BaseClient {
362
427
  }
363
428
  // Iterate raw parts to capture thought signatures and regular content
364
429
  for (const candidate of chunk.candidates || []) {
430
+ if (candidate?.finishReason)
431
+ rawStopReason = candidate.finishReason;
365
432
  for (const part of candidate?.content?.parts || []) {
366
433
  const p = part;
367
434
  // Check functionCall first: Gemini 3 attaches the thought signature to
@@ -395,10 +462,16 @@ export class SmolGoogle extends BaseClient {
395
462
  }
396
463
  }
397
464
  }
398
- else if (p.thoughtSignature) {
465
+ else if (p.thought) {
466
+ // A thinking part is identified by `thought: true` — NOT merely by
467
+ // the presence of a thoughtSignature. Gemini 3 also rides a
468
+ // thoughtSignature on the final answer part (no `thought` flag) for
469
+ // stateless reasoning continuity; keying on the signature alone
470
+ // would misfile that answer text as a thinking block and drop it
471
+ // from the completion. See egonSchiele/agency-lang.
399
472
  const block = {
400
473
  text: p.text || "",
401
- signature: p.thoughtSignature,
474
+ signature: p.thoughtSignature || "",
402
475
  };
403
476
  thinkingBlocks.push(block);
404
477
  yield {
@@ -425,16 +498,18 @@ export class SmolGoogle extends BaseClient {
425
498
  toolCalls.push(toolCall);
426
499
  yield { type: "tool_call", toolCall };
427
500
  }
428
- yield {
429
- type: "done",
430
- result: {
431
- output: content || null,
432
- toolCalls,
433
- ...(thinkingBlocks.length > 0 && { thinkingBlocks }),
434
- usage,
435
- cost,
436
- model: request.model,
437
- },
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),
438
509
  };
510
+ if (rawStopReason) {
511
+ result.rawStopReason = rawStopReason;
512
+ }
513
+ yield { type: "done", result };
439
514
  }
440
515
  }