smoltalk 0.15.3 → 0.17.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.
Files changed (54) hide show
  1. package/README.md +62 -0
  2. package/dist/clients/anthropic.js +3 -2
  3. package/dist/clients/baseClient.js +4 -3
  4. package/dist/clients/google.js +2 -1
  5. package/dist/clients/llamaCppLoader.js +2 -2
  6. package/dist/clients/mlx.d.ts +12 -1
  7. package/dist/clients/mlx.js +23 -0
  8. package/dist/clients/ollama.d.ts +1 -1
  9. package/dist/clients/ollama.js +1 -1
  10. package/dist/clients/openai.d.ts +2 -1
  11. package/dist/clients/openai.js +5 -3
  12. package/dist/clients/openaiResponses.js +3 -2
  13. package/dist/clients/resolveAttachments.js +2 -1
  14. package/dist/clients/structuredAfterTools.d.ts +36 -0
  15. package/dist/clients/structuredAfterTools.js +87 -0
  16. package/dist/clients/validationRetry.d.ts +5 -0
  17. package/dist/clients/validationRetry.js +24 -0
  18. package/dist/embed/openai.js +3 -2
  19. package/dist/files/anthropic.js +2 -2
  20. package/dist/files/openai.js +2 -2
  21. package/dist/image/google.js +3 -2
  22. package/dist/image/openai.js +3 -2
  23. package/dist/image/openaiCompat.js +3 -2
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +2 -0
  26. package/dist/modelRefresh.js +10 -11
  27. package/dist/models.d.ts +298 -56
  28. package/dist/models.js +331 -66
  29. package/dist/platform.browser.d.ts +5 -0
  30. package/dist/platform.browser.js +13 -0
  31. package/dist/platform.d.ts +33 -0
  32. package/dist/platform.js +13 -0
  33. package/dist/platform.node.d.ts +5 -0
  34. package/dist/platform.node.js +17 -0
  35. package/dist/speech/google.js +2 -1
  36. package/dist/speech/groq.js +1 -0
  37. package/dist/speech/mlx.js +1 -0
  38. package/dist/speech/openai.js +1 -1
  39. package/dist/speech/openaiCompat.js +1 -1
  40. package/dist/statelogClient.js +2 -1
  41. package/dist/transcription/google.js +3 -2
  42. package/dist/transcription/groq.js +1 -0
  43. package/dist/transcription/openai.js +1 -1
  44. package/dist/transcription/openaiCompat.js +1 -1
  45. package/dist/types.d.ts +6 -0
  46. package/dist/util/attachments.js +2 -1
  47. package/dist/util/base64.d.ts +17 -0
  48. package/dist/util/base64.js +59 -0
  49. package/dist/util/blobRef.js +18 -12
  50. package/dist/util/fileExtension.d.ts +7 -0
  51. package/dist/util/fileExtension.js +15 -0
  52. package/dist/util/provider.d.ts +1 -0
  53. package/dist/util/provider.js +16 -14
  54. package/package.json +14 -2
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
 
@@ -813,6 +838,43 @@ unsupported provider, a model without audio input, or audio that isn't
813
838
  `mp3`/`wav` surfaces as a `Failure` from that call, not from `audioPart()`
814
839
  itself. Audio is inlined as base64, not uploaded via the Files API.
815
840
 
