From b8be62c587f05d8e42cee108b7eda5e02bc75bc4 Mon Sep 17 00:00:00 2001 From: Aayam Bansal Date: Sun, 5 Jul 2026 17:18:37 +0530 Subject: [PATCH] feat(settings): Spend controls in the workspace, compute keys actually applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute panel keys now do something: applyComputeEnv() mirrors the credentials store's pattern — decrypt the stored GPU provider keys and inject them under the canonical env var names the skills read (Modal's combined key splits into token id + secret), at boot and after every connect/disconnect, with removed-entry cleanup, shell-export precedence, and output redaction. Ship the Spend settings panel: LLM spend (managed / byok / auto) and compute spend (managed / byok) driven by GET/PUT /settings/billing via the generated SDK, plus wallet balance and signed-in state. The PUT route keeps the server-side account billing mode in sync and now accepts llm: null to set the toggle back to auto (config schema + SDK types regenerated). Usage panel copy now points at the real control. Fixes #71 Fixes #72 --- backend/cli/src/config/config.ts | 3 +- backend/cli/src/index.ts | 6 + .../cli/src/server/routes/settings/billing.ts | 6 +- .../cli/src/server/routes/settings/compute.ts | 111 ++++++++- backend/cli/src/session/billing-gate.ts | 5 +- .../cli/test/server/settings-billing.test.ts | 17 ++ .../cli/test/server/settings-compute.test.ts | 80 +++++++ .../src/components/settings/Spend.tsx | 217 ++++++++++++++++++ .../src/components/settings/Usage.tsx | 2 +- .../src/components/settings/registry.ts | 2 + tooling/sdk/js/src/v2/gen/sdk.gen.ts | 2 +- tooling/sdk/js/src/v2/gen/types.gen.ts | 6 +- 12 files changed, 446 insertions(+), 11 deletions(-) create mode 100644 backend/cli/test/server/settings-compute.test.ts create mode 100644 frontend/workspace/src/components/settings/Spend.tsx diff --git a/backend/cli/src/config/config.ts b/backend/cli/src/config/config.ts index 1f825d4..643490d 100644 --- a/backend/cli/src/config/config.ts +++ b/backend/cli/src/config/config.ts @@ -965,9 +965,10 @@ export namespace Config { .object({ llm: z .enum(["managed", "byok"]) + .nullable() .optional() .describe( - "How LLM inference is paid for. 'managed' routes through the Atlas wallet (metered credits); 'byok' uses your own provider API keys or first-party OAuth (ChatGPT/Claude Pro/Copilot) and is never billed. Unset = auto-detect from the resolved credential.", + "How LLM inference is paid for. 'managed' routes through the Atlas wallet (metered credits); 'byok' uses your own provider API keys or first-party OAuth (ChatGPT/Claude Pro/Copilot) and is never billed. Unset or null = auto-detect from the resolved credential.", ), compute: z .enum(["managed", "byok"]) diff --git a/backend/cli/src/index.ts b/backend/cli/src/index.ts index d0b0d17..ccee36b 100644 --- a/backend/cli/src/index.ts +++ b/backend/cli/src/index.ts @@ -100,6 +100,12 @@ const cli = yargs(hideBin(process.argv)) .then((m) => m.applyCredentialEnv()) .catch(() => {}) + // Same for BYOK GPU provider keys (settings ▸ Compute) — decrypt and inject + // under the canonical env var names the compute skills read. + await import("./server/routes/settings/compute") + .then((m) => m.ComputeSettings.applyComputeEnv()) + .catch(() => {}) + // Retry any failed usage reports from previous sessions OpenScience.flushPendingUsage().catch(() => {}) }) diff --git a/backend/cli/src/server/routes/settings/billing.ts b/backend/cli/src/server/routes/settings/billing.ts index cae0628..c4453f6 100644 --- a/backend/cli/src/server/routes/settings/billing.ts +++ b/backend/cli/src/server/routes/settings/billing.ts @@ -22,8 +22,10 @@ export const BillingState = z.object({ }) export type BillingState = z.infer +// `llm: null` sets the toggle back to auto (auto-detect from the resolved +// credential); omitting a field leaves it untouched. const BillingPatch = z.object({ - llm: z.enum(["managed", "byok"]).optional(), + llm: z.enum(["managed", "byok"]).nullable().optional(), compute: z.enum(["managed", "byok"]).optional(), }) @@ -78,6 +80,8 @@ export const BillingSettingsRoutes = lazy(() => // Mirror the LLM toggle to the account-scoped server billing mode, then force // a fresh sync so the right provider credentials (managed proxy token vs the // user's BYOK keys) are re-injected into the environment for the next call. + // Auto (null) has no server-side counterpart — the account mode stays put and + // the gate auto-detects from the resolved credential per call. if (patch.llm) { await OpenScience.setBillingMode(patch.llm).catch((e) => log.warn("setBillingMode failed", { error: e instanceof Error ? e.message : String(e) }), diff --git a/backend/cli/src/server/routes/settings/compute.ts b/backend/cli/src/server/routes/settings/compute.ts index 64a9a29..e088d63 100644 --- a/backend/cli/src/server/routes/settings/compute.ts +++ b/backend/cli/src/server/routes/settings/compute.ts @@ -5,6 +5,8 @@ import crypto from "crypto" import path from "path" import fs from "fs/promises" import { Global } from "../../../global" +import { Env } from "../../../env" +import { OpenScience } from "../../../openscience" import { errors } from "../../error" import { lazy } from "../../../util/lazy" @@ -20,6 +22,15 @@ import { lazy } from "../../../util/lazy" // NEVER returned to the client — only presence + metadata are surfaced. // • SSH hosts the agent can dispatch runs to. // • Model endpoints (local or remote inference URLs). +// +// How a stored key actually does something: applyComputeEnv() (mirroring +// applyCredentialEnv in ./credentials.ts) decrypts each connected provider's +// key and injects it into the process environment under the canonical env var +// names the real consumers read — the cloud-compute/ml-training skills and +// every bash subprocess via OpenScience.subprocessEnv. It runs at CLI/server +// boot (index.ts) and again after each provider connect/disconnect, so a saved +// key applies live without a restart. Decrypted values are registered for +// output redaction, and an explicit shell export always wins. export namespace ComputeSettings { const storePath = path.join(Global.Path.data, "settings-compute.json") @@ -45,6 +56,19 @@ export namespace ComputeSettings { return Buffer.concat([iv, tag, enc]).toString("base64") } + // Inverse of encrypt(): iv(12) | tag(16) | ciphertext. Throws on a bad + // key/tag, which callers treat as "unreadable key, skip it". + async function decrypt(payload: string): Promise { + const key = await machineKey() + const buf = Buffer.from(payload, "base64") + const iv = buf.subarray(0, 12) + const tag = buf.subarray(12, 28) + const enc = buf.subarray(28) + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8") + } + // ── GPU provider catalog ── // `verified` = a first-class provider whose integration we've validated; // surfaced as the green "verified" badge vs a plain "connected" one. @@ -135,6 +159,83 @@ export namespace ComputeSettings { return crypto.randomUUID().slice(0, 8) } + // ── Runtime env injection ── + // This is the ONLY thing that turns a stored provider key into a working one. + + // Canonical env var names each provider's real consumers read (skill scripts, + // session prompts, dashboard sync). Where two spellings exist in the wild + // both are set. Modal is handled separately — its single pasted key + // ("ak-… : as-…") splits into a token id + secret pair. + const PROVIDER_ENV: Record = { + tensorpool: ["TENSORPOOL_KEY", "TENSORPOOL_API_KEY"], + lambda: ["LAMBDA_API_KEY", "LAMBDA_LABS_API_KEY"], + prime: ["PRIME_API_KEY", "PRIME_INTELLECT_API_KEY"], + vast: ["VAST_API_KEY"], + runpod: ["RUNPOD_API_KEY"], + } + + /** Map one provider's decrypted key to the canonical env var names its real + * consumers read. Modal's combined "token_id : token_secret" key is split; + * a half-pasted modal key maps to nothing (both vars are required). */ + function mapProviderEnv(target: string, key: string): Record { + if (target === "modal") { + const [token, secret] = key.split(":").map((part) => part.trim()) + if (!token || !secret) return {} + return { MODAL_TOKEN_ID: token, MODAL_TOKEN_SECRET: secret } + } + return Object.fromEntries((PROVIDER_ENV[target] ?? []).map((name) => [name, key])) + } + + // Env keys this module has set, so a re-apply after save can update our own + // values while still never clobbering an explicit shell export. + const ownedKeys = new Set() + + /** Decrypt stored GPU provider keys and inject them into the process + * environment so the real consumers use them (see the module header). + * Explicit shell exports always win. Registers key values for redaction. + * Best-effort; never throws. Call at boot and after every connect/disconnect. */ + export async function applyComputeEnv(): Promise { + try { + const stored = await read() + const env: Record = {} + const secrets: string[] = [] + for (const [target, entry] of Object.entries(stored.providers)) { + const key = await decrypt(entry.key).catch(() => undefined) + // Unreadable (rotated key / corrupt) — skip; the UI still shows it connected. + if (!key) continue + for (const [name, value] of Object.entries(mapProviderEnv(target, key))) { + env[name] = value + secrets.push(value) + } + } + // Drop vars we previously injected that are gone now (provider removed) — + // but never touch a key the user exported in their own shell. + for (const name of [...ownedKeys]) { + if (name in env) continue + delete process.env[name] + try { + Env.remove(name) + } catch { + /* Instance state not initialized — process.env delete is enough */ + } + ownedKeys.delete(name) + } + for (const [name, value] of Object.entries(env)) { + if (process.env[name] && !ownedKeys.has(name)) continue + process.env[name] = value + ownedKeys.add(name) + try { + Env.set(name, value) + } catch { + // Instance state not initialized yet — process.env alone is enough here. + } + } + OpenScience.registerSecretValues(secrets) + } catch { + // best-effort; a broken store must not break boot or a save response + } + } + // Build the client-facing view — never includes the encrypted key. function view(stored: Stored): Info { const providers = CATALOG.map((spec) => { @@ -240,7 +341,9 @@ export const ComputeSettingsRoutes = lazy(() => async (c) => { const target = c.req.valid("param").id if (!ComputeSettings.isProvider(target)) return c.json({ error: "Unknown provider" }, 400) - return c.json(await ComputeSettings.connectProvider(target, c.req.valid("json").key.trim())) + const info = await ComputeSettings.connectProvider(target, c.req.valid("json").key.trim()) + await ComputeSettings.applyComputeEnv() // apply the new key to the running process + return c.json(info) }, ) .delete( @@ -253,7 +356,11 @@ export const ComputeSettingsRoutes = lazy(() => }, }), validator("param", z.object({ id: z.string() })), - async (c) => c.json(await ComputeSettings.disconnectProvider(c.req.valid("param").id)), + async (c) => { + const info = await ComputeSettings.disconnectProvider(c.req.valid("param").id) + await ComputeSettings.applyComputeEnv() // re-sync process env after removal + return c.json(info) + }, ) .post( "/ssh", diff --git a/backend/cli/src/session/billing-gate.ts b/backend/cli/src/session/billing-gate.ts index 0a1804e..cf9ba20 100644 --- a/backend/cli/src/session/billing-gate.ts +++ b/backend/cli/src/session/billing-gate.ts @@ -24,9 +24,10 @@ export type CredentialSource = "byok" | "managed" | "oauth-free" export type BillingMode = "managed" | "byok" /** The user-facing LLM spend toggle (Settings → Spend). `undefined` = auto-detect - * from the resolved credential (legacy behaviour). */ + * from the resolved credential (legacy behaviour; `null` in the config file — + * the toggle set back to auto — normalizes to the same thing). */ export async function llmBillingMode(): Promise { - return (await Config.get()).billing?.llm + return (await Config.get()).billing?.llm ?? undefined } /** The user-facing compute spend toggle. Defaults to "byok" (own GPU providers). */ diff --git a/backend/cli/test/server/settings-billing.test.ts b/backend/cli/test/server/settings-billing.test.ts index 4da28d0..6a2c591 100644 --- a/backend/cli/test/server/settings-billing.test.ts +++ b/backend/cli/test/server/settings-billing.test.ts @@ -34,3 +34,20 @@ test("PUT persists the toggle without baking resolved secrets into the config fi const written = JSON.parse(text) expect(written.billing).toEqual({ llm: "byok" }) }) + +test("PUT llm null sets the toggle back to auto", async () => { + await fs.mkdir(Global.Path.config, { recursive: true }) + await Bun.write(file, JSON.stringify({ billing: { llm: "managed" } }, null, 2)) + + const res = await BillingSettingsRoutes().request("/", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ llm: null }), + }) + expect(res.status).toBe(200) + const state = await res.json() + expect(state.llm).toBeNull() + + const written = JSON.parse(await Bun.file(file).text()) + expect(written.billing.llm).toBeNull() +}) diff --git a/backend/cli/test/server/settings-compute.test.ts b/backend/cli/test/server/settings-compute.test.ts new file mode 100644 index 0000000..40de845 --- /dev/null +++ b/backend/cli/test/server/settings-compute.test.ts @@ -0,0 +1,80 @@ +import { test, expect, afterAll } from "bun:test" +import { ComputeSettingsRoutes } from "../../src/server/routes/settings/compute" + +// Every env var the compute store can own — cleaned up so other test files +// never see leftovers from this one. +const VARS = [ + "MODAL_TOKEN_ID", + "MODAL_TOKEN_SECRET", + "TENSORPOOL_KEY", + "TENSORPOOL_API_KEY", + "LAMBDA_API_KEY", + "LAMBDA_LABS_API_KEY", + "PRIME_API_KEY", + "PRIME_INTELLECT_API_KEY", + "VAST_API_KEY", + "RUNPOD_API_KEY", +] + +afterAll(() => { + for (const name of VARS) delete process.env[name] +}) + +function connect(provider: string, key: string) { + return ComputeSettingsRoutes().request(`/provider/${provider}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ key }), + }) +} + +test("connecting a provider applies its key to the process env under every canonical name", async () => { + const res = await connect("tensorpool", "tp-test-secret-123") + expect(res.status).toBe(200) + + // The saved key is live immediately — no restart needed. + expect(process.env["TENSORPOOL_KEY"]).toBe("tp-test-secret-123") + expect(process.env["TENSORPOOL_API_KEY"]).toBe("tp-test-secret-123") + + // The key itself never travels back to the client. + const body = await res.text() + expect(body).not.toContain("tp-test-secret-123") + const info = JSON.parse(body) + expect(info.providers.find((p: { id: string }) => p.id === "tensorpool").connected).toBe(true) +}) + +test("modal's combined key splits into token id + secret", async () => { + const res = await connect("modal", "ak-test-id : as-test-secret") + expect(res.status).toBe(200) + expect(process.env["MODAL_TOKEN_ID"]).toBe("ak-test-id") + expect(process.env["MODAL_TOKEN_SECRET"]).toBe("as-test-secret") +}) + +test("an explicit shell export always wins over a stored key", async () => { + process.env["VAST_API_KEY"] = "from-shell" + const res = await connect("vast", "vast-stored-key") + expect(res.status).toBe(200) + expect(process.env["VAST_API_KEY"]).toBe("from-shell") +}) + +test("disconnecting a provider removes the injected vars but never a shell export", async () => { + for (const provider of ["tensorpool", "modal", "vast"]) { + const res = await ComputeSettingsRoutes().request(`/provider/${provider}`, { method: "DELETE" }) + expect(res.status).toBe(200) + } + expect(process.env["TENSORPOOL_KEY"]).toBeUndefined() + expect(process.env["TENSORPOOL_API_KEY"]).toBeUndefined() + expect(process.env["MODAL_TOKEN_ID"]).toBeUndefined() + expect(process.env["MODAL_TOKEN_SECRET"]).toBeUndefined() + // The shell export was never owned by the store, so removal leaves it alone. + expect(process.env["VAST_API_KEY"]).toBe("from-shell") +}) + +test("re-saving a key updates the injected value in place", async () => { + await connect("runpod", "rpa_first") + expect(process.env["RUNPOD_API_KEY"]).toBe("rpa_first") + await connect("runpod", "rpa_second") + expect(process.env["RUNPOD_API_KEY"]).toBe("rpa_second") + await ComputeSettingsRoutes().request("/provider/runpod", { method: "DELETE" }) + expect(process.env["RUNPOD_API_KEY"]).toBeUndefined() +}) diff --git a/frontend/workspace/src/components/settings/Spend.tsx b/frontend/workspace/src/components/settings/Spend.tsx new file mode 100644 index 0000000..8579db6 --- /dev/null +++ b/frontend/workspace/src/components/settings/Spend.tsx @@ -0,0 +1,217 @@ +// Spend — managed (Atlas wallet) vs bring-your-own-key, toggled independently +// for LLM inference and compute. Everything is wired to a real endpoint: +// • State + wallet → GET /settings/billing (client.settings.billing.get). +// • Toggles → PUT /settings/billing (client.settings.billing.update), which +// persists `billing.llm` / `billing.compute` in the global config AND keeps +// the account-scoped server billing mode in sync — one source of truth. +// • Buy credits → opens the Atlas top-up / checkout (URLS.dashboardCli). +import { Component, Show, createSignal, onMount, type JSX } from "solid-js" +import { Button } from "@synsci/ui/button" +import type { SettingsBillingGetResponse } from "@synsci/sdk/v2/client" +import { useGlobalSDK } from "@/context/global-sdk" +import { usePlatform } from "@/context/platform" +import { URLS } from "@/config/urls" +import { FONT_SANS } from "@/styles/tokens" + +type Billing = SettingsBillingGetResponse + +const money = (n: number) => `$${(n < 0 ? 0 : n).toFixed(n >= 100 ? 0 : 2)}` + +const LLM_MODES = [ + { + value: "managed" as const, + title: "Managed", + body: "LLM calls route through your Atlas wallet — metered credits, no API keys needed.", + }, + { + value: "byok" as const, + title: "BYOK", + body: "Your own provider keys or OAuth subscriptions (Claude Pro, ChatGPT, Copilot). Never billed here.", + }, + { + value: null, + title: "Auto", + body: "Detect from the credential backing each call — managed proxy tokens bill the wallet, your own keys don't.", + }, +] + +const COMPUTE_MODES = [ + { + value: "managed" as const, + title: "Managed", + body: "Atlas-provisioned GPUs, billed to your wallet.", + }, + { + value: "byok" as const, + title: "BYOK", + body: "Your own connected GPU providers (Settings → Compute). Your provider bills you directly.", + }, +] + +export default function Spend() { + const sdk = useGlobalSDK() + const platform = usePlatform() + + const [billing, setBilling] = createSignal() + const [error, setError] = createSignal() + const [busy, setBusy] = createSignal(false) + + const load = async () => { + const res = await sdk.client.settings.billing.get() + if (res.data) return setBilling(res.data) + setError("Couldn't load spend settings.") + } + onMount(() => void load()) + + const update = async (patch: { llm?: "managed" | "byok" | null; compute?: "managed" | "byok" }) => { + setBusy(true) + setError(undefined) + const res = await sdk.client.settings.billing.update(patch) + if (res.data) setBilling(res.data) + if (!res.data) setError("Couldn't save spend settings.") + setBusy(false) + } + + const wallet = () => billing()?.wallet + const balance = () => { + const w = wallet() + if (!w || !w.signedIn || w.balanceUsd < 0) return "—" + return money(w.balanceUsd) + } + + return ( +
+
+
+

