@gonrocca/zero-pi 0.1.10 → 0.1.12

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,183 @@
1
+ // zero-pi — metered-provider guard, pure-logic module.
2
+ //
3
+ // pi can reach the same Claude models through two providers: `pi-claude-cli`
4
+ // (routes via the locally installed Claude CLI, billed against the user's
5
+ // subscription PLAN — no per-token charge) and `anthropic` (the direct
6
+ // Anthropic API provider, which consumes a metered "extra usage" pool and
7
+ // bills per token). `/model` lists the same models under both, so it is easy
8
+ // to slide into metered billing unnoticed.
9
+ //
10
+ // This module holds every *decision* of the guard — classifying a model switch
11
+ // into the action the wiring must run — in plain, dependency-free TypeScript so
12
+ // it is testable with plain objects via `node --test`. The pi wiring (the
13
+ // `model_select` handler that performs `confirm`/`notify`/`setModel`) lives in
14
+ // `provider-guard-extension.ts`.
15
+ //
16
+ // This file has no pi imports, no filesystem access, and no side-effecting
17
+ // top-level code; it is pure data plus an injected lookup function.
18
+
19
+ /**
20
+ * Mapping metered-provider → equivalent subscription-provider.
21
+ *
22
+ * A provider that appears as a *key* here is "metered" — switching to it
23
+ * triggers the guard. The mapped *value* is the subscription provider the
24
+ * guard offers to redirect to. v1 has exactly one pair; adding another
25
+ * metered↔subscription pair is a single extra line.
26
+ *
27
+ * Invariant the no-redirect-loop guarantee depends on: a redirection target
28
+ * (a *value*) must never itself be a *key*. `pi-claude-cli` is a value, not a
29
+ * key, so the `setModel`-triggered second `model_select` event classifies as
30
+ * `ignore` and the chain terminates.
31
+ */
32
+ export const METERED_TO_SUBSCRIPTION: Readonly<Record<string, string>> = {
33
+ anthropic: "pi-claude-cli",
34
+ };
35
+
36
+ /**
37
+ * Origin of a model change, as pi reports it on `ModelSelectEvent.source`.
38
+ * `set`/`cycle` are deliberate user actions; `restore` is a non-deliberate
39
+ * session restore that must never raise a modal.
40
+ */
41
+ export type GuardSource = "set" | "cycle" | "restore";
42
+
43
+ /** The deliberate sources that may raise a redirect modal. Any other source
44
+ * (including `restore` and unknown strings) is treated as non-deliberate. */
45
+ const DELIBERATE_SOURCES: readonly string[] = ["set", "cycle"];
46
+
47
+ /** The minimum a `Model` of pi must expose for the guard to classify it. */
48
+ export interface ModelLike {
49
+ provider: string;
50
+ id: string;
51
+ }
52
+
53
+ /**
54
+ * Injected registry-lookup function. The wiring builds this over
55
+ * `ctx.modelRegistry.find`; the pure module never knows `modelRegistry` or any
56
+ * pi type. Returns `undefined` when no equivalent model exists. The wiring is
57
+ * responsible for wrapping `find` so this lookup is total — `classifyModelSwitch`
58
+ * assumes a `lookup` that never throws.
59
+ */
60
+ export type RegistryLookup = (provider: string, id: string) => ModelLike | undefined;
61
+
62
+ /**
63
+ * The action the wiring must execute, as a discriminated union keyed on `kind`.
64
+ *
65
+ * - `ignore` — no-op: non-metered provider, or a malformed event.
66
+ * - `warn` — emit only `ctx.ui.notify(message, "warning")`; no modal, no
67
+ * `setModel`. Used for `restore`, for a metered provider with no equivalent,
68
+ * and for any non-deliberate source.
69
+ * - `offer-redirect` — show `ctx.ui.confirm(confirmTitle, confirmMessage)`; on
70
+ * a truthy answer `setModel(safeModel)` and, if applied, notify with
71
+ * `redirectMessage`.
72
+ */
73
+ export type GuardAction =
74
+ | { kind: "ignore" }
75
+ | { kind: "warn"; message: string }
76
+ | {
77
+ kind: "offer-redirect";
78
+ safeModel: ModelLike;
79
+ confirmTitle: string;
80
+ confirmMessage: string;
81
+ redirectMessage: string;
82
+ };
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // User-facing Spanish strings
86
+ // ---------------------------------------------------------------------------
87
+ //
88
+ // Exported as constants/functions so the test can assert them verbatim. `id`
89
+ // is the model id (e.g. `claude-opus-4-7`); `metered`/`subscription` are the
90
+ // provider names from `METERED_TO_SUBSCRIPTION`.
91
+
92
+ /** Title of the redirect `confirm` dialog. */
93
+ export const CONFIRM_TITLE = "zero · proveedor con cobro por token";
94
+
95
+ /** Body of the redirect `confirm` dialog — explains the metered billing and
96
+ * asks whether to switch to the subscription equivalent. */
97
+ export function confirmMessage(id: string, metered: string, subscription: string): string {
98
+ return `Estás cambiando a «${id}» vía el proveedor «${metered}», que factura por token y consume tu pool medido de extra usage. ¿Querés cambiar al equivalente «${id}» en «${subscription}», que usa los límites de tu suscripción?`;
99
+ }
100
+
101
+ /** `info` notification emitted after `setModel` succeeds. */
102
+ export function redirectMessage(id: string, subscription: string): string {
103
+ return `zero: redirigido a «${id}» en ${subscription} — usando los límites de tu suscripción.`;
104
+ }
105
+
106
+ /** `warning` notification — no equivalent available, or `restore`. */
107
+ export function warnMessage(id: string, metered: string): string {
108
+ return `zero: «${id}» está activo vía el proveedor «${metered}», que factura por token y consume tu pool medido de extra usage.`;
109
+ }
110
+
111
+ /** `warning` notification — `setModel` returned `false` (redirection did not
112
+ * apply). Optional; emitted by the wiring as a defensive courtesy. */
113
+ export function redirectFailedMessage(id: string, subscription: string): string {
114
+ return `zero: no se pudo cambiar a «${id}» en ${subscription} — seguís en el proveedor medido.`;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Classification
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** Whether a value is a non-empty string. */
122
+ function isNonEmptyString(value: unknown): value is string {
123
+ return typeof value === "string" && value !== "";
124
+ }
125
+
126
+ /**
127
+ * Classify a model switch into the `GuardAction` the wiring must execute.
128
+ *
129
+ * Pure and total — never throws (assuming `lookup` is total, which is the
130
+ * wiring's contract). The branches, in order:
131
+ *
132
+ * 1. `model` falsy, or `model.provider`/`model.id` not non-empty strings →
133
+ * `ignore` (malformed event, AC 6.3).
134
+ * 2. `model.provider` is not a key of `METERED_TO_SUBSCRIPTION` → `ignore`
135
+ * (non-metered provider, AC 4.1/4.2 — this also classifies `pi-claude-cli`,
136
+ * which is the structural no-redirect-loop guarantee, Story 5).
137
+ * 3. The provider is metered. Resolve the subscription provider and call
138
+ * `lookup(subProvider, model.id)`:
139
+ * - `source === "restore"` → always `warn`, never a modal, regardless of
140
+ * whether an equivalent exists (AC 3.x).
141
+ * - `source` deliberate (`set`/`cycle`):
142
+ * - `lookup` found a model → `offer-redirect` (AC 1).
143
+ * - `lookup` returned `undefined` → `warn` (AC 2).
144
+ * - any other (unknown) `source` → treated as non-deliberate → `warn`
145
+ * (defensive: no modal unless the switch is known to be deliberate).
146
+ */
147
+ export function classifyModelSwitch(
148
+ model: ModelLike | null | undefined,
149
+ source: GuardSource | string | null | undefined,
150
+ lookup: RegistryLookup,
151
+ ): GuardAction {
152
+ // 1. Malformed event — no model, or missing/empty provider/id.
153
+ if (!model || !isNonEmptyString(model.provider) || !isNonEmptyString(model.id)) {
154
+ return { kind: "ignore" };
155
+ }
156
+
157
+ // 2. Non-metered provider — silent no-op (covers `pi-claude-cli`).
158
+ const subscriptionProvider = METERED_TO_SUBSCRIPTION[model.provider];
159
+ if (subscriptionProvider === undefined) {
160
+ return { kind: "ignore" };
161
+ }
162
+
163
+ // 3. Metered provider. A deliberate switch with an equivalent gets a modal;
164
+ // everything else (restore, no equivalent, unknown source) gets a warn.
165
+ const meteredProvider = model.provider;
166
+ const id = model.id;
167
+ const isDeliberate = typeof source === "string" && DELIBERATE_SOURCES.includes(source);
168
+
169
+ if (isDeliberate) {
170
+ const safeModel = lookup(subscriptionProvider, id);
171
+ if (safeModel) {
172
+ return {
173
+ kind: "offer-redirect",
174
+ safeModel,
175
+ confirmTitle: CONFIRM_TITLE,
176
+ confirmMessage: confirmMessage(id, meteredProvider, subscriptionProvider),
177
+ redirectMessage: redirectMessage(id, subscriptionProvider),
178
+ };
179
+ }
180
+ }
181
+
182
+ return { kind: "warn", message: warnMessage(id, meteredProvider) };
183
+ }