@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/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,7 +1,6 @@
1
1
  {
2
2
  "name": "@bitbaum/ai-kit",
3
- "version": "1.2.0",
4
- "description": "One install for the AI layer of an app: which model to call, what to do when the vendor retires it, how to walk the fallback chain and know when none of it worked, how to read the three kinds of 429, a fair daily budget across users, headless AI form filling \u2014 and now the model registry (one SSOT for every callable id, with the paid/free boundary as a field) and the grounding harness (facts, contract, deterministic fabrication check).",
3
+ "version": "1.4.0",
5
4
  "license": "MIT",
6
5
  "author": "Mao Nakamoto",
7
6
  "homepage": "https://github.com/bitbaum/ai-kit#readme",
@@ -67,6 +66,10 @@
67
66
  "types": "./dist/web/index.d.ts",
68
67
  "default": "./dist/web/index.js"
69
68
  },
69
+ "./capability": {
70
+ "types": "./dist/capability/index.d.ts",
71
+ "default": "./dist/capability/index.js"
72
+ },
70
73
  "./grounding": {
71
74
  "types": "./dist/grounding/index.d.ts",
72
75
  "require": "./dist-cjs/grounding/index.js",
@@ -0,0 +1,149 @@
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
+ /**
25
+ * Phrases that mean "this model does not do tools", conservatively.
26
+ *
27
+ * Every entry names tools or functions explicitly. Deliberately absent:
28
+ * "invalid request", "bad parameter", "unsupported" on its own — each of those
29
+ * appears in vendor 400s for a dozen unrelated reasons, and a match on one of
30
+ * them would silently disable a working model. When in doubt the answer is to
31
+ * record nothing; an unobserved model gets asked again on the next message,
32
+ * whereas a wrongly-negative one does not.
33
+ */
34
+ const TOOLS_UNSUPPORTED_PATTERNS: RegExp[] = [
35
+ /tool[\s_-]?(use|call|calls|calling)\s+(is\s+)?(not|un)[\s_-]?support/i,
36
+ /does\s+not\s+support\s+tool/i,
37
+ /doesn'?t\s+support\s+tool/i,
38
+ /no\s+support\s+for\s+tool/i,
39
+ /function[\s_-]?call(ing)?\s+(is\s+)?(not|un)[\s_-]?support/i,
40
+ /does\s+not\s+support\s+function/i,
41
+ /doesn'?t\s+support\s+function/i,
42
+ /model\s+.{0,60}?\s+does\s+not\s+support\s+(the\s+)?(`?tools`?|`?functions`?)/i,
43
+ /unsupported\s+parameter:?\s*'?"?tools?"?'?/i,
44
+ /unknown\s+(field|parameter):?\s*'?"?tools?"?'?/i,
45
+ /`?tools`?\s+is\s+not\s+(a\s+)?(valid|supported|allowed)/i,
46
+ ];
47
+
48
+ /**
49
+ * Does this error body explicitly say the model cannot do tools?
50
+ *
51
+ * Conservative on purpose — see the header. A false positive here is a model
52
+ * permanently downgraded for a reason nobody can see; a false negative just
53
+ * means we ask again next time, which costs one request.
54
+ */
55
+ export function saysToolsUnsupported(body: string): boolean {
56
+ if (!body) return false;
57
+ return TOOLS_UNSUPPORTED_PATTERNS.some((re) => re.test(body));
58
+ }
59
+
60
+ /** Shape of an OpenAI-compatible chat completion, as far as we care. */
61
+ type ChatBody = {
62
+ choices?: Array<{
63
+ finish_reason?: unknown;
64
+ message?: { content?: unknown; tool_calls?: unknown };
65
+ }>;
66
+ };
67
+
68
+ export type ToolAttempt = {
69
+ /** HTTP status. 0 or undefined for a transport failure. */
70
+ status?: number;
71
+ /** Parsed JSON body, when there was one. */
72
+ parsed?: unknown;
73
+ /** Raw body text. Used only for error classification. */
74
+ bodyText?: string;
75
+ /**
76
+ * Did the caller's own text-protocol parser find a usable tool call in the
77
+ * assistant's prose? Only the app knows its envelope, so it answers this.
78
+ * Absent means "not checked", which is not the same as "no".
79
+ */
80
+ textProtocolFound?: boolean;
81
+ };
82
+
83
+ /**
84
+ * What does this attempt prove?
85
+ *
86
+ * Call it after EVERY request that carried tool definitions. Real traffic then
87
+ * classifies every model a user brings, on its first message, at no extra cost
88
+ * — which is the property that makes this scale to models nobody has heard of.
89
+ */
90
+ export function classifyToolAttempt(attempt: ToolAttempt): Classification {
91
+ const { status, parsed, bodyText = "", textProtocolFound } = attempt;
92
+
93
+ // ── transport failures prove nothing ────────────────────────────────────
94
+ if (!status) {
95
+ return { verdict: "unobserved", record: false, evidence: "no response" };
96
+ }
97
+
98
+ // ── the vendor refused, and we must be careful about why ────────────────
99
+ if (status >= 400) {
100
+ if (saysToolsUnsupported(bodyText)) {
101
+ return {
102
+ verdict: "none",
103
+ record: true,
104
+ evidence: `${status}: the vendor says this model does not support tools`,
105
+ };
106
+ }
107
+ // Everything else — 429, 401, 500, a 400 about context length or a bad
108
+ // parameter — says nothing about tools. Learning nothing is correct.
109
+ return {
110
+ verdict: "unobserved",
111
+ record: false,
112
+ evidence: `${status}: not a statement about tool support`,
113
+ };
114
+ }
115
+
116
+ // ── a success: did it actually call a tool? ─────────────────────────────
117
+ const body = (parsed ?? {}) as ChatBody;
118
+ const choice = body.choices?.[0];
119
+ const toolCalls = choice?.message?.tool_calls;
120
+
121
+ if (Array.isArray(toolCalls) && toolCalls.length > 0) {
122
+ return { verdict: "native", record: true, evidence: "returned tool_calls" };
123
+ }
124
+ if (choice?.finish_reason === "tool_calls") {
125
+ return { verdict: "native", record: true, evidence: "finish_reason was tool_calls" };
126
+ }
127
+
128
+ // The app's own envelope parser found a call in the prose. That is the text
129
+ // protocol, and it is a real capability — five of nine free models probed in
130
+ // this fleet answer only this way.
131
+ if (textProtocolFound === true) {
132
+ return {
133
+ verdict: "text",
134
+ record: true,
135
+ evidence: "no tool_calls, but a tool call was parsed from the text",
136
+ };
137
+ }
138
+
139
+ // A successful answer with no tool call is the ambiguous case, and the
140
+ // ambiguity is real: the model may be incapable, or it may simply have
141
+ // decided no tool was needed — which is the correct behaviour for most
142
+ // messages. Treating this as evidence of incapacity would mark almost every
143
+ // model `none` within a few turns of ordinary chat.
144
+ return {
145
+ verdict: "unobserved",
146
+ record: false,
147
+ evidence: "answered without calling a tool, which is not evidence either way",
148
+ };
149
+ }
@@ -0,0 +1,183 @@
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
+ import type { CapabilityKind, CapabilityRecord, ToolVerdict } from "./types.js";
20
+
21
+ /**
22
+ * How long an observation stands before it must be re-earned.
23
+ *
24
+ * Models change under their own names — a vendor updates the weights behind an
25
+ * alias, an org enables a feature, a local user swaps a quantization. A record
26
+ * with no expiry is a hardcoded list again, just one we wrote ourselves.
27
+ *
28
+ * Negatives expire sooner than positives: a model that gained tool support and
29
+ * is still marked `none` is invisibly crippled, while a model that lost it
30
+ * announces itself loudly on the next call.
31
+ */
32
+ export const DEFAULT_TTL_MS = {
33
+ native: 30 * 24 * 60 * 60 * 1000,
34
+ text: 30 * 24 * 60 * 60 * 1000,
35
+ none: 7 * 24 * 60 * 60 * 1000,
36
+ unobserved: 0,
37
+ } as const;
38
+
39
+ export function isStale(
40
+ record: Pick<CapabilityRecord, "verdict" | "observedAt">,
41
+ now: Date = new Date(),
42
+ ttl: Partial<Record<ToolVerdict, number>> = {},
43
+ ): boolean {
44
+ const limit = ttl[record.verdict] ?? DEFAULT_TTL_MS[record.verdict];
45
+ if (!limit) return true;
46
+ const age = now.getTime() - new Date(record.observedAt).getTime();
47
+ return !Number.isFinite(age) || age > limit;
48
+ }
49
+
50
+ /** The verdict a record still supports, or `unobserved` once it has expired. */
51
+ export function currentVerdict(
52
+ record: CapabilityRecord | null | undefined,
53
+ now: Date = new Date(),
54
+ ttl: Partial<Record<ToolVerdict, number>> = {},
55
+ ): ToolVerdict {
56
+ if (!record) return "unobserved";
57
+ return isStale(record, now, ttl) ? "unobserved" : record.verdict;
58
+ }
59
+
60
+ export type ToolPlan = {
61
+ /** Put tool definitions on the request? */
62
+ sendTools: boolean;
63
+ /** Parse the prose for a tool envelope as well? */
64
+ expectTextProtocol: boolean;
65
+ /** True when this request is also the thing that will teach us. */
66
+ isLearning: boolean;
67
+ reason: string;
68
+ };
69
+
70
+ /**
71
+ * What to send. Optimistic about the unknown, because asking is how we learn
72
+ * and a declared prior is only a guess about where to start.
73
+ */
74
+ export function planToolAttempt(input: {
75
+ observed: ToolVerdict;
76
+ /** What a registry or the vendor's docs claim. A prior, never a fact. */
77
+ declared?: ToolVerdict;
78
+ }): ToolPlan {
79
+ const { observed, declared } = input;
80
+
81
+ if (observed === "native") {
82
+ return {
83
+ sendTools: true,
84
+ expectTextProtocol: false,
85
+ isLearning: false,
86
+ reason: "observed to return tool_calls",
87
+ };
88
+ }
89
+ if (observed === "text") {
90
+ return {
91
+ sendTools: false,
92
+ expectTextProtocol: true,
93
+ isLearning: false,
94
+ reason: "observed to answer tools only in prose",
95
+ };
96
+ }
97
+ if (observed === "none") {
98
+ return {
99
+ sendTools: false,
100
+ expectTextProtocol: false,
101
+ isLearning: false,
102
+ reason: "the vendor said this model does not support tools",
103
+ };
104
+ }
105
+
106
+ // Unobserved. Ask — and accept EITHER answer, because a model that ignores
107
+ // the definitions and writes the envelope in prose is capable, just not
108
+ // natively, and a native-only client silently loses most of a free chain.
109
+ return {
110
+ sendTools: declared !== "none",
111
+ expectTextProtocol: true,
112
+ isLearning: true,
113
+ reason:
114
+ declared === "none"
115
+ ? "never observed, and the registry says no — asking in prose only"
116
+ : "never observed — this request is also the probe",
117
+ };
118
+ }
119
+
120
+ /**
121
+ * What we may TELL the user, and what the prompt may claim.
122
+ *
123
+ * Pessimistic about the unknown. `unobserved` returns `none` here on purpose:
124
+ * until a model has demonstrated a capability, an assistant that announces it
125
+ * is writing a cheque the model may not honour, and the user discovers that as
126
+ * a broken promise rather than as a missing feature.
127
+ */
128
+ export function claimableVerdict(observed: ToolVerdict): Exclude<ToolVerdict, "unobserved"> {
129
+ return observed === "unobserved" ? "none" : observed;
130
+ }
131
+
132
+ /**
133
+ * A stable, non-reversible handle for the credential an observation was made
134
+ * through. Capability differs per key, so observations must not leak across
135
+ * keys — and the key itself must never be stored to achieve that.
136
+ */
137
+ export function scopeKey(secret: string | undefined | null): string {
138
+ if (!secret) return "anonymous";
139
+ return createHash("sha256").update(secret).digest("hex").slice(0, 16);
140
+ }
141
+
142
+ /** Build a record from a classification. Keeps `observedAt` in one place. */
143
+ export function makeRecord(input: {
144
+ provider: string;
145
+ model: string;
146
+ scope: string;
147
+ capability: CapabilityKind;
148
+ verdict: ToolVerdict;
149
+ via: CapabilityRecord["via"];
150
+ evidence?: string;
151
+ now?: Date;
152
+ }): CapabilityRecord {
153
+ return {
154
+ provider: input.provider,
155
+ model: input.model,
156
+ scope: input.scope,
157
+ capability: input.capability,
158
+ verdict: input.verdict,
159
+ via: input.via,
160
+ observedAt: (input.now ?? new Date()).toISOString(),
161
+ ...(input.evidence ? { evidence: input.evidence } : {}),
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Should a new observation overwrite the stored one?
167
+ *
168
+ * Strength beats age, and `live` beats `declared`, so a real call always
169
+ * overrules a registry guess. Between two observations of equal provenance the
170
+ * newer wins — including a `none` replacing a `native`, because a model really
171
+ * can lose a capability and refusing to believe that is how a chain keeps
172
+ * calling something that no longer works.
173
+ */
174
+ export function shouldReplace(
175
+ existing: CapabilityRecord | null | undefined,
176
+ incoming: CapabilityRecord,
177
+ ): boolean {
178
+ if (!existing) return true;
179
+ const rank = { declared: 0, probe: 1, live: 2 } as const;
180
+ if (rank[incoming.via] > rank[existing.via]) return true;
181
+ if (rank[incoming.via] < rank[existing.via]) return false;
182
+ return new Date(incoming.observedAt).getTime() >= new Date(existing.observedAt).getTime();
183
+ }