Public: anyone can read this; nobody can change it.

Any one-line secret is a model key; a setup-token may go to a child under a budget #28

Merged opened this view on origin

Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.

Merged

any-model-key → main 1 commit

  • Merged. any-model-key was merged into main.

Review

Sign in to review this change.
9 files changed +65 −42
src/actions/spawn.ts +2 −2
@@ -82,8 +82,8 @@ export interface SpawnDeps {
8282 readonly request: Request;
8383 readonly url: URL;
8484 /**
85 * The child's own model key, when the spawner gives it one: an API-billed
86 * key (never a person's subscription token), sealed in the child with the
85 * The child's own model key, when the spawner gives it one: an API key, or
86 * a person's own setup-token under a budget (their choice), sealed in the child with the
8787 * child's own `secret.key`, granted to its organisation, and never shown
8888 * again or kept by the parent. Its whole-life budget is the plan's
8989 * `budgetUsd`, locked in the child.
src/core/subscriptions.ts +12 −15
@@ -180,22 +180,19 @@ export const API_BASE_MAX = 200;
180180
181181 /**
182182 * What kind of token this is. A token from `claude setup-token` begins
183 * `sk-ant-oat…`; an Anthropic API key `sk-ant-api…`; an OpenRouter key
184 * `sk-or-v1-…`. With an API base URL set (OpenRouter or another Anthropic-
185 * compatible gateway), any other non-empty token is accepted as an API key
186 * so a gateway's own key shape can seal. OAuth tokens never take a base URL.
183 * `sk-ant-oat…` and is a person's own plan: it goes under
184 * `CLAUDE_CODE_OAUTH_TOKEN` and never takes a base URL. Anything else that
185 * could be a key — one line, no spaces, not absurdly long — is an API key:
186 * Anthropic's `sk-ant-api…`, the `sk-ant-usr…` keys that come with a
187 * subscription's API credits, OpenRouter's `sk-or-v1-…`, or whatever shape
188 * a gateway hands out with its base URL. The forge keeps no list of shapes;
189 * the provider answers a bad key on the first call, and the run says so.
187190 */
188 export function kindOfToken(
189 token: string,
190 opts?: { readonly apiBase?: string | null | undefined },
191 ): SubscriptionKind | undefined {
192 if (token.length > TOKEN_MAX || /\s|\0/.test(token)) return undefined;
191 export function kindOfToken(token: string): SubscriptionKind | undefined {
192 if (token === "" || token.length > TOKEN_MAX || /\s|\0/.test(token))
193 return undefined;
193194 if (/^sk-ant-oat\d*-/.test(token)) return "oauth";
194 if (/^sk-ant-api\d*-/.test(token)) return "api_key";
195 if (/^sk-or-v1-/.test(token)) return "api_key";
196 const base = (opts?.apiBase ?? "").trim();
197 if (base !== "" && parseApiBase(base) !== undefined) return "api_key";
198 return undefined;
195 return "api_key";
199196 }
200197
201198 /**
@@ -294,7 +291,7 @@ export function addSubscription(
294291 if (parsed === undefined) return err("invalid_api_base");
295292 apiBase = parsed;
296293 }
297 const kind = kindOfToken(input.token, { apiBase });
294 const kind = kindOfToken(input.token);
298295 if (kind === undefined) return err("invalid_token");
299296 // A Claude subscription token is Anthropic's own plan — never a gateway.
300297 if (kind === "oauth" && apiBase !== null) return err("invalid_api_base");
src/spawn/door.ts +6 −6
@@ -167,7 +167,7 @@ export async function spawnDoor(
167167 "modelKey",
168168 "anonymous spawns carry no model key; claim the forge and add one there",
169169 );
170 // The child's model key: an API-billed key with a whole-life budget, or none. A
170 // The child's model key: a key or subscription token with a whole-life budget, or none. A
171171 // subscription token is a person's own plan and never travels.
172172 const modelEndpointRaw = raw["modelEndpoint"];
173173 let modelEndpoint: string | undefined;
@@ -181,14 +181,14 @@ export async function spawnDoor(
181181 }
182182 const modelKey = raw["modelKey"];
183183 if (modelKey !== undefined) {
184 if (
185 typeof modelKey !== "string" ||
186 kindOfToken(modelKey, { apiBase: modelEndpoint }) !== "api_key"
187 )
184 const kind = typeof modelKey === "string" ? kindOfToken(modelKey) : undefined;
185 if (kind === undefined)
188186 return invalid(
189187 "modelKey",
190 "must be an API key (sk-ant-api… or sk-or-v1-…); a subscription token is a person's own plan and never goes to a child",
188 "must be one line with no spaces: an Anthropic key (sk-ant-api…, sk-ant-usr…), a `claude setup-token` token (sk-ant-oat…), or a gateway's key with its base URL",
191189 );
190 if (kind === "oauth" && modelEndpoint !== undefined)
191 return invalid("modelEndpoint", "a subscription token (sk-ant-oat…) talks to Anthropic itself and takes no base URL");
192192 if (raw["budgetUsd"] === undefined) return invalid("budgetUsd", "a model key needs a budget in dollars, for its whole life");
193193 } else if (raw["budgetUsd"] !== undefined) {
194194 return invalid("budgetUsd", "a budget is for a model key; send modelKey with it");
src/spawn/plan.ts +1 −1
@@ -223,7 +223,7 @@ export function dryRun(plan: SpawnPlan, source: SpawnSource): DryRun {
223223 ...(plan.keepAccess ? ["your owner access, kept after the child is claimed"] : []),
224224 ...(plan.budgetUsd === null
225225 ? []
226 : [`its own model key (API-billed, sealed, never shown again), with a $${plan.budgetUsd} budget it cannot raise`]),
226 : [`its own model key (sealed, never shown again), with a $${plan.budgetUsd} budget it cannot raise`]),
227227 ];
228228 const bytes =
229229 plan.apps.reduce((n, a) => n + a.bytes, 0) +
src/web/pages/new-forge.ts +1 −1
@@ -202,7 +202,7 @@ function formView(
202202 required: values.hadKey,
203203 hint: values.hadKey
204204 ? "Type the key again to spawn: it is never sent back to your browser."
205 : "An API-billed key (sk-ant-api… or sk-or-v1-…) with its own spend limit; sealed in the child, never shown again. Never a subscription token.",
205 : "An Anthropic key (sk-ant-api…, sk-ant-usr…), a `claude setup-token` token (sk-ant-oat…), or any gateway's key with its base URL below; with its own spend limit, sealed in the child, never shown again.",
206206 ...(errors["modelKey"] ? { error: errors["modelKey"] } : {}),
207207 })}
208208 ${anon
src/web/pages/subscriptions.ts +4 −4
@@ -301,7 +301,7 @@ export function subscriptionsPage(ctx: Ctx): Response {
301301 id="f-token"
302302 name="token"
303303 type="password"
304 placeholder="sk-ant-… or sk-or-v1-…"
304 placeholder="sk-ant-… or any provider's key"
305305 required
306306 autocomplete="off"
307307 spellcheck="false"
@@ -327,8 +327,8 @@ export function subscriptionsPage(ctx: Ctx): Response {
327327 <span class="hint"
328328 >Anthropic-compatible gateway for Claude Code. OpenRouter:
329329 <code>https://openrouter.ai/api</code> (no <code>/v1</code> — the
330 SDK adds it). Leave empty for Anthropic itself. With a base URL set,
331 non-<code>sk-ant</code> API keys are accepted.</span
330 SDK adds it). Leave empty for Anthropic itself; a setup-token
331 (sk-ant-oat…) takes none.</span
332332 >
333333 </div>
334334 <div>${btn("Add", { variant: "primary", size: "sm", icon: "plus" })}</div>
@@ -420,7 +420,7 @@ export async function subscriptionAdd(ctx: Ctx): Promise<Response> {
420420 invalid_name: `A name is a word or two, up to ${NAME_MAX} characters.`,
421421 name_taken: `There is already a subscription called ${name.trim()}.`,
422422 invalid_token:
423 "That is not a model token. One from `claude setup-token` begins sk-ant-oat; an Anthropic API key begins sk-ant-api; an OpenRouter key begins sk-or-v1. With an API base URL set, other API keys are accepted too.",
423 "That is not a model token: one line, no spaces. One from `claude setup-token` begins sk-ant-oat; anything else is taken as an API key (sk-ant-api…, sk-ant-usr…, sk-or-v1-…, or a gateway's own with its base URL).",
424424 invalid_api_base:
425425 "The API base URL must be https, with no credentials, query, or hash. For OpenRouter use https://openrouter.ai/api (a Claude subscription token cannot use a gateway).",
426426 };
test/new-forge.test.ts +2 −2
@@ -225,13 +225,13 @@ describe("+ New forge", () => {
225225 const key = await post(admin, [
226226 ["name", "kai"],
227227 ["apps", "alice/bakery"],
228 ["modelKey", "sk-ant-oat01-a-persons-own-plan-XXXXXXXX"],
228 ["modelKey", "sk-ant-api03 pasted with a space"],
229229 ["budgetUsd", "5"],
230230 ["do", "plan"],
231231 ]);
232232 expect(key.status).toBe(422);
233233 expect(await key.text()).toMatch(
234 /id="f-modelKey-err"[^>]*>[^<]*a subscription token is a person/,
234 /id="f-modelKey-err"[^>]*>[^<]*one line with no spaces/,
235235 );
236236 });
237237
test/spawn-keys.test.ts +31 −6
@@ -114,18 +114,43 @@ describe("a spawned child's own keys and domain", () => {
114114 await rm(dir, { recursive: true, force: true });
115115 });
116116
117 test("a subscription token never goes to a child, and a key needs a budget", async () => {
117 test("a setup-token or a subscription's credits key may go to a child under a budget; a key needs a budget; a token with a space is refused", async () => {
118118 const oauth = await post({
119119 name: "maria",
120120 apps: ["alice/bakery"],
121 dryRun: false,
121 dryRun: true,
122122 modelKey: OAUTH,
123123 budgetUsd: 5,
124124 });
125 expect(oauth.status).toBe(422);
126 expect(await oauth.text()).toContain(
127 "a subscription token is a person's own plan",
128 );
125 expect(oauth.status).toBe(200);
126 expect(await oauth.json()).toMatchObject({ dryRun: true });
127 const credits = await post({
128 name: "maria",
129 apps: ["alice/bakery"],
130 dryRun: true,
131 modelKey: "sk-ant-usr01-credits-that-come-with-the-plan-CCCCCCCC",
132 budgetUsd: 5,
133 });
134 expect(credits.status).toBe(200);
135 const gatewayForOauth = await post({
136 name: "maria",
137 apps: ["alice/bakery"],
138 dryRun: true,
139 modelKey: OAUTH,
140 modelEndpoint: "https://openrouter.ai/api",
141 budgetUsd: 5,
142 });
143 expect(gatewayForOauth.status).toBe(422);
144 expect(await gatewayForOauth.text()).toContain("takes no base URL");
145 const spaced = await post({
146 name: "maria",
147 apps: ["alice/bakery"],
148 dryRun: true,
149 modelKey: "sk-ant-api03 with a space",
150 budgetUsd: 5,
151 });
152 expect(spaced.status).toBe(422);
153 expect(await spaced.text()).toContain("one line with no spaces");
129154 expect(
130155 (
131156 await post({
test/subscriptions.test.ts +6 −5
@@ -39,12 +39,13 @@ const OR_KEY = "sk-or-v1-openrouter-key-for-the-test-aaaaaaaa";
3939 const GATEWAY = "https://openrouter.ai/api";
4040
4141 describe("a token's kind", () => {
42 test("is read from its shape, and anything else is refused", () => {
42 test("a setup-token is a plan; any other one-line secret is an API key; spaces, nothing and absurd lengths are refused", () => {
4343 expect(kindOfToken(OAT)).toBe("oauth");
4444 expect(kindOfToken(KEY)).toBe("api_key");
4545 expect(kindOfToken(OR_KEY)).toBe("api_key");
46 expect(kindOfToken("hunter2")).toBeUndefined();
47 expect(kindOfToken("hunter2", { apiBase: GATEWAY })).toBe("api_key");
46 expect(kindOfToken("sk-ant-usr01-credits-with-a-subscription")).toBe("api_key");
47 expect(kindOfToken("hunter2")).toBe("api_key");
48 expect(kindOfToken("")).toBeUndefined();
4849 expect(kindOfToken("sk-ant-oat01-with space")).toBeUndefined();
4950 expect(kindOfToken(`sk-ant-api03-${"x".repeat(600)}`)).toBeUndefined();
5051 });
@@ -93,7 +94,7 @@ describe("the pool", () => {
9394 error: "invalid_name",
9495 });
9596 expect(
96 addSubscription(app.db, dataDir, { name: "nope", token: "hunter2" }),
97 addSubscription(app.db, dataDir, { name: "nope", token: "hunter 2" }),
9798 ).toEqual({ ok: false, error: "invalid_token" });
9899 expect(JSON.stringify(listSubscriptions(app.db))).not.toContain(OAT);
99100 const raw = app.db
@@ -516,7 +517,7 @@ describe("the pages", () => {
516517 expect(added.status).toBe(303);
517518 const refused = await post("/settings/subscriptions", {
518519 name: "nope",
519 token: "hunter2",
520 token: "hunter 2",
520521 });
521522 expect(refused.status).toBe(303);
522523 expect(listSubscriptions(app.db).map((s) => s.name)).toEqual(["work"]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs