@bitbaum/ai-kit 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -333,6 +333,67 @@ It does not summarise, re-rank with an LLM, crawl, render JavaScript, or cache.
333
333
  The first two are the model's job and belong upstream where the app's prompt
334
334
  lives; the last three are a different product with a different cost profile.
335
335
 
336
+ ### What can this model do? — observe, never assert
337
+
338
+ ```ts
339
+ import {
340
+ planToolAttempt,
341
+ classifyToolAttempt,
342
+ claimableVerdict,
343
+ currentVerdict,
344
+ makeRecord,
345
+ scopeKey,
346
+ } from "@bitbaum/ai-kit/capability";
347
+
348
+ const observed = currentVerdict(await store.get(key)); // "native" | "text" | "none" | "unobserved"
349
+ const plan = planToolAttempt({ observed }); // what to put on the wire
350
+
351
+ const res = await fetch(endpoint, {
352
+ /* … tools attached when plan.sendTools … */
353
+ });
354
+ const seen = classifyToolAttempt({ status: res.status, parsed, bodyText, textProtocolFound });
355
+ if (seen.record) await store.put(makeRecord({ ...key, verdict: seen.verdict, via: "live" }));
356
+ ```
357
+
358
+ This replaces the line every app writes and every app gets wrong:
359
+
360
+ ```ts
361
+ const TOOL_CAPABLE_PROVIDERS = ["groq", "openrouter"]; // wrong tomorrow
362
+ ```
363
+
364
+ That list is wrong the moment a user brings a model nobody has heard of, which
365
+ is every day. It is also wrong in the other direction: it cannot express that
366
+ **five of nine** free models probed here answer tools only in prose, so a
367
+ native-only client loses most of its chain while believing it is fine.
368
+
369
+ **The first real call is the probe.** Send the tools, read what comes back,
370
+ write down what it proved. Every model a user brings classifies itself on its
371
+ first message, at no extra cost and with no release from us. Nothing here
372
+ spends a separate request, which matters most when the key is the user's.
373
+
374
+ **A positive is cheap; a negative is expensive and sticky.** One `tool_calls`
375
+ response proves capability outright. A 400 proves nothing *unless the vendor
376
+ says it is about tools* — a context-length overflow, a content filter or a bad
377
+ parameter must be recorded as nothing at all, because writing one down as "no
378
+ tools" cripples a capable model until the record expires and nothing in the
379
+ product explains why. Hence `record: false`: the right response to an
380
+ uninformative failure is to learn nothing, not to guess.
381
+
382
+ **"Never asked" is its own answer, read two ways.** `planToolAttempt` is
383
+ optimistic about it, because asking is the only way to learn. `claimableVerdict`
384
+ is pessimistic about it, because announcing a capability a model has never
385
+ demonstrated is a promise it may not keep, and the user meets that as a broken
386
+ feature rather than a missing one. A single boolean cannot hold both, which is
387
+ the whole reason this module exists.
388
+
389
+ Records expire, and negatives expire sooner than positives: a model that gained
390
+ tool support and is still marked incapable is invisibly crippled, while one that
391
+ lost it says so loudly on the next call. Observations are keyed by a **hash** of
392
+ the credential, never the credential, because capability genuinely differs per
393
+ key and observations must not leak across them.
394
+
395
+ Storage stays yours — a table, a KV, a file. This owns the shape and the rules.
396
+
336
397
  ---
337
398
 
