Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
6 changes: 6 additions & 0 deletions backend/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {})
})
Expand Down
6 changes: 5 additions & 1 deletion backend/cli/src/server/routes/settings/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ export const BillingState = z.object({
})
export type BillingState = z.infer<typeof BillingState>

// `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(),
})

Expand Down Expand Up @@ -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) }),
Expand Down
111 changes: 109 additions & 2 deletions backend/cli/src/server/routes/settings/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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")
Expand All @@ -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<string> {
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.
Expand Down Expand Up @@ -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<string, string[]> = {
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<string, string> {
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<string>()

/** 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<void> {
try {
const stored = await read()
const env: Record<string, string> = {}
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) => {
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions backend/cli/src/session/billing-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BillingMode | undefined> {
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). */
Expand Down
17 changes: 17 additions & 0 deletions backend/cli/test/server/settings-billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
80 changes: 80 additions & 0 deletions backend/cli/test/server/settings-compute.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading
Loading