@bitbaum/ai-kit 1.1.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.
@@ -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
+ };
package/src/index.ts CHANGED
@@ -128,6 +128,21 @@ export {
128
128
  answersRemaining,
129
129
  } from "./meter.js";
130
130
 
131
+ export {
132
+ type PoolId,
133
+ type RungId,
134
+ type TierPolicy,
135
+ type AiPolicy,
136
+ type UserState,
137
+ type WallOption,
138
+ type Wall,
139
+ type Decision,
140
+ DEFAULT_LADDER,
141
+ decide,
142
+ shouldSurface,
143
+ nextUtcReset,
144
+ } from "./policy.js";
145
+
131
146
  export {
132
147
  DAY_SECONDS,
133
148
  DEFAULT_BURST,
package/src/policy.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Who may spend what, and what to offer when they may not.
3
+ *
4
+ * Pure by the same rule as `fair-share`: no database, no clock, no provider.
5
+ * The caller owns "what has this user done"; this file owns "what are they
6
+ * entitled to, and what should they be shown next".
7
+ *
8
+ * ── WHY THIS IS ONE OBJECT AND NOT TWO ───────────────────────────────────────
9
+ *
10
+ * Across this fleet the same feature was built twice, half each. One app
11
+ * enforces a daily budget with no interface at all — the user is refused and
12
+ * never learns why, or what their share was. Another shows a quota meter with
13
+ * its own separate notion of tier, which the enforcement path does not read.
14
+ * Both are correct in isolation and they cannot agree, because there was no
15
+ * single statement of the policy for them to agree ABOUT.
16
+ *
17
+ * So the policy is data, and the same object answers both questions: the gate
18
+ * asks `decide()` whether to allow, and the settings page asks `decide()` what
19
+ * to draw. A number on screen that the gate does not use is decoration; a gate
20
+ * whose reasoning cannot be rendered is a wall.
21
+ *
22
+ * ── WHY FREE CAPACITY IS A LADDER AND NOT A LIMIT ────────────────────────────
23
+ *
24
+ * Shared free capacity is a fixed cost carried on behalf of strangers, and its
25
+ * only job is to be good enough, once, that someone wants more of it. The
26
+ * moment it runs out is therefore the only moment anybody changes anything —
27
+ * which makes the refusal the most important screen in the product, not an
28
+ * error state.
29
+ *
30
+ * Hence `Wall`: a refusal always carries the ways out, in order, and the
31
+ * default order puts the exits that cost the user NOTHING ahead of the one that
32
+ * costs money. Asking someone to make a free account elsewhere converts far
33
+ * better than asking them to pay, and it removes them from the shared pool just
34
+ * as completely.
35
+ *
36
+ * And there is always a floor. `wait` is a real rung with a real time on it,
37
+ * because "come back at 01:00" is a path and "rate limit exceeded" is a dead
38
+ * end that reads as broken.
39
+ */
40
+
41
+ /** Where the capacity for a turn comes from. */
42
+ export type PoolId =
43
+ /** Your keys, your cost, shared by everyone. The sample. */
44
+ | "platform"
45
+ /** The user's own key. Free to you, uncapped for them. */
46
+ | "user"
47
+ /** A model on the user's machine. Free to everyone. */
48
+ | "local";
49
+
50
+ /** The ways off the shared pool, and the floor beneath them. */
51
+ export type RungId = "byok" | "local" | "earn" | "paid" | "wait";
52
+
53
+ export interface TierPolicy {
54
+ /** Turns per UTC day. `null` means uncapped — the user is not your cost. */
55
+ turnsPerDay: number | null;
56
+ pool: PoolId;
57
+ /** Present when spending is metered against a balance rather than a count. */
58
+ meter?: "credits";
59
+ }
60
+
61
+ export interface AiPolicy {
62
+ /** Tier id → what it may do. App-defined; only the shape is fixed here. */
63
+ tiers: Record<string, TierPolicy>;
64
+ /**
65
+ * What to offer at a wall, in order.
66
+ *
67
+ * Defaults to free exits first. Overriding it to lead with `paid` is a
68
+ * product decision this package will not make for you, but it is worth
69
+ * knowing you are then asking for money from someone who has not yet
70
+ * finished evaluating the thing.
71
+ */
72
+ ladder?: RungId[];
73
+ }
74
+
75
+ /** Sensible order: everything that costs the user nothing, then money, then the floor. */
76
+ export const DEFAULT_LADDER: RungId[] = ["byok", "local", "earn", "paid", "wait"];
77
+
78
+ /** What the caller knows about this user right now. */
79
+ export interface UserState {
80
+ /** Which tier they are on — a key of `policy.tiers`. */
81
+ tier: string;
82
+ /** Turns already spent this UTC day. */
83
+ spentToday: number;
84
+ /** Credit balance, when the tier meters credits. */
85
+ credits?: number;
86
+ /** Rungs this user could actually take. A rung they cannot reach is not offered. */
87
+ available?: Partial<Record<RungId, boolean>>;
88
+ }
89
+
90
+ /** One way out of a wall, ready to render. */
91
+ export interface WallOption {
92
+ rung: RungId;
93
+ /** Whether this user can actually take it. */
94
+ available: boolean;
95
+ }
96
+
97
+ /** Everything needed to draw a refusal that is not a dead end. */
98
+ export interface Wall {
99
+ reason: "day-spent" | "no-credits" | "unknown-tier";
100
+ /** Ordered, best first. Never empty — `wait` is always last. */
101
+ options: WallOption[];
102
+ /** Epoch ms when the allowance refills, when the caller supplied a day boundary. */
103
+ resetAt: number | null;
104
+ }
105
+
106
+ export interface Decision {
107
+ allowed: boolean;
108
+ /** The resolved tier policy, or null when the tier is not in the policy. */
109
+ policy: TierPolicy | null;
110
+ pool: PoolId | null;
111
+ /**
112
+ * Turns left today. `null` means uncapped, which is NOT the same as zero and
113
+ * must not render as a gauge — there is nothing to draw a level against.
114
+ */
115
+ remaining: number | null;
116
+ /** Present only when `allowed` is false. */
117
+ wall?: Wall;
118
+ }
119
+
120
+ /** Next UTC midnight — when a per-day allowance refills. */
121
+ export function nextUtcReset(now = Date.now()): number {
122
+ const d = new Date(now);
123
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
124
+ }
125
+
126
+ function buildWall(reason: Wall["reason"], policy: AiPolicy, state: UserState, now: number): Wall {
127
+ const order = policy.ladder ?? DEFAULT_LADDER;
128
+ const options: WallOption[] = order
129
+ // `wait` is appended below so it can never be configured away.
130
+ .filter((rung) => rung !== "wait")
131
+ .map((rung) => ({ rung, available: state.available?.[rung] ?? true }));
132
+
133
+ // The floor. A refusal without a way forward reads as a broken product, and
134
+ // the honest way forward is always "this refills, here is when".
135
+ options.push({ rung: "wait", available: true });
136
+
137
+ return { reason, options, resetAt: nextUtcReset(now) };
138
+ }
139
+
140
+ /**
141
+ * May this user spend a turn, and if not, what should they be offered?
142
+ *
143
+ * The same call answers the gate and the settings page. A caller that only
144
+ * needs the number reads `remaining`; one drawing a refusal reads `wall`.
145
+ */
146
+ export function decide(policy: AiPolicy, state: UserState, now = Date.now()): Decision {
147
+ const tier = policy.tiers[state.tier];
148
+
149
+ // An unknown tier is not a licence to spend. It is a configuration error, and
150
+ // failing closed here is the difference between a typo costing nothing and a
151
+ // typo handing out the shared pool.
152
+ if (!tier) {
153
+ return {
154
+ allowed: false,
155
+ policy: null,
156
+ pool: null,
157
+ remaining: 0,
158
+ wall: buildWall("unknown-tier", policy, state, now),
159
+ };
160
+ }
161
+
162
+ if (tier.meter === "credits") {
163
+ const credits = state.credits ?? 0;
164
+ return credits > 0
165
+ ? { allowed: true, policy: tier, pool: tier.pool, remaining: null }
166
+ : {
167
+ allowed: false,
168
+ policy: tier,
169
+ pool: tier.pool,
170
+ remaining: 0,
171
+ wall: buildWall("no-credits", policy, state, now),
172
+ };
173
+ }
174
+
175
+ // Uncapped: the user is not your cost, so there is no number to police.
176
+ if (tier.turnsPerDay === null) {
177
+ return { allowed: true, policy: tier, pool: tier.pool, remaining: null };
178
+ }
179
+
180
+ const remaining = Math.max(0, tier.turnsPerDay - state.spentToday);
181
+ return remaining > 0
182
+ ? { allowed: true, policy: tier, pool: tier.pool, remaining }
183
+ : {
184
+ allowed: false,
185
+ policy: tier,
186
+ pool: tier.pool,
187
+ remaining: 0,
188
+ wall: buildWall("day-spent", policy, state, now),
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Should the remaining count be put in front of the user yet?
194
+ *
195
+ * A permanent gauge at 96% trains people to ignore the one at 4%. The indicator
196
+ * earns attention near the edge and stays quiet before it — so this returns
197
+ * false for an uncapped tier (nothing to say) and for a user who has barely
198
+ * started.
199
+ */
200
+ export function shouldSurface(decision: Decision, threshold = 0.34): boolean {
201
+ if (decision.remaining === null) return false;
202
+ if (!decision.allowed) return true;
203
+ const cap = decision.policy?.turnsPerDay;
204
+ if (!cap) return false;
205
+ return decision.remaining / cap <= threshold;
206
+ }