smoltalk 0.15.3 → 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 +25 -0
- package/dist/clients/baseClient.js +4 -3
- package/dist/clients/mlx.d.ts +12 -1
- package/dist/clients/mlx.js +23 -0
- 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/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/models.d.ts +298 -56
- package/dist/models.js +331 -66
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
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
|
|
|
@@ -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/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
|
}
|
|
@@ -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/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";
|