@intentic/sandbox-contract 1.245.0 → 1.247.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/README.md +17 -1
- package/dist/batch-runs.d.ts +2 -0
- package/dist/batch-runs.d.ts.map +1 -1
- package/dist/batch-runs.js +1 -0
- package/dist/batch-runs.js.map +1 -1
- package/dist/command-classes.d.ts +6 -3
- package/dist/command-classes.d.ts.map +1 -1
- package/dist/command-classes.js +43 -18
- package/dist/command-classes.js.map +1 -1
- package/dist/contracts/{cursor.contract.d.ts → accounts.contract.d.ts} +102 -3
- package/dist/contracts/accounts.contract.d.ts.map +1 -0
- package/dist/contracts/accounts.contract.js +61 -0
- package/dist/contracts/accounts.contract.js.map +1 -0
- package/dist/contracts/agent.contract.d.ts +19 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +121 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +4 -4
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/ci.contract.d.ts +2 -0
- package/dist/contracts/ci.contract.d.ts.map +1 -1
- package/dist/contracts/host.contract.d.ts +35 -0
- package/dist/contracts/host.contract.d.ts.map +1 -1
- package/dist/contracts/host.contract.js +3 -2
- package/dist/contracts/host.contract.js.map +1 -1
- package/dist/contracts/personas.contract.d.ts +4 -2
- package/dist/contracts/personas.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.d.ts +2 -2
- package/dist/contracts/settings.contract.d.ts +58 -20
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +80 -30
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +26 -17
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/contracts/usage.contract.d.ts +22 -0
- package/dist/contracts/usage.contract.d.ts.map +1 -1
- package/dist/contracts/usage.contract.js +19 -0
- package/dist/contracts/usage.contract.js.map +1 -1
- package/dist/definition.d.ts +20 -28
- package/dist/definition.d.ts.map +1 -1
- package/dist/documents.d.ts +0 -1
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1 -2
- package/dist/documents.js.map +1 -1
- package/dist/embed.d.ts +23 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +84 -0
- package/dist/embed.js.map +1 -0
- package/dist/events.d.ts +21 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +5 -2
- package/dist/events.js.map +1 -1
- package/dist/fast-tier.js +1 -1
- package/dist/fast-tier.js.map +1 -1
- package/dist/history-state.d.ts.map +1 -1
- package/dist/history-state.js +2 -0
- package/dist/history-state.js.map +1 -1
- package/dist/index.d.ts +453 -306
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -15
- package/dist/index.js.map +1 -1
- package/dist/model-pins.d.ts +17 -0
- package/dist/model-pins.d.ts.map +1 -0
- package/dist/{quick-model.js → model-pins.js} +18 -11
- package/dist/model-pins.js.map +1 -0
- package/dist/model-roles.d.ts +144 -0
- package/dist/model-roles.d.ts.map +1 -0
- package/dist/model-roles.js +129 -0
- package/dist/model-roles.js.map +1 -0
- package/dist/peer-dial.d.ts +33 -0
- package/dist/peer-dial.d.ts.map +1 -0
- package/dist/peer-dial.js +79 -0
- package/dist/peer-dial.js.map +1 -0
- package/dist/peer-mcp-server.d.ts +36 -0
- package/dist/peer-mcp-server.d.ts.map +1 -0
- package/dist/peer-mcp-server.js +71 -0
- package/dist/peer-mcp-server.js.map +1 -0
- package/dist/provider-specs.d.ts +38 -20
- package/dist/provider-specs.d.ts.map +1 -1
- package/dist/provider-specs.js +39 -13
- package/dist/provider-specs.js.map +1 -1
- package/dist/runtime-state.d.ts +1 -1
- package/dist/runtime-state.js +1 -1
- package/dist/runtime-state.js.map +1 -1
- package/dist/safety-policy.d.ts +12 -3
- package/dist/safety-policy.d.ts.map +1 -1
- package/dist/safety-policy.js +30 -5
- package/dist/safety-policy.js.map +1 -1
- package/dist/schemas/agent.d.ts +27 -8
- package/dist/schemas/agent.d.ts.map +1 -1
- package/dist/schemas/agent.js +10 -4
- package/dist/schemas/agent.js.map +1 -1
- package/dist/schemas/agents.d.ts +42 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +25 -4
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/automations.d.ts +11 -2
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/automations.js +1 -1
- package/dist/schemas/automations.js.map +1 -1
- package/dist/schemas/ci.d.ts +6 -0
- package/dist/schemas/ci.d.ts.map +1 -1
- package/dist/schemas/ci.js +3 -2
- package/dist/schemas/ci.js.map +1 -1
- package/dist/schemas/context.d.ts +30 -0
- package/dist/schemas/context.d.ts.map +1 -0
- package/dist/schemas/context.js +34 -0
- package/dist/schemas/context.js.map +1 -0
- package/dist/schemas/{computers.d.ts → devices.d.ts} +154 -60
- package/dist/schemas/devices.d.ts.map +1 -0
- package/dist/schemas/devices.js +157 -0
- package/dist/schemas/devices.js.map +1 -0
- package/dist/schemas/hosts.d.ts +12 -0
- package/dist/schemas/hosts.d.ts.map +1 -1
- package/dist/schemas/hosts.js +1 -0
- package/dist/schemas/hosts.js.map +1 -1
- package/dist/schemas/issues.d.ts +0 -5
- package/dist/schemas/issues.d.ts.map +1 -1
- package/dist/schemas/issues.js +0 -1
- package/dist/schemas/issues.js.map +1 -1
- package/dist/schemas/personas.d.ts +5 -3
- package/dist/schemas/personas.d.ts.map +1 -1
- package/dist/schemas/personas.js +3 -2
- package/dist/schemas/personas.js.map +1 -1
- package/dist/schemas/plan-limits.d.ts +20 -0
- package/dist/schemas/plan-limits.d.ts.map +1 -1
- package/dist/schemas/plan-limits.js +21 -0
- package/dist/schemas/plan-limits.js.map +1 -1
- package/dist/schemas/provider-oauth.d.ts +48 -16
- package/dist/schemas/provider-oauth.d.ts.map +1 -1
- package/dist/schemas/provider-oauth.js +22 -20
- package/dist/schemas/provider-oauth.js.map +1 -1
- package/dist/schemas/settings.d.ts +42 -16
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +22 -28
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/terminal.js +9 -9
- package/dist/schemas/terminal.js.map +1 -1
- package/dist/schemas/usage.d.ts +5 -2
- package/dist/schemas/usage.d.ts.map +1 -1
- package/dist/schemas/usage.js +5 -2
- package/dist/schemas/usage.js.map +1 -1
- package/dist/shell-regions.d.ts +4 -0
- package/dist/shell-regions.d.ts.map +1 -0
- package/dist/shell-regions.js +156 -0
- package/dist/shell-regions.js.map +1 -0
- package/dist/workspace-state.d.ts +8 -0
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +13 -5
- package/dist/workspace-state.js.map +1 -1
- package/package.json +37 -4
- package/src/agent-catalog.ts +2 -2
- package/src/arrival.ts +3 -3
- package/src/batch-runs.test.ts +10 -5
- package/src/batch-runs.ts +10 -3
- package/src/command-classes.test.ts +195 -71
- package/src/command-classes.ts +148 -46
- package/src/contracts/accounts.contract.ts +94 -0
- package/src/contracts/agents.contract.ts +4 -3
- package/src/contracts/exit.contract.ts +2 -2
- package/src/contracts/host.contract.ts +17 -5
- package/src/contracts/settings.contract.ts +1 -1
- package/src/contracts/system.contract.ts +43 -24
- package/src/contracts/usage.contract.ts +31 -0
- package/src/contracts/vpn.contract.ts +2 -2
- package/src/documents.test.ts +2 -1
- package/src/documents.ts +7 -11
- package/src/embed.test.ts +68 -0
- package/src/embed.ts +164 -0
- package/src/events.ts +31 -4
- package/src/fast-tier.test.ts +1 -1
- package/src/fast-tier.ts +5 -5
- package/src/history-state.ts +12 -3
- package/src/host-protocol.ts +2 -2
- package/src/index.ts +8 -16
- package/src/model-order.ts +1 -1
- package/src/{quick-model.test.ts → model-pins.test.ts} +73 -29
- package/src/model-pins.ts +183 -0
- package/src/model-roles.ts +224 -0
- package/src/peer-dial.test.ts +203 -0
- package/src/peer-dial.ts +163 -0
- package/src/peer-mcp-server.test.ts +104 -0
- package/src/peer-mcp-server.ts +144 -0
- package/src/plan-pools.ts +1 -1
- package/src/prompt-complexity.test.ts +1 -1
- package/src/prompt-complexity.ts +2 -2
- package/src/provider-specs.test.ts +45 -18
- package/src/provider-specs.ts +147 -67
- package/src/routes.test.ts +6 -3
- package/src/runner-protocol.ts +1 -1
- package/src/runtime-state.ts +2 -2
- package/src/safety-policy.test.ts +88 -0
- package/src/safety-policy.ts +84 -14
- package/src/schemas/agent.ts +83 -30
- package/src/schemas/agents.ts +67 -6
- package/src/schemas/automations.ts +6 -4
- package/src/schemas/capabilities.ts +4 -4
- package/src/schemas/ci.ts +23 -6
- package/src/schemas/context.ts +87 -0
- package/src/schemas/{computers.ts → devices.ts} +190 -107
- package/src/schemas/hosts.ts +5 -1
- package/src/schemas/issues.ts +0 -4
- package/src/schemas/personas.ts +8 -3
- package/src/schemas/plan-limits.ts +50 -0
- package/src/schemas/provider-oauth.ts +49 -52
- package/src/schemas/settings.ts +105 -140
- package/src/schemas/terminal.ts +12 -12
- package/src/schemas/usage.ts +62 -27
- package/src/schemas/version-seam.test.ts +0 -1
- package/src/shell-regions.ts +289 -0
- package/src/versions.ts +2 -2
- package/src/webext-links.ts +2 -2
- package/src/webext-protocol.ts +2 -2
- package/src/workspace-state.test.ts +48 -1
- package/src/workspace-state.ts +48 -11
- package/dist/agent-run-model.d.ts +0 -4
- package/dist/agent-run-model.d.ts.map +0 -1
- package/dist/agent-run-model.js +0 -13
- package/dist/agent-run-model.js.map +0 -1
- package/dist/contracts/claude.contract.d.ts +0 -91
- package/dist/contracts/claude.contract.d.ts.map +0 -1
- package/dist/contracts/claude.contract.js +0 -50
- package/dist/contracts/claude.contract.js.map +0 -1
- package/dist/contracts/cursor.contract.d.ts.map +0 -1
- package/dist/contracts/cursor.contract.js +0 -50
- package/dist/contracts/cursor.contract.js.map +0 -1
- package/dist/contracts/grok.contract.d.ts +0 -36
- package/dist/contracts/grok.contract.d.ts.map +0 -1
- package/dist/contracts/grok.contract.js +0 -31
- package/dist/contracts/grok.contract.js.map +0 -1
- package/dist/contracts/keys.contract.d.ts +0 -81
- package/dist/contracts/keys.contract.d.ts.map +0 -1
- package/dist/contracts/keys.contract.js +0 -51
- package/dist/contracts/keys.contract.js.map +0 -1
- package/dist/quick-model.d.ts +0 -15
- package/dist/quick-model.d.ts.map +0 -1
- package/dist/quick-model.js.map +0 -1
- package/dist/schemas/computers.d.ts.map +0 -1
- package/dist/schemas/computers.js +0 -135
- package/dist/schemas/computers.js.map +0 -1
- package/src/agent-run-model.test.ts +0 -76
- package/src/agent-run-model.ts +0 -65
- package/src/contracts/claude.contract.ts +0 -71
- package/src/contracts/cursor.contract.ts +0 -74
- package/src/contracts/grok.contract.ts +0 -41
- package/src/contracts/keys.contract.ts +0 -79
- package/src/quick-model.ts +0 -155
package/src/host-protocol.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
|
|
3
3
|
/* The handshake on /system/hosts/connect, the ONE message that is not oRPC.
|
|
4
4
|
*
|
|
5
|
-
* Everything a connected
|
|
5
|
+
* Everything a connected device is asked lives in `hostContract` (contracts/host.contract.ts), spoken over
|
|
6
6
|
* this socket by oRPC's websocket adapter: the machine hosts the server, the daemon holds the client. But a
|
|
7
7
|
* socket has to prove whose it is before it can be given a typed client, and that proof cannot itself be an
|
|
8
8
|
* oRPC call, the daemon has nothing to call yet, and would be attaching a link to a stranger.
|
|
@@ -12,7 +12,7 @@ import { z } from "zod";
|
|
|
12
12
|
* wire is oRPC. Anything arriving before the link exists is either this frame or a closed socket. */
|
|
13
13
|
|
|
14
14
|
// The MCP protocol revision the machine's tool server implements. Shared because the daemon answers the
|
|
15
|
-
// handshake ITSELF when the machine is asleep (
|
|
15
|
+
// handshake ITSELF when the machine is asleep (the daemon's peer bridge), two spellings of this would mean an
|
|
16
16
|
// offline machine negotiating a different protocol than the same machine awake.
|
|
17
17
|
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
18
18
|
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ContractRoute } from "./routes.js";
|
|
2
2
|
import { contractRoutes, requestPathFor, routeForProcedure, routeNameForRequest, routeShapes } from "./routes.js";
|
|
3
|
+
import { accountsContract } from "./contracts/accounts.contract.js";
|
|
3
4
|
import { activityContract } from "./contracts/activity.contract.js";
|
|
4
5
|
import { agentContract } from "./contracts/agent.contract.js";
|
|
5
6
|
import { agentsContract } from "./contracts/agents.contract.js";
|
|
@@ -8,14 +9,11 @@ import { automationsContract } from "./contracts/automations.contract.js";
|
|
|
8
9
|
import { capabilitiesContract } from "./contracts/capabilities.contract.js";
|
|
9
10
|
import { choresContract } from "./contracts/chores.contract.js";
|
|
10
11
|
import { ciContract } from "./contracts/ci.contract.js";
|
|
11
|
-
import { claudeContract } from "./contracts/claude.contract.js";
|
|
12
|
-
import { cursorContract } from "./contracts/cursor.contract.js";
|
|
13
12
|
import { endpointsContract } from "./contracts/endpoints.contract.js";
|
|
14
13
|
import { exitContract } from "./contracts/exit.contract.js";
|
|
15
14
|
import { extensionsContract } from "./contracts/extensions.contract.js";
|
|
16
15
|
import { personasContract } from "./contracts/personas.contract.js";
|
|
17
16
|
import { gitContract } from "./contracts/git.contract.js";
|
|
18
|
-
import { grokContract } from "./contracts/grok.contract.js";
|
|
19
17
|
import { historyContract } from "./contracts/history.contract.js";
|
|
20
18
|
import { intenticContract } from "./contracts/intentic.contract.js";
|
|
21
19
|
import { inventoryContract } from "./contracts/inventory.contract.js";
|
|
@@ -26,7 +24,6 @@ import { panelsContract } from "./contracts/panels.contract.js";
|
|
|
26
24
|
import { portsContract } from "./contracts/ports.contract.js";
|
|
27
25
|
import { publicContract } from "./contracts/public.contract.js";
|
|
28
26
|
import { prepushContract } from "./contracts/prepush.contract.js";
|
|
29
|
-
import { keysContract } from "./contracts/keys.contract.js";
|
|
30
27
|
import { providersContract } from "./contracts/providers.contract.js";
|
|
31
28
|
import { pushContract } from "./contracts/push.contract.js";
|
|
32
29
|
import { safetyContract } from "./contracts/safety.contract.js";
|
|
@@ -42,6 +39,7 @@ import { vpnContract } from "./contracts/vpn.contract.js";
|
|
|
42
39
|
import { workflowsContract } from "./contracts/workflows.contract.js";
|
|
43
40
|
import { workspaceContract } from "./contracts/workspace.contract.js";
|
|
44
41
|
|
|
42
|
+
export { accountsContract } from "./contracts/accounts.contract.js";
|
|
45
43
|
export { activityContract } from "./contracts/activity.contract.js";
|
|
46
44
|
export { agentContract } from "./contracts/agent.contract.js";
|
|
47
45
|
export { agentsContract } from "./contracts/agents.contract.js";
|
|
@@ -50,17 +48,14 @@ export { automationsContract } from "./contracts/automations.contract.js";
|
|
|
50
48
|
export { capabilitiesContract } from "./contracts/capabilities.contract.js";
|
|
51
49
|
export { choresContract } from "./contracts/chores.contract.js";
|
|
52
50
|
export { ciContract } from "./contracts/ci.contract.js";
|
|
53
|
-
export { claudeContract } from "./contracts/claude.contract.js";
|
|
54
|
-
export { cursorContract } from "./contracts/cursor.contract.js";
|
|
55
51
|
export { endpointsContract, type TrialHealth, TrialStatusSchema, type TrialStatusResponse } from "./contracts/endpoints.contract.js";
|
|
56
52
|
export { exitContract } from "./contracts/exit.contract.js";
|
|
57
53
|
export { extensionsContract } from "./contracts/extensions.contract.js";
|
|
58
54
|
export { personasContract } from "./contracts/personas.contract.js";
|
|
59
55
|
export { gitContract } from "./contracts/git.contract.js";
|
|
60
|
-
export { grokContract } from "./contracts/grok.contract.js";
|
|
61
56
|
export { historyContract } from "./contracts/history.contract.js";
|
|
62
57
|
/* Deliberately NOT part of `sandboxContract` below: that map is the daemon's own HTTP surface, and this one is
|
|
63
|
-
* spoken the other way round, over a connected
|
|
58
|
+
* spoken the other way round, over a connected device's WebSocket, with the MACHINE implementing it. */
|
|
64
59
|
export { hostContract } from "./contracts/host.contract.js";
|
|
65
60
|
// The same inversion again, one layer in: spoken over the socket a BROWSER EXTENSION opened, with the
|
|
66
61
|
// extension implementing it (contracts/webext.contract.ts).
|
|
@@ -77,7 +72,6 @@ export { panelsContract } from "./contracts/panels.contract.js";
|
|
|
77
72
|
export { portsContract } from "./contracts/ports.contract.js";
|
|
78
73
|
export { publicContract } from "./contracts/public.contract.js";
|
|
79
74
|
export { prepushContract } from "./contracts/prepush.contract.js";
|
|
80
|
-
export { keysContract } from "./contracts/keys.contract.js";
|
|
81
75
|
export { providersContract } from "./contracts/providers.contract.js";
|
|
82
76
|
export { pushContract } from "./contracts/push.contract.js";
|
|
83
77
|
export { safetyContract } from "./contracts/safety.contract.js";
|
|
@@ -119,7 +113,6 @@ export * from "./history-state.js";
|
|
|
119
113
|
export * from "./agent-runtimes.js";
|
|
120
114
|
export * from "./provider-specs.js";
|
|
121
115
|
export * from "./agent-catalog.js";
|
|
122
|
-
export * from "./agent-run-model.js";
|
|
123
116
|
export * from "./capability-env.js";
|
|
124
117
|
export * from "./command-classes.js";
|
|
125
118
|
export * from "./command-run.js";
|
|
@@ -137,10 +130,11 @@ export * from "./listener-protocol.js";
|
|
|
137
130
|
export * from "./hostnames.js";
|
|
138
131
|
export * from "./overlay-lint.js";
|
|
139
132
|
export * from "./model-order.js";
|
|
133
|
+
export * from "./model-pins.js";
|
|
134
|
+
export * from "./model-roles.js";
|
|
140
135
|
export * from "./plan-pools.js";
|
|
141
136
|
export * from "./path-refs.js";
|
|
142
137
|
export * from "./prompt-complexity.js";
|
|
143
|
-
export * from "./quick-model.js";
|
|
144
138
|
export * from "./output-fields.js";
|
|
145
139
|
export * from "./approvals-execution.js";
|
|
146
140
|
/* The wire shapes, one module per subject area, mirroring `contracts/` above: every request and response the
|
|
@@ -159,7 +153,8 @@ export * from "./schemas/capabilities.js";
|
|
|
159
153
|
export * from "./schemas/ci.js";
|
|
160
154
|
export * from "./schemas/claude-gate.js";
|
|
161
155
|
export * from "./schemas/codebase-health.js";
|
|
162
|
-
export * from "./schemas/
|
|
156
|
+
export * from "./schemas/context.js";
|
|
157
|
+
export * from "./schemas/devices.js";
|
|
163
158
|
export * from "./schemas/engines.js";
|
|
164
159
|
export * from "./schemas/environment.js";
|
|
165
160
|
export * from "./schemas/exit.js";
|
|
@@ -212,6 +207,7 @@ export * from "./workflow-faults.js";
|
|
|
212
207
|
// browser's typed oRPC client (ContractRouterClient<typeof sandboxContract>). The wire paths it declares are
|
|
213
208
|
// mounted at the sandbox root, so /health and /workspace/raw (plain Hono routes) sit alongside it.
|
|
214
209
|
export const sandboxContract = {
|
|
210
|
+
accounts: accountsContract,
|
|
215
211
|
activity: activityContract,
|
|
216
212
|
agent: agentContract,
|
|
217
213
|
agents: agentsContract,
|
|
@@ -220,8 +216,6 @@ export const sandboxContract = {
|
|
|
220
216
|
capabilities: capabilitiesContract,
|
|
221
217
|
chores: choresContract,
|
|
222
218
|
ci: ciContract,
|
|
223
|
-
claude: claudeContract,
|
|
224
|
-
cursor: cursorContract,
|
|
225
219
|
endpoints: endpointsContract,
|
|
226
220
|
extensions: extensionsContract,
|
|
227
221
|
personas: personasContract,
|
|
@@ -232,7 +226,6 @@ export const sandboxContract = {
|
|
|
232
226
|
skills: skillsContract,
|
|
233
227
|
intentic: intenticContract,
|
|
234
228
|
git: gitContract,
|
|
235
|
-
grok: grokContract,
|
|
236
229
|
history: historyContract,
|
|
237
230
|
workspace: workspaceContract,
|
|
238
231
|
inventory: inventoryContract,
|
|
@@ -243,7 +236,6 @@ export const sandboxContract = {
|
|
|
243
236
|
ports: portsContract,
|
|
244
237
|
public: publicContract,
|
|
245
238
|
prepush: prepushContract,
|
|
246
|
-
keys: keysContract,
|
|
247
239
|
providers: providersContract,
|
|
248
240
|
push: pushContract,
|
|
249
241
|
secrets: secretsContract,
|
package/src/model-order.ts
CHANGED
|
@@ -146,7 +146,7 @@ const releaseTierRankOf = (family: string): number => lastRankOf(family, RELEASE
|
|
|
146
146
|
*
|
|
147
147
|
* That is not a small mis-sort. Thinking is the difference between a commit message that is in the box before
|
|
148
148
|
* the user has finished reading the file list and one that arrives half a minute later, measured at ~2s versus
|
|
149
|
-
* ~27s on the same model and the same diff (
|
|
149
|
+
* ~27s on the same model and the same diff (claude/claude-one-shot.ts, which disables thinking for the rungs where a
|
|
150
150
|
* request parameter can). For a routed rung there is no such parameter: the id IS the setting, so this ranking
|
|
151
151
|
* is the only place the choice can be made.
|
|
152
152
|
*
|
|
@@ -1,23 +1,39 @@
|
|
|
1
1
|
import { expect, test } from "vitest";
|
|
2
|
-
import { type
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
import { type ModelSource, modelPinKey, parsePinned, resolveRoleModels } from "./model-pins.js";
|
|
3
|
+
import type { ModelPin } from "./schemas/agent.js";
|
|
4
|
+
|
|
5
|
+
/* Which models a job spends, and in which order. The rule answers two surfaces at once: the daemon walks it,
|
|
6
|
+
* the browser names its head in that job's settings row, so what these tests pin is that a sandbox's
|
|
7
|
+
* connections alone decide it, with no stored id to go stale, and that there is always a rung underneath the
|
|
8
|
+
* first one whenever the sandbox has another account to reach for.
|
|
9
|
+
*
|
|
10
|
+
* `commit-message` and `pipeline-fix` stand in for the two KINDS of role here (model-roles.ts): the first is a
|
|
11
|
+
* one-shot, whose empty list derives the Auto ladder, and the second is a whole session, whose empty list
|
|
12
|
+
* resolves to nothing so the caller's own composer pick answers. Everything else about the two is identical,
|
|
13
|
+
* which is why one resolver serves both. */
|
|
14
|
+
|
|
15
|
+
const HELPER = `commit-message` as const;
|
|
16
|
+
const RUN = `pipeline-fix` as const;
|
|
17
|
+
|
|
18
|
+
// A pin as the settings rows store one. The tests are about ORDER, so most of them name only the pair; the
|
|
19
|
+
// knobs an entry can carry ride through untouched and are asserted where that matters.
|
|
20
|
+
const pin = (key: string): ModelPin => {
|
|
21
|
+
const at = key.indexOf(`:`);
|
|
22
|
+
return { provider: key.slice(0, at), model: key.slice(at + 1) };
|
|
23
|
+
};
|
|
8
24
|
|
|
9
25
|
// Catalogs as their providers actually publish them: Claude's ranked list, the rest in registry order.
|
|
10
|
-
const CLAUDE:
|
|
11
|
-
const GOOGLE:
|
|
12
|
-
const CODEX:
|
|
13
|
-
const KIMI:
|
|
26
|
+
const CLAUDE: ModelSource = { provider: `claude`, ready: true, models: [`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001`] };
|
|
27
|
+
const GOOGLE: ModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-flash`, `gemini-3-flash-lite`, `gemini-3-pro`] };
|
|
28
|
+
const CODEX: ModelSource = { provider: `codex`, ready: true, models: [`gpt-5.4-mini`, `gpt-5.6`] };
|
|
29
|
+
const KIMI: ModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`] };
|
|
14
30
|
|
|
15
|
-
const offline = (source:
|
|
31
|
+
const offline = (source: ModelSource): ModelSource => ({ ...source, ready: false });
|
|
16
32
|
|
|
17
33
|
// The model that answers when nothing goes wrong: the head of the chain, which is what most of what follows is
|
|
18
34
|
// about and what every surface naming the spend up front reads.
|
|
19
|
-
const head = (sources: readonly
|
|
20
|
-
|
|
35
|
+
const head = (sources: readonly ModelSource[], pinned: readonly string[]): ModelPin | undefined =>
|
|
36
|
+
resolveRoleModels(sources, pinned.map(pin), HELPER)[0];
|
|
21
37
|
|
|
22
38
|
test("reaches for the efficient rung of the one connected provider, never its flagship", () => {
|
|
23
39
|
expect(head([CLAUDE], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
@@ -32,14 +48,14 @@ test("spends the FREE channel over the subscription when both offer the same run
|
|
|
32
48
|
test("puts tier ahead of cost: a free frontier model is still the wrong tool for a commit message", () => {
|
|
33
49
|
// Google connected but publishing only its Pro line. Ordering on price first would seat a flagship here,
|
|
34
50
|
// which is the exact outcome the feature exists to avoid.
|
|
35
|
-
const proOnly:
|
|
51
|
+
const proOnly: ModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-pro`] };
|
|
36
52
|
|
|
37
53
|
expect(head([CLAUDE, proOnly], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
38
54
|
});
|
|
39
55
|
|
|
40
56
|
test("uses stable provider order when two subscriptions offer the same tier", () => {
|
|
41
|
-
const kimiCheap:
|
|
42
|
-
const claudeCheap:
|
|
57
|
+
const kimiCheap: ModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2-mini`] };
|
|
58
|
+
const claudeCheap: ModelSource = { provider: `claude`, ready: true, models: [`claude-haiku-4-5`] };
|
|
43
59
|
|
|
44
60
|
expect(head([kimiCheap, claudeCheap], [])?.provider).toBe(`claude`);
|
|
45
61
|
});
|
|
@@ -48,7 +64,7 @@ test("answers the same thing however the connected providers happen to be listed
|
|
|
48
64
|
// The daemon assembles these from live stores and the browser from its own refs; neither order is a fact.
|
|
49
65
|
const answers = [head([CLAUDE, GOOGLE, CODEX], []), head([CODEX, CLAUDE, GOOGLE], []), head([GOOGLE, CODEX, CLAUDE], [])];
|
|
50
66
|
|
|
51
|
-
expect(new Set(answers.map((answer) =>
|
|
67
|
+
expect(new Set(answers.map((answer) => modelPinKey(answer!))).size).toBe(1);
|
|
52
68
|
});
|
|
53
69
|
|
|
54
70
|
test("honours a pinned model verbatim, including an id no catalog lists yet", () => {
|
|
@@ -66,10 +82,38 @@ test("falls back to Auto when the pinned provider is no longer connected", () =>
|
|
|
66
82
|
});
|
|
67
83
|
});
|
|
68
84
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
/* A MALFORMED KEY IS REFUSED WHERE KEYS STILL EXIST. A role's list holds PINS, whose two halves are separate
|
|
86
|
+
* fields the schema requires (ModelPinSchema), so "claude with an empty model" is no longer a shape the
|
|
87
|
+
* resolver can be handed — it is rejected at the settings boundary instead. What still travels as a key is
|
|
88
|
+
* `autoFastModels`, so the rule lives with the parser that reads one, and this is where it is pinned. */
|
|
89
|
+
test("refuses a malformed key rather than reading half a pin out of it", () => {
|
|
90
|
+
for (const key of [`claude`, `claude:`, `:claude-haiku-4-5`, ` `]) {
|
|
91
|
+
expect(parsePinned(key)).toBeUndefined();
|
|
72
92
|
}
|
|
93
|
+
expect(parsePinned(`claude:claude-haiku-4-5`)).toEqual({ provider: `claude`, model: `claude-haiku-4-5` });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/* THE TWO KINDS OF ROLE DIFFER ON EXACTLY ONE THING: what an empty list means. It is the whole reason the
|
|
97
|
+
* resolver takes a role rather than a boolean, and the reason the two used to be separate files. */
|
|
98
|
+
test("derives a ladder for an unpinned one-shot and nothing at all for an unpinned session", () => {
|
|
99
|
+
// A commit message exists to stay off the frontier tier, so working it out from what is connected is a good
|
|
100
|
+
// answer and gets better as accounts are added.
|
|
101
|
+
expect(resolveRoleModels([CLAUDE, GOOGLE], [], HELPER)).toEqual([
|
|
102
|
+
{ provider: `gemini`, model: `gemini-3-flash-lite` },
|
|
103
|
+
{ provider: `claude`, model: `claude-haiku-4-5-20251001` },
|
|
104
|
+
]);
|
|
105
|
+
// A pipeline fix is a whole session billed whole, and nothing here can judge what one is worth — so the
|
|
106
|
+
// caller's own floor (the owner's composer pick) answers instead of a ladder this file invented.
|
|
107
|
+
expect(resolveRoleModels([CLAUDE, GOOGLE], [], RUN)).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("carries an entry's run settings through untouched, on either kind of role", () => {
|
|
111
|
+
// The resolver picks WHICH entry; how that entry runs is the entry's own business and rides along whole,
|
|
112
|
+
// because the turn (or the one-shot) is composed from all of it.
|
|
113
|
+
const configured: ModelPin = { provider: `claude`, model: `claude-opus-5`, effort: `max`, thinking: true, harness: `claude-code` };
|
|
114
|
+
|
|
115
|
+
expect(resolveRoleModels([CLAUDE], [configured], RUN)).toEqual([configured]);
|
|
116
|
+
expect(resolveRoleModels([CLAUDE], [configured], HELPER)).toEqual([configured]);
|
|
73
117
|
});
|
|
74
118
|
|
|
75
119
|
test("serves the newest of a catalog that publishes no cheap tier at all", () => {
|
|
@@ -79,12 +123,12 @@ test("serves the newest of a catalog that publishes no cheap tier at all", () =>
|
|
|
79
123
|
|
|
80
124
|
test("reports nothing when no account is connected, so the button can say so instead of failing on click", () => {
|
|
81
125
|
expect(head([offline(CLAUDE), offline(GOOGLE)], [])).toBeUndefined();
|
|
82
|
-
expect(
|
|
126
|
+
expect(resolveRoleModels([offline(CLAUDE), offline(GOOGLE)], [], HELPER)).toEqual([]);
|
|
83
127
|
expect(head([], [`claude:claude-haiku-4-5`])).toBeUndefined();
|
|
84
128
|
});
|
|
85
129
|
|
|
86
130
|
test("skips a connected provider whose catalog has not loaded yet", () => {
|
|
87
|
-
const unloaded:
|
|
131
|
+
const unloaded: ModelSource = { provider: `grok`, ready: true, models: [] };
|
|
88
132
|
|
|
89
133
|
expect(head([unloaded, CLAUDE], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
90
134
|
expect(head([unloaded], [])).toBeUndefined();
|
|
@@ -95,7 +139,7 @@ test("skips a connected provider whose catalog has not loaded yet", () => {
|
|
|
95
139
|
* point of the list is that the click still lands on the next rung down. */
|
|
96
140
|
|
|
97
141
|
test("keeps the pinned models in the order they were written", () => {
|
|
98
|
-
expect(
|
|
142
|
+
expect(resolveRoleModels([CLAUDE, GOOGLE, CODEX], [`codex:gpt-5.6`, `gemini:gemini-3-flash`, `claude:claude-haiku-4-5-20251001`].map(pin), HELPER)).toEqual([
|
|
99
143
|
{ provider: `codex`, model: `gpt-5.6` },
|
|
100
144
|
{ provider: `gemini`, model: `gemini-3-flash` },
|
|
101
145
|
{ provider: `claude`, model: `claude-haiku-4-5-20251001` },
|
|
@@ -103,7 +147,7 @@ test("keeps the pinned models in the order they were written", () => {
|
|
|
103
147
|
});
|
|
104
148
|
|
|
105
149
|
test("drops a pin whose provider went away and keeps the rest of the order intact", () => {
|
|
106
|
-
expect(
|
|
150
|
+
expect(resolveRoleModels([CLAUDE, offline(GOOGLE), CODEX], [`codex:gpt-5.6`, `gemini:gemini-3-flash`, `claude:claude-haiku-4-5`].map(pin), HELPER)).toEqual([
|
|
107
151
|
{ provider: `codex`, model: `gpt-5.6` },
|
|
108
152
|
{ provider: `claude`, model: `claude-haiku-4-5` },
|
|
109
153
|
]);
|
|
@@ -112,18 +156,18 @@ test("drops a pin whose provider went away and keeps the rest of the order intac
|
|
|
112
156
|
test("stops at the end of a pinned list rather than reaching for an account the user left out", () => {
|
|
113
157
|
// Google and Kimi are connected and cheaper. The user wrote down one model, so one model is what this may
|
|
114
158
|
// spend: a pin exists precisely to keep a helper off the accounts it does not name.
|
|
115
|
-
expect(
|
|
159
|
+
expect(resolveRoleModels([CLAUDE, GOOGLE, KIMI], [`claude:claude-haiku-4-5`].map(pin), HELPER)).toEqual([{ provider: `claude`, model: `claude-haiku-4-5` }]);
|
|
116
160
|
});
|
|
117
161
|
|
|
118
162
|
test("names each model once, however many times the list repeats it", () => {
|
|
119
163
|
// The list is edited by hand; a duplicate would spend a second attempt proving the same account is out.
|
|
120
|
-
expect(
|
|
164
|
+
expect(resolveRoleModels([CLAUDE], [`claude:claude-haiku-4-5`, `claude:claude-haiku-4-5`].map(pin), HELPER)).toEqual([
|
|
121
165
|
{ provider: `claude`, model: `claude-haiku-4-5` },
|
|
122
166
|
]);
|
|
123
167
|
});
|
|
124
168
|
|
|
125
169
|
test("Auto is a ladder too, every connected provider's cheap rung, best first", () => {
|
|
126
|
-
expect(
|
|
170
|
+
expect(resolveRoleModels([CLAUDE, GOOGLE, KIMI], [], HELPER)).toEqual([
|
|
127
171
|
{ provider: `gemini`, model: `gemini-3-flash-lite` },
|
|
128
172
|
{ provider: `claude`, model: `claude-haiku-4-5-20251001` },
|
|
129
173
|
{ provider: `kimi`, model: `kimi-k3` },
|
|
@@ -133,14 +177,14 @@ test("Auto is a ladder too, every connected provider's cheap rung, best first",
|
|
|
133
177
|
/* A MODEL ENDPOINT the user configured is a provider like any other here, and the reason it has to be is the
|
|
134
178
|
* settings row: its options are built from the same picker catalog, so a pin naming one that this resolver
|
|
135
179
|
* dropped would print one model's name in the settings row and spend a different account entirely. */
|
|
136
|
-
const OLLAMA:
|
|
180
|
+
const OLLAMA: ModelSource = { provider: `endpoint/ollama`, ready: true, models: [`qwen3-coder`, `gemma3-27b`] };
|
|
137
181
|
|
|
138
182
|
test("honours a pin on a configured endpoint: the whole id, not the half before its slash", () => {
|
|
139
183
|
expect(head([CLAUDE, OLLAMA], [`endpoint/ollama:qwen3-coder`])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
|
|
140
184
|
// And it round-trips through the key shape the picker mints, which is where the slash-not-colon rule earns
|
|
141
185
|
// itself: parsePinned splits on the FIRST colon, so an `endpoint:ollama` id would have parsed the provider
|
|
142
186
|
// as "endpoint" and the model as "ollama:qwen3-coder": a pin that silently resolves to nothing.
|
|
143
|
-
expect(
|
|
187
|
+
expect(modelPinKey({ provider: `endpoint/ollama`, model: `qwen3-coder` })).toBe(`endpoint/ollama:qwen3-coder`);
|
|
144
188
|
});
|
|
145
189
|
|
|
146
190
|
test("leaves Auto to the providers whose price is known, rather than reaching for someone's own server", () => {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { accessFor, modelsFor, PROVIDERS } from "./agent-catalog.js";
|
|
2
|
+
import { ACCESS_COST } from "./provider-specs.js";
|
|
3
|
+
import { compareCheapestFirst, familyOf, tierRankOf } from "./model-order.js";
|
|
4
|
+
import { type ModelRole, modelRole } from "./model-roles.js";
|
|
5
|
+
import type { AgentProvider, ModelPin } from "./schemas/agent.js";
|
|
6
|
+
|
|
7
|
+
/* WHICH MODELS A ROLE MAY RUN, IN THE ORDER TO TRY THEM. One resolver over every list in
|
|
8
|
+
* settings.modelRoles, and the browser and the daemon both read it.
|
|
9
|
+
*
|
|
10
|
+
* IT IS AN ORDER, NOT A MODEL, and that is the shape of every list this file answers for. A single pick is a
|
|
11
|
+
* single point of failure: the account it names spends its allowance on the chat all morning, and the role
|
|
12
|
+
* fails on a limit for the rest of the day while three other connected providers sit idle. So a setting is a
|
|
13
|
+
* LIST read top to bottom, this hands back the whole ladder, and the caller walks it until one answers. Nothing
|
|
14
|
+
* here decides WHICH failures are worth stepping over — only the runner has made the call and seen it fail —
|
|
15
|
+
* this side says what the running order is.
|
|
16
|
+
*
|
|
17
|
+
* THE RULE LIVES IN THE CONTRACT because both sides need the same answer for different jobs: the daemon runs
|
|
18
|
+
* the model, and the browser has to NAME it, in the settings row's "Auto: …" line, before anything has run. Two
|
|
19
|
+
* implementations would drift precisely where it matters most, since a row promising Haiku while the daemon
|
|
20
|
+
* bills Opus is worse than no row at all.
|
|
21
|
+
*
|
|
22
|
+
* WHAT AN EMPTY LIST MEANS IS THE ROLE'S OWN ANSWER (model-roles.ts): a `helper` role derives the Auto ladder
|
|
23
|
+
* from whatever is connected, a `run` role resolves to nothing and lets the caller's floor answer. That fork
|
|
24
|
+
* used to be two near-identical files; it is one line here because it was always one difference. */
|
|
25
|
+
|
|
26
|
+
/* One provider's standing in the decision: whether a turn on it can be sent at all, and what its catalog holds.
|
|
27
|
+
*
|
|
28
|
+
* ACP agents are deliberately not expressible here — an ACP row's model id is empty because the agent owns its
|
|
29
|
+
* own model, so there is no rung to point it at. `endpoint/<id>` providers ARE, and have to be: their models
|
|
30
|
+
* appear in the same picker the settings rows build their options from, so a pin naming one has to hold rather
|
|
31
|
+
* than fall silently back to Auto and spend an account the user was deliberately steering away from. */
|
|
32
|
+
export interface ModelSource {
|
|
33
|
+
// AgentProvider, not NativeProvider: an endpoint's id is user-created and cannot be in a fixed union. Auto's
|
|
34
|
+
// ranking degrades gracefully for one, costOf falls to the metered rung and an id with no tier word is
|
|
35
|
+
// UNRANKED, which is genuine last place, so an endpoint effectively only wins Auto when nothing else is
|
|
36
|
+
// connected, while a PIN on one holds. Both are the right answers: what a turn on someone's own model server
|
|
37
|
+
// costs is not a fact this repo can know, so it is not one Auto should be asserting.
|
|
38
|
+
readonly provider: AgentProvider;
|
|
39
|
+
// The same connection predicate every other surface gates on (access.ts web-side, the daemon's own account
|
|
40
|
+
// stores daemon-side). A catalog is never empty by construction, so "has rows" says nothing about "can send".
|
|
41
|
+
readonly ready: boolean;
|
|
42
|
+
readonly models: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ModelChoice {
|
|
46
|
+
readonly provider: AgentProvider;
|
|
47
|
+
readonly model: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A (provider, model) pair on the wire: `${provider}:${modelId}`, the same key shape the model picker mints for
|
|
51
|
+
// its entries (PickerEntry.key). Every role list stores PINS rather than these keys — an entry says how it runs
|
|
52
|
+
// as well as which model it is — but the key is still how two entries are compared, how a role list dedupes,
|
|
53
|
+
// and how `autoFastModels` (which pins no knobs, see its own note in settings.ts) is stored.
|
|
54
|
+
export const modelPinKey = (choice: ModelChoice): string => `${choice.provider}:${choice.model}`;
|
|
55
|
+
|
|
56
|
+
/* Split on the FIRST colon only: a provider id never contains one and a model id might. Exported because
|
|
57
|
+
* `autoFastModels` stores these keys, and because a session composed from a pin travels as one
|
|
58
|
+
* (composeSession). */
|
|
59
|
+
export const parsePinned = (pinned: string): ModelChoice | undefined => {
|
|
60
|
+
const separator = pinned.indexOf(`:`);
|
|
61
|
+
if (separator <= 0 || separator === pinned.length - 1) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return { provider: pinned.slice(0, separator), model: pinned.slice(separator + 1) };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/* A pin as a person reads it: the catalog's own label for the id, or the id itself for one the static catalog
|
|
68
|
+
* has not caught up with (the picker offers a custom-id escape hatch, so this is a real case rather than a
|
|
69
|
+
* defensive branch). Beside parsePinned because the two are always wanted together, by any surface that has to
|
|
70
|
+
* name what a click is about to spend BEFORE it spends it, and the two loudest of those are extensions that
|
|
71
|
+
* share no other code with each other. */
|
|
72
|
+
export const pinnedModelLabel = (choice: ModelChoice): string =>
|
|
73
|
+
modelsFor(choice.provider).find((option) => option.value === choice.model)?.label ?? choice.model;
|
|
74
|
+
|
|
75
|
+
// The cheapest row a provider publishes, its whole catalog read from the cheap end. Undefined for a catalog
|
|
76
|
+
// that hasn't loaded yet, which is a real state: every provider serves a floor, but only once something has
|
|
77
|
+
// asked it.
|
|
78
|
+
const cheapestOf = (source: ModelSource): string | undefined => source.models.toSorted(compareCheapestFirst)[0];
|
|
79
|
+
|
|
80
|
+
// Where a provider's cheapest row sits on the shared tier scale, and therefore how well it answers the question
|
|
81
|
+
// Auto asks. UNRANKED (-1) is a genuine last place: it means the id carries no tier word we know, so the row is
|
|
82
|
+
// the provider's base line rather than its budget one.
|
|
83
|
+
const tierOf = (model: string): number => tierRankOf(familyOf(model));
|
|
84
|
+
|
|
85
|
+
// PROVIDERS order, as the final tiebreak. Arbitrary, but the SAME arbitrary answer on every read, the property
|
|
86
|
+
// compareUnrankedModelIds exists to guarantee, and the one a default actually needs. An endpoint is in no fixed
|
|
87
|
+
// list, so it reads -1 and leads the tiebreak; unreachable in practice, since it can never tie on cost.
|
|
88
|
+
const providerOrder = (provider: AgentProvider): number => PROVIDERS.findIndex((entry) => entry.value === provider);
|
|
89
|
+
|
|
90
|
+
/* WHAT AN ENDPOINT COSTS, one rung past every provider's, and the reason it is a number here rather than a
|
|
91
|
+
* member of AccessKind. That axis describes the providers this repo ships, and every one of them is unlocked by
|
|
92
|
+
* signing in to something the user already holds, so none of them is metered per call. An endpoint is the
|
|
93
|
+
* opposite: whatever gateway somebody pointed us at, whose bill this repo cannot see. Reading it as dearer than
|
|
94
|
+
* anything on the table is the conservative answer, and it is what keeps Auto from reaching for a paid gateway
|
|
95
|
+
* on its own initiative. */
|
|
96
|
+
const METERED_COST = Math.max(...Object.values(ACCESS_COST)) + 1;
|
|
97
|
+
|
|
98
|
+
// How much a call on this provider costs at the margin. Every native provider declares an access kind; an
|
|
99
|
+
// endpoint declares none, and takes the metered rung above.
|
|
100
|
+
const costOf = (provider: AgentProvider): number => {
|
|
101
|
+
const access = accessFor(provider);
|
|
102
|
+
return access === undefined ? METERED_COST : ACCESS_COST[access.kind];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/* AUTO, every connected provider's cheapest row, best-first, as a ladder rather than a winner. The floor under
|
|
106
|
+
* every `helper` role, and under nothing else.
|
|
107
|
+
*
|
|
108
|
+
* Ranked on TIER FIRST, then cost. That order is the point: a helper's Auto exists to not be the frontier
|
|
109
|
+
* model, so a free flagship is still the wrong tool, while a free Haiku-class row and a subscription
|
|
110
|
+
* Haiku-class row differ only in whose quota they spend. Cost then breaks that tie towards the channel the user
|
|
111
|
+
* is not paying per token for, and against the one they are.
|
|
112
|
+
*
|
|
113
|
+
* NOTE WHAT AUTO IS AND IS NOT AN ARGUMENT FOR. It is the answer for an owner who has said nothing, not a claim
|
|
114
|
+
* that cheap is right: an owner who pins Opus to commit messages is not being talked out of it, which is the
|
|
115
|
+
* whole reason these lists are per role. Auto is what a row says while it is empty.
|
|
116
|
+
*
|
|
117
|
+
* The whole ladder, not just its head, because the same ranking that picks the best answer also states the best
|
|
118
|
+
* SECOND answer, and a sandbox with three accounts connected should not lose its commit messages for six hours
|
|
119
|
+
* because one of them is spent. */
|
|
120
|
+
export const autoLadder = (sources: readonly ModelSource[]): readonly ModelPin[] =>
|
|
121
|
+
sources
|
|
122
|
+
.filter((source) => source.ready)
|
|
123
|
+
.flatMap((source) => {
|
|
124
|
+
const model = cheapestOf(source);
|
|
125
|
+
return model === undefined ? [] : [{ provider: source.provider, model }];
|
|
126
|
+
})
|
|
127
|
+
.toSorted(
|
|
128
|
+
(left, right) =>
|
|
129
|
+
tierOf(right.model) - tierOf(left.model) ||
|
|
130
|
+
costOf(left.provider) - costOf(right.provider) ||
|
|
131
|
+
providerOrder(left.provider) - providerOrder(right.provider),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
/* WHICH MODELS THIS ROLE MAY RUN, IN THE ORDER TO TRY THEM, given what this sandbox has connected.
|
|
135
|
+
* `pinned` is the stored setting: settings.modelRoles[role], an ordered list of pins, empty for the role's own
|
|
136
|
+
* floor.
|
|
137
|
+
*
|
|
138
|
+
* A pin only holds while its provider is READY: an account the user disconnected would otherwise sit at the
|
|
139
|
+
* head of the chain failing on a credential error, when the sandbox can plainly still answer. Dropping it is
|
|
140
|
+
* the same move the composer already makes when a live catalog stops offering the selected model. It stays on
|
|
141
|
+
* SCREEN, greyed — the settings row renders the stored list, not this one — because a setting that vanished
|
|
142
|
+
* from view would look like the app had eaten it.
|
|
143
|
+
*
|
|
144
|
+
* THE PINNED LIST IS THE WHOLE ANSWER whenever any of it survives that filter. The floor is NOT appended
|
|
145
|
+
* underneath, and that is deliberate: a user who writes down three models has said which accounts this job may
|
|
146
|
+
* spend, and quietly reaching for a fourth when all three are out is exactly the "spend an account they were
|
|
147
|
+
* steering away from" failure a pin exists to prevent. When NONE of the pins is connected any more the list has
|
|
148
|
+
* stopped saying anything about this sandbox, so the floor takes over rather than leaving a dead button.
|
|
149
|
+
*
|
|
150
|
+
* THE WHOLE PIN SURVIVES, not the pair inside it: an entry's effort, thinking, speed and harness are what the
|
|
151
|
+
* work is composed from, so a resolver handing back a bare (provider, model) would silently run the head of the
|
|
152
|
+
* list at the provider's defaults. Nothing here reads or judges those fields, which is the point of carrying
|
|
153
|
+
* them whole.
|
|
154
|
+
*
|
|
155
|
+
* Empty out means the role has nothing it can reach. For a `helper` that is a sandbox with nothing connected at
|
|
156
|
+
* all, and the caller renders a control that says so rather than a live button that fails on click; for a `run`
|
|
157
|
+
* it is the ordinary state of an unpinned role, and the caller's own floor answers. */
|
|
158
|
+
export const resolveRoleModels = (sources: readonly ModelSource[], pinned: readonly ModelPin[], role: ModelRole): readonly ModelPin[] => {
|
|
159
|
+
const ready = new Set(sources.filter((source) => source.ready).map((source) => source.provider));
|
|
160
|
+
// Taken verbatim, unvalidated against the catalog on purpose: the picker offers a custom-id escape hatch for
|
|
161
|
+
// a model a catalog hasn't caught up with, and second-guessing the user's own id here would silently run a
|
|
162
|
+
// different model than the settings row names.
|
|
163
|
+
const requested = pinned.filter((pin) => ready.has(pin.provider));
|
|
164
|
+
/* The same model twice would spend two attempts proving one account is out — a real state, since the list is
|
|
165
|
+
* hand-edited and Auto's ladder can rank a provider the user has also pinned.
|
|
166
|
+
*
|
|
167
|
+
* THE FIRST OF A PAIR WINS, WHOLE. Two entries can name one model and differ in their knobs (the same Sonnet
|
|
168
|
+
* at Max and again at Low, written while reordering the list), and the one the user reads first is the one
|
|
169
|
+
* they meant; keeping the earlier position with the later entry's effort would run a tier that appears
|
|
170
|
+
* nowhere the pin does. */
|
|
171
|
+
const chain: ModelPin[] = [];
|
|
172
|
+
for (const pin of requested) {
|
|
173
|
+
if (!chain.some((held) => modelPinKey(held) === modelPinKey(pin))) {
|
|
174
|
+
chain.push(pin);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (chain.length > 0) {
|
|
178
|
+
return chain;
|
|
179
|
+
}
|
|
180
|
+
// The floor, which the role declares. An id outside the table has no floor to fall to, and answering with
|
|
181
|
+
// the cheapest connected model for it would be this file inventing a job.
|
|
182
|
+
return modelRole(role)?.kind === `helper` ? autoLadder(sources) : [];
|
|
183
|
+
};
|