@intentic/sandbox-contract 1.245.0 → 1.246.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -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/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 +2 -51
- 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/definition.d.ts +4 -8
- 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/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 +354 -335
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -13
- package/dist/index.js.map +1 -1
- 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/quick-model.d.ts.map +1 -1
- package/dist/quick-model.js +2 -1
- package/dist/quick-model.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 +6 -6
- package/dist/schemas/agent.d.ts.map +1 -1
- package/dist/schemas/agent.js +6 -2
- 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/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 +1 -52
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +8 -11
- 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 +0 -2
- package/dist/schemas/usage.d.ts.map +1 -1
- package/dist/schemas/usage.js +0 -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 +1 -1
- package/src/arrival.ts +3 -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/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 +30 -3
- package/src/history-state.ts +12 -3
- package/src/host-protocol.ts +2 -2
- package/src/index.ts +6 -14
- package/src/model-order.ts +1 -1
- 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/provider-specs.test.ts +44 -17
- package/src/provider-specs.ts +147 -67
- package/src/quick-model.ts +10 -3
- 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 +41 -13
- package/src/schemas/agents.ts +65 -4
- 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/provider-oauth.ts +49 -52
- package/src/schemas/settings.ts +30 -54
- package/src/schemas/terminal.ts +12 -12
- package/src/schemas/usage.ts +3 -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/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/schemas/computers.d.ts.map +0 -1
- package/dist/schemas/computers.js +0 -135
- package/dist/schemas/computers.js.map +0 -1
- 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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { MCP_PROTOCOL_VERSION } from "./host-protocol.js";
|
|
3
|
+
|
|
4
|
+
/* THE MCP SERVER A PEER RUNS, on the peer, not in the sandbox.
|
|
5
|
+
*
|
|
6
|
+
* The sandbox's daemon forwards JSON-RPC verbatim and interprets none of it, so the tool table handed to this is
|
|
7
|
+
* the entire tool surface: what a device or a browser can do is decided by the build installed on it, and a
|
|
8
|
+
* peer that upgrades learns new tools without anything changing in the sandbox. That is the reason for the
|
|
9
|
+
* split; the alternative (schemas in the daemon, execution on the peer) makes every new tool a coordinated
|
|
10
|
+
* release of two products.
|
|
11
|
+
*
|
|
12
|
+
* The protocol implemented is the subset a Streamable HTTP client actually uses against a stateless server:
|
|
13
|
+
* initialize, tools/list, tools/call, ping, and notifications (which get no reply). Anything else answers
|
|
14
|
+
* "method not found", which is the correct JSON-RPC response and not an error worth logging.
|
|
15
|
+
*
|
|
16
|
+
* A FAILED TOOL IS NOT A FAILED CALL. Every error, a refused scope, a missing file, a command that exited 1, an
|
|
17
|
+
* argument that does not typecheck, comes back as a normal result with isError, because that is what a model can
|
|
18
|
+
* read and act on; a JSON-RPC error surfaces as a transport fault and invites a retry loop against a peer that
|
|
19
|
+
* will refuse it exactly the same way the second time.
|
|
20
|
+
*
|
|
21
|
+
* EACH TOOL'S ARGUMENTS ARE DESCRIBED ONCE. The zod schema a tool is built with is what the model is shown
|
|
22
|
+
* (`tools/list` publishes it as JSON Schema) AND what an arriving call is checked against, so the advertised
|
|
23
|
+
* shape and the accepted one cannot drift, the failure mode of writing both by hand, where a renamed field
|
|
24
|
+
* keeps validating and the model keeps being told about the old name. A handler receives its arguments typed. */
|
|
25
|
+
|
|
26
|
+
export interface McpTool<Ctx> {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly description: string;
|
|
29
|
+
// JSON Schema for `tools/list`, derived from the zod schema once at module load rather than per request.
|
|
30
|
+
readonly inputSchema: Record<string, unknown>;
|
|
31
|
+
readonly call: (args: unknown, ctx: Ctx) => Promise<Record<string, unknown>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const textResult = (text: string, isError = false): Record<string, unknown> => ({ content: [{ type: "text", text }], isError });
|
|
35
|
+
|
|
36
|
+
/* One tool, from the only description of its arguments there is. The generic is what carries the schema's type
|
|
37
|
+
* through to the handler's parameter; `McpTool` erases it again, because the dispatch table holds them all and
|
|
38
|
+
* the parse is what re-establishes the type at the boundary. `Ctx` is what the peer hands every call beside
|
|
39
|
+
* its arguments: a device's live grant, nothing for a browser.
|
|
40
|
+
*
|
|
41
|
+
* `$schema` is dropped: the enclosing tool entry already says what this document is, and MCP clients read the
|
|
42
|
+
* keywords rather than the dialect declaration. */
|
|
43
|
+
export const tool = <Schema extends z.ZodType, Ctx>(spec: {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly description: string;
|
|
46
|
+
readonly input: Schema;
|
|
47
|
+
readonly run: (args: z.output<Schema>, ctx: Ctx) => Promise<Record<string, unknown>>;
|
|
48
|
+
}): McpTool<Ctx> => {
|
|
49
|
+
const { $schema: _dialect, ...inputSchema } = z.toJSONSchema(spec.input, { io: "input" });
|
|
50
|
+
return {
|
|
51
|
+
name: spec.name,
|
|
52
|
+
description: spec.description,
|
|
53
|
+
inputSchema,
|
|
54
|
+
call: async (args, ctx) => {
|
|
55
|
+
const parsed = spec.input.safeParse(args);
|
|
56
|
+
// Readable enough for a model to fix its own call: which field, and what was expected there.
|
|
57
|
+
return parsed.success ? await spec.run(parsed.data, ctx) : textResult(z.prettifyError(parsed.error), true);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// What the peer's audit log is told about one call: the arguments verbatim (redaction is the peer's, it knows
|
|
63
|
+
// which of its tools carry typed secrets), and how it ended. A tool that answered with isError is `ok: false`
|
|
64
|
+
// with no message; one that threw carries what it said, and whether it was the peer's own refusal.
|
|
65
|
+
export interface McpAuditEntry {
|
|
66
|
+
readonly tool: string;
|
|
67
|
+
readonly args: Record<string, unknown>;
|
|
68
|
+
readonly ok: boolean;
|
|
69
|
+
readonly failure?: { readonly refused: boolean; readonly message: string };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface McpServerSpec<Ctx> {
|
|
73
|
+
readonly serverInfo: () => { readonly name: string; readonly version: string };
|
|
74
|
+
readonly tools: readonly McpTool<Ctx>[];
|
|
75
|
+
// The sentence for a tool this peer does not have, in the peer's own noun.
|
|
76
|
+
readonly noSuchTool: (name: string) => string;
|
|
77
|
+
// Whether a thrown error is this peer's own refusal (a switch that is off, a site that is not granted) as
|
|
78
|
+
// opposed to a tool that failed: the audit line says which.
|
|
79
|
+
readonly refused: (error: unknown) => boolean;
|
|
80
|
+
readonly errorMessage: (error: unknown) => string;
|
|
81
|
+
// Every call, accepted or refused, once it has an outcome. Best-effort: a log that cannot be written must
|
|
82
|
+
// never fail the answer, so a rejection here is swallowed.
|
|
83
|
+
readonly audit: (entry: McpAuditEntry) => Promise<void> | void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
87
|
+
|
|
88
|
+
// Handle one JSON-RPC message. Returns the response, or undefined for a notification (nothing to answer). `ctx`
|
|
89
|
+
// is read per call by the tool that runs, so a grant pushed mid-session takes effect on the very next call.
|
|
90
|
+
export const createMcpServer = <Ctx>(spec: McpServerSpec<Ctx>): ((message: unknown, ctx: Ctx) => Promise<Record<string, unknown> | undefined>) => {
|
|
91
|
+
const byName = new Map(spec.tools.map((entry) => [entry.name, entry]));
|
|
92
|
+
const listing = spec.tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
|
|
93
|
+
const audited = async (entry: McpAuditEntry): Promise<void> => {
|
|
94
|
+
try {
|
|
95
|
+
await spec.audit(entry);
|
|
96
|
+
} catch {
|
|
97
|
+
// Deliberately silent: a record for a human, never a control.
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const callTool = async (name: string, args: Record<string, unknown>, ctx: Ctx): Promise<Record<string, unknown>> => {
|
|
102
|
+
const found = byName.get(name);
|
|
103
|
+
if (found === undefined) {
|
|
104
|
+
return textResult(spec.noSuchTool(name), true);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const result = await found.call(args, ctx);
|
|
108
|
+
await audited({ tool: name, args, ok: result["isError"] !== true });
|
|
109
|
+
return result;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const message = spec.errorMessage(error);
|
|
112
|
+
await audited({ tool: name, args, ok: false, failure: { refused: spec.refused(error), message } });
|
|
113
|
+
return textResult(message, true);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return async (message, ctx) => {
|
|
118
|
+
if (!isRecord(message)) {
|
|
119
|
+
return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } };
|
|
120
|
+
}
|
|
121
|
+
const id = message["id"];
|
|
122
|
+
if (id === undefined) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const method = message["method"];
|
|
126
|
+
const reply = (result: Record<string, unknown>): Record<string, unknown> => ({ jsonrpc: "2.0", id, result });
|
|
127
|
+
if (method === "initialize") {
|
|
128
|
+
return reply({ protocolVersion: MCP_PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: spec.serverInfo() });
|
|
129
|
+
}
|
|
130
|
+
if (method === "ping") {
|
|
131
|
+
return reply({});
|
|
132
|
+
}
|
|
133
|
+
if (method === "tools/list") {
|
|
134
|
+
return reply({ tools: listing });
|
|
135
|
+
}
|
|
136
|
+
if (method === "tools/call") {
|
|
137
|
+
const params = isRecord(message["params"]) ? message["params"] : {};
|
|
138
|
+
const name = typeof params["name"] === "string" ? params["name"] : "";
|
|
139
|
+
const args = isRecord(params["arguments"]) ? params["arguments"] : {};
|
|
140
|
+
return reply(await callTool(name, args, ctx));
|
|
141
|
+
}
|
|
142
|
+
return { jsonrpc: "2.0", id, error: { code: -32601, message: `method "${String(method)}" is not supported` } };
|
|
143
|
+
};
|
|
144
|
+
};
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { PROVIDER_BRAND_PATHS } from "@intentic/constants";
|
|
2
2
|
import { describe, expect, test } from "vitest";
|
|
3
3
|
import { accessFor, capabilitiesOf, harnessChoosable, PROVIDER_ACCESS, PROVIDER_VENDOR, PROVIDERS, providerLabel } from "./agent-catalog.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ACCESS_COST,
|
|
6
|
+
cliProxyIdOf,
|
|
7
|
+
MINTED_PROVIDERS,
|
|
8
|
+
mintedVariant,
|
|
9
|
+
mintedVariants,
|
|
10
|
+
NATIVE_PROVIDERS,
|
|
11
|
+
PROVIDER_SPECS,
|
|
12
|
+
providerSpec,
|
|
13
|
+
} from "./provider-specs.js";
|
|
5
14
|
import { NativeProviderParamSchema } from "./schemas/agent.js";
|
|
6
15
|
import { TranslatorAccountsSchema } from "./schemas/plan-limits.js";
|
|
7
16
|
import { KeyedProviderSchema } from "./schemas/provider-subscriptions.js";
|
|
@@ -44,22 +53,39 @@ describe("every provider in the table", () => {
|
|
|
44
53
|
expect(Object.keys(TranslatorAccountsSchema.shape), `${id} has no slot in TranslatorAccounts`).toContain(id);
|
|
45
54
|
return;
|
|
46
55
|
}
|
|
47
|
-
if (spec.auth.kind === "
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
if (spec.auth.kind === "minted") {
|
|
57
|
+
expect(MINTED_PROVIDERS, `${id} is minted but missing from MINTED_PROVIDERS`).toContain(id);
|
|
58
|
+
// At least one estate to sign in to, because the head of this list is what a `login/start` that names
|
|
59
|
+
// no variant gets: an empty one is a provider whose connect row has no button that can do anything.
|
|
60
|
+
expect(spec.auth.variants.length, `${id} is minted but offers no estate to sign in to`).toBeGreaterThan(0);
|
|
61
|
+
for (const variant of spec.auth.variants) {
|
|
62
|
+
/* THE TURN URL AND THE CATALOG URL ARE DIFFERENT SURFACES OF ONE ESTATE, and both halves of that
|
|
63
|
+
* sentence are asserted: same host (a pair that drifted apart would send the catalog to one
|
|
64
|
+
* vendor and the turn to another), and the turn's base carries NO version segment, because the
|
|
65
|
+
* harness appends `/v1/messages` itself and a doubled one is a 404 mid-conversation. */
|
|
66
|
+
expect(new URL(variant.anthropicBase).host, `${id}/${variant.id}'s catalog and turn hosts differ`).toBe(
|
|
67
|
+
new URL(variant.catalogBase).host,
|
|
68
|
+
);
|
|
69
|
+
expect(variant.anthropicBase, `${id}/${variant.id}'s turn base carries a version segment the harness would double`).not.toMatch(
|
|
70
|
+
/\/v\d+$/,
|
|
71
|
+
);
|
|
72
|
+
expect(variant.label.trim(), `${id}/${variant.id} has no estate label`).not.toBe("");
|
|
73
|
+
// The lookup every surface goes through, rather than the row behind it: an id that names a
|
|
74
|
+
// variant must resolve to that variant and nothing else.
|
|
75
|
+
expect(mintedVariant(id, variant.id), `${id}/${variant.id} does not resolve`).toEqual(variant);
|
|
76
|
+
}
|
|
77
|
+
// An absent variant takes the head of the list, which is what a single-estate provider's connect row
|
|
78
|
+
// always sends and what a `login/start` with no choice on it means.
|
|
79
|
+
expect(mintedVariant(id), `${id}'s default estate is not the head of its list`).toEqual(spec.auth.variants[0]);
|
|
80
|
+
// An estate id nobody declared is NOT the default: falling back would mint a key on one estate and
|
|
81
|
+
// dial it at another, and report the refusal as an authentication problem.
|
|
82
|
+
expect(mintedVariant(id, "no-such-estate"), `${id} defaults an unknown estate instead of refusing`).toBeUndefined();
|
|
58
83
|
return;
|
|
59
84
|
}
|
|
60
85
|
// An oauth provider is served by a handshake this daemon runs; it is in neither routed list.
|
|
61
86
|
expect(KeyedProviderSchema.options, `${id} is not routed but appears in KeyedProvider`).not.toContain(id);
|
|
62
|
-
expect(
|
|
87
|
+
expect(MINTED_PROVIDERS, `${id} is not minted but appears in MINTED_PROVIDERS`).not.toContain(id);
|
|
88
|
+
expect(mintedVariants(id), `${id} is not minted but names estates`).toBeUndefined();
|
|
63
89
|
});
|
|
64
90
|
|
|
65
91
|
test.each(PROVIDER_SPECS.map((spec) => ({ id: spec.id })))("$id is in every derived list", ({ id }) => {
|
|
@@ -109,11 +135,11 @@ test("the harness chip is offered exactly where the two harnesses run different
|
|
|
109
135
|
}
|
|
110
136
|
});
|
|
111
137
|
|
|
112
|
-
/* A
|
|
138
|
+
/* A MINTED PROVIDER HAS NO NATIVE RUNTIME, by construction rather than by coincidence: it is reached by pointing
|
|
113
139
|
* the Claude Code loop at the vendor's own Anthropic endpoint, and there is no second loop to point anywhere
|
|
114
140
|
* else. A spec row that claimed one would produce a picker chip offering a runtime nothing serves. */
|
|
115
|
-
test("a
|
|
116
|
-
for (const provider of
|
|
141
|
+
test("a minted provider runs the Claude Code loop on both harnesses", () => {
|
|
142
|
+
for (const provider of MINTED_PROVIDERS) {
|
|
117
143
|
expect(capabilitiesOf(provider, "native").runtime, provider).toBe("claude-code");
|
|
118
144
|
expect(capabilitiesOf(provider, "claude-code").runtime, provider).toBe("claude-code");
|
|
119
145
|
}
|
|
@@ -125,6 +151,7 @@ test("an id that is not a provider resolves to nothing", () => {
|
|
|
125
151
|
for (const id of ["", "some-installed-agent", "endpoint/ollama", "META", "z.ai"]) {
|
|
126
152
|
expect(providerSpec(id), id).toBeUndefined();
|
|
127
153
|
expect(cliProxyIdOf(id), id).toBeUndefined();
|
|
128
|
-
expect(
|
|
154
|
+
expect(mintedVariants(id), id).toBeUndefined();
|
|
155
|
+
expect(mintedVariant(id), id).toBeUndefined();
|
|
129
156
|
}
|
|
130
157
|
});
|
package/src/provider-specs.ts
CHANGED
|
@@ -14,15 +14,14 @@ import { type AgentCapabilities, CLAUDE_CODE, CODEX, CURSOR, OPENCODE, OPENCODE_
|
|
|
14
14
|
*
|
|
15
15
|
* WHAT A ROW IS, and the two axes it deliberately keeps apart:
|
|
16
16
|
*
|
|
17
|
-
* `access` is what a turn COSTS: free, an already-paid subscription with a quota
|
|
18
|
-
*
|
|
19
|
-
* (ACCESS_COST).
|
|
17
|
+
* `access` is what a turn COSTS: free, or an already-paid subscription with a quota. It is what the picker
|
|
18
|
+
* badges, what orders the locked band, and what quick-model spends against (ACCESS_COST).
|
|
20
19
|
* `auth` is what the user CONNECTS: an OAuth account this daemon stores, a subscription the bundled
|
|
21
|
-
* translator holds, or
|
|
20
|
+
* translator holds, or a sign-in that mints the vendor's own API key.
|
|
22
21
|
*
|
|
23
22
|
* 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,
|
|
25
|
-
* axes apart is what lets a surface ask the one it actually needs.
|
|
23
|
+
* single word was picked: its cost is a prepaid coding plan, and the credential that plan is spent through is an
|
|
24
|
+
* API key its sign-in mints. Keeping the axes apart is what lets a surface ask the one it actually needs.
|
|
26
25
|
*
|
|
27
26
|
* `brand` is typed against the marks in @intentic/constants, so a provider added without a logo does not
|
|
28
27
|
* compile. That is deliberate: the fallback glyph is honest for an ACP agent nobody here has heard of, and
|
|
@@ -36,7 +35,12 @@ import { type AgentCapabilities, CLAUDE_CODE, CODEX, CURSOR, OPENCODE, OPENCODE_
|
|
|
36
35
|
// "can this row actually run" is the first thing a model list has to answer. `free` is not a courtesy tier: the
|
|
37
36
|
// Google channel serves its models on an ordinary Google sign-in, at no subscription, which is the single most
|
|
38
37
|
// useful thing this catalog can tell a user who has connected nothing yet.
|
|
39
|
-
|
|
38
|
+
//
|
|
39
|
+
// There is deliberately no `key` rung. Every provider here is unlocked by signing in to something the user
|
|
40
|
+
// already holds, so a per-call metered credential is not a shape this table can describe — a raw API key against
|
|
41
|
+
// somebody's own gateway is an `endpoint` capability (schemas/capabilities.ts), which is not a provider row and
|
|
42
|
+
// never appeared on this axis.
|
|
43
|
+
export type AccessKind = "free" | "subscription";
|
|
40
44
|
|
|
41
45
|
export interface ProviderAccess {
|
|
42
46
|
readonly kind: AccessKind;
|
|
@@ -46,11 +50,11 @@ export interface ProviderAccess {
|
|
|
46
50
|
readonly runs: string;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
// What a turn on this provider costs at the MARGIN, ordering the
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
export const ACCESS_COST: Record<AccessKind, number> = { free: 0, subscription: 1
|
|
53
|
+
// What a turn on this provider costs at the MARGIN, ordering the two kinds by the only question a helper
|
|
54
|
+
// spending the user's allowance on their behalf has to answer: free is free; a subscription is already paid but
|
|
55
|
+
// has a quota the user watches. Deliberately not folded into AccessKind's declaration order, a union's order is
|
|
56
|
+
// not a runtime fact, and this one is relied on.
|
|
57
|
+
export const ACCESS_COST: Record<AccessKind, number> = { free: 0, subscription: 1 };
|
|
54
58
|
|
|
55
59
|
/* HOW A CREDENTIAL FOR THIS PROVIDER IS OBTAINED AND HELD. Three mechanisms, and every surface that used to
|
|
56
60
|
* branch on a provider's NAME (the web's readiness rules, the connect panel's shape, the daemon's credential
|
|
@@ -63,25 +67,53 @@ export const ACCESS_COST: Record<AccessKind, number> = { free: 0, subscription:
|
|
|
63
67
|
* "translator" , the bundled CLIProxyAPI holds a SUBSCRIPTION OAuth and re-serves it behind an Anthropic
|
|
64
68
|
* endpoint, so the Claude Code loop can run a non-Claude model on it. `cliProxy` is that
|
|
65
69
|
* provider's id in the proxy's own vocabulary, which is not always ours.
|
|
66
|
-
* "
|
|
67
|
-
*
|
|
68
|
-
*
|
|
70
|
+
* "minted" , the daemon runs a sign-in whose token is NOT an inference credential, so it goes on to mint
|
|
71
|
+
* the vendor's own API key from it and stores that. The harness is then pointed straight at the
|
|
72
|
+
* vendor's Anthropic Messages endpoint with the minted key — no translator hop, because there
|
|
73
|
+
* is nothing to translate, the same road an `anthropic`-protocol endpoint capability drives.
|
|
74
|
+
*
|
|
75
|
+
* NOBODY PASTES A KEY, and the absence is the point rather than an omission. Both of these vendors sell a plan
|
|
76
|
+
* and issue keys under it, and the first cut of these two providers therefore shipped as a password field — which
|
|
77
|
+
* is a worse product than what the vendors' own CLIs do (their sign-in mints the key) and the only connect flow
|
|
78
|
+
* in this app that asked the user to go and find a credential. The minted mechanism is that sign-in. A raw key
|
|
79
|
+
* against somebody's own gateway is still supported and always was: it is an `endpoint` capability, not this.
|
|
69
80
|
*/
|
|
70
81
|
export type ProviderAuth =
|
|
71
82
|
| { readonly kind: "oauth" }
|
|
72
83
|
| { readonly kind: "translator"; readonly cliProxy: string }
|
|
73
|
-
| {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
| { readonly kind: "minted"; readonly variants: readonly MintedVariant[] };
|
|
85
|
+
|
|
86
|
+
/* ONE IDENTITY PROVIDER A MINTED PROVIDER CAN BE SIGNED INTO, and the reason this is a list rather than three
|
|
87
|
+
* fields on the auth row: Z.ai is one product sold through two entirely separate estates. An international plan
|
|
88
|
+
* signs in at chat.z.ai and its key works against api.z.ai; a mainland GLM Coding Plan signs in at bigmodel.cn
|
|
89
|
+
* and its key works against open.bigmodel.cn. Same models, same picker row, same store, and a credential minted
|
|
90
|
+
* on one estate is refused by the other's endpoint.
|
|
91
|
+
*
|
|
92
|
+
* Which is why the bases live HERE and not on the provider: a key knows which variant minted it, and the turn
|
|
93
|
+
* has to dial that variant's host. A provider-wide base URL would send a mainland plan's key to a host that has
|
|
94
|
+
* never heard of it, and the failure would arrive as an authentication error the user cannot act on. */
|
|
95
|
+
export interface MintedVariant {
|
|
96
|
+
// The stored account's record of where it came from, and what a `login/start` names. Never shown.
|
|
97
|
+
readonly id: string;
|
|
98
|
+
// What the connect row's estate control calls it, in the vendor's own words.
|
|
99
|
+
readonly label: string;
|
|
100
|
+
/* HOW THIS SIGN-IN ENDS, which decides the shape of the connect panel and nothing else.
|
|
101
|
+
*
|
|
102
|
+
* "device" , the daemon polls the vendor to completion and the account appears: nothing to paste back
|
|
103
|
+
* (Cursor's shape). Some of these also carry a one-time code to read off the card, which is
|
|
104
|
+
* a fact about the flow at RUNTIME, not about the provider, so it is not on this row.
|
|
105
|
+
* "redirect" , the vendor sends the browser to a loopback address only this container could bind, so the
|
|
106
|
+
* page dead-ends and the grant is in the address bar. The user brings that URL back
|
|
107
|
+
* (Google's shape, picture and all). */
|
|
108
|
+
readonly flow: "device" | "redirect";
|
|
109
|
+
// What ANTHROPIC_BASE_URL is set to for a turn on an account minted here. WITHOUT a version segment: the
|
|
110
|
+
// harness appends `/v1/messages` itself (see the daemon's endpoint-config.ts for why the two ecosystems
|
|
111
|
+
// disagree here).
|
|
112
|
+
readonly anthropicBase: string;
|
|
113
|
+
// Where this estate's model catalog is read from, an OpenAI-compatible root WITH its version segment,
|
|
114
|
+
// because that is the surface these vendors publish `GET …/models` on.
|
|
115
|
+
readonly catalogBase: string;
|
|
116
|
+
}
|
|
85
117
|
|
|
86
118
|
export interface ProviderSpec {
|
|
87
119
|
// The wire id, and the reserved capability id: an installed `agent` capability may not take one of these.
|
|
@@ -126,9 +158,9 @@ export interface ProviderSpec {
|
|
|
126
158
|
* to: the api-call substitutes that token server-side like it does for the other two.
|
|
127
159
|
*
|
|
128
160
|
* 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
|
|
130
|
-
* neither publishes a quota surface
|
|
131
|
-
* nothing else. */
|
|
161
|
+
* auth-file listing, and the fallback probe spends a token to answer. The minted providers are the other:
|
|
162
|
+
* neither publishes a quota surface their own minted key can read. Adding one is a reader and this flag,
|
|
163
|
+
* and nothing else. */
|
|
132
164
|
readonly planLimits: boolean;
|
|
133
165
|
/* THE TWO RUNTIMES THIS PROVIDER RUNS ON, one per value of the harness axis. Equal records mean the harness
|
|
134
166
|
* is not a choice for this provider, and every surface reads that from here rather than keeping its own
|
|
@@ -242,12 +274,12 @@ export const PROVIDER_SPECS = [
|
|
|
242
274
|
// Cursor ignores the harness for the mirror of Gemini's reason: there is no route to it but its own SDK.
|
|
243
275
|
runtimes: { native: CURSOR, claudeCode: CURSOR },
|
|
244
276
|
},
|
|
245
|
-
/* THE TWO
|
|
277
|
+
/* THE TWO MINTED PROVIDERS, and the reason they cost no new runtime, no new adapter and no translator hop:
|
|
246
278
|
* both publish an ANTHROPIC MESSAGES endpoint of their own. The Claude Code loop is pointed straight at it
|
|
247
|
-
* with the
|
|
248
|
-
* drives. So they are the Kimi shape — one record on both harnesses, no adapter, a catalog and a
|
|
249
|
-
* rung — and everything that makes them feel first-class (the brand, the badge, the section, the
|
|
250
|
-
* row) is these rows and nothing else. */
|
|
279
|
+
* with the key their sign-in minted, which is exactly the road an `anthropic`-protocol endpoint capability
|
|
280
|
+
* already drives. So they are the Kimi shape — one record on both harnesses, no adapter, a catalog and a
|
|
281
|
+
* readiness rung — and everything that makes them feel first-class (the brand, the badge, the section, the
|
|
282
|
+
* account row) is these rows and nothing else. */
|
|
251
283
|
{
|
|
252
284
|
id: "meta",
|
|
253
285
|
label: "Meta",
|
|
@@ -255,18 +287,30 @@ export const PROVIDER_SPECS = [
|
|
|
255
287
|
accountLabel: "Meta",
|
|
256
288
|
destination: "Meta",
|
|
257
289
|
brand: "meta",
|
|
258
|
-
// `
|
|
259
|
-
//
|
|
260
|
-
|
|
290
|
+
// A `subscription` like the rest, because that is what the sign-in connects: Muse Code's device login
|
|
291
|
+
// mints a plan key, and the plan is what a turn spends. It shipped as `key` for one day, which put it in
|
|
292
|
+
// the metered band and told automatic helpers every call here was real money — true of Meta's
|
|
293
|
+
// pay-per-token Model API, and not true of the thing this row now connects.
|
|
294
|
+
access: { kind: "subscription", requirement: "Muse Code subscription", runs: "Muse Spark under Claude Code" },
|
|
261
295
|
auth: {
|
|
262
|
-
kind: "
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
296
|
+
kind: "minted",
|
|
297
|
+
// One estate, so no choice to offer: `login/start` takes no variant for Meta and the connect row
|
|
298
|
+
// shows no control. The id still exists because a stored account records which variant minted it.
|
|
299
|
+
variants: [
|
|
300
|
+
{
|
|
301
|
+
id: "meta",
|
|
302
|
+
label: "Meta",
|
|
303
|
+
// Meta's is the textbook device flow (RFC 8628): a user code to read off the card, and a
|
|
304
|
+
// poll that finishes without anything coming back here.
|
|
305
|
+
flow: "device",
|
|
306
|
+
// No version segment: the harness appends `/v1/messages` itself, and Meta serves the
|
|
307
|
+
// Anthropic surface there beside the OpenAI one the catalog is read from.
|
|
308
|
+
anthropicBase: "https://api.meta.ai",
|
|
309
|
+
catalogBase: "https://api.meta.ai/v1",
|
|
310
|
+
},
|
|
311
|
+
],
|
|
268
312
|
},
|
|
269
|
-
// Nothing published that a
|
|
313
|
+
// Nothing published that a minted key can read: no quota surface, so an account row shows no meter and
|
|
270
314
|
// says so, rather than showing an empty one that reads as "nothing left".
|
|
271
315
|
planLimits: false,
|
|
272
316
|
runtimes: { native: CLAUDE_CODE, claudeCode: CLAUDE_CODE },
|
|
@@ -278,21 +322,41 @@ export const PROVIDER_SPECS = [
|
|
|
278
322
|
accountLabel: "Z.ai",
|
|
279
323
|
destination: "Z.ai",
|
|
280
324
|
brand: "zai",
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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. */
|
|
325
|
+
// A GLM Coding Plan: prepaid, with a quota the user watches, which is what `subscription` means to
|
|
326
|
+
// ACCESS_COST and to the picker's ordering. The sign-in mints the plan's own key, so the requirement
|
|
327
|
+
// names the plan rather than the credential — nobody has to go and find one.
|
|
286
328
|
access: { kind: "subscription", requirement: "Z.ai GLM Coding Plan", runs: "GLM under Claude Code" },
|
|
287
329
|
auth: {
|
|
288
|
-
kind: "
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
|
|
295
|
-
|
|
330
|
+
kind: "minted",
|
|
331
|
+
/* TWO ESTATES, and a key minted on one is refused by the other, which is why they are two variants
|
|
332
|
+
* rather than one base URL with a note. The catalog root is the CODING-PLAN one on both
|
|
333
|
+
* (`/api/coding/paas/v4`), not the general `/api/paas/v4`: that is a different entitlement, and
|
|
334
|
+
* pointing the catalog at it would list models the plan's own Anthropic endpoint then refuses,
|
|
335
|
+
* which is the worst shape a picker row can have. */
|
|
336
|
+
variants: [
|
|
337
|
+
{
|
|
338
|
+
id: "zai",
|
|
339
|
+
// Cased as the vendor cases it, which is also how the row this control sits under is
|
|
340
|
+
// titled: a pill reading "Z.AI" under a row reading "Z.ai" is two spellings of one product
|
|
341
|
+
// on one screen.
|
|
342
|
+
label: "Z.ai international",
|
|
343
|
+
// zcode.z.ai mediates the callback itself, so the daemon polls it and nothing dead-ends in
|
|
344
|
+
// the user's browser.
|
|
345
|
+
flow: "device",
|
|
346
|
+
anthropicBase: "https://api.z.ai/api/anthropic",
|
|
347
|
+
catalogBase: "https://api.z.ai/api/coding/paas/v4",
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
id: "bigmodel",
|
|
351
|
+
label: "BigModel (中国大陆)",
|
|
352
|
+
// BigModel refuses that mediated callback and takes a loopback redirect instead, which no
|
|
353
|
+
// browser outside this container can reach: the page dead-ends carrying the grant, and the
|
|
354
|
+
// user brings the address back. Google's flow exactly, down to the picture.
|
|
355
|
+
flow: "redirect",
|
|
356
|
+
anthropicBase: "https://open.bigmodel.cn/api/anthropic",
|
|
357
|
+
catalogBase: "https://open.bigmodel.cn/api/coding/paas/v4",
|
|
358
|
+
},
|
|
359
|
+
],
|
|
296
360
|
},
|
|
297
361
|
planLimits: false,
|
|
298
362
|
runtimes: { native: CLAUDE_CODE, claudeCode: CLAUDE_CODE },
|
|
@@ -330,10 +394,11 @@ export const TRANSLATOR_PROVIDERS: readonly TranslatorProvider[] = PROVIDER_SPEC
|
|
|
330
394
|
(spec): spec is Extract<Spec, { auth: { kind: "translator" } }> => spec.auth.kind === "translator",
|
|
331
395
|
).map((spec) => spec.id);
|
|
332
396
|
|
|
333
|
-
// The providers
|
|
334
|
-
|
|
335
|
-
export
|
|
336
|
-
|
|
397
|
+
// The providers whose sign-in mints the vendor's own API key, served straight off the vendor's own Anthropic
|
|
398
|
+
// Messages endpoint.
|
|
399
|
+
export type MintedProvider = Extract<Spec, { auth: { kind: "minted" } }>["id"];
|
|
400
|
+
export const MINTED_PROVIDERS: readonly MintedProvider[] = PROVIDER_SPECS.filter(
|
|
401
|
+
(spec): spec is Extract<Spec, { auth: { kind: "minted" } }> => spec.auth.kind === "minted",
|
|
337
402
|
).map((spec) => spec.id);
|
|
338
403
|
|
|
339
404
|
// This provider's CLIProxyAPI id, where it has one. Not always ours: the app says "grok" where the proxy says
|
|
@@ -343,10 +408,25 @@ export const cliProxyIdOf = (provider: string): string | undefined => {
|
|
|
343
408
|
return auth?.kind === "translator" ? auth.cliProxy : undefined;
|
|
344
409
|
};
|
|
345
410
|
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
export const keyEndpointOf = (provider: string): Extract<ProviderAuth, { kind: "key" }> | undefined => {
|
|
411
|
+
// Every estate a minted provider can be signed into, in the order the connect row offers them, or nothing when
|
|
412
|
+
// the provider is not one of them. The head is the default: what a `login/start` that names no variant gets.
|
|
413
|
+
export const mintedVariants = (provider: string): readonly MintedVariant[] | undefined => {
|
|
350
414
|
const auth = providerSpec(provider)?.auth;
|
|
351
|
-
return auth?.kind === "
|
|
415
|
+
return auth?.kind === "minted" ? auth.variants : undefined;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/* THE ESTATE ONE ACCOUNT BELONGS TO: its bases and its flow, whole, because a base URL read without its sibling
|
|
419
|
+
* is how a catalog and a turn end up pointed at two different hosts.
|
|
420
|
+
*
|
|
421
|
+
* An ABSENT id takes the default (the head of the list), which is what a connect row that offers no choice
|
|
422
|
+
* sends and what a store written before a second variant existed reads back as. An id that names no variant is
|
|
423
|
+
* `undefined` and NOT the default: silently falling back would dial a mainland key against api.z.ai and report
|
|
424
|
+
* the refusal as an authentication problem, when what happened is that we lost track of where the key came
|
|
425
|
+
* from. */
|
|
426
|
+
export const mintedVariant = (provider: string, variant?: string): MintedVariant | undefined => {
|
|
427
|
+
const variants = mintedVariants(provider);
|
|
428
|
+
if (variants === undefined) {
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
return variant === undefined || variant === "" ? variants[0] : variants.find((entry) => entry.id === variant);
|
|
352
432
|
};
|
package/src/quick-model.ts
CHANGED
|
@@ -92,12 +92,19 @@ const tierOf = (model: string): number => tierRankOf(familyOf(model));
|
|
|
92
92
|
// list, so it reads -1 and leads the tiebreak; unreachable in practice, since it can never tie on cost.
|
|
93
93
|
const providerOrder = (provider: AgentProvider): number => PROVIDERS.findIndex((entry) => entry.value === provider);
|
|
94
94
|
|
|
95
|
+
/* WHAT AN ENDPOINT COSTS, one rung past every provider's, and the reason it is a number here rather than a
|
|
96
|
+
* member of AccessKind. That axis describes the providers this repo ships, and every one of them is unlocked by
|
|
97
|
+
* signing in to something the user already holds, so none of them is metered per call. An endpoint is the
|
|
98
|
+
* opposite: whatever gateway somebody pointed us at, whose bill this repo cannot see. Reading it as dearer than
|
|
99
|
+
* anything on the table is the conservative answer, and it is what keeps Auto from reaching for a paid gateway
|
|
100
|
+
* on its own initiative. */
|
|
101
|
+
const METERED_COST = Math.max(...Object.values(ACCESS_COST)) + 1;
|
|
102
|
+
|
|
95
103
|
// How much a call on this provider costs at the margin. Every native provider declares an access kind; an
|
|
96
|
-
// endpoint declares none, and takes the metered rung
|
|
97
|
-
// repo cannot see, which keeps Auto from reaching for someone's paid gateway on its own initiative.
|
|
104
|
+
// endpoint declares none, and takes the metered rung above.
|
|
98
105
|
const costOf = (provider: AgentProvider): number => {
|
|
99
106
|
const access = accessFor(provider);
|
|
100
|
-
return access === undefined ?
|
|
107
|
+
return access === undefined ? METERED_COST : ACCESS_COST[access.kind];
|
|
101
108
|
};
|
|
102
109
|
|
|
103
110
|
/* AUTO, every connected provider's cheapest row, best-first, as a ladder rather than a winner.
|
package/src/routes.test.ts
CHANGED
|
@@ -137,7 +137,7 @@ describe(`routeShapes`, () => {
|
|
|
137
137
|
describe(`the real sandbox contract`, () => {
|
|
138
138
|
it(`fingerprints all but the streaming routes`, () => {
|
|
139
139
|
const unshaped = SANDBOX_ROUTE_NAMES.filter((name) => !(name in SANDBOX_ROUTE_SHAPES));
|
|
140
|
-
// oRPC wraps an event iterator's output in an opaque type with no schema under it, so these
|
|
140
|
+
// oRPC wraps an event iterator's output in an opaque type with no schema under it, so these eleven
|
|
141
141
|
// cannot be fingerprinted and are assumed compatible. Named rather than counted: a NEW entry here is
|
|
142
142
|
// a route that quietly lost its shape check, which is worth failing a test over.
|
|
143
143
|
expect(unshaped.toSorted()).toEqual([
|
|
@@ -152,14 +152,17 @@ describe(`the real sandbox contract`, () => {
|
|
|
152
152
|
`intentic.applyEvents`,
|
|
153
153
|
`intentic.run`,
|
|
154
154
|
`system.events`,
|
|
155
|
-
`system.
|
|
155
|
+
`system.manageDeviceSandbox`,
|
|
156
|
+
// The device's own agent updating or restarting itself: minutes of download and swap, and the
|
|
157
|
+
// stream dies with the process it is reporting on, so the lines have to arrive as they happen.
|
|
158
|
+
`system.runDeviceAgentFlow`,
|
|
156
159
|
`vpn.connect`,
|
|
157
160
|
]);
|
|
158
161
|
});
|
|
159
162
|
|
|
160
163
|
it(`fingerprints every other route exactly once`, () => {
|
|
161
164
|
expect(Object.keys(SANDBOX_ROUTE_SHAPES).every((name) => SANDBOX_ROUTE_NAMES.includes(name))).toBe(true);
|
|
162
|
-
expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length -
|
|
165
|
+
expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length - 11);
|
|
163
166
|
});
|
|
164
167
|
|
|
165
168
|
it(`derives a route table with no duplicate names`, () => {
|
package/src/runner-protocol.ts
CHANGED
|
@@ -154,7 +154,7 @@ export type RunnerParity = z.infer<typeof RunnerParitySchema>;
|
|
|
154
154
|
// HostSummary shape retold for a runner (no platform/scopes, parity instead).
|
|
155
155
|
export const RunnerSummarySchema = z.object({
|
|
156
156
|
id: z.string(),
|
|
157
|
-
// The connected
|
|
157
|
+
// The connected device holding it, when this sandbox is what asked for it (the Devices view's create
|
|
158
158
|
// flow). Absent for one started by hand on a machine, which this sandbox can dispatch to but not manage.
|
|
159
159
|
host: z.string().optional(),
|
|
160
160
|
online: z.boolean(),
|