841
+ ## In the browser
842
+
843
+ smoltalk bundles for the browser. A bundler run for the browser (Vite,
844
+ webpack with `target: "web"`, esbuild with `--platform=browser`) picks the
845
+ browser side of smoltalk's `#platform` and `#ollama` imports on its own;
846
+ there is no separate entry point and no flag. With esbuild, mark Node
847
+ builtins external, because `@anthropic-ai/sdk` lazily imports `node:fs` on
848
+ the path it takes when no API key is passed (Vite does this for you):
849
+
850
+ ```sh
851
+ esbuild app.ts --bundle --platform=browser --format=esm --external:node:*
852
+ ```
853
+
854
+ Differences from Node:
855
+
856
+ - There are no environment variables. `env` reads as unset, nothing
857
+ throws, and a client built without a key fails with the SDK's own
858
+ missing-key error. Pass keys in `apiKey`.
859
+ - A `{ kind: "path" }` attachment throws: "readFileBytes is not available
860
+ in a browser build of smoltalk."
861
+ - `refreshModels` accepts `https:` URLs only; a `file:` URL fails the
862
+ same way, from `readTextFromFileUrl`.
863
+ - `loadLlamaCpp` fails: it loads a native module. Without `entryPath` the
864
+ error is the loader's "Failed to load smoltalk-llama-cpp", wrapping the
865
+ browser's refusal to resolve the bare specifier; with `entryPath` it is
866
+ `pathToFileUrl`'s. Local inference in a browser is the `smoltalk-webllm`
867
+ plugin.
868
+
869
+ The `openai` and `@anthropic-ai/sdk` clients are constructed with
870
+ `dangerouslyAllowBrowser: true`. That flag exists because a key sent to a
871
+ browser is visible to anyone who can open the page. For a production app,
872
+ keep the key on a server. The built-in OpenAI, Anthropic, and Google
873
+ clients take no base URL, so the browser side reaches such a server as an
874
+ OpenAI-compatible endpoint: `provider: "openai-compat"` with
875
+ `baseUrl.openAiCompat` set to the proxy, and the proxy holding the real
876
+ key.
877
+
816
878
  ## Limitations
817
879
  Smoltalk has support for a limited number of providers right now, and is mostly focused on the stateless APIs for text completion, though I plan to add support for more providers as well as image and speech models later. Smoltalk is also a personal project, and there are alternatives backed by companies:
818
880
 
@@ -1,3 +1,4 @@
1
+ import { env } from "#platform";
1
2
  import Anthropic from "@anthropic-ai/sdk";
2
3
  import { ToolCall } from "../classes/ToolCall.js";
3
4
  import { SystemMessage, DeveloperMessage } from "../classes/message/index.js";
