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 +46 -1
- package/dist/clients/anthropic.d.ts +10 -0
- package/dist/clients/anthropic.js +145 -12
- package/dist/clients/baseClient.d.ts +10 -0
- package/dist/clients/baseClient.js +26 -1
- package/dist/clients/google.d.ts +15 -0
- package/dist/clients/google.js +98 -23
- package/dist/clients/ollama.js +29 -12
- package/dist/clients/openai.js +29 -13
- package/dist/clients/openaiResponses.js +27 -10
- package/dist/models.d.ts +110 -2
- package/dist/models.js +114 -2
- package/dist/types/stopReason.d.ts +19 -0
- package/dist/types/stopReason.js +1 -0
- package/dist/types.d.ts +15 -1
- package/dist/types.js +4 -1
- package/dist/util/jsonSchema.d.ts +38 -0
- package/dist/util/jsonSchema.js +133 -0
- package/dist/util/stopReason.d.ts +11 -0
- package/dist/util/stopReason.js +80 -0
- package/dist/util/tool.js +4 -3
- package/package.json +1 -1
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/clients/google.d.ts
CHANGED
|
@@ -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[];
|
package/dist/clients/google.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
138
|
-
//
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
295
|
-
//
|
|
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"
|
|
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.
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
}
|