Route managed Pi accounts through their selected provider

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-21 15:43:39 -05:00
1 parent 046d8c2963
commit 4c2c98f6db
10 files changed
+79 -18

No files matched your search

+3 -2
View File
@@ -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.
+5 -1
View File
@@ -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.
+7 -4
View File
@@ -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;
+4 -3
View File
@@ -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, string>
)[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}`;
}
@@ -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();
@@ -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",
+5 -4
View File
@@ -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");
@@ -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;
@@ -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 () => {
@@ -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
? {