From 012a806362b5efbf1a576dfe097d7499acfbd0b6 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 5 May 2026 21:19:06 +0800 Subject: [PATCH] feat(proxy): fall back to cloud-cache provider catalog when getProvider misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap surfaced by the M2 E2E walkthrough: when a user configured a provider on thomas-cloud but hadn't run \`thomas providers register \` locally, the proxy returned "Unknown provider" even though the cloud snapshot fully described how to reach it. Result: the user had to register the same provider in two places. Now the lookup is local-first, cloud-as-fallback: attempt() 1. getProvider(id) ← builtins + ~/.thomas/providers.json (existing) 2. loadProviderFromCloudCache(id) ← reads cloud-cache.providers (new) 3. neither → 503 unknown_provider Privacy boundary unchanged: this is metadata only (originBaseUrl, protocol). **Credentials NEVER come from cloud** — they always live in ~/.thomas/credentials.json. If the user binds an agent to a cloud provider without a local key, they still get 503 "no credentials for X" — but with a clear remediation pointing at \`thomas providers add \` and noting the provider came from cloud (not the legacy "unknown provider" message that was effectively a dead end). Local-first ordering means an explicit \`thomas providers register\` still wins over a cloud snapshot of the same id — the user's machine remains authoritative for endpoints they care to lock in. Adds: src/cloud/providers.ts loadProviderFromCloudCache() tests/cloud-provider-fallback.test.ts - cloud-only provider with local key → 200, request goes through - cloud-only provider without local key → 502, "delivered from cloud" hint includes \`thomas providers add\` command - same id in both stores → local wins (cloud not hit) 266/266 tests pass; build 187 KB. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cloud/providers.ts | 47 +++++ src/proxy/server.ts | 31 +++- tests/cloud-provider-fallback.test.ts | 247 ++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 src/cloud/providers.ts create mode 100644 tests/cloud-provider-fallback.test.ts diff --git a/src/cloud/providers.ts b/src/cloud/providers.ts new file mode 100644 index 0000000..47af17d --- /dev/null +++ b/src/cloud/providers.ts @@ -0,0 +1,47 @@ +// Bridge: surface provider metadata from the cloud-cache so the proxy can +// route to providers configured ONLY on thomas-cloud (not yet registered +// locally via `thomas providers register`). +// +// Privacy boundary unchanged: this returns *metadata only* (origin URL, +// protocol). Credentials NEVER come from the cloud — they stay in the local +// `~/.thomas/credentials.json`. If the user binds an agent to a cloud +// provider for which they haven't `thomas providers add `'d a key +// locally, the proxy still returns a credential-missing error — just with a +// clearer remediation message than the legacy "unknown provider" 503. + +import type { ProviderSpec } from "../providers/registry.js"; +import { readCache } from "./cache.js"; +import type { Protocol } from "../agents/types.js"; + +type WireProvider = { + providerId: string; + protocol: string; + originBaseUrl?: string | null; + isBuiltin?: boolean; +}; + +/** + * Look up `providerId` in the cloud cache's `providers[]` and return a + * ProviderSpec if found. Returns undefined when there's no cache, the + * provider isn't in it, or its protocol is something we don't understand. + * + * Caller is expected to have already checked the local registry / store — + * this is a fallback path, not a replacement. + */ +export async function loadProviderFromCloudCache( + providerId: string, +): Promise { + const snapshot = await readCache(); + const wire = (snapshot.providers as WireProvider[]).find( + (p) => p.providerId === providerId, + ); + if (!wire) return undefined; + if (wire.protocol !== "openai" && wire.protocol !== "anthropic") return undefined; + if (!wire.originBaseUrl) return undefined; + return { + id: wire.providerId, + protocol: wire.protocol as Protocol, + originBaseUrl: wire.originBaseUrl, + custom: !wire.isBuiltin, + }; +} diff --git a/src/proxy/server.ts b/src/proxy/server.ts index fc188f5..acf8e4c 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -6,6 +6,7 @@ import { findCredential, resolveSecret } from "../config/credentials.js"; import { paths } from "../config/paths.js"; import { getRoute } from "../config/routes.js"; import { getAgent } from "../agents/registry.js"; +import { loadProviderFromCloudCache } from "../cloud/providers.js"; import { getProvider, type ProviderSpec } from "../providers/registry.js"; import type { AgentId, AgentSpec, Protocol } from "../agents/types.js"; import { decideForAgent } from "../policy/decide.js"; @@ -238,12 +239,36 @@ async function attempt(params: { inboundPath: string; req: IncomingMessage; }): Promise { - const provider = await getProvider(params.target.provider); + // Local registry first (builtins + ~/.thomas/providers.json). Falls back to + // the cloud-cache snapshot — covers the case where the user configured a + // provider on thomas-cloud but hasn't run `thomas providers register` locally. + // Credential lookup is unchanged: keys NEVER come from cloud, only from + // ~/.thomas/credentials.json. + let provider = await getProvider(params.target.provider); + let providerSource: "local" | "cloud" = "local"; if (!provider) { - return { ok: false, status: 503, reply: `Unknown provider ${params.target.provider}` }; + provider = await loadProviderFromCloudCache(params.target.provider); + if (provider) providerSource = "cloud"; + } + if (!provider) { + return { + ok: false, + status: 503, + reply: `Unknown provider ${params.target.provider}`, + }; } const cred = await findCredential(provider.id); - if (!cred) return { ok: false, status: 503, reply: `No credentials for provider ${provider.id}` }; + if (!cred) { + const hint = + providerSource === "cloud" + ? ` Provider was delivered from thomas-cloud; add a local key with \`thomas providers add ${provider.id} \`.` + : ""; + return { + ok: false, + status: 503, + reply: `No credentials for provider ${provider.id}.${hint}`, + }; + } const secret = resolveSecret(cred); if (!secret) { return { ok: false, status: 503, reply: `Could not resolve secret for ${provider.id}` }; diff --git a/tests/cloud-provider-fallback.test.ts b/tests/cloud-provider-fallback.test.ts new file mode 100644 index 0000000..64f4502 --- /dev/null +++ b/tests/cloud-provider-fallback.test.ts @@ -0,0 +1,247 @@ +// When a provider is configured ONLY on thomas-cloud (cloud-cache.providers +// has it, but the local user hasn't `thomas providers register`'d it), the +// proxy should still be able to reach it. Credential lookup stays local — +// "no key" still 503s, but with a clearer remediation hint. + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { createServer, type Server } from "node:http"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { writeCache } from "../src/cloud/cache.js"; +import type { CloudSnapshot } from "../src/cloud/types.js"; +import { recordConnect } from "../src/config/agents.js"; +import { upsertCredential } from "../src/config/credentials.js"; +import { setRoute } from "../src/config/routes.js"; +import { startServer } from "../src/proxy/server.js"; + +let dir: string; +const ORIG_THOMAS_HOME = process.env.THOMAS_HOME; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "thomas-cloud-prov-")); + process.env.THOMAS_HOME = dir; +}); + +afterEach(async () => { + if (ORIG_THOMAS_HOME !== undefined) process.env.THOMAS_HOME = ORIG_THOMAS_HOME; + else delete process.env.THOMAS_HOME; + await rm(dir, { recursive: true, force: true }); +}); + +function listen(server: Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const a = server.address(); + if (a && typeof a !== "string") resolve(a.port); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((r) => server.close(() => r())); +} + +function snapshot(partial: Partial): CloudSnapshot { + return { + schemaVersion: 1, + policies: [], + bundles: [], + bindings: [], + providers: [], + redactRulesVersion: null, + syncedAt: new Date().toISOString(), + ...partial, + }; +} + +describe("proxy: cloud-only provider fallback", () => { + it("forwards to a provider that exists ONLY in cloud-cache (not in local providers.json)", async () => { + let upstreamHits = 0; + let upstreamAuth = ""; + const upstream = createServer((req, res) => { + upstreamHits += 1; + upstreamAuth = String(req.headers.authorization ?? ""); + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + id: "chatcmpl-x", + object: "chat.completion", + choices: [ + { index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + ); + }); + const upstreamPort = await listen(upstream); + + // Cloud-cache says "cloudonly" provider exists at this URL with openai protocol. + // We deliberately do NOT call providers.register("cloudonly") locally. + await writeCache( + snapshot({ + providers: [ + { + providerId: "cloudonly", + protocol: "openai", + originBaseUrl: `http://127.0.0.1:${upstreamPort}/v1`, + isBuiltin: false, + }, + ], + }) as unknown as CloudSnapshot, + ); + + // Local: cred for "cloudonly" lives here. Privacy boundary unchanged. + await upsertCredential({ provider: "cloudonly", type: "api_key", key: "test-key" }); + + // Connected agent + route → cloudonly. Cloud could also drive this via a + // binding, but the route fallback is enough to exercise the lookup path. + await recordConnect("claude-code", { + shimPath: "", + originalBinary: "/usr/bin/claude", + connectedAt: new Date().toISOString(), + token: "thomas-claude-code-test-token", + }); + await setRoute("claude-code", { provider: "cloudonly", model: "anything" }); + + const server = await startServer(0); + const port = (server.address() as { port: number }).port; + + try { + const resp = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer thomas-claude-code-test-token", + }, + body: JSON.stringify({ + model: "anything", + messages: [{ role: "user", content: "hi" }], + }), + }); + + expect(resp.status).toBe(200); + expect(upstreamHits).toBe(1); + // The proxy uses our LOCAL key when forwarding upstream — not anything + // from cloud (which never sees keys). + expect(upstreamAuth).toBe("Bearer test-key"); + } finally { + await close(server); + await close(upstream); + } + }); + + it("returns a clear remediation when cloud provider has no local credential", async () => { + await writeCache( + snapshot({ + providers: [ + { + providerId: "cloud-no-key", + protocol: "openai", + originBaseUrl: "http://example.invalid/v1", + isBuiltin: false, + }, + ], + }) as unknown as CloudSnapshot, + ); + // NO upsertCredential call → local key missing. + + await recordConnect("claude-code", { + shimPath: "", + originalBinary: "/usr/bin/claude", + connectedAt: new Date().toISOString(), + token: "thomas-tok-nokey", + }); + await setRoute("claude-code", { provider: "cloud-no-key", model: "x" }); + + const server = await startServer(0); + const port = (server.address() as { port: number }).port; + try { + const resp = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer thomas-tok-nokey", + }, + body: JSON.stringify({ model: "x", messages: [{ role: "user", content: "hi" }] }), + }); + // proxy wraps internal 503s as 502 (bad gateway) externally + expect(resp.status).toBe(502); + const text = await resp.text(); + expect(text).toContain("No credentials for provider cloud-no-key"); + // The cloud-only path adds a remediation hint that ordinary local + // missing-cred 503s don't have: + expect(text).toContain("delivered from thomas-cloud"); + expect(text).toContain("thomas providers add cloud-no-key"); + } finally { + await close(server); + } + }); + + it("local providers.json wins over cloud-cache when both have the same id", async () => { + let cloudHits = 0; + let localHits = 0; + const cloud = createServer((_req, res) => { + cloudHits += 1; + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"choices":[{"index":0,"message":{"role":"assistant","content":""}}],"usage":{}}'); + }); + const local = createServer((_req, res) => { + localHits += 1; + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"choices":[{"index":0,"message":{"role":"assistant","content":""}}],"usage":{}}'); + }); + const cloudPort = await listen(cloud); + const localPort = await listen(local); + + // Both cloud-cache + local providers.json claim "shared" — local should win. + await writeCache( + snapshot({ + providers: [ + { + providerId: "shared", + protocol: "openai", + originBaseUrl: `http://127.0.0.1:${cloudPort}/v1`, + isBuiltin: false, + }, + ], + }) as unknown as CloudSnapshot, + ); + + const { registerCustom } = await import("../src/providers/registry.js"); + await registerCustom({ + id: "shared", + protocol: "openai", + originBaseUrl: `http://127.0.0.1:${localPort}/v1`, + }); + await upsertCredential({ provider: "shared", type: "api_key", key: "k" }); + + await recordConnect("claude-code", { + shimPath: "", + originalBinary: "/usr/bin/claude", + connectedAt: new Date().toISOString(), + token: "thomas-tok-shared", + }); + await setRoute("claude-code", { provider: "shared", model: "m" }); + + const server = await startServer(0); + const port = (server.address() as { port: number }).port; + try { + await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer thomas-tok-shared", + }, + body: JSON.stringify({ model: "m", messages: [{ role: "user", content: "hi" }] }), + }); + expect(localHits).toBe(1); + expect(cloudHits).toBe(0); + } finally { + await close(server); + await close(cloud); + await close(local); + } + }); +});