@chatpanel/events 0.95.0 → 0.96.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/failover.js ADDED
@@ -0,0 +1,93 @@
1
+ // Run the call, and try the next model when this one cannot answer.
2
+ //
3
+ // A provider that returns "you have depleted your monthly credits" has not failed the
4
+ // request — it has declined it, and there is very likely another model that would say yes.
5
+ // Showing that error to the user when an alternative was available is the router not doing
6
+ // the one job it exists for.
7
+ //
8
+ // ONLY WHEN THE ROUTER CHOSE. If the user picked a specific model, silently answering from a
9
+ // different one would be worse than the error: they asked for that model for a reason.
10
+ //
11
+ // This was the extension's `withFailover`; the desktop had none, so the same decline ended
12
+ // the turn there. What is shared is the attempt loop and its rules — keep trying (bounded),
13
+ // classify before retrying, never announce a model that will not be called, say "N models
14
+ // tried" rather than the last provider's error as if it were the whole story. What is
15
+ // injected is how the host picks the next model (its router, its roster) and how it tells
16
+ // the user.
17
+ //
18
+ // Class R with an async seam: no I/O of its own.
19
+
20
+ /** Enough to work through a realistic set of models rather than sampling it — but bounded, because sitting through every failure is its own kind of broken. */
21
+ export const FAILOVER_MAX_ATTEMPTS = 6;
22
+
23
+ /** The terminal message: what was tried, and the last thing that went wrong. */
24
+ export function failoverExhausted(tried, err) {
25
+ const n = Array.isArray(tried) ? tried.length : Number(tried) || 0;
26
+ const e = new Error(`${n} model${n === 1 ? '' : 's'} tried, none could answer. Last error — ${err?.message || err}`);
27
+ e.cause = err;
28
+ e.tried = Array.isArray(tried) ? [...tried] : [];
29
+ return e;
30
+ }
31
+
32
+ /**
33
+ * @param first the target to call first — `{ id, model, name?, routedVia? }` plus whatever the host's call needs
34
+ * @param call `(target) => Promise<result>` — MUST throw on a failure (an Error with
35
+ * `status` when the host has one); a host whose call returns `{ ok: false }`
36
+ * converts before handing it here
37
+ * @param chose did the router choose `first`? When false, a failure is the answer.
38
+ * @param health a model-health ledger (`createModelHealth`)
39
+ * @param next `async ({ current, tried, reason, marked }) => target | null` — the host's
40
+ * router: the same class of thing, excluding what already failed
41
+ * @param onHop `({ from, to, reason, reasons, next }) => void` — tell the user, record it
42
+ * @param labelOf `(target) => string` for messages
43
+ * @param signal an aborted signal ends the chain with the current error
44
+ */
45
+ export async function runWithFailover({
46
+ first, call, chose = false, health = null, next = null, onHop = null, labelOf = defaultLabel, signal = null,
47
+ maxAttempts = FAILOVER_MAX_ATTEMPTS,
48
+ } = {}) {
49
+ if (typeof call !== 'function') throw new Error('runWithFailover: call required');
50
+ const tried = [];
51
+ let current = first;
52
+ let lastErr = null;
53
+
54
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
55
+ try {
56
+ const out = await call(current);
57
+ if (current?.id && health) health.markHealthy(current.id);
58
+ return out;
59
+ } catch (err) {
60
+ lastErr = err;
61
+ if (!chose || signal?.aborted || !health || typeof next !== 'function') throw err;
62
+ // The model name goes with the report, so "this model fails everywhere" is learnable
63
+ // rather than rediscovered at each provider in turn.
64
+ const marked = health.markUnhealthy(current?.id, err, current?.model);
65
+ if (!marked) throw err;
66
+ tried.push(current?.id);
67
+
68
+ // Do not ANNOUNCE a model we are not going to call. The loop used to pick and announce
69
+ // the next one and only then discover it was out of attempts, so the chain named a
70
+ // model that never ran and the error shown came from the hop before it.
71
+ if (attempt === maxAttempts - 1) throw failoverExhausted(tried, err);
72
+
73
+ const to = await next({ current, tried: [...tried], reason: marked.reason, marked, error: err });
74
+ // Genuinely out of options. Say that, rather than showing the last provider's error as
75
+ // though it were the whole story — "Groq says no" and "every model you have said no"
76
+ // are different problems with different fixes.
77
+ if (!to) throw failoverExhausted(tried, err);
78
+
79
+ const hop = {
80
+ from: labelOf(current), to: labelOf(to), reason: marked.reason,
81
+ reasons: [`${labelOf(current)} declined (${marked.reason})`, ...(to.routedVia?.reasons || [])],
82
+ next: to,
83
+ };
84
+ try { onHop?.(hop); } catch { /* telling is best effort */ }
85
+ current = to;
86
+ }
87
+ }
88
+ throw lastErr;
89
+ }
90
+
91
+ function defaultLabel(t) {
92
+ return t?.routedVia?.model || t?.name || t?.label || t?.model || t?.id || 'model';
93
+ }
package/index.js CHANGED
@@ -178,6 +178,10 @@ export {
178
178
  createToolLoopGuard, roundSignature, stableToolCallKey, toolMadeProgress, isLoopableTool, blockedToolResult,
179
179
  OBSERVATION_TOOLS, INPUT_PROGRESS_TOOLS,
180
180
  } from './tool-loop-guard.js';