Spend

+

+ Choose what runs on your Atlas wallet and what runs on your own keys — independently for LLM inference and + compute. +

+
+
+ +
+ +
+ {error()} +
+
+ + {/* Wallet */} +
+
+
+
+ Atlas session + + {wallet() ? (wallet()!.signedIn ? "Signed in" : "Signed out") : "—"} + +
+
+ Wallet balance + {balance()} +
+ +
+ +
+

+ Not connected to Atlas — run openscience login to use managed + spend. BYOK works without an account. +

+
+
+
+
+ + {/* LLM spend */} +
+
+ {LLM_MODES.map((mode) => ( + void update({ llm: mode.value })} + /> + ))} +
+
+ + {/* Compute spend */} +
+
+ {COMPUTE_MODES.map((mode) => ( + void update({ compute: mode.value })} + /> + ))} +
+
+
+
+ ) +} + +const Section: Component<{ title: string; description?: string; children: JSX.Element }> = (props) => ( +
+
+

{props.title}

+ +

{props.description}

+
+
+ {props.children} +
+) + +const ModeCard: Component<{ active: boolean; disabled: boolean; title: string; body: string; onClick: () => void }> = ( + props, +) => ( + +) diff --git a/frontend/workspace/src/components/settings/Usage.tsx b/frontend/workspace/src/components/settings/Usage.tsx index 6bcc09d..afc6d1b 100644 --- a/frontend/workspace/src/components/settings/Usage.tsx +++ b/frontend/workspace/src/components/settings/Usage.tsx @@ -195,7 +195,7 @@ export default function Usage() { > Managed calls debit this wallet. Top up any time — credits never expire. diff --git a/frontend/workspace/src/components/settings/registry.ts b/frontend/workspace/src/components/settings/registry.ts index 247f861..529d944 100644 --- a/frontend/workspace/src/components/settings/registry.ts +++ b/frontend/workspace/src/components/settings/registry.ts @@ -31,6 +31,7 @@ export type SettingsPanelId = | "network" | "permissions" | "credentials" + | "spend" | "storage" | "usage" | "general" @@ -60,6 +61,7 @@ export const SETTINGS_PANELS: SettingsPanel[] = [ // ── Workspace ── { id: "permissions", title: "Permissions", icon: "check", section: "workspace", component: lazy(() => import("./Permissions")) }, { id: "credentials", title: "Credentials", icon: "providers", section: "workspace", component: lazy(() => import("./Credentials")) }, + { id: "spend", title: "Spend", icon: "sliders", section: "workspace", component: lazy(() => import("./Spend")) }, { id: "storage", title: "Storage", icon: "folder", section: "workspace", component: lazy(() => import("./Storage")) }, { id: "usage", title: "Usage", icon: "bullet-list", section: "workspace", component: lazy(() => import("./Usage")) }, { id: "general", title: "General", icon: "settings-gear", section: "workspace", component: lazy(() => import("./General")) }, diff --git a/tooling/sdk/js/src/v2/gen/sdk.gen.ts b/tooling/sdk/js/src/v2/gen/sdk.gen.ts index d24db32..f5105d9 100644 --- a/tooling/sdk/js/src/v2/gen/sdk.gen.ts +++ b/tooling/sdk/js/src/v2/gen/sdk.gen.ts @@ -966,7 +966,7 @@ export class Billing extends HeyApiClient { */ public update( parameters?: { - llm?: "managed" | "byok" + llm?: "managed" | "byok" | null compute?: "managed" | "byok" }, options?: Options, diff --git a/tooling/sdk/js/src/v2/gen/types.gen.ts b/tooling/sdk/js/src/v2/gen/types.gen.ts index 617951e..2b7c2b4 100644 --- a/tooling/sdk/js/src/v2/gen/types.gen.ts +++ b/tooling/sdk/js/src/v2/gen/types.gen.ts @@ -1607,9 +1607,9 @@ export type Config = { */ billing?: { /** - * How LLM inference is paid for. 'managed' routes through the Atlas wallet (metered credits); 'byok' uses your own provider API keys or first-party OAuth (ChatGPT/Claude Pro/Copilot) and is never billed. Unset = auto-detect from the resolved credential. + * How LLM inference is paid for. 'managed' routes through the Atlas wallet (metered credits); 'byok' uses your own provider API keys or first-party OAuth (ChatGPT/Claude Pro/Copilot) and is never billed. Unset or null = auto-detect from the resolved credential. */ - llm?: "managed" | "byok" + llm?: "managed" | "byok" | null /** * How GPU/compute is paid for. 'managed' runs on Atlas-provisioned compute billed to your wallet (via the bundled atlas CLI); 'byok' uses your own connected GPU providers (Modal, Tinker, TensorPool, …). Unset = byok. */ @@ -3120,7 +3120,7 @@ export type SettingsBillingGetResponse = SettingsBillingGetResponses[keyof Setti export type SettingsBillingUpdateData = { body?: { - llm?: "managed" | "byok" + llm?: "managed" | "byok" | null compute?: "managed" | "byok" } path?: never