synabun 2.0.5 → 2.0.7
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/CHANGELOG.md +40 -0
- package/README.md +2 -1
- package/THIRD-PARTY-LICENSES.md +13 -0
- package/docs/whatsapp.md +151 -16
- package/hooks/claude-code/shared.mjs +6 -3
- package/lib/child-path.js +303 -0
- package/lib/claude-hooks.js +2 -1
- package/lib/data-home-migration.js +2 -0
- package/lib/desktop-app.js +265 -0
- package/lib/external-tools.js +7 -3
- package/lib/log-redact.js +102 -0
- package/lib/packaged-runtime.js +138 -11
- package/mcp-server/dist/http.d.ts +3 -1
- package/mcp-server/dist/http.d.ts.map +1 -1
- package/mcp-server/dist/http.js +15 -4
- package/mcp-server/dist/http.js.map +1 -1
- package/mcp-server/dist/index.d.ts.map +1 -1
- package/mcp-server/dist/index.js +9 -6
- package/mcp-server/dist/index.js.map +1 -1
- package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
- package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/budget-judgments.js +153 -0
- package/mcp-server/dist/services/budget-judgments.js.map +1 -0
- package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
- package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
- package/mcp-server/dist/services/codex-config-heal.js +41 -4
- package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
- package/mcp-server/dist/services/identity.d.ts +11 -0
- package/mcp-server/dist/services/identity.d.ts.map +1 -1
- package/mcp-server/dist/services/identity.js +24 -0
- package/mcp-server/dist/services/identity.js.map +1 -1
- package/mcp-server/dist/services/neural-interface.d.ts +13 -0
- package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
- package/mcp-server/dist/services/neural-interface.js +34 -2
- package/mcp-server/dist/services/neural-interface.js.map +1 -1
- package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
- package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
- package/mcp-server/dist/services/typesafe-config.js +4 -1
- package/mcp-server/dist/services/typesafe-config.js.map +1 -1
- package/mcp-server/dist/tools/agents.d.ts +10 -7
- package/mcp-server/dist/tools/agents.d.ts.map +1 -1
- package/mcp-server/dist/tools/agents.js +34 -13
- package/mcp-server/dist/tools/agents.js.map +1 -1
- package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
- package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
- package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
- package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
- package/mcp-server/dist/tui.js +2 -1
- package/mcp-server/dist/tui.js.map +1 -1
- package/mcp-server/dist/version.d.ts +5 -0
- package/mcp-server/dist/version.d.ts.map +1 -0
- package/mcp-server/dist/version.js +21 -0
- package/mcp-server/dist/version.js.map +1 -0
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/run.mjs +15 -9
- package/mcp-server/src/http.ts +18 -3
- package/mcp-server/src/index.ts +9 -6
- package/mcp-server/src/services/budget-judgments.ts +183 -0
- package/mcp-server/src/services/codex-config-heal.ts +35 -4
- package/mcp-server/src/services/identity.ts +27 -0
- package/mcp-server/src/services/neural-interface.ts +34 -2
- package/mcp-server/src/services/typesafe-config.ts +4 -1
- package/mcp-server/src/tools/agents.ts +33 -14
- package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
- package/mcp-server/src/tui.ts +2 -1
- package/mcp-server/src/version.ts +22 -0
- package/neural-interface/i18n/en.json +627 -57
- package/neural-interface/i18n/pt-BR.json +626 -56
- package/neural-interface/lib/account-usage.js +163 -0
- package/neural-interface/lib/assistant-api.js +255 -52
- package/neural-interface/lib/assistant-brains/claude.js +15 -2
- package/neural-interface/lib/assistant-brains/codex.js +3 -0
- package/neural-interface/lib/assistant-brains/opencode.js +17 -4
- package/neural-interface/lib/assistant-budget-planner.js +390 -0
- package/neural-interface/lib/assistant-budget-split.js +408 -0
- package/neural-interface/lib/assistant-budget.js +94 -95
- package/neural-interface/lib/assistant-config.js +62 -14
- package/neural-interface/lib/assistant-dispatch.js +432 -125
- package/neural-interface/lib/assistant-jev.js +26 -3
- package/neural-interface/lib/assistant-persona.js +68 -11
- package/neural-interface/lib/assistant-plan-permissions.js +8 -1
- package/neural-interface/lib/assistant-route-accounts.js +14 -0
- package/neural-interface/lib/assistant-route-gate.js +19 -3
- package/neural-interface/lib/assistant-router.js +373 -42
- package/neural-interface/lib/assistant-runtime.js +307 -47
- package/neural-interface/lib/assistant-user-writer.js +4 -2
- package/neural-interface/lib/augmented-path.js +317 -53
- package/neural-interface/lib/claude-accounts.js +342 -31
- package/neural-interface/lib/claude-agent-bridge.js +90 -3
- package/neural-interface/lib/claude-executable.js +6 -1
- package/neural-interface/lib/claude-model-catalog.js +3 -3
- package/neural-interface/lib/claude-panel-auth.js +221 -0
- package/neural-interface/lib/claude-profile-api-key.js +62 -0
- package/neural-interface/lib/cli-install-api.js +137 -0
- package/neural-interface/lib/cli-install.js +797 -0
- package/neural-interface/lib/cli-path.js +130 -0
- package/neural-interface/lib/cli-resolver.js +744 -0
- package/neural-interface/lib/cli-update-planner.js +143 -7
- package/neural-interface/lib/codex-account-auth.js +178 -0
- package/neural-interface/lib/codex-accounts.js +489 -0
- package/neural-interface/lib/codex-capabilities.js +2 -1
- package/neural-interface/lib/codex-compat-rpc.js +8 -2
- package/neural-interface/lib/codex-mcp-approvals.js +5 -1
- package/neural-interface/lib/codex-panel-auth.js +62 -0
- package/neural-interface/lib/codex-session-settings.js +1 -1
- package/neural-interface/lib/codex-settings-schema.js +120 -0
- package/neural-interface/lib/desktop/config.js +3 -0
- package/neural-interface/lib/git-bash/bzip2.js +339 -0
- package/neural-interface/lib/git-bash/claude-settings.js +73 -0
- package/neural-interface/lib/git-bash/extract-worker.js +34 -0
- package/neural-interface/lib/git-bash/install.js +529 -0
- package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
- package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
- package/neural-interface/lib/git-bash/manager.js +268 -0
- package/neural-interface/lib/git-bash/pins.js +206 -0
- package/neural-interface/lib/git-bash/pins.json +34 -0
- package/neural-interface/lib/git-bash/resolver.js +194 -0
- package/neural-interface/lib/git-read.js +34 -2
- package/neural-interface/lib/install-details.js +145 -0
- package/neural-interface/lib/mcp-installer.js +14 -2
- package/neural-interface/lib/native-loop-providers.js +16 -3
- package/neural-interface/lib/native-loop-runtime.js +9 -4
- package/neural-interface/lib/os-path.js +319 -0
- package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
- package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
- package/neural-interface/lib/pty-host/core.js +2 -1
- package/neural-interface/lib/pty-host/manager.js +27 -2
- package/neural-interface/lib/remote-policy.js +20 -11
- package/neural-interface/lib/session-title-generator.js +4 -3
- package/neural-interface/lib/system-about.js +340 -0
- package/neural-interface/lib/system-activity.js +66 -0
- package/neural-interface/lib/terminal-env.js +11 -3
- package/neural-interface/lib/terminal-path.js +55 -0
- package/neural-interface/lib/virtual-socket.js +12 -1
- package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
- package/neural-interface/lib/whatsapp/bridge.js +78 -34
- package/neural-interface/lib/whatsapp/commands.js +13 -14
- package/neural-interface/lib/whatsapp/config.js +134 -20
- package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
- package/neural-interface/lib/whatsapp/host-core.js +168 -35
- package/neural-interface/lib/whatsapp/inbound.js +5 -3
- package/neural-interface/lib/whatsapp/local-file.js +524 -0
- package/neural-interface/lib/whatsapp/manager.js +92 -32
- package/neural-interface/lib/whatsapp/paths.js +11 -1
- package/neural-interface/lib/whatsapp/protocol.js +65 -5
- package/neural-interface/lib/whatsapp/redact.js +63 -0
- package/neural-interface/lib/whatsapp/service.js +343 -91
- package/neural-interface/lib/whatsapp/transcription.js +463 -0
- package/neural-interface/package-lock.json +2 -2
- package/neural-interface/package.json +1 -1
- package/neural-interface/public/index.html +2 -0
- package/neural-interface/public/index2d.html +2 -0
- package/neural-interface/public/offline.html +16 -1
- package/neural-interface/public/onboarding.html +96 -2
- package/neural-interface/public/shared/account-i18n.js +7 -0
- package/neural-interface/public/shared/account-menu.js +539 -0
- package/neural-interface/public/shared/account-usage.js +19 -0
- package/neural-interface/public/shared/account-view.js +335 -0
- package/neural-interface/public/shared/agent-runtime-options.js +28 -8
- package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
- package/neural-interface/public/shared/assistant/asst-api.js +32 -2
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
- package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
- package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
- package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
- package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
- package/neural-interface/public/shared/assistant/asst-render.js +38 -7
- package/neural-interface/public/shared/assistant/asst-route.js +72 -14
- package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
- package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
- package/neural-interface/public/shared/assistant/asst-state.js +1 -1
- package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
- package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
- package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
- package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
- package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
- package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
- package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
- package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
- package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
- package/neural-interface/public/shared/claude-account-view.js +3 -0
- package/neural-interface/public/shared/cli-install-view.js +392 -0
- package/neural-interface/public/shared/cli-status.js +282 -22
- package/neural-interface/public/shared/cp/cp-events.js +2 -1
- package/neural-interface/public/shared/cp/cp-styles.js +46 -0
- package/neural-interface/public/shared/desktop-bridge.js +39 -0
- package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
- package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
- package/neural-interface/public/shared/desktop-titlebar.js +153 -0
- package/neural-interface/public/shared/desktop-update-view.js +462 -0
- package/neural-interface/public/shared/editor/editor-panel.js +1 -1
- package/neural-interface/public/shared/explorer/fx-main.js +1 -1
- package/neural-interface/public/shared/git-bash-view.js +145 -0
- package/neural-interface/public/shared/html-shell.js +1 -0
- package/neural-interface/public/shared/i18n.js +19 -1
- package/neural-interface/public/shared/install-details-view.js +86 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
- package/neural-interface/public/shared/pwa-install-page.js +3 -1
- package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
- package/neural-interface/public/shared/settings/settings-about.js +254 -0
- package/neural-interface/public/shared/settings/settings-ia.js +15 -0
- package/neural-interface/public/shared/showcase-privacy.js +489 -0
- package/neural-interface/public/shared/storage.js +30 -3
- package/neural-interface/public/shared/styles.css +278 -31
- package/neural-interface/public/shared/synabun-mascot.js +48 -2
- package/neural-interface/public/shared/ui-claude-panel.js +212 -213
- package/neural-interface/public/shared/ui-cli-banner.js +355 -0
- package/neural-interface/public/shared/ui-git-bash.js +124 -0
- package/neural-interface/public/shared/ui-judgments.js +10 -1
- package/neural-interface/public/shared/ui-loading.js +30 -6
- package/neural-interface/public/shared/ui-menubar.js +13 -3
- package/neural-interface/public/shared/ui-navbar.js +3 -1
- package/neural-interface/public/shared/ui-provider-auth.js +104 -0
- package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
- package/neural-interface/public/shared/ui-sessions.js +9 -3
- package/neural-interface/public/shared/ui-settings.js +55 -3
- package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
- package/neural-interface/public/shared/ui-sync.js +6 -0
- package/neural-interface/public/shared/ui-terminal.js +17 -0
- package/neural-interface/public/shared/ui-tool-updates.js +5 -0
- package/neural-interface/public/shared/ui-update.js +594 -5
- package/neural-interface/public/shared/ui-whatsapp.js +21 -6
- package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
- package/neural-interface/public/shared/worker-end.js +2 -0
- package/neural-interface/public/sw.js +1 -1
- package/neural-interface/public/variant/2d/main.js +10 -1
- package/neural-interface/public/variant/3d/main.js +10 -1
- package/neural-interface/server.js +1219 -608
- package/package.json +4 -1
- package/setup.js +9 -8
|
@@ -35,6 +35,9 @@ import { randomBytes } from 'node:crypto';
|
|
|
35
35
|
import { CATALOG_PROVIDERS, TIERS, defaultIsHidden, defaultModelRow, findModel, hiddenModelId, priceText, providerAllHidden, providerEmpty, rowIsHidden, rowMakes } from './assistant-catalog.js';
|
|
36
36
|
import { effectiveRouting, normalizeRouteMode } from './assistant-config.js';
|
|
37
37
|
import { normalizeEffort } from './effort-levels.js';
|
|
38
|
+
import { hasRouteAccounts, routeAccountText } from './assistant-route-accounts.js';
|
|
39
|
+
import { chooseUsageAccount } from './account-usage.js';
|
|
40
|
+
import { PROVIDER_LABELS, coversWork, dryProviders, isFreePrice, listPriceOf, rowOf } from './assistant-budget-split.js';
|
|
38
41
|
|
|
39
42
|
export const TASK_CLASS_META = Object.freeze({
|
|
40
43
|
chat: { label: 'Chat', description: 'Conversation and questions answered from knowledge or memory', defaultKind: 'direct' },
|
|
@@ -55,7 +58,7 @@ export const TASK_CLASS_META = Object.freeze({
|
|
|
55
58
|
});
|
|
56
59
|
export const TASK_CLASSES = Object.freeze(Object.keys(TASK_CLASS_META));
|
|
57
60
|
export const PREFERENCE_KEYS = Object.freeze([...TASK_CLASSES, 'vision']);
|
|
58
|
-
export const ROUTE_REASONS = Object.freeze(['always-ask', 'low_confidence', 'no_confidence', 'model_missing', 'model_unknown', 'model_disabled', 'model_unavailable', 'needs_vision', 'needs_output', 'no_capable_model']);
|
|
61
|
+
export const ROUTE_REASONS = Object.freeze(['always-ask', 'low_confidence', 'no_confidence', 'model_missing', 'model_unknown', 'model_disabled', 'model_unavailable', 'needs_vision', 'needs_output', 'no_capable_model', 'budget_exhausted']);
|
|
59
62
|
export const MODEL_DISABLED_TEXT = "Disabled in the Assistant's Models list.";
|
|
60
63
|
const VISION_CLASSES = new Set(['computer']);
|
|
61
64
|
const MEDIUM_WORDS = { image: 'images', video: 'videos' };
|
|
@@ -92,6 +95,7 @@ function clip(value, max = 200) {
|
|
|
92
95
|
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
93
96
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
94
97
|
}
|
|
98
|
+
const usdText = (value) => `$${(Number(value) || 0).toFixed(2)}`;
|
|
95
99
|
function routeError(code, message, status = 400, extra = {}) {
|
|
96
100
|
const error = new Error(message);
|
|
97
101
|
error.code = code;
|
|
@@ -103,7 +107,7 @@ function tierRank(tier) { return TIER_RANK[tier] ?? 1; }
|
|
|
103
107
|
function outPrice(row) { const n = Number(row?.price?.output); return Number.isFinite(n) ? n : Number.POSITIVE_INFINITY; }
|
|
104
108
|
function sameTarget(a, b) {
|
|
105
109
|
if (!a || !b) return false;
|
|
106
|
-
return a.kind === b.kind && a.provider === b.provider && String(a.model || '') === String(b.model || '') && String(a.effort || '') === String(b.effort || '');
|
|
110
|
+
return a.kind === b.kind && a.provider === b.provider && String(a.model || '') === String(b.model || '') && String(a.effort || '') === String(b.effort || '') && String(a.accountId || '') === String(b.accountId || '');
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
/**
|
|
@@ -215,6 +219,7 @@ export function normalizeRouteRequest(body = {}, { brain = {} } = {}) {
|
|
|
215
219
|
proposals.push({
|
|
216
220
|
kind, provider, model,
|
|
217
221
|
effort: raw.effort ? String(raw.effort).trim().slice(0, 40) : null,
|
|
222
|
+
...(raw.accountId || raw.account_id ? { accountId: String(raw.accountId || raw.account_id).slice(0, 64) } : {}),
|
|
218
223
|
reason: raw.reason ? clip(raw.reason, 240) : null,
|
|
219
224
|
});
|
|
220
225
|
}
|
|
@@ -241,6 +246,7 @@ export function describeTarget(target, { catalog = null, brain = {}, brainInfo =
|
|
|
241
246
|
const unlistedClaude = target.provider === 'claude-code' && match === 'unlisted';
|
|
242
247
|
return {
|
|
243
248
|
kind: target.kind, provider: target.provider, model: target.model || null, effort: target.effort || null,
|
|
249
|
+
...(Object.hasOwn(target, 'accountId') ? { accountId: target.accountId, account: target.account || null, accountError: target.accountError || null } : {}),
|
|
244
250
|
label, tier: info?.tier || row?.tier || null,
|
|
245
251
|
vision: info ? info.vision ?? null : (row ? row.vision ?? null : (unlistedClaude ? true : null)),
|
|
246
252
|
// What it generates besides text (image / video creation needs a true one).
|
|
@@ -272,6 +278,7 @@ function preferenceTarget(pref, brain, catalog = null, { dispatchOnly = false }
|
|
|
272
278
|
const kind = here && !dispatchOnly ? 'direct' : 'dispatch';
|
|
273
279
|
const model = pref.model || (here && dispatchOnly ? brain.model || null : null);
|
|
274
280
|
const target = { kind, provider: pref.provider, model, effort: pref.effort || null };
|
|
281
|
+
if (hasRouteAccounts(pref.provider) && pref.accountId) target.accountId = pref.accountId;
|
|
275
282
|
// A remembered route onto a model the user since disabled is not applied.
|
|
276
283
|
return catalog && isDisabled(catalog, target) ? null : target;
|
|
277
284
|
}
|
|
@@ -338,6 +345,122 @@ function sightFallback(catalog, provider, { brain = {}, routing = {}, taskClass
|
|
|
338
345
|
return alt ? { kind: 'dispatch', provider: alt.provider, model: alt.row.id, effort: null } : null;
|
|
339
346
|
}
|
|
340
347
|
|
|
348
|
+
// ── the session budget (assistant-budget-split.js coverage) ──────────────────
|
|
349
|
+
|
|
350
|
+
/** Does `target` run on a free model (the catalog says so)? Free models need no share of the budget. */
|
|
351
|
+
export function targetIsFree(catalog, target) {
|
|
352
|
+
if (!target?.provider) return false;
|
|
353
|
+
return isFreePrice(listPriceOf(rowOf(catalog, target.provider, target.model || null), { provider: target.provider, model: target.model || null }));
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Can the session's budget pay for `target` now? A direct target is the brain's own work (it may use its
|
|
357
|
+
* reserve), a dispatch a worker's; a free model needs only the session to be under its budget.
|
|
358
|
+
* `coverage` null (No limit, or no budget wired): always.
|
|
359
|
+
*/
|
|
360
|
+
export function budgetCovers(coverage, catalog, target) {
|
|
361
|
+
if (!coverage || !target?.provider) return true;
|
|
362
|
+
return coversWork(coverage, { provider: target.provider, forBrain: target.kind === 'direct', free: targetIsFree(catalog, target) });
|
|
363
|
+
}
|
|
364
|
+
/** "codex share used up" / "the session budget is spent". */
|
|
365
|
+
function dryText(coverage, provider) {
|
|
366
|
+
if (!coverage || coverage.leftUsd < 0.01) return 'the session budget is spent';
|
|
367
|
+
return `${provider} share used up`;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* With a budget, a task whose provider has no share left moves to the next capable provider with money
|
|
371
|
+
* (the user chose Re-route): the brain's next proposal first, then the same tier on another provider
|
|
372
|
+
* (its saved model for that class, else its default model, else its cheapest model of that tier), then a
|
|
373
|
+
* free capable model, and last any capable model with share left, the closest tier first (cheapest
|
|
374
|
+
* within it), so a task is declined only when nothing at all can pay. Every capability rule holds for
|
|
375
|
+
* each candidate (`capable`). → { target } (moved or not), or { declined: true } when nothing can pay.
|
|
376
|
+
*/
|
|
377
|
+
function budgetReroute({ target, request, catalog, brain, routing, coverage, capable }) {
|
|
378
|
+
const covers = (t) => budgetCovers(coverage, catalog, t);
|
|
379
|
+
if (covers(target)) return { target, moved: false };
|
|
380
|
+
const providerOk = (p) => coverage.providers?.[p]?.eligible !== false;
|
|
381
|
+
const tried = new Set();
|
|
382
|
+
const key = (t) => `${t.kind}:${t.provider}:${t.model || ''}`;
|
|
383
|
+
const pick = (t) => {
|
|
384
|
+
if (!t?.provider || tried.has(key(t))) return null;
|
|
385
|
+
tried.add(key(t));
|
|
386
|
+
return providerOk(t.provider) && capable(t) && covers(t) ? t : null;
|
|
387
|
+
};
|
|
388
|
+
// 1. The brain's next proposals.
|
|
389
|
+
for (const p of (request.proposals || []).slice(1)) {
|
|
390
|
+
const t = pick({ kind: p.kind === 'direct' && p.provider === brain.provider && !request.dispatchOnly ? 'direct' : 'dispatch', provider: p.provider, model: p.model || null, effort: p.effort || null, ...(p.accountId ? { accountId: p.accountId } : {}) });
|
|
391
|
+
if (t) return { target: t, moved: true, step: 'proposal' };
|
|
392
|
+
}
|
|
393
|
+
// 2. The same tier on another provider.
|
|
394
|
+
const tierOf = (t) => (t?.model ? findModel(catalog, t.provider, t.model)?.row?.tier : defaultModelRow(catalog, t?.provider)?.tier) || null;
|
|
395
|
+
const tier = tierOf(target);
|
|
396
|
+
for (const provider of CATALOG_PROVIDERS) {
|
|
397
|
+
if (provider === target.provider || !providerOk(provider)) continue;
|
|
398
|
+
const pref = routing?.preferences?.[request.taskClass];
|
|
399
|
+
const candidates = [];
|
|
400
|
+
if (pref?.provider === provider) candidates.push({ kind: 'dispatch', provider, model: pref.model || null, effort: pref.effort || null, ...(pref.accountId ? { accountId: pref.accountId } : {}) });
|
|
401
|
+
const own = defaultModelRow(catalog, provider);
|
|
402
|
+
if (own && (!tier || own.tier === tier)) candidates.push({ kind: 'dispatch', provider, model: own.id, effort: null });
|
|
403
|
+
visibleRows(catalog, provider).filter((row) => row.status !== 'unavailable' && (!tier || row.tier === tier))
|
|
404
|
+
.sort((a, b) => outPrice(a) - outPrice(b)).forEach((row) => candidates.push({ kind: 'dispatch', provider, model: row.id, effort: null }));
|
|
405
|
+
for (const candidate of candidates) {
|
|
406
|
+
const t = pick(candidate);
|
|
407
|
+
if (t) return { target: t, moved: true, step: 'same_tier' };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// 3. A free capable model, anywhere.
|
|
411
|
+
for (const provider of CATALOG_PROVIDERS) {
|
|
412
|
+
if (!providerOk(provider)) continue;
|
|
413
|
+
const free = visibleRows(catalog, provider).filter((row) => row.status !== 'unavailable' && isFreePrice(row.price || null));
|
|
414
|
+
for (const row of free) {
|
|
415
|
+
const t = pick({ kind: 'dispatch', provider, model: row.id, effort: null });
|
|
416
|
+
if (t) return { target: t, moved: true, step: 'free' };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// 4. Any capable model with share left: the closest tier first, the cheapest within it.
|
|
420
|
+
const distance = (row) => (tier ? Math.abs(tierRank(row.tier) - tierRank(tier)) : 0);
|
|
421
|
+
const rest = CATALOG_PROVIDERS.filter(providerOk)
|
|
422
|
+
.flatMap((provider) => visibleRows(catalog, provider).filter((row) => row.status !== 'unavailable').map((row) => ({ provider, row })))
|
|
423
|
+
.sort((a, b) => distance(a.row) - distance(b.row) || outPrice(a.row) - outPrice(b.row));
|
|
424
|
+
for (const { provider, row } of rest) {
|
|
425
|
+
const t = pick({ kind: 'dispatch', provider, model: row.id, effort: null });
|
|
426
|
+
if (t) return { target: t, moved: true, step: 'closest_tier' };
|
|
427
|
+
}
|
|
428
|
+
return { declined: true, dry: dryProviders(coverage) };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The capability rules a target of `request` must meet: not disabled, a listed model, the medium it must
|
|
433
|
+
* make (image / video creation), sight (design: a confirmed vision on an available row; other vision
|
|
434
|
+
* work: not a confirmed blind one). → (target) => boolean
|
|
435
|
+
*/
|
|
436
|
+
export function capableFor(request, { catalog = null, brain = {}, brainInfo = null } = {}) {
|
|
437
|
+
const medium = request?.needsOutput || null;
|
|
438
|
+
const visionOnly = request?.visionOnly === true;
|
|
439
|
+
return (target) => {
|
|
440
|
+
if (!target?.provider || isDisabled(catalog, target)) return false;
|
|
441
|
+
if (target.model && findModel(catalog, target.provider, target.model) === null) return false;
|
|
442
|
+
if (medium && !targetMakes(catalog, target, medium)) return false;
|
|
443
|
+
if (request?.needsVision) {
|
|
444
|
+
if (visionOnly ? designFit(catalog, target, { brain, brainInfo }) !== 'ok' : describeTarget(target, { catalog, brain, brainInfo })?.vision === false) return false;
|
|
445
|
+
}
|
|
446
|
+
return true;
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* An approved route whose provider ran out of share after approval: move the dispatch the same way
|
|
451
|
+
* agent_route would (budgetReroute). → { target, correction } or { declined, dry }.
|
|
452
|
+
*/
|
|
453
|
+
export function budgetMove({ target, request, catalog = null, brain = {}, brainInfo = null, routing = {}, coverage = null }) {
|
|
454
|
+
if (!coverage || budgetCovers(coverage, catalog, target)) return { target, correction: null };
|
|
455
|
+
const moved = budgetReroute({ target, request, catalog, brain, routing, coverage, capable: capableFor(request, { catalog, brain, brainInfo }) });
|
|
456
|
+
if (moved.declined) return { declined: true, dry: moved.dry };
|
|
457
|
+
const next = effortChecked(request.dispatchOnly && moved.target.kind !== 'dispatch' ? { ...moved.target, kind: 'dispatch' } : moved.target, catalog);
|
|
458
|
+
return {
|
|
459
|
+
target: next,
|
|
460
|
+
correction: { field: 'route', from: routeText(target), to: routeText(next), reason: 'budget_share', step: moved.step, text: `budget: ${dryText(coverage, target.provider)} -> ${next.provider}/${next.model || 'default'}` },
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
341
464
|
/**
|
|
342
465
|
* Decide whether the user must be asked. Returns
|
|
343
466
|
* { ask, reasons[], auto: target|null, proposal: target, corrections[], source }.
|
|
@@ -345,12 +468,19 @@ function sightFallback(catalog, provider, { brain = {}, routing = {}, taskClass
|
|
|
345
468
|
* with no model in the catalog that makes the medium, and for design with none
|
|
346
469
|
* that can see, it returns { ask: false, unavailable: 'no_capable_model', auto: null }
|
|
347
470
|
* in every mode.
|
|
471
|
+
*
|
|
472
|
+
* `budget` ({ coverage }: assistant-budget-split.js coverageOf, null under No limit): a target whose
|
|
473
|
+
* provider has no share left moves to the next capable provider with money (budgetReroute) and the
|
|
474
|
+
* corrections say so (reason budget_share); with a budget the saved routes yield to this. When nothing
|
|
475
|
+
* can pay: { ask: false, unavailable: 'budget_exhausted', dry }. `budgetFrom` names the target that was
|
|
476
|
+
* moved or could not be paid, so the caller can divide the budget again and ask once more.
|
|
348
477
|
*/
|
|
349
|
-
export function evaluateRoute({ mode, routing = {}, request, brain = {}, brainInfo = null, catalog = null }) {
|
|
478
|
+
export function evaluateRoute({ mode, routing = {}, request, brain = {}, brainInfo = null, catalog = null, budget = null }) {
|
|
350
479
|
const reasons = [];
|
|
351
480
|
const corrections = [];
|
|
352
481
|
const first = request.proposals[0];
|
|
353
482
|
let proposal = { kind: first.kind, provider: first.provider, model: first.model, effort: first.effort };
|
|
483
|
+
if (first.accountId) proposal.accountId = first.accountId;
|
|
354
484
|
let source = 'brain';
|
|
355
485
|
if (request.taskClass === 'chat') {
|
|
356
486
|
return { ask: false, reasons, auto: { kind: 'direct', provider: brain.provider, model: brain.model || null, effort: brain.effort || null }, proposal, corrections, source };
|
|
@@ -370,11 +500,33 @@ export function evaluateRoute({ mode, routing = {}, request, brain = {}, brainIn
|
|
|
370
500
|
const fit = (target) => designFit(catalog, target, { brain, brainInfo });
|
|
371
501
|
// Too blind for this task: design needs a confirmed true (null never counts) on an available row; other vision work refuses a confirmed false.
|
|
372
502
|
const tooBlind = (target) => (visionOnly ? fit(target) !== 'ok' : sees(target) === false);
|
|
503
|
+
// The session budget: a target whose provider has no share left moves (every capability rule holds).
|
|
504
|
+
const coverage = budget?.coverage || null;
|
|
505
|
+
const capable = capableFor(request, { catalog, brain, brainInfo });
|
|
506
|
+
/** → { target } (the same one, or moved with a budget_share correction), or { declined } (nothing can pay). */
|
|
507
|
+
const applyBudget = (target) => {
|
|
508
|
+
if (!coverage || !target) return { target };
|
|
509
|
+
const moved = budgetReroute({ target, request, catalog, brain, routing, coverage, capable });
|
|
510
|
+
if (moved.declined) return { declined: true, dry: moved.dry, from: target };
|
|
511
|
+
if (!moved.moved) return { target };
|
|
512
|
+
const next = effortChecked(dispatchOnly && moved.target.kind !== 'dispatch' ? { ...moved.target, kind: 'dispatch' } : moved.target, catalog, corrections);
|
|
513
|
+
corrections.push({
|
|
514
|
+
field: 'route', from: routeText(target), to: routeText(next), reason: 'budget_share', step: moved.step,
|
|
515
|
+
text: `budget: ${dryText(coverage, target.provider)} -> ${next.provider}/${next.model || 'default'}`,
|
|
516
|
+
});
|
|
517
|
+
return { target: next, from: target };
|
|
518
|
+
};
|
|
519
|
+
const budgetDeclined = (outcome, extra = {}) => ({
|
|
520
|
+
ask: false, unavailable: 'budget_exhausted', reasons: ['budget_exhausted'], auto: null, proposal, corrections, source, bound: false,
|
|
521
|
+
dry: outcome.dry || [], budgetFrom: outcome.from || null, ...extra,
|
|
522
|
+
});
|
|
373
523
|
const remembered = preferenceTarget(routing.preferences?.[request.taskClass], brain, null, { dispatchOnly });
|
|
374
524
|
const disabled = !!remembered && isDisabled(catalog, remembered);
|
|
375
525
|
// A saved route onto a model that cannot make the medium (or, for design, is not known to see) is not applied either.
|
|
376
526
|
const incapable = !!remembered && !disabled && (!targetMakes(catalog, remembered, medium) || (visionOnly && tooBlind(remembered)));
|
|
377
527
|
const pref = remembered && !disabled && !incapable ? remembered : null;
|
|
528
|
+
// In either asking mode, a proposal of the saved target carries its saved account too.
|
|
529
|
+
if (pref && sameTarget({ ...proposal, accountId: null, effort: null }, { ...pref, accountId: null, effort: null }) && (!pref.effort || pref.effort === proposal.effort) && (pref.accountId || proposal.accountId)) proposal.accountId = pref.accountId || null;
|
|
378
530
|
// Omitted model: a remembered preference for the class fills it.
|
|
379
531
|
if (!proposal.model && proposal.kind === 'dispatch') {
|
|
380
532
|
if (pref && (pref.provider === proposal.provider || !first.model)) { proposal = { ...pref, effort: proposal.effort || pref.effort }; source = 'remembered'; }
|
|
@@ -395,11 +547,16 @@ export function evaluateRoute({ mode, routing = {}, request, brain = {}, brainIn
|
|
|
395
547
|
else if (request.confidence < (routing.askBelow ?? 0.75)) reasons.push('low_confidence');
|
|
396
548
|
|
|
397
549
|
const effective = normalizeRouteMode(mode) || 'ask-unsure';
|
|
398
|
-
if (effective === 'always-ask'
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
if (
|
|
402
|
-
|
|
550
|
+
if (effective === 'always-ask' || effective === 'ask-unsure') {
|
|
551
|
+
// The suggestion a card shows (or the pick that runs) is one the budget can pay for.
|
|
552
|
+
const budgeted = applyBudget(proposal);
|
|
553
|
+
if (budgeted.declined) return budgetDeclined(budgeted);
|
|
554
|
+
const budgetFrom = budgeted.from || null;
|
|
555
|
+
if (budgetFrom) { proposal = budgeted.target; source = 'budget'; }
|
|
556
|
+
if (effective === 'always-ask') return { ask: true, reasons: ['always-ask', ...reasons], auto: null, proposal, corrections, source, budgetFrom };
|
|
557
|
+
const unsure = reasons.filter((r) => r !== 'no_confidence' || (source !== 'remembered' && source !== 'budget'));
|
|
558
|
+
if (unsure.length) return { ask: true, reasons: unsure, auto: null, proposal, corrections, source, budgetFrom };
|
|
559
|
+
return { ask: false, reasons, auto: proposal, proposal, corrections, source, budgetFrom };
|
|
403
560
|
}
|
|
404
561
|
// never: the user's saved route for the class binds (the brain only classifies);
|
|
405
562
|
// without an enabled one, run the brain's pick, correcting what cannot run.
|
|
@@ -407,7 +564,7 @@ export function evaluateRoute({ mode, routing = {}, request, brain = {}, brainIn
|
|
|
407
564
|
const bound = !!pref;
|
|
408
565
|
if (bound) {
|
|
409
566
|
// Provider and model from the saved route; its effort when set, else the brain's.
|
|
410
|
-
auto = {
|
|
567
|
+
auto = { ...pref, model: pref.model || (pref.kind === 'direct' ? brain.model || null : null), effort: pref.effort || proposal.effort || null };
|
|
411
568
|
const pinnedPref = pinnedDefault(catalog, auto);
|
|
412
569
|
if (pinnedPref) auto = { ...auto, model: pinnedPref.id };
|
|
413
570
|
if (!sameTarget({ ...auto, effort: null }, { ...proposal, effort: null }) || (pref.effort && pref.effort !== proposal.effort)) {
|
|
@@ -485,10 +642,14 @@ export function evaluateRoute({ mode, routing = {}, request, brain = {}, brainIn
|
|
|
485
642
|
// A corrected model or provider re-checks the effort.
|
|
486
643
|
auto = effortChecked(auto, catalog, corrections);
|
|
487
644
|
if (!bound && corrections.some((c) => c.reason !== 'remembered_disabled' && c.reason !== 'remembered_incapable')) source = 'corrected';
|
|
488
|
-
|
|
645
|
+
// With a budget the saved route yields: a provider with no share left moves the task on.
|
|
646
|
+
const budgeted = applyBudget(auto);
|
|
647
|
+
if (budgeted.declined) return budgetDeclined(budgeted);
|
|
648
|
+
if (budgeted.from) { auto = budgeted.target; source = 'budget'; }
|
|
649
|
+
return { ask: false, reasons, auto, proposal, corrections, source, bound, budgetFrom: budgeted.from || null };
|
|
489
650
|
}
|
|
490
651
|
|
|
491
|
-
function optionFor(id, rawTarget, badge, { catalog, brain, brainInfo, needsVision, needsOutput = null, visionOnly = false, reason = null }) {
|
|
652
|
+
function optionFor(id, rawTarget, badge, { catalog, brain, brainInfo, needsVision, needsOutput = null, visionOnly = false, reason = null, coverage = null }) {
|
|
492
653
|
const target = effortChecked(rawTarget, catalog);
|
|
493
654
|
const d = describeTarget(target, { catalog, brain, brainInfo });
|
|
494
655
|
const here = target.kind === 'direct';
|
|
@@ -497,6 +658,7 @@ function optionFor(id, rawTarget, badge, { catalog, brain, brainInfo, needsVisio
|
|
|
497
658
|
const fit = visionOnly ? designFit(catalog, target, { brain, brainInfo }) : 'ok';
|
|
498
659
|
const option = {
|
|
499
660
|
id, kind: target.kind, provider: target.provider, model: target.model || null, effort: target.effort || null,
|
|
661
|
+
...(Object.hasOwn(target, 'accountId') ? { accountId: target.accountId } : {}),
|
|
500
662
|
// A design card shows sight as design judges it (an unlisted claude-* id is unknown there).
|
|
501
663
|
label, badge, tier: d.tier, vision: fit === 'unknown' ? null : d.vision, price: d.price, contextWindow: d.contextWindow,
|
|
502
664
|
reason: reason || null, warning: null, disabled: false, disabledReason: null,
|
|
@@ -507,6 +669,12 @@ function optionFor(id, rawTarget, badge, { catalog, brain, brainInfo, needsVisio
|
|
|
507
669
|
else if (fit !== 'ok') { option.disabled = true; option.disabledReason = fitText(fit); }
|
|
508
670
|
else if (needsVision && d.vision === false) { option.disabled = true; option.disabledReason = 'This model cannot see images, and this task needs to.'; }
|
|
509
671
|
else if (needsVision && d.vision === null) option.warning = 'Image support unknown for this model.';
|
|
672
|
+
// The session budget: a provider with no share left cannot take the task (a free model still can).
|
|
673
|
+
if (!option.disabled && coverage && !budgetCovers(coverage, catalog, target)) {
|
|
674
|
+
option.disabled = true;
|
|
675
|
+
option.budget = 'share_used_up';
|
|
676
|
+
option.disabledReason = coverage.leftUsd < 0.01 ? "Budget: this session's budget is spent." : `Budget: the ${providerLabel(target.provider)} share of this session's budget is used up.`;
|
|
677
|
+
}
|
|
510
678
|
if (here && brainInfo?.contextWindow && d.contextWindow && d.contextWindow < brainInfo.contextWindow) {
|
|
511
679
|
option.warning = `Smaller context (${Math.round(d.contextWindow / 1000)}k) than the current model; a long conversation may not fit.`;
|
|
512
680
|
}
|
|
@@ -556,19 +724,21 @@ function strongestClaude(catalog) {
|
|
|
556
724
|
* the medium disabled, and — when the suggestion cannot — the model that would
|
|
557
725
|
* run it instead (badge "capable"). Design: the same with "can see".
|
|
558
726
|
*/
|
|
559
|
-
export function buildRouteOptions({ request, evaluation, brain = {}, brainInfo = null, catalog = null, routing = {} }) {
|
|
727
|
+
export function buildRouteOptions({ request, evaluation, brain = {}, brainInfo = null, catalog = null, routing = {}, coverage = null }) {
|
|
560
728
|
const medium = request.needsOutput || null;
|
|
561
729
|
const dispatchOnly = request.dispatchOnly === true;
|
|
562
730
|
const visionOnly = request.visionOnly === true;
|
|
563
|
-
const ctx = { catalog, brain, brainInfo, needsVision: request.needsVision, needsOutput: medium, visionOnly };
|
|
731
|
+
const ctx = { catalog, brain, brainInfo, needsVision: request.needsVision, needsOutput: medium, visionOnly, coverage };
|
|
564
732
|
const options = [];
|
|
565
733
|
const push = (option) => { if (!options.some((o) => sameTarget(o, option))) options.push(option); };
|
|
566
734
|
const hereTarget = { kind: 'direct', provider: brain.provider, model: brain.model || null, effort: brain.effort || null };
|
|
567
735
|
request.proposals.forEach((p, index) => {
|
|
568
736
|
const target = { kind: p.kind, provider: p.provider, model: p.model || (index === 0 ? evaluation.proposal?.model || null : null), effort: p.effort };
|
|
569
|
-
if (index === 0 && evaluation.proposal) Object.assign(target,
|
|
737
|
+
if (index === 0 && evaluation.proposal) Object.assign(target, evaluation.proposal);
|
|
570
738
|
push(optionFor(`s${index + 1}`, target, 'suggested', { ...ctx, reason: p.reason }));
|
|
571
739
|
});
|
|
740
|
+
// The session budget moved the suggestion: the brain's own pick stays on the card, disabled with why.
|
|
741
|
+
if (evaluation.budgetFrom && coverage) push(optionFor('b0', evaluation.budgetFrom, 'suggested', { ...ctx, reason: request.proposals[0]?.reason || null }));
|
|
572
742
|
if (!dispatchOnly) push(optionFor('here', hereTarget, 'current', ctx));
|
|
573
743
|
const pref = preferenceTarget(routing.preferences?.[request.taskClass], brain, catalog, { dispatchOnly });
|
|
574
744
|
if (pref) push(optionFor('r1', pref, 'remembered', ctx));
|
|
@@ -627,9 +797,9 @@ export function targetFromAnswer({ card, response = {}, catalog = null, brain =
|
|
|
627
797
|
// Image / video creation and design always run on a worker: a "here" pick is a dispatch onto that model.
|
|
628
798
|
const dispatchOnly = card.dispatchOnly === true;
|
|
629
799
|
if (option) {
|
|
630
|
-
if (option.disabled) throw routeError('ROUTE_INVALID', option.disabledReason || 'That option is not available for this task.');
|
|
800
|
+
if (option.disabled && !option.accountError) throw routeError('ROUTE_INVALID', option.disabledReason || 'That option is not available for this task.');
|
|
631
801
|
const corrections = [];
|
|
632
|
-
const target = effortChecked({ kind: dispatchOnly ? 'dispatch' : option.kind, provider: option.provider, model: option.model || (dispatchOnly && option.kind === 'direct' ? brain.model || null : null), effort: option.effort }, catalog, corrections);
|
|
802
|
+
const target = effortChecked({ kind: dispatchOnly ? 'dispatch' : option.kind, provider: option.provider, model: option.model || (dispatchOnly && option.kind === 'direct' ? brain.model || null : null), effort: option.effort, ...(option.accountId ? { accountId: option.accountChoice || option.accountId, ...(option.accountKind ? { accountKind: dispatchOnly ? 'dispatch' : option.accountKind } : {}) } : {}) }, catalog, corrections);
|
|
633
803
|
ensureAvailable(target);
|
|
634
804
|
return { target, optionId, remember, corrections };
|
|
635
805
|
}
|
|
@@ -681,7 +851,7 @@ export function escalationFor({ run, catalog = null, routing = {}, depth = 0 })
|
|
|
681
851
|
// just dropped) the retry stands: the launch checks it against a fresh catalog.
|
|
682
852
|
const retryable = !sight || !catalog || designFit(catalog, { kind: 'dispatch', provider, model: run.model || null }) === 'ok';
|
|
683
853
|
if (cause === 'transient' && !isDisabled(catalog, { provider, model: run.model }) && retryable) {
|
|
684
|
-
return { reason, cause, kind: 'retry', from, to: describeTarget(effortChecked({ kind: 'dispatch', provider, model: run.model || null, effort: run.effort || null }, catalog), { catalog }), depth: depth + 1 };
|
|
854
|
+
return { reason, cause, kind: 'retry', from, to: describeTarget(effortChecked({ kind: 'dispatch', provider, model: run.model || null, effort: run.effort || null, ...(run.accountId ? { accountId: run.accountId } : {}) }, catalog), { catalog }), depth: depth + 1 };
|
|
685
855
|
}
|
|
686
856
|
let to = null;
|
|
687
857
|
const ladder = Array.isArray(routing.ladders?.[provider]) ? routing.ladders[provider] : null;
|
|
@@ -697,13 +867,14 @@ export function escalationFor({ run, catalog = null, routing = {}, depth = 0 })
|
|
|
697
867
|
}
|
|
698
868
|
if (!to && !medium) {
|
|
699
869
|
const pref = routing.preferences?.complex;
|
|
700
|
-
if (pref?.provider && !(pref.provider === provider && pref.model === run.model) && !isDisabled(catalog, pref) && canRun({ kind: 'dispatch', provider: pref.provider, model: pref.model || null })) to = { kind: 'dispatch', provider: pref.provider, model: pref.model || null, effort: pref.effort || null };
|
|
870
|
+
if (pref?.provider && !(pref.provider === provider && pref.model === run.model) && !isDisabled(catalog, pref) && canRun({ kind: 'dispatch', provider: pref.provider, model: pref.model || null })) to = { kind: 'dispatch', provider: pref.provider, model: pref.model || null, effort: pref.effort || null, ...(pref.accountId ? { accountId: pref.accountId } : {}) };
|
|
701
871
|
}
|
|
702
872
|
if (!to && provider !== 'claude-code' && !medium) {
|
|
703
873
|
const opus = strongestClaude(catalog);
|
|
704
874
|
if (opus && canRun({ kind: 'dispatch', provider: 'claude-code', model: opus.id })) to = { kind: 'dispatch', provider: 'claude-code', model: opus.id, effort: 'high' };
|
|
705
875
|
}
|
|
706
876
|
if (!to) return null;
|
|
877
|
+
if (to.provider === provider && !to.accountId && run.accountId) to.accountId = run.accountId;
|
|
707
878
|
return { reason, cause, kind: 'escalate', from, to: describeTarget(effortChecked(to, catalog), { catalog }), depth: depth + 1 };
|
|
708
879
|
}
|
|
709
880
|
|
|
@@ -722,6 +893,9 @@ export function escalationFor({ run, catalog = null, routing = {}, depth = 0 })
|
|
|
722
893
|
* declineHeld(runId, reason), routed(sessionId, result), routeFailed(sessionId, error) }
|
|
723
894
|
* @param {object} [deps.clarifier] assistant-clarify.js — { blocking(sessionId, { independent }) }: while the
|
|
724
895
|
* user has not answered the brain's questions, agent_route answers "clarifying"
|
|
896
|
+
* @param {object} [deps.budget] assistant-budget-planner.js — the session budget: the split is made at the
|
|
897
|
+
* session's first actionable route, a provider out of share is a checkpoint, and
|
|
898
|
+
* with a budget a task moves to a provider with money left (the saved routes yield)
|
|
725
899
|
*/
|
|
726
900
|
export function createAssistantRouter({
|
|
727
901
|
catalog,
|
|
@@ -732,6 +906,9 @@ export function createAssistantRouter({
|
|
|
732
906
|
randomId = () => randomBytes(6).toString('hex'),
|
|
733
907
|
log = () => {},
|
|
734
908
|
clarifier = null,
|
|
909
|
+
listAccounts = null,
|
|
910
|
+
accountUsage = null,
|
|
911
|
+
budget = null,
|
|
735
912
|
} = {}) {
|
|
736
913
|
if (!catalog) throw new Error('createAssistantRouter requires a catalog');
|
|
737
914
|
const cards = new Map(); // routeId → card
|
|
@@ -749,9 +926,108 @@ export function createAssistantRouter({
|
|
|
749
926
|
return normalizeRouteMode(session?.routingMode) || routing().defaultMode;
|
|
750
927
|
}
|
|
751
928
|
async function catalogValue() {
|
|
929
|
+
if (accountUsage) await accountUsage.refresh();
|
|
752
930
|
try { return await catalog.full(); } catch (error) { log('router:catalog-error', error?.message || String(error)); return catalog.peek?.() || { models: {} }; }
|
|
753
931
|
}
|
|
754
932
|
function brainInfoFor(brain) { try { return catalog.brainInfo(brain); } catch { return null; } }
|
|
933
|
+
/** Whether the session runs under a budget now (No limit, or no planner: false). */
|
|
934
|
+
function budgeted() { try { return !!budget?.limited?.(); } catch { return false; } }
|
|
935
|
+
function coverageOf(sessionId) { try { return budgeted() ? budget.coverage(sessionId) : null; } catch { return null; } }
|
|
936
|
+
/** The shares a route result carries: { mode, amountUsd, leftUsd, providers: [{ provider, shareUsd, spentUsd, leftUsd }] }, null under No limit. */
|
|
937
|
+
function compactBudget(sessionId) {
|
|
938
|
+
let view = null;
|
|
939
|
+
try { view = budget?.view?.(sessionId) || null; } catch { view = null; }
|
|
940
|
+
if (!view) return null;
|
|
941
|
+
if (view.unlimited) return { mode: 'unlimited' };
|
|
942
|
+
return {
|
|
943
|
+
mode: 'limited', amountUsd: view.amountUsd, spentUsd: view.spentUsd, leftUsd: view.leftUsd, source: view.split?.source || null,
|
|
944
|
+
providers: (view.providers || []).map((row) => ({ provider: row.provider, shareUsd: row.shareUsd, spentUsd: row.spentUsd, leftUsd: row.workerLeftUsd, brain: row.isBrain || undefined })),
|
|
945
|
+
dry: view.dry || [],
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
/** A task the budget planner keeps for its next split (the route was approved). */
|
|
949
|
+
function noteBudgetTask(sessionId, request, target) {
|
|
950
|
+
if (!budgeted() || !target?.provider || request?.taskClass === 'chat') return;
|
|
951
|
+
try { budget.noteTask(sessionId, { taskClass: request.taskClass, summary: request.summary || null, provider: target.provider, model: target.model || null }); } catch { /* the split goes on without it */ }
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* evaluateRoute under the session budget. The split is made at the session's first actionable route
|
|
955
|
+
* (the planner asks Jev, else its fallback). A target whose provider has no share left is a checkpoint:
|
|
956
|
+
* the unspent budget is divided again, then the route is evaluated once more on the new shares (where
|
|
957
|
+
* it may still move to another provider, or be declined). No limit: exactly evaluateRoute.
|
|
958
|
+
*/
|
|
959
|
+
async function evaluateWithBudget(sessionId, plan) {
|
|
960
|
+
if (!budgeted() || plan.request.taskClass === 'chat') return evaluateRoute(plan);
|
|
961
|
+
const first = plan.request.proposals[0] || {};
|
|
962
|
+
const task = { taskClass: plan.request.taskClass, summary: plan.request.summary || null, provider: first.provider || null, model: first.model || null };
|
|
963
|
+
try { await budget.ensureSplit(sessionId, { task }); } catch { /* the coverage below works without a split */ }
|
|
964
|
+
let evaluation = evaluateRoute({ ...plan, budget: { coverage: coverageOf(sessionId) } });
|
|
965
|
+
const from = evaluation.budgetFrom;
|
|
966
|
+
if (from) {
|
|
967
|
+
try { await budget.ensureCovered(sessionId, { provider: from.provider, forBrain: from.kind === 'direct', free: targetIsFree(plan.catalog, from), task: { ...task, provider: from.provider, model: from.model || null } }); } catch { /* evaluated on the shares as they are */ }
|
|
968
|
+
evaluation = evaluateRoute({ ...plan, budget: { coverage: coverageOf(sessionId) } });
|
|
969
|
+
}
|
|
970
|
+
evaluation.coverage = coverageOf(sessionId);
|
|
971
|
+
return evaluation;
|
|
972
|
+
}
|
|
973
|
+
/** What the brain is told when no provider can pay for the task. */
|
|
974
|
+
function budgetDeclinedText(sessionId, dry) {
|
|
975
|
+
const view = compactBudget(sessionId);
|
|
976
|
+
const money = view?.mode === 'limited' ? ` (${usdText(view.leftUsd)} of the ${usdText(view.amountUsd)} budget left)` : '';
|
|
977
|
+
return `No provider has budget left for this task: ${dry.length ? `the ${dry.join(', ')} share${dry.length === 1 ? ' is' : 's are'} used up` : 'the session budget is spent'} and no unspent budget could be moved${money}. Do not start or dispatch it. Tell the user in one line that this session's budget is used up for this work, and that they can raise it or switch to No limit (Assistant → Budget).`;
|
|
978
|
+
}
|
|
979
|
+
/** Declined because no provider can pay (every mode, no card). */
|
|
980
|
+
function budgetDeclinedResult({ sessionId, request, mode, cat, brain, brainInfo, evaluation }) {
|
|
981
|
+
const routeId = `route-${randomId()}`;
|
|
982
|
+
const dry = evaluation.dry || [];
|
|
983
|
+
const route = {
|
|
984
|
+
routeId, status: 'declined', sessionId, taskClass: request.taskClass, summary: request.summary, confidence: request.confidence,
|
|
985
|
+
target: null, decidedBy: 'budget', remembered: false, corrections: evaluation.corrections || [], mode, runIds: [], reasonCode: 'budget_exhausted', dry,
|
|
986
|
+
reason: dry.length ? `Budget: the ${dry.join(', ')} share${dry.length === 1 ? ' is' : 's are'} used up.` : "Budget: this session's budget is spent.",
|
|
987
|
+
};
|
|
988
|
+
call('routeEvent', sessionId, 'declined', routeView(route, { catalog: cat, brain, brainInfo }));
|
|
989
|
+
return {
|
|
990
|
+
ok: true, status: 'declined', reason: 'budget_exhausted', dry, routeId, mode, source: 'budget', target: null,
|
|
991
|
+
remembered: false, continuation: false, corrections: evaluation.corrections || [], runIds: [], expiresAt: null,
|
|
992
|
+
budget: compactBudget(sessionId), next: budgetDeclinedText(sessionId, dry),
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
function liveAccounts(cat) { return accountUsage ? accountUsage.list() : listAccounts ? listAccounts() : cat?.accounts || {}; }
|
|
996
|
+
function accountTarget(target, brain, cat, { check = true, fallback = null, select = true } = {}) {
|
|
997
|
+
if (!hasRouteAccounts(target?.provider)) return target;
|
|
998
|
+
const accounts = liveAccounts(cat);
|
|
999
|
+
// Older hosts/test catalogs that have no account service retain their dispatch validation.
|
|
1000
|
+
if (!Object.hasOwn(accounts, target.provider)) return target;
|
|
1001
|
+
const workerKey = target.provider === 'codex' ? 'codexAccount' : 'claudeAccount';
|
|
1002
|
+
const defaultId = configStore?.workers?.()?.[workerKey] || 'default';
|
|
1003
|
+
const choice = target.accountId || (target.kind === 'direct' ? brain.accountId || 'default' : (!Object.hasOwn(target, 'accountId') && fallback) || defaultId);
|
|
1004
|
+
const rows = accounts[target.provider] || [];
|
|
1005
|
+
const { row, reason } = select ? chooseUsageAccount(rows, choice, defaultId) : { row: rows.find(a => a.id === choice), reason: null };
|
|
1006
|
+
const accountId = row?.id || choice;
|
|
1007
|
+
const before = rows.find(a => a.id === choice);
|
|
1008
|
+
const usage = row?.usage ? { remainingPercent: row.usage.remainingPercent, limited: row.usage.limited, resetsAt: row.usage.resetsAt } : null;
|
|
1009
|
+
const why = reason === 'accounts_all_limited' ? 'All subscription accounts are limited; selected the soonest reset.' : reason === 'account_limited' ? `Account ${before?.label || choice} out of usage${before?.usage?.resetsAt ? ` (resets ${before.usage.resetsAt})` : ''} → ${row?.label || accountId}, ${usage?.remainingPercent == null ? 'usage unknown' : `${Math.round(usage.remainingPercent)}% left`}.` : reason === 'account_auto' ? `Auto · most usage left → ${row?.label || accountId}, ${usage?.remainingPercent == null ? 'usage unknown; using subscription default' : `${Math.round(usage.remainingPercent)}% left`}.` : null;
|
|
1010
|
+
const account = row ? { id: row.id, label: row.label || row.id, mode: row.mode || null, signedIn: row.signedIn ?? null, identity: row.identity || null, ...(usage ? { usage } : {}), ...(why ? { reason: why } : {}) } : null;
|
|
1011
|
+
const message = !row && choice === 'auto' ? 'Auto requires a signed-in subscription account. Sign in to a Claude or ChatGPT subscription, or pin an API-key account explicitly.' : !row
|
|
1012
|
+
? `Account ${accountId} was removed. Open Model routes and save a new Account choice for this route.`
|
|
1013
|
+
: row.signedIn === false ? `Account ${row.label || row.id} (${row.id}) is signed out. Sign it in from the ${providerLabel(target.provider)} account menu, or save another Account in Model routes.` : '';
|
|
1014
|
+
const accountError = message ? { code: row ? 'ACCOUNT_SIGNED_OUT' : choice === 'auto' ? 'ACCOUNT_NO_SUBSCRIPTION' : 'ACCOUNT_NOT_FOUND', message } : null;
|
|
1015
|
+
// Provider histories are account-owned. Another account can do the task on
|
|
1016
|
+
// a worker; it cannot resume this account's conversation.
|
|
1017
|
+
const moved = target.kind === 'direct' && accountId !== (brain.accountId || 'default');
|
|
1018
|
+
const accountCorrection = why ? { reason, from: before?.label || choice, to: row?.label || accountId, usage, workerForAccount: moved, message: why + (moved ? ' Runs on a worker because this conversation belongs to another account.' : '') } : null;
|
|
1019
|
+
const enriched = { ...target, accountKind: target.accountKind || target.kind, ...(moved ? { kind: 'dispatch' } : {}), accountId, account, accountError, accountChoice: target.accountChoice || choice, accountCorrection };
|
|
1020
|
+
if (check && accountError) throw routeError(accountError.code, message, 409, { accountId, target: enriched });
|
|
1021
|
+
return enriched;
|
|
1022
|
+
}
|
|
1023
|
+
function accountCorrections(target, base = []) { return target.accountCorrection && !base.some(c => c.reason === target.accountCorrection.reason && c.from === target.accountCorrection.from && c.to === target.accountCorrection.to && c.message === target.accountCorrection.message) ? [...base, target.accountCorrection] : base; }
|
|
1024
|
+
function checkedTarget(target, brain, cat, route = null, fallback = null) {
|
|
1025
|
+
try { return accountTarget(target, brain, cat, { fallback }); }
|
|
1026
|
+
catch (error) {
|
|
1027
|
+
if (route) call('routeEvent', route.sessionId, 'declined', routeView({ ...route, target: error.target, status: 'declined', reason: error.message, reasonCode: error.code }, { catalog: cat, brain }));
|
|
1028
|
+
throw error;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
755
1031
|
function requireSession(sessionId) {
|
|
756
1032
|
const session = sessionId ? getSession(sessionId) : null;
|
|
757
1033
|
if (!session) throw routeError('SESSION_NOT_FOUND', `Unknown assistant session ${sessionId}`, 404);
|
|
@@ -773,6 +1049,8 @@ export function createAssistantRouter({
|
|
|
773
1049
|
reasonCode: source.reasonCode || null,
|
|
774
1050
|
// Design's no_capable_model: no model can see.
|
|
775
1051
|
...(source.needs ? { needs: source.needs } : {}),
|
|
1052
|
+
// budget_exhausted: the providers whose share is used up (the panel names them).
|
|
1053
|
+
...(Array.isArray(source.dry) ? { dry: [...source.dry] } : {}),
|
|
776
1054
|
};
|
|
777
1055
|
}
|
|
778
1056
|
|
|
@@ -803,19 +1081,24 @@ export function createAssistantRouter({
|
|
|
803
1081
|
return call('continueDirect', sessionId, { target, route: source, text: source.summary || '' }) !== false;
|
|
804
1082
|
}
|
|
805
1083
|
|
|
806
|
-
function resultFor(status, { routeId, mode, source = null, target = null, card = null, runIds = [], corrections = [], cat = null, brain = {}, brainInfo = null, remembered = false, continuation = false } = {}) {
|
|
1084
|
+
function resultFor(status, { routeId, mode, source = null, target = null, card = null, runIds = [], corrections = [], cat = null, brain = {}, brainInfo = null, remembered = false, continuation = false, sessionId = null } = {}) {
|
|
807
1085
|
const described = target ? { ...describeTarget(target, { catalog: cat, brain, brainInfo }), continuation } : null;
|
|
808
1086
|
// A saved route that replaced the brain's pick (never mode) says so first.
|
|
809
1087
|
const binding = (corrections || []).find((c) => c?.reason === 'remembered_route');
|
|
810
1088
|
const skipped = (corrections || []).find((c) => c?.reason === 'remembered_disabled');
|
|
811
1089
|
// Image / video creation: a pick that cannot make the medium was moved onto one that can.
|
|
812
1090
|
const maker = (corrections || []).find((c) => c?.reason === 'needs_output');
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1091
|
+
// The session budget moved the task: the provider it was going to has no share left.
|
|
1092
|
+
const moved = (corrections || []).find((c) => c?.reason === 'budget_share');
|
|
1093
|
+
const lead = moved ? `Budget: ${moved.text.replace(/^budget: /, '')} (the session budget moved it; with a budget the saved routes yield). `
|
|
1094
|
+
: binding ? `The user's saved route for this task class applies (${binding.to}), not your pick (${binding.from}). `
|
|
1095
|
+
: skipped ? `The saved route for this task class (${skipped.from}) is on a disabled model, so your pick stands. `
|
|
1096
|
+
: maker ? `Your pick (${maker.from}) cannot make what this task creates, so it runs on ${maker.to}. ` : '';
|
|
1097
|
+
const shares = sessionId && budgeted() ? compactBudget(sessionId) : null;
|
|
816
1098
|
return {
|
|
817
1099
|
ok: true, status, routeId, mode, source, target: described, remembered, continuation,
|
|
818
1100
|
corrections, runIds, expiresAt: card?.expiresAt ? new Date(card.expiresAt).toISOString() : null,
|
|
1101
|
+
...(shares ? { budget: shares } : {}),
|
|
819
1102
|
next: `${lead}${nextText(status, described, { routeId, label: described?.label })}`,
|
|
820
1103
|
};
|
|
821
1104
|
}
|
|
@@ -868,7 +1151,13 @@ export function createAssistantRouter({
|
|
|
868
1151
|
const routeId = `route-${randomId()}`;
|
|
869
1152
|
const requestId = routeId;
|
|
870
1153
|
const r = routing();
|
|
871
|
-
const { options, defaultOptionId } = buildRouteOptions({ request, evaluation, brain, brainInfo, catalog: cat, routing: r });
|
|
1154
|
+
const { options, defaultOptionId } = buildRouteOptions({ request, evaluation, brain, brainInfo, catalog: cat, routing: r, coverage: evaluation.coverage || null });
|
|
1155
|
+
for (const option of options) {
|
|
1156
|
+
const first = request.proposals[0];
|
|
1157
|
+
const fallback = origin === 'dispatch' && option.badge !== 'remembered' && option.provider === first?.provider ? first.accountId : null;
|
|
1158
|
+
Object.assign(option, accountTarget(option, brain, cat, { check: false, fallback }));
|
|
1159
|
+
if (option.accountError) { option.disabled = true; option.disabledReason = option.accountError.message; }
|
|
1160
|
+
}
|
|
872
1161
|
const computer = computerConsent({ session, request, options, defaultOptionId, brain, origin });
|
|
873
1162
|
const expiresAt = now() + r.cardTimeoutMinutes * 60_000;
|
|
874
1163
|
const pref = r.preferences?.[request.taskClass];
|
|
@@ -1005,21 +1294,30 @@ export function createAssistantRouter({
|
|
|
1005
1294
|
const request = normalizeRouteRequest(body, { brain });
|
|
1006
1295
|
const mode = modeFor(sessionId);
|
|
1007
1296
|
const r = routing();
|
|
1008
|
-
const evaluation =
|
|
1297
|
+
const evaluation = await evaluateWithBudget(sessionId, { mode, routing: r, request, brain, brainInfo, catalog: cat });
|
|
1298
|
+
if (evaluation.unavailable === 'budget_exhausted') return budgetDeclinedResult({ sessionId, request, mode, cat, brain, brainInfo, evaluation });
|
|
1009
1299
|
if (evaluation.unavailable) return unavailableResult({ sessionId, request, mode, cat, brain, brainInfo });
|
|
1010
1300
|
if (!evaluation.ask) {
|
|
1011
1301
|
const routeId = `route-${randomId()}`;
|
|
1012
|
-
|
|
1013
|
-
const
|
|
1302
|
+
let target = evaluation.auto;
|
|
1303
|
+
const checked = accountTarget(target, brain, cat, { check: false, select: request.taskClass !== 'chat' });
|
|
1304
|
+
if (checked.accountError) {
|
|
1305
|
+
const failed = { routeId, sessionId, taskClass: request.taskClass, summary: request.summary, target: checked, status: 'declined', reason: checked.accountError.message, reasonCode: checked.accountError.code, mode };
|
|
1306
|
+
call('routeEvent', sessionId, 'declined', routeView(failed, { catalog: cat, brain, brainInfo }));
|
|
1307
|
+
return { ok: false, status: 'declined', routeId, mode, code: checked.accountError.code, target: describeTarget(checked, { catalog: cat, brain }), next: checked.accountError.message };
|
|
1308
|
+
}
|
|
1309
|
+
target = checked;
|
|
1310
|
+
const decidedBy = evaluation.source === 'remembered' ? 'remembered' : evaluation.source === 'budget' ? 'budget' : 'brain';
|
|
1014
1311
|
const route = {
|
|
1015
1312
|
routeId, status: 'approved', sessionId, taskClass: request.taskClass, summary: request.summary, confidence: request.confidence,
|
|
1016
|
-
target, decidedBy, remembered: evaluation.source === 'remembered', corrections: evaluation.corrections, mode,
|
|
1313
|
+
target, decidedBy, remembered: evaluation.source === 'remembered', corrections: accountCorrections(target, evaluation.corrections), mode,
|
|
1017
1314
|
reason: request.proposals[0]?.reason || null, runIds: [], expiresAt: now() + DECIDED_TTL_MS,
|
|
1018
1315
|
};
|
|
1019
1316
|
decided.set(routeId, route);
|
|
1317
|
+
noteBudgetTask(sessionId, request, target);
|
|
1020
1318
|
if (request.taskClass !== 'chat') call('routeEvent', sessionId, 'auto', routeView(route, { catalog: cat, brain, brainInfo }));
|
|
1021
1319
|
const continuation = origin === 'agent_route' ? maybeContinuation(sessionId, target, brain, route) : false;
|
|
1022
|
-
return resultFor('approved', { routeId, mode, source: evaluation.source, target, corrections:
|
|
1320
|
+
return resultFor('approved', { routeId, mode, source: evaluation.source, target, corrections: route.corrections, cat, brain, brainInfo, remembered: route.remembered, continuation, sessionId });
|
|
1023
1321
|
}
|
|
1024
1322
|
// The session as it is now (the catalog was awaited since): its computer-use answer decides what the card says.
|
|
1025
1323
|
const card = createCard({ sessionId, request, evaluation, brain, brainInfo, cat, mode, origin, session: getSession(sessionId) || session });
|
|
@@ -1035,7 +1333,7 @@ export function createAssistantRouter({
|
|
|
1035
1333
|
const outcome = card.settled || (wait > 0 ? await waitFor(card, wait, signal) : null);
|
|
1036
1334
|
if (!outcome) {
|
|
1037
1335
|
card.mailboxOnDecision = true;
|
|
1038
|
-
return resultFor('pending', { routeId: card.routeId, mode, card, cat, brain, brainInfo });
|
|
1336
|
+
return resultFor('pending', { routeId: card.routeId, mode, card, cat, brain, brainInfo, sessionId });
|
|
1039
1337
|
}
|
|
1040
1338
|
if (outcome.result) return outcome.result;
|
|
1041
1339
|
const closed = resultFor(outcome.status || 'cancelled', { routeId: card.routeId, mode, card, cat, brain, brainInfo });
|
|
@@ -1085,7 +1383,18 @@ export function createAssistantRouter({
|
|
|
1085
1383
|
closeCard(card);
|
|
1086
1384
|
return result;
|
|
1087
1385
|
}
|
|
1088
|
-
const {
|
|
1386
|
+
const { optionId, remember } = parsed;
|
|
1387
|
+
const target = checkedTarget(parsed.target, brain, cat);
|
|
1388
|
+
const corrections = accountCorrections(target, parsed.corrections || []);
|
|
1389
|
+
// The session budget must be able to pay for the pick: a provider with no share left divides the
|
|
1390
|
+
// unspent budget again first; still none, and the card stays open with the reason.
|
|
1391
|
+
if (budgeted() && !budgetCovers(coverageOf(card.sessionId), cat, target)) {
|
|
1392
|
+
try { await budget.ensureCovered(card.sessionId, { provider: target.provider, forBrain: target.kind === 'direct', free: targetIsFree(cat, target) }); } catch { /* decided on the shares as they are */ }
|
|
1393
|
+
if (cards.get(routeId) !== card || card.status !== 'pending') openCard(routeId, { sessionId });
|
|
1394
|
+
if (!budgetCovers(coverageOf(card.sessionId), cat, target)) {
|
|
1395
|
+
throw routeError('BUDGET_SHARE_EXHAUSTED', `Budget: the ${providerLabel(target.provider)} share of this session's budget is used up. Pick another option (or a free model), or raise the budget.`, 409, { provider: target.provider });
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1089
1398
|
// Every held run must be able to start on the pick (the dispatcher's checkHeld: a design run's
|
|
1090
1399
|
// sight and browser settings on that provider). A pick one would refuse is not approved, saved
|
|
1091
1400
|
// or reported started: the card stays open with the reason.
|
|
@@ -1111,7 +1420,7 @@ export function createAssistantRouter({
|
|
|
1111
1420
|
if (remember && card.taskClass !== 'chat' && configStore?.setPreference) {
|
|
1112
1421
|
try {
|
|
1113
1422
|
const described = describeTarget(target, { catalog: cat, brain, brainInfo });
|
|
1114
|
-
configStore.setPreference(card.taskClass, { ...target, label: described?.label || null }, { by: 'user' });
|
|
1423
|
+
configStore.setPreference(card.taskClass, { ...target, kind: target.accountKind || target.kind, accountId: target.accountChoice || target.accountId, label: described?.label || null }, { by: 'user' });
|
|
1115
1424
|
remembered = true;
|
|
1116
1425
|
} catch (error) { log('router:remember-error', error?.message || String(error)); }
|
|
1117
1426
|
}
|
|
@@ -1121,6 +1430,7 @@ export function createAssistantRouter({
|
|
|
1121
1430
|
reason: card.options.find((o) => o.id === optionId)?.reason || null, alternatives: card.alternatives, expiresAt: now() + DECIDED_TTL_MS,
|
|
1122
1431
|
};
|
|
1123
1432
|
decided.set(routeId, route);
|
|
1433
|
+
noteBudgetTask(card.sessionId, { taskClass: card.taskClass, summary: card.summary }, target);
|
|
1124
1434
|
const heldRuns = [...card.runIds];
|
|
1125
1435
|
// A remote session: the held run this card was for is its one dispatch.
|
|
1126
1436
|
if (heldRuns.length && session.remote === true) route.consumed = true;
|
|
@@ -1137,7 +1447,7 @@ export function createAssistantRouter({
|
|
|
1137
1447
|
// `authority` is passed on untouched and never kept: the runtime alone can tell whose it is.
|
|
1138
1448
|
call('computerApproved', card.sessionId, { routeId, origin, viaMailbox, authority });
|
|
1139
1449
|
}
|
|
1140
|
-
const result = resultFor('approved', { routeId, mode: card.mode, source: 'user', target, corrections, cat, brain, brainInfo, remembered, runIds: heldRuns, continuation });
|
|
1450
|
+
const result = resultFor('approved', { routeId, mode: card.mode, source: 'user', target, corrections, cat, brain, brainInfo, remembered, runIds: heldRuns, continuation, sessionId: card.sessionId });
|
|
1141
1451
|
if (viaMailbox) {
|
|
1142
1452
|
call('mailbox', card.sessionId, { kind: 'route_decided', route: view, runIds: heldRuns, text: heldRuns.length ? `The user picked ${view.target?.label || 'a model'}; the held run${heldRuns.length === 1 ? '' : 's'} started.` : `The user picked ${view.target?.label || 'a model'}. ${result.next}` });
|
|
1143
1453
|
}
|
|
@@ -1173,10 +1483,27 @@ export function createAssistantRouter({
|
|
|
1173
1483
|
// A remote session's route is claimed here, so a second dispatch cannot take it meanwhile.
|
|
1174
1484
|
// `claimed` tells the dispatcher to give it back (releaseRoute) if it refuses the launch
|
|
1175
1485
|
// before a run exists: only the run it starts uses the route up.
|
|
1486
|
+
const t = checkedTarget(approved.target, brain, cat, approved);
|
|
1487
|
+
approved.target = t;
|
|
1488
|
+
approved.corrections = accountCorrections(t, approved.corrections);
|
|
1489
|
+
// The approved provider may have run out of share since the approval: the unspent budget is
|
|
1490
|
+
// divided again first, then the dispatch moves the way agent_route would, or is refused.
|
|
1491
|
+
let runTarget = t;
|
|
1492
|
+
let budgetCorrection = null;
|
|
1493
|
+
if (budgeted() && !budgetCovers(coverageOf(sessionId), cat, t)) {
|
|
1494
|
+
try { await budget.ensureCovered(sessionId, { provider: t.provider, forBrain: false, free: targetIsFree(cat, t), task: { taskClass: approved.taskClass, summary: approved.summary || null, provider: t.provider, model: t.model || null } }); } catch { /* decided on the shares as they are */ }
|
|
1495
|
+
const request = normalizeRouteRequest({ task_class: approved.taskClass, summary: approved.summary || '', confidence: 1, needs_vision: !!spec.usesComputer, proposals: [{ kind: 'dispatch', provider: t.provider, model: t.model || null, effort: t.effort || null }] }, { brain });
|
|
1496
|
+
const moved = budgetMove({ target: { ...t, kind: 'dispatch' }, request, catalog: cat, brain, brainInfo, routing: r, coverage: coverageOf(sessionId) });
|
|
1497
|
+
if (moved.declined) {
|
|
1498
|
+
throw routeError('BUDGET_SHARE_EXHAUSTED', `Route ${routeId} was approved for ${t.provider}${t.model ? `/${t.model}` : ''}, but ${t.provider} has no share of this session's budget left, and no other provider can take this task now (out of share: ${(moved.dry || []).join(', ') || 'all'}). Do not dispatch it again on this route: call agent_route again for this task, or tell the user the budget is used up (Assistant → Budget).`, 409, { routeId, provider: t.provider, dry: moved.dry || [] });
|
|
1499
|
+
}
|
|
1500
|
+
if (moved.correction) { runTarget = moved.target; budgetCorrection = moved.correction; }
|
|
1501
|
+
}
|
|
1176
1502
|
if (remote) approved.consumed = true;
|
|
1177
|
-
const
|
|
1178
|
-
const
|
|
1179
|
-
|
|
1503
|
+
const applied = { ...spec, provider: runTarget.provider, model: runTarget.model || null, effort: runTarget.effort || spec.effort || null, ...(runTarget.accountId ? { accountId: runTarget.accountId, codexAccountId: null, claudeAccountId: null } : runTarget !== t ? { accountId: null, codexAccountId: null, claudeAccountId: null } : {}) };
|
|
1504
|
+
const meta = routeMeta(approved, cat, brain, brainInfo);
|
|
1505
|
+
const route = budgetCorrection ? { ...meta, target: describeTarget(runTarget, { catalog: cat, brain, brainInfo }), corrections: [...(meta.corrections || []), budgetCorrection] } : meta;
|
|
1506
|
+
return { action: 'start', spec: applied, route: { ...route, requested: { provider: spec.provider || null, model: spec.model || null, effort: spec.effort || null } }, ...(remote ? { claimed: true } : {}) };
|
|
1180
1507
|
}
|
|
1181
1508
|
const pending = cards.get(routeId);
|
|
1182
1509
|
if (pending && pending.sessionId === sessionId && pending.status === 'pending' && !(remote && pending.runIds.size)) {
|
|
@@ -1186,17 +1513,21 @@ export function createAssistantRouter({
|
|
|
1186
1513
|
const request = normalizeRouteRequest({
|
|
1187
1514
|
task_class: spec.taskClass || 'code', confidence: spec.confidence ?? null, summary: spec.title || clip(spec.task, 200),
|
|
1188
1515
|
needs_vision: !!spec.usesComputer,
|
|
1189
|
-
proposals: [{ kind: 'dispatch', provider: spec.provider || brain.provider, model: spec.model || null, effort: spec.effort || null }],
|
|
1516
|
+
proposals: [{ kind: 'dispatch', provider: spec.provider || brain.provider, model: spec.model || null, effort: spec.effort || null, accountId: spec.accountId || spec.codexAccountId || spec.claudeAccountId || null }],
|
|
1190
1517
|
}, { brain });
|
|
1191
1518
|
if (!spec.provider) request.proposals[0].provider = brain.provider || 'claude-code';
|
|
1192
|
-
const evaluation =
|
|
1519
|
+
const evaluation = await evaluateWithBudget(sessionId, { mode, routing: r, request, brain, brainInfo, catalog: cat });
|
|
1520
|
+
if (evaluation.unavailable === 'budget_exhausted') {
|
|
1521
|
+
throw routeError('BUDGET_SHARE_EXHAUSTED', budgetDeclinedText(sessionId, evaluation.dry || []), 409, { dry: evaluation.dry || [], taskClass: request.taskClass, reason: 'budget_exhausted' });
|
|
1522
|
+
}
|
|
1193
1523
|
if (evaluation.unavailable) throw routeError('NO_CAPABLE_MODEL', noMakerText(request), 409, { requires: request.needsOutput, ...(request.needsOutput ? {} : { needs: 'vision' }), taskClass: request.taskClass });
|
|
1194
1524
|
if (!evaluation.ask) {
|
|
1195
|
-
const t = evaluation.auto;
|
|
1196
|
-
|
|
1525
|
+
const t = checkedTarget(evaluation.auto, brain, cat);
|
|
1526
|
+
noteBudgetTask(sessionId, request, t);
|
|
1527
|
+
const applied = { ...spec, provider: t.provider, model: t.model || null, effort: t.effort || spec.effort || null, ...(t.accountId ? { accountId: t.accountId, codexAccountId: null, claudeAccountId: null } : {}) };
|
|
1197
1528
|
const route = {
|
|
1198
|
-
routeId: `route-${randomId()}`, status: 'auto', decidedBy: evaluation.source === 'remembered' ? 'remembered' : 'brain', mode,
|
|
1199
|
-
taskClass: request.taskClass, confidence: request.confidence, target: t, corrections: evaluation.corrections,
|
|
1529
|
+
routeId: `route-${randomId()}`, status: 'auto', decidedBy: evaluation.source === 'remembered' ? 'remembered' : evaluation.source === 'budget' ? 'budget' : 'brain', mode,
|
|
1530
|
+
taskClass: request.taskClass, confidence: request.confidence, target: t, corrections: accountCorrections(t, evaluation.corrections),
|
|
1200
1531
|
remembered: evaluation.source === 'remembered',
|
|
1201
1532
|
};
|
|
1202
1533
|
return { action: 'start', spec: applied, route: { ...routeMeta(route, cat, brain, brainInfo), requested: { provider: spec.provider || null, model: spec.model || null, effort: spec.effort || null } } };
|
|
@@ -1298,7 +1629,7 @@ export function createAssistantRouter({
|
|
|
1298
1629
|
const r = routing();
|
|
1299
1630
|
const mode = modeFor(sessionId);
|
|
1300
1631
|
const prefs = Object.entries(r.preferences || {}).filter(([, p]) => p?.provider)
|
|
1301
|
-
.map(([cls, p]) => `${cls} → ${p.kind === 'direct' ? 'here' : p.provider}${p.model ? `/${p.model}` : ''}${p.effort ? ` (${p.effort})` : ''}`);
|
|
1632
|
+
.map(([cls, p]) => `${cls} → ${p.kind === 'direct' ? 'here' : p.provider}${p.model ? `/${p.model}` : ''}${p.effort ? ` (${p.effort})` : ''}${hasRouteAccounts(p.provider) ? ` as ${routeAccountText(p, { accounts: liveAccounts(catalog.peek?.()), brain: getSession(sessionId)?.brain, workers: { codex: configStore?.workers?.()?.codexAccount, 'claude-code': configStore?.workers?.()?.claudeAccount } })}` : ''}`);
|
|
1302
1633
|
const modeLine = mode === 'always-ask' ? 'always ask (call agent_route before every actionable task; the user picks the model)'
|
|
1303
1634
|
: mode === 'never' ? 'never ask (agent_route approves at once; the saved routes below are binding and agent_route applies them over your pick, so classify the task correctly)'
|
|
1304
1635
|
: `ask when unsure (agent_route asks the user below ${Math.round(r.askBelow * 100)}% confidence or for unknown/missing models)`;
|