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 +21 -0
- package/README.md +230 -2
- package/failure-translation.ts +142 -0
- package/index.ts +910 -0
- package/key-ring.ts +888 -0
- package/log.ts +126 -0
- package/outage-watch.ts +1023 -0
- package/package.json +56 -4
- package/presend-guard.ts +412 -0
- package/probe.ts +380 -0
- package/ring-health.ts +601 -0
- package/ring-lifecycle.ts +2720 -0
- package/ring-provider.ts +123 -0
- package/route-state.ts +617 -0
- package/state-paths.ts +90 -0
- package/venice-catalog.ts +61 -0
- package/venice-direct.ts +59 -0
- package/virtual-router.ts +614 -0
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
|
-
#
|
|
1
|
+
# pi-braid-providers
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|