338
399
  ## What it deliberately does not ship
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Reading a real response for what it proves about capability.
3
+ *
4
+ * The whole design rests on one asymmetry:
5
+ *
6
+ * A POSITIVE is cheap. One response carrying `tool_calls` proves, beyond
7
+ * argument, that this model on this provider with this key can call tools.
8
+ * Write it down immediately.
9
+ *
10
+ * A NEGATIVE is expensive and sticky. If a 400 gets recorded as "this model
11
+ * has no tools", that model is crippled until the record expires — and the
12
+ * user sees a capable model behaving like a toy with nothing explaining why.
13
+ * So a negative requires the vendor to SAY it is about tools. A 400 for a
14
+ * context-length overflow, a malformed parameter, a content filter or a
15
+ * billing problem proves nothing about tools and must be recorded as
16
+ * nothing at all.
17
+ *
18
+ * That asymmetry is why `record: false` exists. Most failures are
19
+ * uninformative, and the correct response to an uninformative failure is to
20
+ * learn nothing, not to guess.
21
+ */
22
+ import type { Classification } from "./types.js";
23
+ /**
24
+ * Does this error body explicitly say the model cannot do tools?
25
+ *
26
+ * Conservative on purpose — see the header. A false positive here is a model
27
+ * permanently downgraded for a reason nobody can see; a false negative just
28
+ * means we ask again next time, which costs one request.
29
+ */
30
+ export declare function saysToolsUnsupported(body: string): boolean;
31
+ export type ToolAttempt = {
32
+ /** HTTP status. 0 or undefined for a transport failure. */
33
+ status?: number;
34
+ /** Parsed JSON body, when there was one. */
35
+ parsed?: unknown;
36
+ /** Raw body text. Used only for error classification. */
37
+ bodyText?: string;
38
+ /**
39
+ * Did the caller's own text-protocol parser find a usable tool call in the
40
+ * assistant's prose? Only the app knows its envelope, so it answers this.
41
+ * Absent means "not checked", which is not the same as "no".
42
+ */
43
+ textProtocolFound?: boolean;
44
+ };
45
+ /**
46
+ * What does this attempt prove?
47
+ *
48
+ * Call it after EVERY request that carried tool definitions. Real traffic then
49
+ * classifies every model a user brings, on its first message, at no extra cost
50
+ * — which is the property that makes this scale to models nobody has heard of.
51
+ */
52
+ export declare function classifyToolAttempt(attempt: ToolAttempt): Classification;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Phrases that mean "this model does not do tools", conservatively.
3
+ *
4
+ * Every entry names tools or functions explicitly. Deliberately absent:
5
+ * "invalid request", "bad parameter", "unsupported" on its own — each of those
6
+ * appears in vendor 400s for a dozen unrelated reasons, and a match on one of
7
+ * them would silently disable a working model. When in doubt the answer is to
8
+ * record nothing; an unobserved model gets asked again on the next message,
9
+ * whereas a wrongly-negative one does not.
10
+ */
11
+ const TOOLS_UNSUPPORTED_PATTERNS = [
12
+ /tool[\s_-]?(use|call|calls|calling)\s+(is\s+)?(not|un)[\s_-]?support/i,
13
+ /does\s+not\s+support\s+tool/i,
14
+ /doesn'?t\s+support\s+tool/i,
15
+ /no\s+support\s+for\s+tool/i,
16
+ /function[\s_-]?call(ing)?\s+(is\s+)?(not|un)[\s_-]?support/i,
17
+ /does\s+not\s+support\s+function/i,
18
+ /doesn'?t\s+support\s+function/i,
19
+ /model\s+.{0,60}?\s+does\s+not\s+support\s+(the\s+)?(`?tools`?|`?functions`?)/i,
20
+ /unsupported\s+parameter:?\s*'?"?tools?"?'?/i,
21
+ /unknown\s+(field|parameter):?\s*'?"?tools?"?'?/i,
22
+ /`?tools`?\s+is\s+not\s+(a\s+)?(valid|supported|allowed)/i,
23
+ ];
24
+ /**
25
+ * Does this error body explicitly say the model cannot do tools?
26
+ *
27
+ * Conservative on purpose — see the header. A false positive here is a model
28
+ * permanently downgraded for a reason nobody can see; a false negative just
29
+ * means we ask again next time, which costs one request.
30
+ */
31
+ export function saysToolsUnsupported(body) {
32
+ if (!body)
33
+ return false;
34
+ return TOOLS_UNSUPPORTED_PATTERNS.some((re) => re.test(body));
35
+ }
36
+ /**
37
+ * What does this attempt prove?
38
+ *
39
+ * Call it after EVERY request that carried tool definitions. Real traffic then
40
+ * classifies every model a user brings, on its first message, at no extra cost
41
+ * — which is the property that makes this scale to models nobody has heard of.
42
+ */
43
+ export function classifyToolAttempt(attempt) {
44
+ const { status, parsed, bodyText = "", textProtocolFound } = attempt;
45
+ // ── transport failures prove nothing ────────────────────────────────────
46
+ if (!status) {
47
+ return { verdict: "unobserved", record: false, evidence: "no response" };
48
+ }
49
+ // ── the vendor refused, and we must be careful about why ────────────────
50
+ if (status >= 400) {
51
+ if (saysToolsUnsupported(bodyText)) {
52
+ return {
53
+ verdict: "none",
54
+ record: true,
55
+ evidence: `${status}: the vendor says this model does not support tools`,
56
+ };
57
+ }
58
+ // Everything else — 429, 401, 500, a 400 about context length or a bad
59
+ // parameter — says nothing about tools. Learning nothing is correct.
60
+ return {
61
+ verdict: "unobserved",
62
+ record: false,
63
+ evidence: `${status}: not a statement about tool support`,
64
+ };
65
+ }
66
+ // ── a success: did it actually call a tool? ─────────────────────────────
67
+ const body = (parsed ?? {});
68
+ const choice = body.choices?.[0];
69
+ const toolCalls = choice?.message?.tool_calls;
70
+ if (Array.isArray(toolCalls) && toolCalls.length > 0) {
71
+ return { verdict: "native", record: true, evidence: "returned tool_calls" };
72
+ }
73
+ if (choice?.finish_reason === "tool_calls") {
74
+ return { verdict: "native", record: true, evidence: "finish_reason was tool_calls" };
75
+ }
76
+ // The app's own envelope parser found a call in the prose. That is the text
77
+ // protocol, and it is a real capability — five of nine free models probed in
78
+ // this fleet answer only this way.
79
+ if (textProtocolFound === true) {
80
+ return {
81
+ verdict: "text",
82
+ record: true,
83
+ evidence: "no tool_calls, but a tool call was parsed from the text",
84
+ };
85
+ }
86
+ // A successful answer with no tool call is the ambiguous case, and the
87
+ // ambiguity is real: the model may be incapable, or it may simply have
88
+ // decided no tool was needed — which is the correct behaviour for most
89
+ // messages. Treating this as evidence of incapacity would mark almost every
90
+ // model `none` within a few turns of ordinary chat.
91
+ return {
92
+ verdict: "unobserved",
93
+ record: false,
94
+ evidence: "answered without calling a tool, which is not evidence either way",
95
+ };
96
+ }
@@ -0,0 +1,75 @@
1
+ import type { CapabilityKind, CapabilityRecord, ToolVerdict } from "./types.js";
2
+ /**
3
+ * How long an observation stands before it must be re-earned.
4
+ *
5
+ * Models change under their own names — a vendor updates the weights behind an
6
+ * alias, an org enables a feature, a local user swaps a quantization. A record
7
+ * with no expiry is a hardcoded list again, just one we wrote ourselves.
8
+ *
9
+ * Negatives expire sooner than positives: a model that gained tool support and
10
+ * is still marked `none` is invisibly crippled, while a model that lost it
11
+ * announces itself loudly on the next call.
12
+ */
13
+ export declare const DEFAULT_TTL_MS: {
14
+ readonly native: number;
15
+ readonly text: number;
16
+ readonly none: number;
17
+ readonly unobserved: 0;
18
+ };
19
+ export declare function isStale(record: Pick<CapabilityRecord, "verdict" | "observedAt">, now?: Date, ttl?: Partial<Record<ToolVerdict, number>>): boolean;
20
+ /** The verdict a record still supports, or `unobserved` once it has expired. */
21
+ export declare function currentVerdict(record: CapabilityRecord | null | undefined, now?: Date, ttl?: Partial<Record<ToolVerdict, number>>): ToolVerdict;
22
+ export type ToolPlan = {
23
+ /** Put tool definitions on the request? */
24
+ sendTools: boolean;
25
+ /** Parse the prose for a tool envelope as well? */
26
+ expectTextProtocol: boolean;
27
+ /** True when this request is also the thing that will teach us. */
28
+ isLearning: boolean;
29
+ reason: string;
30
+ };
31
+ /**
32
+ * What to send. Optimistic about the unknown, because asking is how we learn
33
+ * and a declared prior is only a guess about where to start.
34
+ */
35
+ export declare function planToolAttempt(input: {
36
+ observed: ToolVerdict;
37
+ /** What a registry or the vendor's docs claim. A prior, never a fact. */
38
+ declared?: ToolVerdict;
39
+ }): ToolPlan;
40
+ /**
41
+ * What we may TELL the user, and what the prompt may claim.
42
+ *
43
+ * Pessimistic about the unknown. `unobserved` returns `none` here on purpose:
44
+ * until a model has demonstrated a capability, an assistant that announces it
45
+ * is writing a cheque the model may not honour, and the user discovers that as
46
+ * a broken promise rather than as a missing feature.
47
+ */
48
+ export declare function claimableVerdict(observed: ToolVerdict): Exclude<ToolVerdict, "unobserved">;
49
+ /**
50
+ * A stable, non-reversible handle for the credential an observation was made
51
+ * through. Capability differs per key, so observations must not leak across
52
+ * keys — and the key itself must never be stored to achieve that.
53
+ */
54
+ export declare function scopeKey(secret: string | undefined | null): string;
55
+ /** Build a record from a classification. Keeps `observedAt` in one place. */
56
+ export declare function makeRecord(input: {
57
+ provider: string;
58
+ model: string;
59
+ scope: string;
60
+ capability: CapabilityKind;
61
+ verdict: ToolVerdict;
62
+ via: CapabilityRecord["via"];
63
+ evidence?: string;
64
+ now?: Date;
65
+ }): CapabilityRecord;
66
+ /**
67
+ * Should a new observation overwrite the stored one?
68
+ *
69
+ * Strength beats age, and `live` beats `declared`, so a real call always
70
+ * overrules a registry guess. Between two observations of equal provenance the
71
+ * newer wins — including a `none` replacing a `native`, because a model really
72
+ * can lose a capability and refusing to believe that is how a chain keeps
73
+ * calling something that no longer works.
74
+ */
75
+ export declare function shouldReplace(existing: CapabilityRecord | null | undefined, incoming: CapabilityRecord): boolean;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * What to send this time, and what to tell the user we can do.
3
+ *
4
+ * Two different questions, deliberately separated:
5
+ *
6
+ * `planToolAttempt` decides what to PUT ON THE WIRE. It is optimistic about
7
+ * an unobserved model, because the only way to learn is to ask, and the cost
8
+ * of asking is one request that may ignore the tools.
9
+ *
10
+ * `claimableVerdict` decides what to SAY. It is pessimistic about an
11
+ * unobserved model, because promising a capability we have never seen is how
12
+ * an assistant comes to announce an action it cannot perform.
13
+ *
14
+ * Those two pulling in opposite directions is the whole point. A single
15
+ * "supportsTools" boolean cannot express it, and every app that has tried has
16
+ * either refused to learn or lied to its users.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ /**
20
+ * How long an observation stands before it must be re-earned.
21
+ *
22
+ * Models change under their own names — a vendor updates the weights behind an
23
+ * alias, an org enables a feature, a local user swaps a quantization. A record
24
+ * with no expiry is a hardcoded list again, just one we wrote ourselves.
25
+ *
26
+ * Negatives expire sooner than positives: a model that gained tool support and
27
+ * is still marked `none` is invisibly crippled, while a model that lost it
28
+ * announces itself loudly on the next call.
29
+ */
30
+ export const DEFAULT_TTL_MS = {
31
+ native: 30 * 24 * 60 * 60 * 1000,
32
+ text: 30 * 24 * 60 * 60 * 1000,
33
+ none: 7 * 24 * 60 * 60 * 1000,
34
+ unobserved: 0,
35
+ };
36
+ export function isStale(record, now = new Date(), ttl = {}) {
37
+ const limit = ttl[record.verdict] ?? DEFAULT_TTL_MS[record.verdict];
38
+ if (!limit)
39
+ return true;
40
+ const age = now.getTime() - new Date(record.observedAt).getTime();
41
+ return !Number.isFinite(age) || age > limit;
42
+ }
43
+ /** The verdict a record still supports, or `unobserved` once it has expired. */
44
+ export function currentVerdict(record, now = new Date(), ttl = {}) {
45
+ if (!record)
46
+ return "unobserved";
47
+ return isStale(record, now, ttl) ? "unobserved" : record.verdict;
48
+ }
49
+ /**
50
+ * What to send. Optimistic about the unknown, because asking is how we learn
51
+ * and a declared prior is only a guess about where to start.
52
+ */
53
+ export function planToolAttempt(input) {
54
+ const { observed, declared } = input;
55
+ if (observed === "native") {
56
+ return {
57
+ sendTools: true,
58
+ expectTextProtocol: false,
59
+ isLearning: false,
60
+ reason: "observed to return tool_calls",
61
+ };
62
+ }
63
+ if (observed === "text") {
64
+ return {
65
+ sendTools: false,
66
+ expectTextProtocol: true,
67
+ isLearning: false,
68
+ reason: "observed to answer tools only in prose",
69
+ };
70
+ }
71
+ if (observed === "none") {
72
+ return {
73
+ sendTools: false,
74
+ expectTextProtocol: false,
75
+ isLearning: false,
76
+ reason: "the vendor said this model does not support tools",
77
+ };
78
+ }
79
+ // Unobserved. Ask — and accept EITHER answer, because a model that ignores
80
+ // the definitions and writes the envelope in prose is capable, just not
81
+ // natively, and a native-only client silently loses most of a free chain.
82
+ return {
83
+ sendTools: declared !== "none",
84
+ expectTextProtocol: true,
85
+ isLearning: true,
86
+ reason: declared === "none"
87
+ ? "never observed, and the registry says no — asking in prose only"
88
+ : "never observed — this request is also the probe",
89
+ };
90
+ }
91
+ /**
92
+ * What we may TELL the user, and what the prompt may claim.
93
+ *
94
+ * Pessimistic about the unknown. `unobserved` returns `none` here on purpose:
95
+ * until a model has demonstrated a capability, an assistant that announces it
96
+ * is writing a cheque the model may not honour, and the user discovers that as
97
+ * a broken promise rather than as a missing feature.
98
+ */
99
+ export function claimableVerdict(observed) {
100
+ return observed === "unobserved" ? "none" : observed;
101
+ }
102
+ /**
103
+ * A stable, non-reversible handle for the credential an observation was made
104
+ * through. Capability differs per key, so observations must not leak across
105
+ * keys — and the key itself must never be stored to achieve that.
106
+ */
107
+ export function scopeKey(secret) {
108
+ if (!secret)
109
+ return "anonymous";
110
+ return createHash("sha256").update(secret).digest("hex").slice(0, 16);
111
+ }
112
+ /** Build a record from a classification. Keeps `observedAt` in one place. */
113
+ export function makeRecord(input) {
114
+ return {
115
+ provider: input.provider,
116
+ model: input.model,
117
+ scope: input.scope,
118
+ capability: input.capability,
119
+ verdict: input.verdict,
120
+ via: input.via,
121
+ observedAt: (input.now ?? new Date()).toISOString(),
122
+ ...(input.evidence ? { evidence: input.evidence } : {}),
123
+ };
124
+ }
125
+ /**
126
+ * Should a new observation overwrite the stored one?
127
+ *
128
+ * Strength beats age, and `live` beats `declared`, so a real call always
129
+ * overrules a registry guess. Between two observations of equal provenance the
130
+ * newer wins — including a `none` replacing a `native`, because a model really
131
+ * can lose a capability and refusing to believe that is how a chain keeps
132
+ * calling something that no longer works.
133
+ */
134
+ export function shouldReplace(existing, incoming) {
135
+ if (!existing)
136
+ return true;
137
+ const rank = { declared: 0, probe: 1, live: 2 };
138
+ if (rank[incoming.via] > rank[existing.via])
139
+ return true;
140
+ if (rank[incoming.via] < rank[existing.via])
141
+ return false;
142
+ return new Date(incoming.observedAt).getTime() >= new Date(existing.observedAt).getTime();
143
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * ai-kit/capability — what a model can do, observed rather than declared.
3
+ *
4
+ * Replaces the list every app writes and every app gets wrong:
5
+ *
6
+ * const TOOL_CAPABLE_PROVIDERS = ['groq', 'openrouter'];
7
+ *
8
+ * That line is wrong the moment a user brings a model nobody on the team has
9
+ * heard of, which is every day. It is also wrong in the other direction: it
10
+ * cannot express that five of nine free models answer tools only in prose, so
11
+ * a native-only client silently loses most of its chain while believing it is
12
+ * fine.
13
+ *
14
+ * The replacement is not a better list. It is three rules:
15
+ *
16
+ * 1. The first real call IS the probe. Send tools, read what comes back,
17
+ * write down what it proved. Every model a user brings classifies itself
18
+ * on its first message, at no extra cost and with no release from us.
19
+ * 2. A positive is cheap and a negative is expensive. One `tool_calls`
20
+ * proves capability. A 400 proves nothing unless the vendor SAYS it is
21
+ * about tools — otherwise a context-length overflow permanently cripples
22
+ * a capable model and nothing explains why.
23
+ * 3. "Never asked" is its own answer. Optimistic on the wire, because asking
24
+ * is how we learn; pessimistic in the prompt and the UI, because
25
+ * announcing an unproven capability is a promise the model may not keep.
26
+ *
27
+ * Storage stays with the app — a table, a KV, a file. This package owns the
28
+ * shape and the rules, which is the part everyone gets wrong; it does not own
29
+ * where rows live, which is the part where apps legitimately differ.
30
+ */
31
+ export type { ToolVerdict, CapabilityKind, Provenance, CapabilityRecord, CapabilityStore, Classification, } from "./types.js";
32
+ export { classifyToolAttempt, saysToolsUnsupported, type ToolAttempt } from "./classify.js";
33
+ export { planToolAttempt, claimableVerdict, currentVerdict, isStale, makeRecord, scopeKey, shouldReplace, DEFAULT_TTL_MS, type ToolPlan, } from "./decide.js";
@@ -0,0 +1,2 @@
1
+ export { classifyToolAttempt, saysToolsUnsupported } from "./classify.js";
2
+ export { planToolAttempt, claimableVerdict, currentVerdict, isStale, makeRecord, scopeKey, shouldReplace, DEFAULT_TTL_MS, } from "./decide.js";
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What a model can actually do, as OBSERVED rather than as claimed.
3
+ *
4
+ * The problem this exists for: capability is not a property of a model name.
5
+ * It is a property of a model, on a provider, through a particular deployment,
6
+ * reached with a particular credential. A quantized local build drops tool
7
+ * support the upstream weights have. A proxy strips `tool_calls`. An org's key
8
+ * has vision disabled. A vendor updates a model in place behind an alias. None
9
+ * of that is knowable from a name, and every one of it is knowable by asking
10
+ * once.
11
+ *
12
+ * So this module holds no list of models. It holds the shape of an observation,
13
+ * the rules for turning a real call into one, and the decision of what to send
14
+ * next time. The list every app is tempted to write — "these providers support
15
+ * tools" — is the thing being replaced: it was wrong the day a user brought a
16
+ * model nobody had heard of, which is every day.
17
+ */
18
+ /**
19
+ * How a model answers a request carrying tool definitions.
20
+ *
21
+ * Four values, and the fourth is the one that matters. `unobserved` is not a
22
+ * synonym for `none`: it means nobody has ever asked, and a system that treats
23
+ * it as `none` silently disables tools for every model it has not met yet,
24
+ * while a system that treats it as `native` promises a capability it cannot
25
+ * demonstrate. It has to stay its own answer all the way to the user.
26
+ */
27
+ export type ToolVerdict = "native" | "text" | "none" | "unobserved";
28
+ /** What a single capability question is asked about. */
29
+ export type CapabilityKind = "tools" | "vision";
30
+ /** How we came to believe something, ordered weakest to strongest. */
31
+ export type Provenance =
32
+ /** The vendor's docs or a hand-maintained registry. A prior, never a fact. */
33
+ "declared"
34
+ /** A deliberate probe request made to answer this question. */
35
+ | "probe"
36
+ /** Real traffic the user asked for, which answered it for free. */
37
+ | "live";
38
+ export type CapabilityRecord = {
39
+ provider: string;
40
+ model: string;
41
+ /**
42
+ * Which credential this was observed through — a HASH, never the key.
43
+ * Capability differs per key (an org with vision disabled, a proxy that
44
+ * strips tool calls), so an observation made with one credential is not
45
+ * evidence about another.
46
+ */
47
+ scope: string;
48
+ capability: CapabilityKind;
49
+ verdict: ToolVerdict;
50
+ /** ISO 8601. Used for staleness; models change under their own names. */
51
+ observedAt: string;
52
+ via: Provenance;
53
+ /** Short, human-readable reason. Goes in logs and in the UI. */
54
+ evidence?: string;
55
+ };
56
+ /**
57
+ * Storage is the app's problem — a table, a KV, a file. This package owns the
58
+ * shape and the rules, because those are what every app gets wrong; it does not
59
+ * own where the rows live, because that is the one part where apps legitimately
60
+ * differ.
61
+ */
62
+ export type CapabilityStore = {
63
+ get(key: {
64
+ provider: string;
65
+ model: string;
66
+ scope: string;
67
+ capability: CapabilityKind;
68
+ }): Promise<CapabilityRecord | null>;
69
+ put(record: CapabilityRecord): Promise<void>;
70
+ };
71
+ /** The outcome of reading one real response for what it says about capability. */
72
+ export type Classification = {
73
+ verdict: ToolVerdict;
74
+ /**
75
+ * Whether this is worth WRITING DOWN. A response can be uninformative —
76
+ * a 429, a 500, a timeout, a 400 about something other than tools — and
77
+ * recording those is how a model gets wrongly marked incapable forever.
78
+ */
79
+ record: boolean;
80
+ evidence: string;
81
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * What a model can actually do, as OBSERVED rather than as claimed.
3
+ *
4
+ * The problem this exists for: capability is not a property of a model name.
5
+ * It is a property of a model, on a provider, through a particular deployment,
6
+ * reached with a particular credential. A quantized local build drops tool
7
+ * support the upstream weights have. A proxy strips `tool_calls`. An org's key
8
+ * has vision disabled. A vendor updates a model in place behind an alias. None
9
+ * of that is knowable from a name, and every one of it is knowable by asking
10
+ * once.
11
+ *
12
+ * So this module holds no list of models. It holds the shape of an observation,
13
+ * the rules for turning a real call into one, and the decision of what to send
14
+ * next time. The list every app is tempted to write — "these providers support
15
+ * tools" — is the thing being replaced: it was wrong the day a user brought a
16
+ * model nobody had heard of, which is every day.
17
+ */
18
+ export {};
@@ -72,9 +72,11 @@ export interface ChatMessage {
72
72
  * actually answer on.
73
73
  *
74
74
  * Both exist in the default chain: of nine free models probed live, four
75
- * answered with native `tool_calls` and five only in text. Callers get the
76
- * native shape here; parsing the text protocol is the caller's business,
77
- * because its convention differs per app.
75
+ * answered with native `tool_calls` and five only in text and three of the
76
+ * seven models shipped in the chain today are in that second group. Since 1.4.0
77
+ * both are read here, so a caller no longer has to know which half of the chain
78
+ * answered. See `toolProtocol` on CompleteOptions, and tool-protocol.ts for why
79
+ * a native-only read is a fabrication path rather than a missing feature.
78
80
  */
79
81
  export interface ToolCall {
80
82
  id: string;
@@ -140,6 +142,23 @@ export interface CompleteOptions {
140
142
  temperature?: number;
141
143
  /** Tool definitions in the OpenAI shape; passed through untouched. */
142
144
  tools?: unknown[];
145
+ /**
146
+ * Which tool-call protocols to READ from the reply. Default `"both"`.
147
+ *
148
+ * `"both"` also parses the `TOOL:` / `ARGS:` line protocol out of ordinary
149
+ * content and strips those lines from `text`. This matters more than it
150
+ * sounds: three of the seven models in the default chain cannot emit a native
151
+ * tool call at all, and a native-only read hands their narration back as a
152
+ * finished answer. The turn then reports a lookup that never happened.
153
+ *
154
+ * Parsing is skipped entirely when no `tools` are supplied — a model does not
155
+ * narrate a call it was never offered — so this is inert for the callers who
156
+ * do not use tools, which today is all of them.
157
+ *
158
+ * Set `"native"` only if you parse the text protocol yourself and would
159
+ * otherwise execute each call twice.
160
+ */
161
+ toolProtocol?: "both" | "native";
143
162
  /** Extra body fields for a vendor-specific parameter. Merged last, so it can override. */
144
163
  extraBody?: Record<string, unknown>;
145
164
  /**
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 content = firstText(choice?.message);
272
- const toolCalls = toolCallsFrom(choice?.message);
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
@@ -57,5 +57,6 @@ export { type HealthStatus, type Health, type HealthTrackerOptions, type HealthT
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";