181
+ // Failover — the attempt loop and the health ledger behind it; the host's router picks the
182
+ // next model.
183
+ export { classifyFailure, createModelHealth, COOLDOWN_MS, UNAVAILABLE_REASONS, normModelName } from './model-health.js';
184
+ export { runWithFailover, failoverExhausted, FAILOVER_MAX_ATTEMPTS } from './failover.js';
181
185
  export {
182
186
  runTurnLoop, createCallRunner, roundCap, withToolSystem, describeCall as describeToolCall, stepResultText, addUsage, normalizeUsage,
183
187
  openAiTranscript, anthropicTranscript,
@@ -0,0 +1,182 @@
1
+ // What a provider failure MEANS, and how long to stand the model down for it.
2
+ //
3
+ // Lived in the extension as `js/model-health.js`, wrapped around chrome.storage.session.
4
+ // The desktop could not read it and so could not fail over at all: a model that returned
5
+ // "you have depleted your monthly credits" ended the turn there, while the extension would
6
+ // have moved to the next one. The classifier and the ledger are pure; only the persistence
7
+ // was the extension's, and that is injected now.
8
+ //
9
+ // Only the categories that change what to DO are distinguished. A 402 and a 429 are both
10
+ // "not now", but one is "not for a while" and the other is "in a moment", and routing that
11
+ // treats them the same either hammers a dead endpoint or abandons a live one.
12
+ //
13
+ // Class R: strings in, a category and a deadline out. The clock is injected.
14
+
15
+ const MODEL_STANDDOWN_MS = 30 * 60_000;
16
+
17
+ /** How long to stand a model down, by what went wrong. */
18
+ export const COOLDOWN_MS = Object.freeze({
19
+ quota: 30 * 60_000,
20
+ rate: 60_000,
21
+ server: 2 * 60_000,
22
+ // The model is gone — retired, removed, renamed. It is not coming back, so standing it
23
+ // down for the rest of the session is the honest answer; anything shorter just repeats the
24
+ // same failure on a timer.
25
+ gone: 24 * 60 * 60_000,
26
+ // The account needs reconnecting — a human action, on no timetable. Retrying on a short
27
+ // timer just walks the chain back into the same wall every turn.
28
+ auth: 6 * 60 * 60_000,
29
+ // A request this provider would not take. Another may; this one probably still will not,
30
+ // but it is worth re-checking well before an auth problem.
31
+ request: 10 * 60_000,
32
+ // Nobody is listening. A local model that is not running, a hostname that does not
33
+ // resolve, a server that refuses the connection. It is not coming back on a 30-second
34
+ // timer — someone has to start the thing — and re-dialling it every turn was the exact
35
+ // failure a user watched as ERR_CONNECTION_REFUSED, twice, on two different pages.
36
+ unreachable: 5 * 60_000,
37
+ // Anything else — treat as transient and barely stand it down at all.
38
+ unknown: 30_000,
39
+ });
40
+
41
+ /** The reasons that mean "not available" rather than "not right now". */
42
+ export const UNAVAILABLE_REASONS = Object.freeze(['quota', 'server', 'gone', 'auth', 'request', 'unreachable']);
43
+
44
+ export const normModelName = (m) => String(m || '').toLowerCase().replace(/^[^/]+\//, '').replace(/[:@].*$/, '').replace(/[^a-z0-9.]+/g, '');
45
+
46
+ /**
47
+ * Classify a provider failure — an Error, or a `{ status, message }` the host built from a
48
+ * response it did not throw on.
49
+ */
50
+ export function classifyFailure(err) {
51
+ const text = String(err?.message || err?.error || err || '');
52
+ const status = Number(err?.status) || Number(/\b(4\d\d|5\d\d)\b/.exec(text)?.[1]) || 0;
53
+ if (status === 402 || /credit|quota|billing|payment required|depleted/i.test(text)) return 'quota';
54
+ if (status === 429 || /rate.?limit|too many requests/i.test(text)) return 'rate';
55
+ // THE MODEL IS GONE, not our request. A 410 saying "reached its end of life", a 404 on the
56
+ // model name, a deprecation notice — every other model would handle this request fine, so
57
+ // failing the turn is the one response that helps nobody. Checked BEFORE the generic 4xx
58
+ // rule, which would otherwise read this as our mistake and refuse to fail over.
59
+ if (status === 410
60
+ || /end of life|no longer available|has been (retired|deprecated|removed)|decommissioned/i.test(text)
61
+ // "The model X does not exist or you do not have access to it" — a 404 naming a model is
62
+ // the provider saying THIS model is unusable, not that our request was malformed.
63
+ || /model.*(does not exist|not found|no access|do not have access)|unknown model|no such model/i.test(text)
64
+ // An agent configured for a model it does not have. Nothing about that changes in thirty
65
+ // seconds, and retrying it costs a process spawn to be told the same thing.
66
+ || /invalid model selection|not recognized as a (known|custom) model|unsupported model/i.test(text)) return 'gone';
67
+ if (status >= 500 || /overloaded|unavailable|timeout|ECONNRESET/i.test(text)) return 'server';
68
+ // NOBODY IS LISTENING. A browser fetch to a dead endpoint throws TypeError: Failed to fetch
69
+ // (Safari: "Load failed"); Node says ECONNREFUSED. None carry a status.
70
+ if (/failed to fetch|load failed|networkerror|network error|connection refused|ECONNREFUSED|ERR_CONNECTION|ENOTFOUND|EHOSTUNREACH|ECONNABORTED|couldn't reach the gateway|gateway is not answering/i.test(text)) return 'unreachable';
71
+ // A BROKEN CONNECTION IS THIS PROVIDER'S, NOT THE REQUEST'S. An expired refresh token —
72
+ // "OAuth token exchange failed: HTTP 400 — invalid_grant" — says this provider's
73
+ // credentials went stale, and every other model would have answered the question fine.
74
+ if (status === 401 || status === 403
75
+ || /oauth|invalid[_ ]?grant|refresh[_ ]?token|token exchange|api[_ ]?key|unauthorized|not authenticated|authentication|credential|expired token|sign in|log ?in again/i.test(text)) {
76
+ return 'auth';
77
+ }
78
+ // A plain 400 usually IS a malformed request — but providers reject each other's
79
+ // parameters, tool schemas and sampling settings all the time. Failing over costs one
80
+ // extra attempt; dead-ending costs the user their turn.
81
+ if (status === 400) return 'request';
82
+ // Everything else gets tried elsewhere. A router that gives up on an unrecognised failure
83
+ // is a router that gives up.
84
+ return 'unknown';
85
+ }
86
+
87
+ /**
88
+ * The health ledger: which models are standing down, and why.
89
+ *
90
+ * @param now clock
91
+ * @param onChange `(snapshot) => void` — the host persists it (the extension: the session
92
+ * area; the desktop: memory). Called after every change.
93
+ */
94
+ export function createModelHealth({ now = () => Date.now(), onChange = null } = {}) {
95
+ const health = new Map(); // id -> { until, reason, failures }
96
+ const byModel = new Map(); // normalised model name -> { providers:Set, until, reason }
97
+
98
+ const snapshot = () => ({
99
+ health: [...health].map(([id, h]) => [id, h]),
100
+ byModel: [...byModel].map(([k, m]) => [k, { providers: [...m.providers], until: m.until, reason: m.reason }]),
101
+ });
102
+ const changed = () => { try { onChange?.(snapshot()); } catch { /* persistence is best effort */ } };
103
+
104
+ return {
105
+ snapshot,
106
+
107
+ /** Load what another context already learned. Never overwrites what this one knows. */
108
+ hydrate(snap) {
109
+ if (!snap) return false;
110
+ const t = now();
111
+ for (const [id, h] of snap.health || []) if (h?.until > t && !health.has(id)) health.set(id, h);
112
+ for (const [k, m] of snap.byModel || []) {
113
+ if (!m || byModel.has(k)) continue;
114
+ byModel.set(k, { providers: new Set(m.providers || []), until: m.until || 0, reason: m.reason || null });
115
+ }
116
+ return true;
117
+ },
118
+
119
+ /** Record that a model failed, and stand it down for as long as that failure warrants. */
120
+ markUnhealthy(id, err, modelName = '') {
121
+ const reason = classifyFailure(err);
122
+ if (!id || !reason) return null;
123
+ // Learn about the MODEL, not only the endpoint. A model that is gone is gone everywhere,
124
+ // so one report is enough; anything else needs two providers to agree before we believe
125
+ // it is the model rather than the provider.
126
+ const key = normModelName(modelName);
127
+ if (key) {
128
+ const seen = byModel.get(key) || { providers: new Set(), until: 0, reason: null };
129
+ seen.providers.add(id);
130
+ if (reason === 'gone' || seen.providers.size >= 2) {
131
+ seen.until = now() + MODEL_STANDDOWN_MS;
132
+ seen.reason = reason;
133
+ }
134
+ byModel.set(key, seen);
135
+ }
136
+ const prev = health.get(id);
137
+ const failures = (prev?.failures || 0) + 1;
138
+ // Repeated failures extend the wait, capped — a model failing every time should be
139
+ // tried rarely, not never. The cap never shortens the base: capping a 24-hour
140
+ // stand-down at an hour would retry a model that no longer exists, 23 times a day.
141
+ const base = COOLDOWN_MS[reason] || COOLDOWN_MS.unknown;
142
+ const ceiling = Math.max(base, 60 * 60_000);
143
+ const until = now() + Math.min(base * failures, ceiling);
144
+ health.set(id, { until, reason, failures });
145
+ changed();
146
+ return { reason, until };
147
+ },
148
+
149
+ /** A model answered, so whatever was wrong is over. */
150
+ markHealthy(id) {
151
+ if (id && health.has(id)) { health.delete(id); changed(); }
152
+ },
153
+
154
+ /** `{ available, rateLimited, reason, until? }` for the router. Unknown models are healthy. */
155
+ healthOf(id, modelName = '') {
156
+ const key = normModelName(modelName);
157
+ if (key) {
158
+ const m = byModel.get(key);
159
+ if (m && m.until && now() < m.until) return { available: false, rateLimited: false, reason: m.reason, until: m.until, model: true };
160
+ }
161
+ const h = health.get(id);
162
+ if (!h || now() >= h.until) {
163
+ if (h) health.delete(id); // expired; forget it rather than carrying dead state
164
+ return { available: true, rateLimited: false, reason: null };
165
+ }
166
+ // A rate limit is "not right now"; everything else on the list is "not available". The
167
+ // router rejects both; the reason it shows the user differs.
168
+ return { available: !UNAVAILABLE_REASONS.includes(h.reason), rateLimited: h.reason === 'rate', reason: h.reason, until: h.until };
169
+ },
170
+
171
+ /** What is currently stood down, for a settings page. */
172
+ unhealthyModels() {
173
+ const out = [];
174
+ const t = now();
175
+ for (const [id, h] of health) if (t < h.until) out.push({ id, ...h });
176
+ return out;
177
+ },
178
+
179
+ /** Forget everything. */
180
+ reset() { health.clear(); byModel.clear(); changed(); },
181
+ };
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.95.0",
3
+ "version": "0.96.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts — typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -26,6 +26,7 @@
26
26
  "./entity.js": "./entity.js",
27
27
  "./event.js": "./event.js",
28
28
  "./extraction.js": "./extraction.js",
29
+ "./failover.js": "./failover.js",
29
30
  "./find-tool.js": "./find-tool.js",
30
31
  "./flowchart.js": "./flowchart.js",
31
32
  "./harness.js": "./harness.js",
@@ -49,6 +50,7 @@
49
50
  "./meeting-text.js": "./meeting-text.js",
50
51
  "./memory.js": "./memory.js",
51
52
  "./model-candidates.js": "./model-candidates.js",
53
+ "./model-health.js": "./model-health.js",
52
54
  "./model-ledger.js": "./model-ledger.js",
53
55
  "./model-picker.js": "./model-picker.js",
54
56
  "./note-actions.js": "./note-actions.js",
@@ -161,6 +163,7 @@
161
163
  "entity.js",
162
164
  "event.js",
163
165
  "extraction.js",
166
+ "failover.js",
164
167
  "find-tool.js",
165
168
  "flowchart.js",
166
169
  "harness.js",
@@ -185,6 +188,7 @@
185
188
  "meeting-text.js",
186
189
  "memory.js",
187
190
  "model-candidates.js",
191
+ "model-health.js",
188
192
  "model-ledger.js",
189
193
  "model-picker.js",
190
194
  "note-actions.js",
package/turn-loop.js CHANGED
@@ -412,7 +412,8 @@ export async function runTurnLoop({
412
412
  // Reconcile: an adapter that returned text without streaming it still gets it into `said`.
413
413
  const text = String(res?.text || '');
414
414
  if (text && !roundText) { if (said) said += ROUND_SEPARATOR; said += text; roundText = text; }
415
- if (!res?.ok) { finish('error'); return result({ ok: false, error: res?.error || 'the model did not answer', aborted: !!res?.aborted, transcript: text.trim() ? [...convo, transcript.said(said)] : convo }); }
415
+ // `status` rides along when the adapter had one a failover classifier reads it.
416
+ if (!res?.ok) { finish('error'); return result({ ok: false, error: res?.error || 'the model did not answer', ...(res?.status ? { status: res.status } : {}), aborted: !!res?.aborted, transcript: text.trim() ? [...convo, transcript.said(said)] : convo }); }
416
417
  if (res.aborted || signal?.aborted) { finish('aborted'); return closeWith(said, { aborted: true }); }
417
418
 
418
419
  const wanted = (Array.isArray(res.toolCalls) ? res.toolCalls : []).filter((c) => c && c.name).map((c) => ({ id: c.id, name: c.name, input: c.input ?? safeJson(c.arguments), arguments: c.arguments }));