@intentic/sandbox-contract 1.239.0 → 1.242.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 +52 -16
- package/dist/agent-catalog.d.ts +5 -28
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +21 -157
- package/dist/agent-catalog.js.map +1 -1
- package/dist/agent-run-model.d.ts +3 -2
- package/dist/agent-run-model.d.ts.map +1 -1
- package/dist/agent-run-model.js +9 -6
- package/dist/agent-run-model.js.map +1 -1
- package/dist/agent-runtimes.d.ts +27 -0
- package/dist/agent-runtimes.d.ts.map +1 -0
- package/dist/agent-runtimes.js +113 -0
- package/dist/agent-runtimes.js.map +1 -0
- package/dist/approvals-execution.d.ts +11 -0
- package/dist/approvals-execution.d.ts.map +1 -0
- package/dist/approvals-execution.js +44 -0
- package/dist/approvals-execution.js.map +1 -0
- package/dist/arrival.d.ts +151 -0
- package/dist/arrival.d.ts.map +1 -0
- package/dist/arrival.js +56 -0
- package/dist/arrival.js.map +1 -0
- package/dist/card-status.d.ts +5 -0
- package/dist/card-status.d.ts.map +1 -0
- package/dist/card-status.js +74 -0
- package/dist/card-status.js.map +1 -0
- package/dist/chores/chores.d.ts.map +1 -1
- package/dist/chores/chores.js +1 -1
- package/dist/chores/chores.js.map +1 -1
- package/dist/command-classes.d.ts +0 -1
- package/dist/command-classes.d.ts.map +1 -1
- package/dist/command-classes.js +9 -2
- package/dist/command-classes.js.map +1 -1
- package/dist/command-run.d.ts +10 -0
- package/dist/command-run.d.ts.map +1 -0
- package/dist/command-run.js +46 -0
- package/dist/command-run.js.map +1 -0
- package/dist/contracts/agent.contract.d.ts +991 -771
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +483 -1
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +20 -2
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/approvals.contract.d.ts +99 -0
- package/dist/contracts/approvals.contract.d.ts.map +1 -0
- package/dist/contracts/approvals.contract.js +32 -0
- package/dist/contracts/approvals.contract.js.map +1 -0
- package/dist/contracts/ci.contract.d.ts +1 -4
- package/dist/contracts/ci.contract.d.ts.map +1 -1
- package/dist/contracts/ci.contract.js +1 -9
- package/dist/contracts/ci.contract.js.map +1 -1
- package/dist/contracts/claude.contract.d.ts +9 -0
- package/dist/contracts/claude.contract.d.ts.map +1 -1
- package/dist/contracts/cursor.contract.d.ts +6 -0
- package/dist/contracts/cursor.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.d.ts +63 -1
- package/dist/contracts/git.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.js +22 -4
- package/dist/contracts/git.contract.js.map +1 -1
- package/dist/contracts/grok.contract.d.ts +3 -0
- package/dist/contracts/grok.contract.d.ts.map +1 -1
- package/dist/contracts/keys.contract.d.ts +81 -0
- package/dist/contracts/keys.contract.d.ts.map +1 -0
- package/dist/contracts/keys.contract.js +51 -0
- package/dist/contracts/keys.contract.js.map +1 -0
- package/dist/contracts/panels.contract.d.ts +7 -0
- package/dist/contracts/panels.contract.d.ts.map +1 -1
- package/dist/contracts/prepush.contract.js +2 -2
- package/dist/contracts/providers.contract.d.ts +2 -0
- package/dist/contracts/providers.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.d.ts +99 -101
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/safety.contract.d.ts +35 -0
- package/dist/contracts/safety.contract.d.ts.map +1 -0
- package/dist/contracts/safety.contract.js +32 -0
- package/dist/contracts/safety.contract.js.map +1 -0
- package/dist/contracts/sessions.contract.d.ts +211 -1
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +30 -32
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +277 -1
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/translator.contract.d.ts +31 -3
- package/dist/contracts/translator.contract.d.ts.map +1 -1
- package/dist/contracts/usage.contract.d.ts +29 -20
- package/dist/contracts/usage.contract.d.ts.map +1 -1
- package/dist/contracts/usage.contract.js +13 -0
- package/dist/contracts/usage.contract.js.map +1 -1
- package/dist/contracts/workspace.contract.d.ts +8 -0
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/conversation-ids.d.ts +2 -0
- package/dist/conversation-ids.d.ts.map +1 -1
- package/dist/conversation-ids.js +8 -0
- package/dist/conversation-ids.js.map +1 -1
- package/dist/credential-material.d.ts +1 -0
- package/dist/credential-material.d.ts.map +1 -1
- package/dist/credential-material.js +24 -2
- package/dist/credential-material.js.map +1 -1
- package/dist/definition.d.ts +51 -93
- package/dist/definition.d.ts.map +1 -1
- package/dist/definition.js +6 -28
- package/dist/definition.js.map +1 -1
- package/dist/events.d.ts +2947 -991
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +233 -82
- package/dist/events.js.map +1 -1
- package/dist/history-state.d.ts.map +1 -1
- package/dist/history-state.js +1 -0
- package/dist/history-state.js.map +1 -1
- package/dist/index.d.ts +2532 -1054
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -5
- package/dist/index.js.map +1 -1
- package/dist/ingress-protocol.d.ts +13 -1
- package/dist/ingress-protocol.d.ts.map +1 -1
- package/dist/ingress-protocol.js +19 -0
- package/dist/ingress-protocol.js.map +1 -1
- package/dist/mentions.d.ts +3 -0
- package/dist/mentions.d.ts.map +1 -0
- package/dist/mentions.js +13 -0
- package/dist/mentions.js.map +1 -0
- package/dist/needs-action.d.ts +7 -0
- package/dist/needs-action.d.ts.map +1 -0
- package/dist/needs-action.js +3 -0
- package/dist/needs-action.js.map +1 -0
- package/dist/overlay-lint.d.ts +7 -0
- package/dist/overlay-lint.d.ts.map +1 -0
- package/dist/overlay-lint.js +68 -0
- package/dist/overlay-lint.js.map +1 -0
- package/dist/owner-ticket.d.ts +18 -0
- package/dist/owner-ticket.d.ts.map +1 -0
- package/dist/owner-ticket.js +51 -0
- package/dist/owner-ticket.js.map +1 -0
- package/dist/plan-pools.d.ts +12 -0
- package/dist/plan-pools.d.ts.map +1 -0
- package/dist/plan-pools.js +30 -0
- package/dist/plan-pools.js.map +1 -0
- package/dist/provider-specs.d.ts +228 -0
- package/dist/provider-specs.d.ts.map +1 -0
- package/dist/provider-specs.js +124 -0
- package/dist/provider-specs.js.map +1 -0
- package/dist/quick-model.d.ts.map +1 -1
- package/dist/quick-model.js +2 -1
- package/dist/quick-model.js.map +1 -1
- package/dist/runner-protocol.js +2 -2
- package/dist/runner-protocol.js.map +1 -1
- package/dist/runtime-state.d.ts +2 -2
- package/dist/runtime-state.d.ts.map +1 -1
- package/dist/runtime-state.js +1 -1
- package/dist/runtime-state.js.map +1 -1
- package/dist/safety-policy.d.ts +49 -0
- package/dist/safety-policy.d.ts.map +1 -0
- package/dist/safety-policy.js +52 -0
- package/dist/safety-policy.js.map +1 -0
- package/dist/schemas/agent.d.ts +21 -3
- package/dist/schemas/agent.d.ts.map +1 -1
- package/dist/schemas/agent.js +18 -8
- package/dist/schemas/agent.js.map +1 -1
- package/dist/schemas/agents.d.ts +19 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +15 -0
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/approvals.d.ts +241 -0
- package/dist/schemas/approvals.d.ts.map +1 -0
- package/dist/schemas/approvals.js +63 -0
- package/dist/schemas/approvals.js.map +1 -0
- package/dist/schemas/automations.d.ts +5 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/ci.d.ts +5 -9
- package/dist/schemas/ci.d.ts.map +1 -1
- package/dist/schemas/ci.js +3 -10
- package/dist/schemas/ci.js.map +1 -1
- package/dist/schemas/computers.d.ts +31 -5
- package/dist/schemas/computers.d.ts.map +1 -1
- package/dist/schemas/computers.js +7 -5
- package/dist/schemas/computers.js.map +1 -1
- package/dist/schemas/environment.d.ts +11 -131
- package/dist/schemas/environment.d.ts.map +1 -1
- package/dist/schemas/environment.js +5 -42
- package/dist/schemas/environment.js.map +1 -1
- package/dist/schemas/git-history.d.ts +4 -0
- package/dist/schemas/git-history.d.ts.map +1 -1
- package/dist/schemas/git.d.ts +179 -0
- package/dist/schemas/git.d.ts.map +1 -1
- package/dist/schemas/git.js +39 -1
- package/dist/schemas/git.js.map +1 -1
- package/dist/schemas/panels.d.ts +21 -0
- package/dist/schemas/panels.d.ts.map +1 -1
- package/dist/schemas/panels.js +5 -0
- package/dist/schemas/panels.js.map +1 -1
- package/dist/schemas/plan-limits.d.ts +50 -3
- package/dist/schemas/plan-limits.d.ts.map +1 -1
- package/dist/schemas/plan-limits.js +11 -6
- package/dist/schemas/plan-limits.js.map +1 -1
- package/dist/schemas/provider-oauth.d.ts +6 -0
- package/dist/schemas/provider-oauth.d.ts.map +1 -1
- package/dist/schemas/provider-subscriptions.d.ts +2 -1
- package/dist/schemas/provider-subscriptions.d.ts.map +1 -1
- package/dist/schemas/provider-subscriptions.js +2 -1
- package/dist/schemas/provider-subscriptions.js.map +1 -1
- package/dist/schemas/settings.d.ts +29 -16
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +13 -20
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/system.d.ts +3 -0
- package/dist/schemas/system.d.ts.map +1 -1
- package/dist/schemas/system.js +5 -1
- package/dist/schemas/system.js.map +1 -1
- package/dist/schemas/workspace-repos.d.ts +14 -0
- package/dist/schemas/workspace-repos.d.ts.map +1 -1
- package/dist/schemas/workspace-repos.js +5 -0
- package/dist/schemas/workspace-repos.js.map +1 -1
- package/dist/schemas/workspace-tree.d.ts +1 -0
- package/dist/schemas/workspace-tree.d.ts.map +1 -1
- package/dist/schemas/workspace-tree.js +3 -0
- package/dist/schemas/workspace-tree.js.map +1 -1
- package/dist/transcript-fold.d.ts +31 -0
- package/dist/transcript-fold.d.ts.map +1 -0
- package/dist/transcript-fold.js +457 -0
- package/dist/transcript-fold.js.map +1 -0
- package/dist/workspace-state.d.ts +20 -3
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +22 -2
- package/dist/workspace-state.js.map +1 -1
- package/package.json +27 -5
- package/src/agent-catalog.test.ts +1 -1
- package/src/agent-catalog.ts +76 -555
- package/src/agent-run-model.test.ts +31 -15
- package/src/agent-run-model.ts +26 -12
- package/src/agent-runtimes.ts +419 -0
- package/src/approvals-execution.ts +96 -0
- package/src/arrival.ts +160 -0
- package/src/capability-ledger.test.ts +6 -4
- package/src/card-status.ts +93 -0
- package/src/chores/chores.ts +6 -3
- package/src/command-classes.test.ts +25 -0
- package/src/command-classes.ts +47 -5
- package/src/command-run.ts +78 -0
- package/src/contract-lock.test.ts +1 -1
- package/src/contract-lock.ts +2 -2
- package/src/contracts/agents.contract.ts +35 -2
- package/src/contracts/approvals.contract.ts +37 -0
- package/src/contracts/ci.contract.ts +0 -13
- package/src/contracts/git.contract.ts +28 -3
- package/src/contracts/keys.contract.ts +79 -0
- package/src/contracts/prepush.contract.ts +3 -3
- package/src/contracts/safety.contract.ts +48 -0
- package/src/contracts/usage.contract.ts +22 -0
- package/src/conversation-ids.test.ts +31 -1
- package/src/conversation-ids.ts +37 -3
- package/src/credential-material.test.ts +69 -1
- package/src/credential-material.ts +86 -5
- package/src/definition.ts +25 -53
- package/src/events.ts +508 -198
- package/src/history-state.ts +6 -0
- package/src/index.ts +24 -5
- package/src/ingress-protocol.ts +52 -1
- package/src/mentions.test.ts +22 -0
- package/src/mentions.ts +25 -0
- package/src/needs-action.ts +14 -0
- package/src/overlay-lint.test.ts +89 -0
- package/src/overlay-lint.ts +116 -0
- package/src/owner-ticket.test.ts +54 -0
- package/src/owner-ticket.ts +102 -0
- package/src/plan-pools.test.ts +71 -0
- package/src/plan-pools.ts +82 -0
- package/src/provider-specs.test.ts +130 -0
- package/src/provider-specs.ts +352 -0
- package/src/quick-model.ts +9 -5
- package/src/runner-protocol.ts +2 -2
- package/src/runtime-state.ts +6 -6
- package/src/safety-policy.ts +165 -0
- package/src/schemas/agent.ts +62 -17
- package/src/schemas/agents.ts +71 -0
- package/src/schemas/approvals.ts +174 -0
- package/src/schemas/ci.ts +19 -26
- package/src/schemas/computers.ts +52 -11
- package/src/schemas/environment.ts +21 -100
- package/src/schemas/git.ts +121 -6
- package/src/schemas/issues.ts +1 -1
- package/src/schemas/panels.ts +20 -0
- package/src/schemas/plan-limits.ts +58 -11
- package/src/schemas/provider-subscriptions.ts +7 -2
- package/src/schemas/settings.ts +67 -93
- package/src/schemas/system.ts +12 -1
- package/src/schemas/workspace-repos.ts +9 -0
- package/src/schemas/workspace-tree.ts +11 -0
- package/src/transcript-fold.test.ts +493 -0
- package/src/transcript-fold.ts +655 -0
- package/src/workspace-state.test.ts +77 -29
- package/src/workspace-state.ts +99 -31
- package/dist/contracts/drafts.contract.d.ts +0 -57
- package/dist/contracts/drafts.contract.d.ts.map +0 -1
- package/dist/contracts/drafts.contract.js +0 -32
- package/dist/contracts/drafts.contract.js.map +0 -1
- package/dist/definition-action.d.ts +0 -7
- package/dist/definition-action.d.ts.map +0 -1
- package/dist/definition-action.js +0 -3
- package/dist/definition-action.js.map +0 -1
- package/dist/publish-drafts.d.ts +0 -7
- package/dist/publish-drafts.d.ts.map +0 -1
- package/dist/publish-drafts.js +0 -20
- package/dist/publish-drafts.js.map +0 -1
- package/dist/schemas/drafts.d.ts +0 -84
- package/dist/schemas/drafts.d.ts.map +0 -1
- package/dist/schemas/drafts.js +0 -44
- package/dist/schemas/drafts.js.map +0 -1
- package/src/contracts/drafts.contract.ts +0 -35
- package/src/definition-action.ts +0 -8
- package/src/publish-drafts.ts +0 -59
- package/src/schemas/drafts.ts +0 -108
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { bindingWindow, gatesModel, gatingWindows, scopedWindow } from "./plan-pools.js";
|
|
3
|
+
import type { AccountUsage, UsageWindow } from "./schemas/plan-limits.js";
|
|
4
|
+
|
|
5
|
+
/* ONE RULE FOR "WHICH POOL STANDS IN THE WAY OF THIS MODEL", shared by the daemon's picker and the browser's
|
|
6
|
+
* rings. What these pin is the case the six rules it replaced disagreed on: a plan that meters models
|
|
7
|
+
* separately, where the account's fullest pool and the pool a given model spends are different allowances. */
|
|
8
|
+
|
|
9
|
+
const window = (over: Partial<UsageWindow> & Pick<UsageWindow, "kind">): UsageWindow => ({ utilization: 10, gates: "all", ...over });
|
|
10
|
+
const usage = (...windows: UsageWindow[]): AccountUsage => ({ windows, measuredAt: 0 });
|
|
11
|
+
|
|
12
|
+
const GOOGLE = usage(
|
|
13
|
+
window({ kind: "google:gemini-weekly", label: "Gemini models", utilization: 100, gates: { models: ["gemini"] } }),
|
|
14
|
+
window({ kind: "google:3p-weekly", label: "Claude and GPT models", utilization: 27, gates: { models: ["claude", "gpt"] } }),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const CLAUDE = usage(
|
|
18
|
+
window({ kind: "five_hour", utilization: 12 }),
|
|
19
|
+
window({ kind: "seven_day", utilization: 30 }),
|
|
20
|
+
window({ kind: "model:Opus", label: "Opus", utilization: 100, gates: { models: ["Opus"] } }),
|
|
21
|
+
window({ kind: "surface:Cowork", label: "Cowork", utilization: 99, gates: "none" }),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
test("matches a pool's names as runs of whole words against the id and the label alike", () => {
|
|
25
|
+
expect(gatesModel({ models: ["opus"] }, { id: "claude-opus-4-6" })).toBe(true);
|
|
26
|
+
expect(gatesModel({ models: ["Opus"] }, { id: "some-id", label: "Claude Opus 4.6" })).toBe(true);
|
|
27
|
+
// "Sonnet" is not in "claude-opus-4-6", and a substring test is what would have said "son" was.
|
|
28
|
+
expect(gatesModel({ models: ["son"] }, { id: "claude-sonnet-4-6" })).toBe(false);
|
|
29
|
+
expect(gatesModel({ models: ["claude opus"] }, { id: "claude-opus-4-6" })).toBe(true);
|
|
30
|
+
expect(gatesModel({ models: ["opus claude"] }, { id: "claude-opus-4-6" })).toBe(false);
|
|
31
|
+
expect(gatesModel("all", { id: "anything" })).toBe(true);
|
|
32
|
+
expect(gatesModel("none", { id: "anything" })).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a Google account spent for Gemini still has room for Claude Opus, and the other way round", () => {
|
|
36
|
+
// The Antigravity case: one sign-in, two allowances on two clocks. Reading the account's fullest pool put a
|
|
37
|
+
// red ring over Claude Opus while its own pool sat at 27%.
|
|
38
|
+
expect(bindingWindow(GOOGLE, { id: "claude-opus-4-6-thinking" })?.kind).toBe("google:3p-weekly");
|
|
39
|
+
expect(bindingWindow(GOOGLE, { id: "gpt-oss-120b" })?.kind).toBe("google:3p-weekly");
|
|
40
|
+
expect(bindingWindow(GOOGLE, { id: "gemini-3-pro" })?.kind).toBe("google:gemini-weekly");
|
|
41
|
+
// A family the channel has not published a pool for is gated by nothing: unmeasured, never blocked.
|
|
42
|
+
expect(bindingWindow(GOOGLE, { id: "kimi-k2" })).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a Claude account's spent Opus slice does not bind a Haiku call, and its own 5-hour and weekly pools do", () => {
|
|
46
|
+
expect(gatingWindows(CLAUDE, { id: "claude-haiku-4-5" }).map((entry) => entry.kind)).toEqual(["five_hour", "seven_day"]);
|
|
47
|
+
expect(bindingWindow(CLAUDE, { id: "claude-haiku-4-5" })?.kind).toBe("seven_day");
|
|
48
|
+
expect(bindingWindow(CLAUDE, { id: "claude-opus-4-6" })?.kind).toBe("model:Opus");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("with no model named, the account's tightest pool is the answer, and a pool gating nothing is never it", () => {
|
|
52
|
+
// The Cowork surface pool is at 99% and gates none of this sandbox's turns: shown on the roster, never the
|
|
53
|
+
// account's headroom.
|
|
54
|
+
expect(bindingWindow(CLAUDE)?.kind).toBe("model:Opus");
|
|
55
|
+
expect(gatingWindows(CLAUDE).map((entry) => entry.kind)).toEqual(["five_hour", "seven_day", "model:Opus"]);
|
|
56
|
+
expect(bindingWindow(undefined)).toBeUndefined();
|
|
57
|
+
expect(bindingWindow(usage())).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("names the pool a plan meters this model by on its own, preferring the more specific and refusing a tie", () => {
|
|
61
|
+
expect(scopedWindow(CLAUDE, { id: "claude-opus-4-6" })?.kind).toBe("model:Opus");
|
|
62
|
+
// The all-models weekly is not this model's own allowance, so a plan with no slice for it says nothing.
|
|
63
|
+
expect(scopedWindow(CLAUDE, { id: "claude-haiku-4-5" })).toBeUndefined();
|
|
64
|
+
const layered = usage(
|
|
65
|
+
window({ kind: "model:Opus", gates: { models: ["Opus"] } }),
|
|
66
|
+
window({ kind: "model:Claude Opus", gates: { models: ["Claude Opus"] } }),
|
|
67
|
+
);
|
|
68
|
+
expect(scopedWindow(layered, { id: "claude-opus-4-6" })?.kind).toBe("model:Claude Opus");
|
|
69
|
+
const tied = usage(window({ kind: "model:Opus", gates: { models: ["Opus"] } }), window({ kind: "model:Claude", gates: { models: ["Claude"] } }));
|
|
70
|
+
expect(scopedWindow(tied, { id: "claude-opus-4-6" })).toBeUndefined();
|
|
71
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { AccountUsage, UsageWindow, WindowGates } from "./schemas/plan-limits.js";
|
|
2
|
+
|
|
3
|
+
/* WHICH OF AN ACCOUNT'S POOLS STAND BETWEEN A TURN AND A MODEL, read once, for both sides of the wire.
|
|
4
|
+
*
|
|
5
|
+
* Every question about headroom is really a question about ONE model: which account should serve this Haiku
|
|
6
|
+
* call, is this Google fleet spent for Claude Opus, when does the pool that refused this turn reopen, what
|
|
7
|
+
* does the ring beside the composer measure. The pools a reading carries answer that only through their
|
|
8
|
+
* `gates` (UsageWindowSchema says why the reader decides them), and this file is the one place the gate is
|
|
9
|
+
* read, so the daemon's account picker, its quick-model walk, its refusal dressing and the browser's rings,
|
|
10
|
+
* rail and picker rows all agree about which pool is binding for a given model.
|
|
11
|
+
*
|
|
12
|
+
* WITHOUT A MODEL the answer is the account's own tightest pool, which is what a roster or a rail that has not
|
|
13
|
+
* chosen a model yet is asking. Pools gated to `none` are left out of that too: a code-review limit is the
|
|
14
|
+
* account's to see, never the thing that decides whether a chat turn can run. */
|
|
15
|
+
|
|
16
|
+
export interface ModelRef {
|
|
17
|
+
// The wire id ("claude-opus-4-6", "gemini-3-pro").
|
|
18
|
+
readonly id: string;
|
|
19
|
+
// What the picker calls it ("Claude Opus 4.6"), when the caller has it. Both are matched, because which of
|
|
20
|
+
// the two carries the tier word differs by vendor.
|
|
21
|
+
readonly label?: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* TOKENS, NOT SUBSTRINGS. A pool is named by the plan ("Opus", "gemini") and the model by its vendor
|
|
25
|
+
* ("claude-opus-4-6", "Claude Opus 4.6"), and the two only ever agree on a word. A substring test would match
|
|
26
|
+
* "opus" inside an id that merely mentions it, and a normalized-string equality would match nothing at all. */
|
|
27
|
+
export const wordsOf = (text: string): readonly string[] =>
|
|
28
|
+
text
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.split(/[^a-z0-9]+/)
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
|
|
33
|
+
// Whether `needle` appears as a run of whole words in `words`: "claude opus" is in "claude-opus-4-6", "opus" is
|
|
34
|
+
// in "Claude Opus 4.6", and "sonnet" is in neither.
|
|
35
|
+
export const runOfWords = (words: readonly string[], needle: readonly string[]): boolean =>
|
|
36
|
+
needle.length > 0 && words.some((_, at) => needle.every((word, index) => words[at + index] === word));
|
|
37
|
+
|
|
38
|
+
const nameMatches = (name: string, model: ModelRef): boolean => {
|
|
39
|
+
const needle = wordsOf(name);
|
|
40
|
+
return runOfWords(wordsOf(model.id), needle) || (model.label !== undefined && runOfWords(wordsOf(model.label), needle));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const gatesModel = (gates: WindowGates, model: ModelRef): boolean =>
|
|
44
|
+
gates === "all" ? true : gates === "none" ? false : gates.models.some((name) => nameMatches(name, model));
|
|
45
|
+
|
|
46
|
+
// The pools that gate a model, or, with no model named, every pool that gates anything at all.
|
|
47
|
+
export const gatingWindows = (usage: AccountUsage | undefined, model?: ModelRef): readonly UsageWindow[] =>
|
|
48
|
+
(usage?.windows ?? []).filter((window) => (model === undefined ? window.gates !== "none" : gatesModel(window.gates, model)));
|
|
49
|
+
|
|
50
|
+
const fullest = (windows: readonly UsageWindow[]): UsageWindow | undefined =>
|
|
51
|
+
windows.reduce<UsageWindow | undefined>((worst, window) => (worst === undefined || window.utilization > worst.utilization ? window : worst), undefined);
|
|
52
|
+
|
|
53
|
+
/* THE POOL THAT WILL GATE THE NEXT TURN: the fullest of the ones this model spends. A single headroom number
|
|
54
|
+
* can only ever be this one, the account is as constrained as its tightest allowance, whichever that happens
|
|
55
|
+
* to be today. Undefined when nothing gates the model (or nothing was measured), which every reader keeps
|
|
56
|
+
* distinct from a measured 0%. */
|
|
57
|
+
export const bindingWindow = (usage: AccountUsage | undefined, model?: ModelRef): UsageWindow | undefined => fullest(gatingWindows(usage, model));
|
|
58
|
+
|
|
59
|
+
/* THE POOL A PLAN METERS THIS MODEL BY ON ITS OWN, when it does: the model-scoped window whose name is the most
|
|
60
|
+
* specific match. Distinct from bindingWindow, which answers "what stops the next turn" and may well be the
|
|
61
|
+
* all-models weekly; this answers "does the plan keep a separate allowance for this tier", which is what a
|
|
62
|
+
* sentence naming the allowance wants ("Opus 82% used").
|
|
63
|
+
*
|
|
64
|
+
* AMBIGUITY ANSWERS NOTHING. Two pools matching one model at the same specificity (a plan metering "Opus" and
|
|
65
|
+
* "Claude Opus" separately, or the same name twice) means we cannot say which allowance a turn spends, so the
|
|
66
|
+
* more specific one wins and a tie returns undefined: no sentence beats a sentence naming the wrong pool. */
|
|
67
|
+
export const scopedWindow = (usage: AccountUsage | undefined, model: ModelRef): UsageWindow | undefined => {
|
|
68
|
+
const matched = (usage?.windows ?? [])
|
|
69
|
+
.flatMap((window) => {
|
|
70
|
+
if (window.gates === "all" || window.gates === "none") {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const specificity = Math.max(0, ...window.gates.models.filter((name) => nameMatches(name, model)).map((name) => wordsOf(name).length));
|
|
74
|
+
return specificity === 0 ? [] : [{ window, specificity }];
|
|
75
|
+
})
|
|
76
|
+
.toSorted((left, right) => right.specificity - left.specificity);
|
|
77
|
+
const best = matched[0];
|
|
78
|
+
if (best === undefined || matched[1]?.specificity === best.specificity) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
return best.window;
|
|
82
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { PROVIDER_BRAND_PATHS } from "@intentic/constants";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { accessFor, capabilitiesOf, harnessChoosable, PROVIDER_ACCESS, PROVIDER_VENDOR, PROVIDERS, providerLabel } from "./agent-catalog.js";
|
|
4
|
+
import { ACCESS_COST, cliProxyIdOf, KEY_PROVIDERS, keyEndpointOf, NATIVE_PROVIDERS, PROVIDER_SPECS, providerSpec } from "./provider-specs.js";
|
|
5
|
+
import { NativeProviderParamSchema } from "./schemas/agent.js";
|
|
6
|
+
import { TranslatorAccountsSchema } from "./schemas/plan-limits.js";
|
|
7
|
+
import { KeyedProviderSchema } from "./schemas/provider-subscriptions.js";
|
|
8
|
+
|
|
9
|
+
/* THE SPEC TABLE'S OWN GUARD, and it walks the TABLE rather than a list of names, which is the property that
|
|
10
|
+
* makes it worth having: a provider added tomorrow is covered the day it is added, and one removed takes its
|
|
11
|
+
* assertions with it.
|
|
12
|
+
*
|
|
13
|
+
* What it is for. Ten surfaces used to keep their own enumeration of the same providers; six were
|
|
14
|
+
* `Record<NativeProvider, …>` and could not be short a row, and the other four were arrays and if-chains that
|
|
15
|
+
* silently could. Those four are derived now, and this is what says so: for every provider in the table, every
|
|
16
|
+
* derived list contains it, and every derived answer is one a surface can actually render. */
|
|
17
|
+
|
|
18
|
+
describe("every provider in the table", () => {
|
|
19
|
+
test.each(PROVIDER_SPECS.map((spec) => ({ id: spec.id, spec })))("$id is described completely", ({ id, spec }) => {
|
|
20
|
+
// The strings a surface prints. Empty is the failure worth naming: each of these lands somewhere a
|
|
21
|
+
// person reads, and a blank one renders as a row with no name rather than as an error anybody notices.
|
|
22
|
+
for (const [field, value] of Object.entries({
|
|
23
|
+
label: spec.label,
|
|
24
|
+
vendor: spec.vendor,
|
|
25
|
+
accountLabel: spec.accountLabel,
|
|
26
|
+
destination: spec.destination,
|
|
27
|
+
requirement: spec.access.requirement,
|
|
28
|
+
runs: spec.access.runs,
|
|
29
|
+
})) {
|
|
30
|
+
expect(value.trim(), `${id}.${field}`).not.toBe("");
|
|
31
|
+
}
|
|
32
|
+
// A brand mark, not the fallback glyph. `brand` is typed against these keys so this cannot fail without
|
|
33
|
+
// someone reaching for a cast, which is exactly when a test earns its place.
|
|
34
|
+
expect(PROVIDER_BRAND_PATHS[spec.brand], `${id} has no brand mark`).toEqual(expect.any(String));
|
|
35
|
+
expect(ACCESS_COST[spec.access.kind], `${id} has an unpriced access kind`).toBeTypeOf("number");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test.each(PROVIDER_SPECS.map((spec) => ({ id: spec.id, spec })))("$id declares a reachable credential", ({ id, spec }) => {
|
|
39
|
+
if (spec.auth.kind === "translator") {
|
|
40
|
+
// The proxy's own name for it, which is what the daemon addresses its management API by. Reading
|
|
41
|
+
// back undefined here is the `…/model-definitions/undefined` request that used to be possible.
|
|
42
|
+
expect(cliProxyIdOf(id), `${id} is routed but names no CLIProxyAPI provider`).toBe(spec.auth.cliProxy);
|
|
43
|
+
expect(KeyedProviderSchema.options, `${id} is routed but missing from KeyedProvider`).toContain(id);
|
|
44
|
+
expect(Object.keys(TranslatorAccountsSchema.shape), `${id} has no slot in TranslatorAccounts`).toContain(id);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (spec.auth.kind === "key") {
|
|
48
|
+
const endpoint = keyEndpointOf(id);
|
|
49
|
+
expect(endpoint, `${id} is keyed but names no endpoint`).toMatchObject({ kind: "key" });
|
|
50
|
+
expect(KEY_PROVIDERS, `${id} is keyed but missing from KEY_PROVIDERS`).toContain(id);
|
|
51
|
+
/* THE TURN URL AND THE CATALOG URL ARE DIFFERENT SURFACES OF ONE VENDOR, and both halves of that
|
|
52
|
+
* sentence are asserted: same host (a pair that drifted apart would send the catalog to one vendor
|
|
53
|
+
* and the turn to another), and the turn's base carries NO version segment, because the harness
|
|
54
|
+
* appends `/v1/messages` itself and a doubled one is a 404 mid-conversation. */
|
|
55
|
+
expect(new URL(endpoint!.anthropicBase).host, `${id}'s catalog and turn hosts differ`).toBe(new URL(endpoint!.catalogBase).host);
|
|
56
|
+
expect(endpoint!.anthropicBase, `${id}'s turn base carries a version segment the harness would double`).not.toMatch(/\/v\d+$/);
|
|
57
|
+
expect(new URL(endpoint!.console).protocol, `${id}'s key console is not a link`).toBe("https:");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// An oauth provider is served by a handshake this daemon runs; it is in neither routed list.
|
|
61
|
+
expect(KeyedProviderSchema.options, `${id} is not routed but appears in KeyedProvider`).not.toContain(id);
|
|
62
|
+
expect(KEY_PROVIDERS, `${id} is not keyed but appears in KEY_PROVIDERS`).not.toContain(id);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test.each(PROVIDER_SPECS.map((spec) => ({ id: spec.id })))("$id is in every derived list", ({ id }) => {
|
|
66
|
+
expect(NATIVE_PROVIDERS).toContain(id);
|
|
67
|
+
expect(
|
|
68
|
+
PROVIDERS.map((option) => option.value),
|
|
69
|
+
"the picker's list",
|
|
70
|
+
).toContain(id);
|
|
71
|
+
expect(Object.keys(PROVIDER_ACCESS), "the access table").toContain(id);
|
|
72
|
+
expect(Object.keys(PROVIDER_VENDOR), "the vendor table").toContain(id);
|
|
73
|
+
// The catalog route's param schema is closed over the same vocabulary, so a provider the daemon holds a
|
|
74
|
+
// catalog for but the schema does not name is a 400 on its own models route.
|
|
75
|
+
expect(NativeProviderParamSchema.safeParse({ provider: id }).success, "the catalog route's param").toBe(true);
|
|
76
|
+
// The two lookups every surface goes through, rather than the tables behind them.
|
|
77
|
+
expect(providerSpec(id)?.id).toBe(id);
|
|
78
|
+
expect(accessFor(id)?.requirement).toEqual(expect.any(String));
|
|
79
|
+
expect(providerLabel(id)).not.toBe(id);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/* IDS ARE THE KEY EVERYTHING ELSE IS FILED UNDER, so a duplicate is not a cosmetic problem: the derived Records
|
|
84
|
+
* would silently keep the last row, and `providerSpec` the first. */
|
|
85
|
+
test("provider ids are unique", () => {
|
|
86
|
+
const ids = PROVIDER_SPECS.map((spec) => spec.id);
|
|
87
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/* An id may contain neither a slash nor a colon, and both exclusions are load-bearing rather than tidy.
|
|
91
|
+
* `endpoint/<id>` uses the slash to namespace a capability-minted provider, and the picker's pinned selections
|
|
92
|
+
* are `${provider}:${model}` split on the FIRST colon (quick-model.ts), so an id carrying either would parse as
|
|
93
|
+
* something else entirely, silently. */
|
|
94
|
+
test("no provider id can be mistaken for an endpoint or a pinned selection", () => {
|
|
95
|
+
for (const spec of PROVIDER_SPECS) {
|
|
96
|
+
expect(spec.id, `${spec.id} would collide with the picker's key shapes`).toMatch(/^[a-z0-9-]+$/);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/* THE HARNESS AXIS IS A FACT ABOUT THE TABLE, not a list any surface keeps. A provider whose two runtimes are
|
|
101
|
+
* the same one has nothing to choose, and the chip must not be offered; one with two must offer it. Both
|
|
102
|
+
* directions matter: the version of this that lived in the web named two providers and would have offered
|
|
103
|
+
* Kimi, Meta and Z.ai a switch between two identical loops. */
|
|
104
|
+
test("the harness chip is offered exactly where the two harnesses run different loops", () => {
|
|
105
|
+
for (const spec of PROVIDER_SPECS) {
|
|
106
|
+
const native = capabilitiesOf(spec.id, "native");
|
|
107
|
+
const claudeCode = capabilitiesOf(spec.id, "claude-code");
|
|
108
|
+
expect(harnessChoosable(spec.id), `${spec.id}`).toBe(native.runtime !== claudeCode.runtime);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/* A KEYED PROVIDER HAS NO NATIVE RUNTIME, by construction rather than by coincidence: it is reached by pointing
|
|
113
|
+
* the Claude Code loop at the vendor's own Anthropic endpoint, and there is no second loop to point anywhere
|
|
114
|
+
* else. A spec row that claimed one would produce a picker chip offering a runtime nothing serves. */
|
|
115
|
+
test("a keyed provider runs the Claude Code loop on both harnesses", () => {
|
|
116
|
+
for (const provider of KEY_PROVIDERS) {
|
|
117
|
+
expect(capabilitiesOf(provider, "native").runtime, provider).toBe("claude-code");
|
|
118
|
+
expect(capabilitiesOf(provider, "claude-code").runtime, provider).toBe("claude-code");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// An id that names no provider must fall through every lookup rather than resolving to a neighbour's row, which
|
|
123
|
+
// is what makes ACP agents and endpoint capabilities safe to name with arbitrary strings.
|
|
124
|
+
test("an id that is not a provider resolves to nothing", () => {
|
|
125
|
+
for (const id of ["", "some-installed-agent", "endpoint/ollama", "META", "z.ai"]) {
|
|
126
|
+
expect(providerSpec(id), id).toBeUndefined();
|
|
127
|
+
expect(cliProxyIdOf(id), id).toBeUndefined();
|
|
128
|
+
expect(keyEndpointOf(id), id).toBeUndefined();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import type { ProviderBrand } from "@intentic/constants";
|
|
2
|
+
import { type AgentCapabilities, CLAUDE_CODE, CODEX, CURSOR, OPENCODE, OPENCODE_GEMINI } from "./agent-runtimes.js";
|
|
3
|
+
|
|
4
|
+
/* EVERY FACT ABOUT A PROVIDER THAT MORE THAN ONE SURFACE NEEDS, one row each, and the reason this file exists
|
|
5
|
+
* rather than the nine lists it replaced.
|
|
6
|
+
*
|
|
7
|
+
* The same six names used to be written out in ten places: the wire vocabulary, the picker's labels, the access
|
|
8
|
+
* table, the vendor table, the plan-limit list, the translator's provider enum and its accounts schema, the
|
|
9
|
+
* daemon's CLIProxyAPI id map and its requirement strings, and the web's tabs, account seed and readiness
|
|
10
|
+
* branches. Six of those were `Record<NativeProvider, …>` and could not silently miss a provider; the rest were
|
|
11
|
+
* arrays and if-chains, and those are the ones that DID. Cursor shipped absent from the secrets inventory for a
|
|
12
|
+
* release because an enumeration does not know what it is missing, which is the same failure the daemon's
|
|
13
|
+
* ProviderModule registry was built to end on its side of the wire. This is that fix for the other side.
|
|
14
|
+
*
|
|
15
|
+
* WHAT A ROW IS, and the two axes it deliberately keeps apart:
|
|
16
|
+
*
|
|
17
|
+
* `access` is what a turn COSTS: free, an already-paid subscription with a quota, or a metered key. It is
|
|
18
|
+
* what the picker badges, what orders the locked band, and what quick-model spends against
|
|
19
|
+
* (ACCESS_COST).
|
|
20
|
+
* `auth` is what the user CONNECTS: an OAuth account this daemon stores, a subscription the bundled
|
|
21
|
+
* translator holds, or an API key pasted into a field.
|
|
22
|
+
*
|
|
23
|
+
* They are not the same question and conflating them is how Z.ai would have been described wrongly whichever
|
|
24
|
+
* single word was picked: its cost is a prepaid coding plan, its credential is a key you paste. Keeping the
|
|
25
|
+
* axes apart is what lets a surface ask the one it actually needs.
|
|
26
|
+
*
|
|
27
|
+
* `brand` is typed against the marks in @intentic/constants, so a provider added without a logo does not
|
|
28
|
+
* compile. That is deliberate: the fallback glyph is honest for an ACP agent nobody here has heard of, and
|
|
29
|
+
* dishonest for a first-class provider whose vendor has a mark everybody recognises.
|
|
30
|
+
*
|
|
31
|
+
* ADDING A PROVIDER is a row here, its brand path, and (daemon-side) one line in the provider registry.
|
|
32
|
+
* Everything else derives — see agent-catalog.ts for the contract's derivations, and provider-specs.test.ts
|
|
33
|
+
* for the guard that walks this table rather than a list. */
|
|
34
|
+
|
|
35
|
+
// What it COSTS to unlock a provider, and what the user connects to do it, the axis the picker groups on, since
|
|
36
|
+
// "can this row actually run" is the first thing a model list has to answer. `free` is not a courtesy tier: the
|
|
37
|
+
// Google channel serves its models on an ordinary Google sign-in, at no subscription, which is the single most
|
|
38
|
+
// useful thing this catalog can tell a user who has connected nothing yet.
|
|
39
|
+
export type AccessKind = "free" | "subscription" | "key";
|
|
40
|
+
|
|
41
|
+
export interface ProviderAccess {
|
|
42
|
+
readonly kind: AccessKind;
|
|
43
|
+
// What the user connects, named the way its vendor names it, this is the noun every connect prompt uses.
|
|
44
|
+
readonly requirement: string;
|
|
45
|
+
// What connecting it lets them run, for the connect gate's one-line pitch.
|
|
46
|
+
readonly runs: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// What a turn on this provider costs at the MARGIN, ordering the same three kinds by the only question a
|
|
50
|
+
// helper spending the user's money on their behalf has to answer: free is free; a subscription is already paid
|
|
51
|
+
// but has a quota the user watches; a key is metered, so every call is real money. Deliberately not folded into
|
|
52
|
+
// AccessKind's declaration order, a union's order is not a runtime fact, and this one is relied on.
|
|
53
|
+
export const ACCESS_COST: Record<AccessKind, number> = { free: 0, subscription: 1, key: 2 };
|
|
54
|
+
|
|
55
|
+
/* HOW A CREDENTIAL FOR THIS PROVIDER IS OBTAINED AND HELD. Three mechanisms, and every surface that used to
|
|
56
|
+
* branch on a provider's NAME (the web's readiness rules, the connect panel's shape, the daemon's credential
|
|
57
|
+
* resolution) branches on this instead, so a fourth provider of an existing mechanism needs no new branch
|
|
58
|
+
* anywhere.
|
|
59
|
+
*
|
|
60
|
+
* "oauth" , the daemon runs the sign-in itself and stores the tokens (one file per account under
|
|
61
|
+
* .intentic/secrets/auth/<provider>/). Claude's PKCE paste-back and Cursor's poll-to-completion
|
|
62
|
+
* are both this: what makes them one mechanism is who ends up holding the credential.
|
|
63
|
+
* "translator" , the bundled CLIProxyAPI holds a SUBSCRIPTION OAuth and re-serves it behind an Anthropic
|
|
64
|
+
* endpoint, so the Claude Code loop can run a non-Claude model on it. `cliProxy` is that
|
|
65
|
+
* provider's id in the proxy's own vocabulary, which is not always ours.
|
|
66
|
+
* "key" , the user pastes an API key and the harness is pointed straight at the provider's own
|
|
67
|
+
* Anthropic Messages endpoint with it. No translator hop, because there is nothing to
|
|
68
|
+
* translate — the same reasoning an `anthropic`-protocol endpoint capability already rides.
|
|
69
|
+
*/
|
|
70
|
+
export type ProviderAuth =
|
|
71
|
+
| { readonly kind: "oauth" }
|
|
72
|
+
| { readonly kind: "translator"; readonly cliProxy: string }
|
|
73
|
+
| {
|
|
74
|
+
readonly kind: "key";
|
|
75
|
+
// What ANTHROPIC_BASE_URL is set to for a turn. WITHOUT a version segment: the harness appends
|
|
76
|
+
// `/v1/messages` itself (see the daemon's endpoint-config.ts for why the two ecosystems disagree here).
|
|
77
|
+
readonly anthropicBase: string;
|
|
78
|
+
// Where the model catalog is read from, an OpenAI-compatible root WITH its version segment, because
|
|
79
|
+
// that is the surface both of these vendors publish `GET …/models` on.
|
|
80
|
+
readonly catalogBase: string;
|
|
81
|
+
// Where a person goes to mint the key. Printed as a link in the connect panel, because "paste your API
|
|
82
|
+
// key" is only actionable if you know which of a vendor's several consoles issues it.
|
|
83
|
+
readonly console: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export interface ProviderSpec {
|
|
87
|
+
// The wire id, and the reserved capability id: an installed `agent` capability may not take one of these.
|
|
88
|
+
readonly id: string;
|
|
89
|
+
// What the picker, the rail and the account tabs call it.
|
|
90
|
+
readonly label: string;
|
|
91
|
+
/* WHOSE ALLOWANCE A TURN ON THIS PROVIDER SPENDS, as the subject of a sentence, and not a duplicate of
|
|
92
|
+
* `label` or of `access.requirement`. `label` names the RUNTIME the user picks ("Claude Code", "Kimi Code")
|
|
93
|
+
* and `requirement` names the thing they CONNECT ("Claude subscription", "Google sign-in"); neither reads as
|
|
94
|
+
* English in "… usage limit reached", and neither is what a spent quota belongs to.
|
|
95
|
+
*
|
|
96
|
+
* The routed providers are why this can't be inferred from the harness: a `gemini` turn drives Claude Opus
|
|
97
|
+
* through Google's Antigravity channel on a plain Google sign-in, so the quota that refuses it is Google's
|
|
98
|
+
* and Anthropic has no part in it. Saying "Claude usage limit reached" there sends the user to check the
|
|
99
|
+
* wrong account, and to a reset that is days out on a pool they never touched. */
|
|
100
|
+
readonly vendor: string;
|
|
101
|
+
/* WHAT THE ACCOUNT ROWS ARE FILED UNDER: the tab in Sandbox ▸ Agent, the account picker's section, the
|
|
102
|
+
* connect gate's chip. "Whose account is this", which is a fourth question and not the three above.
|
|
103
|
+
*
|
|
104
|
+
* It matches `vendor` for every provider but Grok, and that one exception is why it is a field rather than
|
|
105
|
+
* an alias. A quota sentence has to say "xAI usage limit reached", because xAI is who metered it; the tab
|
|
106
|
+
* a person clicks to connect the thing says "Grok", because that is what they came here to run and the
|
|
107
|
+
* word "xAI" appears nowhere else in the chat. Folding the two would have made one of those wrong, and the
|
|
108
|
+
* one that would have been wrong is whichever field the surface happened to reach for. */
|
|
109
|
+
readonly accountLabel: string;
|
|
110
|
+
// Where the sign-in actually happens, the destination, not the provider's product name: a user about to
|
|
111
|
+
// leave the page wants to recognize the site they land on.
|
|
112
|
+
readonly destination: string;
|
|
113
|
+
readonly brand: ProviderBrand;
|
|
114
|
+
readonly access: ProviderAccess;
|
|
115
|
+
readonly auth: ProviderAuth;
|
|
116
|
+
/* Whether a plan-limit reading for this provider is OBTAINABLE at all, one fact, on the wire, because both
|
|
117
|
+
* halves need it and they need the same answer. The daemon reads it to decide what to even ask upstream for
|
|
118
|
+
* (usage/translator-usage.ts); the browser reads it to say WHY an account shows no meter, which is the
|
|
119
|
+
* difference between "this plan publishes nothing" and "we haven't measured yet", two states that look
|
|
120
|
+
* identical as a blank row and mean opposite things.
|
|
121
|
+
*
|
|
122
|
+
* Four can be read, by two mechanisms that stop at the daemon's readers: Claude's rides its own turn (the
|
|
123
|
+
* OAuth usage endpoint, agent.ts), ChatGPT's, Google's and Kimi's are pulled through the translator's
|
|
124
|
+
* credential-scoped api-call. Kimi's endpoint is the platform's own `/coding/v1/usages`, which the Kimi Code
|
|
125
|
+
* subscription's OAuth token reads directly, the bundled translator does not route it, but it does not have
|
|
126
|
+
* to: the api-call substitutes that token server-side like it does for the other two.
|
|
127
|
+
*
|
|
128
|
+
* Grok is one absence, because xAI's usable billing data needs a subject id CLIProxyAPI keeps out of its
|
|
129
|
+
* auth-file listing, and the fallback probe spends a token to answer. The keyed providers are the other:
|
|
130
|
+
* neither publishes a quota surface a stored key can read. Adding one is a reader and this flag, and
|
|
131
|
+
* nothing else. */
|
|
132
|
+
readonly planLimits: boolean;
|
|
133
|
+
/* THE TWO RUNTIMES THIS PROVIDER RUNS ON, one per value of the harness axis. Equal records mean the harness
|
|
134
|
+
* is not a choice for this provider, and every surface reads that from here rather than keeping its own
|
|
135
|
+
* list of the providers that offer the switch (see harnessChoosable, web-side).
|
|
136
|
+
*
|
|
137
|
+
* Three providers have a genuine fork (codex, grok, and Claude only trivially); three do not, for three
|
|
138
|
+
* different and stated reasons — Kimi has no native runtime, Google refuses Claude Code's traffic outright,
|
|
139
|
+
* and Cursor has no route but its own SDK. See the records themselves in agent-runtimes.ts. */
|
|
140
|
+
readonly runtimes: { readonly native: AgentCapabilities; readonly claudeCode: AgentCapabilities };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* `as const satisfies` rather than a plain annotation, and the two halves buy different things. `satisfies`
|
|
144
|
+
* type-checks every row against the shape above, so a missing field or a misspelled access kind is a compile
|
|
145
|
+
* error here rather than an `undefined` two packages away. `as const` keeps the ids and the auth kinds LITERAL,
|
|
146
|
+
* which is what lets `NativeProvider` stay a union of six names instead of collapsing to `string`, and
|
|
147
|
+
* `Record<NativeProvider, …>` therefore stay the compiler-enforced tables the daemon and the web depend on. */
|
|
148
|
+
export const PROVIDER_SPECS = [
|
|
149
|
+
{
|
|
150
|
+
id: "claude",
|
|
151
|
+
label: "Claude Code",
|
|
152
|
+
vendor: "Claude",
|
|
153
|
+
accountLabel: "Claude",
|
|
154
|
+
destination: "Anthropic",
|
|
155
|
+
brand: "claude",
|
|
156
|
+
access: { kind: "subscription", requirement: "Claude subscription", runs: "Claude Code" },
|
|
157
|
+
auth: { kind: "oauth" },
|
|
158
|
+
planLimits: true,
|
|
159
|
+
// Claude is always its own Claude Code loop: there is no second runtime to switch to, and the harness
|
|
160
|
+
// axis is therefore not a choice here either.
|
|
161
|
+
runtimes: { native: CLAUDE_CODE, claudeCode: CLAUDE_CODE },
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "codex",
|
|
165
|
+
label: "Codex",
|
|
166
|
+
vendor: "ChatGPT",
|
|
167
|
+
accountLabel: "ChatGPT",
|
|
168
|
+
destination: "ChatGPT",
|
|
169
|
+
brand: "codex",
|
|
170
|
+
access: { kind: "subscription", requirement: "ChatGPT subscription", runs: "Codex" },
|
|
171
|
+
auth: { kind: "translator", cliProxy: "codex" },
|
|
172
|
+
planLimits: true,
|
|
173
|
+
runtimes: { native: CODEX, claudeCode: CLAUDE_CODE },
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "grok",
|
|
177
|
+
label: "Grok",
|
|
178
|
+
vendor: "xAI",
|
|
179
|
+
accountLabel: "Grok",
|
|
180
|
+
destination: "x.ai",
|
|
181
|
+
brand: "grok",
|
|
182
|
+
access: { kind: "subscription", requirement: "SuperGrok subscription", runs: "Grok" },
|
|
183
|
+
// The app says "grok" where CLIProxyAPI says "xai". Grok is also the one provider served BOTH ways: its
|
|
184
|
+
// own xAI account runs its native loop, and the translator's subscription runs it under Claude Code.
|
|
185
|
+
auth: { kind: "translator", cliProxy: "xai" },
|
|
186
|
+
planLimits: false,
|
|
187
|
+
runtimes: { native: OPENCODE, claudeCode: CLAUDE_CODE },
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: "kimi",
|
|
191
|
+
label: "Kimi Code",
|
|
192
|
+
vendor: "Kimi Code",
|
|
193
|
+
accountLabel: "Kimi Code",
|
|
194
|
+
destination: "Kimi Code",
|
|
195
|
+
brand: "kimi",
|
|
196
|
+
access: { kind: "subscription", requirement: "Kimi Code subscription", runs: "Kimi Code" },
|
|
197
|
+
auth: { kind: "translator", cliProxy: "kimi" },
|
|
198
|
+
planLimits: true,
|
|
199
|
+
// Kimi has no native runtime: it only exists under the Claude Code loop, so both harnesses answer it.
|
|
200
|
+
runtimes: { native: CLAUDE_CODE, claudeCode: CLAUDE_CODE },
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
// Labelled for the ACCOUNT, not the model family: the `gemini` id names one channel. Google's
|
|
204
|
+
// Antigravity, and that channel vends Claude and GPT-OSS models alongside Gemini's own (see
|
|
205
|
+
// gemini-models.ts). A section headed "Gemini" holding Claude Opus would be a lie; "Google" is what the
|
|
206
|
+
// whole list has in common.
|
|
207
|
+
id: "gemini",
|
|
208
|
+
label: "Google",
|
|
209
|
+
vendor: "Google",
|
|
210
|
+
accountLabel: "Google",
|
|
211
|
+
destination: "Google",
|
|
212
|
+
brand: "gemini",
|
|
213
|
+
access: { kind: "free", requirement: "Google sign-in", runs: "Gemini, Claude and GPT-OSS under Claude Code" },
|
|
214
|
+
// "antigravity" is Google's own agent product, and the name CLIProxyAPI files this credential under.
|
|
215
|
+
auth: { kind: "translator", cliProxy: "antigravity" },
|
|
216
|
+
planLimits: true,
|
|
217
|
+
/* GEMINI IGNORES THE HARNESS, and it is the only routed provider that does. The Claude Code loop
|
|
218
|
+
* announces itself in every request it sends and Google refuses on that announcement (see
|
|
219
|
+
* OPENCODE_GEMINI), so "Gemini under Claude Code" was never a slower or poorer option, it was one that
|
|
220
|
+
* could not complete a single turn, on any of the connected accounts, ever. Naming the same record on
|
|
221
|
+
* both harnesses is what makes that structural rather than a rule each surface has to remember. */
|
|
222
|
+
runtimes: { native: OPENCODE_GEMINI, claudeCode: OPENCODE_GEMINI },
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
// Cursor's own agent runtime, driven through the SDK Anysphere publishes, on the user's Cursor
|
|
226
|
+
// subscription. Like Google above, the label names the ACCOUNT rather than a model family: the channel
|
|
227
|
+
// vends Anthropic, OpenAI and xAI models alongside Cursor's own Composer, and no model name covers that
|
|
228
|
+
// list. The plan that gets billed is Cursor's, whichever vendor's model actually answered.
|
|
229
|
+
id: "cursor",
|
|
230
|
+
label: "Cursor",
|
|
231
|
+
vendor: "Cursor",
|
|
232
|
+
accountLabel: "Cursor",
|
|
233
|
+
destination: "Cursor",
|
|
234
|
+
brand: "cursor",
|
|
235
|
+
// A `subscription` like the others, and the requirement names the PLAN rather than the account, because
|
|
236
|
+
// a free Cursor account signs in perfectly and still cannot run a turn here: the SDK behind this
|
|
237
|
+
// provider is gated to the paid tiers. Saying "Cursor account" would send someone to a sign-in that ends
|
|
238
|
+
// in a refusal they had no way to predict.
|
|
239
|
+
access: { kind: "subscription", requirement: "Cursor Pro subscription", runs: "Cursor Agent" },
|
|
240
|
+
auth: { kind: "oauth" },
|
|
241
|
+
planLimits: false,
|
|
242
|
+
// Cursor ignores the harness for the mirror of Gemini's reason: there is no route to it but its own SDK.
|
|
243
|
+
runtimes: { native: CURSOR, claudeCode: CURSOR },
|
|
244
|
+
},
|
|
245
|
+
/* THE TWO KEYED PROVIDERS, and the reason they cost no new runtime, no new adapter and no translator hop:
|
|
246
|
+
* both publish an ANTHROPIC MESSAGES endpoint of their own. The Claude Code loop is pointed straight at it
|
|
247
|
+
* with the user's key, which is exactly the road an `anthropic`-protocol endpoint capability already
|
|
248
|
+
* drives. So they are the Kimi shape — one record on both harnesses, no adapter, a catalog and a readiness
|
|
249
|
+
* rung — and everything that makes them feel first-class (the brand, the badge, the section, the account
|
|
250
|
+
* row) is these rows and nothing else. */
|
|
251
|
+
{
|
|
252
|
+
id: "meta",
|
|
253
|
+
label: "Meta",
|
|
254
|
+
vendor: "Meta",
|
|
255
|
+
accountLabel: "Meta",
|
|
256
|
+
destination: "Meta",
|
|
257
|
+
brand: "meta",
|
|
258
|
+
// `key`, because the Model API is METERED: every call is real money, which is what ACCESS_COST's third
|
|
259
|
+
// rung means and what keeps an automatic helper from reaching for it to write a commit message.
|
|
260
|
+
access: { kind: "key", requirement: "Meta Model API key", runs: "Muse Spark under Claude Code" },
|
|
261
|
+
auth: {
|
|
262
|
+
kind: "key",
|
|
263
|
+
// No version segment: the harness appends `/v1/messages` itself, and Meta serves the Anthropic
|
|
264
|
+
// surface there.
|
|
265
|
+
anthropicBase: "https://api.meta.ai",
|
|
266
|
+
catalogBase: "https://api.meta.ai/v1",
|
|
267
|
+
console: "https://dev.meta.ai/docs/getting-started/authentication",
|
|
268
|
+
},
|
|
269
|
+
// Nothing published that a stored key can read: no quota surface, so an account row shows no meter and
|
|
270
|
+
// says so, rather than showing an empty one that reads as "nothing left".
|
|
271
|
+
planLimits: false,
|
|
272
|
+
runtimes: { native: CLAUDE_CODE, claudeCode: CLAUDE_CODE },
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: "zai",
|
|
276
|
+
label: "Z.ai",
|
|
277
|
+
vendor: "Z.ai",
|
|
278
|
+
accountLabel: "Z.ai",
|
|
279
|
+
destination: "Z.ai",
|
|
280
|
+
brand: "zai",
|
|
281
|
+
/* `subscription` while the credential is a KEY, which is precisely the pair the two axes exist to keep
|
|
282
|
+
* apart. What is being spent is a GLM Coding Plan: prepaid, with a quota the user watches, which is
|
|
283
|
+
* what `subscription` means to ACCESS_COST and to the picker's ordering. What is being CONNECTED is an
|
|
284
|
+
* API key pasted into a field, which is what `auth` says. Collapsing the two into one word would have
|
|
285
|
+
* been wrong whichever word won. */
|
|
286
|
+
access: { kind: "subscription", requirement: "Z.ai GLM Coding Plan", runs: "GLM under Claude Code" },
|
|
287
|
+
auth: {
|
|
288
|
+
kind: "key",
|
|
289
|
+
anthropicBase: "https://api.z.ai/api/anthropic",
|
|
290
|
+
/* THE CODING-PLAN ROOT, not the general one, and they are not interchangeable: a Coding Plan key
|
|
291
|
+
* reads its models from `/api/coding/paas/v4` and the general `/api/paas/v4` is a different
|
|
292
|
+
* entitlement. Pointing the catalog at the general root would list models the plan's own Anthropic
|
|
293
|
+
* endpoint then refuses, which is the worst shape a picker row can have. */
|
|
294
|
+
catalogBase: "https://api.z.ai/api/coding/paas/v4",
|
|
295
|
+
console: "https://z.ai/manage-apikey/apikey-list",
|
|
296
|
+
},
|
|
297
|
+
planLimits: false,
|
|
298
|
+
runtimes: { native: CLAUDE_CODE, claudeCode: CLAUDE_CODE },
|
|
299
|
+
},
|
|
300
|
+
] as const satisfies readonly ProviderSpec[];
|
|
301
|
+
|
|
302
|
+
type Spec = (typeof PROVIDER_SPECS)[number];
|
|
303
|
+
|
|
304
|
+
/* The agent runtimes the daemon can serve, the vocabulary every surface that picks an agent shares (chat turns,
|
|
305
|
+
* automations). The NATIVE providers have dedicated modules (and their ids are reserved); an `endpoint/<id>`
|
|
306
|
+
* value names an installed `endpoint`-kind capability, and any other value is an installed `agent`-kind
|
|
307
|
+
* capability served over ACP.
|
|
308
|
+
*
|
|
309
|
+
* DERIVED from the table above, so the wire vocabulary and the product facts cannot disagree: a spec row is a
|
|
310
|
+
* provider the contract knows, and there is no way to add one without the other. The type stays a UNION of the
|
|
311
|
+
* six names rather than widening to `string`, which is what keeps every `Record<NativeProvider, …>` in the
|
|
312
|
+
* daemon and the web a table the compiler completes for you. */
|
|
313
|
+
export type NativeProvider = Spec["id"];
|
|
314
|
+
export const NATIVE_PROVIDERS: readonly NativeProvider[] = PROVIDER_SPECS.map((spec) => spec.id);
|
|
315
|
+
|
|
316
|
+
const BY_ID = new Map<string, ProviderSpec>(PROVIDER_SPECS.map((spec) => [spec.id, spec] as const));
|
|
317
|
+
|
|
318
|
+
// The row for a provider id, or nothing when the id names an ACP agent, an endpoint or a typo. The one lookup
|
|
319
|
+
// every derived table and every surface goes through.
|
|
320
|
+
export const providerSpec = (provider: string): ProviderSpec | undefined => BY_ID.get(provider);
|
|
321
|
+
|
|
322
|
+
/* The providers whose model runs UNDER the Claude Code harness through the bundled translator (CLIProxyAPI),
|
|
323
|
+
* which holds their SUBSCRIPTION OAuth and re-serves it behind an Anthropic endpoint. `claude` is absent,
|
|
324
|
+
* native Anthropic OAuth serves it directly, without the translator.
|
|
325
|
+
*
|
|
326
|
+
* Narrowed off the auth kind rather than listed again, so this and the `Record<KeyedProvider, …>` tables built
|
|
327
|
+
* on it (the translator's accounts schema, its CLIProxyAPI id map) move together with the table. */
|
|
328
|
+
export type TranslatorProvider = Extract<Spec, { auth: { kind: "translator" } }>["id"];
|
|
329
|
+
export const TRANSLATOR_PROVIDERS: readonly TranslatorProvider[] = PROVIDER_SPECS.filter(
|
|
330
|
+
(spec): spec is Extract<Spec, { auth: { kind: "translator" } }> => spec.auth.kind === "translator",
|
|
331
|
+
).map((spec) => spec.id);
|
|
332
|
+
|
|
333
|
+
// The providers a pasted API key connects, served straight off the vendor's own Anthropic Messages endpoint.
|
|
334
|
+
export type KeyProvider = Extract<Spec, { auth: { kind: "key" } }>["id"];
|
|
335
|
+
export const KEY_PROVIDERS: readonly KeyProvider[] = PROVIDER_SPECS.filter(
|
|
336
|
+
(spec): spec is Extract<Spec, { auth: { kind: "key" } }> => spec.auth.kind === "key",
|
|
337
|
+
).map((spec) => spec.id);
|
|
338
|
+
|
|
339
|
+
// This provider's CLIProxyAPI id, where it has one. Not always ours: the app says "grok" where the proxy says
|
|
340
|
+
// "xai", and "gemini" where it says "antigravity".
|
|
341
|
+
export const cliProxyIdOf = (provider: string): string | undefined => {
|
|
342
|
+
const auth = providerSpec(provider)?.auth;
|
|
343
|
+
return auth?.kind === "translator" ? auth.cliProxy : undefined;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// The endpoint facts a keyed provider's turn and catalog are built from, or nothing when the provider is not
|
|
347
|
+
// one. Returned whole rather than field by field, because a base URL read without its sibling is how a catalog
|
|
348
|
+
// and a turn end up pointed at two different hosts.
|
|
349
|
+
export const keyEndpointOf = (provider: string): Extract<ProviderAuth, { kind: "key" }> | undefined => {
|
|
350
|
+
const auth = providerSpec(provider)?.auth;
|
|
351
|
+
return auth?.kind === "key" ? auth : undefined;
|
|
352
|
+
};
|