From 4c2c98f6db06518a33c9d0e23eb5708cb068d455 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 21 Oct 2026 15:43:39 -0500 Subject: [PATCH] Route managed Pi accounts through their selected provider Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTIONS.md | 5 ++-- maps/agent-access-map/runs-adapters.md | 6 ++++- packages/shared/src/ai-connections.ts | 11 +++++--- packages/shared/src/ai-provider-routing.ts | 7 ++--- server/src/__tests__/ai-connections.test.ts | 27 +++++++++++++++++++ .../src/__tests__/ai-provider-routing.test.ts | 21 +++++++++++++++ server/src/services/ai-connection-runtime.ts | 9 ++++--- server/src/services/ai-provider-routing.ts | 3 +++ .../useConnectionModels.test.tsx | 6 ++--- .../ai-connections/useConnectionModels.ts | 2 +- 10 files changed, 79 insertions(+), 18 deletions(-) diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 96289490ede..affb9ac7a39 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -25,7 +25,7 @@ The shared `AI_CONNECTION_CAPABILITIES` contract defines these combinations: | --- | --- | --- | | Claude / Anthropic | Claude subscription token or Anthropic API key | Claude | | OpenAI | ChatGPT/Codex subscription or OpenAI API key | Codex | -| OpenRouter (legacy, no routing metadata) | API key | OpenCode, with an `openrouter/` model | +| OpenRouter (legacy, no routing metadata) | API key | OpenCode or Pi, with an `openrouter/` model | | Google | API key | Gemini CLI | | Grok / xAI | Grok subscription or xAI API key | Grok | @@ -596,6 +596,7 @@ credentials belong to the connection; the model belongs to the agent. | Codex legacy and Codex New Runner (app-server) | OpenRouter; custom/local OpenAI Responses endpoints | | Claude legacy and Claude New Runner (ACPX) | OpenRouter; custom/local Anthropic Messages; Bedrock API key | | OpenCode legacy and New Runner | OpenRouter; custom/local Chat Completions | +| Pi legacy and New Runner | OpenRouter; native OpenAI, Anthropic, and Google API keys with a matching `provider/model` | | Hermes local | OpenRouter; custom/local Chat Completions | | Gemini CLI, Grok | Their native API connections; custom routes are not advertised | @@ -605,7 +606,7 @@ environment probes and task execution. The settings file contains no credential. OpenClaw Gateway, Hermes Gateway, Claude Managed, AWS AgentCore, Process, HTTP, and legacy `acpx_local` are excluded: external agents retain their own model -configuration, and `acpx_local` is retired. Cursor/Pi/Copilot custom routing, +configuration, and `acpx_local` is retired. Cursor/Copilot custom routing, Pi custom gateways and Bedrock, Vertex, ambient AWS identity, arbitrary authentication headers, and automatic catalog discovery for custom gateways are not part of this implementation. diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 84fab6cb950..23d452382f4 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -76,7 +76,11 @@ boundary, with explicit pinned-runtime setup required on the execution host. Native ACPX Pi is admitted with the generic flag off. Existing direct Pi agents remain Legacy; saved native policies remain authoritative. Incompatible thinking, launch, permission, or lifecycle settings return remediation on conversion. -This adds no agent permissions, cross-company access, or runtime installation API. +Managed Pi accounts accept OpenRouter routing and matching OpenAI, Anthropic, +Google, or OpenRouter API-key models. Subscription methods and custom gateways +remain excluded for Pi. Selection keeps the existing company, user, grant, and +agent-install checks; runtime preparation clears alternate credentials and rejects +custom Pi provider overrides. This adds no cross-company access or runtime installation API. An absent forced-Kubernetes environment is an unqualified sandbox target, never a local target. Discovery and automatic creation keep the Legacy path without provisioning it; explicit Paperclip selection returns qualification guidance. diff --git a/packages/shared/src/ai-connections.ts b/packages/shared/src/ai-connections.ts index 5d3097bb5dc..250eece6212 100644 --- a/packages/shared/src/ai-connections.ts +++ b/packages/shared/src/ai-connections.ts @@ -84,7 +84,7 @@ export const AI_CONNECTION_CAPABILITIES: Record< name: "GitHub Copilot", methods: { api_key: { adapters: ["copilot_runtime"], envKey: "COPILOT_GITHUB_TOKEN" } }, }, - google: { name: "Google", methods: { api_key: { adapters: ["gemini_local"], envKey: "GEMINI_API_KEY" } } }, + google: { name: "Google", methods: { api_key: { adapters: ["gemini_local", "pi_local"], envKey: "GEMINI_API_KEY" } } }, anthropic: { name: "Claude", methods: { @@ -92,20 +92,20 @@ export const AI_CONNECTION_CAPABILITIES: Record< adapters: ["claude_local"], envKey: "CLAUDE_CODE_OAUTH_TOKEN", }, - api_key: { adapters: ["claude_local"], envKey: "ANTHROPIC_API_KEY" }, + api_key: { adapters: ["claude_local", "pi_local"], envKey: "ANTHROPIC_API_KEY" }, }, }, openai: { name: "OpenAI", methods: { subscription: { adapters: ["codex_local"], envKey: "CODEX_HOME" }, - api_key: { adapters: ["codex_local"], envKey: "OPENAI_API_KEY" }, + api_key: { adapters: ["codex_local", "pi_local"], envKey: "OPENAI_API_KEY" }, }, }, openrouter: { name: "OpenRouter", methods: { - api_key: { adapters: ["opencode_local"], envKey: "OPENROUTER_API_KEY" }, + api_key: { adapters: ["opencode_local", "pi_local"], envKey: "OPENROUTER_API_KEY" }, }, }, xai: { @@ -127,6 +127,9 @@ export function isAiConnectionCompatible( if ("routing" in requirement && requirement.routing) return requirement.method === "api_key" && isAiRoutingCompatible(requirement.routing, adapterType); // A fixed binding contains identity only. The service checks authoritative // connection metadata before resolving credentials or running the harness. + if (adapterType === "pi_local") return (requirement.method === "api_key" || ("mode" in requirement && requirement.mode === "responsible_user")) + && ["openrouter", "openai", "anthropic", "google"].includes(requirement.provider) + && typeof model === "string" && model.startsWith(`${requirement.provider}/`); if ("mode" in requirement && requirement.mode !== "responsible_user" && requirement.method === "api_key") return ["claude_local", "codex_local", "opencode_local", "hermes_local", "gemini_local", "grok_local", "copilot_runtime"].includes(adapterType); const methods = AI_CONNECTION_CAPABILITIES[requirement.provider].methods; diff --git a/packages/shared/src/ai-provider-routing.ts b/packages/shared/src/ai-provider-routing.ts index ae508d4fff0..7095664442f 100644 --- a/packages/shared/src/ai-provider-routing.ts +++ b/packages/shared/src/ai-provider-routing.ts @@ -83,7 +83,7 @@ export function aiRoutingHarness( if (adapter !== "paperclip_runner") return adapter; if (provider === "acpx" && acpxAgent === "copilot") return "copilot_runtime"; const runner = - provider === "acpx" && (acpxAgent === "claude" || acpxAgent === "grok") + provider === "acpx" && ["claude", "grok", "pi"].includes(String(acpxAgent)) ? acpxAgent : provider; return ( @@ -93,6 +93,7 @@ export function aiRoutingHarness( codex: "codex_local", opencode: "opencode_local", grok: "grok_local", + pi: "pi_local", } as Record )[String(runner)] ?? "unsupported" ); @@ -107,6 +108,7 @@ export function isAiRoutingCompatible( "claude_local", "codex_local", "opencode_local", + "pi_local", "hermes_local", ].includes(harness); if (route.protocol === "responses") return harness === "codex_local"; @@ -122,13 +124,12 @@ export function aiRoutingBaseUrl( : (route.baseUrl ?? "").replace(/\/+$/, ""); } -/** OpenCode names models with a provider prefix; other harnesses use the raw ID. */ export function aiRoutingModel( route: AiProviderRouting, harness: string, model: string, ): string { - if (!model || harness !== "opencode_local") return model; + if (!model || (harness !== "opencode_local" && !(harness === "pi_local" && route.kind === "openrouter"))) return model; const prefix = route.kind === "openrouter" ? "openrouter" : "paperclip"; return model.startsWith(`${prefix}/`) ? model : `${prefix}/${model}`; } diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 49b9cd7d81d..b1599467775 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -73,6 +73,33 @@ beforeAll(async () => { afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); }); describe("managed AI connections", () => { + it.each([ + { provider: "openrouter", envKey: "OPENROUTER_API_KEY", routed: false }, + { provider: "openrouter", envKey: "OPENROUTER_API_KEY", routed: true }, + { provider: "openai", envKey: "OPENAI_API_KEY", routed: false }, + { provider: "anthropic", envKey: "ANTHROPIC_API_KEY", routed: false }, + { provider: "google", envKey: "GEMINI_API_KEY", routed: false }, + ] as const)("prepares native Pi with isolated $provider credentials (routing=$routed)", async ({ provider, envKey, routed }) => { + const owner = `pi-managed-${provider}-${routed}`; + await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); + const token = "fixture-pi-provider-key"; + const account = await service.save(companyId, owner, { provider, method: "api_key", ownership: "personal", name: owner, apiKey: token, allAgents: true, agentIds: [], + ...(routed ? { routing: { kind: "openrouter" as const, protocol: "chat" as const, auth: "bearer" as const, models: [] } } : {}) }, token); + if (!routed) await service.setDefault(companyId, owner, account.grantId); + const selected = routed + ? { provider, method: "api_key" as const, mode: "delegated" as const, ...account } + : { provider, method: "api_key" as const, mode: "responsible_user" as const }; + const piInput = { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner", binding: selected, + config: { provider: "acpx", acpxAgent: "pi", model: `${provider}/provider-model`, piThinkingLevel: "low", env: { OPENAI_API_KEY: "stale-openai", ANTHROPIC_API_KEY: "stale-anthropic" } } }; + const runtime = await prepareManagedAiRuntime(db, piInput); + try { + expect(runtime.config).toMatchObject({ provider: "acpx", acpxAgent: "pi", model: piInput.config.model, env: { OPENCODE_CONFIG_CONTENT: "", PAPERCLIP_PI_PROVIDERS: "" } }); + for (const key of ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "CODEX_API_KEY"]) expect(runtime.config.env[key]).toBe(key === envKey ? token : ""); + expect(runtime.attribution).toMatchObject({ connectionId: account.connectionId, grantId: account.grantId, provider }); + } finally { await runtime.cleanup(); } + await expect(prepareManagedAiRuntime(db, { ...piInput, config: { ...piInput.config, env: { PAPERCLIP_PI_PROVIDERS: "{}" } } })).rejects.toThrow("routing is incompatible"); + }); + it("defers a new plugin agent's Copilot check until its saved-agent lifecycle can acquire the computer", async () => { const { errorHandler } = await import("../middleware/index.js"); const owner = "copilot-plugin-bootstrap", pluginId = randomUUID(); diff --git a/server/src/__tests__/ai-provider-routing.test.ts b/server/src/__tests__/ai-provider-routing.test.ts index 4e46b0f80d3..c5f520a715d 100644 --- a/server/src/__tests__/ai-provider-routing.test.ts +++ b/server/src/__tests__/ai-provider-routing.test.ts @@ -1,12 +1,33 @@ import { describe, it, expect } from "vitest"; import { aiProviderRoutingSchema, + aiRoutingHarness, + aiRoutingModel, createAiConnectionSchema, isAiConnectionCompatible, } from "@paperclipai/shared"; import { managedProviderRouting } from "../services/ai-provider-routing.js"; describe("provider routing", () => { + it("routes managed OpenRouter accounts to direct and native Pi without OpenCode configuration", () => { + const routing = aiProviderRoutingSchema.parse({ kind: "openrouter", protocol: "chat" }); + expect(aiRoutingHarness("paperclip_runner", "acpx", "pi")).toBe("pi_local"); + for (const adapterType of ["pi_local", "paperclip_runner"]) { + expect(isAiConnectionCompatible({ provider: "openrouter", method: "api_key", routing }, adapterType, "openrouter/anthropic/model", "acpx", "pi")).toBe(true); + } + expect(aiRoutingModel(routing, "pi_local", "anthropic/model")).toBe("openrouter/anthropic/model"); + expect(aiRoutingModel(routing, "pi_local", "openrouter/anthropic/model")).toBe("openrouter/anthropic/model"); + const projected = managedProviderRouting(routing, "pi_local", "selected-pi-key", "anthropic/model"); + expect(projected).toMatchObject({ config: { model: "openrouter/anthropic/model" }, env: { OPENROUTER_API_KEY: "selected-pi-key" } }); + expect(projected.env).not.toHaveProperty("OPENCODE_CONFIG_CONTENT"); + expect(isAiConnectionCompatible({ provider: "openai", method: "api_key", routing: aiProviderRoutingSchema.parse({ kind: "gateway", protocol: "chat", baseUrl: "https://gateway.example/v1" }) }, "pi_local", "custom/model")).toBe(false); + }); + it.each(["openai", "anthropic", "google", "openrouter"] as const)("accepts Pi %s API keys only for matching models", provider => { + expect(isAiConnectionCompatible({ provider, method: "api_key" }, "paperclip_runner", `${provider}/model`, "acpx", "pi")).toBe(true); + expect(isAiConnectionCompatible({ provider, method: "subscription" }, "pi_local", `${provider}/model`)).toBe(false); + expect(isAiConnectionCompatible({ provider, method: "subscription", mode: "responsible_user" }, "pi_local", `${provider}/model`)).toBe(true); + expect(isAiConnectionCompatible({ provider, method: "api_key" }, "pi_local", "different/model")).toBe(false); + }); it.each([ "https://user:secret@gateway.example/v1", "https://gateway.example/v1?key=secret", diff --git a/server/src/services/ai-connection-runtime.ts b/server/src/services/ai-connection-runtime.ts index ca89b1621c1..2620e8cbb53 100644 --- a/server/src/services/ai-connection-runtime.ts +++ b/server/src/services/ai-connection-runtime.ts @@ -53,6 +53,7 @@ export const AI_AUTH_ENV_KEYS = [ "OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR", "PAPERCLIP_OPENCODE_PROVIDERS", + "PAPERCLIP_PI_PROVIDERS", "ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "XAI_BASE_URL", @@ -247,7 +248,7 @@ export async function prepareManagedAiRuntime( "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "PAPERCLIP_OPENCODE_PROVIDERS", - "PAPERCLIP_AI_PROVIDER_URL", "PAPERCLIP_CODEX_PROVIDERS", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR", "GOOGLE_GEMINI_BASE_URL", "ANTHROPIC_BEDROCK_BASE_URL", + "PAPERCLIP_AI_PROVIDER_URL", "PAPERCLIP_CODEX_PROVIDERS", "PAPERCLIP_PI_PROVIDERS", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR", "GOOGLE_GEMINI_BASE_URL", "ANTHROPIC_BEDROCK_BASE_URL", ]) { if (configuredEnv[key]) throw unprocessable( @@ -339,7 +340,7 @@ export async function prepareManagedAiRuntime( if (!routing && subscriptionFile) await writeFile(authFile, value, { mode: 0o600 }); else if (!routing) env[capability.envKey] = value; if ( - !routing && input.binding.provider === "openai" && + !routing && input.binding.provider === "openai" && harness === "codex_local" && selection.attribution.method === "api_key" ) { env.CODEX_API_KEY = value; @@ -347,13 +348,13 @@ export async function prepareManagedAiRuntime( mode: 0o600, }); } - if (!routing && input.binding.provider === "openrouter") { + if (!routing && input.binding.provider === "openrouter" && harness === "opencode_local") { env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ provider: { openrouter: { options: { apiKey: value, headers: OPENROUTER_ATTRIBUTION_HEADERS } } }, }); env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; } - if (!routing && input.binding.provider === "google") { + if (!routing && input.binding.provider === "google" && harness === "gemini_local") { // Gemini headless CLI requires an explicit auth choice even with an API key. // Seed only the disposable connection home, never the operator's settings. const geminiHome = path.join(home, ".gemini"); diff --git a/server/src/services/ai-provider-routing.ts b/server/src/services/ai-provider-routing.ts index 22f86d6063b..5efc70561bc 100644 --- a/server/src/services/ai-provider-routing.ts +++ b/server/src/services/ai-provider-routing.ts @@ -42,6 +42,9 @@ export function managedProviderRouting( env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model; env.CLAUDE_CODE_SUBAGENT_MODEL = model; } + } else if (harness === "pi_local" && route.kind === "openrouter") { + env.OPENROUTER_API_KEY = credential; + config.model = aiRoutingModel(route, harness, model); } else if (harness === "opencode_local") { const provider = route.kind === "openrouter" ? "openrouter" : "paperclip"; if (route.kind === "openrouter") env.OPENROUTER_API_KEY = credential; diff --git a/ui/src/components/ai-connections/useConnectionModels.test.tsx b/ui/src/components/ai-connections/useConnectionModels.test.tsx index ac575945f54..2df022de52e 100644 --- a/ui/src/components/ai-connections/useConnectionModels.test.tsx +++ b/ui/src/components/ai-connections/useConnectionModels.test.tsx @@ -49,13 +49,13 @@ async function mount(accounts: AiManagedConnectionSummary[] = [account], harness await render(value); } -it.each(["opencode_local", "codex_local", "claude_local", "hermes_local"])("automatically discovers an empty OpenRouter connection's models for %s", async harness => { +it.each(["opencode_local", "pi_local", "codex_local", "claude_local", "hermes_local"])("automatically discovers an empty OpenRouter connection's models for %s", async harness => { await mount([account], harness); await vi.waitFor(() => expect(result?.models).toHaveLength(3)); expect(agentsApi.adapterModels).toHaveBeenCalledWith("company", "opencode_local", { provider: "openrouter" }); - expect(result?.models.map(m => m.id)).toEqual(catalog.map(m => harness === "opencode_local" ? m.id : m.id.slice("openrouter/".length))); + expect(result?.models.map(m => m.id)).toEqual(catalog.map(m => ["opencode_local", "pi_local"].includes(harness) ? m.id : m.id.slice("openrouter/".length))); expect(result?.models.map(m => m.label)).toEqual(catalog.map(m => m.label)); - expect(result?.resolveModel("anthropic/custom-model")).toBe(harness === "opencode_local" ? "openrouter/anthropic/custom-model" : "anthropic/custom-model"); + expect(result?.resolveModel("anthropic/custom-model")).toBe(["opencode_local", "pi_local"].includes(harness) ? "openrouter/anthropic/custom-model" : "anthropic/custom-model"); }); it("also discovers models for legacy OpenRouter connections without routing metadata", async () => { diff --git a/ui/src/components/ai-connections/useConnectionModels.ts b/ui/src/components/ai-connections/useConnectionModels.ts index 1173a8e8994..0e53b0842bd 100644 --- a/ui/src/components/ai-connections/useConnectionModels.ts +++ b/ui/src/components/ai-connections/useConnectionModels.ts @@ -36,7 +36,7 @@ export function useConnectionModels( ? { kind: "openrouter", protocol: "chat", auth: "bearer", models: [] } : undefined); const modelOptions = discover - ? (catalog.data ?? []).map((m) => ({ ...m, id: harness === "opencode_local" ? m.id : m.id.replace(/^openrouter\//, "") })) + ? (catalog.data ?? []).map((m) => ({ ...m, id: ["opencode_local", "pi_local"].includes(harness) ? m.id : m.id.replace(/^openrouter\//, "") })) : routing?.models ?? []; return effectiveRouting ? {