@bitbaum/ai-kit 1.1.0 → 1.2.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.d.ts CHANGED
@@ -57,4 +57,5 @@ export { type HealthStatus, type Health, type HealthTrackerOptions, type HealthT
57
57
  export { type LivenessResult, type LivenessOptions, type LivenessProbe, type AiHealthHandlerOptions, createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
58
58
  export { type RateLimitKind, classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
59
59
  export { type QuotaScope, type QuotaWindow, type QuotaReading, type HeaderBag, readQuota, readingFromRefusal, parseResetAt, answersRemaining, } from "./meter.js";
60
+ export { type PoolId, type RungId, type TierPolicy, type AiPolicy, type UserState, type WallOption, type Wall, type Decision, DEFAULT_LADDER, decide, shouldSurface, nextUtcReset, } from "./policy.js";
60
61
  export { DAY_SECONDS, DEFAULT_BURST, type ShareInput, type ShareReason, type ShareDecision, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
package/dist/index.js CHANGED
@@ -57,6 +57,7 @@ export { createHealthTracker, } from "./health.js";
57
57
  export { createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
58
58
  export { classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
59
59
  export { readQuota, readingFromRefusal, parseResetAt, answersRemaining, } from "./meter.js";
60
+ export { DEFAULT_LADDER, decide, shouldSurface, nextUtcReset, } from "./policy.js";
60
61
  export { DAY_SECONDS, DEFAULT_BURST, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
61
62
  // Form filling lives at `ai-kit/forms`, NOT here.
62
63
  //
@@ -0,0 +1,127 @@
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
+ /** Where the capacity for a turn comes from. */
41
+ export type PoolId =
42
+ /** Your keys, your cost, shared by everyone. The sample. */
43
+ "platform"
44
+ /** The user's own key. Free to you, uncapped for them. */
45
+ | "user"
46
+ /** A model on the user's machine. Free to everyone. */
47
+ | "local";
48
+ /** The ways off the shared pool, and the floor beneath them. */
49
+ export type RungId = "byok" | "local" | "earn" | "paid" | "wait";
50
+ export interface TierPolicy {
51
+ /** Turns per UTC day. `null` means uncapped — the user is not your cost. */
52
+ turnsPerDay: number | null;
53
+ pool: PoolId;
54
+ /** Present when spending is metered against a balance rather than a count. */
55
+ meter?: "credits";
56
+ }
57
+ export interface AiPolicy {
58
+ /** Tier id → what it may do. App-defined; only the shape is fixed here. */
59
+ tiers: Record<string, TierPolicy>;
60
+ /**
61
+ * What to offer at a wall, in order.
62
+ *
63
+ * Defaults to free exits first. Overriding it to lead with `paid` is a
64
+ * product decision this package will not make for you, but it is worth
65
+ * knowing you are then asking for money from someone who has not yet
66
+ * finished evaluating the thing.
67
+ */
68
+ ladder?: RungId[];
69
+ }
70
+ /** Sensible order: everything that costs the user nothing, then money, then the floor. */
71
+ export declare const DEFAULT_LADDER: RungId[];
72
+ /** What the caller knows about this user right now. */
73
+ export interface UserState {
74
+ /** Which tier they are on — a key of `policy.tiers`. */
75
+ tier: string;
76
+ /** Turns already spent this UTC day. */
77
+ spentToday: number;
78
+ /** Credit balance, when the tier meters credits. */
79
+ credits?: number;
80
+ /** Rungs this user could actually take. A rung they cannot reach is not offered. */
81
+ available?: Partial<Record<RungId, boolean>>;
82
+ }
83
+ /** One way out of a wall, ready to render. */
84
+ export interface WallOption {
85
+ rung: RungId;
86
+ /** Whether this user can actually take it. */
87
+ available: boolean;
88
+ }
89
+ /** Everything needed to draw a refusal that is not a dead end. */
90
+ export interface Wall {
91
+ reason: "day-spent" | "no-credits" | "unknown-tier";
92
+ /** Ordered, best first. Never empty — `wait` is always last. */
93
+ options: WallOption[];
94
+ /** Epoch ms when the allowance refills, when the caller supplied a day boundary. */
95
+ resetAt: number | null;
96
+ }
97
+ export interface Decision {
98
+ allowed: boolean;
99
+ /** The resolved tier policy, or null when the tier is not in the policy. */
100
+ policy: TierPolicy | null;
101
+ pool: PoolId | null;
102
+ /**
103
+ * Turns left today. `null` means uncapped, which is NOT the same as zero and
104
+ * must not render as a gauge — there is nothing to draw a level against.
105
+ */
106
+ remaining: number | null;
107
+ /** Present only when `allowed` is false. */
108
+ wall?: Wall;
109
+ }
110
+ /** Next UTC midnight — when a per-day allowance refills. */
111
+ export declare function nextUtcReset(now?: number): number;
112
+ /**
113
+ * May this user spend a turn, and if not, what should they be offered?
114
+ *
115
+ * The same call answers the gate and the settings page. A caller that only
116
+ * needs the number reads `remaining`; one drawing a refusal reads `wall`.
117
+ */
118
+ export declare function decide(policy: AiPolicy, state: UserState, now?: number): Decision;
119
+ /**
120
+ * Should the remaining count be put in front of the user yet?
121
+ *
122
+ * A permanent gauge at 96% trains people to ignore the one at 4%. The indicator
123
+ * earns attention near the edge and stays quiet before it — so this returns
124
+ * false for an uncapped tier (nothing to say) and for a user who has barely
125
+ * started.
126
+ */
127
+ export declare function shouldSurface(decision: Decision, threshold?: number): boolean;
package/dist/policy.js ADDED
@@ -0,0 +1,122 @@
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
+ /** Sensible order: everything that costs the user nothing, then money, then the floor. */
41
+ export const DEFAULT_LADDER = ["byok", "local", "earn", "paid", "wait"];
42
+ /** Next UTC midnight — when a per-day allowance refills. */
43
+ export function nextUtcReset(now = Date.now()) {
44
+ const d = new Date(now);
45
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
46
+ }
47
+ function buildWall(reason, policy, state, now) {
48
+ const order = policy.ladder ?? DEFAULT_LADDER;
49
+ const options = order
50
+ // `wait` is appended below so it can never be configured away.
51
+ .filter((rung) => rung !== "wait")
52
+ .map((rung) => ({ rung, available: state.available?.[rung] ?? true }));
53
+ // The floor. A refusal without a way forward reads as a broken product, and
54
+ // the honest way forward is always "this refills, here is when".
55
+ options.push({ rung: "wait", available: true });
56
+ return { reason, options, resetAt: nextUtcReset(now) };
57
+ }
58
+ /**
59
+ * May this user spend a turn, and if not, what should they be offered?
60
+ *
61
+ * The same call answers the gate and the settings page. A caller that only
62
+ * needs the number reads `remaining`; one drawing a refusal reads `wall`.
63
+ */
64
+ export function decide(policy, state, now = Date.now()) {
65
+ const tier = policy.tiers[state.tier];
66
+ // An unknown tier is not a licence to spend. It is a configuration error, and
67
+ // failing closed here is the difference between a typo costing nothing and a
68
+ // typo handing out the shared pool.
69
+ if (!tier) {
70
+ return {
71
+ allowed: false,
72
+ policy: null,
73
+ pool: null,
74
+ remaining: 0,
75
+ wall: buildWall("unknown-tier", policy, state, now),
76
+ };
77
+ }
78
+ if (tier.meter === "credits") {
79
+ const credits = state.credits ?? 0;
80
+ return credits > 0
81
+ ? { allowed: true, policy: tier, pool: tier.pool, remaining: null }
82
+ : {
83
+ allowed: false,
84
+ policy: tier,
85
+ pool: tier.pool,
86
+ remaining: 0,
87
+ wall: buildWall("no-credits", policy, state, now),
88
+ };
89
+ }
90
+ // Uncapped: the user is not your cost, so there is no number to police.
91
+ if (tier.turnsPerDay === null) {
92
+ return { allowed: true, policy: tier, pool: tier.pool, remaining: null };
93
+ }
94
+ const remaining = Math.max(0, tier.turnsPerDay - state.spentToday);
95
+ return remaining > 0
96
+ ? { allowed: true, policy: tier, pool: tier.pool, remaining }
97
+ : {
98
+ allowed: false,
99
+ policy: tier,
100
+ pool: tier.pool,
101
+ remaining: 0,
102
+ wall: buildWall("day-spent", policy, state, now),
103
+ };
104
+ }
105
+ /**
106
+ * Should the remaining count be put in front of the user yet?
107
+ *
108
+ * A permanent gauge at 96% trains people to ignore the one at 4%. The indicator
109
+ * earns attention near the edge and stays quiet before it — so this returns
110
+ * false for an uncapped tier (nothing to say) and for a user who has barely
111
+ * started.
112
+ */
113
+ export function shouldSurface(decision, threshold = 0.34) {
114
+ if (decision.remaining === null)
115
+ return false;
116
+ if (!decision.allowed)
117
+ return true;
118
+ const cap = decision.policy?.turnsPerDay;
119
+ if (!cap)
120
+ return false;
121
+ return decision.remaining / cap <= threshold;
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbaum/ai-kit",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
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).",
5
5
  "license": "MIT",
6
6
  "author": "Mao Nakamoto",
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
+ }