@chatpanel/events 0.85.0 → 0.88.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/agent.js +248 -0
- package/attribution.js +132 -0
- package/client-prefs.js +10 -1
- package/engine.js +131 -0
- package/gate.js +74 -0
- package/index.js +11 -2
- package/job.js +148 -0
- package/model-candidates.js +376 -0
- package/model-ledger.js +204 -0
- package/model-picker.js +3 -1
- package/package.json +19 -1
- package/project.js +170 -0
- package/route-strategies.js +232 -0
- package/scm-connection.js +180 -0
- package/scorecard.js +148 -4
- package/team-run.js +41 -6
- package/team-tool.js +16 -6
- package/team-trail.js +6 -0
- package/team.js +104 -9
- package/voice-speaker.js +98 -0
package/job.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// A job — a posting on a project's board that the pool applies to.
|
|
2
|
+
//
|
|
3
|
+
// The executive posts the first jobs; a recruited agent posts more when the work needs more
|
|
4
|
+
// hands, a skill or a tool it does not have; a person posts one by hand. A job says what it
|
|
5
|
+
// needs (skills, tools, grants), what it may cost (carved from the project's budget), and
|
|
6
|
+
// where the work happens (a repo and a base branch — the bridge gives it a worktree). Agents
|
|
7
|
+
// in the pool APPLY by construction (recruit.js scores every type at once); an evaluator
|
|
8
|
+
// picks; the pick is recruited with a budget and the job becomes a role on a run.
|
|
9
|
+
//
|
|
10
|
+
// A job's status is a machine: open → evaluating → recruited → in-progress → done | failed,
|
|
11
|
+
// with withdrawn from any of the first three. Every move is an event on the project's
|
|
12
|
+
// record (project.js foldProject: `job.posted`, `job.updated`).
|
|
13
|
+
|
|
14
|
+
import { validateBudget, normalizeBudget } from './budget.js';
|
|
15
|
+
import { GRANT_RE } from './team.js';
|
|
16
|
+
|
|
17
|
+
export const JOB_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
18
|
+
export const JOB_STATUSES = Object.freeze(['open', 'evaluating', 'recruited', 'in-progress', 'done', 'failed', 'withdrawn']);
|
|
19
|
+
const NEXT = Object.freeze({
|
|
20
|
+
open: ['evaluating', 'recruited', 'withdrawn'], evaluating: ['recruited', 'open', 'withdrawn'], recruited: ['in-progress', 'open', 'withdrawn'],
|
|
21
|
+
'in-progress': ['done', 'failed', 'open'], done: [], failed: ['open'], withdrawn: ['open'],
|
|
22
|
+
});
|
|
23
|
+
export const MAX_NEEDS = 24;
|
|
24
|
+
export const MAX_APPLICATIONS = 64;
|
|
25
|
+
|
|
26
|
+
export class JobError extends Error {
|
|
27
|
+
constructor(code, message) { super(message); this.name = 'JobError'; this.code = code; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
31
|
+
const clip = (s, n) => String(s || '').trim().slice(0, n);
|
|
32
|
+
const list = (xs, n, max) => [...new Set((Array.isArray(xs) ? xs : typeof xs === 'string' ? xs.split(/[,\s]+/) : []).map((x) => clip(x, n)).filter(Boolean))].slice(0, max);
|
|
33
|
+
|
|
34
|
+
export function validateJob(j) {
|
|
35
|
+
const errors = [];
|
|
36
|
+
if (!isRecord(j)) return { ok: false, errors: ['job must be an object'] };
|
|
37
|
+
if (!JOB_ID_RE.test(String(j.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
|
|
38
|
+
if (!JOB_ID_RE.test(String(j.projectId || ''))) errors.push('projectId: the project this job belongs to');
|
|
39
|
+
if (!clip(j.title, 200)) errors.push('title: what the job is');
|
|
40
|
+
if (!clip(j.brief, 8000)) errors.push('brief: what to do, what done looks like');
|
|
41
|
+
if (j.needs !== undefined) {
|
|
42
|
+
if (!isRecord(j.needs)) errors.push('needs: { skills[], tools[], grants[] }');
|
|
43
|
+
else {
|
|
44
|
+
const badGrants = (Array.isArray(j.needs.grants) ? j.needs.grants : []).filter((g) => !GRANT_RE.test(String(g)));
|
|
45
|
+
if (badGrants.length) errors.push(`needs.grants: not grantable: ${badGrants.join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (j.budget !== undefined) { const b = validateBudget(j.budget); if (!b.ok) errors.push(...b.errors.map((e) => `budget: ${e}`)); }
|
|
49
|
+
if (j.status !== undefined && !JOB_STATUSES.includes(j.status)) errors.push(`status: one of ${JOB_STATUSES.join(', ')}`);
|
|
50
|
+
if (j.dependsOn !== undefined && !Array.isArray(j.dependsOn)) errors.push('dependsOn: a list of job ids');
|
|
51
|
+
if (j.workspace !== undefined && j.workspace !== null && !isRecord(j.workspace)) errors.push('workspace: { repoId, base, branch? }');
|
|
52
|
+
if (j.deadline !== undefined && j.deadline !== null && !Number.isFinite(Number(j.deadline))) errors.push('deadline: a time');
|
|
53
|
+
return { ok: errors.length === 0, errors };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function normalizeJob(j) {
|
|
57
|
+
const v = validateJob(j);
|
|
58
|
+
if (!v.ok) throw new JobError('INVALID', v.errors.join('; '));
|
|
59
|
+
return {
|
|
60
|
+
id: String(j.id),
|
|
61
|
+
projectId: String(j.projectId),
|
|
62
|
+
title: clip(j.title, 200),
|
|
63
|
+
brief: clip(j.brief, 8000),
|
|
64
|
+
needs: {
|
|
65
|
+
skills: list(j.needs?.skills, 80, MAX_NEEDS),
|
|
66
|
+
tools: list(j.needs?.tools, 120, MAX_NEEDS),
|
|
67
|
+
grants: list(j.needs?.grants, 64, MAX_NEEDS).filter((g) => GRANT_RE.test(g)),
|
|
68
|
+
},
|
|
69
|
+
...(j.budget ? { budget: normalizeBudget(j.budget) } : {}),
|
|
70
|
+
size: { steps: Math.max(0, Math.round(Number(j.size?.steps) || 0)) },
|
|
71
|
+
status: JOB_STATUSES.includes(j.status) ? j.status : 'open',
|
|
72
|
+
postedBy: clip(j.postedBy, 80) || 'person',
|
|
73
|
+
postedAt: Number(j.postedAt) || Date.now(),
|
|
74
|
+
...(j.deadline ? { deadline: Number(j.deadline) } : {}),
|
|
75
|
+
dependsOn: list(j.dependsOn, 64, 32).filter((d) => d !== j.id),
|
|
76
|
+
...(j.workspace ? { workspace: { repoId: clip(j.workspace.repoId, 120), base: clip(j.workspace.base, 120) || 'main', ...(j.workspace.branch ? { branch: clip(j.workspace.branch, 200) } : {}), ...(j.workspace.worktreePath ? { worktreePath: clip(j.workspace.worktreePath, 400) } : {}) } } : {}),
|
|
77
|
+
applications: Array.isArray(j.applications) ? j.applications.slice(0, MAX_APPLICATIONS).map(normalizeApplication).filter(Boolean) : [],
|
|
78
|
+
...(j.recruited ? { recruited: normalizeRecruit(j.recruited) } : {}),
|
|
79
|
+
...(j.runId ? { runId: String(j.runId) } : {}),
|
|
80
|
+
...(j.result ? { result: { text: clip(j.result.text, 8000), by: clip(j.result.by, 80), at: Number(j.result.at) || Date.now(), ...(Array.isArray(j.result.refs) ? { refs: j.result.refs.slice(0, 12) } : {}) } } : {}),
|
|
81
|
+
...(j.origin && isRecord(j.origin) ? { origin: { ...j.origin } } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeApplication(a) {
|
|
86
|
+
if (!isRecord(a) || !a.agentId) return null;
|
|
87
|
+
return { agentId: String(a.agentId), ...(a.engine ? { engine: a.engine } : {}), fit: Math.max(0, Math.min(1, Number(a.fit) || 0)), reasons: Array.isArray(a.reasons) ? a.reasons.map((r) => clip(r, 200)).slice(0, 8) : [], pitch: clip(a.pitch, 600), at: Number(a.at) || Date.now() };
|
|
88
|
+
}
|
|
89
|
+
function normalizeRecruit(r) {
|
|
90
|
+
return { agentId: String(r.agentId), ...(r.engine ? { engine: r.engine } : {}), ...(r.budget ? { budget: normalizeBudget(r.budget) } : {}), by: clip(r.by, 80) || 'evaluator', at: Number(r.at) || Date.now(), ...(r.why ? { why: clip(r.why, 600) } : {}) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function defineJob(j) { return Object.freeze(normalizeJob(j)); }
|
|
94
|
+
|
|
95
|
+
/** May the job move from `from` to `to`? */
|
|
96
|
+
export function canTransition(from, to) { return (NEXT[from] || []).includes(to); }
|
|
97
|
+
|
|
98
|
+
/** Applications are computed, not asked for: every eligible type in the pool applies at once. */
|
|
99
|
+
export function applyAll(job, pool, fitFn, { cards = {} } = {}) {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
return (pool || [])
|
|
102
|
+
.filter((a) => a && a.enabled !== false && (a.appliesTo || ['jobs']).includes('jobs'))
|
|
103
|
+
.map((a) => { const f = fitFn(job, a, cards[a.id] || null); return { agentId: a.id, fit: f.score, reasons: f.reasons, pitch: '', at: now }; })
|
|
104
|
+
.sort((x, y) => y.fit - x.fit)
|
|
105
|
+
.slice(0, MAX_APPLICATIONS);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A recruited job as the role a run gives the agent: the brief is the prompt, the needs are the grants. */
|
|
109
|
+
export function jobToRole(job, agent) {
|
|
110
|
+
return {
|
|
111
|
+
id: agent?.id || job.id,
|
|
112
|
+
agent: agent?.id,
|
|
113
|
+
name: agent?.name || job.title,
|
|
114
|
+
prompt: [agent?.prompt, `Job: ${job.title}\n\n${job.brief}`].filter(Boolean).join('\n\n'),
|
|
115
|
+
grants: (job.needs?.grants?.length ? job.needs.grants : agent?.grants) || ['none'],
|
|
116
|
+
...(job.dependsOn?.length ? { dependsOn: job.dependsOn } : {}),
|
|
117
|
+
...(job.workspace ? { workspace: job.workspace } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The jobs that may run now: every dependency done, and not already taken. */
|
|
122
|
+
export function readyJobs(jobs) {
|
|
123
|
+
const byId = new Map((jobs || []).map((j) => [j.id, j]));
|
|
124
|
+
return (jobs || []).filter((j) => j.status === 'open' && (j.dependsOn || []).every((d) => byId.get(d)?.status === 'done'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A blank posting for the form. */
|
|
128
|
+
export function blankJob(projectId = '') {
|
|
129
|
+
return { id: '', projectId, title: '', brief: '', needs: { skills: [], tools: [], grants: [] }, budget: { tokens: 40000, ms: 900000 }, status: 'open', dependsOn: [] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function jobFromForm(form) {
|
|
133
|
+
const budget = {};
|
|
134
|
+
for (const k of ['tokens', 'calls', 'ms', 'usd']) {
|
|
135
|
+
const v = Number(form?.budget?.[k]);
|
|
136
|
+
if (form?.budget?.[k] !== '' && form?.budget?.[k] != null && Number.isFinite(v) && v > 0) budget[k] = v;
|
|
137
|
+
}
|
|
138
|
+
const j = {
|
|
139
|
+
id: String(form?.id || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 64),
|
|
140
|
+
projectId: form?.projectId, title: form?.title, brief: form?.brief,
|
|
141
|
+
needs: { skills: form?.needs?.skills, tools: form?.needs?.tools, grants: form?.needs?.grants },
|
|
142
|
+
...(Object.keys(budget).length ? { budget } : {}),
|
|
143
|
+
status: form?.status || 'open', postedBy: form?.postedBy || 'person', dependsOn: form?.dependsOn,
|
|
144
|
+
...(form?.workspace?.repoId ? { workspace: form.workspace } : {}), ...(form?.size ? { size: form.size } : {}),
|
|
145
|
+
};
|
|
146
|
+
const v = validateJob(j);
|
|
147
|
+
return v.ok ? { ok: true, job: normalizeJob(j) } : { ok: false, errors: v.errors };
|
|
148
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// WHAT A MODEL IS, GUESSED FROM WHAT THE USER CONFIGURED — one answer for every client.
|
|
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 } from './model-ledger.js';
|
|
266
|
+
export { DEFAULT_MIN_CALLS };
|
|
267
|
+
const r3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The override a card yields for model-candidates.js `applyOverride` — only the fields it
|
|
271
|
+
* has enough history for. `quality` is the mean rating (for `jobKind` when the card has
|
|
272
|
+
* ratings for it, else overall); `latencyMs` the observed p50 to first token (total when no
|
|
273
|
+
* ttft was recorded); `costPer1k` from the price when one is known; `available: false`
|
|
274
|
+
* only while it is declining right now. Returns `{ override, observed }`.
|
|
275
|
+
*/
|
|
276
|
+
export function cardOverride(card, { minCalls = DEFAULT_MIN_CALLS, jobKind = null } = {}) {
|
|
277
|
+
const override = {}; const observed = [];
|
|
278
|
+
if (!card) return { override, observed };
|
|
279
|
+
const q = (jobKind && card.quality?.byJobKind?.[jobKind]?.count >= minCalls) ? card.quality.byJobKind[jobKind] : card.quality?.overall;
|
|
280
|
+
if (q && q.count >= minCalls && q.avg != null) { override.quality = q.avg; observed.push('quality'); }
|
|
281
|
+
const lat = card.latency?.ttft?.n >= minCalls ? card.latency.ttft.p50 : card.latency?.total?.n >= minCalls ? card.latency.total.p50 : null;
|
|
282
|
+
if (lat != null) { override.latencyMs = lat; observed.push('latencyMs'); }
|
|
283
|
+
if (card.cost?.per1kIn != null && card.cost?.per1kOut != null) { override.costPer1k = r3((card.cost.per1kIn + card.cost.per1kOut) / 2); observed.push('costPer1k'); }
|
|
284
|
+
if (card.availability?.decliningNow) { override.available = false; observed.push('available'); }
|
|
285
|
+
return { override, observed };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A router model with its card applied: `applyOverride` with what the card observed, then
|
|
290
|
+
* withdrawn capabilities removed (a proof beats a guess in both directions). A person's own
|
|
291
|
+
* override (`userOverride`) is applied LAST — what they said outranks what was observed —
|
|
292
|
+
* except reach, which `applyOverride` already keeps outward-only. The result carries
|
|
293
|
+
* `observed: [...]` so the Context Ledger can say guess or observed per field.
|
|
294
|
+
*/
|
|
295
|
+
export function applyCard(inferred, card, { minCalls = DEFAULT_MIN_CALLS, jobKind = null, userOverride = null } = {}) {
|
|
296
|
+
const { override, observed } = cardOverride(card, { minCalls, jobKind });
|
|
297
|
+
let out = applyOverride(inferred, override);
|
|
298
|
+
const withdrawn = new Set(card?.capabilities?.withdrawn || []);
|
|
299
|
+
if (withdrawn.size && Array.isArray(out.capabilities)) { out = { ...out, capabilities: out.capabilities.filter((c) => !withdrawn.has(c)) }; observed.push('capabilities'); }
|
|
300
|
+
if (userOverride) out = applyOverride(out, userOverride);
|
|
301
|
+
return { ...out, observed };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export const REACH_RANK = { device: 0, trusted: 1, any: 2 };
|
|
305
|
+
const REACH_STEPS = ['device', 'trusted', 'any'];
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The reach values a user may declare for a model we detected as `detected`.
|
|
309
|
+
*
|
|
310
|
+
* The rule and the CONTROL that offers it have to come from one place. They did not: the
|
|
311
|
+
* settings page built its options by slicing from the model's CURRENT reach, which is the
|
|
312
|
+
* value after the override has been applied — so saving 'any' left 'any' as the only option
|
|
313
|
+
* and the correction could never be taken back. Enforcing outward-only in applyOverride while
|
|
314
|
+
* a second copy of the rule decided what to offer is what turned a safety rule into a
|
|
315
|
+
* one-way door.
|
|
316
|
+
*
|
|
317
|
+
* Always includes `detected` itself: coming back to what we detected is not moving inward, it
|
|
318
|
+
* is dropping the override. Anything closer in than the detection is never offered, because
|
|
319
|
+
* applyOverride would refuse it and a control that silently discards half its own values is
|
|
320
|
+
* worse than no control.
|
|
321
|
+
*/
|
|
322
|
+
export function reachChoicesFor(detected) {
|
|
323
|
+
const i = REACH_STEPS.indexOf(detected);
|
|
324
|
+
return i < 0 ? [...REACH_STEPS] : REACH_STEPS.slice(i);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* One router model from one configured target. The core of every client's candidate list.
|
|
330
|
+
*
|
|
331
|
+
* `kind` is 'bridge' for a CLI agent on this machine and 'api' for an HTTP endpoint;
|
|
332
|
+
* `override` is what the user said about this model (see applyOverride); `health` is
|
|
333
|
+
* `{ available, rateLimited }` as the host measured it, or null for "nothing observed".
|
|
334
|
+
* Returns null for a target that names no model and is not an agent — nothing to route to.
|
|
335
|
+
*/
|
|
336
|
+
export function inferCandidate(t, kind, { override = null, health = null } = {}) {
|
|
337
|
+
if (!t || (!t.model && kind !== 'bridge' && t.kind !== 'bridge')) return null;
|
|
338
|
+
const id = t.id || t.name || t.model;
|
|
339
|
+
if (!id) return null;
|
|
340
|
+
// NEVER A GENERATED ID. 'mqr0ifmw7sqxr7' appeared as the answer to "which model did this"
|
|
341
|
+
// in a real log — falling back to the id was the same as having no label at all. A bridge
|
|
342
|
+
// agent the user never renamed still knows which CLI it runs, and that is readable.
|
|
343
|
+
const label = [t.name, t.model && t.model !== t.name ? t.model : null]
|
|
344
|
+
.filter(Boolean).join(' · ')
|
|
345
|
+
|| [t.bridgeAgent, t.model].filter(Boolean).join(' · ')
|
|
346
|
+
|| kind || t.kind
|
|
347
|
+
|| String(id);
|
|
348
|
+
const k = kind || t.kind;
|
|
349
|
+
const reach = reachOf({ ...t, kind: k });
|
|
350
|
+
const inferred = {
|
|
351
|
+
id,
|
|
352
|
+
label,
|
|
353
|
+
// Kept so failover can recognise the SAME model at another provider — the closest
|
|
354
|
+
// possible replacement, and invisible if only the display label survived.
|
|
355
|
+
model: t.model || '',
|
|
356
|
+
reach,
|
|
357
|
+
classUsed: reach === 'device' ? 'L' : (k === 'bridge' ? 'A' : 'C'),
|
|
358
|
+
capabilities: capabilitiesOf({ ...t, kind: k }),
|
|
359
|
+
costPer1k: costOf(t, reach),
|
|
360
|
+
latencyMs: latencyOf(reach, qualityOf({ ...t, kind: k })),
|
|
361
|
+
quality: qualityOf({ ...t, kind: k }),
|
|
362
|
+
providerRank: providerRankOf(t, k),
|
|
363
|
+
available: t.enabled !== false,
|
|
364
|
+
};
|
|
365
|
+
const configured = applyOverride(inferred, override || {});
|
|
366
|
+
// AN EXPLICIT DISABLE OUTRANKS A TUNING OVERRIDE. `enabled: false` is the user saying,
|
|
367
|
+
// right now, "don't use this"; a routing override is a hint saved earlier.
|
|
368
|
+
if (t.enabled === false) configured.available = false;
|
|
369
|
+
// A CORRECTED QUALITY CORRECTS THE SPEED DERIVED FROM IT — unless the user set the speed.
|
|
370
|
+
if (numericOverride(override?.latencyMs) === null) configured.latencyMs = latencyOf(reach, configured.quality);
|
|
371
|
+
return defineModel({
|
|
372
|
+
...configured,
|
|
373
|
+
available: configured.available && (health ? health.available !== false : true),
|
|
374
|
+
rateLimited: !!health?.rateLimited,
|
|
375
|
+
});
|
|
376
|
+
}
|