@chatpanel/events 0.101.0 → 0.103.1
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/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +1 -4
- package/pdf-layout.js +7 -253
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -348
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/model-candidates.js
CHANGED
|
@@ -1,358 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Routing needs attributes nobody types in: how far a request travels to reach a model,
|
|
4
|
-
// what it can probably do, roughly what it costs, how good it is likely to be. The extension
|
|
5
|
-
// inferred these from names and URLs for its own endpoint and agent records; the desktop
|
|
6
|
-
// needed the same inference over the gateway's model list, and a second copy of a guess is
|
|
7
|
-
// two guesses that drift. So the heuristics live here and each client hands in its own
|
|
8
|
-
// shape: `inferCandidate(target, kind, { override, health })` takes anything with
|
|
9
|
-
// `{ id?, name?, model?, baseUrl?|url?, kind?, enabled?, bridgeAgent? }` and returns a router
|
|
10
|
-
// model. Health (rate-limited, observed down) is INJECTED, because measuring it is a host's
|
|
11
|
-
// job — the extension keeps a health map, the desktop asks the gateway.
|
|
12
|
-
//
|
|
13
|
-
// Everything below is a starting point the user corrects (`applyOverride`), never a verdict.
|
|
14
|
-
|
|
15
|
-
import { classifySource } from './sources.js';
|
|
16
|
-
import { defineModel } from './router.js';
|
|
17
|
-
|
|
18
|
-
/** Where a request must travel to reach this target — the only attribute privacy depends on. */
|
|
19
|
-
export function reachOf(target) {
|
|
20
|
-
// A bridge agent runs a CLI on the user's own machine; the model behind it may still be
|
|
21
|
-
// remote, which is why this says 'trusted' rather than 'device'. Claiming otherwise would
|
|
22
|
-
// let a device-only request reach a cloud model through a local process.
|
|
23
|
-
if (target.kind === 'bridge') return 'trusted';
|
|
24
|
-
const url = String(target.baseUrl || target.url || '');
|
|
25
|
-
if (/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)(:|\/|$)/i.test(url)) return 'device';
|
|
26
|
-
// A .local or on-LAN host is the user's own machine or network — not a third party, but
|
|
27
|
-
// not the device either. Same private-address rules as the source classifier, so
|
|
28
|
-
// "internal" cannot mean one thing for a page and another for an endpoint.
|
|
29
|
-
//
|
|
30
|
-
// BUT THE FAIL-SAFE DIRECTION IS OPPOSITE HERE. classifySource fails CLOSED — an
|
|
31
|
-
// unreadable URL counts as internal, because a source we cannot identify must not be sent
|
|
32
|
-
// out. A DESTINATION we cannot identify is the reverse: calling it 'trusted' would admit
|
|
33
|
-
// it to a restricted turn. So an unparseable endpoint is treated as the furthest reach.
|
|
34
|
-
const c = classifySource(url);
|
|
35
|
-
return c.internal && c.matched !== 'unparseable' ? 'trusted' : 'any';
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* The levers a user can pull, and what each one means for routing.
|
|
40
|
-
*
|
|
41
|
-
* Named rather than free-form: a capability only matters if something asks for it, and a
|
|
42
|
-
* typo in a free-text field would silently make a model ineligible forever with no way to
|
|
43
|
-
* see why.
|
|
44
|
-
*/
|
|
45
|
-
export const KNOWN_CAPABILITIES = Object.freeze([
|
|
46
|
-
{ id: 'tools', label: 'Tools', hint: 'Can call functions — needed for page actions, search and MCP.' },
|
|
47
|
-
{ id: 'vision', label: 'Vision', hint: 'Can read images and screenshots.' },
|
|
48
|
-
{ id: 'reasoning', label: 'Reasoning', hint: 'Thinks before answering — worth the wait on hard tasks.' },
|
|
49
|
-
{ id: 'long-context', label: 'Long context', hint: 'Handles large documents and long meetings.' },
|
|
50
|
-
{ id: 'coding', label: 'Coding', hint: 'Strong at writing and refactoring code.' },
|
|
51
|
-
{ id: 'json', label: 'Structured output', hint: 'Reliably returns valid JSON.' },
|
|
52
|
-
// The media capabilities (architecture-pillars.md §13.2). Never guessed from a name: a
|
|
53
|
-
// model earns these by proof (the ledger's `capability` entries) or by the person's word.
|
|
54
|
-
{ id: 'speech-in', label: 'Speech in', hint: 'Takes audio as input — a meeting, a voice note.' },
|
|
55
|
-
{ id: 'speech-out', label: 'Speech out', hint: 'Speaks its answer.' },
|
|
56
|
-
{ id: 'audio', label: 'Audio', hint: 'Understands audio content beyond speech — music, sounds.' },
|
|
57
|
-
{ id: 'image-out', label: 'Image out', hint: 'Generates images.' },
|
|
58
|
-
]);
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* What a model can probably do, guessed from its name.
|
|
62
|
-
*
|
|
63
|
-
* Conservative on purpose: an unproven capability claimed here becomes a failed turn, and a
|
|
64
|
-
* missing one only means the router does not volunteer it. The user corrects both — these
|
|
65
|
-
* are a starting point, not a verdict.
|
|
66
|
-
*/
|
|
67
|
-
export function capabilitiesOf(target) {
|
|
68
|
-
const m = String(target.model || '').toLowerCase();
|
|
69
|
-
const caps = new Set(['json']);
|
|
70
|
-
// Bridge agents relay tools through the bridge's MCP server; API endpoints vary, so tool
|
|
71
|
-
// support is assumed only where the user has actually configured a model for it.
|
|
72
|
-
if (target.kind === 'bridge' || target.model) caps.add('tools');
|
|
73
|
-
if (/gpt-4|gpt-5|claude|gemini|vision|vl\b|llava|pixtral/.test(m)) caps.add('vision');
|
|
74
|
-
if (/o1|o3|r1|reason|think|opus|sonnet|deepseek-r/.test(m)) caps.add('reasoning');
|
|
75
|
-
if (/200k|1m\b|long|gemini|claude|gpt-4\.1|gpt-5/.test(m)) caps.add('long-context');
|
|
76
|
-
if (/code|coder|codex|deepseek|qwen|opus|sonnet/.test(m)) caps.add('coding');
|
|
77
|
-
// A CLI coding agent is a coding agent, whatever its model is called.
|
|
78
|
-
if (target.kind === 'bridge') { caps.add('coding'); caps.add('reasoning'); }
|
|
79
|
-
return [...caps];
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Which provider to prefer when two of them offer the same model. Lower wins.
|
|
84
|
-
*
|
|
85
|
-
* Ties were breaking alphabetically, which is not a preference — it is the absence of one,
|
|
86
|
-
* and it sent every equal choice to whichever provider happened to sort first. The order
|
|
87
|
-
* below is a starting point with a reason behind each rung; the user overrides it per model.
|
|
88
|
-
*
|
|
89
|
-
* FEWER HOPS FIRST. A direct API is one network call to the people who run the model; an
|
|
90
|
-
* aggregator adds a hop, its own quotas, and its own outages on top of the provider's. When
|
|
91
|
-
* everything else is equal, the shorter path is the more reliable one.
|
|
92
|
-
*/
|
|
93
|
-
const PROVIDER_ORDER = [
|
|
94
|
-
// The user's own machine: no quota, no outage, no third party.
|
|
95
|
-
/localhost|127\.0\.0\.1|ollama|lm.?studio/i,
|
|
96
|
-
// First-party APIs.
|
|
97
|
-
/anthropic|openai\.com|api\.deepseek|googleapis|x\.ai/i,
|
|
98
|
-
// Local CLI agents — capable, but they spawn a process and run their own loop.
|
|
99
|
-
/(^|\W)bridge(\W|$)/i,
|
|
100
|
-
// Aggregators and gateways: an extra hop and someone else's quota.
|
|
101
|
-
/openrouter|huggingface|together|groq|fireworks|nvidia|replicate/i,
|
|
102
|
-
];
|
|
103
|
-
|
|
104
|
-
// Inferred ranks sit ABOVE every number the settings UI can produce (it offers 1..N), so
|
|
105
|
-
// an order someone chose by hand always outranks one we guessed. Sharing the range meant
|
|
106
|
-
// picking "Order: 1" still lost to a local model we had silently rated 0 — the setting looked
|
|
107
|
-
// like the top priority and was not.
|
|
108
|
-
const INFERRED_RANK_FLOOR = 1000;
|
|
109
|
-
|
|
110
|
-
export function providerRankOf(target, kind) {
|
|
111
|
-
const hay = `${target.baseUrl || target.url || ''} ${target.name || ''} ${kind || target.kind || ''}`;
|
|
112
|
-
for (let i = 0; i < PROVIDER_ORDER.length; i++) {
|
|
113
|
-
if (PROVIDER_ORDER[i].test(hay)) return INFERRED_RANK_FLOOR + i * 10;
|
|
114
|
-
}
|
|
115
|
-
// Unrecognised: mid-table, so a provider we have no opinion on is not buried.
|
|
116
|
-
return INFERRED_RANK_FLOOR + 50;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Roughly how capable a model is, guessed from its name.
|
|
121
|
-
*
|
|
122
|
-
* Shipping the quality lever with no default meant every model scored the same, so a
|
|
123
|
-
* frontier model that declined was replaced by an 8B instant model with equal standing —
|
|
124
|
-
* "same capabilities, cheaper" is what the ranking saw, and it is nonsense. A wrong guess a
|
|
125
|
-
* user can correct beats a blank that makes every model interchangeable.
|
|
126
|
-
*
|
|
127
|
-
* Names are a crude signal and deliberately so: this only has to ORDER models, not score
|
|
128
|
-
* them, and the ordering it needs is the obvious one everybody already knows.
|
|
129
|
-
*/
|
|
130
|
-
export function qualityOf(target) {
|
|
131
|
-
const m = `${target.model || ''} ${target.name || ''}`.toLowerCase();
|
|
132
|
-
|
|
133
|
-
// Parameter count, READ AS A NUMBER rather than pattern-matched. A regex for "any digits
|
|
134
|
-
// followed by b" cannot tell 8B from 26B from 405B, and the first version of this scored
|
|
135
|
-
// a 26B model as tiny for exactly that reason. Size is a number; treat it as one.
|
|
136
|
-
const size = Number(/(\d+(?:\.\d+)?)\s*b\b/.exec(m)?.[1]);
|
|
137
|
-
if (Number.isFinite(size)) {
|
|
138
|
-
if (size >= 60) return 0.85; // frontier-scale open weights
|
|
139
|
-
if (size >= 20) return 0.6; // the solid mid-range most people run locally
|
|
140
|
-
return 0.3; // small and fast, never a stand-in for a frontier model
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Named tiers, for hosted models that do not advertise a size.
|
|
144
|
-
if (/instant|mini|nano|tiny|lite|-small\b|haiku/.test(m)) return 0.3;
|
|
145
|
-
if (/opus|gpt-5|o1|o3|\bpro\b|ultra|deepseek-r|thinking/.test(m)) return 0.9;
|
|
146
|
-
if (/sonnet|gpt-4|flash|gemini|deepseek|qwen|mistral|codestral/.test(m)) return 0.6;
|
|
147
|
-
// A CLI HARNESS IS NOT AN UNKNOWN MODEL, and the harness's NAME is not its model's name.
|
|
148
|
-
//
|
|
149
|
-
// Claude Code, Codex and the rest usually carry no `model` string — the CLI picks that
|
|
150
|
-
// itself — and 'Claude Code' matches none of the tiers above, so every coding agent landed
|
|
151
|
-
// on the "genuinely unknown" 0.5 below. requirementsFor puts a 0.55 quality floor on
|
|
152
|
-
// complex, code and structured turns, so 0.5 meant a CLI coding agent was ELIMINATED from
|
|
153
|
-
// precisely the tasks it exists for — rejected as "below the quality this task needs" while
|
|
154
|
-
// the work went to an API model. A harness running a frontier model behind its own loop is
|
|
155
|
-
// not the weakest thing configured.
|
|
156
|
-
//
|
|
157
|
-
// LAST, not first: an agent that names its model has told us something better than this
|
|
158
|
-
// default, and overriding it would make a declared `opus` indistinguishable from a bare
|
|
159
|
-
// harness — which is exactly the distance failover ranks by.
|
|
160
|
-
if (target.kind === 'bridge') return 0.8;
|
|
161
|
-
|
|
162
|
-
return 0.5; // genuinely unknown: mid-table, so it is neither buried nor promoted
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Rough relative cost — unitless, and only ever compared against its siblings. */
|
|
166
|
-
/**
|
|
167
|
-
* Roughly how long this model takes, from the two things that actually decide it.
|
|
168
|
-
*
|
|
169
|
-
* This used to read WHERE a model runs and nothing else — every hosted model 700ms, every
|
|
170
|
-
* local one 1500ms — so an 8B and a frontier model at the same provider were equally fast.
|
|
171
|
-
* Asking the router for speed could therefore never find the small model, which is the one
|
|
172
|
-
* thing "prefer latency" exists to do.
|
|
173
|
-
*
|
|
174
|
-
* SIZE IS THE OTHER HALF. A frontier model thinks for longer than an 8B wherever it runs,
|
|
175
|
-
* and quality is the only size signal available here — it is already inferred from the
|
|
176
|
-
* parameter count in the name (see qualityOf), and already correctable by the user, so
|
|
177
|
-
* deriving from it keeps one number to fix rather than two.
|
|
178
|
-
*
|
|
179
|
-
* Still a guess, deliberately crude: this only has to ORDER models. Health can measure the
|
|
180
|
-
* real thing later and override it per model, which is exactly why it is a plain field.
|
|
181
|
-
*/
|
|
182
|
-
export function latencyOf(reach, quality) {
|
|
183
|
-
// A local model is slower to first token than a hosted one far more often than not: no
|
|
184
|
-
// warm pool, and usually a laptop rather than a datacentre.
|
|
185
|
-
const base = reach === 'device' ? 1500 : 700;
|
|
186
|
-
const q = Number.isFinite(quality) ? quality : 0.5;
|
|
187
|
-
// 0.6 + q: an 8B (0.3) is ~0.9x the base, a frontier model (0.9) ~1.5x. A spread of under
|
|
188
|
-
// two to one, because the difference is real but not the order of magnitude a bigger
|
|
189
|
-
// coefficient would claim.
|
|
190
|
-
return Math.round(base * (0.6 + q));
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function costOf(target, reach) {
|
|
194
|
-
if (reach === 'device') return 0;
|
|
195
|
-
const m = String(target.model || '').toLowerCase();
|
|
196
|
-
if (/opus|gpt-4|pro\b/.test(m)) return 5;
|
|
197
|
-
if (/sonnet|mini|flash|haiku/.test(m)) return 1;
|
|
198
|
-
return 2;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Everything the router infers about one model, and what the user said instead.
|
|
203
|
-
*
|
|
204
|
-
* Defaults are guesses — a name matched against a regex, a URL judged local. They are right
|
|
205
|
-
* often enough to be useful and wrong often enough that someone who knows their own setup
|
|
206
|
-
* must be able to say so. A router that cannot be corrected is one people work around.
|
|
207
|
-
*
|
|
208
|
-
* OVERRIDES CAN ONLY MOVE REACH OUTWARD. Every other attribute is the user's to set, but
|
|
209
|
-
* reach is what privacy depends on, and the two directions are not symmetric:
|
|
210
|
-
*
|
|
211
|
-
* 'this cloud endpoint is really on my device' — would let a device-only request reach a
|
|
212
|
-
* third party, from one typo or one synced settings file. Refused.
|
|
213
|
-
* 'this local-looking endpoint actually goes out' — makes FEWER requests eligible for it.
|
|
214
|
-
* Always allowed, because a user is entitled to trust their own setup less than we do.
|
|
215
|
-
*
|
|
216
|
-
* A model that reaches further can serve fewer kinds of request, so outward is the safe
|
|
217
|
-
* direction and inward is the one that has to be earned rather than declared.
|
|
218
|
-
*/
|
|
219
|
-
/**
|
|
220
|
-
* A number the user actually SET, or null for "cleared — use what we inferred".
|
|
221
|
-
*
|
|
222
|
-
* CLEARING AN OVERRIDE WAS SETTING IT TO ZERO. The settings selects write `null` for their
|
|
223
|
-
* "default" option, and the guard here was `Number.isFinite(Number(v))` — but `Number(null)`
|
|
224
|
-
* is 0 and 0 is finite, so every cleared field became a real, extreme value. Picking
|
|
225
|
-
* "Speed: default" made a model claim it answers in 0 ms; "Cost: default" made it free;
|
|
226
|
-
* "Quality: default" made it worthless; and clearing Order pinned it at position 0, ahead of
|
|
227
|
-
* everything, flagged as a deliberate choice.
|
|
228
|
-
*
|
|
229
|
-
* It stayed invisible while the balanced score multiplied cost by latency — every free model
|
|
230
|
-
* scored 0 anyway. The moment a request could ask for SPEED, a model with a cleared speed
|
|
231
|
-
* field beat everything that had a real one, and "hi" went to the most expensive model
|
|
232
|
-
* configured. An unset field must read as unset.
|
|
233
|
-
*/
|
|
234
|
-
export function numericOverride(v) {
|
|
235
|
-
if (v === null || v === undefined || v === '' || typeof v === 'boolean') return null;
|
|
236
|
-
const n = Number(v);
|
|
237
|
-
return Number.isFinite(n) ? n : null;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export function applyOverride(inferred, override = {}) {
|
|
241
|
-
if (!override || typeof override !== 'object') return inferred;
|
|
242
|
-
const out = { ...inferred };
|
|
243
|
-
const rank = numericOverride(override.providerRank);
|
|
244
|
-
if (rank !== null) {
|
|
245
|
-
out.providerRank = rank;
|
|
246
|
-
// Flagged as chosen, not guessed: the router honours a hand-set order outright between
|
|
247
|
-
// two routes to one model, and treats the order we inferred as a tie-break only.
|
|
248
|
-
out.orderPinned = true;
|
|
249
|
-
}
|
|
250
|
-
if (Array.isArray(override.capabilities)) out.capabilities = [...override.capabilities];
|
|
251
|
-
for (const key of ['costPer1k', 'latencyMs', 'quality']) {
|
|
252
|
-
const n = numericOverride(override[key]);
|
|
253
|
-
if (n !== null) out[key] = n;
|
|
254
|
-
}
|
|
255
|
-
if (typeof override.available === 'boolean') out.available = override.available;
|
|
256
|
-
if (override.reach && REACH_RANK[override.reach] > REACH_RANK[inferred.reach]) {
|
|
257
|
-
// Outward only. See the note above.
|
|
258
|
-
out.reach = override.reach;
|
|
259
|
-
}
|
|
260
|
-
return out;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ── The engine card over the guess (model-ledger.js, architecture-pillars.md §13.2) ──────
|
|
264
|
-
|
|
265
|
-
import { DEFAULT_MIN_CALLS, cardOverride } from './model-ledger.js';
|
|
266
|
-
export { DEFAULT_MIN_CALLS };
|
|
267
|
-
|
|
268
|
-
export { cardOverride } from './model-ledger.js';
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* A router model with its card applied: `applyOverride` with what the card observed, then
|
|
272
|
-
* withdrawn capabilities removed (a proof beats a guess in both directions). A person's own
|
|
273
|
-
* override (`userOverride`) is applied LAST — what they said outranks what was observed —
|
|
274
|
-
* except reach, which `applyOverride` already keeps outward-only. The result carries
|
|
275
|
-
* `observed: [...]` so the Context Ledger can say guess or observed per field.
|
|
276
|
-
*/
|
|
277
|
-
export function applyCard(inferred, card, { minCalls = DEFAULT_MIN_CALLS, jobKind = null, userOverride = null } = {}) {
|
|
278
|
-
const { override, observed } = cardOverride(card, { minCalls, jobKind });
|
|
279
|
-
let out = applyOverride(inferred, override);
|
|
280
|
-
const withdrawn = new Set(card?.capabilities?.withdrawn || []);
|
|
281
|
-
if (withdrawn.size && Array.isArray(out.capabilities)) { out = { ...out, capabilities: out.capabilities.filter((c) => !withdrawn.has(c)) }; observed.push('capabilities'); }
|
|
282
|
-
if (userOverride) out = applyOverride(out, userOverride);
|
|
283
|
-
return { ...out, observed };
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
export const REACH_RANK = { device: 0, trusted: 1, any: 2 };
|
|
287
|
-
const REACH_STEPS = ['device', 'trusted', 'any'];
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* The reach values a user may declare for a model we detected as `detected`.
|
|
291
|
-
*
|
|
292
|
-
* The rule and the CONTROL that offers it have to come from one place. They did not: the
|
|
293
|
-
* settings page built its options by slicing from the model's CURRENT reach, which is the
|
|
294
|
-
* value after the override has been applied — so saving 'any' left 'any' as the only option
|
|
295
|
-
* and the correction could never be taken back. Enforcing outward-only in applyOverride while
|
|
296
|
-
* a second copy of the rule decided what to offer is what turned a safety rule into a
|
|
297
|
-
* one-way door.
|
|
298
|
-
*
|
|
299
|
-
* Always includes `detected` itself: coming back to what we detected is not moving inward, it
|
|
300
|
-
* is dropping the override. Anything closer in than the detection is never offered, because
|
|
301
|
-
* applyOverride would refuse it and a control that silently discards half its own values is
|
|
302
|
-
* worse than no control.
|
|
303
|
-
*/
|
|
304
|
-
export function reachChoicesFor(detected) {
|
|
305
|
-
const i = REACH_STEPS.indexOf(detected);
|
|
306
|
-
return i < 0 ? [...REACH_STEPS] : REACH_STEPS.slice(i);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* One router model from one configured target. The core of every client's candidate list.
|
|
312
|
-
*
|
|
313
|
-
* `kind` is 'bridge' for a CLI agent on this machine and 'api' for an HTTP endpoint;
|
|
314
|
-
* `override` is what the user said about this model (see applyOverride); `health` is
|
|
315
|
-
* `{ available, rateLimited }` as the host measured it, or null for "nothing observed".
|
|
316
|
-
* Returns null for a target that names no model and is not an agent — nothing to route to.
|
|
317
|
-
*/
|
|
318
|
-
export function inferCandidate(t, kind, { override = null, health = null } = {}) {
|
|
319
|
-
if (!t || (!t.model && kind !== 'bridge' && t.kind !== 'bridge')) return null;
|
|
320
|
-
const id = t.id || t.name || t.model;
|
|
321
|
-
if (!id) return null;
|
|
322
|
-
// NEVER A GENERATED ID. 'mqr0ifmw7sqxr7' appeared as the answer to "which model did this"
|
|
323
|
-
// in a real log — falling back to the id was the same as having no label at all. A bridge
|
|
324
|
-
// agent the user never renamed still knows which CLI it runs, and that is readable.
|
|
325
|
-
const label = [t.name, t.model && t.model !== t.name ? t.model : null]
|
|
326
|
-
.filter(Boolean).join(' · ')
|
|
327
|
-
|| [t.bridgeAgent, t.model].filter(Boolean).join(' · ')
|
|
328
|
-
|| kind || t.kind
|
|
329
|
-
|| String(id);
|
|
330
|
-
const k = kind || t.kind;
|
|
331
|
-
const reach = reachOf({ ...t, kind: k });
|
|
332
|
-
const inferred = {
|
|
333
|
-
id,
|
|
334
|
-
label,
|
|
335
|
-
// Kept so failover can recognise the SAME model at another provider — the closest
|
|
336
|
-
// possible replacement, and invisible if only the display label survived.
|
|
337
|
-
model: t.model || '',
|
|
338
|
-
reach,
|
|
339
|
-
classUsed: reach === 'device' ? 'L' : (k === 'bridge' ? 'A' : 'C'),
|
|
340
|
-
capabilities: capabilitiesOf({ ...t, kind: k }),
|
|
341
|
-
costPer1k: costOf(t, reach),
|
|
342
|
-
latencyMs: latencyOf(reach, qualityOf({ ...t, kind: k })),
|
|
343
|
-
quality: qualityOf({ ...t, kind: k }),
|
|
344
|
-
providerRank: providerRankOf(t, k),
|
|
345
|
-
available: t.enabled !== false,
|
|
346
|
-
};
|
|
347
|
-
const configured = applyOverride(inferred, override || {});
|
|
348
|
-
// AN EXPLICIT DISABLE OUTRANKS A TUNING OVERRIDE. `enabled: false` is the user saying,
|
|
349
|
-
// right now, "don't use this"; a routing override is a hint saved earlier.
|
|
350
|
-
if (t.enabled === false) configured.available = false;
|
|
351
|
-
// A CORRECTED QUALITY CORRECTS THE SPEED DERIVED FROM IT — unless the user set the speed.
|
|
352
|
-
if (numericOverride(override?.latencyMs) === null) configured.latencyMs = latencyOf(reach, configured.quality);
|
|
353
|
-
return defineModel({
|
|
354
|
-
...configured,
|
|
355
|
-
available: configured.available && (health ? health.available !== false : true),
|
|
356
|
-
rateLimited: !!health?.rateLimited,
|
|
357
|
-
});
|
|
358
|
-
}
|
|
1
|
+
import{classifySource as y}from"./sources.js";import{defineModel as x}from"./router.js";function w(e){if(e.kind==="bridge")return"trusted";const n=String(e.baseUrl||e.url||"");if(/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)(:|\/|$)/i.test(n))return"device";const i=y(n);return i.internal&&i.matched!=="unparseable"?"trusted":"any"}const L=Object.freeze([{id:"tools",label:"Tools",hint:"Can call functions — needed for page actions, search and MCP."},{id:"vision",label:"Vision",hint:"Can read images and screenshots."},{id:"reasoning",label:"Reasoning",hint:"Thinks before answering — worth the wait on hard tasks."},{id:"long-context",label:"Long context",hint:"Handles large documents and long meetings."},{id:"coding",label:"Coding",hint:"Strong at writing and refactoring code."},{id:"json",label:"Structured output",hint:"Reliably returns valid JSON."},{id:"speech-in",label:"Speech in",hint:"Takes audio as input — a meeting, a voice note."},{id:"speech-out",label:"Speech out",hint:"Speaks its answer."},{id:"audio",label:"Audio",hint:"Understands audio content beyond speech — music, sounds."},{id:"image-out",label:"Image out",hint:"Generates images."}]);function O(e){const n=String(e.model||"").toLowerCase(),i=new Set(["json"]);return(e.kind==="bridge"||e.model)&&i.add("tools"),/gpt-4|gpt-5|claude|gemini|vision|vl\b|llava|pixtral/.test(n)&&i.add("vision"),/o1|o3|r1|reason|think|opus|sonnet|deepseek-r/.test(n)&&i.add("reasoning"),/200k|1m\b|long|gemini|claude|gpt-4\.1|gpt-5/.test(n)&&i.add("long-context"),/code|coder|codex|deepseek|qwen|opus|sonnet/.test(n)&&i.add("coding"),e.kind==="bridge"&&(i.add("coding"),i.add("reasoning")),[...i]}const p=[/localhost|127\.0\.0\.1|ollama|lm.?studio/i,/anthropic|openai\.com|api\.deepseek|googleapis|x\.ai/i,/(^|\W)bridge(\W|$)/i,/openrouter|huggingface|together|groq|fireworks|nvidia|replicate/i],b=1e3;function R(e,n){const i=`${e.baseUrl||e.url||""} ${e.name||""} ${n||e.kind||""}`;for(let t=0;t<p.length;t++)if(p[t].test(i))return b+t*10;return b+50}function m(e){const n=`${e.model||""} ${e.name||""}`.toLowerCase(),i=Number(/(\d+(?:\.\d+)?)\s*b\b/.exec(n)?.[1]);return Number.isFinite(i)?i>=60?.85:i>=20?.6:.3:/instant|mini|nano|tiny|lite|-small\b|haiku/.test(n)?.3:/opus|gpt-5|o1|o3|\bpro\b|ultra|deepseek-r|thinking/.test(n)?.9:/sonnet|gpt-4|flash|gemini|deepseek|qwen|mistral|codestral/.test(n)?.6:e.kind==="bridge"?.8:.5}function g(e,n){const i=e==="device"?1500:700,t=Number.isFinite(n)?n:.5;return Math.round(i*(.6+t))}function S(e,n){if(n==="device")return 0;const i=String(e.model||"").toLowerCase();return/opus|gpt-4|pro\b/.test(i)?5:/sonnet|mini|flash|haiku/.test(i)?1:2}function c(e){if(e==null||e===""||typeof e=="boolean")return null;const n=Number(e);return Number.isFinite(n)?n:null}function u(e,n={}){if(!n||typeof n!="object")return e;const i={...e},t=c(n.providerRank);t!==null&&(i.providerRank=t,i.orderPinned=!0),Array.isArray(n.capabilities)&&(i.capabilities=[...n.capabilities]);for(const a of["costPer1k","latencyMs","quality"]){const l=c(n[a]);l!==null&&(i[a]=l)}return typeof n.available=="boolean"&&(i.available=n.available),n.reach&&k[n.reach]>k[e.reach]&&(i.reach=n.reach),i}import{DEFAULT_MIN_CALLS as h,cardOverride as v}from"./model-ledger.js";import{cardOverride as I}from"./model-ledger.js";function E(e,n,{minCalls:i=h,jobKind:t=null,userOverride:a=null}={}){const{override:l,observed:r}=v(n,{minCalls:i,jobKind:t});let o=u(e,l);const d=new Set(n?.capabilities?.withdrawn||[]);return d.size&&Array.isArray(o.capabilities)&&(o={...o,capabilities:o.capabilities.filter(s=>!d.has(s))},r.push("capabilities")),a&&(o=u(o,a)),{...o,observed:r}}const k={device:0,trusted:1,any:2},f=["device","trusted","any"];function M(e){const n=f.indexOf(e);return n<0?[...f]:f.slice(n)}function _(e,n,{override:i=null,health:t=null}={}){if(!e||!e.model&&n!=="bridge"&&e.kind!=="bridge")return null;const a=e.id||e.name||e.model;if(!a)return null;const l=[e.name,e.model&&e.model!==e.name?e.model:null].filter(Boolean).join(" · ")||[e.bridgeAgent,e.model].filter(Boolean).join(" · ")||n||e.kind||String(a),r=n||e.kind,o=w({...e,kind:r}),d={id:a,label:l,model:e.model||"",reach:o,classUsed:o==="device"?"L":r==="bridge"?"A":"C",capabilities:O({...e,kind:r}),costPer1k:S(e,o),latencyMs:g(o,m({...e,kind:r})),quality:m({...e,kind:r}),providerRank:R(e,r),available:e.enabled!==!1},s=u(d,i||{});return e.enabled===!1&&(s.available=!1),c(i?.latencyMs)===null&&(s.latencyMs=g(o,s.quality)),x({...s,available:s.available&&(t?t.available!==!1:!0),rateLimited:!!t?.rateLimited})}export{h as DEFAULT_MIN_CALLS,L as KNOWN_CAPABILITIES,k as REACH_RANK,E as applyCard,u as applyOverride,O as capabilitiesOf,I as cardOverride,S as costOf,_ as inferCandidate,g as latencyOf,c as numericOverride,R as providerRankOf,m as qualityOf,M as reachChoicesFor,w as reachOf};
|
package/model-health.js
CHANGED
|
@@ -1,182 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
const _=18e5,p=Object.freeze({quota:18e5,rate:6e4,server:12e4,gone:864e5,auth:216e5,request:6e5,unreachable:3e5,unknown:3e4}),N=Object.freeze(["quota","server","gone","auth","request","unreachable"]),g=r=>String(r||"").toLowerCase().replace(/^[^/]+\//,"").replace(/[:@].*$/,"").replace(/[^a-z0-9.]+/g,"");function b(r){const s=String(r?.message||r?.error||r||""),n=Number(r?.status)||Number(/\b(4\d\d|5\d\d)\b/.exec(s)?.[1])||0;return n===402||/credit|quota|billing|payment required|depleted/i.test(s)?"quota":n===429||/rate.?limit|too many requests/i.test(s)?"rate":n===410||/end of life|no longer available|has been (retired|deprecated|removed)|decommissioned/i.test(s)||/model.*(does not exist|not found|no access|do not have access)|unknown model|no such model/i.test(s)||/invalid model selection|not recognized as a (known|custom) model|unsupported model/i.test(s)?"gone":n>=500||/overloaded|unavailable|timeout|ECONNRESET/i.test(s)?"server":/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(s)?"unreachable":n===401||n===403||/oauth|invalid[_ ]?grant|refresh[_ ]?token|token exchange|api[_ ]?key|unauthorized|not authenticated|authentication|credential|expired token|sign in|log ?in again/i.test(s)?"auth":n===400?"request":"unknown"}function E({now:r=()=>Date.now(),onChange:s=null}={}){const n=new Map,l=new Map,d=()=>({health:[...n].map(([t,o])=>[t,o]),byModel:[...l].map(([t,o])=>[t,{providers:[...o.providers],until:o.until,reason:o.reason}])}),c=()=>{try{s?.(d())}catch{}};return{snapshot:d,hydrate(t){if(!t)return!1;const o=r();for(const[a,e]of t.health||[])e?.until>o&&!n.has(a)&&n.set(a,e);for(const[a,e]of t.byModel||[])!e||l.has(a)||l.set(a,{providers:new Set(e.providers||[]),until:e.until||0,reason:e.reason||null});return!0},markUnhealthy(t,o,a=""){const e=b(o);if(!t||!e)return null;const i=g(a);if(i){const u=l.get(i)||{providers:new Set,until:0,reason:null};u.providers.add(t),(e==="gone"||u.providers.size>=2)&&(u.until=r()+18e5,u.reason=e),l.set(i,u)}const f=(n.get(t)?.failures||0)+1,h=p[e]||p.unknown,v=Math.max(h,60*6e4),m=r()+Math.min(h*f,v);return n.set(t,{until:m,reason:e,failures:f}),c(),{reason:e,until:m}},markHealthy(t){t&&n.has(t)&&(n.delete(t),c())},healthOf(t,o=""){const a=g(o);if(a){const i=l.get(a);if(i&&i.until&&r()<i.until)return{available:!1,rateLimited:!1,reason:i.reason,until:i.until,model:!0}}const e=n.get(t);return!e||r()>=e.until?(e&&n.delete(t),{available:!0,rateLimited:!1,reason:null}):{available:!N.includes(e.reason),rateLimited:e.reason==="rate",reason:e.reason,until:e.until}},unhealthyModels(){const t=[],o=r();for(const[a,e]of n)o<e.until&&t.push({id:a,...e});return t},reset(){n.clear(),l.clear(),c()}}}export{p as COOLDOWN_MS,N as UNAVAILABLE_REASONS,b as classifyFailure,E as createModelHealth,g as normModelName};
|