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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * JSON Schema sanitization for structured output.
3
+ *
4
+ * Zod converts `z.any()` / `z.unknown()` to an unconstrained schema — a bare
5
+ * `{}` (nested), `{"$schema":…}` (top-level), or the boolean `true`. That is
6
+ * valid JSON Schema ("accept any value"), but every provider rejects an
7
+ * unconstrained node inside a structured-output or strict-tool schema, since the
8
+ * whole point of structured output is that it is structured. These helpers map
9
+ * such nodes to `{"type":"string"}` (the safe universal container) and let
10
+ * callers detect the whole-schema-is-`any` case so they can drop structured
11
+ * output entirely and return free text.
12
+ */
13
+ /**
14
+ * Pure-annotation keywords. A node carrying *only* these constrains nothing, so
15
+ * it is treated as unconstrained. Everything else — `type`, `properties`,
16
+ * `enum`, `$ref`, any validation keyword — counts as constraining. This is an
17
+ * allowlist, not a denylist of structural keywords, so unknown/future keywords
18
+ * are constraining by default (the safe direction: never rewrite a schema that
19
+ * means something).
20
+ */
21
+ const ANNOTATION_KEYS = new Set([
22
+ "$schema",
23
+ "$id",
24
+ "$anchor",
25
+ "$comment",
26
+ "description",
27
+ "title",
28
+ "default",
29
+ "examples",
30
+ "readOnly",
31
+ "deprecated",
32
+ ]);
33
+ /**
34
+ * True if `node` accepts any value: the boolean `true`, or a plain object whose
35
+ * every own key is a pure annotation (`{}`, `{$schema:…}`,
36
+ * `{$schema:…, description:…}`). `false` and any object with a structural or
37
+ * validation keyword are constrained.
38
+ */
39
+ export function isUnconstrainedSchema(node) {
40
+ if (node === true)
41
+ return true;
42
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
43
+ return false;
44
+ }
45
+ return Object.keys(node).every((key) => ANNOTATION_KEYS.has(key));
46
+ }
47
+ /**
48
+ * Convert a Zod `responseFormat` schema to a sanitized JSON Schema for a
49
+ * provider's structured-output request: any nested unconstrained node becomes
50
+ * `{"type":"string"}`. (The whole-schema-is-`any` case is handled upstream in
51
+ * `BaseClient.normalizeResponseFormat`, which drops structured output entirely.)
52
+ */
53
+ export function responseFormatToJsonSchema(schema) {
54
+ // Zod's top-level toJSONSchema() is always an object, and the whole-schema-is-
55
+ // `any` case is stripped upstream, so sanitize always yields an object here.
56
+ return sanitizeJsonSchema(schema.toJSONSchema());
57
+ }
58
+ /**
59
+ * Subschema positions holding a single nested value schema (recursed into).
60
+ *
61
+ * `not` and `contains` are deliberately excluded: they are *assertions*, not
62
+ * value slots, so an unconstrained schema there is meaningful and must not be
63
+ * rewritten — `not: {}` means "reject everything" and would silently become
64
+ * "reject only strings" if mapped to `{type:"string"}`. Zod never emits either,
65
+ * so leaving them untouched is both correct and zero-impact in practice.
66
+ */
67
+ const SCHEMA_KEYS = ["items", "propertyNames"];
68
+ /** Subschema positions holding an object map of schemas. */
69
+ const SCHEMA_MAP_KEYS = ["properties", "patternProperties", "$defs", "definitions"];
70
+ /** Subschema positions holding an array of schemas. */
71
+ const SCHEMA_ARRAY_KEYS = ["anyOf", "oneOf", "allOf", "prefixItems"];
72
+ /**
73
+ * Returns a new JSON Schema with every unconstrained node replaced by
74
+ * `{"type":"string"}` (annotations preserved), recursing through all subschema
75
+ * positions. Idempotent — a node that already has `type` is left untouched.
76
+ *
77
+ * `additionalProperties` and `items` may be a boolean (`true`/`false`), which is
78
+ * a legitimate provider-accepted flag, not an any-typed value slot; booleans
79
+ * there are left as-is and only an object subschema is sanitized.
80
+ */
81
+ export function sanitizeJsonSchema(node) {
82
+ if (node === true)
83
+ return { type: "string" };
84
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
85
+ return node;
86
+ }
87
+ if (isUnconstrainedSchema(node)) {
88
+ return { ...node, type: "string" };
89
+ }
90
+ const src = node;
91
+ const out = { ...src };
92
+ for (const key of SCHEMA_KEYS) {
93
+ if (key in out) {
94
+ out[key] = sanitizeSubschema(out[key]);
95
+ }
96
+ }
97
+ for (const key of SCHEMA_MAP_KEYS) {
98
+ const map = out[key];
99
+ if (map && typeof map === "object" && !Array.isArray(map)) {
100
+ // Object.create(null): a property literally named "__proto__" (a legal Zod
101
+ // key) would otherwise reassign the prototype instead of setting an own key.
102
+ const sanitized = Object.create(null);
103
+ for (const [name, sub] of Object.entries(map)) {
104
+ sanitized[name] = sanitizeJsonSchema(sub);
105
+ }
106
+ out[key] = sanitized;
107
+ }
108
+ }
109
+ for (const key of SCHEMA_ARRAY_KEYS) {
110
+ const arr = out[key];
111
+ if (Array.isArray(arr)) {
112
+ out[key] = arr.map((sub) => sanitizeJsonSchema(sub));
113
+ }
114
+ }
115
+ // additionalProperties: leave booleans as-is, sanitize an object subschema.
116
+ if ("additionalProperties" in out) {
117
+ out.additionalProperties = sanitizeSubschema(out.additionalProperties);
118
+ }
119
+ return out;
120
+ }
121
+ /**
122
+ * Sanitize a value at a single-schema position (`items`, `propertyNames`,
123
+ * `additionalProperties`). These positions may legitimately hold a boolean:
124
+ * `additionalProperties: false`/`true` and `items: false` are provider-accepted
125
+ * flags, not any-typed value slots, so booleans pass through untouched and only
126
+ * an object subschema is recursively sanitized. (Zod does not emit a boolean at
127
+ * these positions, so `items: true` etc. are theoretical.)
128
+ */
129
+ function sanitizeSubschema(value) {
130
+ if (typeof value === "boolean")
131
+ return value;
132
+ return sanitizeJsonSchema(value);
133
+ }
@@ -0,0 +1,11 @@
1
+ import type { StopReason } from "../types/stopReason.js";
2
+ export declare function normalizeOpenAIStopReason(raw: string | null | undefined): StopReason;
3
+ export declare function normalizeAnthropicStopReason(raw: string | null | undefined): StopReason;
4
+ export declare function normalizeGoogleStopReason(raw: string | null | undefined, hasToolCalls: boolean): StopReason;
5
+ export declare function normalizeOllamaStopReason(raw: string | null | undefined): StopReason;
6
+ /**
7
+ * The Responses API has no single finish-reason field: a `completed` response
8
+ * is a normal stop (or tool use, if it carries tool calls), while an
9
+ * `incomplete` one carries the reason in `incomplete_details.reason`.
10
+ */
11
+ export declare function normalizeOpenAIResponsesStopReason(status: string | null | undefined, incompleteReason: string | null | undefined, hasToolCalls: boolean): StopReason;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Pure mappers from each provider's raw finish/stop-reason vocabulary to the
3
+ * unified {@link StopReason}. Clients set `PromptResult.stopReason` from these
4
+ * and keep the untouched provider value in `PromptResult.rawStopReason`.
5
+ */
6
+ const OPENAI_MAP = {
7
+ stop: "stop",
8
+ length: "length",
9
+ tool_calls: "tool_use",
10
+ function_call: "tool_use",
11
+ content_filter: "content_filter",
12
+ };
13
+ export function normalizeOpenAIStopReason(raw) {
14
+ return (raw && OPENAI_MAP[raw]) || "other";
15
+ }
16
+ const ANTHROPIC_MAP = {
17
+ end_turn: "stop",
18
+ max_tokens: "length",
19
+ // Ran out of context window — a length-class stop, same bucket as max_tokens.
20
+ model_context_window_exceeded: "length",
21
+ tool_use: "tool_use",
22
+ stop_sequence: "stop_sequence",
23
+ refusal: "content_filter",
24
+ pause_turn: "pause",
25
+ };
26
+ export function normalizeAnthropicStopReason(raw) {
27
+ return (raw && ANTHROPIC_MAP[raw]) || "other";
28
+ }
29
+ const GOOGLE_MAP = {
30
+ STOP: "stop",
31
+ MAX_TOKENS: "length",
32
+ SAFETY: "content_filter",
33
+ PROHIBITED_CONTENT: "content_filter",
34
+ RECITATION: "content_filter",
35
+ BLOCKLIST: "content_filter",
36
+ SPII: "content_filter",
37
+ IMAGE_SAFETY: "content_filter",
38
+ LANGUAGE: "content_filter",
39
+ };
40
+ export function normalizeGoogleStopReason(raw, hasToolCalls) {
41
+ // Gemini reports `STOP` even for tool-call turns, so infer `tool_use` when
42
+ // tool calls are present — otherwise the unified field couldn't detect tool
43
+ // use on Google the way it does on OpenAI/Anthropic.
44
+ if (raw === "STOP" && hasToolCalls) {
45
+ return "tool_use";
46
+ }
47
+ return (raw && GOOGLE_MAP[raw]) || "other";
48
+ }
49
+ const OLLAMA_MAP = {
50
+ stop: "stop",
51
+ length: "length",
52
+ };
53
+ export function normalizeOllamaStopReason(raw) {
54
+ return (raw && OLLAMA_MAP[raw]) || "other";
55
+ }
56
+ const RESPONSES_INCOMPLETE_MAP = {
57
+ max_output_tokens: "length",
58
+ content_filter: "content_filter",
59
+ };
60
+ /**
61
+ * The Responses API has no single finish-reason field: a `completed` response
62
+ * is a normal stop (or tool use, if it carries tool calls), while an
63
+ * `incomplete` one carries the reason in `incomplete_details.reason`.
64
+ */
65
+ export function normalizeOpenAIResponsesStopReason(status, incompleteReason, hasToolCalls) {
66
+ if (incompleteReason) {
67
+ return RESPONSES_INCOMPLETE_MAP[incompleteReason] || "other";
68
+ }
69
+ // Tool-call turns report a generic terminal status, so infer tool_use from the
70
+ // presence of tool calls. Also treat a missing status (e.g. a stream that ended
71
+ // without a completed/incomplete event) as "unknown but has tools" rather than
72
+ // letting a tool-call turn silently degrade to "other".
73
+ if (hasToolCalls && (status === "completed" || status == null)) {
74
+ return "tool_use";
75
+ }
76
+ if (status === "completed") {
77
+ return "stop";
78
+ }
79
+ return "other";
80
+ }
package/dist/util/tool.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { validateToolName } from "./util.js";
2
+ import { sanitizeJsonSchema } from "./jsonSchema.js";
2
3
  export function zodToOpenAITool(name, schema, options = {}) {
3
4
  validateToolName(name);
4
5
  // Convert Zod schema to JSON Schema
5
- const jsonSchema = schema.toJSONSchema();
6
+ const jsonSchema = sanitizeJsonSchema(schema.toJSONSchema());
6
7
  let description = "";
7
8
  if (options?.description) {
8
9
  description = options.description;
@@ -37,7 +38,7 @@ export function zodToOpenAITool(name, schema, options = {}) {
37
38
  }
38
39
  export function zodToOpenAIResponsesTool(name, schema, options = {}) {
39
40
  validateToolName(name);
40
- const jsonSchema = schema.toJSONSchema();
41
+ const jsonSchema = sanitizeJsonSchema(schema.toJSONSchema());
41
42
  const strict = options?.strict ?? false;
42
43
  const parameters = {
43
44
  type: "object",
@@ -67,7 +68,7 @@ export function zodToOpenAIResponsesTool(name, schema, options = {}) {
67
68
  }
68
69
  export function zodToAnthropicTool(name, schema, options = {}) {
69
70
  validateToolName(name);
70
- const jsonSchema = schema.toJSONSchema();
71
+ const jsonSchema = sanitizeJsonSchema(schema.toJSONSchema());
71
72
  let description;
72
73
  if (options?.description) {
73
74
  description = options.description;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoltalk",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "A common interface for LLM APIs",
5
5
  "homepage": "https://github.com/egonSchiele/smoltalk",
6
6
  "files": [