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.
- package/README.md +62 -0
- package/dist/clients/anthropic.js +3 -2
- package/dist/clients/baseClient.js +4 -3
- package/dist/clients/google.js +2 -1
- package/dist/clients/llamaCppLoader.js +2 -2
- package/dist/clients/mlx.d.ts +12 -1
- package/dist/clients/mlx.js +23 -0
- package/dist/clients/ollama.d.ts +1 -1
- package/dist/clients/ollama.js +1 -1
- package/dist/clients/openai.d.ts +2 -1
- package/dist/clients/openai.js +5 -3
- package/dist/clients/openaiResponses.js +3 -2
- package/dist/clients/resolveAttachments.js +2 -1
- package/dist/clients/structuredAfterTools.d.ts +36 -0
- package/dist/clients/structuredAfterTools.js +87 -0
- package/dist/clients/validationRetry.d.ts +5 -0
- package/dist/clients/validationRetry.js +24 -0
- package/dist/embed/openai.js +3 -2
- package/dist/files/anthropic.js +2 -2
- package/dist/files/openai.js +2 -2
- package/dist/image/google.js +3 -2
- package/dist/image/openai.js +3 -2
- package/dist/image/openaiCompat.js +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/modelRefresh.js +10 -11
- package/dist/models.d.ts +298 -56
- package/dist/models.js +331 -66
- package/dist/platform.browser.d.ts +5 -0
- package/dist/platform.browser.js +13 -0
- package/dist/platform.d.ts +33 -0
- package/dist/platform.js +13 -0
- package/dist/platform.node.d.ts +5 -0
- package/dist/platform.node.js +17 -0
- package/dist/speech/google.js +2 -1
- package/dist/speech/groq.js +1 -0
- package/dist/speech/mlx.js +1 -0
- package/dist/speech/openai.js +1 -1
- package/dist/speech/openaiCompat.js +1 -1
- package/dist/statelogClient.js +2 -1
- package/dist/transcription/google.js +3 -2
- package/dist/transcription/groq.js +1 -0
- package/dist/transcription/openai.js +1 -1
- package/dist/transcription/openaiCompat.js +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/util/attachments.js +2 -1
- package/dist/util/base64.d.ts +17 -0
- package/dist/util/base64.js +59 -0
- package/dist/util/blobRef.js +18 -12
- package/dist/util/fileExtension.d.ts +7 -0
- package/dist/util/fileExtension.js +15 -0
- package/dist/util/provider.d.ts +1 -0
- package/dist/util/provider.js +16 -14
- 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 ||
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
336
|
+
failedValidationMessage(errorMessage),
|
|
336
337
|
];
|
|
337
338
|
return this.textWithRetry({ ...promptConfig, messages: retryMessages }, retries - 1);
|
|
338
339
|
}
|
package/dist/clients/google.js
CHANGED
|
@@ -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 ||
|
|
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 {
|
|
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 =
|
|
66
|
+
importSource = pathToFileUrl(entryPath);
|
|
67
67
|
}
|
|
68
68
|
else {
|
|
69
69
|
importSource = "smoltalk-llama-cpp";
|
package/dist/clients/mlx.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/clients/mlx.js
CHANGED
|
@@ -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
|
}
|
package/dist/clients/ollama.d.ts
CHANGED
package/dist/clients/ollama.js
CHANGED
package/dist/clients/openai.d.ts
CHANGED
|
@@ -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.
|
package/dist/clients/openai.js
CHANGED
|
@@ -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 ||
|
|
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 ||
|
|
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:
|
|
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
|
+
}
|
package/dist/embed/openai.js
CHANGED
|
@@ -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 =
|
|
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,
|
package/dist/files/anthropic.js
CHANGED
|
@@ -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
|
}
|
package/dist/files/openai.js
CHANGED
|
@@ -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
|
}
|
package/dist/image/google.js
CHANGED
|
@@ -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:
|
|
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:
|
|
36
|
+
data: base64ToBytes(part.inlineData.data),
|
|
36
37
|
mimeType: part.inlineData.mimeType,
|
|
37
38
|
});
|
|
38
39
|
}
|
package/dist/image/openai.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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";
|