@@ -219,11 +220,11 @@ export class SmolAnthropic extends BaseClient {
219
220
  model;
220
221
  constructor(config) {
221
222
  super(config);
222
- const apiKey = config.apiKey?.anthropic || process.env.ANTHROPIC_API_KEY;
223
+ const apiKey = config.apiKey?.anthropic || env("ANTHROPIC_API_KEY");
223
224
  if (!apiKey) {
224
225
  throw new Error("Anthropic API key is required for SmolAnthropic client.");
225
226
  }
226
- this.client = new Anthropic({ apiKey });
227
+ this.client = new Anthropic({ dangerouslyAllowBrowser: true, apiKey });
227
228
  this.logger = getLogger();
228
229
  this.model = new Model(config.model, config.provider, config.modelData);
229
230
  }
@@ -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,3 +1,4 @@
1
+ import { env } from "#platform";
1
2
  import { GoogleGenAI } from "@google/genai";
2
3
  import { ToolCall } from "../classes/ToolCall.js";
3
4
  import { getLogger } from "../util/logger.js";
@@ -203,7 +204,7 @@ export class SmolGoogle extends BaseClient {
203
204
  model;
204
205
  constructor(config) {
205
206
  super(config);
206
- const apiKey = config.apiKey?.google || process.env.GEMINI_API_KEY;
207
+ const apiKey = config.apiKey?.google || env("GEMINI_API_KEY");
207
208
  if (!apiKey) {
208
209
  throw new Error("Google API key is required for SmolGoogle client.");
209
210
  }
@@ -1,4 +1,4 @@
1
- import { pathToFileURL } from "url";
1
+ import { pathToFileUrl } from "#platform";
2
2
  import { hasProvider, registerProvider } from "../client.js";
3
3
  import { SmolError } from "../smolError.js";
4
4
  const realImport = (specifier) => import(specifier);
@@ -63,7 +63,7 @@ export function loadLlamaCpp(options) {
63
63
  async function doLoad(entryPath) {
64
64
  let importSource;
65
65
  if (entryPath) {
66
- importSource = pathToFileURL(entryPath).href;
66
+ importSource = pathToFileUrl(entryPath);
67
67
  }
68
68
  else {
69
69
  importSource = "smoltalk-llama-cpp";
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import { Ollama } from "ollama";
1
+ import { Ollama } from "#ollama";
2
2
  import { PromptResult, Result, SmolClient, SmolConfig, StreamChunk } from "../types.js";
3
3
  import { BaseClient } from "./baseClient.js";
4
4
  import { ModelName } from "../models.js";
@@ -1,4 +1,4 @@
1
- import { Ollama } from "ollama";
1
+ import { Ollama } from "#ollama";
2
2
  import { ToolCall } from "../classes/ToolCall.js";
3
3
  import { getLogger } from "../util/logger.js";
4
4
  import { redactAttachments } from "../util/redact.js";
@@ -14,7 +14,8 @@ export declare class SmolOpenAi extends BaseClient implements SmolClient {
14
14
  protected attachmentCapabilities(): ClientAttachmentCapabilities;
15
15
  /**
16
16
  * Build the `new OpenAI({...})` options. Subclasses override to inject a
17
- * different baseURL or to pull the key from a different config field.
17
+ * different baseURL or to pull the key from a different config field. The
18
+ * constructor adds `dangerouslyAllowBrowser` itself.
18
19
  *
19
20
  * Default behavior: read OPENAI_API_KEY (or config.apiKey.openAi). Throws
20
21
  * if the key is missing — subclasses with required keys do their own check.
@@ -1,3 +1,4 @@
1
+ import { env } from "#platform";
1
2
  import OpenAI from "openai";
2
3
  import { success, } from "../types.js";
3
4
  import { ToolCall } from "../classes/ToolCall.js";
@@ -19,7 +20,7 @@ export class SmolOpenAi extends BaseClient {
19
20
  constructor(config) {
20
21
  super(config);
21
22
  const options = this.resolveClientOptions(config);
22
- this.client = new OpenAI(options);
23
+ this.client = new OpenAI({ ...options, dangerouslyAllowBrowser: true });
23
24
  this.logger = getLogger();
24
25
  this.model = new Model(config.model, config.provider, config.modelData);
25
26
  }
@@ -29,13 +30,14 @@ export class SmolOpenAi extends BaseClient {
29
30
  }
30
31
  /**
31
32
  * Build the `new OpenAI({...})` options. Subclasses override to inject a
32
- * different baseURL or to pull the key from a different config field.
33
+ * different baseURL or to pull the key from a different config field. The
34
+ * constructor adds `dangerouslyAllowBrowser` itself.
33
35
  *
34
36
  * Default behavior: read OPENAI_API_KEY (or config.apiKey.openAi). Throws
35
37
  * if the key is missing — subclasses with required keys do their own check.
36
38
  */
37
39
  resolveClientOptions(config) {
38
- const apiKey = config.apiKey?.openAi || process.env.OPENAI_API_KEY;
40
+ const apiKey = config.apiKey?.openAi || env("OPENAI_API_KEY");
39
41
  if (!apiKey) {
40
42
  throw new Error("OpenAI API key is required for SmolOpenAi client.");
41
43
  }
@@ -1,3 +1,4 @@
1
+ import { env } from "#platform";
1
2
  import OpenAI from "openai";
2
3
  import { success, } from "../types.js";
3
4
  import { ToolCall } from "../classes/ToolCall.js";
@@ -57,11 +58,11 @@ export class SmolOpenAiResponses extends BaseClient {
57
58
  model;
58
59
  constructor(config) {
59
60
  super(config);
60
- const apiKey = config.apiKey?.openAi || process.env.OPENAI_API_KEY;
61
+ const apiKey = config.apiKey?.openAi || env("OPENAI_API_KEY");
61
62
  if (!apiKey) {
62
63
  throw new Error("OpenAI API key is required for SmolOpenAiResponses client.");
63
64
  }
64
- this.client = new OpenAI({ apiKey });
65
+ this.client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey });
65
66
  this.logger = getLogger();
66
67
  this.model = new Model(config.model, config.provider, config.modelData);
67
68
  }
@@ -1,3 +1,4 @@
1
+ import { bytesToBase64 } from "../util/base64.js";
1
2
  import { UserMessage } from "../classes/message/index.js";
2
3
  import { normalizeBlob } from "../util/blobRef.js";
3
4
  import { fileFamily } from "../util/attachments.js";
@@ -45,7 +46,7 @@ async function toBase64Source(source, allowed, maxBytes) {
45
46
  allowedMimePrefixes: allowed,
46
47
  maxBytes,
47
48
  });
48
- return { kind: "base64", base64: Buffer.from(data).toString("base64"), mimeType };
49
+ return { kind: "base64", base64: bytesToBase64(data), mimeType };
49
50
  }
50
51
  /** Error message when a providerFile ref targets the wrong provider family, else null. */
51
52
  function providerFileError(fileProvider, targetProvider) {
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { base64ToBytes } from "../util/base64.js";
1
2
  import OpenAI from "openai";
2
3
  import { success, failure } from "../types/result.js";
3
4
  import { getModel, isEmbeddingsModel } from "../models.js";
@@ -10,7 +11,7 @@ import { round } from "../util/util.js";
10
11
  */
11
12
  function toFloats(embedding) {
12
13
  if (typeof embedding === "string") {
13
- const bytes = Buffer.from(embedding, "base64");
14
+ const bytes = base64ToBytes(embedding);
14
15
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
15
16
  return Array.from(view);
16
17
  }
@@ -24,7 +25,7 @@ function toFloats(embedding) {
24
25
  */
25
26
  export async function openaiEmbed(inputs, config, apiKey, baseURL, options) {
26
27
  try {
27
- const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
28
+ const client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey, ...(baseURL ? { baseURL } : {}) });
28
29
  const body = {
29
30
  model: config.model,
30
31
  input: inputs,
@@ -7,13 +7,13 @@ export function anthropicFileRef(uploaded, mimeType) {
7
7
  class AnthropicFileProvider extends BaseFileProvider {
8
8
  label = "Anthropic";
9
9
  async doUpload(data, mimeType, ctx) {
10
- const client = new Anthropic({ apiKey: ctx.apiKey });
10
+ const client = new Anthropic({ dangerouslyAllowBrowser: true, apiKey: ctx.apiKey });
11
11
  const file = await toFile(data, ctx.filename ?? "upload", { type: mimeType });
12
12
  const uploaded = await client.beta.files.upload({ file, betas: [FILES_BETA] });
13
13
  return anthropicFileRef(uploaded, mimeType);
14
14
  }
15
15
  async doDelete(ref, ctx) {
16
- const client = new Anthropic({ apiKey: ctx.apiKey });
16
+ const client = new Anthropic({ dangerouslyAllowBrowser: true, apiKey: ctx.apiKey });
17
17
  await client.beta.files.delete(ref.id, { betas: [FILES_BETA] });
18
18
  }
19
19
  }
@@ -8,7 +8,7 @@ export function openaiFileRef(created, mimeType) {
8
8
  class OpenAIFileProvider extends BaseFileProvider {
9
9
  label = "OpenAI";
10
10
  async doUpload(data, mimeType, ctx) {
11
- const client = new OpenAI({ apiKey: ctx.apiKey });
11
+ const client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey: ctx.apiKey });
12
12
  const file = await toFile(data, ctx.filename ?? "upload", { type: mimeType });
13
13
  // S5: the mapper below is trivial and won't throw post-create, so no orphan
14
14
  // is left here; a provider whose mapper does real work should best-effort delete.
@@ -16,7 +16,7 @@ class OpenAIFileProvider extends BaseFileProvider {
16
16
  return openaiFileRef(created, mimeType);
17
17
  }
18
18
  async doDelete(ref, ctx) {
19
- const client = new OpenAI({ apiKey: ctx.apiKey });
19
+ const client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey: ctx.apiKey });
20
20
  await client.files.delete(ref.id);
21
21
  }
22
22
  }
@@ -1,3 +1,4 @@
1
+ import { base64ToBytes, bytesToBase64 } from "../util/base64.js";
1
2
  import { GoogleGenAI } from "@google/genai";
2
3
  import { success, failure } from "../types/result.js";
3
4
  import { getModel, isImageModel } from "../models.js";
@@ -14,7 +15,7 @@ export async function googleImage(input, config, apiKey) {
14
15
  parts.push({
15
16
  inlineData: {
16
17
  mimeType: img.mimeType,
17
- data: Buffer.from(img.data).toString("base64"),
18
+ data: bytesToBase64(img.data),
18
19
  },
19
20
  });
20
21
  }
@@ -32,7 +33,7 @@ export async function googleImage(input, config, apiKey) {
32
33
  for (const part of candidate.content?.parts ?? []) {
33
34
  if (part.inlineData?.data && part.inlineData?.mimeType) {
34
35
  images.push({
35
- data: new Uint8Array(Buffer.from(part.inlineData.data, "base64")),
36
+ data: base64ToBytes(part.inlineData.data),
36
37
  mimeType: part.inlineData.mimeType,
37
38
  });
38
39
  }
@@ -1,3 +1,4 @@
1
+ import { base64ToBytes } from "../util/base64.js";
1
2
  import OpenAI from "openai";
2
3
  import { toFile } from "openai/uploads";
3
4
  import { success, failure } from "../types/result.js";
@@ -11,14 +12,14 @@ export async function openaiImage(input, config, apiKey) {
11
12
  if (normalized.mask && !hasImages) {
12
13
  return failure("A mask was provided without any input images. Masks are only valid for image edits — pass at least one entry in `images` alongside the mask.");
13
14
  }
14
- const client = new OpenAI({ apiKey });
15
+ const client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey });
15
16
  const baseParams = buildBaseParams(config, normalized.prompt);
16
17
  const response = hasImages
17
18
  ? await callEdit(client, baseParams, normalized)
18
19
  : await client.images.generate(baseParams);
19
20
  const mimeType = mimeFromFormat(config.outputFormat) ?? "image/png";
20
21
  const images = (response.data ?? []).map((d) => ({
21
- data: new Uint8Array(Buffer.from(d.b64_json, "base64")),
22
+ data: base64ToBytes(d.b64_json),
22
23
  mimeType,
23
24
  revisedPrompt: d.revised_prompt,
24
25
  }));
@@ -1,3 +1,4 @@
1
+ import { base64ToBytes } from "../util/base64.js";
1
2
  import OpenAI from "openai";
2
3
  import { success, failure } from "../types/result.js";
3
4
  import { omitUndefined } from "../util/util.js";
@@ -17,7 +18,7 @@ export async function openaiCompatImage(input, config, apiKey, baseURL) {
17
18
  if (normalized.mask) {
18
19
  return failure("openai-compat: image masks are not supported on this generic adapter.");
19
20
  }
20
- const client = new OpenAI({ apiKey, baseURL });
21
+ const client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey, baseURL });
21
22
  const params = omitUndefined({
22
23
  model: config.model,
23
24
  prompt: normalized.prompt,
@@ -31,7 +32,7 @@ export async function openaiCompatImage(input, config, apiKey, baseURL) {
31
32
  const response = await client.images.generate(params);
32
33
  const mimeType = mimeFromFormat(config.outputFormat) ?? "image/png";
33
34
  const images = (response.data ?? []).map((d) => ({
34
- data: new Uint8Array(Buffer.from(d.b64_json, "base64")),
35
+ data: base64ToBytes(d.b64_json),
35
36
  mimeType,
36
37
  revisedPrompt: d.revised_prompt,
37
38
  }));
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";