@bitbaum/ai-kit 1.3.0 → 1.4.1
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/dist/capability/classify.d.ts +4 -3
- package/dist/capability/classify.js +30 -9
- package/dist/complete.d.ts +52 -5
- package/dist/complete.js +21 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/tool-protocol.d.ts +93 -0
- package/dist/tool-protocol.js +215 -0
- package/package.json +4 -4
- package/src/capability/classify.ts +30 -9
- package/src/complete.ts +69 -7
- package/src/index.ts +10 -0
- package/src/tool-protocol.ts +223 -0
|
@@ -23,9 +23,10 @@ import type { Classification } from "./types.js";
|
|
|
23
23
|
/**
|
|
24
24
|
* Does this error body explicitly say the model cannot do tools?
|
|
25
25
|
*
|
|
26
|
-
* Conservative on purpose — see the header.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Conservative on purpose — see the header. Both failures are real and neither
|
|
27
|
+
* is free: a false positive downgrades a working model for a reason nobody can
|
|
28
|
+
* see, and a false negative leaves the caller repeating a request it will
|
|
29
|
+
* never learn from. Match on "names tools AND negates support", nothing looser.
|
|
29
30
|
*/
|
|
30
31
|
export declare function saysToolsUnsupported(body: string): boolean;
|
|
31
32
|
export type ToolAttempt = {
|
|
@@ -4,29 +4,50 @@
|
|
|
4
4
|
* Every entry names tools or functions explicitly. Deliberately absent:
|
|
5
5
|
* "invalid request", "bad parameter", "unsupported" on its own — each of those
|
|
6
6
|
* appears in vendor 400s for a dozen unrelated reasons, and a match on one of
|
|
7
|
-
* them would silently disable a working model.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* them would silently disable a working model.
|
|
8
|
+
*
|
|
9
|
+
* But "when in doubt, record nothing" is only cheap for a caller that can
|
|
10
|
+
* retry freely, and the real caller cannot. A caller that sends definitions
|
|
11
|
+
* ALSO drops whatever prose fallback it has, because definitions are supposed
|
|
12
|
+
* to replace it — so a refusal this list fails to recognise costs a whole turn
|
|
13
|
+
* in which the model can neither call a tool nor be told how to act without
|
|
14
|
+
* one, and it costs that turn EVERY turn, forever, because nothing is ever
|
|
15
|
+
* learned. A missed phrasing is not one wasted request; it is a permanently
|
|
16
|
+
* mute model.
|
|
17
|
+
*
|
|
18
|
+
* So the bar is: does the vendor NAME tools or functions, and NEGATE support?
|
|
19
|
+
* Both halves, explicitly. Everything meeting that bar belongs here, including
|
|
20
|
+
* the boring grammatical variants — plural, `are`, `cannot use` — which is
|
|
21
|
+
* where the first real gap was found (`tools are not supported by this model`
|
|
22
|
+
* matched nothing at all).
|
|
10
23
|
*/
|
|
11
24
|
const TOOLS_UNSUPPORTED_PATTERNS = [
|
|
12
|
-
/tool[\s_-]?(use|call|calls|calling)\s+(is\s+)?(not|un)[\s_-]?support/i,
|
|
25
|
+
/tool[\s_-]?(use|call|calls|calling)\s+(is\s+|are\s+)?(not|un)[\s_-]?support/i,
|
|
13
26
|
/does\s+not\s+support\s+tool/i,
|
|
14
27
|
/doesn'?t\s+support\s+tool/i,
|
|
15
28
|
/no\s+support\s+for\s+tool/i,
|
|
16
|
-
/function[\s_-]?call(ing)?\s+(is\s+)?(not|un)[\s_-]?support/i,
|
|
29
|
+
/function[\s_-]?call(ing)?\s+(is\s+|are\s+)?(not|un)[\s_-]?support/i,
|
|
17
30
|
/does\s+not\s+support\s+function/i,
|
|
18
31
|
/doesn'?t\s+support\s+function/i,
|
|
19
32
|
/model\s+.{0,60}?\s+does\s+not\s+support\s+(the\s+)?(`?tools`?|`?functions`?)/i,
|
|
20
33
|
/unsupported\s+parameter:?\s*'?"?tools?"?'?/i,
|
|
21
34
|
/unknown\s+(field|parameter):?\s*'?"?tools?"?'?/i,
|
|
22
|
-
|
|
35
|
+
// "tools are not supported", "tool is not valid", "functions are unsupported".
|
|
36
|
+
// The singular-and-`is` form of this was already here; the plural-and-`are`
|
|
37
|
+
// form is the one vendors actually write, and it matched nothing.
|
|
38
|
+
/`?(tools?|functions?)`?\s+(is|are)\s+(not\s+(a\s+)?(valid|supported|allowed|available)|unsupported)/i,
|
|
39
|
+
// "this model cannot use tools" / "can't call functions".
|
|
40
|
+
/(cannot|can'?t|is\s+unable\s+to)\s+(use|call|handle|execute)\s+(`?tools?`?|`?functions?`?)/i,
|
|
41
|
+
// "no tool support", "without function support".
|
|
42
|
+
/no\s+(`?tools?`?|`?functions?`?)\s+support/i,
|
|
23
43
|
];
|
|
24
44
|
/**
|
|
25
45
|
* Does this error body explicitly say the model cannot do tools?
|
|
26
46
|
*
|
|
27
|
-
* Conservative on purpose — see the header.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
47
|
+
* Conservative on purpose — see the header. Both failures are real and neither
|
|
48
|
+
* is free: a false positive downgrades a working model for a reason nobody can
|
|
49
|
+
* see, and a false negative leaves the caller repeating a request it will
|
|
50
|
+
* never learn from. Match on "names tools AND negates support", nothing looser.
|
|
30
51
|
*/
|
|
31
52
|
export function saysToolsUnsupported(body) {
|
|
32
53
|
if (!body)
|
package/dist/complete.d.ts
CHANGED
|
@@ -59,10 +59,38 @@ import { type Env, type Link, type Provider } from "./chain.js";
|
|
|
59
59
|
import type { HealthTracker } from "./health.js";
|
|
60
60
|
import { type RateLimitKind } from "./limits.js";
|
|
61
61
|
import { type QuotaReading } from "./meter.js";
|
|
62
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* A piece of a message, for the models that accept more than text.
|
|
64
|
+
*
|
|
65
|
+
* The same shape every provider here already speaks, because it is OpenAI's —
|
|
66
|
+
* `image_url.url` takes a `data:` URL or an `https:` one.
|
|
67
|
+
*/
|
|
68
|
+
export type ContentPart = {
|
|
69
|
+
type: "text";
|
|
70
|
+
text: string;
|
|
71
|
+
} | {
|
|
72
|
+
type: "image_url";
|
|
73
|
+
image_url: {
|
|
74
|
+
url: string;
|
|
75
|
+
detail?: "auto" | "low" | "high";
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* One message in the OpenAI chat-completions shape every provider here speaks.
|
|
80
|
+
*
|
|
81
|
+
* `content` accepts parts as well as a string because the implementation
|
|
82
|
+
* always carried them: `callLink` forwards `messages` into the request body
|
|
83
|
+
* untouched, so a multimodal message has worked at runtime since the first
|
|
84
|
+
* release while the type insisted it could not. A consumer that needed to send
|
|
85
|
+
* a screenshot therefore had to cast around our own type — and a cast written
|
|
86
|
+
* to work around a library is a thing the next consumer copies.
|
|
87
|
+
*
|
|
88
|
+
* Backward compatible by construction: every existing caller passes a string,
|
|
89
|
+
* and a string is still a `ChatMessage["content"]`.
|
|
90
|
+
*/
|
|
63
91
|
export interface ChatMessage {
|
|
64
92
|
role: "system" | "user" | "assistant" | "tool";
|
|
65
|
-
content: string;
|
|
93
|
+
content: string | ContentPart[];
|
|
66
94
|
/** Present on `role: "tool"` replies; passed through untouched. */
|
|
67
95
|
tool_call_id?: string;
|
|
68
96
|
name?: string;
|
|
@@ -72,9 +100,11 @@ export interface ChatMessage {
|
|
|
72
100
|
* actually answer on.
|
|
73
101
|
*
|
|
74
102
|
* Both exist in the default chain: of nine free models probed live, four
|
|
75
|
-
* answered with native `tool_calls` and five only in text
|
|
76
|
-
*
|
|
77
|
-
*
|
|
103
|
+
* answered with native `tool_calls` and five only in text — and three of the
|
|
104
|
+
* seven models shipped in the chain today are in that second group. Since 1.4.0
|
|
105
|
+
* both are read here, so a caller no longer has to know which half of the chain
|
|
106
|
+
* answered. See `toolProtocol` on CompleteOptions, and tool-protocol.ts for why
|
|
107
|
+
* a native-only read is a fabrication path rather than a missing feature.
|
|
78
108
|
*/
|
|
79
109
|
export interface ToolCall {
|
|
80
110
|
id: string;
|
|
@@ -140,6 +170,23 @@ export interface CompleteOptions {
|
|
|
140
170
|
temperature?: number;
|
|
141
171
|
/** Tool definitions in the OpenAI shape; passed through untouched. */
|
|
142
172
|
tools?: unknown[];
|
|
173
|
+
/**
|
|
174
|
+
* Which tool-call protocols to READ from the reply. Default `"both"`.
|
|
175
|
+
*
|
|
176
|
+
* `"both"` also parses the `TOOL:` / `ARGS:` line protocol out of ordinary
|
|
177
|
+
* content and strips those lines from `text`. This matters more than it
|
|
178
|
+
* sounds: three of the seven models in the default chain cannot emit a native
|
|
179
|
+
* tool call at all, and a native-only read hands their narration back as a
|
|
180
|
+
* finished answer. The turn then reports a lookup that never happened.
|
|
181
|
+
*
|
|
182
|
+
* Parsing is skipped entirely when no `tools` are supplied — a model does not
|
|
183
|
+
* narrate a call it was never offered — so this is inert for the callers who
|
|
184
|
+
* do not use tools, which today is all of them.
|
|
185
|
+
*
|
|
186
|
+
* Set `"native"` only if you parse the text protocol yourself and would
|
|
187
|
+
* otherwise execute each call twice.
|
|
188
|
+
*/
|
|
189
|
+
toolProtocol?: "both" | "native";
|
|
143
190
|
/** Extra body fields for a vendor-specific parameter. Merged last, so it can override. */
|
|
144
191
|
extraBody?: Record<string, unknown>;
|
|
145
192
|
/**
|
package/dist/complete.js
CHANGED
|
@@ -59,6 +59,7 @@ import { chainFrom, freeChain, usableChain } from "./chain.js";
|
|
|
59
59
|
import { ChainExhaustedError } from "./attempt.js";
|
|
60
60
|
import { classifyRateLimit, retryAfterSeconds } from "./limits.js";
|
|
61
61
|
import { readQuota, readingFromRefusal } from "./meter.js";
|
|
62
|
+
import { parseTextToolCalls, stripToolCallLines, toolNamesFrom } from "./tool-protocol.js";
|
|
62
63
|
/**
|
|
63
64
|
* A link failed in a way that says something about the WALK, not just this link.
|
|
64
65
|
*
|
|
@@ -268,10 +269,28 @@ async function callLink(link, options, key) {
|
|
|
268
269
|
}
|
|
269
270
|
const choice = parsed
|
|
270
271
|
?.choices?.[0];
|
|
271
|
-
const
|
|
272
|
-
const
|
|
272
|
+
const rawContent = firstText(choice?.message);
|
|
273
|
+
const native = toolCallsFrom(choice?.message);
|
|
274
|
+
// Read the line protocol out of the prose as well, unless the caller opted
|
|
275
|
+
// out or offered no tools. Native wins on a tie: a model that emits BOTH the
|
|
276
|
+
// real call and a prose echo of it must not run the tool twice — that wastes
|
|
277
|
+
// a round trip and can double-propose an action.
|
|
278
|
+
const wantsText = (options.toolProtocol ?? "both") === "both" && (options.tools?.length ?? 0) > 0;
|
|
279
|
+
const fromText = wantsText
|
|
280
|
+
? parseTextToolCalls(rawContent, toolNamesFrom(options.tools)).filter((c) => {
|
|
281
|
+
const key = `${c.name}:${c.args}`;
|
|
282
|
+
return !native.some((n) => `${n.name}:${n.args}` === key);
|
|
283
|
+
})
|
|
284
|
+
: [];
|
|
285
|
+
const toolCalls = [...native, ...fromText];
|
|
286
|
+
// Strip the protocol lines only when they were actually read as calls.
|
|
287
|
+
// Removing them without parsing would delete the evidence and leave a shorter
|
|
288
|
+
// hallucination behind, which is worse than either extreme.
|
|
289
|
+
const content = fromText.length > 0 ? stripToolCallLines(rawContent) : rawContent;
|
|
273
290
|
// A 200 that carries neither text nor a tool call is an outage wearing a
|
|
274
291
|
// success code — see the header. Demote, so the chain gets its chance.
|
|
292
|
+
// Note the ordering: a reply that was ONLY a narrated call is now empty text
|
|
293
|
+
// WITH tool calls, which is a valid turn, not an outage.
|
|
275
294
|
if (content.trim() === "" && toolCalls.length === 0) {
|
|
276
295
|
throw new LinkFailure(link, `${linkId(link)}: 200 with empty content — model produced no output`, {
|
|
277
296
|
status: res.status,
|
package/dist/index.d.ts
CHANGED
|
@@ -52,10 +52,11 @@
|
|
|
52
52
|
export { type Provider, type Env, type Link, type CostVerdict, providerModels, withEnvPrefix, freeChain, modelCost, modelCostAt, paidModelsIn, dayCapacityTokens, usableChain, chainFrom, } from "./chain.js";
|
|
53
53
|
export { type CatalogVerdict, type CheckCatalogOptions, checkCatalog, hasRot, deadProviders, catalogReport, } from "./catalog.js";
|
|
54
54
|
export { type ChainAttemptFailure, type TryChainOptions, ChainExhaustedError, tryChain, } from "./attempt.js";
|
|
55
|
-
export { type ChatMessage, type ToolCall, type CompleteOptions, type CompleteResult, LinkFailure, complete, linkId, } from "./complete.js";
|
|
55
|
+
export { type ChatMessage, type ContentPart, type ToolCall, type CompleteOptions, type CompleteResult, LinkFailure, complete, linkId, } from "./complete.js";
|
|
56
56
|
export { type HealthStatus, type Health, type HealthTrackerOptions, type HealthTracker, createHealthTracker, } from "./health.js";
|
|
57
57
|
export { type LivenessResult, type LivenessOptions, type LivenessProbe, type AiHealthHandlerOptions, createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
|
|
58
58
|
export { type RateLimitKind, classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
|
|
59
59
|
export { type QuotaScope, type QuotaWindow, type QuotaReading, type HeaderBag, readQuota, readingFromRefusal, parseResetAt, answersRemaining, } from "./meter.js";
|
|
60
|
+
export { type ParsedToolCall, TEXT_TOOL_PROTOCOL_HINT, parseTextToolCalls, stripToolCallLines, safeJsonObject, toolNamesFrom, } from "./tool-protocol.js";
|
|
60
61
|
export { type PoolId, type RungId, type TierPolicy, type AiPolicy, type UserState, type WallOption, type Wall, type Decision, DEFAULT_LADDER, decide, shouldSurface, nextUtcReset, } from "./policy.js";
|
|
61
62
|
export { DAY_SECONDS, DEFAULT_BURST, type ShareInput, type ShareReason, type ShareDecision, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
|
package/dist/index.js
CHANGED
|
@@ -57,6 +57,7 @@ export { createHealthTracker, } from "./health.js";
|
|
|
57
57
|
export { createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
|
|
58
58
|
export { classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
|
|
59
59
|
export { readQuota, readingFromRefusal, parseResetAt, answersRemaining, } from "./meter.js";
|
|
60
|
+
export { TEXT_TOOL_PROTOCOL_HINT, parseTextToolCalls, stripToolCallLines, safeJsonObject, toolNamesFrom, } from "./tool-protocol.js";
|
|
60
61
|
export { DEFAULT_LADDER, decide, shouldSurface, nextUtcReset, } from "./policy.js";
|
|
61
62
|
export { DAY_SECONDS, DEFAULT_BURST, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
|
|
62
63
|
// Form filling lives at `ai-kit/forms`, NOT here.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other way models ask for a tool: by writing it in the reply.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY A NATIVE-ONLY CLIENT LOSES MOST OF THIS PACKAGE'S OWN CHAIN ──────────
|
|
5
|
+
*
|
|
6
|
+
* `chain.ts` carries a probe table from nine free models called with a real
|
|
7
|
+
* tool. Four answered with native `tool_calls`. Five answered only in TEXT.
|
|
8
|
+
* Of the seven models in the shipped default chain today, three are in that
|
|
9
|
+
* second group:
|
|
10
|
+
*
|
|
11
|
+
* google/gemma-4-26b-a4b-it:free text
|
|
12
|
+
* cohere/north-mini-code:free text
|
|
13
|
+
* openrouter/free text
|
|
14
|
+
*
|
|
15
|
+
* A client that reads only `message.tool_calls` gets ZERO tool calls from those
|
|
16
|
+
* three — and, far worse, gets the model's narration of the call as ordinary
|
|
17
|
+
* content. So a turn that should have looked up a record instead returns a
|
|
18
|
+
* confident sentence describing the lookup it did not perform, and every layer
|
|
19
|
+
* downstream treats it as an answer. That is not a missing feature; it is a
|
|
20
|
+
* fabrication path, and it is open on nearly half the chain this package ships.
|
|
21
|
+
*
|
|
22
|
+
* ── WHY LINE-BASED AND NOT NESTED JSON ───────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* The format is chosen for the WEAKEST model expected to run it, not the
|
|
25
|
+
* strongest. An 8B model reliably reproduces two flat lines:
|
|
26
|
+
*
|
|
27
|
+
* TOOL: search_people
|
|
28
|
+
* ARGS: {"query": "Elena"}
|
|
29
|
+
*
|
|
30
|
+
* The same model routinely breaks nested-JSON escaping. Every leniency in the
|
|
31
|
+
* parser below is a shape a small model actually emitted in production — bolded
|
|
32
|
+
* keys because it was writing markdown, a fenced ARGS block, the parentheses
|
|
33
|
+
* copied from the example, a missing ARGS line for a no-argument tool. Rejecting
|
|
34
|
+
* any of them would fail the turn over formatting, which is the exact failure
|
|
35
|
+
* this protocol exists to avoid.
|
|
36
|
+
*/
|
|
37
|
+
/** The tool-call shape, matching `complete`'s. `args` stays a raw JSON string. */
|
|
38
|
+
export interface ParsedToolCall {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
args: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The convention, as prompt text.
|
|
45
|
+
*
|
|
46
|
+
* Exported so apps stop writing their own wording and drifting from the parser
|
|
47
|
+
* that has to read it back. A prompt and its parser are one contract; keeping
|
|
48
|
+
* them in separate repos is how they diverge.
|
|
49
|
+
*
|
|
50
|
+
* Note what it does NOT say: it never tells the model to use this INSTEAD of
|
|
51
|
+
* native calling. A model with native support should use it, and this is the
|
|
52
|
+
* fallback for one that cannot — offering both costs a few tokens and covers
|
|
53
|
+
* both halves of the chain.
|
|
54
|
+
*/
|
|
55
|
+
export declare const TEXT_TOOL_PROTOCOL_HINT: string;
|
|
56
|
+
/**
|
|
57
|
+
* Parse a JSON object, tolerating fences and trailing prose. Null if hopeless.
|
|
58
|
+
*
|
|
59
|
+
* `null` and `{}` must stay distinguishable. Returning `{}` for "nothing
|
|
60
|
+
* parseable here" satisfies a caller's `?? {}` fallback and silently discards
|
|
61
|
+
* arguments the model put on the NEXT line — which is exactly what a model does
|
|
62
|
+
* when it opens a ```json fence after `ARGS:`. That cost a debugging cycle once
|
|
63
|
+
* already; it is a test here now.
|
|
64
|
+
*/
|
|
65
|
+
export declare function safeJsonObject(raw: string): Record<string, unknown> | null;
|
|
66
|
+
/**
|
|
67
|
+
* Extract text-protocol calls from a reply.
|
|
68
|
+
*
|
|
69
|
+
* `validNames` is the closed set the model was offered. A line naming anything
|
|
70
|
+
* else is left alone: without that check, a model writing the words "TOOL:
|
|
71
|
+
* whatever" in prose would manufacture a call to a tool that does not exist,
|
|
72
|
+
* and the executor would report a failure the model never asked for.
|
|
73
|
+
*/
|
|
74
|
+
export declare function parseTextToolCalls(text: string, validNames: string[]): ParsedToolCall[];
|
|
75
|
+
/**
|
|
76
|
+
* Remove protocol lines from prose.
|
|
77
|
+
*
|
|
78
|
+
* A narrated call must never reach the user as though it were an answer, which
|
|
79
|
+
* is the whole failure this module exists to close. Stripping also means a reply
|
|
80
|
+
* that was ONLY a tool call ends up empty — and an empty reply carrying tool
|
|
81
|
+
* calls is a valid turn, while an empty reply carrying none is an outage. The
|
|
82
|
+
* caller has to keep those apart.
|
|
83
|
+
*/
|
|
84
|
+
export declare function stripToolCallLines(text: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* The tool names inside an OpenAI-shaped `tools` array.
|
|
87
|
+
*
|
|
88
|
+
* Derived from what the caller already passed rather than asked for separately:
|
|
89
|
+
* two lists that must agree are one list that cannot disagree, and the failure
|
|
90
|
+
* of the split version is silent — a name missing from the second list makes
|
|
91
|
+
* the parser ignore a real call.
|
|
92
|
+
*/
|
|
93
|
+
export declare function toolNamesFrom(tools: unknown[] | undefined): string[];
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other way models ask for a tool: by writing it in the reply.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY A NATIVE-ONLY CLIENT LOSES MOST OF THIS PACKAGE'S OWN CHAIN ──────────
|
|
5
|
+
*
|
|
6
|
+
* `chain.ts` carries a probe table from nine free models called with a real
|
|
7
|
+
* tool. Four answered with native `tool_calls`. Five answered only in TEXT.
|
|
8
|
+
* Of the seven models in the shipped default chain today, three are in that
|
|
9
|
+
* second group:
|
|
10
|
+
*
|
|
11
|
+
* google/gemma-4-26b-a4b-it:free text
|
|
12
|
+
* cohere/north-mini-code:free text
|
|
13
|
+
* openrouter/free text
|
|
14
|
+
*
|
|
15
|
+
* A client that reads only `message.tool_calls` gets ZERO tool calls from those
|
|
16
|
+
* three — and, far worse, gets the model's narration of the call as ordinary
|
|
17
|
+
* content. So a turn that should have looked up a record instead returns a
|
|
18
|
+
* confident sentence describing the lookup it did not perform, and every layer
|
|
19
|
+
* downstream treats it as an answer. That is not a missing feature; it is a
|
|
20
|
+
* fabrication path, and it is open on nearly half the chain this package ships.
|
|
21
|
+
*
|
|
22
|
+
* ── WHY LINE-BASED AND NOT NESTED JSON ───────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* The format is chosen for the WEAKEST model expected to run it, not the
|
|
25
|
+
* strongest. An 8B model reliably reproduces two flat lines:
|
|
26
|
+
*
|
|
27
|
+
* TOOL: search_people
|
|
28
|
+
* ARGS: {"query": "Elena"}
|
|
29
|
+
*
|
|
30
|
+
* The same model routinely breaks nested-JSON escaping. Every leniency in the
|
|
31
|
+
* parser below is a shape a small model actually emitted in production — bolded
|
|
32
|
+
* keys because it was writing markdown, a fenced ARGS block, the parentheses
|
|
33
|
+
* copied from the example, a missing ARGS line for a no-argument tool. Rejecting
|
|
34
|
+
* any of them would fail the turn over formatting, which is the exact failure
|
|
35
|
+
* this protocol exists to avoid.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* The convention, as prompt text.
|
|
39
|
+
*
|
|
40
|
+
* Exported so apps stop writing their own wording and drifting from the parser
|
|
41
|
+
* that has to read it back. A prompt and its parser are one contract; keeping
|
|
42
|
+
* them in separate repos is how they diverge.
|
|
43
|
+
*
|
|
44
|
+
* Note what it does NOT say: it never tells the model to use this INSTEAD of
|
|
45
|
+
* native calling. A model with native support should use it, and this is the
|
|
46
|
+
* fallback for one that cannot — offering both costs a few tokens and covers
|
|
47
|
+
* both halves of the chain.
|
|
48
|
+
*/
|
|
49
|
+
export const TEXT_TOOL_PROTOCOL_HINT = [
|
|
50
|
+
"If you cannot emit a native tool call, call a tool by writing these two lines in your reply, exactly like this:",
|
|
51
|
+
"",
|
|
52
|
+
"TOOL: tool_name",
|
|
53
|
+
'ARGS: {"argument": "value"}',
|
|
54
|
+
"",
|
|
55
|
+
"Write nothing else in a reply that calls tools — you will be given the results and asked again.",
|
|
56
|
+
].join("\n");
|
|
57
|
+
/** Matches a TOOL: line, tolerating list bullets, markdown bold, and `=`. */
|
|
58
|
+
const TOOL_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?TOOL(?:\*\*)?\s*[:=]\s*(.+?)\s*$/i;
|
|
59
|
+
/** Matches an ARGS: line, same tolerances. */
|
|
60
|
+
const ARGS_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?ARGS(?:\*\*)?\s*[:=]\s*(.*)$/i;
|
|
61
|
+
/** Either key, for the strip pass. */
|
|
62
|
+
const EITHER_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?(?:TOOL|ARGS)(?:\*\*)?\s*[:=]/i;
|
|
63
|
+
/** How far past a TOOL: line to look for its ARGS:, and how far to join a multi-line object. */
|
|
64
|
+
const ARGS_SEARCH_LINES = 4;
|
|
65
|
+
const ARGS_JOIN_LINES = 8;
|
|
66
|
+
/**
|
|
67
|
+
* Parse a JSON object, tolerating fences and trailing prose. Null if hopeless.
|
|
68
|
+
*
|
|
69
|
+
* `null` and `{}` must stay distinguishable. Returning `{}` for "nothing
|
|
70
|
+
* parseable here" satisfies a caller's `?? {}` fallback and silently discards
|
|
71
|
+
* arguments the model put on the NEXT line — which is exactly what a model does
|
|
72
|
+
* when it opens a ```json fence after `ARGS:`. That cost a debugging cycle once
|
|
73
|
+
* already; it is a test here now.
|
|
74
|
+
*/
|
|
75
|
+
export function safeJsonObject(raw) {
|
|
76
|
+
const cleaned = raw
|
|
77
|
+
.trim()
|
|
78
|
+
.replace(/^```(?:json)?/i, "")
|
|
79
|
+
.replace(/```$/, "")
|
|
80
|
+
.trim();
|
|
81
|
+
if (!cleaned)
|
|
82
|
+
return null;
|
|
83
|
+
if (cleaned === "{}")
|
|
84
|
+
return {};
|
|
85
|
+
const start = cleaned.indexOf("{");
|
|
86
|
+
if (start === -1)
|
|
87
|
+
return null;
|
|
88
|
+
// Walk to the matching brace so trailing commentary does not break the parse.
|
|
89
|
+
// String-aware: a brace inside a quoted value must not change the depth, or
|
|
90
|
+
// an argument like {"q": "a } b"} truncates at the wrong place.
|
|
91
|
+
let depth = 0;
|
|
92
|
+
let inString = false;
|
|
93
|
+
let escaped = false;
|
|
94
|
+
for (let i = start; i < cleaned.length; i++) {
|
|
95
|
+
const ch = cleaned[i];
|
|
96
|
+
if (escaped) {
|
|
97
|
+
escaped = false;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (ch === "\\") {
|
|
101
|
+
escaped = true;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (ch === '"') {
|
|
105
|
+
inString = !inString;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (inString)
|
|
109
|
+
continue;
|
|
110
|
+
if (ch === "{")
|
|
111
|
+
depth++;
|
|
112
|
+
else if (ch === "}") {
|
|
113
|
+
depth--;
|
|
114
|
+
if (depth === 0) {
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(cleaned.slice(start, i + 1));
|
|
117
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
118
|
+
? parsed
|
|
119
|
+
: null;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Extract text-protocol calls from a reply.
|
|
131
|
+
*
|
|
132
|
+
* `validNames` is the closed set the model was offered. A line naming anything
|
|
133
|
+
* else is left alone: without that check, a model writing the words "TOOL:
|
|
134
|
+
* whatever" in prose would manufacture a call to a tool that does not exist,
|
|
135
|
+
* and the executor would report a failure the model never asked for.
|
|
136
|
+
*/
|
|
137
|
+
export function parseTextToolCalls(text, validNames) {
|
|
138
|
+
if (!text || validNames.length === 0)
|
|
139
|
+
return [];
|
|
140
|
+
const calls = [];
|
|
141
|
+
const valid = new Set(validNames);
|
|
142
|
+
const lines = text.split(/\r?\n/);
|
|
143
|
+
for (let i = 0; i < lines.length; i++) {
|
|
144
|
+
const line = lines[i] ?? "";
|
|
145
|
+
const m = TOOL_LINE.exec(line);
|
|
146
|
+
const rawName = m?.[1];
|
|
147
|
+
if (!rawName)
|
|
148
|
+
continue;
|
|
149
|
+
// Tolerate `name(...)`, backticks, and trailing punctuation copied from prose.
|
|
150
|
+
const name = rawName
|
|
151
|
+
.replace(/[`*]/g, "")
|
|
152
|
+
.replace(/\(.*$/, "")
|
|
153
|
+
.replace(/[.,;]$/, "")
|
|
154
|
+
.trim();
|
|
155
|
+
if (!valid.has(name))
|
|
156
|
+
continue;
|
|
157
|
+
let args = "{}";
|
|
158
|
+
for (let j = i + 1; j < Math.min(i + ARGS_SEARCH_LINES, lines.length); j++) {
|
|
159
|
+
const next = lines[j] ?? "";
|
|
160
|
+
const a = ARGS_LINE.exec(next);
|
|
161
|
+
if (a) {
|
|
162
|
+
// The object may continue past this line: a model opening a ```json
|
|
163
|
+
// fence puts the brace on the NEXT line, and a pretty-printed object
|
|
164
|
+
// spans several. Join forward and let the brace matcher find the end.
|
|
165
|
+
const rest = lines.slice(j, Math.min(j + ARGS_JOIN_LINES, lines.length)).join("\n");
|
|
166
|
+
const parsed = safeJsonObject(a[1] ?? "") ?? safeJsonObject(rest.replace(/^[^:=]*[:=]/, ""));
|
|
167
|
+
if (parsed)
|
|
168
|
+
args = JSON.stringify(parsed);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
// A new TOOL line means this call simply had no arguments.
|
|
172
|
+
if (TOOL_LINE.test(next))
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
calls.push({ id: `text_${calls.length}_${name}`, name, args });
|
|
176
|
+
}
|
|
177
|
+
return calls;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Remove protocol lines from prose.
|
|
181
|
+
*
|
|
182
|
+
* A narrated call must never reach the user as though it were an answer, which
|
|
183
|
+
* is the whole failure this module exists to close. Stripping also means a reply
|
|
184
|
+
* that was ONLY a tool call ends up empty — and an empty reply carrying tool
|
|
185
|
+
* calls is a valid turn, while an empty reply carrying none is an outage. The
|
|
186
|
+
* caller has to keep those apart.
|
|
187
|
+
*/
|
|
188
|
+
export function stripToolCallLines(text) {
|
|
189
|
+
return text
|
|
190
|
+
.split(/\r?\n/)
|
|
191
|
+
.filter((l) => !EITHER_LINE.test(l))
|
|
192
|
+
.join("\n")
|
|
193
|
+
.replace(/```(?:json)?\s*```/g, "")
|
|
194
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
195
|
+
.trim();
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The tool names inside an OpenAI-shaped `tools` array.
|
|
199
|
+
*
|
|
200
|
+
* Derived from what the caller already passed rather than asked for separately:
|
|
201
|
+
* two lists that must agree are one list that cannot disagree, and the failure
|
|
202
|
+
* of the split version is silent — a name missing from the second list makes
|
|
203
|
+
* the parser ignore a real call.
|
|
204
|
+
*/
|
|
205
|
+
export function toolNamesFrom(tools) {
|
|
206
|
+
if (!Array.isArray(tools))
|
|
207
|
+
return [];
|
|
208
|
+
const names = [];
|
|
209
|
+
for (const entry of tools) {
|
|
210
|
+
const fn = entry?.function;
|
|
211
|
+
if (fn && typeof fn.name === "string" && fn.name)
|
|
212
|
+
names.push(fn.name);
|
|
213
|
+
}
|
|
214
|
+
return names;
|
|
215
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitbaum/ai-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Mao Nakamoto",
|
|
6
6
|
"homepage": "https://github.com/bitbaum/ai-kit#readme",
|
|
@@ -90,12 +90,12 @@
|
|
|
90
90
|
},
|
|
91
91
|
"devDependencies": {
|
|
92
92
|
"@eslint/js": "^10.0.1",
|
|
93
|
-
"@types/node": "^26.
|
|
94
|
-
"eslint": "^10.
|
|
93
|
+
"@types/node": "^26.5.0",
|
|
94
|
+
"eslint": "^10.10.0",
|
|
95
95
|
"globals": "^17.12.0",
|
|
96
96
|
"prettier": "3.9.6",
|
|
97
97
|
"typescript": "^6.0.3",
|
|
98
|
-
"typescript-eslint": "^8.
|
|
98
|
+
"typescript-eslint": "^8.70.0"
|
|
99
99
|
},
|
|
100
100
|
"dependencies": {
|
|
101
101
|
"ai-forms": "^0.1.2"
|
|
@@ -27,30 +27,51 @@ import type { Classification } from "./types.js";
|
|
|
27
27
|
* Every entry names tools or functions explicitly. Deliberately absent:
|
|
28
28
|
* "invalid request", "bad parameter", "unsupported" on its own — each of those
|
|
29
29
|
* appears in vendor 400s for a dozen unrelated reasons, and a match on one of
|
|
30
|
-
* them would silently disable a working model.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* them would silently disable a working model.
|
|
31
|
+
*
|
|
32
|
+
* But "when in doubt, record nothing" is only cheap for a caller that can
|
|
33
|
+
* retry freely, and the real caller cannot. A caller that sends definitions
|
|
34
|
+
* ALSO drops whatever prose fallback it has, because definitions are supposed
|
|
35
|
+
* to replace it — so a refusal this list fails to recognise costs a whole turn
|
|
36
|
+
* in which the model can neither call a tool nor be told how to act without
|
|
37
|
+
* one, and it costs that turn EVERY turn, forever, because nothing is ever
|
|
38
|
+
* learned. A missed phrasing is not one wasted request; it is a permanently
|
|
39
|
+
* mute model.
|
|
40
|
+
*
|
|
41
|
+
* So the bar is: does the vendor NAME tools or functions, and NEGATE support?
|
|
42
|
+
* Both halves, explicitly. Everything meeting that bar belongs here, including
|
|
43
|
+
* the boring grammatical variants — plural, `are`, `cannot use` — which is
|
|
44
|
+
* where the first real gap was found (`tools are not supported by this model`
|
|
45
|
+
* matched nothing at all).
|
|
33
46
|
*/
|
|
34
47
|
const TOOLS_UNSUPPORTED_PATTERNS: RegExp[] = [
|
|
35
|
-
/tool[\s_-]?(use|call|calls|calling)\s+(is\s+)?(not|un)[\s_-]?support/i,
|
|
48
|
+
/tool[\s_-]?(use|call|calls|calling)\s+(is\s+|are\s+)?(not|un)[\s_-]?support/i,
|
|
36
49
|
/does\s+not\s+support\s+tool/i,
|
|
37
50
|
/doesn'?t\s+support\s+tool/i,
|
|
38
51
|
/no\s+support\s+for\s+tool/i,
|
|
39
|
-
/function[\s_-]?call(ing)?\s+(is\s+)?(not|un)[\s_-]?support/i,
|
|
52
|
+
/function[\s_-]?call(ing)?\s+(is\s+|are\s+)?(not|un)[\s_-]?support/i,
|
|
40
53
|
/does\s+not\s+support\s+function/i,
|
|
41
54
|
/doesn'?t\s+support\s+function/i,
|
|
42
55
|
/model\s+.{0,60}?\s+does\s+not\s+support\s+(the\s+)?(`?tools`?|`?functions`?)/i,
|
|
43
56
|
/unsupported\s+parameter:?\s*'?"?tools?"?'?/i,
|
|
44
57
|
/unknown\s+(field|parameter):?\s*'?"?tools?"?'?/i,
|
|
45
|
-
|
|
58
|
+
// "tools are not supported", "tool is not valid", "functions are unsupported".
|
|
59
|
+
// The singular-and-`is` form of this was already here; the plural-and-`are`
|
|
60
|
+
// form is the one vendors actually write, and it matched nothing.
|
|
61
|
+
/`?(tools?|functions?)`?\s+(is|are)\s+(not\s+(a\s+)?(valid|supported|allowed|available)|unsupported)/i,
|
|
62
|
+
// "this model cannot use tools" / "can't call functions".
|
|
63
|
+
/(cannot|can'?t|is\s+unable\s+to)\s+(use|call|handle|execute)\s+(`?tools?`?|`?functions?`?)/i,
|
|
64
|
+
// "no tool support", "without function support".
|
|
65
|
+
/no\s+(`?tools?`?|`?functions?`?)\s+support/i,
|
|
46
66
|
];
|
|
47
67
|
|
|
48
68
|
/**
|
|
49
69
|
* Does this error body explicitly say the model cannot do tools?
|
|
50
70
|
*
|
|
51
|
-
* Conservative on purpose — see the header.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
71
|
+
* Conservative on purpose — see the header. Both failures are real and neither
|
|
72
|
+
* is free: a false positive downgrades a working model for a reason nobody can
|
|
73
|
+
* see, and a false negative leaves the caller repeating a request it will
|
|
74
|
+
* never learn from. Match on "names tools AND negates support", nothing looser.
|
|
54
75
|
*/
|
|
55
76
|
export function saysToolsUnsupported(body: string): boolean {
|
|
56
77
|
if (!body) return false;
|
package/src/complete.ts
CHANGED
|
@@ -61,11 +61,34 @@ import { ChainExhaustedError, type ChainAttemptFailure } from "./attempt.js";
|
|
|
61
61
|
import type { HealthTracker } from "./health.js";
|
|
62
62
|
import { classifyRateLimit, retryAfterSeconds, type RateLimitKind } from "./limits.js";
|
|
63
63
|
import { readQuota, readingFromRefusal, type QuotaReading } from "./meter.js";
|
|
64
|
+
import { parseTextToolCalls, stripToolCallLines, toolNamesFrom } from "./tool-protocol.js";
|
|
64
65
|
|
|
65
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* A piece of a message, for the models that accept more than text.
|
|
68
|
+
*
|
|
69
|
+
* The same shape every provider here already speaks, because it is OpenAI's —
|
|
70
|
+
* `image_url.url` takes a `data:` URL or an `https:` one.
|
|
71
|
+
*/
|
|
72
|
+
export type ContentPart =
|
|
73
|
+
| { type: "text"; text: string }
|
|
74
|
+
| { type: "image_url"; image_url: { url: string; detail?: "auto" | "low" | "high" } };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One message in the OpenAI chat-completions shape every provider here speaks.
|
|
78
|
+
*
|
|
79
|
+
* `content` accepts parts as well as a string because the implementation
|
|
80
|
+
* always carried them: `callLink` forwards `messages` into the request body
|
|
81
|
+
* untouched, so a multimodal message has worked at runtime since the first
|
|
82
|
+
* release while the type insisted it could not. A consumer that needed to send
|
|
83
|
+
* a screenshot therefore had to cast around our own type — and a cast written
|
|
84
|
+
* to work around a library is a thing the next consumer copies.
|
|
85
|
+
*
|
|
86
|
+
* Backward compatible by construction: every existing caller passes a string,
|
|
87
|
+
* and a string is still a `ChatMessage["content"]`.
|
|
88
|
+
*/
|
|
66
89
|
export interface ChatMessage {
|
|
67
90
|
role: "system" | "user" | "assistant" | "tool";
|
|
68
|
-
content: string;
|
|
91
|
+
content: string | ContentPart[];
|
|
69
92
|
/** Present on `role: "tool"` replies; passed through untouched. */
|
|
70
93
|
tool_call_id?: string;
|
|
71
94
|
name?: string;
|
|
@@ -76,9 +99,11 @@ export interface ChatMessage {
|
|
|
76
99
|
* actually answer on.
|
|
77
100
|
*
|
|
78
101
|
* Both exist in the default chain: of nine free models probed live, four
|
|
79
|
-
* answered with native `tool_calls` and five only in text
|
|
80
|
-
*
|
|
81
|
-
*
|
|
102
|
+
* answered with native `tool_calls` and five only in text — and three of the
|
|
103
|
+
* seven models shipped in the chain today are in that second group. Since 1.4.0
|
|
104
|
+
* both are read here, so a caller no longer has to know which half of the chain
|
|
105
|
+
* answered. See `toolProtocol` on CompleteOptions, and tool-protocol.ts for why
|
|
106
|
+
* a native-only read is a fabrication path rather than a missing feature.
|
|
82
107
|
*/
|
|
83
108
|
export interface ToolCall {
|
|
84
109
|
id: string;
|
|
@@ -145,6 +170,23 @@ export interface CompleteOptions {
|
|
|
145
170
|
temperature?: number;
|
|
146
171
|
/** Tool definitions in the OpenAI shape; passed through untouched. */
|
|
147
172
|
tools?: unknown[];
|
|
173
|
+
/**
|
|
174
|
+
* Which tool-call protocols to READ from the reply. Default `"both"`.
|
|
175
|
+
*
|
|
176
|
+
* `"both"` also parses the `TOOL:` / `ARGS:` line protocol out of ordinary
|
|
177
|
+
* content and strips those lines from `text`. This matters more than it
|
|
178
|
+
* sounds: three of the seven models in the default chain cannot emit a native
|
|
179
|
+
* tool call at all, and a native-only read hands their narration back as a
|
|
180
|
+
* finished answer. The turn then reports a lookup that never happened.
|
|
181
|
+
*
|
|
182
|
+
* Parsing is skipped entirely when no `tools` are supplied — a model does not
|
|
183
|
+
* narrate a call it was never offered — so this is inert for the callers who
|
|
184
|
+
* do not use tools, which today is all of them.
|
|
185
|
+
*
|
|
186
|
+
* Set `"native"` only if you parse the text protocol yourself and would
|
|
187
|
+
* otherwise execute each call twice.
|
|
188
|
+
*/
|
|
189
|
+
toolProtocol?: "both" | "native";
|
|
148
190
|
/** Extra body fields for a vendor-specific parameter. Merged last, so it can override. */
|
|
149
191
|
extraBody?: Record<string, unknown>;
|
|
150
192
|
/**
|
|
@@ -432,11 +474,31 @@ async function callLink(
|
|
|
432
474
|
|
|
433
475
|
const choice = (parsed as { choices?: Array<{ message?: Record<string, unknown> }> })
|
|
434
476
|
?.choices?.[0];
|
|
435
|
-
const
|
|
436
|
-
const
|
|
477
|
+
const rawContent = firstText(choice?.message);
|
|
478
|
+
const native = toolCallsFrom(choice?.message);
|
|
479
|
+
|
|
480
|
+
// Read the line protocol out of the prose as well, unless the caller opted
|
|
481
|
+
// out or offered no tools. Native wins on a tie: a model that emits BOTH the
|
|
482
|
+
// real call and a prose echo of it must not run the tool twice — that wastes
|
|
483
|
+
// a round trip and can double-propose an action.
|
|
484
|
+
const wantsText = (options.toolProtocol ?? "both") === "both" && (options.tools?.length ?? 0) > 0;
|
|
485
|
+
const fromText = wantsText
|
|
486
|
+
? parseTextToolCalls(rawContent, toolNamesFrom(options.tools)).filter((c) => {
|
|
487
|
+
const key = `${c.name}:${c.args}`;
|
|
488
|
+
return !native.some((n) => `${n.name}:${n.args}` === key);
|
|
489
|
+
})
|
|
490
|
+
: [];
|
|
491
|
+
|
|
492
|
+
const toolCalls = [...native, ...fromText];
|
|
493
|
+
// Strip the protocol lines only when they were actually read as calls.
|
|
494
|
+
// Removing them without parsing would delete the evidence and leave a shorter
|
|
495
|
+
// hallucination behind, which is worse than either extreme.
|
|
496
|
+
const content = fromText.length > 0 ? stripToolCallLines(rawContent) : rawContent;
|
|
437
497
|
|
|
438
498
|
// A 200 that carries neither text nor a tool call is an outage wearing a
|
|
439
499
|
// success code — see the header. Demote, so the chain gets its chance.
|
|
500
|
+
// Note the ordering: a reply that was ONLY a narrated call is now empty text
|
|
501
|
+
// WITH tool calls, which is a valid turn, not an outage.
|
|
440
502
|
if (content.trim() === "" && toolCalls.length === 0) {
|
|
441
503
|
throw new LinkFailure(
|
|
442
504
|
link,
|
package/src/index.ts
CHANGED
|
@@ -84,6 +84,7 @@ export {
|
|
|
84
84
|
|
|
85
85
|
export {
|
|
86
86
|
type ChatMessage,
|
|
87
|
+
type ContentPart,
|
|
87
88
|
type ToolCall,
|
|
88
89
|
type CompleteOptions,
|
|
89
90
|
type CompleteResult,
|
|
@@ -128,6 +129,15 @@ export {
|
|
|
128
129
|
answersRemaining,
|
|
129
130
|
} from "./meter.js";
|
|
130
131
|
|
|
132
|
+
export {
|
|
133
|
+
type ParsedToolCall,
|
|
134
|
+
TEXT_TOOL_PROTOCOL_HINT,
|
|
135
|
+
parseTextToolCalls,
|
|
136
|
+
stripToolCallLines,
|
|
137
|
+
safeJsonObject,
|
|
138
|
+
toolNamesFrom,
|
|
139
|
+
} from "./tool-protocol.js";
|
|
140
|
+
|
|
131
141
|
export {
|
|
132
142
|
type PoolId,
|
|
133
143
|
type RungId,
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other way models ask for a tool: by writing it in the reply.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY A NATIVE-ONLY CLIENT LOSES MOST OF THIS PACKAGE'S OWN CHAIN ──────────
|
|
5
|
+
*
|
|
6
|
+
* `chain.ts` carries a probe table from nine free models called with a real
|
|
7
|
+
* tool. Four answered with native `tool_calls`. Five answered only in TEXT.
|
|
8
|
+
* Of the seven models in the shipped default chain today, three are in that
|
|
9
|
+
* second group:
|
|
10
|
+
*
|
|
11
|
+
* google/gemma-4-26b-a4b-it:free text
|
|
12
|
+
* cohere/north-mini-code:free text
|
|
13
|
+
* openrouter/free text
|
|
14
|
+
*
|
|
15
|
+
* A client that reads only `message.tool_calls` gets ZERO tool calls from those
|
|
16
|
+
* three — and, far worse, gets the model's narration of the call as ordinary
|
|
17
|
+
* content. So a turn that should have looked up a record instead returns a
|
|
18
|
+
* confident sentence describing the lookup it did not perform, and every layer
|
|
19
|
+
* downstream treats it as an answer. That is not a missing feature; it is a
|
|
20
|
+
* fabrication path, and it is open on nearly half the chain this package ships.
|
|
21
|
+
*
|
|
22
|
+
* ── WHY LINE-BASED AND NOT NESTED JSON ───────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* The format is chosen for the WEAKEST model expected to run it, not the
|
|
25
|
+
* strongest. An 8B model reliably reproduces two flat lines:
|
|
26
|
+
*
|
|
27
|
+
* TOOL: search_people
|
|
28
|
+
* ARGS: {"query": "Elena"}
|
|
29
|
+
*
|
|
30
|
+
* The same model routinely breaks nested-JSON escaping. Every leniency in the
|
|
31
|
+
* parser below is a shape a small model actually emitted in production — bolded
|
|
32
|
+
* keys because it was writing markdown, a fenced ARGS block, the parentheses
|
|
33
|
+
* copied from the example, a missing ARGS line for a no-argument tool. Rejecting
|
|
34
|
+
* any of them would fail the turn over formatting, which is the exact failure
|
|
35
|
+
* this protocol exists to avoid.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** The tool-call shape, matching `complete`'s. `args` stays a raw JSON string. */
|
|
39
|
+
export interface ParsedToolCall {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
args: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The convention, as prompt text.
|
|
47
|
+
*
|
|
48
|
+
* Exported so apps stop writing their own wording and drifting from the parser
|
|
49
|
+
* that has to read it back. A prompt and its parser are one contract; keeping
|
|
50
|
+
* them in separate repos is how they diverge.
|
|
51
|
+
*
|
|
52
|
+
* Note what it does NOT say: it never tells the model to use this INSTEAD of
|
|
53
|
+
* native calling. A model with native support should use it, and this is the
|
|
54
|
+
* fallback for one that cannot — offering both costs a few tokens and covers
|
|
55
|
+
* both halves of the chain.
|
|
56
|
+
*/
|
|
57
|
+
export const TEXT_TOOL_PROTOCOL_HINT = [
|
|
58
|
+
"If you cannot emit a native tool call, call a tool by writing these two lines in your reply, exactly like this:",
|
|
59
|
+
"",
|
|
60
|
+
"TOOL: tool_name",
|
|
61
|
+
'ARGS: {"argument": "value"}',
|
|
62
|
+
"",
|
|
63
|
+
"Write nothing else in a reply that calls tools — you will be given the results and asked again.",
|
|
64
|
+
].join("\n");
|
|
65
|
+
|
|
66
|
+
/** Matches a TOOL: line, tolerating list bullets, markdown bold, and `=`. */
|
|
67
|
+
const TOOL_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?TOOL(?:\*\*)?\s*[:=]\s*(.+?)\s*$/i;
|
|
68
|
+
/** Matches an ARGS: line, same tolerances. */
|
|
69
|
+
const ARGS_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?ARGS(?:\*\*)?\s*[:=]\s*(.*)$/i;
|
|
70
|
+
/** Either key, for the strip pass. */
|
|
71
|
+
const EITHER_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?(?:TOOL|ARGS)(?:\*\*)?\s*[:=]/i;
|
|
72
|
+
|
|
73
|
+
/** How far past a TOOL: line to look for its ARGS:, and how far to join a multi-line object. */
|
|
74
|
+
const ARGS_SEARCH_LINES = 4;
|
|
75
|
+
const ARGS_JOIN_LINES = 8;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse a JSON object, tolerating fences and trailing prose. Null if hopeless.
|
|
79
|
+
*
|
|
80
|
+
* `null` and `{}` must stay distinguishable. Returning `{}` for "nothing
|
|
81
|
+
* parseable here" satisfies a caller's `?? {}` fallback and silently discards
|
|
82
|
+
* arguments the model put on the NEXT line — which is exactly what a model does
|
|
83
|
+
* when it opens a ```json fence after `ARGS:`. That cost a debugging cycle once
|
|
84
|
+
* already; it is a test here now.
|
|
85
|
+
*/
|
|
86
|
+
export function safeJsonObject(raw: string): Record<string, unknown> | null {
|
|
87
|
+
const cleaned = raw
|
|
88
|
+
.trim()
|
|
89
|
+
.replace(/^```(?:json)?/i, "")
|
|
90
|
+
.replace(/```$/, "")
|
|
91
|
+
.trim();
|
|
92
|
+
if (!cleaned) return null;
|
|
93
|
+
if (cleaned === "{}") return {};
|
|
94
|
+
|
|
95
|
+
const start = cleaned.indexOf("{");
|
|
96
|
+
if (start === -1) return null;
|
|
97
|
+
|
|
98
|
+
// Walk to the matching brace so trailing commentary does not break the parse.
|
|
99
|
+
// String-aware: a brace inside a quoted value must not change the depth, or
|
|
100
|
+
// an argument like {"q": "a } b"} truncates at the wrong place.
|
|
101
|
+
let depth = 0;
|
|
102
|
+
let inString = false;
|
|
103
|
+
let escaped = false;
|
|
104
|
+
for (let i = start; i < cleaned.length; i++) {
|
|
105
|
+
const ch = cleaned[i];
|
|
106
|
+
if (escaped) {
|
|
107
|
+
escaped = false;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ch === "\\") {
|
|
111
|
+
escaped = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (ch === '"') {
|
|
115
|
+
inString = !inString;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (inString) continue;
|
|
119
|
+
if (ch === "{") depth++;
|
|
120
|
+
else if (ch === "}") {
|
|
121
|
+
depth--;
|
|
122
|
+
if (depth === 0) {
|
|
123
|
+
try {
|
|
124
|
+
const parsed: unknown = JSON.parse(cleaned.slice(start, i + 1));
|
|
125
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
126
|
+
? (parsed as Record<string, unknown>)
|
|
127
|
+
: null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Extract text-protocol calls from a reply.
|
|
139
|
+
*
|
|
140
|
+
* `validNames` is the closed set the model was offered. A line naming anything
|
|
141
|
+
* else is left alone: without that check, a model writing the words "TOOL:
|
|
142
|
+
* whatever" in prose would manufacture a call to a tool that does not exist,
|
|
143
|
+
* and the executor would report a failure the model never asked for.
|
|
144
|
+
*/
|
|
145
|
+
export function parseTextToolCalls(text: string, validNames: string[]): ParsedToolCall[] {
|
|
146
|
+
if (!text || validNames.length === 0) return [];
|
|
147
|
+
const calls: ParsedToolCall[] = [];
|
|
148
|
+
const valid = new Set(validNames);
|
|
149
|
+
const lines = text.split(/\r?\n/);
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < lines.length; i++) {
|
|
152
|
+
const line = lines[i] ?? "";
|
|
153
|
+
const m = TOOL_LINE.exec(line);
|
|
154
|
+
const rawName = m?.[1];
|
|
155
|
+
if (!rawName) continue;
|
|
156
|
+
|
|
157
|
+
// Tolerate `name(...)`, backticks, and trailing punctuation copied from prose.
|
|
158
|
+
const name = rawName
|
|
159
|
+
.replace(/[`*]/g, "")
|
|
160
|
+
.replace(/\(.*$/, "")
|
|
161
|
+
.replace(/[.,;]$/, "")
|
|
162
|
+
.trim();
|
|
163
|
+
if (!valid.has(name)) continue;
|
|
164
|
+
|
|
165
|
+
let args = "{}";
|
|
166
|
+
for (let j = i + 1; j < Math.min(i + ARGS_SEARCH_LINES, lines.length); j++) {
|
|
167
|
+
const next = lines[j] ?? "";
|
|
168
|
+
const a = ARGS_LINE.exec(next);
|
|
169
|
+
if (a) {
|
|
170
|
+
// The object may continue past this line: a model opening a ```json
|
|
171
|
+
// fence puts the brace on the NEXT line, and a pretty-printed object
|
|
172
|
+
// spans several. Join forward and let the brace matcher find the end.
|
|
173
|
+
const rest = lines.slice(j, Math.min(j + ARGS_JOIN_LINES, lines.length)).join("\n");
|
|
174
|
+
const parsed =
|
|
175
|
+
safeJsonObject(a[1] ?? "") ?? safeJsonObject(rest.replace(/^[^:=]*[:=]/, ""));
|
|
176
|
+
if (parsed) args = JSON.stringify(parsed);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
// A new TOOL line means this call simply had no arguments.
|
|
180
|
+
if (TOOL_LINE.test(next)) break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
calls.push({ id: `text_${calls.length}_${name}`, name, args });
|
|
184
|
+
}
|
|
185
|
+
return calls;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Remove protocol lines from prose.
|
|
190
|
+
*
|
|
191
|
+
* A narrated call must never reach the user as though it were an answer, which
|
|
192
|
+
* is the whole failure this module exists to close. Stripping also means a reply
|
|
193
|
+
* that was ONLY a tool call ends up empty — and an empty reply carrying tool
|
|
194
|
+
* calls is a valid turn, while an empty reply carrying none is an outage. The
|
|
195
|
+
* caller has to keep those apart.
|
|
196
|
+
*/
|
|
197
|
+
export function stripToolCallLines(text: string): string {
|
|
198
|
+
return text
|
|
199
|
+
.split(/\r?\n/)
|
|
200
|
+
.filter((l) => !EITHER_LINE.test(l))
|
|
201
|
+
.join("\n")
|
|
202
|
+
.replace(/```(?:json)?\s*```/g, "")
|
|
203
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
204
|
+
.trim();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The tool names inside an OpenAI-shaped `tools` array.
|
|
209
|
+
*
|
|
210
|
+
* Derived from what the caller already passed rather than asked for separately:
|
|
211
|
+
* two lists that must agree are one list that cannot disagree, and the failure
|
|
212
|
+
* of the split version is silent — a name missing from the second list makes
|
|
213
|
+
* the parser ignore a real call.
|
|
214
|
+
*/
|
|
215
|
+
export function toolNamesFrom(tools: unknown[] | undefined): string[] {
|
|
216
|
+
if (!Array.isArray(tools)) return [];
|
|
217
|
+
const names: string[] = [];
|
|
218
|
+
for (const entry of tools) {
|
|
219
|
+
const fn = (entry as { function?: { name?: unknown } } | null)?.function;
|
|
220
|
+
if (fn && typeof fn.name === "string" && fn.name) names.push(fn.name);
|
|
221
|
+
}
|
|
222
|
+
return names;
|
|
223
|
+
}
|