@bitbaum/ai-kit 1.2.0 → 1.3.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 +61 -0
- package/dist/capability/classify.d.ts +52 -0
- package/dist/capability/classify.js +96 -0
- package/dist/capability/decide.d.ts +75 -0
- package/dist/capability/decide.js +143 -0
- package/dist/capability/index.d.ts +33 -0
- package/dist/capability/index.js +2 -0
- package/dist/capability/types.d.ts +81 -0
- package/dist/capability/types.js +18 -0
- package/package.json +5 -2
- package/src/capability/classify.ts +149 -0
- package/src/capability/decide.ts +183 -0
- package/src/capability/index.ts +52 -0
- package/src/capability/types.ts +87 -0
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,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 {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitbaum/ai-kit",
|
|
3
|
-
"version": "1.
|
|
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.3.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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 {
|
|
32
|
+
ToolVerdict,
|
|
33
|
+
CapabilityKind,
|
|
34
|
+
Provenance,
|
|
35
|
+
CapabilityRecord,
|
|
36
|
+
CapabilityStore,
|
|
37
|
+
Classification,
|
|
38
|
+
} from "./types.js";
|
|
39
|
+
|
|
40
|
+
export { classifyToolAttempt, saysToolsUnsupported, type ToolAttempt } from "./classify.js";
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
planToolAttempt,
|
|
44
|
+
claimableVerdict,
|
|
45
|
+
currentVerdict,
|
|
46
|
+
isStale,
|
|
47
|
+
makeRecord,
|
|
48
|
+
scopeKey,
|
|
49
|
+
shouldReplace,
|
|
50
|
+
DEFAULT_TTL_MS,
|
|
51
|
+
type ToolPlan,
|
|
52
|
+
} from "./decide.js";
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
/**
|
|
20
|
+
* How a model answers a request carrying tool definitions.
|
|
21
|
+
*
|
|
22
|
+
* Four values, and the fourth is the one that matters. `unobserved` is not a
|
|
23
|
+
* synonym for `none`: it means nobody has ever asked, and a system that treats
|
|
24
|
+
* it as `none` silently disables tools for every model it has not met yet,
|
|
25
|
+
* while a system that treats it as `native` promises a capability it cannot
|
|
26
|
+
* demonstrate. It has to stay its own answer all the way to the user.
|
|
27
|
+
*/
|
|
28
|
+
export type ToolVerdict = "native" | "text" | "none" | "unobserved";
|
|
29
|
+
|
|
30
|
+
/** What a single capability question is asked about. */
|
|
31
|
+
export type CapabilityKind = "tools" | "vision";
|
|
32
|
+
|
|
33
|
+
/** How we came to believe something, ordered weakest to strongest. */
|
|
34
|
+
export type Provenance =
|
|
35
|
+
/** The vendor's docs or a hand-maintained registry. A prior, never a fact. */
|
|
36
|
+
| "declared"
|
|
37
|
+
/** A deliberate probe request made to answer this question. */
|
|
38
|
+
| "probe"
|
|
39
|
+
/** Real traffic the user asked for, which answered it for free. */
|
|
40
|
+
| "live";
|
|
41
|
+
|
|
42
|
+
export type CapabilityRecord = {
|
|
43
|
+
provider: string;
|
|
44
|
+
model: string;
|
|
45
|
+
/**
|
|
46
|
+
* Which credential this was observed through — a HASH, never the key.
|
|
47
|
+
* Capability differs per key (an org with vision disabled, a proxy that
|
|
48
|
+
* strips tool calls), so an observation made with one credential is not
|
|
49
|
+
* evidence about another.
|
|
50
|
+
*/
|
|
51
|
+
scope: string;
|
|
52
|
+
capability: CapabilityKind;
|
|
53
|
+
verdict: ToolVerdict;
|
|
54
|
+
/** ISO 8601. Used for staleness; models change under their own names. */
|
|
55
|
+
observedAt: string;
|
|
56
|
+
via: Provenance;
|
|
57
|
+
/** Short, human-readable reason. Goes in logs and in the UI. */
|
|
58
|
+
evidence?: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Storage is the app's problem — a table, a KV, a file. This package owns the
|
|
63
|
+
* shape and the rules, because those are what every app gets wrong; it does not
|
|
64
|
+
* own where the rows live, because that is the one part where apps legitimately
|
|
65
|
+
* differ.
|
|
66
|
+
*/
|
|
67
|
+
export type CapabilityStore = {
|
|
68
|
+
get(key: {
|
|
69
|
+
provider: string;
|
|
70
|
+
model: string;
|
|
71
|
+
scope: string;
|
|
72
|
+
capability: CapabilityKind;
|
|
73
|
+
}): Promise<CapabilityRecord | null>;
|
|
74
|
+
put(record: CapabilityRecord): Promise<void>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The outcome of reading one real response for what it says about capability. */
|
|
78
|
+
export type Classification = {
|
|
79
|
+
verdict: ToolVerdict;
|
|
80
|
+
/**
|
|
81
|
+
* Whether this is worth WRITING DOWN. A response can be uninformative —
|
|
82
|
+
* a 429, a 500, a timeout, a 400 about something other than tools — and
|
|
83
|
+
* recording those is how a model gets wrongly marked incapable forever.
|
|
84
|
+
*/
|
|
85
|
+
record: boolean;
|
|
86
|
+
evidence: string;
|
|
87
|
+
};
|