smoltalk 0.8.2 → 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.js +25 -12
- package/dist/clients/baseClient.d.ts +10 -0
- package/dist/clients/baseClient.js +20 -0
- package/dist/clients/google.js +31 -13
- package/dist/clients/ollama.js +29 -12
- package/dist/clients/openai.js +29 -13
- package/dist/clients/openaiResponses.js +27 -10
- package/dist/types/stopReason.d.ts +19 -0
- package/dist/types/stopReason.js +1 -0
- package/dist/types.d.ts +7 -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.
|
|
@@ -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
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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 {
|
package/dist/clients/google.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
}
|
package/dist/clients/ollama.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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);
|
package/dist/clients/openai.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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;
|