mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
Route managed Pi accounts through their selected provider
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
046d8c2963
commit
4c2c98f6db
10 files changed
+79
-18
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
Reference in new issue
Block a user