pi-braid-providers 0.0.0-stage → 1.0.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kifirkin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,231 @@
1
- # Temporary Holding Version
1
+ # pi-braid-providers
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Additional LLM providers for `pi-coding-agent`, built on a generalized **API key ring** for multi-key rotation and self-healing.
4
+
5
+ The `providers` workspace registers providers into pi at session start. The key-ring machinery that both the agent loop and direct extension LLM calls use ships here; the provider itself is the **`agent-zero`** setup: the multi-vendor gateway that proxies Venice's models, configured from a catalog file resolved at runtime.
6
+
7
+ > **A fresh install registers no provider.** The `agent-zero` model catalog
8
+ > (Venice's models) and gateway URL are read from a userland catalog file
9
+ > resolved at runtime (see `venice-catalog.ts`) that this package does not
10
+ > include. Without it the extension loads with the key-ring machinery active
11
+ > and zero registered providers; a synthetic route can be registered through
12
+ > `registerRoute` (`ring-lifecycle.ts`), the seam the test suite uses for its
13
+ > multi-route coverage.
14
+
15
+ ## The key ring
16
+
17
+ Each provider gets its own key ring with **exponential backoff**:
18
+
19
+ - **Multi-key rotation.** Keys load from `${PREFIX}_P0` … `${PREFIX}_P9` in the
20
+ environment, falling back to the bare `PREFIX` var when no numbered keys
21
+ exist. On a quota/auth/403 error the active key is exhausted and the next
22
+ request serves the next healthy key.
23
+ - **Persisted state.** Exhausted-key state persists to
24
+ `~/.pi/agent/.key-ring-state-<provider>.json`, so restarts skip known-dead
25
+ keys instead of retrying them and hitting the same 403. The file lives in
26
+ pi's agent directory (`state-paths.ts` owns the resolution); if no absolute
27
+ home can be resolved, the ring refuses to persist and says so rather than
28
+ writing a path relative to the cwd.
29
+ - **Self-healing turn retry.** When a turn fails and a fresh key remains, an
30
+ explicit marker is appended so pi-core re-runs the turn on the next key
31
+ automatically. Fatal account-level errors (quota exhausted, billing, balance)
32
+ are never masqueraded as retryable.
33
+ - **Direct-call feedback.** Direct extension LLM calls (an extension's own
34
+ `completeSimple`/`streamSimple` use)
35
+ feed 429/403 back into the ring too, so no dead key is served on those paths.
36
+ - **Diagnostics.** Rotation/exhaustion events route through a throttled notify
37
+ sink (not `console.*`); a loud _all-keys-exhausted_ warning fires when every
38
+ key is in backoff.
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pi install npm:pi-braid-providers
44
+ ```
45
+
46
+ Or register the workspace in `~/.pi/agent/settings.json`:
47
+
48
+ ```json
49
+ {
50
+ "packages": ["file:~/pi-braid/packages/providers"]
51
+ }
52
+ ```
53
+
54
+ ## What this does not do
55
+
56
+ - It does not ship a provider catalog. The `agent-zero` model list and gateway URL are read from a
57
+ catalog file resolved at runtime; without it the extension loads the key-ring machinery and
58
+ registers zero providers.
59
+ - It does not rotate to a route you did not configure - a route is recognized only by the model ids
60
+ it declares.
61
+ - It does not retry a fatal account error (quota exhausted, billing, balance) as if it were transient.
62
+
63
+ ## Activation preconditions
64
+
65
+ - `AGENT_ZERO_API_KEY` (or the numbered `AGENT_ZERO_API_KEY_P0`-`P9` rotation keys) set.
66
+ - A userland catalog file for the `agent-zero` route; otherwise zero providers register and only a
67
+ synthetic route (via `registerRoute`) can serve traffic.
68
+
69
+ ## Environment variables
70
+
71
+ ```bash
72
+ AGENT_ZERO_API_KEY=<your-agent-zero-gateway-key>
73
+ # Optional: multiple gateway keys for rotation
74
+ AGENT_ZERO_API_KEY_P0=<key-1>
75
+ AGENT_ZERO_API_KEY_P1=<key-2>
76
+ ```
77
+
78
+ Env vars are resolved at request time by the key ring (`auth.apiKey.resolve`).
79
+ `warnBelowKeyCount` (default 4) surfaces keys-remaining on each rotation so
80
+ you know when to top up.
81
+
82
+ ## The two routes
83
+
84
+ When the userland catalog exists, two providers register:
85
+
86
+ | Provider id | Serves | Via | Key prefix |
87
+ | --------------- | ------------------ | --------------------------------------------------- | -------------------- |
88
+ | `agent-zero` | Venice's models | agent-zero gateway (`https://llm.agent-zero.ai/v1`) | `AGENT_ZERO_API_KEY` |
89
+ | `venice-direct` | The same model ids | Venice's own API (`https://api.venice.ai/api/v1`) | `VENICE_API_KEY` |
90
+
91
+ `agent-zero` is the primary. On any registered route's key-ring death (a quota/auth/403 error that leaves its ring with zero usable keys), the **rotation engine** (ring-lifecycle.ts) walks the ordered route table and engages the first route that is not the dead route, has a usable key, and can serve the dead turn's model id - directly (a shared id) or through its `models` map. The order is `braid.providers.routes` (`[{ provider, keys, modelIds?, models?, venicePayload? }]`), array order being the rotation order; with no `routes` key the default pair is `agent-zero` then `venice-direct`, so an `agent-zero` death still falls over to Venice's own API. The table is the only attribution source: a route is recognized by the model ids it declares (`modelIds` plus the provider ids its `models` map resolves to) and takes the Venice payload tweak only when it sets `venicePayload` - off by default for a configured route, on for the default pair. A route the owner did not configure is neither routed to nor shaped. Exactly one rotation per episode: the engage swaps the session model for the retrying turn and emits one owner-visible whisper. A bounded fail-back probe (`FAILBACK_PROBE_INTERVAL_MS`, 10 min) re-checks the dead route at most once per interval at `turn_end` boundaries; when it recovers the session fails back automatically (one fail-back whisper) and the engage re-arms. State is in-memory per extension load; `session_start` resets the engine.
92
+
93
+ ## Health-routed entries (`auto`)
94
+
95
+ A route can carry a virtual entry selectable as `<provider>/auto`: one model
96
+ that answers each request with the first healthy route in the configured order,
97
+ so a per-spawn route decision becomes a per-request one. The auto route spans
98
+ several providers, so its entry lives under this package's own `braid`
99
+ namespace: `braid/auto` exists whenever this build carries the agent-zero route;
100
+ `opencode-go/auto` whenever the owner's route table names that provider -
101
+ `opencode-go` sits ahead of the gateway in the live table, so a session that
102
+ starts there leaves by the health walk instead of by hand. The entry declares
103
+ no context window, max tokens or input types (pi reads those from the physical
104
+ model that answered) and never dispatches its own id: a request with no
105
+ previous physical model resolves its base id from the route table, or from the
106
+ configured `defaultModel`, and the thinking level is resolved against the
107
+ target model's own map. A provider that already ships a physical `auto` skips
108
+ its own entry with one diagnostic, so a virtual entry can never shadow a real
109
+ model.
110
+
111
+ ## Route probes and cross-session state
112
+
113
+ A route may declare a non-billable status capability in the ordered route table:
114
+
115
+ ```json
116
+ {
117
+ "provider": "venice-direct",
118
+ "keys": "VENICE_API_KEY",
119
+ "probe": {
120
+ "url": "https://api.venice.ai/api/v1/api_keys/rate_limits",
121
+ "balanceFloorUsd": 1
122
+ }
123
+ }
124
+ ```
125
+
126
+ When present, one probe runs at extension registration - a plain GET at the
127
+ declared URL with the route's own key as a bearer token, never an inference
128
+ call - and the outcome is recorded per route in
129
+ `~/.pi/agent/.provider-route-state.json`: the last probe, the last recorded
130
+ death (its key identity as a hash, never the key), the fail-back target and the
131
+ warmed model ids. A later session reads that file instead of re-learning it.
132
+ The probe never throws, never rotates a key and never starts a billable call;
133
+ the write is lock- and CAS-guarded exactly like the key-ring state, because
134
+ every pi process on the host shares the file.
135
+
136
+ A recorded outcome carries the endpoint's own remaining figure as
137
+ `remainingUsd` (Venice's `balances.USD`, OpenRouter's `limit_remaining`), so no
138
+ consumer has to derive remaining by subtracting a monthly figure from a cap. A
139
+ route whose status document reports a spendable USD balance is classified
140
+ against `probe.balanceFloorUsd` (default `1`, owner-configurable per route):
141
+ above the floor is `ok`, at or below it but above zero is `degraded`, and zero
142
+ or below is `unavailable`. A route whose status endpoint answers 200 while the
143
+ account has no spendable balance therefore no longer reads as healthy.
144
+
145
+ Two rules keep the layers honest:
146
+
147
+ - **The runner is provider-agnostic.** Which provider ids exist and what their
148
+ status responses look like lives in ONE adapter table in `probe.ts`. A
149
+ provider absent from it is reported unavailable rather than guessed at.
150
+ - **The state store interprets nothing.** It writes down an opaque record per
151
+ route and reads it back; the probe vocabulary and the death classes belong to
152
+ the layers that produce them.
153
+
154
+ A route without a `probe` descriptor is never probed. Absent probes, resolution
155
+ and rotation are unchanged.
156
+
157
+ ## Ring and route health
158
+
159
+ `ring-health.ts` is the read side of those two files: it summarizes them for a
160
+ human or a consumer script without writing, locking or interpreting anything, so
161
+ "which keys are in backoff, and what did each route last look like" needs no
162
+ knowledge of either store's write protocol.
163
+
164
+ - `ringHealth(providerId, { stateDir?, file? })` reads one ring file
165
+ (`~/.pi/agent/.key-ring-state-<provider>.json`, or an explicit path).
166
+ `{ provider, present: false }` means no file, or one this reader cannot
167
+ trust; otherwise it carries `rev`, `savedAt`, `activeIndex` and one entry per
168
+ key: `{ exhausted, exhaustedAt, retryCount, exhaustedBy, keyHash }`.
169
+ - `routeHealth({ stateDir?, providers?, routeState? })` reports every route it
170
+ can see - the ids the caller names, the routes the route store already
171
+ carries, and the providers with a ring file - as
172
+ `{ provider, ring, lastProbe, deathClass, deathAt, failback, warmModels }`.
173
+ Facts the route store has not recorded come back as `null`, `0` or `[]`.
174
+
175
+ Four rules hold the reader to its layer:
176
+
177
+ - **Read-only.** No write, no lock, no migration. The stores own the one write
178
+ protocol; a reader that could write would be a second one.
179
+ - **Hash-only.** The ring file holds `hash`, never the key, and the reader
180
+ copies named fields instead of forwarding the document - so a value the ring
181
+ never wrote cannot leak through here.
182
+ - **Provider-blind.** Every provider id is an argument or is discovered from
183
+ the files; none is hardcoded, so a configured route table needs no code
184
+ change here.
185
+ - **Tolerant, never throwing.** An absent file is the normal empty state and
186
+ stays silent; a malformed, partial or wrong-version file is the same
187
+ `present: false` plus exactly one diagnostic.
188
+
189
+ The reader takes its file paths from its caller or derives them from the
190
+ provider id; it writes no state file of its own and reads the route document
191
+ through `route-state.ts`'s reader. Nothing in `packages/core` imports it: an
192
+ external consumer imports the package, never the other way round.
193
+
194
+ `formatRingHealth(routes, freshness)` and `summarizeRouteHealth(routes, freshness)`
195
+ turn a report into the `ring_health` tool's text and its `details` payload.
196
+ Both take the death window the caller resolved
197
+ (`braid.providers.probeFreshnessMs`, the same value the router's death gate is
198
+ built with) and mark a recorded death `(expired)` when that gate would no
199
+ longer act on it: an age at or past the window, a death with no recorded time,
200
+ or any death while the window is switched off. The age rule is the gate's own
201
+ predicate (`deathInForce` in `virtual-router.ts`), so the surface and the
202
+ router cannot disagree about which deaths act.
203
+
204
+ ## Disabling the provider
205
+
206
+ The registration lives in `index.ts` behind the venice-catalog seam: remove
207
+ (or chmod) the userland catalog file and no provider is registered. All
208
+ ring-lifecycle logic stays in `ring-lifecycle.ts`, unit-testable without
209
+ loading pi-ai.
210
+
211
+ ## Package layout
212
+
213
+ ```
214
+ providers/
215
+ ├── index.ts # registers the configured routes + wires key-ring/rotation events
216
+ ├── venice-catalog.ts # userland catalog seam (the models file, resolved at runtime)
217
+ ├── venice-direct.ts # the venice-direct route module: constants, key ring, model configs
218
+ ├── key-ring.ts # multi-key rotation + backoff + persistence
219
+ ├── ring-lifecycle.ts # per-request ring attribution, exhaust/self-heal, route-order rotation engine
220
+ ├── probe.ts # provider-agnostic probe runner + the adapter table
221
+ ├── route-state.ts # cross-session route state, lock + CAS writes
222
+ ├── ring-health.ts # read-only health surface over the key-ring + route state files
223
+ ├── log.ts # notify-sink routing (no console.*)
224
+ └── test/ # fixture-driven behavior pins
225
+ ```
226
+
227
+ > The registered id was `venice`, then `a0`, now `agent-zero` - each rename
228
+ > follows the gateway the provider serves models through. `venice-direct`, the
229
+ > `VENICE_API_KEY` family and `venice-models.json` are unchanged; the gateway's
230
+ > own credential namespace moved from `A0_VENICE_API_KEY` to `AGENT_ZERO_API_KEY`
231
+ > with the last rename.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * failure-translation.ts: one place that answers both failure questions for a
3
+ * raw error string.
4
+ *
5
+ * Two vocabularies, two questions, one input:
6
+ *
7
+ * - providers `FailureClass` (ring-lifecycle.ts): the fate of the ROUTE.
8
+ * Delegated, never re-derived: the rotation walk's own `classifyFailure`
9
+ * answers it, so this module cannot drift from the walk.
10
+ * - core `LlmErrorCategory` (packages/core/src/lib/errors/llm-error.ts):
11
+ * the health of the SESSION. Providers is a standalone package, so
12
+ * product code cannot import across the package boundary; core's string
13
+ * classifier is mirrored here as data instead. The mirror is pinned by
14
+ * test/failure-translation.test.mjs: every canonical row there is
15
+ * cross-checked against the real core classifier, so drift on a text a row
16
+ * carries is a red diff. The rows are witnesses, not a derivation: a core
17
+ * change that alters the verdict for a text no row carries leaves this
18
+ * mirror stale and the test green.
19
+ *
20
+ * Translation, not unification: the two verdicts are reported side by side
21
+ * and never merged. A string can be `key` to the route and `unknown` to the
22
+ * session (a bare 403), or `none` to the route and `rate_limit` to the
23
+ * session (a plain 429). Both are current, documented behaviour and the pin
24
+ * records them as they are.
25
+ */
26
+ import { classifyFailure, type FailureClass } from "./ring-lifecycle";
27
+
28
+ /**
29
+ * Core's error vocabulary, declared here as data because providers cannot
30
+ * import `packages/core`. The member strings are core's own values, kept
31
+ * verbatim: the pin test compares the values `classifyLlmError` returns.
32
+ */
33
+ export type LlmErrorCategory =
34
+ | "balance"
35
+ | "rate_limit"
36
+ | "context_window"
37
+ | "transient"
38
+ | "abort"
39
+ | "auth"
40
+ | "unknown";
41
+
42
+ /**
43
+ * Core's string classifier, mirrored as an ordered data table. The order is
44
+ * core's own precedence (balance, rate_limit, auth, context_window,
45
+ * transient) and each pattern list is copied verbatim from llm-error.ts
46
+ * (BALANCE_PATTERNS, RATE_LIMIT_PATTERNS, AUTH_PATTERNS,
47
+ * CONTEXT_WINDOW_PATTERNS, TRANSIENT_PATTERNS). Core's numeric status
48
+ * branches (`status === 402` and friends) are unreachable from a raw string:
49
+ * a status enters core only through `extractStatusCode(err)`, which returns
50
+ * nothing for a string. Core's `abort` branch likewise needs an error
51
+ * object, so it is unreachable here and is deliberately not a rule.
52
+ */
53
+ const CORE_CATEGORY_RULES: ReadonlyArray<
54
+ readonly [LlmErrorCategory, readonly RegExp[]]
55
+ > = [
56
+ [
57
+ "balance",
58
+ [
59
+ /402/,
60
+ /insufficient[_ ]balance/i,
61
+ /billing/i,
62
+ /payment[_ ]required/i,
63
+ /quota[_ ]exceeded/i,
64
+ ],
65
+ ],
66
+ [
67
+ "rate_limit",
68
+ [/429/, /rate[_ ]limit/i, /too[_ ]many[_ ]requests/i, /throttl/i],
69
+ ],
70
+ [
71
+ "auth",
72
+ [/401/, /invalid[_ ]api[_ ]key/i, /unauthorized/i, /authentication/i],
73
+ ],
74
+ [
75
+ "context_window",
76
+ [
77
+ /413/,
78
+ /context[_ ]length[_ ]exceeded/i,
79
+ /context[_ ]window/i,
80
+ /maximum[_ ]context/i,
81
+ /prompt[_ ]is[_ ]too[_ ]long/i,
82
+ ],
83
+ ],
84
+ [
85
+ "transient",
86
+ [
87
+ /500/,
88
+ /502/,
89
+ /503/,
90
+ /504/,
91
+ /529/,
92
+ /overloaded/i,
93
+ /server[_ ]error/i,
94
+ /temporarily[_ ]unavailable/i,
95
+ /econnreset/i,
96
+ /etimedout/i,
97
+ /enotfound/i,
98
+ /network[_ ]error/i,
99
+ /fetch failed/i,
100
+ /socket hang up/i,
101
+ /econnaborted/i,
102
+ /epipe/i,
103
+ /eai[_ ]again/i,
104
+ /und[_ ]err/i,
105
+ /other side closed/i,
106
+ ],
107
+ ],
108
+ ];
109
+
110
+ /** Core's category for a raw error string: the classifier's string path. */
111
+ function coreCategoryOf(errText: string): LlmErrorCategory {
112
+ // Core builds its text as `${status ?? ""} ${message}`.trim(); for a string
113
+ // input the message is the string itself.
114
+ const text = errText.trim();
115
+ for (const [category, patterns] of CORE_CATEGORY_RULES) {
116
+ if (patterns.some((pattern) => pattern.test(text))) return category;
117
+ }
118
+ return "unknown";
119
+ }
120
+
121
+ /** Both verdicts for one raw error string, side by side. */
122
+ export interface FailureTranslation {
123
+ /** Providers vocabulary: the fate of the route (the rotation walk). */
124
+ class: FailureClass;
125
+ /** The 4xx/5xx status the text names, when it names one. */
126
+ status: number | null;
127
+ /** Core vocabulary: the health of the session. */
128
+ category: LlmErrorCategory;
129
+ }
130
+
131
+ /**
132
+ * Answer both questions for one raw error string. The providers half is the
133
+ * walk's own classifier, delegated; the core half is the mirror above.
134
+ */
135
+ export function translateFailure(errText: string): FailureTranslation {
136
+ const classification = classifyFailure(errText);
137
+ return {
138
+ class: classification.class,
139
+ status: classification.status,
140
+ category: coreCategoryOf(errText),
141
+ };
142
+ }