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
45 changes: 40 additions & 5 deletions backend/cli/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,16 @@ export namespace Provider {
// managed token and 401 the proxy ("thk_* token not found"). Returns {} unless
// both managed spend is on AND the baseURL is the Atlas proxy — genuine BYOK
// (no proxy URL) is untouched and hits the provider directly.
async function managedProxyKey(baseURL: unknown): Promise<{ apiKey?: string }> {
async function managedProxyKey(providerID: string, baseURL: unknown): Promise<{ apiKey?: string }> {
const managed =
isAtlasProxyBaseURL(baseURL) && (await Config.get().catch(() => undefined))?.billing?.llm === "managed"
if (!managed) return {}
// Managed wallet routes through OpenRouter ONLY: never attach the wallet's
// thk_ token to a first-party proxy (anthropic / openai / google). Those
// providers are also dropped from availability (see isProviderAllowed), so
// this is belt-and-suspenders — a managed token can only ever reach the
// sanctioned OpenRouter (or hosted synsci) route.
if (!managedProviderAllowed(providerID)) return {}
const session = await OpenScience.getSession().catch(() => null)
return session?.api_key ? { apiKey: session.api_key } : {}
}
Expand Down Expand Up @@ -124,6 +130,26 @@ export namespace Provider {
return typeof key === "string" && key.length > 0 && key !== "public" && !isAtlasApiKey(key)
}

/** Managed wallet ⇒ OpenRouter-only routing.
*
* When the LLM spend toggle is explicitly "managed", every wallet inference
* call flows through OpenRouter — the one gateway whose stream exposes a
* single, unified reasoning format (`reasoning` / `reasoning_details`). The
* first-party managed proxies (anthropic / openai / google), each with a
* different reasoning shape, are taken out of the managed path entirely; the
* hosted zero-cost `synsci` demo provider is kept. BYOK and the legacy
* auto-detect path (`billing.llm` unset / null / "byok") are UNTOUCHED —
* this only fires on an explicit managed-wallet opt-in. Pure + sync. */
export function managedRoutesOpenRouterOnly(config: Config.Info): boolean {
return config.billing?.llm === "managed"
}

/** Providers a managed (OpenRouter-only) wallet session may load: OpenRouter
* for all real inference, plus the hosted `synsci` demo. Pure + sync. */
export function managedProviderAllowed(providerID: string): boolean {
return providerID === "openrouter" || providerID.startsWith("synsci")
}

/**
* Blocker (c) — the inverse of requireAtlasProxyForManagedKey.
*
Expand Down Expand Up @@ -162,7 +188,7 @@ export namespace Provider {
return {
autoload: false,
options: {
...(baseURL ? { baseURL, ...(await managedProxyKey(baseURL)) } : {}),
...(baseURL ? { baseURL, ...(await managedProxyKey("anthropic", baseURL)) } : {}),
headers: {
"anthropic-beta":
"claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
Expand Down Expand Up @@ -199,7 +225,7 @@ export namespace Provider {
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.responses(modelID)
},
options: baseURL ? { baseURL, ...(await managedProxyKey(baseURL)) } : {},
options: baseURL ? { baseURL, ...(await managedProxyKey("openai", baseURL)) } : {},
}
},
"github-copilot": async () => {
Expand Down Expand Up @@ -405,7 +431,7 @@ export namespace Provider {
return {
autoload: false,
options: {
...(baseURL ? { baseURL, ...(await managedProxyKey(baseURL)) } : {}),
...(baseURL ? { baseURL, ...(await managedProxyKey("openrouter", baseURL)) } : {}),
headers: {
"HTTP-Referer": "https://syntheticsciences.ai/",
"X-Title": "synsci",
Expand Down Expand Up @@ -598,7 +624,7 @@ export namespace Provider {
const baseURL = Env.get("GOOGLE_GENERATIVE_AI_BASE_URL") ?? Env.get("GEMINI_BASE_URL")
return {
autoload: false,
options: baseURL ? { baseURL, ...(await managedProxyKey(baseURL)) } : {},
options: baseURL ? { baseURL, ...(await managedProxyKey("google", baseURL)) } : {},
}
},
}
Expand Down Expand Up @@ -838,8 +864,17 @@ export namespace Provider {

const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
// Managed wallet ⇒ OpenRouter-only. Drop every other provider (the
// first-party managed proxies included) from a managed session so wallet
// inference can only flow through OpenRouter's unified reasoning stream,
// plus the hosted zero-cost demo. Gated on the explicit toggle, so BYOK and
// legacy auto-detect sessions see every provider exactly as before. This is
// the single seam that makes defaultModel()/getSmallModel() managed-safe:
// both read the filtered state, so they can only resolve openrouter/synsci.
const managedOpenRouterOnly = managedRoutesOpenRouterOnly(config)

function isProviderAllowed(providerID: string): boolean {
if (managedOpenRouterOnly && !managedProviderAllowed(providerID)) return false
if (enabled && !enabled.has(providerID)) return false
if (disabled.has(providerID)) return false
return true
Expand Down
149 changes: 149 additions & 0 deletions backend/cli/test/provider/managed-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { test, expect, describe, mock } from "bun:test"

// Mock BunProc + default auth plugins so importing Provider never shells out or
// hits the network (mirrors test/provider/provider.test.ts).
mock.module("../../src/bun/index", () => ({
BunProc: {
install: async (pkg: string, _version?: string) => {
const at = pkg.lastIndexOf("@")
return at > 0 ? pkg.substring(0, at) : pkg
},
run: async () => {
throw new Error("BunProc.run should not be called in tests")
},
which: () => process.execPath,
InstallFailedError: class extends Error {},
},
}))
const mockPlugin = () => ({})
mock.module("openscience-copilot-auth", () => ({ default: mockPlugin }))
mock.module("openscience-anthropic-auth", () => ({ default: mockPlugin }))
mock.module("@gitlab/openscience-gitlab-auth", () => ({ default: mockPlugin }))

import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { Env } from "../../src/env"

function clearManagedLLMEnv() {
for (const key of [
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"GOOGLE_GENERATIVE_AI_API_KEY",
"GOOGLE_GENERATIVE_AI_BASE_URL",
"GEMINI_API_KEY",
"GEMINI_BASE_URL",
"OPENROUTER_API_KEY",
"OPENROUTER_BASE_URL",
]) {
Env.remove(key)
}
}

const PROXY = "https://atlas.test/api/llm/proxy"

// ── Pure decision helpers ────────────────────────────────────────────────────

describe("Provider.managedRoutesOpenRouterOnly (pure)", () => {
test("true only when billing.llm === 'managed'", () => {
expect(Provider.managedRoutesOpenRouterOnly({ billing: { llm: "managed" } } as any)).toBe(true)
})
test("false for explicit byok", () => {
expect(Provider.managedRoutesOpenRouterOnly({ billing: { llm: "byok" } } as any)).toBe(false)
})
test("false for auto-detect (unset / empty billing / null)", () => {
expect(Provider.managedRoutesOpenRouterOnly({} as any)).toBe(false)
expect(Provider.managedRoutesOpenRouterOnly({ billing: {} } as any)).toBe(false)
expect(Provider.managedRoutesOpenRouterOnly({ billing: { llm: null } } as any)).toBe(false)
})
})

describe("Provider.managedProviderAllowed (pure)", () => {
test("OpenRouter and the hosted synsci demo are the only allowed providers", () => {
expect(Provider.managedProviderAllowed("openrouter")).toBe(true)
expect(Provider.managedProviderAllowed("synsci")).toBe(true)
expect(Provider.managedProviderAllowed("synsci-hosted")).toBe(true)
})
test("first-party managed proxies + everything else are rejected", () => {
for (const id of ["anthropic", "openai", "google", "openai-codex", "github-copilot", "gateway", "azure"]) {
expect(Provider.managedProviderAllowed(id)).toBe(false)
}
})
})

// ── Availability filter (hermetic, catalog-backed) ───────────────────────────

describe("managed session availability", () => {
test("managed ⇒ only OpenRouter (+ demo) load; first-party proxies are dropped", async () => {
await using tmp = await tmpdir({ config: { billing: { llm: "managed" } } })
await Instance.provide({
directory: tmp.path,
init: async () => {
clearManagedLLMEnv()
Env.set("ANTHROPIC_API_KEY", "thk_anthropic")
Env.set("ANTHROPIC_BASE_URL", `${PROXY}/anthropic/v1`)
Env.set("OPENAI_API_KEY", "thk_openai")
Env.set("OPENAI_BASE_URL", `${PROXY}/openai/v1`)
Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "thk_google")
Env.set("GOOGLE_GENERATIVE_AI_BASE_URL", `${PROXY}/gemini/v1beta`)
Env.set("OPENROUTER_API_KEY", "thk_openrouter")
Env.set("OPENROUTER_BASE_URL", `${PROXY}/openrouter/v1`)
Provider.invalidate()
},
fn: async () => {
const providers = await Provider.list()
expect(providers["openrouter"]).toBeDefined()
expect(providers["openrouter"].options.baseURL).toBe(`${PROXY}/openrouter/v1`)
expect(providers["anthropic"]).toBeUndefined()
expect(providers["openai"]).toBeUndefined()
expect(providers["google"]).toBeUndefined()
},
})
})

test("BYOK (managed off): anthropic keeps its public endpoint and no wallet token", async () => {
await using tmp = await tmpdir({ config: { billing: { llm: "byok" } } })
await Instance.provide({
directory: tmp.path,
init: async () => {
clearManagedLLMEnv()
Env.set("ANTHROPIC_API_KEY", "sk-ant-byok-key")
Provider.invalidate()
},
fn: async () => {
const providers = await Provider.list()
const anthropic = providers["anthropic"]
expect(anthropic).toBeDefined()
// No Atlas proxy baseURL injected → getSDK falls back to the public
// model.api.url; the BYOK key is the only credential.
expect(anthropic.options.baseURL).toBeUndefined()
expect(anthropic.options.apiKey).toBeUndefined()
expect(anthropic.key).toBe("sk-ant-byok-key")
},
})
})

test("legacy auto-detect (thk_ present, billing.llm unset) is unchanged — proxies still load", async () => {
await using tmp = await tmpdir({ config: {} })
await Instance.provide({
directory: tmp.path,
init: async () => {
clearManagedLLMEnv()
Env.set("ANTHROPIC_API_KEY", "thk_anthropic")
Env.set("ANTHROPIC_BASE_URL", `${PROXY}/anthropic/v1`)
Env.set("OPENROUTER_API_KEY", "thk_openrouter")
Env.set("OPENROUTER_BASE_URL", `${PROXY}/openrouter/v1`)
Provider.invalidate()
},
fn: async () => {
const providers = await Provider.list()
// Gating is scoped to the explicit toggle: without it, nothing is dropped.
expect(providers["anthropic"]).toBeDefined()
expect(providers["anthropic"].options.baseURL).toBe(`${PROXY}/anthropic/v1`)
expect(providers["openrouter"]).toBeDefined()
},
})
})
})
Loading