smoltalk 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -229,6 +229,31 @@ Used with `responseFormat` to control validation behavior (currently OpenAI only
229
229
  | `strict` | `boolean` | | Whether to use strict schema validation. |
230
230
  | `numRetries` | `number` | `2` | How many times to retry if the response fails schema validation. |
231
231
  | `allowExtraKeys` | `boolean` | | If `true`, strips unexpected keys instead of failing validation. |
232
+ | `separateFromTools` | `boolean` | `true` | Local providers only (`mlx`, `llama-cpp`). See below. |
233
+
234
+ #### Tools and a response format in one call
235
+
236
+ Some providers cannot hold a reply to a schema while the model may still call
237
+ a tool. Google rejects the combination. A local server (`mlx`, `llama-cpp`)
238
+ accepts it but leaves the schema unenforced, because a tool call is not JSON,
239
+ and a local model then tends to answer in prose.
240
+
241
+ For these providers smoltalk makes the call as two requests. The first
242
+ carries the tools and no schema. If the model calls a tool, that is the
243
+ result, and your tool loop carries on as usual. Once the model answers in
244
+ words, a second request asks for that answer in the schema, and that is the
245
+ `output` you get. Usage and cost cover both requests.
246
+
247
+ On the local providers the second request keeps the whole conversation, so a
248
+ field such as "did this cover everything the user asked?" can be answered.
249
+ `mlx` also keeps the tool list and sends `tool_choice: "none"`, so the server
250
+ can reuse its cached prompt. `responseFormatOptions.separateFromTools: false`
251
+ sends one request instead and leaves the schema unenforced. A streamed call
252
+ is never split.
253
+
254
+ With `strict: true`, a reply that fails validation is retried as usual. The
255
+ retry repeats only the second request, so the model cannot answer "fix this
256
+ JSON" with another tool call.
232
257
 
233
258
  ### `toolLoopDetection`
234
259
 
@@ -615,12 +640,12 @@ const r = await decide(
615
640
  );
616
641
  ```
617
642
 
618
- `provider: "typesafe"` is required for any model name the registry does not
619
- know. It says "this endpoint speaks the decision protocol".
643
+ For a Laya model name the registry does not know, set `provider: "typesafe"`
644
+ to select its decision protocol.
620
645
 
621
646
  OpenRouter and Vercel AI Gateway also serve Jev over this protocol, so no
622
- TypeSafe account is needed. OpenRouter's model name, `jev-1.13`, is in the
623
- registry:
647
+ TypeSafe account is needed. To use OpenRouter, set `provider: "openrouter"`
648
+ and provide `OPENROUTER_API_KEY` or `config.apiKey.openRouter`:
624
649
 
625
650
  ```typescript
626
651
  import { decide } from "smoltalk";
@@ -630,12 +655,17 @@ const r = await decide(
630
655
  { refund: { type: "noul", instructions: "Is the customer asking for money back?" } },
631
656
  {
632
657
  model: "jev-1.13",
633
- apiKey: { typesafe: process.env.OPENROUTER_API_KEY },
634
- baseUrl: { typesafe: "https://openrouter.ai/api" },
658
+ provider: "openrouter",
659
+ apiKey: { openRouter: process.env.OPENROUTER_API_KEY },
635
660
  },
636
661
  );
637
662
  ```
638
663
 
664
+ OpenRouter requests go to `https://openrouter.ai/api/v1/systemone`. Set
665
+ `baseUrl.openRouter` to override the API base URL, including its version
666
+ path; `decide()` appends `/systemone`. Without an explicit provider,
667
+ `jev-1.13` still defaults to TypeSafe.
668
+
639
669
  Vercel's base URL is `https://ai-gateway.vercel.sh/typesafe` and its model
640
670
  name is `typesafe-ai/jev`, which needs `provider: "typesafe"`.
641
671
 
@@ -1,4 +1,5 @@
1
- import { userMessage, assistantMessage } from "../classes/message/index.js";
1
+ import { assistantMessage } from "../classes/message/index.js";
2
+ import { failedValidationMessage, returnedUndefinedMessage, } from "./validationRetry.js";
2
3
  import { getLogger } from "../util/logger.js";
3
4
  import { SmolStructuredOutputError } from "../smolError.js";
4
5
  import { getStatelogClient } from "../statelogClient.js";
@@ -295,7 +296,7 @@ export class BaseClient {
295
296
  if (!("output" in result.value)) {
296
297
  const retryMessages = [
297
298
  ...promptConfig.messages,
298
- userMessage(`You returned "undefined" instead of a valid response. Please provide a valid response.`),
299
+ returnedUndefinedMessage(),
299
300
  ];
300
301
  return this.textWithRetry({ ...promptConfig, messages: retryMessages }, retries - 1);
301
302
  }
@@ -332,7 +333,7 @@ export class BaseClient {
332
333
  const retryMessages = [
333
334
  ...promptConfig.messages,
334
335
  assistantMessage(output),
335
- userMessage(`Your previous response failed validation. Please fix the following errors and try again:\n${errorMessage}`),
336
+ failedValidationMessage(errorMessage),
336
337
  ];
337
338
  return this.textWithRetry({ ...promptConfig, messages: retryMessages }, retries - 1);
338
339
  }
@@ -1,10 +1,20 @@
1
1
  import { SmolOpenAiCompat } from "./openaiCompat.js";
2
- import type { SmolConfig } from "../types.js";
2
+ import type { PromptResult, SmolConfig } from "../types.js";
3
+ import type { Result } from "../types/result.js";
3
4
  /**
4
5
  * Client for an MLX server on localhost, usually started with `mlx_lm.server`
5
6
  * (or `agency local serve`). The server speaks the OpenAI chat format, so this
6
7
  * is the openai-compat client with three things fixed: the base URL has a
7
8
  * default, no API key is needed, and the cost is always zero.
9
+ *
10
+ * A call with both tools and a `responseFormat` is made as two requests (see
11
+ * structuredAfterTools.ts). The second keeps the tool list and sends
12
+ * `tool_choice: "none"`. Keeping the list matters: the chat template renders
13
+ * the tools at the start of the prompt, so dropping them would make the
14
+ * server read the whole prompt again instead of reusing its cache. A server
15
+ * that enforces a schema when `tool_choice` is `"none"` (`agency local
16
+ * serve`) then holds the reply to the format. Streaming calls are not split:
17
+ * as on Google, a streamed call with tools leaves the format unenforced.
8
18
  */
9
19
  export declare class SmolMlx extends SmolOpenAiCompat {
10
20
  protected resolveClientOptions(config: SmolConfig): {
@@ -12,4 +22,5 @@ export declare class SmolMlx extends SmolOpenAiCompat {
12
22
  baseURL: string;
13
23
  };
14
24
  protected resolveCostUsd(): number;
25
+ _textSync(config: SmolConfig): Promise<Result<PromptResult>>;
15
26
  }
@@ -1,10 +1,20 @@
1
1
  import { SmolOpenAiCompat } from "./openaiCompat.js";
2
2
  import { resolveBaseUrl } from "../util/provider.js";
3
+ import { separatesStructuredOutput, toolsThenStructuredOutput, } from "./structuredAfterTools.js";
3
4
  /**
4
5
  * Client for an MLX server on localhost, usually started with `mlx_lm.server`
5
6
  * (or `agency local serve`). The server speaks the OpenAI chat format, so this
6
7
  * is the openai-compat client with three things fixed: the base URL has a
7
8
  * default, no API key is needed, and the cost is always zero.
9
+ *
10
+ * A call with both tools and a `responseFormat` is made as two requests (see
11
+ * structuredAfterTools.ts). The second keeps the tool list and sends
12
+ * `tool_choice: "none"`. Keeping the list matters: the chat template renders
13
+ * the tools at the start of the prompt, so dropping them would make the
14
+ * server read the whole prompt again instead of reusing its cache. A server
15
+ * that enforces a schema when `tool_choice` is `"none"` (`agency local
16
+ * serve`) then holds the reply to the format. Streaming calls are not split:
17
+ * as on Google, a streamed call with tools leaves the format unenforced.
8
18
  */
9
19
  export class SmolMlx extends SmolOpenAiCompat {
10
20
  resolveClientOptions(config) {
@@ -16,4 +26,17 @@ export class SmolMlx extends SmolOpenAiCompat {
16
26
  resolveCostUsd() {
17
27
  return 0;
18
28
  }
29
+ async _textSync(config) {
30
+ if (!separatesStructuredOutput(config)) {
31
+ return super._textSync(config);
32
+ }
33
+ return toolsThenStructuredOutput({
34
+ config,
35
+ run: (one) => super._textSync(one),
36
+ formatConfig: (followUp) => ({
37
+ ...followUp,
38
+ rawAttributes: { ...followUp.rawAttributes, tool_choice: "none" },
39
+ }),
40
+ });
41
+ }
19
42
  }
@@ -0,0 +1,36 @@
1
+ import type { PromptResult, SmolConfig } from "../types.js";
2
+ import { type Result } from "../types/result.js";
3
+ /**
4
+ * A local model cannot be held to a JSON schema while it may still call a
5
+ * tool: a tool call is not JSON, so the servers leave the schema unenforced
6
+ * on any request that carries tools, and the model is free to answer in
7
+ * prose. A call with both tools and a `responseFormat` is therefore made as
8
+ * two requests, the way the Google client does it. The first carries the
9
+ * tools and no schema. If the model calls a tool, that is the result. If it
10
+ * answers, a second request asks for that answer in the schema, with tool
11
+ * calls ruled out so the schema can be enforced.
12
+ *
13
+ * Unlike the Google client, the second request keeps the whole conversation.
14
+ * A schema field such as "did this answer everything the user asked?" cannot
15
+ * be filled in from the answer alone.
16
+ */
17
+ /** What the second request says, after the model's own answer. */
18
+ export declare const STRUCTURED_FOLLOW_UP = "Give that same reply again in the required JSON format. Do not call a tool.";
19
+ /** Whether a call's typed reply is made as a second request. */
20
+ export declare function separatesStructuredOutput(config: SmolConfig): boolean;
21
+ /**
22
+ * Makes the two requests. `run` sends one request. `formatConfig` turns the
23
+ * follow-up config into one whose schema the provider will enforce: the MLX
24
+ * client sets `tool_choice: "none"`, the llama.cpp client drops the tools.
25
+ *
26
+ * When the reply fails the caller's validation, the caller sends the
27
+ * conversation back with a request to fix it. The model has already answered
28
+ * by then, so only the format request is made. A tool round there would let
29
+ * the model answer "fix this JSON" with a tool call, and a tool that has
30
+ * already run would run again.
31
+ */
32
+ export declare function toolsThenStructuredOutput(args: {
33
+ config: SmolConfig;
34
+ run: (config: SmolConfig) => Promise<Result<PromptResult>>;
35
+ formatConfig: (config: SmolConfig) => SmolConfig;
36
+ }): Promise<Result<PromptResult>>;
@@ -0,0 +1,87 @@
1
+ import { assistantMessage, userMessage } from "../classes/message/index.js";
2
+ import { addCosts } from "../types/costEstimate.js";
3
+ import { addTokenUsage } from "../types/tokenUsage.js";
4
+ import { success } from "../types/result.js";
5
+ import { endsInValidationRetry } from "./validationRetry.js";
6
+ /**
7
+ * A local model cannot be held to a JSON schema while it may still call a
8
+ * tool: a tool call is not JSON, so the servers leave the schema unenforced
9
+ * on any request that carries tools, and the model is free to answer in
10
+ * prose. A call with both tools and a `responseFormat` is therefore made as
11
+ * two requests, the way the Google client does it. The first carries the
12
+ * tools and no schema. If the model calls a tool, that is the result. If it
13
+ * answers, a second request asks for that answer in the schema, with tool
14
+ * calls ruled out so the schema can be enforced.
15
+ *
16
+ * Unlike the Google client, the second request keeps the whole conversation.
17
+ * A schema field such as "did this answer everything the user asked?" cannot
18
+ * be filled in from the answer alone.
19
+ */
20
+ /** What the second request says, after the model's own answer. */
21
+ export const STRUCTURED_FOLLOW_UP = "Give that same reply again in the required JSON format. Do not call a tool.";
22
+ /** Whether a call's typed reply is made as a second request. */
23
+ export function separatesStructuredOutput(config) {
24
+ const hasTools = (config.tools?.length ?? 0) > 0;
25
+ if (!hasTools || !config.responseFormat) {
26
+ return false;
27
+ }
28
+ return config.responseFormatOptions?.separateFromTools !== false;
29
+ }
30
+ /**
31
+ * The format request's result as the caller sees it. The request rules tool
32
+ * calls out, but a server too old to honour that could still return one. It
33
+ * must not be run: the tool round is over. Such a reply has no text either,
34
+ * so `fallback` stands in for it. The caller's validation then fails on the
35
+ * fallback and asks again, instead of finding nothing to validate.
36
+ */
37
+ function formatReply(result, fallback) {
38
+ return { ...result, toolCalls: [], output: result.output || fallback };
39
+ }
40
+ /**
41
+ * Makes the two requests. `run` sends one request. `formatConfig` turns the
42
+ * follow-up config into one whose schema the provider will enforce: the MLX
43
+ * client sets `tool_choice: "none"`, the llama.cpp client drops the tools.
44
+ *
45
+ * When the reply fails the caller's validation, the caller sends the
46
+ * conversation back with a request to fix it. The model has already answered
47
+ * by then, so only the format request is made. A tool round there would let
48
+ * the model answer "fix this JSON" with a tool call, and a tool that has
49
+ * already run would run again.
50
+ */
51
+ export async function toolsThenStructuredOutput(args) {
52
+ const { config, run, formatConfig } = args;
53
+ if (endsInValidationRetry(config.messages)) {
54
+ const only = await run(formatConfig(config));
55
+ return only.success ? success(formatReply(only.value, "")) : only;
56
+ }
57
+ const first = await run({ ...config, responseFormat: undefined });
58
+ if (!first.success) {
59
+ return first;
60
+ }
61
+ // A tool call is the whole reply. An empty reply has nothing to put in
62
+ // the format, so it goes back as it is and fails validation as before.
63
+ if (first.value.toolCalls.length > 0 || !first.value.output) {
64
+ return first;
65
+ }
66
+ const answer = first.value.output;
67
+ const second = await run(formatConfig({
68
+ ...config,
69
+ messages: [...config.messages, assistantMessage(answer), userMessage(STRUCTURED_FOLLOW_UP)],
70
+ }));
71
+ if (!second.success) {
72
+ return second;
73
+ }
74
+ const thinkingBlocks = [
75
+ ...(first.value.thinkingBlocks ?? []),
76
+ ...(second.value.thinkingBlocks ?? []),
77
+ ];
78
+ const merged = {
79
+ ...formatReply(second.value, answer),
80
+ usage: addTokenUsage(first.value.usage, second.value.usage),
81
+ cost: addCosts(first.value.cost, second.value.cost),
82
+ };
83
+ if (thinkingBlocks.length > 0) {
84
+ merged.thinkingBlocks = thinkingBlocks;
85
+ }
86
+ return success(merged);
87
+ }
@@ -0,0 +1,5 @@
1
+ import type { Message } from "../classes/message/index.js";
2
+ export declare function failedValidationMessage(errorMessage: string): import("../classes/message/UserMessage.js").UserMessage;
3
+ export declare function returnedUndefinedMessage(): import("../classes/message/UserMessage.js").UserMessage;
4
+ /** Whether the last message asks the model to correct its previous reply. */
5
+ export declare function endsInValidationRetry(messages: Message[]): boolean;
@@ -0,0 +1,24 @@
1
+ import { userMessage } from "../classes/message/index.js";
2
+ /**
3
+ * The messages `textWithRetry` adds when a reply does not fit the response
4
+ * format, and the test for them. A client that makes a tool round before the
5
+ * format request (see structuredAfterTools.ts) uses the test to skip the tool
6
+ * round on a retry.
7
+ */
8
+ const FAILED_VALIDATION = "Your previous response failed validation.";
9
+ const RETURNED_UNDEFINED = `You returned "undefined" instead of a valid response.`;
10
+ export function failedValidationMessage(errorMessage) {
11
+ return userMessage(`${FAILED_VALIDATION} Please fix the following errors and try again:\n${errorMessage}`);
12
+ }
13
+ export function returnedUndefinedMessage() {
14
+ return userMessage(`${RETURNED_UNDEFINED} Please provide a valid response.`);
15
+ }
16
+ /** Whether the last message asks the model to correct its previous reply. */
17
+ export function endsInValidationRetry(messages) {
18
+ const last = messages[messages.length - 1];
19
+ if (!last || last.role !== "user") {
20
+ return false;
21
+ }
22
+ const text = last.content;
23
+ return text.startsWith(FAILED_VALIDATION) || text.startsWith(RETURNED_UNDEFINED);
24
+ }
package/dist/decide.d.ts CHANGED
@@ -53,15 +53,18 @@ export type DecideConfig = {
53
53
  model: string;
54
54
  /** Required when the model is not in the registry. */
55
55
  provider?: string;
56
- /** API keys, nested by provider. Falls back to TYPESAFE_API_KEY. */
56
+ /** API keys, nested by provider. Falls back to TYPESAFE_API_KEY or OPENROUTER_API_KEY. */
57
57
  apiKey?: {
58
58
  typesafe?: string;
59
+ openRouter?: string;
59
60
  [provider: string]: string | undefined;
60
61
  };
61
- /** Custom base URLs, nested by provider. Falls back to TYPESAFE_BASE_URL,
62
- * then TypeSafe's host. Point `typesafe` at a Laya server to use Laya. */
62
+ /** Custom base URLs, nested by provider. TypeSafe falls back to TYPESAFE_BASE_URL,
63
+ * then its host. OpenRouter defaults to https://openrouter.ai/api/v1.
64
+ * Point `typesafe` at a Laya server to use Laya. */
63
65
  baseUrl?: {
64
66
  typesafe?: string;
67
+ openRouter?: string;
65
68
  [provider: string]: string | undefined;
66
69
  };
67
70
  /** Refreshed model data to layer over the baked-in registry. */
package/dist/decide.js CHANGED
@@ -9,8 +9,8 @@
9
9
  *
10
10
  * `decide()` follows the shape of `embed()`: payload first, config last,
11
11
  * provider and key and base URL resolved through the shared helpers. There
12
- * is one provider, `typesafe`, which is the wire protocol. A Laya server
13
- * speaks the same protocol, so it is reached by setting `baseUrl.typesafe`.
12
+ * are two providers: `typesafe` and `openrouter`. A Laya server speaks the
13
+ * TypeSafe protocol, so it is reached by setting `baseUrl.typesafe`.
14
14
  */
15
15
  import { z } from "zod";
16
16
  import { success, failure } from "./types/result.js";
@@ -151,8 +151,8 @@ export async function decide(state, questions, config) {
151
151
  catch (err) {
152
152
  return failure(errorMessage(err));
153
153
  }
154
- if (provider !== DECISION_PROVIDER) {
155
- return failure(`Provider "${provider}" does not answer decisions. Only "${DECISION_PROVIDER}" does; set config.provider to it for a model the registry does not know.`);
154
+ if (provider !== DECISION_PROVIDER && provider !== "openrouter") {
155
+ return failure(`Provider "${provider}" does not answer decisions. Use "${DECISION_PROVIDER}" or "openrouter"; set config.provider for a model the registry does not know.`);
156
156
  }
157
157
  // The registry entry for the requested name, when there is one. It sets
158
158
  // the question cap and the price. A Laya model has no entry and no price.
@@ -164,15 +164,18 @@ export async function decide(state, questions, config) {
164
164
  }
165
165
  const apiKey = resolveApiKey(provider, config);
166
166
  if (!apiKey) {
167
- return failure("No TypeSafe API key provided. Set config.apiKey.typesafe or the TYPESAFE_API_KEY environment variable.");
167
+ return failure(provider === "openrouter"
168
+ ? "No OpenRouter API key provided. Set config.apiKey.openRouter or the OPENROUTER_API_KEY environment variable."
169
+ : "No TypeSafe API key provided. Set config.apiKey.typesafe or the TYPESAFE_API_KEY environment variable.");
168
170
  }
169
171
  const baseUrl = resolveBaseUrl(provider, config).replace(/\/+$/, "");
172
+ const endpoint = provider === "openrouter" ? `${baseUrl}/systemone` : `${baseUrl}/v1/systemone`;
170
173
  if (config.abortSignal?.aborted) {
171
174
  return failure("Request was aborted");
172
175
  }
173
176
  let response;
174
177
  try {
175
- response = await fetch(`${baseUrl}/v1/systemone`, {
178
+ response = await fetch(endpoint, {
176
179
  method: "POST",
177
180
  headers: {
178
181
  Authorization: `Bearer ${apiKey}`,
package/dist/index.d.ts CHANGED
@@ -23,6 +23,7 @@ export type { BlobRef } from "./util/blobRef.js";
23
23
  export { getLogger, EgonLog } from "./util/logger.js";
24
24
  export type { LogLevel } from "./util/logger.js";
25
25
  export { redactAttachments } from "./util/redact.js";
26
+ export { separatesStructuredOutput, toolsThenStructuredOutput, STRUCTURED_FOLLOW_UP, } from "./clients/structuredAfterTools.js";
26
27
  export { transcribe, registerTranscriptionProvider, DEFAULT_TRANSCRIBE_BYTES, } from "./transcription.js";
27
28
  export type { TranscribeOptions, TranscriptionSegment, TranscriptionWord, TranscriptionResult, TranscriptionClientClass, } from "./transcription.js";
28
29
  export { BaseTranscriptionClient } from "./transcription/baseTranscriptionClient.js";
package/dist/index.js CHANGED
@@ -21,6 +21,8 @@ export { uploadFile, deleteFile, registerFileProvider, DEFAULT_UPLOAD_BYTES } fr
21
21
  export { normalizeBlob, loadBlob } from "./util/blobRef.js";
22
22
  export { getLogger, EgonLog } from "./util/logger.js";
23
23
  export { redactAttachments } from "./util/redact.js";
24
+ // For provider plugins whose backend cannot enforce a schema alongside tools.
25
+ export { separatesStructuredOutput, toolsThenStructuredOutput, STRUCTURED_FOLLOW_UP, } from "./clients/structuredAfterTools.js";
24
26
  // Explicit (not `export *`) so internal factories and test helpers stay private.
25
27
  export { transcribe, registerTranscriptionProvider, DEFAULT_TRANSCRIBE_BYTES, } from "./transcription.js";
26
28
  export { BaseTranscriptionClient } from "./transcription/baseTranscriptionClient.js";