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
|
@@ -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;
|