Add connection-owned model provider routing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-02 17:47:32 -05:00
1 parent 6eaf218924
commit 304af99040
83 files changed
+5796 -359

No files matched your search

+83 -5
View File
@@ -20,7 +20,8 @@ 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 | API key | OpenCode, with an `openrouter/` model |
| OpenRouter (legacy, no routing metadata) | API key | OpenCode, with an `openrouter/` model |
| Google | API key | Gemini CLI |
| Grok / xAI | Grok subscription or xAI API key | Grok |
Native runner supports the corresponding existing Codex, OpenCode, and Claude
@@ -38,9 +39,9 @@ and caller-supplied validation URLs are rejected.
- `responsible_user`: resolve the run's responsible user's personal provider default, using that account's subscription or API key. The `method` hint does not restrict the responsible user's account.
- `shared`: use the named `connectionId` and `grantId`, with audience and agent
access checks.
- `delegated`: retained only to read legacy bindings. It cannot bypass human
access; a personal credential remains available only for its owner's tasks.
New configuration offers personal defaults or shared accounts.
- `delegated`: an explicit personal account selection (the wire name is retained
for compatibility). It cannot bypass human access; a personal credential
remains available only for its owner’s tasks.
“Which humans can use this credential?” is the sole permission for whose work
can use the account. “Just me” means the personal owner; shared accounts allow
@@ -48,7 +49,8 @@ selected company members or every company member. The separate agent-access
setting determines which agents can use it. There is no additional AI agent
authorization, and old delegation records do not override the human audience.
A connection choice never changes the harness, model, or provider routing.
A connection choice never changes the harness or model. For a routed connection,
the selected connection owns its provider routing.
Changing those separately may make a binding incompatible; saving then requires
a compatible choice. Agent configuration cannot grant access to another account.
@@ -512,3 +514,79 @@ identity, a different grant or responsible user, or a changed credential generat
requires a fresh session. The metadata is removed before passing session params
to an adapter. Temporary authentication-home paths do not change the configuration
fingerprint. These checks do not relax current connection authorization.
## Advanced provider routing (2026-10-02)
Use **Connectors → Connect a model provider → Advanced providers** to add an
OpenRouter, Bedrock, custom gateway (including Emissary), or local connection.
The native subscription/API-key onboarding remains the default. Provider choices
show the existing local brand artwork and reuse the existing access step. Custom
URLs, protocol, AWS region, and credential fields appear only after choosing an
advanced provider. New-agent setup also offers the connection picker under
**Use another connection**.
At **Agents → [agent] → Harness / Runtime**, **Connection** is a dropdown of
compatible saved connections, including explicit personal accounts. The existing
model picker uses that connection’s optional model IDs and accepts manual IDs.
Changing connections preserves the model for explicit review. URLs and
credentials belong to the connection; the model belongs to the agent.
| Harness | Implemented managed routes |
| --- | --- |
| 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 or AWS access keys with optional session token |
| OpenCode legacy and New Runner | OpenRouter; custom/local Chat Completions |
| Hermes local | OpenRouter; custom/local Chat Completions |
| Gemini CLI, Grok | Their native API connections; custom routes are not advertised |
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,
Vertex, ambient AWS identity, arbitrary authentication headers, and automatic
provider catalog discovery are not part of this implementation.
`config.ai.routing` stores only kind, protocol, URL, auth method, region, and
optional model IDs/labels. The vault stores keys or the AWS credential bundle.
Fixed bindings contain only connection/grant identity. The server checks actual
connection metadata, company, owner/audience, installation, status, and protocol
before resolving secrets. Advanced connections cannot silently become native
personal defaults. Reconnect replaces credentials and preserves destination;
changing destination requires a separate connection. Credentials are never
submitted to a new URL as part of reconnect.
Only fixed official provider endpoints receive control-plane key checks. Custom
endpoints and Bedrock are exercised by the selected harness in the selected
execution environment, through **Run test**. Saving a custom connection records
configuration; it is not proof that the model can respond. HTTPS is required for
remote URLs; loopback endpoints may use HTTP. Localhost refers to the agent’s
execution environment, including when it is a sandbox. URLs cannot contain user
credentials, query parameters, or fragments.
Runtime projection clears alternate provider credentials and routing overrides,
uses disposable homes, and never falls back to host authentication. Codex probes
retain the selected provider home. The new runner copies only the validated
Paperclip provider stanza into its isolated Codex home, preserves its own tool
and sandbox policy, and disables shell snapshots. TypeScript and Rust launch
boundaries explicitly allow only the corresponding provider credential and
routing fields. Keys remain outside model-issued command environments on the
new Codex runner. Hermes custom endpoints use an isolated `config.yaml` with an
environment reference for the key.
Primary configuration references consulted:
[Codex custom providers](https://developers.openai.com/codex/config-advanced/),
[OpenRouter Codex](https://openrouter.ai/docs/cookbook/coding-agents/codex-cli),
[OpenRouter Claude](https://openrouter.ai/docs/cookbook/coding-agents/claude-code-integration),
[Claude gateways](https://code.claude.com/docs/en/llm-gateway),
[Claude Bedrock](https://code.claude.com/docs/en/amazon-bedrock),
[OpenCode providers](https://opencode.ai/docs/providers/), and
[Hermes providers](https://hermes-agent.nousresearch.com/docs/integrations/providers).
Validation includes negative company/owner/revocation/protocol checks, no-auth
vault behavior, immutable reconnect destinations, credential projection, Codex
probe isolation, and new-runner home/environment boundaries. The isolated local
test-drive exercised live OpenRouter requests using the Codex and Claude CLI
probes, then completed real tasks using Codex, Claude, and OpenCode New Runner.
The app walkthrough verified connection selection, saving, and completed tasks.
Bedrock and private gateway credentials were not available for live verification;
their mapping and validation are covered by deterministic tests.
@@ -203,6 +203,8 @@ export async function testEnvironment(
detail: `Detected in ${source}.`,
hint: "Ensure AWS credentials (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or AWS_PROFILE) and AWS_REGION are configured.",
});
} else if (config.managedAiRouting) {
checks.push({ code: "claude_managed_provider_configured", level: "info", message: "Testing the selected connection’s provider and model." });
} else if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) {
const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment";
const selectedApiKey = Boolean(config.managedAiConnection) || isNonEmpty(configApiKey);
@@ -155,6 +155,7 @@ async function prepareCodexHelloProbe(input: {
path.join(os.tmpdir(), `paperclip-codex-probe-home-${input.runId}-`),
);
let seededAuth = false;
let seededConfig = false;
for (const file of ["auth.json", "config.toml"]) {
// `fs.readFile` follows the home's `auth.json` symlink into the host's
// `~/.codex`, so we copy the resolved bytes as a plain file.
@@ -162,6 +163,7 @@ async function prepareCodexHelloProbe(input: {
if (contents) {
await fs.writeFile(path.join(probeHomeLocalDir, file), contents);
if (file === "auth.json") seededAuth = true;
if (file === "config.toml") seededConfig = true;
}
}
@@ -169,7 +171,7 @@ async function prepareCodexHelloProbe(input: {
// Pointing Codex at an empty uploaded home would mask any login already
// baked into the sandbox (e.g. a captured custom-image snapshot); leaving
// CODEX_HOME unset lets the probe exercise that in-sandbox login instead.
if (!seededAuth) {
if (!seededAuth && !(input.managedAiConnection && seededConfig)) {
return {
command: input.command,
args: input.args,
@@ -341,8 +343,15 @@ export async function testEnvironment(
}
const configOpenAiKey = env.OPENAI_API_KEY;
const hostOpenAiKey = targetIsRemote ? undefined : process.env.OPENAI_API_KEY;
if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
const hostOpenAiKey = targetIsRemote || Object.hasOwn(env, "OPENAI_API_KEY")
? undefined : process.env.OPENAI_API_KEY;
if (config.managedAiRouting) {
checks.push({
code: "codex_managed_provider_configured",
level: "info",
message: "Testing the selected connection’s provider and model.",
});
} else if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment";
checks.push({
code: "codex_openai_api_key_present",
@@ -420,7 +429,9 @@ export async function testEnvironment(
// wrap the probe with a shell that materializes a per-run auth.json so
// the CLI can authenticate. The key content is passed via env (not on
// the command line) to avoid leaking it into process listings.
const probeApiKey = isNonEmpty(configOpenAiKey)
// Managed connections already contain their exact auth and provider config.
// Replacing that home with a native-key probe would test another provider.
const probeApiKey = config.managedAiConnection ? null : isNonEmpty(configOpenAiKey)
? configOpenAiKey
: isNonEmpty(hostOpenAiKey)
? hostOpenAiKey
@@ -46,3 +46,22 @@ test("testEnvironment accepts config.command when hermesCommand is absent", asyn
await rm(tempDir, { recursive: true, force: true });
}
});
test("managed connections probe the selected environment without falling back to host keys", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "hermes-managed-probe-"));
const cliPath = path.join(tempDir, "fake-hermes");
try {
await writeFile(cliPath, '#!/bin/sh\n[ "$HERMES_HOME" = "' + tempDir + '" ] || exit 1\n[ "$OPENAI_API_KEY" = "selected-key" ] || exit 1\n[ "$ANTHROPIC_API_KEY" = "" ] || exit 1\n[ "$1" = "chat" ] || exit 1\necho hello\n');
await chmod(cliPath, 0o755);
const config = { command: cliPath, managedAiRouting: true, model: "custom/model", provider: "auto", env: { HERMES_HOME: tempDir, OPENAI_API_KEY: "selected-key", ANTHROPIC_API_KEY: "" } };
const passed = await testEnvironment({ companyId: "test", adapterType: "hermes_local", config });
expect(passed.status).toBe("pass");
expect(passed.checks[0]?.code).toBe("hermes_hello_probe_passed");
const failed = await testEnvironment({ companyId: "test", adapterType: "hermes_local", config: { ...config, env: { ...config.env, OPENAI_API_KEY: "wrong-key" } } });
expect(failed.status).toBe("fail");
expect(failed.checks[0]?.code).toBe("hermes_hello_probe_failed");
expect(JSON.stringify(failed)).not.toContain("wrong-key");
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
@@ -31,7 +31,8 @@ function resolveHermesHome(config: Record<string, unknown>): string {
? (config.env as Record<string, unknown>)
: {};
const configuredHome = asString(env.HOME);
return configuredHome ? path.resolve(configuredHome) : os.homedir();
const hermesHome = asString(env.HERMES_HOME);
return hermesHome ? path.resolve(hermesHome) : path.join(configuredHome ? path.resolve(configuredHome) : os.homedir(), ".hermes");
}
interface SkillFrontmatter {
@@ -131,7 +132,7 @@ async function buildSkillEntry(
async function buildHermesSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const home = resolveHermesHome(config);
const hermesSkillsHome = path.join(home, ".hermes", "skills");
const hermesSkillsHome = path.join(home, "skills");
// 1. Scan Paperclip-managed skills (bundled with the adapter)
const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
@@ -224,7 +225,7 @@ export async function reconcileHermesPaperclipSkills(
]))
: resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = path.join(resolveHermesHome(config), ".hermes", "skills");
const skillsHome = path.join(resolveHermesHome(config), "skills");
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
@@ -332,6 +332,34 @@ export async function testEnvironment(
const command = resolveHermesCommand(config);
const checks: AdapterEnvironmentCheck[] = [];
// Managed connections must prove the selected destination with the isolated
// runtime environment. Host credentials and ~/.hermes are not evidence.
if (config.managedAiRouting) {
const remote = ctx.executionTarget?.kind === "remote";
if (remote) {
checks.push({ level: "error", code: "hermes_remote_probe_unsupported", message: "Hermes connection testing requires a local execution environment." });
} else {
const env = { ...process.env };
for (const [key, value] of Object.entries((config.env ?? {}) as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
const args = ["chat", "-Q", "-q", "Respond with hello. Do not use tools.", "--max-turns", "1"];
if (asString(config.model)) args.push("-m", asString(config.model)!);
const provider = asString(config.provider);
if (provider && provider !== "auto") args.push("--provider", provider);
try {
const { stdout } = await execFileAsync(command, args, {
env, cwd: asString(config.cwd), timeout: 45_000, maxBuffer: 1024 * 1024,
});
if (!/\bhello\b/i.test(stdout)) throw new Error("No hello response");
checks.push({ level: "info", code: "hermes_hello_probe_passed", message: "Hermes responded through the selected connection." });
} catch {
checks.push({ level: "error", code: "hermes_hello_probe_failed", message: "Hermes could not complete a request through this connection.", hint: "Check the Hermes installation, endpoint, credential, and model, then test again." });
}
}
return { adapterType: ADAPTER_TYPE, status: checks.some(check => check.level === "error") ? "fail" : "pass", checks, testedAt: new Date().toISOString() };
}
// 1. CLI installed?
const cliCheck = await checkCliInstalled(command);
if (cliCheck) {
@@ -96,8 +96,20 @@ impl AcpxSidecarTransport {
agent: &str,
) -> Result<Self, LocalRunnerError> {
let credential_keys: &[&str] = match agent {
"claude" => &["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
"codex" => &["OPENAI_API_KEY", "CODEX_API_KEY"],
"claude" => &[
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_AUTH_TOKEN",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_BEARER_TOKEN_BEDROCK",
],
"codex" => &[
"OPENAI_API_KEY",
"CODEX_API_KEY",
"PAPERCLIP_AI_PROVIDER_KEY",
],
"grok" => &["XAI_API_KEY", "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"],
"pi" => &["OPENROUTER_API_KEY"],
"cursor" => &["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
@@ -136,6 +148,21 @@ impl AcpxSidecarTransport {
// The sidecar checks this controller-minted provider/session marker.
keys.push("PAPERCLIP_ACPX_CREDENTIAL_BINDING");
}
if agent == "claude" {
keys.extend_from_slice(&[
"ANTHROPIC_BASE_URL",
"CLAUDE_CODE_USE_BEDROCK",
"AWS_REGION",
"AWS_DEFAULT_REGION",
"AWS_EC2_METADATA_DISABLED",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
]);
}
keys.extend_from_slice(credential_keys);
Self::start_with_environment_keys(config, &keys)
}
@@ -32,6 +32,8 @@ const WARM_ATTACHMENT_TAIL_DRAIN_LIMIT: usize = 256;
const WARM_ATTACHMENT_QUIET_WINDOW: Duration = Duration::from_millis(10);
const WARM_ATTACHMENT_DRAIN_DEADLINE: Duration = Duration::from_millis(100);
const OPENCODE_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[
"PAPERCLIP_AI_PROVIDER_KEY",
"PAPERCLIP_AI_PROVIDER_URL",
"OPENROUTER_API_KEY",
"PAPERCLIP_NATIVE_MCP_NAME",
"PAPERCLIP_NATIVE_MCP_URL",
@@ -792,6 +794,7 @@ const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[
];
const CODEX_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[
"PAPERCLIP_AI_PROVIDER_KEY",
"CODEX_HOME",
"OPENAI_API_KEY",
"CODEX_API_KEY",
@@ -390,6 +390,9 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary"
environment: {
PATH: "/bin",
OPENROUTER_API_KEY: "provider-key",
PAPERCLIP_AI_PROVIDER_KEY: "managed-provider-key",
ANTHROPIC_AUTH_TOKEN: "managed-claude-key",
ANTHROPIC_BASE_URL: "https://gateway.example",
PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny",
PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode",
DATABASE_URL: "must-not-reach-runnerd",
@@ -419,6 +422,9 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary"
expect(launches[0]!.environment).toMatchObject({
PATH: "/bin",
OPENROUTER_API_KEY: "provider-key",
PAPERCLIP_AI_PROVIDER_KEY: "managed-provider-key",
ANTHROPIC_AUTH_TOKEN: "managed-claude-key",
ANTHROPIC_BASE_URL: "https://gateway.example",
PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny",
PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode",
});
@@ -29,7 +29,7 @@ import type { Duplex } from "node:stream";
import { fileURLToPath } from "node:url";
import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js";
import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES } from "../drivers/acpx/environment.js";
import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES, CLAUDE_ROUTING_ENV_KEYS } from "../drivers/acpx/environment.js";
import { githubCredentialEnvironment } from "../github-credential-environment.js";
import {
validatePrpEvent,
@@ -3338,6 +3338,10 @@ const runnerPlatformEnvironmentKeys = [
] as const;
const runnerExplicitProviderEnvironmentKeys = [
...ACPX_CREDENTIAL_NAMES.claude.filter(name => !name.startsWith("AWS_")),
...CLAUDE_ROUTING_ENV_KEYS,
"PAPERCLIP_AI_PROVIDER_KEY",
"PAPERCLIP_AI_PROVIDER_URL",
...ACPX_CREDENTIAL_NAMES.pi,
...ACPX_CREDENTIAL_NAMES.cursor,
...ACPX_CREDENTIAL_NAMES.copilot,
@@ -3395,6 +3399,11 @@ function runnerEnvironment(
const value = explicitSource[key];
if (value !== undefined) environment[key] = value;
}
if (explicitSource.CLAUDE_CODE_USE_BEDROCK === "1") {
for (const key of ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_BEARER_TOKEN_BEDROCK"] as const) {
if (explicitSource[key] !== undefined) environment[key] = explicitSource[key];
}
}
Object.assign(environment, githubCredentialEnvironment(explicitSource));
}
return environment;
@@ -5,6 +5,23 @@ afterEach(() => vi.unstubAllEnvs());
import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxCredentialBinding, createAcpxSidecarHostEnvironment, createSanitizedAcpxSpawnInput } from "./environment.js";
describe("ACPX launch environment", () => {
it("keeps gateway and Bedrock settings confined to the selected harness", () => {
const source = {
ANTHROPIC_BASE_URL: "https://gateway.example",
ANTHROPIC_AUTH_TOKEN: "selected-key",
CLAUDE_CODE_USE_BEDROCK: "1", AWS_REGION: "us-east-1",
AWS_BEARER_TOKEN_BEDROCK: "bedrock-key",
PAPERCLIP_AI_PROVIDER_KEY: "codex-key", UNRELATED_SECRET: "private",
};
expect(createSanitizedAcpxSpawnInput(source, "claude").env).toEqual({
ANTHROPIC_BASE_URL: source.ANTHROPIC_BASE_URL,
ANTHROPIC_AUTH_TOKEN: "selected-key", CLAUDE_CODE_USE_BEDROCK: "1",
AWS_REGION: "us-east-1", AWS_BEARER_TOKEN_BEDROCK: "bedrock-key",
});
expect(createSanitizedAcpxSpawnInput(source, "codex").env).toEqual({ PAPERCLIP_AI_PROVIDER_KEY: "codex-key" });
expect(createSanitizedAcpxSpawnInput(source, "cursor").env).toEqual({});
});
it("projects only the selected agent's credentials and runtime allowlist", () => {
const source = {
PATH: "/bin",
@@ -6,9 +6,10 @@ export const ACPX_CREDENTIAL_NAMES: Readonly<Record<QualifiedAcpxAgent, readonly
pi: ["OPENROUTER_API_KEY"],
cursor: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
copilot: ["COPILOT_GITHUB_TOKEN"],
claude: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
codex: ["OPENAI_API_KEY", "CODEX_API_KEY"],
claude: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_BEARER_TOKEN_BEDROCK"],
codex: ["OPENAI_API_KEY", "CODEX_API_KEY", "PAPERCLIP_AI_PROVIDER_KEY"],
};
export const CLAUDE_ROUTING_ENV_KEYS = ["ANTHROPIC_BASE_URL", "CLAUDE_CODE_USE_BEDROCK", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_EC2_METADATA_DISABLED", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"] as const;
const isCandidate = (agent: QualifiedAcpxAgent) => agent === "pi" || agent === "cursor" || agent === "copilot";
/** Mint only at the controller's explicit task-environment boundary, never by copying a marker. */
@@ -111,6 +112,7 @@ export function createSanitizedAcpxSpawnInput(
"PAPERCLIP_NATIVE_MCP_NAME",
"PAPERCLIP_NATIVE_MCP_URL",
...credentialNames,
...(agent === "claude" ? CLAUDE_ROUTING_ENV_KEYS : []),
]);
let retainedBytes = 0;
for (const [key, value] of Object.entries(source)) {
@@ -16,6 +16,15 @@ function nodeTransport(
}
describe("Codex app-server transport limits", () => {
it("passes the selected managed provider key without admitting arbitrary host secrets", () => {
expect(createSanitizedCodexEnvironment({
PAPERCLIP_AI_PROVIDER_KEY: "selected-provider-key",
CODEX_HOME: "/isolated/connection",
OPENROUTER_API_KEY: "ambient-other-key",
DATABASE_URL: "private-database",
})).toEqual({ PAPERCLIP_AI_PROVIDER_KEY: "selected-provider-key", CODEX_HOME: "/isolated/connection" });
});
it("passes only bounded controller-projected GitHub credentials", () => {
expect(
createSanitizedCodexEnvironment({
@@ -203,6 +203,8 @@ const SAFE_ENVIRONMENT_KEYS = [
"AGENT_HOME",
"ALL_PROXY",
"CODEX_HOME",
// Only the selected managed provider credential enters the trusted server.
"PAPERCLIP_AI_PROVIDER_KEY",
"HOME",
"HTTP_PROXY",
"HTTPS_PROXY",
@@ -611,6 +611,29 @@ describe("OpenCodeServerDriver", () => {
}
});
it("projects a custom connection into the isolated OpenCode config", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-routing-"));
roots.push(root);
const driver = new OpenCodeServerDriver({
model: "paperclip/team/model-alias",
runtimeDirectory: root,
command: fixture,
environment: { PATH: process.env.PATH, PAPERCLIP_AI_PROVIDER_URL: "https://gateway.example/v1", PAPERCLIP_AI_PROVIDER_KEY: "selected-gateway-key" },
});
const session = await driver.openSession({ runId: "routing", normalizedSessionId: "routing", workingDirectory: root });
try {
const configPath = join(root, "routing", "config", "opencode", "opencode.json");
expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({
model: "paperclip/team/model-alias", small_model: "paperclip/team/model-alias", plugin: [],
provider: { paperclip: { npm: "@ai-sdk/openai-compatible", options: { baseURL: "https://gateway.example/v1", apiKey: "selected-gateway-key" }, models: { "team/model-alias": { name: "team/model-alias" } } } },
});
expect((await stat(configPath)).mode & 0o777).toBe(0o600);
} finally {
await session.close({ reason: "test" });
}
});
it("starts an authenticated isolated server, creates a session, streams usage, aborts, and cleans up", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
@@ -1976,6 +1976,7 @@ async function startRuntime(input: {
bridge.secret,
assignedMcp?.token,
input.options.environment?.OPENROUTER_API_KEY,
input.options.environment?.PAPERCLIP_AI_PROVIDER_KEY,
]);
const instructionRoot =
input.options.runtimeContext?.instructions.bundle.rootPath;
@@ -1995,6 +1996,14 @@ async function startRuntime(input: {
// provider instead of silently falling back or rejecting a newer model.
provider: {
[modelProvider!]: {
...(modelProvider === "paperclip" && input.options.environment?.PAPERCLIP_AI_PROVIDER_URL ? {
npm: "@ai-sdk/openai-compatible",
name: "Paperclip connection",
options: {
baseURL: input.options.environment.PAPERCLIP_AI_PROVIDER_URL,
apiKey: input.options.environment.PAPERCLIP_AI_PROVIDER_KEY ?? "",
},
} : {}),
models: {
[providerModelId]: { name: providerModelId },
},
@@ -166,6 +166,35 @@ describe("runtime context materialization", () => {
expect(config).not.toContain("unassigned");
});
it("preserves a managed provider while excluding ambient auth, hooks, and tool configuration", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-routing-home-"));
roots.push(root);
const source = join(root, "source");
const target = join(root, "isolated");
await mkdir(source);
await writeFile(join(source, "auth.json"), '{"OPENAI_API_KEY":"unrelated-key"}');
await writeFile(join(source, "config.toml"), `model_provider = "paperclip"
[model_providers.paperclip]
name = "Selected connection"
base_url = "https://openrouter.ai/api/v1"
wire_api = "responses"
requires_openai_auth = false
env_key = "PAPERCLIP_AI_PROVIDER_KEY"
[features]
shell_snapshot = true
[mcp_servers.unassigned]
command = "untrusted-command"
`);
await prepareIsolatedCodexHome({ context: null, codexHome: target, sourceCodexHome: source, apiKey: "also-unrelated" });
const config = await readFile(join(target, "config.toml"), "utf8");
expect(config).toContain('model_provider = "paperclip"');
expect(config).toContain('base_url = "https://openrouter.ai/api/v1"');
expect(config).toContain('env_key = "PAPERCLIP_AI_PROVIDER_KEY"');
expect(config).toContain("shell_snapshot = false");
expect(config).not.toMatch(/unrelated|untrusted|unassigned/);
await expect(stat(join(target, "auth.json"))).rejects.toThrow();
});
it("rejects repeated assignments without changing the current assignment", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-runtime-repeat-"));
roots.push(root);
@@ -12,6 +12,7 @@ import {
writeFile,
} from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { parse as parseToml } from "smol-toml";
import type { NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js";
import type { NativeMcpLaunchBinding } from "./native-mcp.js";
@@ -306,6 +307,29 @@ async function readSourceCodexAuth(sourceAuth: string): Promise<Buffer | null> {
}
}
/** Copy only Paperclip's value-free provider projection, never host tools or hooks. */
async function managedCodexProviderConfig(sourceHome?: string | null): Promise<string> {
if (!sourceHome?.trim()) return "";
const source = await readSourceCodexAuth(join(sourceHome, "config.toml"));
if (!source) return "";
let parsed;
try { parsed = parseToml(source.toString("utf8")); } catch { return ""; }
if (parsed.model_provider !== "paperclip") return "";
const providers = parsed.model_providers as Record<string, unknown> | undefined;
const provider = providers?.paperclip as Record<string, unknown> | undefined;
if (!provider || typeof provider.base_url !== "string" || provider.wire_api !== "responses"
|| provider.requires_openai_auth !== false
|| (provider.env_key !== undefined && provider.env_key !== "PAPERCLIP_AI_PROVIDER_KEY")) {
throw new Error("Invalid managed Codex provider configuration");
}
const url = new URL(provider.base_url);
if (url.username || url.password || url.search || url.hash
|| (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) {
throw new Error("Invalid managed Codex provider URL");
}
return `model_provider = "paperclip"\n[model_providers.paperclip]\nname = "Paperclip connection"\nbase_url = ${JSON.stringify(provider.base_url)}\nwire_api = "responses"\nrequires_openai_auth = false\n${provider.env_key ? 'env_key = "PAPERCLIP_AI_PROVIDER_KEY"\n' : ""}`;
}
export async function prepareIsolatedCodexHome(input: {
context: NativeRuntimeContextSnapshot | null;
codexHome: string;
@@ -318,9 +342,11 @@ export async function prepareIsolatedCodexHome(input: {
join(input.codexHome, "skills"),
);
const providerConfig = await managedCodexProviderConfig(input.sourceCodexHome);
const configPath = join(input.codexHome, "config.toml");
await rm(configPath, { force: true });
await writeFile(configPath, [
providerConfig,
// Codex shell snapshots serialize the provider process environment. The
// native runner injects short-lived provider and MCP bindings, so a
// snapshot would turn ephemeral credentials into durable session state.
@@ -343,6 +369,7 @@ export async function prepareIsolatedCodexHome(input: {
const targetAuth = join(input.codexHome, "auth.json");
await rm(targetAuth, { force: true });
if (providerConfig) return;
const apiKey = input.apiKey?.trim();
if (apiKey) {
// The pinned Codex app-server authenticates API-key automation through its
@@ -3211,6 +3211,8 @@ export function resolveRunnerdAcpxPermissionMode(
}
const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([
"PAPERCLIP_AI_PROVIDER_KEY",
"PAPERCLIP_AI_PROVIDER_URL",
"PATH",
"LANG",
"LANGUAGE",
+24 -15
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { aiProviderRoutingSchema, aiAwsCredentialsSchema, aiRoutingHarness, isAiRoutingCompatible, type AiProviderRouting } from "./ai-provider-routing.js";
/** Runtime authentication is a separate transport, never a tool or channel. */
export const connectionPurposeTransportSchema = z.discriminatedUnion(
@@ -31,6 +32,7 @@ export const AI_PROVIDERS = [
"openai",
"openrouter",
"xai",
"google",
] as const;
export const aiProviderSchema = z.enum(AI_PROVIDERS);
export const aiAuthMethodSchema = z.enum(["subscription", "api_key"]);
@@ -56,7 +58,7 @@ export const aiConnectionBindingSchema = z.discriminatedUnion("mode", [
z
.object({
...requirement,
// Legacy wire format only; human access still applies. New UI never creates it.
// Explicit personal selection. Human access still applies on every run.
mode: z.literal("delegated"),
connectionId: z.string().uuid(),
grantId: z.string().uuid(),
@@ -64,7 +66,7 @@ export const aiConnectionBindingSchema = z.discriminatedUnion("mode", [
.strict(),
]);
export type AiConnectionBinding = z.infer<typeof aiConnectionBindingSchema>;
export const aiConnectionMetadataSchema = z.object(requirement).strict();
export const aiConnectionMetadataSchema = z.object({ ...requirement, routing: aiProviderRoutingSchema.optional() }).strict();
export type AiConnectionMetadata = z.infer<typeof aiConnectionMetadataSchema>;
/** Existing integrations only. This table describes compatibility, never routing. */
@@ -77,6 +79,7 @@ export const AI_CONNECTION_CAPABILITIES: Record<
>;
}
> = {
google: { name: "Google", methods: { api_key: { adapters: ["gemini_local"], envKey: "GEMINI_API_KEY" } } },
anthropic: {
name: "Claude",
methods: {
@@ -115,18 +118,12 @@ export function isAiConnectionCompatible(
runnerProvider?: unknown,
acpxAgent?: unknown,
): boolean {
if (adapterType === "paperclip_runner")
adapterType =
runnerProvider === "claude" ||
(runnerProvider === "acpx" && acpxAgent === "claude")
? "claude_local"
: runnerProvider === "acpx" && acpxAgent === "grok"
? "grok_local"
: runnerProvider === "codex"
? "codex_local"
: runnerProvider === "opencode"
? "opencode_local"
: "unsupported";
adapterType = aiRoutingHarness(adapterType, runnerProvider, acpxAgent);
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 ("mode" in requirement && requirement.mode !== "responsible_user" && requirement.method === "api_key")
return ["claude_local", "codex_local", "opencode_local", "hermes_local", "gemini_local", "grok_local"].includes(adapterType);
const methods = AI_CONNECTION_CAPABILITIES[requirement.provider].methods;
const candidates = "mode" in requirement && requirement.mode === "responsible_user"
? Object.values(methods)
@@ -165,6 +162,7 @@ export interface AiManagedConnectionSummary {
provider: AiProvider;
method: AiAuthMethod;
name: string;
routing?: AiProviderRouting;
accountLabel?: string;
ownership: "personal" | "shared";
ownerUserId?: string;
@@ -184,6 +182,8 @@ export const createAiConnectionSchema = z
...requirement,
name: z.string().trim().min(1).max(160),
ownership: z.enum(["personal", "shared"]),
routing: aiProviderRoutingSchema.optional(),
awsCredentials: aiAwsCredentialsSchema.optional(),
apiKey: z.string().trim().min(1).max(32768).optional(),
loginSessionId: z.string().max(128).optional(),
connectionId: z.string().uuid().optional(),
@@ -194,6 +194,15 @@ export const createAiConnectionSchema = z
.superRefine((v, ctx) => {
if (!AI_CONNECTION_CAPABILITIES[v.provider].methods[v.method])
ctx.addIssue({ code: "custom", message: "Unsupported sign-in method" });
if (v.routing && (v.method !== "api_key" || (v.routing.kind === "openrouter" && v.provider !== "openrouter") || (v.routing.kind === "bedrock" && v.provider !== "anthropic")))
ctx.addIssue({ code: "custom", message: "Routing requires the matching provider and API authentication." });
if (v.routing && ["gateway", "local"].includes(v.routing.kind) && v.provider !== (v.routing.protocol === "messages" ? "anthropic" : "openai"))
ctx.addIssue({ code: "custom", message: "The provider must match the endpoint’s API format." });
if (v.awsCredentials && v.routing?.auth !== "aws_credentials") ctx.addIssue({ code: "custom", message: "AWS credentials require AWS authentication." });
if (v.routing?.auth === "none" || v.routing?.auth === "aws_credentials") {
if (v.apiKey || v.loginSessionId || (v.routing.auth === "aws_credentials" ? !v.awsCredentials : Boolean(v.awsCredentials))) ctx.addIssue({ code: "custom", message: "Provide only the selected authentication method." });
return;
}
if (
v.method === "api_key"
? !v.apiKey || Boolean(v.loginSessionId)
@@ -210,7 +219,7 @@ export type CreateAiConnection = z.infer<typeof createAiConnectionSchema>;
export const aiConnectionLoginIntentSchema = z
.object({
provider: aiProviderSchema,
provider: z.enum(["anthropic", "openai", "xai"]),
method: z.literal("subscription"),
name: z.string().trim().min(1).max(160),
ownership: z.enum(["personal", "shared"]),
+142
View File
@@ -0,0 +1,142 @@
import { z } from "zod";
/** Value-free routing stored on the connection, never supplied by an agent binding. */
export const aiProviderRoutingSchema = z
.object({
kind: z.enum(["openrouter", "bedrock", "gateway", "local"]),
protocol: z.enum(["responses", "messages", "chat", "bedrock"]),
baseUrl: z.string().trim().max(2048).optional(),
auth: z
.enum(["bearer", "api_key", "aws_credentials", "none"])
.default("bearer"),
region: z
.string()
.regex(/^[a-z]{2}(?:-[a-z]+)+-\d$/)
.optional(),
models: z
.array(
z
.object({
id: z.string().trim().min(1).max(256),
label: z.string().trim().max(160).optional(),
})
.strict(),
)
.max(200)
.default([]),
})
.strict()
.superRefine((route, ctx) => {
const invalid = (message: string) =>
ctx.addIssue({ code: "custom", message });
if (route.kind === "bedrock") {
if (
route.protocol !== "bedrock" ||
!route.region ||
!["bearer", "aws_credentials"].includes(route.auth) ||
route.baseUrl
)
invalid(
"Bedrock requires a region and AWS credentials or a Bedrock API key.",
);
} else if (
route.protocol === "bedrock" ||
route.auth === "aws_credentials" ||
route.region
)
invalid("AWS settings require Bedrock.");
if (
route.kind === "openrouter" &&
(route.baseUrl || route.auth !== "bearer")
)
invalid("OpenRouter uses its official endpoint and an API key.");
if (route.auth === "api_key" && route.protocol !== "messages")
invalid("API-key header authentication requires Anthropic Messages.");
if (route.kind === "gateway" || route.kind === "local") {
try {
const url = new URL(route.baseUrl ?? "");
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(
url.hostname,
);
if (
url.username ||
url.password ||
url.search ||
url.hash ||
(url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
)
throw new Error();
if (route.kind === "local" && !loopback)
invalid("A local endpoint must use localhost or a loopback address.");
} catch {
invalid(
"Enter an HTTPS base URL without credentials, query parameters, or a fragment. Local endpoints may use HTTP.",
);
}
}
});
export type AiProviderRouting = z.infer<typeof aiProviderRoutingSchema>;
export const aiAwsCredentialsSchema = z
.object({
accessKeyId: z.string().trim().min(1).max(256),
secretAccessKey: z.string().min(1).max(4096),
sessionToken: z.string().max(16384).optional(),
})
.strict();
export function aiRoutingHarness(
adapter: string,
provider?: unknown,
acpxAgent?: unknown,
): string {
if (adapter !== "paperclip_runner") return adapter;
const runner =
provider === "acpx" && (acpxAgent === "claude" || acpxAgent === "grok")
? acpxAgent
: provider;
return (
(
{
claude: "claude_local",
codex: "codex_local",
opencode: "opencode_local",
grok: "grok_local",
} as Record<string, string>
)[String(runner)] ?? "unsupported"
);
}
export function isAiRoutingCompatible(
route: AiProviderRouting,
harness: string,
): boolean {
if (route.kind === "bedrock") return harness === "claude_local";
if (route.kind === "openrouter")
return [
"claude_local",
"codex_local",
"opencode_local",
"hermes_local",
].includes(harness);
if (route.protocol === "responses") return harness === "codex_local";
if (route.protocol === "messages") return harness === "claude_local";
return ["opencode_local", "hermes_local"].includes(harness);
}
export function aiRoutingBaseUrl(
route: AiProviderRouting,
harness: string,
): string {
return route.kind === "openrouter"
? `https://openrouter.ai/api${harness === "claude_local" ? "" : "/v1"}`
: (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;
const prefix = route.kind === "openrouter" ? "openrouter" : "paperclip";
return model.startsWith(`${prefix}/`) ? model : `${prefix}/${model}`;
}
+2
View File
@@ -2816,3 +2816,5 @@ export * from "./browser-use.js";
export * from "./types/skill-source.js";
export * from "./validators/skill-source.js";
export * from "./github-skill-repository.js";
export * from "./ai-provider-routing.js";
@@ -120,6 +120,40 @@ describe("managed AI connections", () => {
} finally { fetchSpy.mockRestore(); }
});
it("vaults routed credentials, enforces compatibility and access, and configures both Codex runners", async () => {
const routing = { kind: "openrouter", protocol: "responses", auth: "bearer", models: [{ id: "openai/gpt-5.4" }] } as const;
const saved = await service.save(companyId, "alice", { provider: "openrouter", method: "api_key", name: "Routed test", ownership: "personal", apiKey: "fixture", allAgents: true, agentIds: [], routing: { ...routing, models: [...routing.models] } }, "fixture-routed-credential");
const selected = { provider: "openrouter", method: "api_key", mode: "delegated", ...saved } as const;
expect(JSON.stringify(await service.list(companyId, "alice"))).not.toContain("fixture-routed-credential");
expect(await service.list(companyId, "alice")).toEqual(expect.arrayContaining([expect.objectContaining({ id: saved.connectionId, routing, isDefault: false })]));
await expect(service.setDefault(companyId, "alice", saved.grantId)).rejects.toThrow("explicit connection");
await expect(service.select({ ...input, binding: selected, userId: "bob", adapterType: "codex_local", model: "openai/gpt-5.4" })).rejects.toThrow("not shared");
await expect(service.select({ ...input, companyId: otherCompanyId, binding: selected, userId: "alice", adapterType: "codex_local" })).rejects.toThrow();
for (const adapterType of ["codex_local", "paperclip_runner"]) {
const runtime = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: "alice", binding: selected, adapterType, config: { provider: "codex", model: "openai/gpt-5.4", env: { OPENAI_API_KEY: "ambient" } } });
try {
expect(runtime.config.env.OPENAI_API_KEY).toBe("");
expect(runtime.config.env.PAPERCLIP_AI_PROVIDER_KEY).toBe("fixture-routed-credential");
const toml = await readFile(path.join(String(runtime.config.env.CODEX_HOME), "config.toml"), "utf8");
expect(toml).toContain('base_url = "https://openrouter.ai/api/v1"');
expect(toml).toContain('wire_api = "responses"');
expect(toml).not.toContain("fixture-routed-credential");
} finally { await runtime.cleanup(); }
}
await expect(service.save(companyId, "alice", { provider: "openrouter", method: "api_key", name: "Routed test", ownership: "personal", apiKey: "fixture", connectionId: saved.connectionId, allAgents: true, agentIds: [], routing: { ...routing, kind: "gateway", baseUrl: "https://other.example/v1", models: [] } }, "fixture-replacement")).rejects.toThrow("retain");
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, saved.grantId));
await expect(service.select({ ...input, binding: selected, userId: "alice", adapterType: "codex_local", model: "openai/gpt-5.4" })).rejects.toThrow("Reconnect");
});
it("saves no-auth endpoints without a secret and refuses protocol mismatches", async () => {
const routing = { kind: "local", protocol: "chat", auth: "none", baseUrl: "http://localhost:11434/v1", models: [] } as const;
const saved = await service.save(companyId, "alice", { provider: "openai", method: "api_key", name: "Local", ownership: "personal", allAgents: true, agentIds: [], routing: { ...routing, models: [] } }, "");
const selected = { provider: "openai", method: "api_key", mode: "delegated", ...saved } as const;
await expect(service.select({ ...input, binding: selected, userId: "alice", adapterType: "codex_local" })).rejects.toThrow("incompatible");
const row = await service.select({ ...input, binding: selected, userId: "alice", adapterType: "opencode_local", model: "qwen" });
expect(row.grant.credentialSecretRefs).toEqual([]);
expect(await service.credential(row)).toBe("");
});
it.each([
["anthropic", false], ["openai", false], ["anthropic", true], ["openai", true],
] as const)("turns a %s auth failure into one card and resumes after repair (switch method: %s)", async (provider, switchMethod) => {
@@ -0,0 +1,147 @@
import { describe, it, expect } from "vitest";
import {
aiProviderRoutingSchema,
createAiConnectionSchema,
isAiConnectionCompatible,
} from "@paperclipai/shared";
import { managedProviderRouting } from "../services/ai-provider-routing.js";
describe("provider routing", () => {
it.each([
"https://user:secret@gateway.example/v1",
"https://gateway.example/v1?key=secret",
"http://169.254.169.254/",
"file:///tmp/secret",
"https://gateway.example/#secret",
])("rejects unsafe or credential-bearing endpoint %s", (baseUrl) => {
expect(
aiProviderRoutingSchema.safeParse({
kind: "gateway",
protocol: "responses",
baseUrl,
}).success,
).toBe(false);
});
it("requires exactly the selected credential and disallows routed subscriptions", () => {
const input = {
provider: "openai",
method: "api_key",
name: "Local",
ownership: "personal",
routing: {
kind: "local",
protocol: "responses",
auth: "none",
baseUrl: "http://localhost:11434/v1",
},
};
expect(createAiConnectionSchema.safeParse(input).success).toBe(true);
expect(
createAiConnectionSchema.safeParse({ ...input, apiKey: "unwanted" })
.success,
).toBe(false);
expect(
createAiConnectionSchema.safeParse({
...input,
method: "subscription",
loginSessionId: "session",
}).success,
).toBe(false);
});
it("rejects unsupported protocols and excluded remote adapters", () => {
const routing = aiProviderRoutingSchema.parse({
kind: "gateway",
protocol: "chat",
baseUrl: "https://gateway.example/v1",
});
for (const adapter of [
"codex_local",
"claude_local",
"http",
"process",
"openclaw_gateway",
"hermes_gateway",
"acpx_local",
])
expect(
isAiConnectionCompatible(
{ provider: "openai", method: "api_key", routing },
adapter,
),
).toBe(false);
expect(
isAiConnectionCompatible(
{ provider: "openai", method: "api_key", routing },
"opencode_local",
),
).toBe(true);
expect(
isAiConnectionCompatible(
{ provider: "openai", method: "api_key", routing },
"paperclip_runner",
"model",
"aws_agentcore",
),
).toBe(false);
});
it("routes OpenRouter through each harness's protocol without putting secrets in Codex config", () => {
const routing = aiProviderRoutingSchema.parse({
kind: "openrouter",
protocol: "responses",
});
const codex = managedProviderRouting(
routing,
"codex_local",
"fixture-secret",
"openai/gpt-5.4",
);
expect(codex.codexConfig).toContain('wire_api = "responses"');
expect(codex.codexConfig).not.toContain("fixture-secret");
const claude = managedProviderRouting(
routing,
"claude_local",
"fixture-secret",
"anthropic/claude-sonnet-4.6",
);
expect(claude.env.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api");
expect(claude.env.ANTHROPIC_AUTH_TOKEN).toBe("fixture-secret");
expect(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe(
"anthropic/claude-sonnet-4.6",
);
const opencode = managedProviderRouting(
routing,
"opencode_local",
"fixture-secret",
"anthropic/claude-sonnet-4.6",
);
expect(opencode.config.model).toBe(
"openrouter/anthropic/claude-sonnet-4.6",
);
});
it("projects AWS credentials and disables ambient instance metadata", () => {
const routing = aiProviderRoutingSchema.parse({
kind: "bedrock",
protocol: "bedrock",
auth: "aws_credentials",
region: "us-east-1",
});
const projected = managedProviderRouting(
routing,
"claude_local",
JSON.stringify({
accessKeyId: "fixture-id",
secretAccessKey: "fixture-secret",
sessionToken: "fixture-token",
}),
"bedrock-model",
);
expect(projected.env).toMatchObject({
CLAUDE_CODE_USE_BEDROCK: "1",
AWS_REGION: "us-east-1",
AWS_ACCESS_KEY_ID: "fixture-id",
AWS_SECRET_ACCESS_KEY: "fixture-secret",
AWS_SESSION_TOKEN: "fixture-token",
AWS_EC2_METADATA_DISABLED: "true",
});
});
});
@@ -7,13 +7,18 @@ import { testEnvironment } from "@paperclipai/adapter-codex-local/server";
const itWindows = process.platform === "win32" ? it : it.skip;
const itPosix = process.platform === "win32" ? it.skip : it;
async function runProbeFixture(options: { failCleanup?: boolean; error?: string } = {}) {
async function runProbeFixture(options: { failCleanup?: boolean; error?: string; managedProvider?: boolean } = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-probe-result-"));
const capture = path.join(root, "capture.json");
const command = path.join(root, "codex");
const managedHome = path.join(root, "managed");
if (options.managedProvider) {
await fs.mkdir(managedHome);
await fs.writeFile(path.join(managedHome, "config.toml"), 'model_provider = "paperclip"\n');
}
await fs.writeFile(command, `#!${process.execPath}
const fs = require('node:fs');
fs.writeFileSync(process.env.PROBE_CAPTURE, JSON.stringify({ args: process.argv.slice(2), home: process.env.CODEX_HOME }));
fs.writeFileSync(process.env.PROBE_CAPTURE, JSON.stringify({ args: process.argv.slice(2), home: process.env.CODEX_HOME, nativeKey: process.env.OPENAI_API_KEY, providerKey: process.env.PAPERCLIP_AI_PROVIDER_KEY, config: fs.existsSync(process.env.CODEX_HOME + "/config.toml") ? fs.readFileSync(process.env.CODEX_HOME + "/config.toml", "utf8") : null }));
console.error('WARN codex_core_plugins::manager: remote installed plugin bundle sync failed error=chatgpt authentication required for remote plugin catalog');
const error = process.env.PROBE_ERROR;
if (error) { console.error(error); process.exit(1); }
@@ -27,12 +32,13 @@ console.log(JSON.stringify({type:'turn.completed',usage:{input_tokens:1,output_t
try {
const result = await testEnvironment({
companyId: "company-1", adapterType: "codex_local",
config: { engine: "cli", command, cwd: root, env: {
OPENAI_API_KEY: "fixture-key", PROBE_CAPTURE: capture,
config: { engine: "cli", command, cwd: root, ...(options.managedProvider ? { managedAiConnection: { identity: "fixture" }, managedAiRouting: { kind: "openrouter" } } : {}), env: {
...(options.managedProvider ? { CODEX_HOME: managedHome, PAPERCLIP_AI_PROVIDER_KEY: "gateway-fixture-key" } : {}),
OPENAI_API_KEY: options.managedProvider ? "" : "fixture-key", PROBE_CAPTURE: capture,
PROBE_ERROR: options.error ?? "", PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`,
} },
});
return { result, capture: JSON.parse(await fs.readFile(capture, "utf8")) as { args: string[]; home: string } };
return { result, capture: JSON.parse(await fs.readFile(capture, "utf8")) as { args: string[]; home: string; nativeKey: string; providerKey: string; config: string | null } };
} finally {
const recorded = await fs.readFile(capture, "utf8").then(JSON.parse).catch(() => null);
if (recorded?.home) await fs.rm(recorded.home, { recursive: true, force: true });
@@ -47,6 +53,16 @@ describe("codex_local environment diagnostics", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
itPosix("tests the managed provider without falling back to the host OpenAI key", async () => {
vi.stubEnv("OPENAI_API_KEY", "unrelated-host-key");
const { result, capture } = await runProbeFixture({ managedProvider: true });
expect(result.status).toBe("pass");
expect(capture.nativeKey).toBe("");
expect(capture.providerKey).toBe("gateway-fixture-key");
expect(capture.config).toContain('model_provider = "paperclip"');
expect(result.checks).not.toContainEqual(expect.objectContaining({ code: "codex_openai_api_key_present" }));
});
itPosix("preserves a successful hello when probe cleanup races a background writer", async () => {
const { result } = await runProbeFixture({ failCleanup: true });
expect(result.status).toBe("pass");
+3 -2
View File
@@ -1,3 +1,4 @@
import { aiRoutingHarness } from "@paperclipai/shared";
import { connectionIntentService } from "../services/connection-intents.js";
import { completeConnectionIntentSchema } from "@paperclipai/shared";
import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-store.js";
@@ -3352,7 +3353,7 @@ export function agentRoutes(
// requirement stays for subscriptions: a stored login is a file layout
// only a provider CLI reads, so proving the runtime lane can consume it
// takes a real hello turn.
if (resolvedMethod === "api_key") {
if (resolvedMethod === "api_key" && !context.config.managedAiRouting) {
const envKey = AI_CONNECTION_CAPABILITIES[binding.provider].methods.api_key?.envKey;
const key = envKey ? parseObject(context.config.env)[envKey] : undefined;
try {
@@ -3367,7 +3368,7 @@ export function agentRoutes(
return result;
}
if (!result.checks.some(check => check.code.includes("hello_probe"))) {
const providerAdapter = { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local" }[binding.provider];
const providerAdapter = context.config.managedAiRouting ? aiRoutingHarness(adapterType, context.config.provider, context.config.acpxAgent) : { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local", google: "gemini_local" }[binding.provider];
const probe = await requireServerAdapter(providerAdapter).testEnvironment({ ...context, adapterType: providerAdapter, config: { ...context.config, engine: "cli" } });
result.checks.push(...probe.checks);
result.status = probe.status === "fail" ? "fail" : result.status === "warn" || probe.status === "warn" ? "warn" : "pass";
+6 -3
View File
@@ -142,6 +142,7 @@ export async function validateAiApiKey(
openai: "https://api.openai.com/v1/models",
openrouter: "https://openrouter.ai/api/v1/key",
xai: "https://api.x.ai/v1/models",
google: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1",
};
let response: Response;
try {
@@ -149,7 +150,7 @@ export async function validateAiApiKey(
redirect: "error",
signal: AbortSignal.timeout(15000),
headers:
provider === "anthropic"
provider === "google" ? { "x-goog-api-key": key } : provider === "anthropic"
? { "x-api-key": key, "anthropic-version": "2023-06-01" }
: { Authorization: `Bearer ${key}` },
});
@@ -295,12 +296,14 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
"Use the existing provider sign-in flow to connect a subscription",
);
const attemptStartedAt = new Date();
await validateAiApiKey(input.provider, input.apiKey!);
// Custom destinations are exercised in the selected execution environment,
// never fetched by the control plane (including localhost/private URLs).
if (!input.routing || input.routing.kind === "openrouter") await validateAiApiKey(input.provider, input.apiKey!);
const result = await service.save(
companyId,
userId,
input,
input.apiKey!,
input.awsCredentials ? JSON.stringify(input.awsCredentials) : input.apiKey ?? "",
undefined,
attemptStartedAt,
);
@@ -9,6 +9,7 @@ import {
// Only keys read by the child's provider express a child auth override.
// A config copied from another provider can retain unrelated keys.
const PROVIDER_AUTH_ENV_KEYS: Record<AiProvider, readonly string[]> = {
google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GEMINI_BASE_URL"],
anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CONFIG_DIR", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"],
openai: ["OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_HOME", "OPENAI_BASE_URL"],
openrouter: ["OPENROUTER_API_KEY", "OPENCODE_AUTH_JSON", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR", "PAPERCLIP_OPENCODE_PROVIDERS"],
+3 -1
View File
@@ -19,8 +19,10 @@ describe("AI authentication failure recovery", () => {
expect(aiBindingForAuthRecovery(adapter, config)).toMatchObject({ provider, mode: "responsible_user" });
});
it("does not guess a provider for unsupported harnesses or routes", () => {
expect(aiBindingForAuthRecovery("gemini_local", {})).toBeUndefined();
expect(aiBindingForAuthRecovery("opencode_local", { model: "anthropic/claude" })).toBeUndefined();
expect(aiBindingForAuthRecovery("paperclip_runner", { provider: "acpx", acpxAgent: "custom" })).toBeUndefined();
});
it("offers Google's supported API-key method for Gemini recovery", () => {
expect(aiBindingForAuthRecovery("gemini_local", {})).toEqual({ provider: "google", method: "api_key", mode: "responsible_user" });
});
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { AI_PROVIDERS, isAiConnectionCompatible, type AiConnectionBinding } from "@paperclipai/shared";
import { AI_PROVIDERS, AI_CONNECTION_CAPABILITIES, isAiConnectionCompatible, type AiConnectionBinding } from "@paperclipai/shared";
/** Provider authentication signals only. Tool authorization and quotas need different repairs. */
export function isAiAuthenticationFailure(code: string | null | undefined): boolean {
@@ -18,7 +18,7 @@ export function aiBindingForAuthRecovery(
config: Record<string, unknown>,
): AiConnectionBinding | undefined {
for (const provider of AI_PROVIDERS) {
const binding = { provider, method: provider === "openrouter" ? "api_key" : "subscription", mode: "responsible_user" } as const;
const binding = { provider, method: AI_CONNECTION_CAPABILITIES[provider].methods.subscription ? "subscription" : "api_key", mode: "responsible_user" } as const;
if (isAiConnectionCompatible(binding, adapterType, config.model, config.provider, config.acpxAgent)) return binding;
}
return undefined;
+25 -6
View File
@@ -7,8 +7,10 @@ import { and, eq } from "drizzle-orm";
import { type Db, companySecrets, connectionGrants } from "@paperclipai/db";
import {
AI_CONNECTION_CAPABILITIES,
aiConnectionMetadataSchema, aiRoutingHarness,
type AiConnectionBinding,
} from "@paperclipai/shared";
import { managedProviderRouting } from "./ai-provider-routing.js";
import { aiConnectionService } from "./ai-connections.js";
import { secretService } from "./secrets.js";
import { decideCodexAuthMerge } from "@paperclipai/adapter-codex-local/server";
@@ -23,6 +25,11 @@ export function isAiConnectionBusy(error: unknown): error is HttpError {
// Blank values intentionally override inherited credentials in CLI child environments.
export const AI_AUTH_ENV_KEYS = [
"PAPERCLIP_AI_PROVIDER_KEY", "PAPERCLIP_AI_PROVIDER_URL", "PAPERCLIP_CODEX_PROVIDERS",
"GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GEMINI_BASE_URL", "GOOGLE_GENAI_USE_VERTEXAI",
"HERMES_HOME", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_ARN", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI",
"ANTHROPIC_BEDROCK_BASE_URL", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
@@ -175,6 +182,7 @@ function managedAiHomeEnvironment(home: string): Record<string, string> {
CODEX_HOME: providerHome,
GROK_HOME: providerHome,
CLAUDE_CONFIG_DIR: providerHome,
HERMES_HOME: providerHome,
};
}
@@ -218,6 +226,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",
]) {
if (configuredEnv[key])
throw unprocessable(
@@ -225,7 +234,8 @@ export async function prepareManagedAiRuntime(
{ code: "ai_connection_incompatible" },
);
}
await assertManagedAiProjectAuth(input.config, input.binding.provider);
const harness = aiRoutingHarness(input.adapterType, input.config.provider, input.config.acpxAgent);
await assertManagedAiProjectAuth(input.config, harness === "claude_local" ? "anthropic" : harness === "codex_local" ? "openai" : input.binding.provider);
const service = aiConnectionService(db);
let selection = await service.select({
...input,
@@ -265,21 +275,22 @@ export async function prepareManagedAiRuntime(
...Object.fromEntries(AI_AUTH_ENV_KEYS.map((key) => [key, ""])),
...managedAiHomeEnvironment(home),
};
const routing = aiConnectionMetadataSchema.parse(selection.connection.config.ai).routing;
const capability =
AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[
selection.attribution.method
]!;
const authFile = path.join(providerHome, "auth.json");
if (input.binding.provider === "openai")
if (harness === "codex_local")
await writeFile(
path.join(providerHome, "config.toml"),
'cli_auth_credentials_store = "file"\n',
{ mode: 0o600 },
);
if (subscriptionFile) await writeFile(authFile, value, { mode: 0o600 });
else env[capability.envKey] = value;
if (!routing && subscriptionFile) await writeFile(authFile, value, { mode: 0o600 });
else if (!routing) env[capability.envKey] = value;
if (
input.binding.provider === "openai" &&
!routing && input.binding.provider === "openai" &&
selection.attribution.method === "api_key"
) {
env.CODEX_API_KEY = value;
@@ -287,12 +298,18 @@ export async function prepareManagedAiRuntime(
mode: 0o600,
});
}
if (input.binding.provider === "openrouter") {
if (!routing && input.binding.provider === "openrouter") {
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
provider: { openrouter: { options: { apiKey: value } } },
});
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
}
const projected = routing ? managedProviderRouting(routing, harness, value, typeof input.config.model === "string" ? input.config.model : "") : undefined;
if (projected) {
Object.assign(env, projected.env);
if (projected.hermesConfig) await writeFile(path.join(providerHome, "config.yaml"), projected.hermesConfig, { mode: 0o600 });
if (projected.codexConfig) await writeFile(path.join(providerHome, "config.toml"), 'cli_auth_credentials_store = "file"\n' + projected.codexConfig, { mode: 0o600 });
}
const generation = createHash("sha256")
.update(value)
.digest("hex")
@@ -301,6 +318,8 @@ export async function prepareManagedAiRuntime(
return {
config: {
...input.config,
...projected?.config,
...(routing ? { managedAiRouting: routing } : {}),
env,
managedAiConnection: { ...selection.attribution, identity },
},
+21 -14
View File
@@ -194,9 +194,8 @@ export function aiConnectionService(db: Db) {
throw unprocessable(
"Reconnect this account before making it your default",
);
const metadata = aiConnectionMetadataSchema.parse(
row.connection.config.ai,
);
const metadata = aiConnectionMetadataSchema.parse(row.connection.config.ai);
if (metadata.routing) throw unprocessable("Custom providers use an explicit connection selection on the agent.");
// Keep old servers' method preferences intact during an additive rollout.
await tx.insert(aiConnectionDefaults)
.values({ companyId, userId, ...metadata, grantId })
@@ -383,7 +382,7 @@ export function aiConnectionService(db: Db) {
!installs.length &&
!(
(input.allowUninstalledPersonal &&
binding.mode === "responsible_user" && grant.subjectUserId === userId) ||
(binding.mode === "responsible_user" || binding.mode === "delegated") && grant.subjectUserId === userId) ||
(input.allowUninstalledShared && binding.mode === "shared" && grant.kind === "organization")
)
)
@@ -401,6 +400,8 @@ export function aiConnectionService(db: Db) {
};
}
async function credential(row: Pick<Awaited<ReturnType<typeof select>>, "connection" | "grant">) {
const metadata = aiConnectionMetadataSchema.parse(row.connection.config.ai);
if (metadata.routing?.auth === "none") return "";
const ref = row.grant.credentialSecretRefs.find(
(r) => r.configPath === "ai.credential",
);
@@ -464,6 +465,7 @@ export function aiConnectionService(db: Db) {
sessionId?: string,
attemptStartedAt = new Date(),
) {
const routing = "routing" in input ? input.routing : undefined;
if (!(await membership(companyId, userId)))
throw forbidden("An active company member must own this connection");
const reconnect = input.connectionId
@@ -486,6 +488,8 @@ export function aiConnectionService(db: Db) {
input.provider
)
throw unprocessable("Reconnect cannot change providers");
if (reconnect && JSON.stringify((reconnect.connection.config.ai as AiConnectionMetadata).routing) !== JSON.stringify(routing))
throw unprocessable("Reconnect must retain this connection’s routing. Create another connection to change its destination.");
if (
reconnect &&
((reconnect.connection.config.ai as AiConnectionMetadata).method !==
@@ -570,7 +574,8 @@ export function aiConnectionService(db: Db) {
: source?.name === `ai-${grantId}`;
if (!privateSlot) secretId = undefined;
}
if (secretId)
if (!verifiedCredential) { secretId = undefined; }
else if (secretId)
await secrets.rotate(
secretId,
{ value: verifiedCredential },
@@ -618,15 +623,17 @@ export function aiConnectionService(db: Db) {
if (targets.length !== new Set(input.agentIds).size)
throw forbidden("Agent does not belong to this company");
}
const key = `app-gallery:${input.provider}`;
const source = routing?.kind === "bedrock" || routing?.kind === "gateway" || routing?.kind === "local" ? routing.kind : input.provider;
const providerName = source === "bedrock" ? "Amazon Bedrock" : source === "gateway" ? "Model gateway" : source === "local" ? "Local models" : AI_CONNECTION_CAPABILITIES[input.provider].name;
const key = `app-gallery:${source}`;
await tx
.insert(toolApplications)
.values({
companyId,
applicationKey: key,
name: AI_CONNECTION_CAPABILITIES[input.provider].name,
name: providerName,
type: "mcp_http",
metadata: { sourceTemplateKey: input.provider },
metadata: { sourceTemplateKey: source },
ownerUserId: userId,
})
.onConflictDoNothing();
@@ -640,7 +647,7 @@ export function aiConnectionService(db: Db) {
eq(toolApplications.applicationKey, key),
eq(
toolApplications.name,
AI_CONNECTION_CAPABILITIES[input.provider].name,
providerName,
),
),
),
@@ -676,8 +683,8 @@ export function aiConnectionService(db: Db) {
enabled: true,
healthStatus: "ok",
config: {
sourceTemplateKey: input.provider,
ai: { provider: input.provider, method: input.method },
sourceTemplateKey: source,
ai: { provider: input.provider, method: input.method, ...(routing ? { routing } : {}) },
aiIsolatedSubscription: input.method === "subscription" && input.provider !== "anthropic",
},
createdByUserId: userId,
@@ -704,14 +711,14 @@ export function aiConnectionService(db: Db) {
/* Safe account identity is optional. */
}
}
const refs = [
const refs = secretId ? [
{
secretId: secretId!,
configPath: "ai.credential",
required: true,
versionSelector: "latest" as const,
},
];
] : [];
if (reconnect)
await tx
.update(connectionGrants)
@@ -742,7 +749,7 @@ export function aiConnectionService(db: Db) {
.from(toolConnections)
.where(eq(toolConnections.id, id));
await syncConnectionCredentialBindings(tx, savedConnection, refs);
if (input.ownership === "personal") {
if (input.ownership === "personal" && !routing) {
await tx
.insert(aiConnectionDefaults)
.values({
@@ -0,0 +1,81 @@
import {
aiAwsCredentialsSchema,
aiRoutingBaseUrl,
aiRoutingModel,
type AiProviderRouting,
} from "@paperclipai/shared";
/** Projects one authoritative connection into an isolated harness environment. */
export function managedProviderRouting(
route: AiProviderRouting,
harness: string,
credential: string,
model: string,
) {
const env: Record<string, string> = {};
const config: Record<string, unknown> = {};
let codexConfig = "";
let hermesConfig = "";
const baseUrl = aiRoutingBaseUrl(route, harness);
if (route.kind === "bedrock") {
env.CLAUDE_CODE_USE_BEDROCK = "1";
env.AWS_REGION = route.region!;
env.AWS_DEFAULT_REGION = route.region!;
env.AWS_EC2_METADATA_DISABLED = "true";
if (route.auth === "aws_credentials") {
const aws = aiAwsCredentialsSchema.parse(JSON.parse(credential));
env.AWS_ACCESS_KEY_ID = aws.accessKeyId;
env.AWS_SECRET_ACCESS_KEY = aws.secretAccessKey;
env.AWS_SESSION_TOKEN = aws.sessionToken ?? "";
} else env.AWS_BEARER_TOKEN_BEDROCK = credential;
} else if (harness === "codex_local") {
env.PAPERCLIP_AI_PROVIDER_KEY = credential;
codexConfig = `model_provider = "paperclip"\n[model_providers.paperclip]\nname = "Paperclip connection"\nbase_url = ${JSON.stringify(baseUrl)}\nwire_api = "responses"\nrequires_openai_auth = false\n${route.auth === "none" ? "" : 'env_key = "PAPERCLIP_AI_PROVIDER_KEY"\n'}`;
} else if (harness === "claude_local") {
env.ANTHROPIC_BASE_URL = baseUrl;
env[
route.auth === "api_key" ? "ANTHROPIC_API_KEY" : "ANTHROPIC_AUTH_TOKEN"
] = credential;
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
if (model) {
env.ANTHROPIC_MODEL = model;
env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model;
env.CLAUDE_CODE_SUBAGENT_MODEL = model;
}
} else if (harness === "opencode_local") {
const provider = route.kind === "openrouter" ? "openrouter" : "paperclip";
if (route.kind === "openrouter") env.OPENROUTER_API_KEY = credential;
else {
env.PAPERCLIP_AI_PROVIDER_KEY = credential;
env.PAPERCLIP_AI_PROVIDER_URL = baseUrl;
}
const id = model.startsWith(`${provider}/`)
? model.slice(provider.length + 1)
: model;
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
provider: {
[provider]: {
...(provider === "paperclip"
? { npm: "@ai-sdk/openai-compatible", name: "Paperclip connection" }
: {}),
options: { baseURL: baseUrl, apiKey: credential },
models: id ? { [id]: { name: id } } : {},
},
},
enabled_providers: [provider],
});
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
config.model = aiRoutingModel(route, harness, model);
} else if (harness === "hermes_local") {
env.OPENAI_BASE_URL = baseUrl;
env.OPENAI_API_KEY = credential;
env.OPENROUTER_API_KEY = route.kind === "openrouter" ? credential : "";
config.provider = route.kind === "openrouter" ? "openrouter" : "auto";
// Hermes now reads custom endpoint routing from config.yaml, not OPENAI_BASE_URL.
env.PAPERCLIP_AI_PROVIDER_KEY = credential;
hermesConfig = `model:\n provider: ${route.kind === "openrouter" ? '"openrouter"' : '"custom"'}\n default: ${JSON.stringify(model)}\n base_url: ${JSON.stringify(baseUrl)}\n api_mode: "chat_completions"\n${route.auth === "none" ? "" : ' api_key: "${PAPERCLIP_AI_PROVIDER_KEY}"\n'}`;
}
return { env, config, codexConfig, hermesConfig };
}
@@ -84,6 +84,15 @@ describe("Paperclip Runner native provider configuration", () => {
});
});
it("keeps the connection's authoritative model namespace in durable input", () => {
const projected = projectPaperclipRunnerTaskConfig("opencode_server",
{ provider: "opencode", model: "openai/gpt-5.4" },
{ provider: "codex", model: "team-alias" },
"paperclip/team-alias");
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "opencode_server", adapterConfig: projected }))
.toMatchObject({ provider: "opencode", model: "paperclip/team-alias" });
});
it("requires persisted Claude recovery to use the qualified identity and current profile secret", () => {
const stored = {
id: "00000000-0000-4000-8000-000000000001",
+1
View File
@@ -24100,6 +24100,7 @@ export function heartbeatService(
nativeRuntimeResolution.profile.backend,
agent.adapterConfig,
issueAssigneeOverrides?.adapterConfig,
managedAiRuntime ? readNonEmptyString(resolvedConfig.model) ?? undefined : undefined,
)
: agent.adapterConfig,
managedProfile,
@@ -148,11 +148,14 @@ export function projectPaperclipRunnerTaskConfig(
backend: "codex_app_server" | "opencode_server",
agentConfig: unknown,
taskOverrides: unknown,
managedModel?: string,
): Record<string, unknown> {
const base = asRecord(agentConfig);
const task = asRecord(taskOverrides);
const config = { ...base };
const model = optionalString(task.model);
// The server resolves a connection's model namespace after task overrides.
// Carry that model into durable input without allowing routing to change the harness.
const model = optionalString(managedModel) ?? optionalString(task.model);
const effortKey = backend === "codex_app_server"
? ["modelReasoningEffort", "reasoningEffort", "effort"].find((key) => key in task)
: undefined;
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-_R_0_" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-_R_0_)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M23 12.245c0-.905-.075-1.565-.236-2.25h-10.54v4.083h6.186c-.124 1.014-.797 2.542-2.294 3.569l-.021.136 3.332 2.53.23.022C21.779 18.417 23 15.593 23 12.245z" fill="#4285F4"></path><path d="M12.225 23c3.03 0 5.574-.978 7.433-2.665l-3.542-2.688c-.948.648-2.22 1.1-3.891 1.1a6.745 6.745 0 01-6.386-4.572l-.132.011-3.465 2.628-.045.124C4.043 20.531 7.835 23 12.225 23z" fill="#34A853"></path><path d="M5.84 14.175A6.65 6.65 0 015.463 12c0-.758.138-1.491.361-2.175l-.006-.147-3.508-2.67-.115.054A10.831 10.831 0 001 12c0 1.772.436 3.447 1.197 4.938l3.642-2.763z" fill="#FBBC05"></path><path d="M12.225 5.253c2.108 0 3.529.892 4.34 1.638l3.167-3.031C17.787 2.088 15.255 1 12.225 1 7.834 1 4.043 3.469 2.197 7.062l3.63 2.763a6.77 6.77 0 016.398-4.572z" fill="#EB4335"></path></svg>

After

Width:  |  Height:  |  Size: 920 B

+12
View File
@@ -32,6 +32,12 @@
"catalogVisible": true,
"localAsset": "/brands/apps/asana.svg"
},
{
"slug": "bedrock",
"provider": "Amazon Bedrock",
"catalogVisible": false,
"localAsset": "/brands/apps/bedrock.svg"
},
{
"slug": "beehiiv",
"provider": "beehiiv",
@@ -154,6 +160,12 @@
"catalogVisible": true,
"localAsset": "/brands/apps/google-sheets.svg"
},
{
"slug": "google",
"provider": "Google",
"catalogVisible": false,
"localAsset": "/brands/apps/google.svg"
},
{
"slug": "google-drive",
"provider": "Google Drive",
@@ -1,12 +1,32 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { act } from "react";
import type { ReactNode } from "react";
import { beforeAll, describe, expect, it } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CodexLocalConfigFields } from "./config-fields";
function renderRunner(config: Record<string, unknown>): string {
return renderToStaticMarkup(
beforeAll(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); });
async function renderMarkup(node: ReactNode, expand?: string): Promise<string> {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => root.render(node));
if (expand) await act(async () => {
container.querySelector(`[aria-label="${expand}"]`)?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
const html = document.body.innerHTML;
await act(async () => root.unmount());
container.remove();
return html;
}
async function renderRunner(config: Record<string, unknown>, expand?: string): Promise<string> {
return renderMarkup(
<TooltipProvider>
<CodexLocalConfigFields
mode="edit"
@@ -21,6 +41,7 @@ function renderRunner(config: Record<string, unknown>): string {
hideInstructionsFile
/>
</TooltipProvider>,
expand,
);
}
@@ -30,19 +51,19 @@ describe("Paperclip Runner Codex configuration", () => {
["approve-paperclip", "Automatic Paperclip actions"],
["approve-reads", "Allow Paperclip reads"],
["deny-all", "Deny all"],
])("displays Grok's default or saved permission mode %s", (acpxPermissionMode, label) => {
const html = renderRunner({ provider: "acpx", acpxAgent: "grok", acpxPermissionMode });
expect(html).toContain('<option value="grok" selected="">Grok Build</option>');
])("displays Grok's default or saved permission mode %s", async (acpxPermissionMode, label) => {
const html = await renderRunner({ provider: "acpx", acpxAgent: "grok", acpxPermissionMode });
expect(html).toContain('Grok Build');
expect(html).toContain('aria-label="Permission mode"');
expect(html).toContain(label);
});
it("exposes all qualified provider choices", () => {
const html = renderRunner({ provider: "codex" });
it("exposes all qualified provider choices", async () => {
const html = await renderRunner({ provider: "codex" }, "Harness");
expect(html).toContain('<option value="codex" selected="">Codex</option>');
expect(html).toContain('aria-label="Harness"');
expect(html).toContain("OpenCode 1.18.32");
expect(html).toContain('<option value="acpx">ACP agents</option>');
expect(html).toContain('ACP agents');
expect(html).not.toContain("Permission mode");
expect(html).not.toContain("Ask when requested");
expect(html).not.toContain("Ask for untrusted operations");
@@ -51,14 +72,14 @@ describe("Paperclip Runner Codex configuration", () => {
expect(html).not.toContain("Bypass sandbox");
});
it("renders OpenCode's bounded permission modes", () => {
const html = renderRunner({
it("renders OpenCode's bounded permission modes", async () => {
const html = await renderRunner({
provider: "opencode",
opencodePermissionMode: "allow",
});
expect(html).toContain(
'<option value="opencode" selected="">OpenCode 1.18.32</option>',
'OpenCode 1.18.32',
);
expect(html).toContain("Full auto (allow)");
expect(html).toContain('aria-label="Permission mode"');
@@ -66,27 +87,28 @@ describe("Paperclip Runner Codex configuration", () => {
expect(html).not.toContain("Ask for untrusted operations");
});
it("offers qualified Claude and keeps candidate ACP agents visibly disabled", () => {
const html = renderRunner({
it("offers qualified Claude and keeps candidate ACP agents visibly disabled", async () => {
const html = await renderRunner({
provider: "acpx",
acpxAgent: "claude",
acpxPermissionMode: "approve-reads",
});
}, "ACP agent");
expect(html).toContain('<option value="acpx" selected="">ACP agents</option>');
expect(html).toContain('ACP agents');
expect(html).toContain("ACP agent");
expect(html).toContain('<option value="claude" selected="">Claude</option>');
expect(html).toContain('<option value="cursor" disabled="">Cursor — qualification pending</option>');
expect(html).toContain('<option value="copilot" disabled="">GitHub Copilot — qualification pending</option>');
expect(html).toContain('<option value="pi" disabled="">Pi — qualification pending</option>');
expect(html).toContain('aria-label="ACP agent"');
expect(html.match(/role="option"[^>]*data-disabled=""/g)).toHaveLength(3);
expect(html).toContain('Cursor — qualification pending');
expect(html).toContain('GitHub Copilot — qualification pending');
expect(html).toContain('Pi — qualification pending');
expect(html).not.toContain("Codex via ACPX");
expect(html).not.toContain("ACPX Codex");
expect(html).not.toContain("Pi via ACPX");
expect(html).toContain("Allow Paperclip reads");
});
it("falls back to the fail-closed Codex permission mode", () => {
const html = renderRunner({ codexPermissionMode: "unrestricted" });
it("falls back to the fail-closed Codex permission mode", async () => {
const html = await renderRunner({ codexPermissionMode: "unrestricted" });
expect(html).toContain("Unsupported saved mode — select a qualified mode");
expect(html).toContain("cannot start or recover a Paperclip Runner run");
@@ -94,12 +116,12 @@ describe("Paperclip Runner Codex configuration", () => {
expect(html).not.toContain("Full auto (never ask)");
});
it("shows a bounded idle timeout only for warm sessions", () => {
const warmHtml = renderRunner({
it("shows a bounded idle timeout only for warm sessions", async () => {
const warmHtml = await renderRunner({
lifecycleMode: "warm",
idleTimeoutMs: 45_000,
});
const turnHtml = renderRunner({
const turnHtml = await renderRunner({
lifecycleMode: "per_turn",
idleTimeoutMs: 45_000,
});
+39 -22
View File
@@ -1,4 +1,5 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
import { AdapterMark } from "../../components/AdapterMark";
import { configFieldsForSection } from "../config-sections";
import type { AdapterConfigFieldsProps } from "../types";
import {
@@ -36,6 +37,14 @@ const defaultOpenCodeRunnerModel = "openrouter/deepseek/deepseek-v4-flash-0731";
const defaultAcpxClaudeModel = "claude-sonnet-5";
const defaultClaudeManagedModel = "claude-sonnet-5";
const defaultAwsAgentCoreModel = "global.anthropic.claude-sonnet-4-6";
const runnerHarnessOptions = [
{ value: "codex", label: "Codex", adapter: "codex_local" },
{ value: "opencode", label: "OpenCode 1.18.32", adapter: "opencode_local" },
{ value: "claude_managed", label: "Claude Managed", adapter: "claude_local" },
{ value: "aws_agentcore", label: "AWS AgentCore", adapter: "aws_agentcore" },
{ value: "acpx", label: "ACP agents", adapter: "acpx_local" },
{ value: "grok", label: "Grok Build", adapter: "grok_local" },
];
export function CodexLocalConfigFields({
section,
@@ -191,16 +200,15 @@ export function CodexLocalConfigFields({
)}
{runnerManaged && (
<Field configSection="adapter"
label="Provider"
hint="The runner persists this provider with each run so recovery cannot drift after configuration changes."
label="Harness"
hint="Choose the agent harness that runs your tasks."
>
<select
className={inputClass}
<Select
value={runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") === "grok" ? "grok" : runnerProvider}
onChange={(event) => {
const grok = event.target.value === "grok";
const provider = grok ? "acpx" : isPaperclipRunnerProvider(event.target.value)
? event.target.value
onValueChange={(value) => {
const grok = value === "grok";
const provider = grok ? "acpx" : isPaperclipRunnerProvider(value)
? value
: "codex";
const model =
provider === "opencode"
@@ -230,30 +238,39 @@ export function CodexLocalConfigFields({
}
}}
>
<option value="codex">Codex</option>
<option value="opencode">OpenCode 1.18.32</option>
<option value="claude_managed">Claude Managed</option>
<option value="aws_agentcore">AWS AgentCore</option>
<option value="acpx">ACP agents</option>
<option value="grok">Grok Build</option>
</select>
<SelectTrigger className="w-full" aria-label="Harness"><SelectValue /></SelectTrigger>
<SelectContent>
{runnerHarnessOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AdapterMark type={option.adapter} className="size-4" />
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
{runnerManaged && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") !== "grok" && (
<Field configSection="adapter" label="ACP agent" hint="Cursor, GitHub Copilot, and Pi are awaiting local and Daytona qualification.">
<select className={inputClass}
<Select
value={String(isCreate ? values!.adapterSchemaValues?.acpxAgent ?? "claude" : eff("adapterConfig", "acpxAgent", config.acpxAgent ?? "claude"))}
onChange={(event) => {
const profile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(entry => entry.value === event.target.value);
onValueChange={(value) => {
const profile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(entry => entry.value === value);
if (!profile?.qualified) return;
if (isCreate) set!({ model: profile.value === "claude" ? defaultAcpxClaudeModel : "",
adapterSchemaValues: { ...values!.adapterSchemaValues, acpxAgent: profile.value } });
else { mark("adapterConfig", "acpxAgent", profile.value); mark("adapterConfig", "model", profile.value === "claude" ? defaultAcpxClaudeModel : ""); }
}}>
{PAPERCLIP_RUNNER_ACPX_PROFILES.map(profile => <option key={profile.value} value={profile.value} disabled={!profile.qualified}>
{profile.label}{profile.qualified ? "" : " — qualification pending"}
</option>)}
</select>
<SelectTrigger className="w-full" aria-label="ACP agent"><SelectValue /></SelectTrigger>
<SelectContent>
{PAPERCLIP_RUNNER_ACPX_PROFILES.map(profile => (
<SelectItem key={profile.value} value={profile.value} disabled={!profile.qualified}>
<AdapterMark type={profile.value === "cursor" ? "cursor" : `${profile.value}_local`} className="size-4" />
{profile.label}{profile.qualified ? "" : " — qualification pending"}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
{runnerManaged && runnerProvider === "claude_managed" && (
+43 -24
View File
@@ -1,6 +1,10 @@
import { renderToStaticMarkup } from "react-dom/server";
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { act } from "react";
import type { ReactNode } from "react";
import type { ComponentType } from "react";
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { TooltipProvider } from "../components/ui/tooltip";
import type { AdapterConfigFieldsProps, AdapterConfigSection } from "./types";
import { CodexLocalConfigFields } from "./codex-local/config-fields";
@@ -10,13 +14,29 @@ import { ProcessConfigFields } from "./process/config-fields";
import { OpenClawGatewayConfigFields } from "./openclaw-gateway/config-fields";
import { HermesGatewayConfigFields } from "./hermes-gateway/config-fields";
function renderSection(
beforeAll(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); });
async function renderMarkup(node: ReactNode, expand?: string): Promise<string> {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => root.render(node));
if (expand) await act(async () => {
container.querySelector(`[aria-label="${expand}"]`)?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
const html = container.innerHTML;
await act(async () => root.unmount());
container.remove();
return html;
}
async function renderSection(
Component: ComponentType<AdapterConfigFieldsProps>,
adapterType: string,
section: AdapterConfigSection,
config: Record<string, unknown> = {},
) {
return renderToStaticMarkup(
return renderMarkup(
<TooltipProvider>
<Component
mode="edit"
@@ -36,31 +56,31 @@ function renderSection(
}
describe("adapter configuration sections", () => {
it("separates provider selection from lifecycle and hides fixed Codex permissions", () => {
it("separates provider selection from lifecycle and hides fixed Codex permissions", async () => {
const config = {
provider: "codex",
lifecycleMode: "warm",
idleTimeoutMs: 45000,
};
const adapter = renderSection(
const adapter = await renderSection(
CodexLocalConfigFields,
"paperclip_runner",
"adapter",
config,
);
const configuration = renderSection(
const configuration = await renderSection(
CodexLocalConfigFields,
"paperclip_runner",
"configuration",
config,
);
const policy = renderSection(
const policy = await renderSection(
CodexLocalConfigFields,
"paperclip_runner",
"runPolicy",
config,
);
expect(adapter).toContain('<option value="acpx">ACP agents</option>');
expect(adapter).toContain('aria-label="Harness"');
expect(adapter).not.toContain("Runner lifecycle");
expect(configuration).not.toContain("Permission mode");
expect(configuration).not.toContain("Runner lifecycle");
@@ -69,13 +89,12 @@ describe("adapter configuration sections", () => {
expect(policy).not.toContain("ACP agents");
});
it("keeps ACP agent admission choices in the adapter section", () => {
it("keeps ACP agent admission choices in the adapter section", async () => {
const config = { provider: "acpx", acpxAgent: "claude" };
const adapter = renderSection(CodexLocalConfigFields, "paperclip_runner", "adapter", config);
const policy = renderSection(CodexLocalConfigFields, "paperclip_runner", "runPolicy", config);
const adapter = await renderSection(CodexLocalConfigFields, "paperclip_runner", "adapter", config);
const policy = await renderSection(CodexLocalConfigFields, "paperclip_runner", "runPolicy", config);
expect(adapter).toContain('<option value="claude" selected="">Claude</option>');
expect(adapter).toContain('<option value="pi" disabled="">Pi — qualification pending</option>');
expect(adapter).toContain('aria-label="ACP agent"');
expect(adapter).not.toContain("Runner lifecycle");
expect(policy).toContain("Runner lifecycle");
expect(policy).not.toContain("ACP agent");
@@ -88,31 +107,31 @@ describe("adapter configuration sections", () => {
["gemini_local", GeminiLocalConfigFields],
] as const)(
"separates ACP commands and lifecycle for %s",
(type, Component) => {
async (type, Component) => {
const config = {
engine: "acp",
agentCommand: "saved-command",
warmHandleIdleMs: 1234,
};
expect(renderSection(Component, type, "advanced", config)).toContain(
expect(await renderSection(Component, type, "advanced", config)).toContain(
'value="saved-command"',
);
expect(
renderSection(Component, type, "configuration", config),
await renderSection(Component, type, "configuration", config),
).not.toContain("ACP server command");
const policy = renderSection(Component, type, "runPolicy", config);
const policy = await renderSection(Component, type, "runPolicy", config);
expect(policy).toContain("ACP session mode");
expect(policy).toContain('value="1234"');
expect(policy).not.toContain("ACP server command");
},
);
it("keeps process command and arguments under Advanced with saved values", () => {
it("keeps process command and arguments under Advanced with saved values", async () => {
const config = { command: "node", args: ["worker.js", "--quiet"] };
expect(
renderSection(ProcessConfigFields, "process", "configuration", config),
await renderSection(ProcessConfigFields, "process", "configuration", config),
).toBe("");
const advanced = renderSection(
const advanced = await renderSection(
ProcessConfigFields,
"process",
"advanced",
@@ -127,12 +146,12 @@ describe("adapter configuration sections", () => {
["hermes_gateway", HermesGatewayConfigFields],
] as const)(
"moves %s timeouts without changing their values",
(type, Component) => {
async (type, Component) => {
const config = { timeoutSec: 37 };
expect(
renderSection(Component, type, "configuration", config),
await renderSection(Component, type, "configuration", config),
).not.toContain('value="37"');
expect(renderSection(Component, type, "runPolicy", config)).toContain(
expect(await renderSection(Component, type, "runPolicy", config)).toContain(
'value="37"',
);
},
+59
View File
@@ -0,0 +1,59 @@
import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
import { cn } from "@/lib/utils";
const brandMarks: Record<string, { src: string; dark?: string }> = {
claude_local: { src: "/brands/claude-color.svg" },
codex_local: { src: "/brands/codex-color.svg" },
gemini_local: { src: "/brands/adapters/gemini-color.svg" },
kimi_local: {
src: "/brands/adapters/kimi-color-light.svg",
dark: "/brands/adapters/kimi-color.svg",
},
...Object.fromEntries(
[
["cursor", "cursor"],
["cursor_cloud", "cursor"],
["grok_local", "grok"],
["hermes_local", "hermesagent"],
["hermes_gateway", "hermesagent"],
["pi_local", "pi"],
].map(([type, icon]) => [
type,
{
src: `/brands/adapters/${icon}.svg`,
dark: `/brands/adapters/${icon}-dark.svg`,
},
]),
),
};
export function AdapterMark({
type,
className = "size-6",
}: {
type: string;
className?: string;
}) {
const Icon = getAdapterDisplay(type).icon;
const mark = brandMarks[type];
if (!mark) return <Icon className={className} />;
return (
<>
<img
src={mark.src}
className={cn(
"shrink-0 object-contain",
mark.dark && "dark:hidden",
className,
)}
alt=""
/>
{mark.dark && (
<img
src={mark.dark}
className={cn("hidden shrink-0 object-contain dark:block", className)}
alt=""
/>
)}
</>
);
}
+30 -12
View File
@@ -1,3 +1,5 @@
import { useConnectionModels } from "./ai-connections/useConnectionModels";
import { aiRoutingHarness } from "@paperclipai/shared";
import { AiConnectionField } from "./ai-connections/AiConnectionField";
import { aiConnectionBindingSchema } from "@paperclipai/shared";
import { testAgentSetup } from "@/lib/test-agent-setup";
@@ -42,6 +44,8 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { selectTriggerClassName } from "@/components/ui/select";
import { AdapterMark } from "./AdapterMark";
import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react";
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
import { copyTextToClipboard } from "../lib/clipboard";
@@ -76,7 +80,6 @@ import { getUIAdapter } from "../adapters";
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
import { MarkdownEditor } from "./MarkdownEditor";
import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { ReportsToPicker } from "./ReportsToPicker";
import {
EnvironmentVariablesEditor,
@@ -911,7 +914,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
});
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
const [refreshingModels, setRefreshingModels] = useState(false);
const models = fetchedModels ?? externalModels ?? [];
const connectionModels = useConnectionModels(selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, config.acpxAgent));
const models = connectionModels?.models ?? fetchedModels ?? externalModels ?? [];
const adapterCommandField = "command";
const {
data: detectedModelData,
@@ -946,7 +950,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
set: isCreate ? (patch: Partial<CreateConfigValues>) => props.onChange(patch) : null,
config,
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
mark: mark as (group: "adapterConfig", field: string, value: unknown) => void,
mark: (group: "adapterConfig", field: string, value: unknown) => mark(group, field,
field === "model" && typeof value === "string" ? connectionModels?.resolveModel(value) ?? value : value),
models,
// Resolve the effective instructions-file gate once. The instructions file
// is an absolute host path, so the managed-sandbox-only policy hides it for
@@ -1658,7 +1663,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field>
)}
{!isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={adapterType === "paperclip_runner" ? eff("adapterConfig", "provider", config.provider) === "codex" ? "codex_local" : eff("adapterConfig", "provider", config.provider) === "opencode" ? "opencode_local" : eff("adapterConfig", "provider", config.provider) === "acpx" && eff("adapterConfig", "acpxAgent", config.acpxAgent) === "grok" ? "grok_local" : eff("adapterConfig", "provider", config.provider) === "acpx" && eff("adapterConfig", "acpxAgent", config.acpxAgent) === "claude" ? "claude_local" : adapterType : adapterType}
{!isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={aiRoutingHarness(adapterType, eff("adapterConfig", "provider", config.provider), eff("adapterConfig", "acpxAgent", config.acpxAgent))}
value={aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data}
model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy
onChange={binding => mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding })} />}
@@ -1732,7 +1737,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
});
return;
}
mark("adapterConfig", "model", v || undefined);
mark("adapterConfig", "model", connectionModels?.resolveModel(v) || v || undefined);
if (clearUnsupportedEffort) {
mark("adapterConfig", thinkingEffortKey, undefined);
mark("adapterConfig", "reasoningEffort", undefined);
@@ -3666,19 +3671,26 @@ export function AdapterTypeDropdown({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<button
type="button"
data-size="default"
className={cn(selectTriggerClassName, "w-full")}
>
<span className="inline-flex min-w-0 items-center gap-1.5">
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
<span aria-hidden="true" className="inline-flex shrink-0">
<AdapterMark type={value} className="size-4" />
</span>
<span className="truncate">{adapterLabels[value] ?? getAdapterLabel(value)}</span>
{selectedDisplay.experimental && <ExperimentalBadge />}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
<ChevronDown className="size-4 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
{adapterList.map((item) => (
<button
key={item.value}
type="button"
disabled={item.comingSoon}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded",
@@ -3695,7 +3707,9 @@ export function AdapterTypeDropdown({
}}
>
<span className="inline-flex items-center gap-1.5">
{item.value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
<span aria-hidden="true" className="inline-flex shrink-0">
<AdapterMark type={item.value} className="size-4" />
</span>
<span>{item.label}</span>
{item.experimental && <ExperimentalBadge />}
</span>
@@ -3836,14 +3850,18 @@ export function ModelDropdown({
}}
>
<PopoverTrigger asChild>
<button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>
<button
type="button"
data-size="default"
className={cn(selectTriggerClassName, "w-full")}
>
<span className={cn("truncate", !value && "text-muted-foreground")}>
{selected
? selected.label
: value
|| (allowDefault ? (defaultLabel ?? "Default") : required ? "Select model (required)" : "Select model")}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
<ChevronDown className="size-4 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
+2 -2
View File
@@ -1016,7 +1016,7 @@ function OnboardingWizardInner({
const localLoginHealth = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get });
const canUseLocalLogin = resolvedLoginEnvironment?.driver === "local" && (localLoginHealth.data?.localAiLoginSupported ?? localLoginHealth.data?.deploymentMode === "local_trusted");
const localLogin = useLocalAiLogin(createdCompanyId, {
provider: managedProvider ?? "anthropic", method: "subscription",
provider: managedProvider === "openai" || managedProvider === "xai" ? managedProvider : "anthropic", method: "subscription",
name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`,
ownership: "personal", agentIds: [], allAgents: true,
}, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" &&
@@ -2815,7 +2815,7 @@ function OnboardingWizardInner({
adapterType={adapterType}
environmentId={resolvedLoginEnvironmentId}
chrome="onboarding"
aiConnection={managedProvider ? { provider: managedProvider, method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true } : undefined}
aiConnection={managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? { provider: managedProvider, method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true } : undefined}
autoStart
onPromptReady={(url) => {
setConnectAuthUrl(url);
@@ -27,7 +27,7 @@ export function AiConnectionAccountControls({
const activeDefault = account.isDefault && available;
return (
<section className="space-y-4" aria-label="AI account settings">
{ownPersonal && (
{ownPersonal && !account.routing && (
<div className={cn(
"flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border p-3",
activeDefault && "border-(--status-task-done)/30 bg-(--status-task-done)/5",
@@ -32,7 +32,7 @@ type Props = {
/** Connections hosts the same provider step as agent setup, with its own save intent. */
export function AiConnectionCredentialStep(props: Props) {
if (props.provider === "openrouter") return <ApiKeyConnectionStep {...props} />;
if (props.provider === "openrouter" || props.provider === "google") return <ApiKeyConnectionStep {...props} />;
return <SubscriptionConnectionStep {...props} />;
}
@@ -74,7 +74,7 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
const canLogin = environment?.driver === "sandbox" && caps.data?.sandboxProviders?.[sandboxProvider]?.supportsLoginPty === true;
const loading = [envs, caps, settings, experimental, general].some((query) => query.isPending);
const error = environmentError ?? [envs, caps, settings, experimental, general].find((query) => query.error)?.error?.message;
const intent: AiConnectionLoginIntent = { provider, method: "subscription", name, ownership, agentIds, allAgents, connectionId };
const intent: AiConnectionLoginIntent = { provider: provider as AiConnectionLoginIntent["provider"], method: "subscription", name, ownership, agentIds, allAgents, connectionId };
return <div className="mx-auto w-full min-w-0 max-w-xl space-y-6">
{!hideName && <label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(connectionId)} /></label>}
{!suppliedEnvironmentId && !forced.forced && loginEnvironments.length > 1 && <Select value={environmentId ?? ""} onValueChange={setChosenEnvironment}>
@@ -109,7 +109,7 @@ function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initial
return <div className="mx-auto w-full min-w-0 max-w-xl space-y-4">
{!hideName && <label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(connectionId)} /></label>}
{save.error && <p role="alert" className="text-sm text-destructive">{save.error.message}</p>}
<ProviderApiKeyCard providerName="OpenRouter" value={apiKey} onChange={setApiKey} onSubmit={() => save.mutate()} disabled={save.isPending} placeholder="Enter API key here" autoFocus />
<ProviderApiKeyCard providerName={provider === "google" ? "Google" : "OpenRouter"} value={apiKey} onChange={setApiKey} onSubmit={() => save.mutate()} disabled={save.isPending} placeholder="Enter API key here" autoFocus />
<div className="flex justify-between gap-2"><Button variant="ghost" onClick={onCancel}>Cancel</Button><Button disabled={!name.trim() || !apiKey.trim() || save.isPending} onClick={() => save.mutate()}>{save.isPending ? "Connecting…" : "Connect"}</Button></div>
</div>;
}
@@ -2,16 +2,16 @@ import { useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
aiConnectionBindingSchema,
isAiConnectionCompatible,
type AiConnectionBinding,
type AiAuthMethod,
type AiConnectionBinding,
type AiProvider,
type AiManagedConnectionSummary,
} from "@paperclipai/shared";
import { aiConnectionsApi } from "@/api/ai-connections";
import { AiConnectionPicker } from "./AiConnectionPicker";
import { AiConnectionLegacyNotice } from "./AiConnectionManagement";
import { AiConnectionSelect } from "./AiConnectionSelect";
import { AiProviderSetup } from "./AiProviderSetup";
import { AiConnectionCredentialStep } from "./AiConnectionCredentialStep";
import { AiConnectionLegacyNotice } from "./AiConnectionManagement";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -32,6 +32,8 @@ export function aiProviderForAdapter(
codex_local: "openai",
opencode_local: "openrouter",
grok_local: "xai",
gemini_local: "google",
hermes_local: "openrouter",
} as Record<string, AiProvider>
)[adapterType];
}
@@ -64,6 +66,7 @@ export function AiConnectionField({
const [adopting, setAdopting] = useState(false);
const [pendingAdoption, setPendingAdoption] = useState<AiConnectionBinding>();
const [connecting, setConnecting] = useState(false);
const [advancedSetup, setAdvancedSetup] = useState(false);
const [reconnecting, setReconnecting] = useState<AiManagedConnectionSummary>();
const [allAgents, setAllAgents] = useState(true);
const [savedAccount, setSavedAccount] = useState<{ connectionId: string; grantId: string; method: AiAuthMethod }>();
@@ -94,6 +97,7 @@ export function AiConnectionField({
const openConnection = (reconnect?: AiManagedConnectionSummary) => {
returnFocus.current = document.activeElement as HTMLElement;
setReconnecting(reconnect);
setAdvancedSetup(false);
setAllAgents(accounts.data?.canManageConnections ?? false);
setSavedAccount(undefined);
selectDefault.reset();
@@ -101,7 +105,7 @@ export function AiConnectionField({
};
const method: AiAuthMethod = (value?.mode !== "responsible_user" ? value?.method : undefined)
?? accounts.data?.connections.find((account) => account.provider === provider && account.isDefault)?.method
?? (provider === "openrouter" ? "api_key" : "subscription");
?? (provider === "openrouter" || provider === "google" ? "api_key" : "subscription");
if (!provider) return null;
if (legacy && !value && !adopting)
return (
@@ -112,13 +116,8 @@ export function AiConnectionField({
);
return (
<div className="space-y-4">
{value && (adapterType !== "opencode_local" || Boolean(model)) && !isAiConnectionCompatible(value, adapterType, model) && (
<p role="alert" className="text-sm text-destructive">
This connection does not support the current harness and model. Choose
a compatible connection before saving.
</p>
)}
<AiConnectionPicker
<AiConnectionSelect
adapterType={adapterType}
requirement={{ companyId, provider }}
connections={accounts.data?.connections ?? []}
value={value}
@@ -184,9 +183,18 @@ export function AiConnectionField({
<DialogHeader>
<DialogTitle>{reconnecting ? "Reconnect account" : "Connect account"}</DialogTitle>
<DialogDescription>
{reconnecting ? "Sign in again to repair your current default account. Its agent access stays the same." : "This account will become your default for this provider. Your tasks will use it; other users keep their own default."}
{advancedSetup ? "Choose a provider connection for this agent." : reconnecting ? "Sign in again to repair your current default account. Its agent access stays the same." : "This account will become your default for this provider. Your tasks will use it; other users keep their own default."}
</DialogDescription>
</DialogHeader>
{advancedSetup ? <AiProviderSetup
companyId={companyId} agentId={agentId} environmentId={environmentId}
onCancel={() => setAdvancedSetup(false)}
onComplete={binding => {
void client.invalidateQueries({ queryKey: ["ai-connections", companyId] });
setConnecting(false);
changeBinding(binding);
}}
/> : <>
{!reconnecting && !savedAccount && <label className="flex items-center gap-2 text-sm">
<Checkbox checked={allAgents} onCheckedChange={(checked) => setAllAgents(checked === true)} />
Allow all agents in this company to use this account for my tasks
@@ -214,6 +222,11 @@ export function AiConnectionField({
selectDefault.mutate(result);
}}
/>}
{!reconnecting && !savedAccount && <details>
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced providers</summary>
<Button type="button" variant="ghost" onClick={() => setAdvancedSetup(true)}>Choose another provider or gateway</Button>
</details>}
</>}
</DialogContent>
</Dialog>
</div>
@@ -0,0 +1,177 @@
import { useId } from "react";
import {
isAiConnectionCompatible,
type AiConnectionBinding,
} from "@paperclipai/shared";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AppLogo } from "@/pages/apps/AppLogo";
import { Button } from "@/components/ui/button";
import { aiConnectionProblem, personalAiDefault } from "./model";
import type { AiConnectionPickerProps } from "./AiConnectionPicker";
/** The same connection control on Create agent and Harness / Runtime. */
export function AiConnectionSelect({
requirement,
connections,
value,
currentUserId,
readOnly,
loading,
error,
onRetry,
onConnect,
onChange,
adapterType,
}: AiConnectionPickerProps & { adapterType: string }) {
const selectId = useId();
const personal = personalAiDefault(connections, requirement, currentUserId);
const selected =
value?.mode === "responsible_user"
? personal
: connections.find(
(c) => c.id === value?.connectionId && c.grantId === value?.grantId,
);
const compatible = connections
.filter(
(c) =>
c.companyId === requirement.companyId &&
isAiConnectionCompatible(
c,
adapterType,
c.provider === "openrouter" ? "openrouter/" : undefined,
),
)
.sort(
(a, b) =>
Number(b.id === selected?.id) - Number(a.id === selected?.id) ||
Number(Boolean(a.routing)) - Number(Boolean(b.routing)) ||
a.name.localeCompare(b.name),
);
const unavailable = Boolean(
value &&
value.mode !== "responsible_user" &&
!selected &&
!loading &&
!error,
);
const canUseDefault = isAiConnectionCompatible(
{
provider: requirement.provider,
method: "api_key",
mode: "responsible_user",
},
adapterType,
requirement.provider === "openrouter" ? "openrouter/" : undefined,
);
const incompatible =
selected && !compatible.some((c) => c.id === selected.id);
const change = (id: string) => {
if (id === "connect") return onConnect();
if (id === "responsible_user")
return onChange({
provider: requirement.provider,
method: personal?.method ?? "api_key",
mode: "responsible_user",
});
const connection = compatible.find((c) => c.id === id);
if (!connection) return;
onChange({
provider: connection.provider,
method: connection.method,
mode: connection.ownership === "shared" ? "shared" : "delegated",
connectionId: connection.id,
grantId: connection.grantId,
} satisfies AiConnectionBinding);
};
return (
<div className="space-y-2">
<label className="text-xs text-muted-foreground" htmlFor={selectId}>
Connection
</label>
<Select
value={
value?.mode === "responsible_user"
? "responsible_user"
: (value?.connectionId ?? "")
}
onValueChange={change}
disabled={readOnly || loading || Boolean(error)}
>
<SelectTrigger id={selectId} aria-label="Connection" className="w-full">
<SelectValue
placeholder={
loading ? "Loading connections…" : "Choose a connection"
}
>
{value?.mode === "responsible_user"
? "Responsible user’s default"
: (selected?.name ??
(value ? "Unavailable connection" : undefined))}
</SelectValue>
</SelectTrigger>
<SelectContent>
{incompatible && selected && (
<SelectItem value={selected.id} disabled>
{selected.name} · Incompatible
</SelectItem>
)}
{compatible.map((c) => (
<SelectItem
key={c.id}
value={c.id}
disabled={Boolean(aiConnectionProblem(c))}
>
<span className="flex items-center gap-2">
<AppLogo
name={c.name}
brandKey={
c.routing?.kind === "bedrock" ? "bedrock" : c.provider
}
size={16}
/>
{c.name}
{c.status !== "connected" ? " · Reconnect" : ""}
</span>
</SelectItem>
))}
{(canUseDefault || value?.mode === "responsible_user") && (
<SelectItem value="responsible_user" disabled={!canUseDefault}>
Responsible user’s default
</SelectItem>
)}
<SelectItem value="connect">Connect an account…</SelectItem>
</SelectContent>
</Select>
{error && (
<div className="space-y-2">
<p role="alert" className="text-sm text-destructive">
{error}
</p>
<Button type="button" variant="outline" onClick={onRetry}>
Retry connections
</Button>
</div>
)}
{unavailable ? (
<p role="alert" className="text-sm text-destructive">
This connection is unavailable. Choose another connection.
</p>
) : incompatible ? (
<p role="alert" className="text-sm text-destructive">
This connection does not support the selected harness. Choose a
compatible connection.
</p>
) : selected && aiConnectionProblem(selected) ? (
<p role="alert" className="text-sm text-destructive">
{aiConnectionProblem(selected)}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,514 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Network, Monitor } from "lucide-react";
import {
aiProviderRoutingSchema,
createAiConnectionSchema,
type AiProvider,
type AiProviderRouting,
type AiConnectionBinding,
type AiManagedConnectionSummary,
} from "@paperclipai/shared";
import { aiConnectionsApi } from "@/api/ai-connections";
import { agentsApi } from "@/api/agents";
import { ConnectionChoiceList } from "@/features/connections/ConnectionChoiceList";
import { AccessStepContent } from "@/features/connections/ConnectionSetupFlow";
import { AppLogo } from "@/pages/apps/AppLogo";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AiConnectionCredentialStep } from "./AiConnectionCredentialStep";
const providers = [
{
id: "openai",
name: "OpenAI",
description: "ChatGPT subscription or API key",
},
{
id: "anthropic",
name: "Anthropic",
description: "Claude subscription or API key",
},
{ id: "google", name: "Google", description: "Gemini API key" },
{ id: "xai", name: "xAI", description: "Grok subscription or API key" },
{
id: "openrouter",
name: "OpenRouter",
description: "Models through one API key",
advanced: true,
},
{
id: "bedrock",
name: "Amazon Bedrock",
description: "AWS credentials or Bedrock API key",
advanced: true,
},
{
id: "gateway",
name: "Custom gateway",
description: "Emissary or another compatible endpoint",
advanced: true,
},
{
id: "local",
name: "Local endpoint",
description: "A model server in the agent’s environment",
advanced: true,
},
] as const;
type ProviderChoice = (typeof providers)[number]["id"];
export function AiProviderSetup({
companyId,
agentId,
environmentId,
initialProvider,
reconnect,
onCancel,
onComplete,
}: {
companyId: string;
agentId?: string;
environmentId?: string;
initialProvider?: ProviderChoice;
reconnect?: AiManagedConnectionSummary;
onCancel: () => void;
onComplete: (
binding: Exclude<AiConnectionBinding, { mode: "responsible_user" }>,
) => void;
}) {
const [provider, setProvider] = useState<ProviderChoice | undefined>(
reconnect
? reconnect.routing?.kind === "gateway" ||
reconnect.routing?.kind === "local" ||
reconnect.routing?.kind === "bedrock"
? reconnect.routing.kind
: reconnect.provider
: initialProvider,
);
const [step, setStep] = useState(
reconnect ? "connect" : initialProvider ? "access" : "provider",
);
const [ownership, setOwnership] = useState<"personal" | "shared">(
reconnect?.ownership ?? "personal",
);
const [allAgents, setAllAgents] = useState(!agentId);
const [agentIds, setAgentIds] = useState(new Set(agentId ? [agentId] : []));
const [protocol, setProtocol] = useState<AiProviderRouting["protocol"]>(
reconnect?.routing?.protocol ?? "responses",
);
const [auth, setAuth] = useState<AiProviderRouting["auth"]>(
reconnect?.routing?.auth ?? "bearer",
);
const [baseUrl, setBaseUrl] = useState(reconnect?.routing?.baseUrl ?? "");
const [region, setRegion] = useState(
reconnect?.routing?.region ?? "us-east-1",
);
const [models, setModels] = useState(
reconnect?.routing?.models.map((m) => m.id).join(", ") ?? "",
);
const [apiKey, setApiKey] = useState("");
const [accessKeyId, setAccessKeyId] = useState("");
const [secretAccessKey, setSecretAccessKey] = useState("");
const [sessionToken, setSessionToken] = useState("");
const client = useQueryClient();
const agents = useQuery({
queryKey: ["agents", companyId, "provider-access"],
queryFn: () => agentsApi.list(companyId),
});
const label = providers.find((p) => p.id === provider)?.name ?? "provider";
const advanced = ["openrouter", "bedrock", "gateway", "local"].includes(
provider ?? "",
);
const nativeProvider: AiProvider =
provider === "bedrock"
? "anthropic"
: provider === "gateway" || provider === "local"
? protocol === "messages"
? "anthropic"
: "openai"
: (provider ?? "openai");
const name =
reconnect?.name ??
`${ownership === "personal" ? "My" : "Company"} ${
provider === "gateway" && baseUrl
? (() => {
try {
return new URL(baseUrl).hostname;
} catch {
return label;
}
})()
: label
}`;
const complete = (result: {
connectionId: string;
grantId: string;
method?: "api_key" | "subscription";
}) => {
void client.invalidateQueries({ queryKey: ["ai-connections", companyId] });
void client.invalidateQueries({ queryKey: ["tools"] });
onComplete({
provider: nativeProvider,
method: result.method ?? "api_key",
mode: ownership === "shared" ? "shared" : "delegated",
...result,
});
};
const save = useMutation({
mutationFn: () => {
const routing =
reconnect?.routing ??
aiProviderRoutingSchema.parse({
kind: provider,
protocol: provider === "bedrock" ? "bedrock" : protocol,
auth: provider === "openrouter" ? "bearer" : auth,
...(provider === "bedrock"
? { region }
: provider === "gateway" || provider === "local"
? { baseUrl }
: {}),
models: models
.split(",")
.map((id) => id.trim())
.filter(Boolean)
.map((id) => ({ id })),
});
return aiConnectionsApi.create(
companyId,
createAiConnectionSchema.parse({
provider: nativeProvider,
method: "api_key",
name,
ownership,
allAgents,
agentIds: [...agentIds],
connectionId: reconnect?.id,
routing,
...(auth === "aws_credentials"
? {
awsCredentials: {
accessKeyId,
secretAccessKey,
...(sessionToken ? { sessionToken } : {}),
},
}
: auth !== "none"
? { apiKey }
: {}),
}),
);
},
onSuccess: complete,
onSettled: () => {
setApiKey("");
setSecretAccessKey("");
setSessionToken("");
},
});
const choices = (advancedOnly: boolean) => (
<ConnectionChoiceList
choices={providers
.filter((p) => Boolean("advanced" in p && p.advanced) === advancedOnly)
.map((p) => ({
...p,
icon:
p.id === "gateway" || p.id === "local" ? (
<span className="flex size-9 items-center justify-center rounded-lg bg-muted">
{p.id === "gateway" ? (
<Network className="size-5" />
) : (
<Monitor className="size-5" />
)}
</span>
) : (
<AppLogo name={p.name} brandKey={p.id} />
),
}))}
onSelect={(id) => {
setProvider(id as ProviderChoice);
setStep("access");
setApiKey("");
setAuth("bearer");
save.reset();
}}
/>
);
return (
<div className="mx-auto w-full max-w-2xl space-y-6" onSubmit={(event) => event.stopPropagation()}>
<div className="space-y-2">
<h2 className="text-xl font-semibold">
{step === "provider"
? "Connect a model provider"
: `${reconnect ? "Reconnect" : "Connect"} ${label}`}
</h2>
{step !== "provider" && (
<p className="text-xs text-muted-foreground">
{step === "access" ? "Access" : "Connect"}
</p>
)}
</div>
{step === "provider" ? (
<>
{choices(false)}
<details>
<summary className="cursor-pointer text-sm text-muted-foreground">
Advanced providers
</summary>
<div className="pt-4">{choices(true)}</div>
</details>
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
</>
) : step === "access" ? (
<>
{agents.error && (
<p role="alert" className="text-sm text-destructive">
Could not load agents. Try again.
</p>
)}
<AccessStepContent
agents={agents.data ?? []}
authKind="api_key"
grantKind={ownership === "shared" ? "organization" : "user"}
grantKinds={["user", "organization"]}
setGrantKind={(kind) =>
setOwnership(kind === "organization" ? "shared" : "personal")
}
installChoice={allAgents ? "all" : "specific"}
setInstallChoice={(choice) => setAllAgents(choice === "all")}
installAgentIds={agentIds}
setInstallAgentIds={setAgentIds}
submitLabel="Continue"
onBack={() => setStep("provider")}
onContinue={() => setStep("connect")}
bare
/>
</>
) : !advanced ? (
<AiConnectionCredentialStep
companyId={companyId}
provider={nativeProvider}
connectionId={reconnect?.id}
initialMethod={reconnect?.method}
name={name}
hideName
ownership={ownership}
allAgents={allAgents}
agentIds={[...agentIds]}
environmentId={environmentId}
onCancel={() => (reconnect ? onCancel() : setStep("access"))}
onComplete={complete}
/>
) : (
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
>
{(provider === "gateway" || provider === "local") && (
<>
<label className="block space-y-2 text-xs text-muted-foreground">
Provider URL
<Input
aria-label="Provider URL"
placeholder={
provider === "local"
? "http://localhost:11434/v1"
: "https://gateway.example.com/v1"
}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
disabled={Boolean(reconnect)}
/>
</label>
<label className="block space-y-2 text-xs text-muted-foreground">
API format
<Select
value={protocol}
onValueChange={(v) => {
setProtocol(v as typeof protocol);
if (v !== "messages" && auth === "api_key")
setAuth("bearer");
}}
disabled={Boolean(reconnect)}
>
<SelectTrigger aria-label="API format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="responses">
OpenAI Responses · Codex
</SelectItem>
<SelectItem value="messages">
Anthropic Messages · Claude
</SelectItem>
<SelectItem value="chat">
Chat Completions · OpenCode, Hermes
</SelectItem>
</SelectContent>
</Select>
</label>
</>
)}
{provider === "bedrock" && (
<label className="block space-y-2 text-xs text-muted-foreground">
AWS region
<Input
aria-label="AWS region"
value={region}
onChange={(e) => setRegion(e.target.value)}
disabled={Boolean(reconnect)}
/>
</label>
)}
{provider !== "openrouter" && (
<label className="block space-y-2 text-xs text-muted-foreground">
Authentication
<Select
value={auth}
onValueChange={(v) => setAuth(v as typeof auth)}
disabled={Boolean(reconnect)}
>
<SelectTrigger aria-label="Authentication">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bearer">
{provider === "bedrock"
? "Bedrock API key"
: "Bearer token"}
</SelectItem>
{provider === "bedrock" ? (
<SelectItem value="aws_credentials">
AWS access keys
</SelectItem>
) : (
<>
{protocol === "messages" && (
<SelectItem value="api_key">
API key · x-api-key
</SelectItem>
)}
<SelectItem value="none">No authentication</SelectItem>
</>
)}
</SelectContent>
</Select>
</label>
)}
{auth === "aws_credentials" ? (
<>
<label className="block space-y-2 text-xs text-muted-foreground">
AWS access key ID
<Input
aria-label="AWS access key ID"
value={accessKeyId}
onChange={(e) => setAccessKeyId(e.target.value)}
autoComplete="off"
/>
</label>
<label className="block space-y-2 text-xs text-muted-foreground">
AWS secret access key
<Input
aria-label="AWS secret access key"
type="password"
value={secretAccessKey}
onChange={(e) => setSecretAccessKey(e.target.value)}
autoComplete="new-password"
/>
</label>
<label className="block space-y-2 text-xs text-muted-foreground">
Session token (optional)
<Input
aria-label="Session token"
type="password"
value={sessionToken}
onChange={(e) => setSessionToken(e.target.value)}
autoComplete="new-password"
/>
</label>
</>
) : (
auth !== "none" && (
<label className="block space-y-2 text-xs text-muted-foreground">
API key
<Input
aria-label="API key"
type="password"
autoComplete="new-password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</label>
)
)}
<details>
<summary className="cursor-pointer text-sm text-muted-foreground">
Advanced model settings
</summary>
<label className="mt-4 block space-y-2 text-xs text-muted-foreground">
Model IDs (comma separated)
<Input
aria-label="Model IDs"
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={Boolean(reconnect)}
/>
<span className="block">
Use the provider’s model ID or your gateway’s alias. You can
also enter one on the agent.
</span>
</label>
</details>
<p className="text-xs text-muted-foreground">
Test the connection with your chosen model in the agent’s execution
environment after connecting.
</p>
{save.error && (
<p role="alert" className="text-sm text-destructive">
{save.error instanceof Error && save.error.name === "ZodError"
? "Check the URL, API format, and authentication fields."
: save.error.message}
</p>
)}
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="ghost"
onClick={() => (reconnect ? onCancel() : setStep("access"))}
disabled={save.isPending}
>
Back
</Button>
<Button
type="submit"
disabled={
save.isPending ||
(auth === "aws_credentials"
? !accessKeyId || !secretAccessKey
: auth !== "none" && !apiKey.trim())
}
>
{save.isPending
? "Connecting…"
: reconnect
? "Reconnect"
: "Connect"}
</Button>
</div>
</form>
)}
</div>
);
}
@@ -6,7 +6,7 @@ import { toolsApi } from "@/api/tools";
import { useNavigate } from "@/lib/router";
import { AiConnectionAccountControls } from "./AiConnectionAccountControls";
import { AiConnectionUsagePanel } from "./AiConnectionUsagePanel";
import type { ToolConnection } from "@paperclipai/shared";
import type { ToolConnection, AiConnectionMetadata } from "@paperclipai/shared";
import { aiMethodLabel } from "./model";
export function ManagedAiConnectionRow({
@@ -14,16 +14,11 @@ export function ManagedAiConnectionRow({
}: {
connection: ToolConnection;
}) {
const metadata = connection.config?.ai as
| {
provider: "anthropic" | "openai" | "openrouter" | "xai";
method: "subscription" | "api_key";
}
| undefined;
const metadata = connection.config?.ai as AiConnectionMetadata | undefined;
if (!metadata) return null;
return (
<p className="text-xs text-muted-foreground">
{aiMethodLabel(metadata.provider, metadata.method)} ·{" "}
{metadata.routing ? metadata.routing.kind === "bedrock" ? `Bedrock · ${metadata.routing.region}` : metadata.routing.kind === "openrouter" ? "OpenRouter" : metadata.routing.baseUrl : aiMethodLabel(metadata.provider, metadata.method)} ·{" "}
{connection.credentialPolicy === "per_user"
? "Personal"
: "Company shared"}
@@ -90,6 +85,7 @@ export function ManagedAiConnectionDetails({
);
return (
<div className="space-y-4">
{account.routing && <details className="rounded-lg border border-border p-4"><summary className="cursor-pointer text-sm font-medium">Provider settings</summary><dl className="mt-4 grid grid-cols-2 gap-3 text-sm"><dt className="text-muted-foreground">Destination</dt><dd className="break-all">{account.routing.baseUrl ?? (account.routing.kind === "bedrock" ? account.routing.region : "OpenRouter")}</dd><dt className="text-muted-foreground">API format</dt><dd>{account.routing.kind === "openrouter" ? "Selected by harness" : account.routing.protocol}</dd><dt className="text-muted-foreground">Authentication</dt><dd>{account.routing.auth}</dd><dt className="text-muted-foreground">Models</dt><dd>{account.routing.models.map(m => m.label ?? m.id).join(", ") || "Enter a model ID on the agent"}</dd></dl></details>}
<AiConnectionAccountControls
account={account}
grant={grant}
@@ -7,6 +7,7 @@ export const AI_PROVIDERS: Record<
AiProvider,
{ name: string; subscriptionName?: string; logo?: string }
> = {
google: { name: "Google", logo: "/brands/apps/google.svg" },
anthropic: {
name: "Claude",
subscriptionName: "Claude subscription",
@@ -0,0 +1,31 @@
import { useQuery } from "@tanstack/react-query";
import { aiRoutingModel, type AiConnectionBinding } from "@paperclipai/shared";
import { aiConnectionsApi } from "@/api/ai-connections";
export function useConnectionModels(
companyId: string | null | undefined,
binding: AiConnectionBinding | undefined,
harness: string,
) {
const accounts = useQuery({
queryKey: ["ai-connections", companyId],
queryFn: () => aiConnectionsApi.list(companyId!),
enabled: Boolean(companyId && binding),
});
const connection = accounts.data?.connections.find((c) =>
binding?.mode === "responsible_user"
? c.provider === binding.provider && c.isDefault
: c.id === binding?.connectionId && c.grantId === binding?.grantId,
);
const routing = connection?.routing;
return routing
? {
models: routing.models.map((m) => ({
id: aiRoutingModel(routing, harness, m.id),
label: m.label ?? m.id,
})),
resolveModel: (model: string) =>
aiRoutingModel(routing, harness, model),
}
: undefined;
}
@@ -1,5 +1,6 @@
import { useCompany } from "@/context/CompanyContext";
import { useAgentAppearanceDraft } from "@/hooks/useAgentAppearanceDraft";
import { AdapterMark } from "../AdapterMark";
import { AgentCharacter } from "../AgentCharacter";
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
@@ -25,62 +26,8 @@ export type AgentBasics = {
adapterType: string;
runnerProvider: string;
};
const brandMarks: Record<string, { src: string; dark?: string }> = {
claude_local: { src: "/brands/claude-color.svg" },
codex_local: { src: "/brands/codex-color.svg" },
gemini_local: { src: "/brands/adapters/gemini-color.svg" },
kimi_local: {
src: "/brands/adapters/kimi-color-light.svg",
dark: "/brands/adapters/kimi-color.svg",
},
...Object.fromEntries(
[
["cursor", "cursor"],
["cursor_cloud", "cursor"],
["grok_local", "grok"],
["hermes_local", "hermesagent"],
["hermes_gateway", "hermesagent"],
["pi_local", "pi"],
].map(([type, icon]) => [
type,
{
src: `/brands/adapters/${icon}.svg`,
dark: `/brands/adapters/${icon}-dark.svg`,
},
]),
),
};
export function AdapterMark({
type,
className = "size-6",
}: {
type: string;
className?: string;
}) {
const Icon = getAdapterDisplay(type).icon;
const mark = brandMarks[type];
if (!mark) return <Icon className={className} />;
return (
<>
<img
src={mark.src}
className={cn(
"shrink-0 object-contain",
mark.dark && "dark:hidden",
className,
)}
alt=""
/>
{mark.dark && (
<img
src={mark.dark}
className={cn("hidden shrink-0 object-contain dark:block", className)}
alt=""
/>
)}
</>
);
}
export { AdapterMark } from "../AdapterMark";
function AgentBasicsCharacter() {
const { selectedCompanyId } = useCompany();
const { appearance } = useAgentAppearanceDraft(`${selectedCompanyId}:new-agent`);
+13 -19
View File
@@ -1,3 +1,4 @@
import { useConnectionModels } from "../ai-connections/useConnectionModels";
import { AgentCharacter } from "../AgentCharacter";
import { useAgentAppearanceDraft } from "../../hooks/useAgentAppearanceDraft";
import { AiConnectionField, aiProviderForAdapter } from "../ai-connections/AiConnectionField";
@@ -155,6 +156,7 @@ function Setup({
);
const [connection, setConnection] = useState<ProviderConnection | null>(null);
const aiBinding = runtimeAiBinding ?? connection?.aiConnection;
const connectionModels = useConnectionModels(companyId, aiBinding, brandType);
const [repository, setRepository] = useState("");
const [branch, setBranch] = useState("");
const [createdInSession, setCreated] = useState<Agent | null>(null);
@@ -716,7 +718,6 @@ function Setup({
<div className="mb-8">
<OnboardingHeading
title="Connect a model"
lede={`Connect ${name} to ${connectionAdapter === "claude_local" ? "Claude" : connectionAdapter === "grok_local" ? "Grok" : "OpenAI"}.`}
center
/>
</div>
@@ -740,6 +741,10 @@ function Setup({
</select>
</Field>
</div>
<details className="mb-5">
<summary className="cursor-pointer text-sm text-muted-foreground">Use another connection</summary>
<div className="pt-4"><AiConnectionField companyId={companyId} agentName={name} adapterType={brandType} model={model} environmentId={environmentId ?? undefined} value={aiBinding} onChange={binding => { setConnection({ env: {}, aiConnection: binding }); setRuntimeAiBinding(undefined); resetTest(); setScreen("runtime"); }} /></div>
</details>
<AgentProviderConnection
key={environmentId ?? "local"}
companyId={companyId}
@@ -764,6 +769,7 @@ function Setup({
}
onConnected={(next) => {
setConnection(next);
setRuntimeAiBinding(undefined);
resetTest();
setScreen("runtime");
}}
@@ -833,21 +839,9 @@ function Setup({
</h2>
<fieldset disabled={busy} className="space-y-8">
<section className="space-y-5">
<h3 className="text-sm font-semibold">Runtime</h3>
{aiProviderForAdapter(brandType) && (
connection && !aiBinding ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Using the connection selected in the Connect step.
</p>
<Button type="button" variant="outline" onClick={() => setScreen("connect")}>
Change connection
</Button>
</div>
) : (
<AiConnectionField companyId={companyId} agentName={name} adapterType={brandType} model={model} environmentId={environmentId ?? undefined} value={aiBinding}
onChange={binding => { setRuntimeAiBinding(binding); resetTest(); }} />
)
{!connectionAdapter && aiProviderForAdapter(brandType) && (
<AiConnectionField companyId={companyId} agentName={name} adapterType={brandType} model={model} environmentId={environmentId ?? undefined} value={aiBinding}
onChange={binding => { setRuntimeAiBinding(binding); resetTest(); }} />
)}
{models.error && <p role="alert" className="text-sm text-destructive">Could not load models. Retry or enter a model ID manually.</p>}
{((showModel && !usingKimiApi) ||
@@ -855,10 +849,10 @@ function Setup({
<div className="grid items-start gap-5 sm:grid-cols-2">
{showModel && !usingKimiApi && (
<ModelDropdown
models={models.data ?? []}
models={connectionModels?.models ?? models.data ?? []}
value={model}
onChange={(value) => {
setModel(value);
setModel(connectionModels?.resolveModel(value) ?? value);
if (
effort &&
!setupEfforts(adapterType, value).includes(
@@ -909,7 +903,7 @@ function Setup({
)}
</div>
)}
{SETUP_LOGIN_HINTS[adapterType] && (
{!aiBinding && SETUP_LOGIN_HINTS[adapterType] && (
<p className="text-sm text-muted-foreground">
{SETUP_LOGIN_HINTS[adapterType]}
</p>
+4 -1
View File
@@ -4,6 +4,9 @@ import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
// Shared with searchable picker triggers so their size and states match Select.
export const selectTriggerClassName = "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-(--tp-color-box-shadow) outline-none focus-visible:ring-(length:--rad-3) disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
@@ -35,7 +38,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-(--tp-color-box-shadow) outline-none focus-visible:ring-(length:--rad-3) disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
selectTriggerClassName,
className
)}
{...props}
@@ -3,7 +3,7 @@ import { Check, ChevronRight, Loader2 } from "lucide-react";
/** The account-reuse rows shared by connection setup and agent bindings. */
export function ConnectionChoiceList({ choices, selectedId, pendingId, disabled, onSelect }: {
choices: { id: string; name: string; description: ReactNode; disabled?: boolean }[];
choices: { id: string; name: string; description: ReactNode; icon?: ReactNode; disabled?: boolean }[];
selectedId?: string;
pendingId?: string | null;
disabled?: boolean;
@@ -19,7 +19,8 @@ export function ConnectionChoiceList({ choices, selectedId, pendingId, disabled,
disabled={disabled || Boolean(pendingId) || choice.disabled}
onClick={() => onSelect(choice.id)}
>
<span className="min-w-0">
{choice.icon && <span aria-hidden="true" className="shrink-0">{choice.icon}</span>}
<span className="min-w-0 flex-1">
<span className="block font-medium text-foreground">{choice.name}</span>
<span className="mt-1 block text-xs text-muted-foreground">{choice.description}</span>
</span>
@@ -1,3 +1,4 @@
import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -357,7 +358,7 @@ export function ConnectionIntentInteractionBody({
{completeMutation.isPending ? "Continuing…" : "Continue task"}
</Button>
</div>
: repair ? repair.canReconnect ? <AiConnectionCredentialStep
: repair ? repair.canReconnect ? repair.connection.routing ? <AiProviderSetup companyId={interaction.companyId} agentId={interaction.payload.requestingAgentId} reconnect={repair.connection} onComplete={result => selectAiAccountMutation.mutate({ ...result, generation })} onCancel={() => { closeSetup(); returnFocusToCard(); }} /> : <AiConnectionCredentialStep
companyId={interaction.companyId}
provider={repair.connection.provider}
initialMethod={repair.connection.method}
+53 -5
View File
@@ -259,7 +259,8 @@ describe("New agent setup", () => {
api.getAdapterAuthSignal.mockResolvedValue({ status: "missing" });
api.testEnvironment.mockResolvedValue({ ...pass, adapterType });
await render(adapterType, "grok");
expect(container.textContent).toContain("Connect Atlas to Grok");
expect(container.textContent).toContain("Connect a model");
expect(container.textContent).not.toContain("Connect Atlas to Grok");
if (method === "subscription") {
await click("GrokSubscription");
await click("Complete subscription login");
@@ -375,7 +376,8 @@ describe("New agent setup", () => {
});
it("uses the shared Grok connection flow and hides ignored Kimi and OpenCode effort controls", async () => {
await render("grok_local");
expect(container.textContent).toContain("Connect Atlas to Grok");
expect(container.textContent).toContain("Connect a model");
expect(container.textContent).not.toContain("Connect Atlas to Grok");
await render("opencode_local");
expect(container.querySelector('[aria-label="Thinking effort"]')).toBeNull();
});
@@ -470,6 +472,8 @@ describe("New agent setup", () => {
expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ apiKey: "connection-key", provider: binding.provider }));
expect(api.testEnvironment.mock.calls[0][2].testCredentials).toEqual({});
expect(api.testEnvironment.mock.calls[0][2].aiConnection).toEqual(binding);
expect(container.textContent).toContain("Configure your agent");
expect(container.querySelector('[role="combobox"][aria-label="Connection"]')).toBeNull();
expect(secrets.createUserSecretDefinition).not.toHaveBeenCalled();
await click("Finish setup");
expect(api.hire.mock.calls[0][1].runtimeConfig.aiConnection).toEqual(binding);
@@ -499,6 +503,34 @@ describe("New agent setup", () => {
expect(secrets.createMyUserSecret).not.toHaveBeenCalled();
expect(secrets.rotateMyUserSecret).not.toHaveBeenCalled();
});
it.each(["codex_local", "paperclip_runner"])("keeps a saved connection chosen in Connect when hiring %s", async (adapter) => {
await render(adapter, "codex");
await act(async () => cache.setQueryData(["ai-connections", "company-1", undefined], {
currentUserId: "user-1",
connections: [{
id: "00000000-0000-4000-8000-000000000021", grantId: "00000000-0000-4000-8000-000000000022", companyId: "company-1",
provider: "openrouter", method: "api_key", name: "Company OpenRouter",
ownership: "shared", isDefault: false, status: "connected",
routing: { kind: "openrouter", protocol: "responses", auth: "bearer" },
}],
}));
const disclosure = [...container.querySelectorAll("summary")].find(node => node.textContent === "Use another connection")!;
await act(async () => disclosure.click());
const picker = container.querySelector('[role="combobox"][aria-label="Connection"]')!;
await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
await settle();
const option = [...document.querySelectorAll('[role="option"]')].find(node => node.textContent?.includes("Company OpenRouter")) as HTMLElement;
expect(option).toBeTruthy();
await act(async () => option.click());
await settle();
expect(container.textContent).toContain("Configure your agent");
expect(container.querySelector('[role="combobox"][aria-label="Connection"]')).toBeNull();
await click("Run test");
const binding = { provider: "openrouter", method: "api_key", mode: "shared", connectionId: "00000000-0000-4000-8000-000000000021", grantId: "00000000-0000-4000-8000-000000000022" };
expect(api.testEnvironment.mock.calls[0][2].aiConnection).toEqual(binding);
await click("Finish setup");
expect(api.hire.mock.calls[0][1].runtimeConfig.aiConnection).toEqual(binding);
});
it.each(["pi_local"])(
"persists %s OpenRouter credentials only as a secret reference",
async (adapter) => {
@@ -528,11 +560,25 @@ describe("New agent setup", () => {
await render("opencode_local");
const model = "openrouter/anthropic/claude-sonnet-4.6";
await fill("Model", model);
await click("Connect another account");
const connectionSelect = container.querySelector('[role="combobox"][aria-label="Connection"]')!;
await act(async () => connectionSelect.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
await settle();
const connectOption = [...document.querySelectorAll('[role="option"]')].find(option => option.textContent?.includes("Connect an account"))!;
expect(connectOption).toBeTruthy();
await act(async () => (connectOption as HTMLElement).click());
await settle();
const dialog = document.querySelector('[role="dialog"]')!;
expect(dialog).toBeTruthy();
expect(api.hire).not.toHaveBeenCalled();
expect(api.testEnvironment).not.toHaveBeenCalled();
const advanced = [...dialog.querySelectorAll("summary")].find(node => node.textContent?.includes("Advanced providers"))!;
await act(async () => advanced.click());
const openrouter = [...dialog.querySelectorAll("button")].find(node => node.textContent?.includes("OpenRouter"))!;
await act(async () => openrouter.click());
await settle();
const accessContinue = [...dialog.querySelectorAll("button")].find(node => node.textContent?.trim() === "Continue")!;
await act(async () => accessContinue.click());
await settle();
const input = dialog.querySelector('[aria-label="API key"]') as HTMLInputElement;
expect(input).toBeTruthy();
await act(async () => {
@@ -544,11 +590,13 @@ describe("New agent setup", () => {
await act(async () => connectButton.click());
await settle();
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(managedApi.setDefault).toHaveBeenCalledWith("company-1", "managed-grant");
expect(managedApi.setDefault).not.toHaveBeenCalled();
expect(api.hire).not.toHaveBeenCalled();
expect(api.testEnvironment).not.toHaveBeenCalled();
expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
provider: "openrouter", method: "api_key", apiKey: "example-test-secret",
}));
const binding = { provider: "openrouter", method: "api_key", mode: "responsible_user" };
const binding = { provider: "openrouter", method: "api_key", mode: "delegated", connectionId: "managed-connection", grantId: "managed-grant" };
await click("Run test");
expect(api.testEnvironment.mock.calls[0][2]).toEqual(expect.objectContaining({
aiConnection: binding, testCredentials: {},
+11
View File
@@ -1,3 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { aiConnectionsApi } from "@/api/ai-connections";
import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
import type { ToolConnectionCredentialSource } from "@paperclipai/shared";
import { useCompany } from "@/context/CompanyContext";
@@ -16,11 +19,19 @@ export function AppsConnect({ byoOnly = false, credentialSource = "paperclip_vau
const [searchParams] = useSearchParams();
const { appKey } = useParams<{ appKey?: string }>();
const source = searchParams.get("source") ?? appKey ?? searchParams.get("appKey");
const reconnectId = searchParams.get("reconnect");
const aiReconnectRequested = Boolean(reconnectId && ["model-provider", "openai", "anthropic", "xai", "openrouter", "google-ai", "google", "bedrock", "gateway", "local"].includes(source ?? ""));
const aiAccounts = useQuery({ queryKey: ["ai-connections", selectedCompanyId], queryFn: () => aiConnectionsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId && aiReconnectRequested) });
const aiReconnect = aiAccounts.data?.connections.find(c => c.id === reconnectId);
const returningToSkills = source === "github" && selectedCompanyId && skillSourceReturnPath(selectedCompanyId);
function returnToSkills() {
const path = selectedCompanyId && consumeSkillSourceReturn(selectedCompanyId);
if (path) navigate(path);
}
if (aiReconnectRequested && aiAccounts.isPending) return <p role="status">Loading connection…</p>;
if (aiReconnectRequested && aiAccounts.isError) return <p role="alert">Could not load this connection. Refresh to try again.</p>;
if (aiReconnectRequested && !aiReconnect) return <p role="alert">This connection is unavailable. Return to Connectors to choose an account.</p>;
if (selectedCompanyId && (source === "model-provider" || source === "openrouter" || source === "google-ai" || source === "google" || source === "bedrock" || source === "gateway" || source === "local" || aiReconnect?.routing)) return <AiProviderSetup companyId={selectedCompanyId} reconnect={aiReconnect} initialProvider={source === "openrouter" ? "openrouter" : source === "google-ai" || source === "google" ? "google" : source === "bedrock" || source === "gateway" || source === "local" ? source : undefined} onCancel={() => navigate("/apps")} onComplete={binding => navigate(`/apps/${binding.connectionId}/permissions`)} />;
return <ConnectionSetupFlow byoOnly={byoOnly} credentialSource={credentialSource} host="page"
onComplete={returningToSkills ? returnToSkills : undefined}
onCancel={returningToSkills ? returnToSkills : undefined} />;
+2 -1
View File
@@ -667,7 +667,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
return (
<div className="max-w-5xl space-y-5 pb-12">
<header className="flex justify-start">
<header className="flex flex-wrap items-center justify-start gap-3">
<div className="relative w-full max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -679,6 +679,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
className="pl-9"
/>
</div>
<Button type="button" variant="outline" onClick={() => navigate("/apps/connect?source=model-provider")}>Connect a model provider</Button>
</header>
{loadFailed ? (
+1
View File
@@ -67,6 +67,7 @@ export function canEnterAppsConnect(
);
}
const source = searchParams.get("source") ?? "";
if (["model-provider", "google-ai", "google", "bedrock", "gateway", "local"].includes(source)) return true;
const entry = getAppStoreDefinition(source);
if (
!chatConnectorsEnabled &&
@@ -4,6 +4,7 @@ import {
useRef,
useState,
type ComponentProps,
type ReactNode,
} from "react";
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -67,11 +68,14 @@ export function AgentSettingsPreview({
adapterType = "claude_local",
testOutcome = "pass",
saveFails = false,
runtimeContent,
}: {
initialTab?: AgentLocalDetailView;
adapterType?: string;
testOutcome?: TestOutcome;
saveFails?: boolean;
/** Storybook-only extension point for reviewing proposed runtime controls in the existing page. */
runtimeContent?: ReactNode;
}) {
const [fixtures] = useState(() =>
createSettingsFixtures(adapterType, testOutcome, saveFails),
@@ -115,7 +119,7 @@ export function AgentSettingsPreview({
<Routes>
<Route
path="/:companyPrefix/agents/:agentId/:tab?"
element={<SettingsPage />}
element={<SettingsPage runtimeContent={runtimeContent} />}
/>
<Route
path="*"
@@ -136,7 +140,7 @@ export function AgentSettingsPreview({
);
}
function SettingsPage() {
function SettingsPage({ runtimeContent }: { runtimeContent?: ReactNode }) {
const location = useLocation();
const view = parseAgentDetailView(
location.pathname.split("/").pop() ?? "overview",
@@ -246,7 +250,7 @@ function SettingsPage() {
</div>
<div className="flex min-h-screen">
<div
className="w-52 shrink-0"
className="hidden w-52 shrink-0 md:block"
onClickCapture={(event) => {
if (
dirty &&
@@ -267,6 +271,16 @@ function SettingsPage() {
</div>
<main className="min-w-0 flex-1 px-6 py-8 lg:px-10">
<div className="mx-auto max-w-5xl space-y-8">
<details className="md:hidden">
<summary className="cursor-pointer text-sm">
{agent.name} · Agent navigation
</summary>
<AgentContextualSidebar
agentRef={REF}
agentId={ID}
agentName={agent.name}
/>
</details>
<header className="flex flex-wrap items-center justify-between gap-5 border-b border-border pb-6">
<div className="flex min-w-0 items-center gap-4">
<div
@@ -349,72 +363,76 @@ function SettingsPage() {
{view === "skills" && (
<AgentSkillsTab agent={agent} companyId={COMPANY} />
)}
{(view === "runtime" || view === "secrets") && (
<div className="space-y-6">
<div className="agent-settings-form">
<AgentConfigForm
mode="edit"
agent={agent}
onSave={saveAgent}
isSaving={saving}
hideInlineSave
hidePromptTemplate
hideInstructionsFile
content={view === "runtime" ? "configuration" : "secrets"}
environmentVariablesPlacement="secrets"
sectionLayout="cards"
canConfigureProviderTrace
sectionOrder={[
"adapter",
"permissions",
"environment",
"run-policy",
"identity",
]}
sectionTitles={{
adapter: "Harness",
permissions: "Model & execution",
identity: "Agent identity",
}}
onDirtyChange={onDirty}
onSaveActionChange={onSaveAction}
onCancelActionChange={onCancelAction}
onTestActionChange={onTestAction}
onTestActionStateChange={setTestAction}
onTestFeedbackChange={setFeedback}
/>
</div>
{view === "runtime" && (
<>
<RuntimeTestCard
state={
testAction.pending
? "running"
: feedback.errorMessage ||
feedback.result?.status === "fail"
? "fail"
: feedback.result?.status === "warn"
? "warn"
: feedback.result
? "pass"
: "idle"
}
result={feedback.result}
error={feedback.errorMessage}
disabled={testAction.disabled}
onTest={() => test.current?.()}
/>
{feedback.login && (
<AdapterLoginPanel
{...feedback.login}
onStored={() => test.current?.()}
onApplyStored={() => test.current?.()}
{view === "runtime" && runtimeContent
? runtimeContent
: (view === "runtime" || view === "secrets") && (
<div className="space-y-6">
<div className="agent-settings-form">
<AgentConfigForm
mode="edit"
agent={agent}
onSave={saveAgent}
isSaving={saving}
hideInlineSave
hidePromptTemplate
hideInstructionsFile
content={
view === "runtime" ? "configuration" : "secrets"
}
environmentVariablesPlacement="secrets"
sectionLayout="cards"
canConfigureProviderTrace
sectionOrder={[
"adapter",
"permissions",
"environment",
"run-policy",
"identity",
]}
sectionTitles={{
adapter: "Harness",
permissions: "Model & execution",
identity: "Agent identity",
}}
onDirtyChange={onDirty}
onSaveActionChange={onSaveAction}
onCancelActionChange={onCancelAction}
onTestActionChange={onTestAction}
onTestActionStateChange={setTestAction}
onTestFeedbackChange={setFeedback}
/>
</div>
{view === "runtime" && (
<>
<RuntimeTestCard
state={
testAction.pending
? "running"
: feedback.errorMessage ||
feedback.result?.status === "fail"
? "fail"
: feedback.result?.status === "warn"
? "warn"
: feedback.result
? "pass"
: "idle"
}
result={feedback.result}
error={feedback.errorMessage}
disabled={testAction.disabled}
onTest={() => test.current?.()}
/>
{feedback.login && (
<AdapterLoginPanel
{...feedback.login}
onStored={() => test.current?.()}
onApplyStored={() => test.current?.()}
/>
)}
</>
)}
</>
</div>
)}
</div>
)}
{view === "permissions" && (
<ConfigurationTab
agent={agent}
@@ -441,36 +459,37 @@ function SettingsPage() {
)}
{(["runtime", "secrets", "instructions"] as string[]).includes(
view,
) && (
<footer className="sticky bottom-0 flex flex-wrap items-center justify-between gap-3 border-t border-border bg-background py-4">
<p role="status" className="text-xs text-muted-foreground">
{saving
? "Saving changes…"
: dirty
? "You have unsaved changes."
: saved
? "Changes saved."
: ""}
</p>
<div className="flex gap-2">
<Button
variant="ghost"
disabled={!dirty || saving}
onClick={() => cancel.current?.()}
>
Discard
</Button>
<Button
disabled={!dirty || saving}
onClick={() => {
Promise.resolve(save.current?.()).catch(() => {});
}}
>
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</footer>
)}
) &&
!(view === "runtime" && runtimeContent) && (
<footer className="sticky bottom-0 flex flex-wrap items-center justify-between gap-3 border-t border-border bg-background py-4">
<p role="status" className="text-xs text-muted-foreground">
{saving
? "Saving changes…"
: dirty
? "You have unsaved changes."
: saved
? "Changes saved."
: ""}
</p>
<div className="flex gap-2">
<Button
variant="ghost"
disabled={!dirty || saving}
onClick={() => cancel.current?.()}
>
Discard
</Button>
<Button
disabled={!dirty || saving}
onClick={() => {
Promise.resolve(save.current?.()).catch(() => {});
}}
>
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</footer>
)}
</div>
</main>
</div>
@@ -0,0 +1,573 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { RuntimeTestCard, type TestState } from "@/components/RuntimeTestCard";
import { ModelSourceTiles } from "@/components/onboarding/ModelSourceTiles";
import { CredentialModeLink } from "@/components/onboarding/CredentialModeLink";
import { AdapterMark } from "@/components/AdapterMark";
import {
AdapterTypeDropdown,
ModelDropdown,
} from "@/components/AgentConfigForm";
import { Field } from "@/components/agent-config-primitives";
import { ConnectionChoiceList } from "@/features/connections/ConnectionChoiceList";
import { AiConnectionLegacyNotice } from "@/components/ai-connections/AiConnectionManagement";
import { AgentSettingsPreview } from "../agent-settings/AgentSettingsPreview";
import { ProviderApiKeyCard } from "@/components/AdapterLoginChrome";
import { ConnectionSetup } from "./ConnectionSetup";
import { ConnectionPicker } from "./ConnectionPicker";
import {
AdvancedOptions,
Choice,
Footer,
Notice,
Surface,
TextField,
} from "./shared";
import {
compatibility,
connections,
defaultConnection,
harnesses,
modelsFor,
type Connection,
type Harness,
} from "./model";
export type AgentProps = {
harness?: Harness;
runner?: "new" | "legacy";
initialConnectionId?: string;
addedConnection?: Connection;
initialModel?: string;
initialTest?: TestState;
scenario?:
| "normal"
| "empty"
| "loading"
| "catalog-error"
| "denied"
| "missing-personal"
| "legacy"
| "readonly";
testFailure?: "tools" | "network";
newAgent?: boolean;
initialAdvanced?: boolean;
};
const adapterTypes: Record<Harness, string> = {
codex: "codex_local",
claude: "claude_local",
opencode: "opencode_local",
grok: "grok_local",
pi: "pi_local",
gemini: "gemini_local",
kimi: "kimi_local",
hermes: "hermes_local",
cursor: "cursor",
copilot: "copilot_local",
};
const excludedAdapters = new Set([
"process",
"http",
"openclaw_gateway",
"hermes_gateway",
"claude_managed",
"aws_agentcore",
"acpx_local",
"paperclip_runner",
"cursor_cloud",
]);
export function AgentSetup(props: AgentProps) {
const content = <AgentRoutingFields {...props} />;
return props.newAgent ? (
content
) : (
<AgentSettingsPreview
initialTab="runtime"
adapterType={adapterTypes[props.harness ?? "codex"]}
runtimeContent={content}
/>
);
}
function AgentRoutingFields({
harness: initialHarness = "codex",
runner = "new",
initialConnectionId,
addedConnection,
initialModel,
initialTest = "idle",
scenario = "normal",
testFailure,
newAgent,
initialAdvanced = false,
}: AgentProps) {
const startingConnection =
addedConnection ??
connections.find((entry) => entry.id === initialConnectionId) ??
defaultConnection(initialHarness);
const [harness, setHarness] = useState(initialHarness);
const [rows, setRows] = useState(
addedConnection
? [
...connections.filter((entry) => entry.id !== addedConnection.id),
addedConnection,
]
: scenario === "empty"
? []
: connections,
);
const [connectionId, setConnectionId] = useState(startingConnection.id);
const [model, setModel] = useState(
initialModel ?? modelsFor(startingConnection, harness)[0],
);
const [manual, setManual] = useState(false);
const [modelOpen, setModelOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(initialAdvanced);
const [effort, setEffort] = useState("default");
const [environment, setEnvironment] = useState("cloud");
const [state, setState] = useState<TestState>(initialTest);
const [catalogError, setCatalogError] = useState(
scenario === "catalog-error",
);
const [creating, setCreating] = useState(false);
const [saved, setSaved] = useState(false);
const [discarded, setDiscarded] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const selected = rows.find((entry) => entry.id === connectionId);
const availableModels = selected ? modelsFor(selected, harness) : [];
const unknownModel = !manual && !availableModels.includes(model);
const reason = selected ? compatibility(selected, harness) : undefined;
const denied = scenario === "denied" || scenario === "missing-personal";
const readOnly = scenario === "readonly";
const nativePending =
runner === "new" &&
!harnesses.find((entry) => entry.value === harness)!.native;
const invalid =
!selected ||
!!reason ||
unknownModel ||
!model.trim() ||
denied ||
nativePending ||
readOnly;
const change = () => {
clearTimeout(timer.current);
setState("idle");
setSaved(false);
setDiscarded(false);
};
const error =
testFailure === "network"
? "This endpoint is unreachable from the selected environment. Check network access or choose another environment."
: "The model responded, but the tool-call test failed. Choose a model with tool support or check the gateway’s API format.";
const test = () => {
setState("running");
timer.current = setTimeout(
() => setState(testFailure ? "fail" : "pass"),
400,
);
};
if (creating)
return (
<ConnectionSetup
initialAdvanced={advancedOpen}
onCancel={() => setCreating(false)}
onComplete={(connection) => {
setRows((current) => [
...current.filter((entry) => entry.id !== connection.id),
connection,
]);
setConnectionId(connection.id);
setCreating(false);
change();
}}
/>
);
return (
<Surface
embedded={!newAgent}
title={newAgent ? "Configure Nova" : "Harness, connection & model"}
description="Choose a harness and model, then test your agent."
>
<fieldset
disabled={readOnly}
aria-label="Harness selection"
className="min-w-0"
>
<Field label="Harness">
<AdapterTypeDropdown
value={adapterTypes[harness]}
disabledTypes={excludedAdapters}
onChange={(value) => {
const next = (Object.keys(adapterTypes) as Harness[]).find(
(key) => adapterTypes[key] === value,
);
if (next) {
setHarness(next);
change();
}
}}
/>
</Field>
</fieldset>
{nativePending && (
<Notice error>
This harness is not yet available through Paperclip Runner. Select a
supported harness or configure its legacy adapter.
</Notice>
)}
{scenario === "legacy" && (
<AiConnectionLegacyNotice
readOnly={readOnly}
onAdopt={() => setCreating(true)}
/>
)}
{scenario === "loading" ? (
<p role="status" className="text-sm text-muted-foreground">
Loading your connections…
</p>
) : selected ? (
<>
<ConnectionPicker
selected={selected}
connections={rows.filter((entry) => !compatibility(entry, harness))}
disabled={readOnly}
onSelect={(id) => {
if (id === connectionId) return;
setConnectionId(id);
setManual(false);
change();
}}
onConnect={() => setCreating(true)}
/>
{reason && (
<Notice error>{reason} Choose a compatible connection.</Notice>
)}
{denied && (
<Notice error>
{scenario === "missing-personal"
? "Sam has no credential for Engineering gateway. Sam needs to connect to this destination, or choose an authorized company credential."
: "You don’t have access to this company credential. Ask its owner for access or choose another connection."}
</Notice>
)}
</>
) : (
<Notice>
No compatible connections yet. Connect a provider to choose a model
for Nova.
</Notice>
)}
{!selected && (
<Button
variant="link"
className="h-auto justify-start self-start p-0"
disabled={readOnly || scenario === "loading"}
onClick={() => setCreating(true)}
>
Connect an account
</Button>
)}
{selected && scenario !== "loading" && (
<>
{catalogError ? (
<Notice error>
<p>Couldn’t load models from this connection.</p>
<Button
size="sm"
variant="outline"
className="mt-3"
onClick={() => setCatalogError(false)}
>
Retry model discovery
</Button>
</Notice>
) : (
<fieldset
disabled={readOnly || Boolean(reason)}
aria-label="Model selection"
className="min-w-0"
>
<ModelDropdown
models={availableModels.map((id) => ({ id, label: id }))}
value={model}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={false}
required
groupByProvider={selected.provider === "openrouter"}
creatable={advancedOpen}
onChange={(value) => {
setManual(!availableModels.includes(value));
setModel(value);
change();
}}
/>
</fieldset>
)}
{catalogError && advancedOpen && (
<TextField
label="Model ID or alias"
value={model}
disabled={readOnly}
onChange={(event) => {
setManual(true);
setModel(event.target.value);
change();
}}
hint="Use the exact ID accepted by this connection. Custom model metadata is managed on the connection."
/>
)}
{unknownModel && !catalogError && (
<Notice error>
Your previous model is unavailable through {selected.name}. Choose
a model to continue. Your harness is still{" "}
{harnesses.find((entry) => entry.value === harness)!.label}.
</Notice>
)}
<Choice
label="Reasoning"
value={effort}
disabled={readOnly}
onChange={(value) => {
setEffort(value);
change();
}}
options={[
{ value: "default", label: "Model default" },
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
]}
/>
<Choice
label="Execution environment"
value={environment}
disabled={readOnly}
onChange={(value) => {
setEnvironment(value);
change();
}}
options={[
{ value: "cloud", label: "Company AWS workspace" },
{ value: "local", label: "Local machine" },
]}
/>
<AdvancedOptions
label="Advanced model settings"
open={advancedOpen}
onOpenChange={setAdvancedOpen}
>
<Button
variant="link"
className="h-auto self-start p-0"
disabled={readOnly}
onClick={() => setCreating(true)}
>
Connect another provider or gateway
</Button>
<p className="text-xs text-muted-foreground">
To use a custom model ID or gateway alias, enter it in the Model
picker above.
</p>
</AdvancedOptions>
<RuntimeTestCard
state={state}
onTest={test}
disabled={invalid || (catalogError && !manual)}
error={error}
result={
state === "pass" || state === "fail"
? {
adapterType: harness,
status: state,
testedAt: "2026-10-02T12:00:00Z",
checks: [
{
code: "destination",
level: "info",
message: `Destination: ${selected.endpoint}`,
},
{
code: "environment",
level: "info",
message: `Tested in ${environment === "cloud" ? "Company AWS workspace" : "Local machine"}`,
},
{
code: "tools",
level: state === "pass" ? "info" : "error",
message:
state === "pass"
? "Authentication, streaming, tool call, and follow-up passed."
: error,
},
],
}
: null
}
/>
</>
)}
{saved && (
<Notice>
{scenario === "legacy"
? "Managed connection adopted."
: "Nova’s configuration saved."}{" "}
{selected?.name} · {model}
</Notice>
)}
{discarded && <Notice>Changes discarded.</Notice>}
<Footer
back="Discard"
onBack={() => {
setHarness(initialHarness);
setConnectionId(startingConnection.id);
setModel(
initialModel ?? modelsFor(startingConnection, initialHarness)[0],
);
setManual(false);
setEffort("default");
setEnvironment("cloud");
change();
setDiscarded(true);
}}
next={
scenario === "legacy"
? "Adopt connection"
: newAgent
? "Finish setup"
: "Save changes"
}
onNext={() => setSaved(true)}
disabled={invalid || state !== "pass" || scenario === "loading"}
/>
</Surface>
);
}
export function Onboarding({
initialAlternate = false,
savedConnection = false,
}: {
initialAlternate?: boolean;
savedConnection?: boolean;
}) {
const [stage, setStage] = useState(initialAlternate ? "other" : "default");
const [mode, setMode] = useState<"subscription" | "api">("subscription");
const [key, setKey] = useState("");
const [connection, setConnection] = useState<Connection>();
if (stage === "other")
return (
<ConnectionSetup
initialAdvanced
onCancel={() => setStage("default")}
onComplete={(value) => {
setConnection(value);
setStage("agent");
}}
/>
);
if (stage === "agent")
return (
<AgentSetup
harness="codex"
newAgent
initialConnectionId="chatgpt"
addedConnection={connection}
/>
);
return (
<Surface
title="Connect OpenAI"
description="You’ve selected Codex for Nova."
>
<ModelSourceTiles
label="Model provider"
sources={[
{
id: "codex",
label: "OpenAI",
icon: <AdapterMark type="codex_local" />,
},
]}
selectedId="codex"
mode={mode}
onSelect={() => {}}
/>
<CredentialModeLink mode={mode} onChange={setMode} />
{savedConnection ? (
<ConnectionChoiceList
selectedId="chatgpt"
choices={[
{
id: "chatgpt",
name: "My ChatGPT subscription",
description: "OpenAI · Your subscription",
},
]}
onSelect={() => {}}
/>
) : mode === "api" ? (
<ProviderApiKeyCard
providerName="OpenAI"
value={key}
onChange={setKey}
onSubmit={() => {
if (key.trim()) {
setConnection({
...connections[0],
id: "openai-key",
name: "My OpenAI API",
endpoint: "https://api.openai.com/v1",
method: "API key",
});
setKey("");
setStage("agent");
}
}}
/>
) : null}
<AdvancedOptions>
<Button
variant="link"
className="h-auto self-start p-0"
onClick={() => setStage("other")}
>
Use another provider or gateway
</Button>
</AdvancedOptions>
<Footer
back="Back"
onBack={() => {
setMode("subscription");
setKey("");
}}
next={
savedConnection
? "Use saved connection"
: mode === "subscription"
? "Sign in to OpenAI"
: "Connect"
}
disabled={!savedConnection && mode === "api" && !key.trim()}
onNext={() => {
setConnection(
mode === "api" && !savedConnection
? {
...connections[0],
id: "openai-key",
name: "My OpenAI API",
endpoint: "https://api.openai.com/v1",
method: "API key",
}
: connections[0],
);
setKey("");
setStage("agent");
}}
/>
</Surface>
);
}
@@ -0,0 +1,196 @@
import { useState, type ComponentProps } from "react";
import {
getAppStoreDefinition,
type ToolConnection,
} from "@paperclipai/shared";
import { appDefinitionLogoUrl } from "@/pages/apps/app-definition-display";
import { ConnectorCard } from "@/pages/apps/Browse";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { providers, type Connection, type Provider } from "./model";
const date = new Date("2026-10-02T12:00:00Z");
export function asToolConnection(connection: Connection): ToolConnection {
return {
id: connection.id,
uid: connection.id,
companyId: "company-storybook",
applicationId: `provider-routing-${connection.provider}`,
name: connection.name,
connectionKind: "managed",
ownership: "customer",
connectionPurpose: "ai",
transport: "runtime_auth",
authKind:
connection.method === "Subscription"
? "oauth"
: connection.method === "No authentication"
? "none"
: "api_key",
credentialSource: "paperclip_vault",
credentialPolicy: connection.ownership === "shared" ? "shared" : "per_user",
status: connection.status === "connected" ? "active" : "disabled",
enabled: connection.status === "connected",
transportConfig: {},
config: { sourceTemplateKey: connection.provider },
credentialSecretRefs: [],
healthStatus: connection.status === "connected" ? "ok" : "error",
healthCheckedAt: date,
healthMessage: null,
lastError: null,
createdByAgentId: null,
createdByUserId: "dotta",
createdAt: date,
updatedAt: date,
};
}
/** Reuse the actual Connectors provider card, account rows, owner identity, status and menus. */
export function ConnectionCatalog({
rows,
onOpen,
onAdd,
onRemove,
readOnly,
}: {
rows: Connection[];
onOpen: (connection: Connection) => void;
onAdd: (provider: Provider) => void;
onRemove: (id: string) => void;
readOnly?: boolean;
}) {
const [query, setQuery] = useState("");
const [removing, setRemoving] = useState<string>();
const visible = providers.filter((provider) =>
rows.some(
(row) =>
row.provider === provider.value &&
`${provider.label} ${row.name} ${row.endpoint}`
.toLowerCase()
.includes(query.toLowerCase()),
),
);
return (
<div className="space-y-5">
<Input
type="search"
aria-label="Search connectors"
placeholder="Search connectors…"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<section className="space-y-3" aria-label="Connector list">
{visible.map((provider) => {
const entry = getAppStoreDefinition(provider.value) ?? null;
const row: ComponentProps<typeof ConnectorCard>["row"] = {
key: provider.value,
slug: provider.value,
name: provider.label,
description: provider.description,
brandKey: provider.value,
entry,
logoUrl: appDefinitionLogoUrl(entry),
connections: rows
.filter((row) => row.provider === provider.value)
.map(asToolConnection),
chatEndpoints: [],
applications: [
{
id: `provider-routing-${provider.value}`,
companyId: "company-storybook",
applicationKey: provider.value,
name: provider.label,
description: provider.description,
type: "mcp_http",
status: "active",
pluginId: null,
ownerAgentId: null,
ownerUserId: "dotta",
metadata: { sourceTemplateKey: provider.value },
archivedAt: null,
createdAt: date,
updatedAt: date,
},
],
};
return (
<ConnectorCard
key={provider.value}
row={row}
userProfileById={
new Map([["dotta", { label: "Dotta", image: null }]])
}
chatConnectorsEnabled={false}
renderAccountDetails={(toolConnection) => {
const connection = rows.find(
(row) => row.id === toolConnection.id,
)!;
return (
<p className="text-xs text-muted-foreground">
{connection.ownership === "shared"
? "Company account"
: "Personal account"}{" "}
· {connection.method}
</p>
);
}}
onNavigate={(href) => {
const connection = rows.find(
(row) => href === `/apps/${row.id}/permissions`,
);
if (connection) onOpen(connection);
else if (!readOnly) onAdd(provider.value);
}}
onRequestRemove={(target) => {
if (!readOnly) setRemoving(target.id);
}}
/>
);
})}
</section>
{!visible.length && (
<p className="text-sm text-muted-foreground">
No connectors match “{query}”.
</p>
)}
<Dialog
open={Boolean(removing)}
onOpenChange={(open) => {
if (!open) setRemoving(undefined);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove this connection?</DialogTitle>
<DialogDescription>
Agents using this connection will need another connection before
their next run.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-between">
<Button variant="ghost" onClick={() => setRemoving(undefined)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
onRemove(removing!);
setRemoving(undefined);
}}
>
Remove connection
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,67 @@
import { useId } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { isAdvancedProvider, type Connection } from "./model";
const connectAccount = "__connect_account__";
export function ConnectionPicker({
selected,
connections,
disabled,
onSelect,
onConnect,
}: {
selected: Connection;
connections: Connection[];
disabled?: boolean;
onSelect: (id: string) => void;
onConnect: () => void;
}) {
const id = useId();
const choices = [...connections].sort(
(a, b) =>
Number(b.id === selected.id) - Number(a.id === selected.id) ||
Number(isAdvancedProvider(a.provider)) -
Number(isAdvancedProvider(b.provider)),
);
return (
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor={id} className="text-xs text-muted-foreground">
Connection
</label>
<Select
value={selected.id}
disabled={disabled}
onValueChange={(value) => {
if (value === connectAccount) onConnect();
else onSelect(value);
}}
>
<SelectTrigger id={id} className="w-full">
<SelectValue>{selected.name}</SelectValue>
</SelectTrigger>
<SelectContent position="popper" align="start">
{choices.map((connection) => (
<SelectItem key={connection.id} value={connection.id}>
{connection.name}
</SelectItem>
))}
{connections.length === 0 && (
<SelectItem value="__none__" disabled>
No compatible connections
</SelectItem>
)}
<SelectSeparator />
<SelectItem value={connectAccount}>Connect an account…</SelectItem>
</SelectContent>
</Select>
</div>
);
}
@@ -0,0 +1,451 @@
import { useState } from "react";
import { KeyRound, Network } from "lucide-react";
import { ConnectionChoiceList } from "@/features/connections/ConnectionChoiceList";
import { StepHeader } from "@/features/connections/ConnectionSetupFlow";
import { ProviderApiKeyCard } from "@/components/AdapterLoginChrome";
import { AppLogo } from "@/pages/apps/AppLogo";
import { RoutingAccess } from "./RoutingAccess";
import { Button } from "@/components/ui/button";
import {
AdvancedOptions,
Choice,
Footer,
Notice,
Surface,
TextField,
} from "./shared";
import {
connections,
formatLabels,
providers,
isAdvancedProvider,
type Connection,
type Protocol,
type Provider,
} from "./model";
export type SetupProps = {
initialProvider?: Provider;
initialStep?: "provider" | "access" | "connect";
initialMethod?: string;
initialProtocol?: Protocol;
initialError?: "credential" | "endpoint";
initialAdvanced?: boolean;
onComplete?: (connection: Connection) => void;
onCancel?: () => void;
};
/** Shared by the onboarding, Apps, agent, and task prototypes. No network requests. */
export function ConnectionSetup({
initialProvider = "openrouter",
initialStep = "provider",
initialMethod = "key",
initialProtocol = "responses",
initialError,
initialAdvanced = false,
onComplete,
onCancel,
}: SetupProps) {
const [step, setStep] = useState<string>(initialStep);
const [provider, setProvider] = useState(initialProvider);
const [method, setMethod] = useState(initialMethod);
const [protocol, setProtocol] = useState<Protocol>(initialProtocol);
const [endpoint, setEndpoint] = useState("");
const [apiKey, setApiKey] = useState("");
const [secret, setSecret] = useState("");
const [sessionToken, setSessionToken] = useState("");
const [region, setRegion] = useState(
initialProvider === "google" ? "us-central1" : "us-east-1",
);
const [project, setProject] = useState("");
const [header, setHeader] = useState("X-API-Key");
const [ownership, setOwnership] = useState<"personal" | "shared">("personal");
const [allAgents, setAllAgents] = useState(true);
const [agentIds, setAgentIds] = useState(new Set(["nova"]));
const [error, setError] = useState(initialError);
const [result, setResult] = useState<Connection>();
const providerLabel = providers.find(
(entry) => entry.value === provider,
)!.label;
const isDirect = ["openai", "anthropic", "xai"].includes(provider);
const isAws = provider === "bedrock";
const isVertex = provider === "google" && method === "identity";
const needsKey = !["identity", "none", "subscription"].includes(method);
const canConnect =
(!needsKey || Boolean(apiKey.trim())) &&
(method !== "header" || Boolean(header.trim())) &&
(method !== "aws-keys" || Boolean(secret.trim())) &&
(provider !== "custom" || Boolean(endpoint.trim())) &&
(!isVertex || Boolean(project.trim() && region.trim()));
function connect() {
if (provider === "custom") {
try {
const url = new URL(endpoint);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password
)
throw new Error();
} catch {
setError("endpoint");
return;
}
}
const template = connections.find((entry) => entry.provider === provider);
const next: Connection = {
id: "created-connection",
name: `${ownership === "personal" ? "My" : "Company"} ${provider === "custom" ? new URL(endpoint).hostname : providerLabel}`,
provider,
endpoint: isAws
? region
: provider === "custom"
? endpoint
: isVertex
? `${project} · ${region}`
: (template?.endpoint ?? providerLabel),
protocols:
provider === "custom"
? [protocol]
: (template?.protocols ?? ["responses", "chat", "messages"]),
method:
method === "identity"
? "Environment identity"
: method === "subscription"
? "Subscription"
: method === "none"
? "No authentication"
: method === "aws-keys"
? "AWS credentials"
: "API key",
ownership,
models:
provider === "custom"
? ["engineering-coder"]
: (template?.models ?? ["grok-4.7"]),
status: "connected",
};
setApiKey("");
setSecret("");
setSessionToken("");
setError(undefined);
setResult(next);
setStep("done");
}
const cancel = () => {
setApiKey("");
setSecret("");
setSessionToken("");
onCancel?.();
if (!onCancel) setStep("provider");
};
const providerChoices = (advanced: boolean) => (
<ConnectionChoiceList
choices={providers
.filter((entry) => isAdvancedProvider(entry.value) === advanced)
.map((entry) => ({
id: entry.value,
name: entry.label,
description: entry.description,
icon:
entry.value === "custom" ? (
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Network className="size-5" />
</span>
) : (
<AppLogo name={entry.label} brandKey={entry.value} />
),
}))}
onSelect={(id) => {
setProvider(id as Provider);
setRegion(id === "google" ? "us-central1" : "us-east-1");
setApiKey("");
setSecret("");
setSessionToken("");
setEndpoint("");
setMethod("key");
setError(undefined);
setStep("access");
}}
/>
);
if (step === "provider")
return (
<Surface
title="Connect a model provider"
description="Choose the account you want to connect."
>
{providerChoices(false)}
<AdvancedOptions
label="Advanced providers"
defaultOpen={initialAdvanced}
>
{providerChoices(true)}
</AdvancedOptions>
<Button variant="ghost" className="self-start" onClick={cancel}>
Cancel
</Button>
</Surface>
);
if (step === "done" && result)
return (
<Surface title="Connection ready" description={result.name}>
<Notice>
Credentials connected. Choose a model and test it in your agent’s
environment before running tasks.
</Notice>
<dl className="grid grid-cols-2 gap-3 text-sm">
<dt className="text-muted-foreground">Provider</dt>
<dd>{providerLabel}</dd>
<dt className="text-muted-foreground">Destination</dt>
<dd className="break-all font-mono text-xs">{result.endpoint}</dd>
<dt className="text-muted-foreground">Credential access</dt>
<dd>
{ownership === "personal"
? "Only me"
: "Any human in the organization"}
</dd>
<dt className="text-muted-foreground">Agent access</dt>
<dd>
{allAgents
? "Any agent"
: [...agentIds]
.map((id) => (id === "nova" ? "Nova" : "Atlas"))
.join(", ")}
</dd>
</dl>
<Footer
back="Add another"
onBack={() => setStep("provider")}
next={onComplete ? "Use connection" : "Done"}
onNext={() => (onComplete ? onComplete(result) : setStep("provider"))}
/>
</Surface>
);
return (
<div className="mx-auto flex max-w-2xl flex-col gap-6 p-4 sm:p-6">
<StepHeader
title={`Connect ${providerLabel}`}
subtitle="Choose access, then connect your provider."
step="key"
activeIndex={step === "access" ? 0 : 1}
labels={["Access", "Connect"]}
/>
{step === "access" ? (
<RoutingAccess
ownership={ownership}
onOwnershipChange={setOwnership}
allAgents={allAgents}
onAllAgentsChange={setAllAgents}
agentIds={agentIds}
onAgentIdsChange={setAgentIds}
onBack={() => setStep("provider")}
onContinue={() => setStep("connect")}
/>
) : (
<>
{isAws && (
<Choice
label="AWS region"
value={region}
onChange={setRegion}
options={[
{ value: "us-east-1", label: "US East · N. Virginia" },
{ value: "us-west-2", label: "US West · Oregon" },
{ value: "eu-west-1", label: "Europe · Ireland" },
]}
/>
)}
{provider === "custom" && (
<>
<Choice
label="API format"
value={protocol}
onChange={(value) => setProtocol(value as Protocol)}
options={Object.entries(formatLabels).map(([value, label]) => ({
value,
label,
}))}
/>
<TextField
label="Base URL"
placeholder="https://models.example.com/v1"
value={endpoint}
onChange={(event) => {
setEndpoint(event.target.value);
setError(undefined);
}}
hint="Use the base URL supplied by your provider, including any API prefix. Model IDs are configured separately."
/>
</>
)}
<Choice
label="Authentication"
value={method}
onChange={(value) => {
setMethod(value);
setApiKey("");
setSecret("");
setError(undefined);
}}
options={[
{ value: "key", label: isAws ? "Bedrock API key" : "API key" },
...(isDirect
? [{ value: "subscription", label: "Subscription" }]
: []),
...(isAws || provider === "google"
? [
{
value: "identity",
label: isAws
? "Use environment’s AWS identity"
: "Vertex AI · environment identity",
},
]
: []),
...(isAws
? [{ value: "aws-keys", label: "AWS access keys" }]
: []),
...(provider === "custom"
? [
{ value: "header", label: "Custom authentication header" },
{ value: "none", label: "No authentication" },
]
: []),
]}
/>
{method === "identity" && (
<>
<Choice
label="Credential environment"
value="cloud"
onChange={() => {}}
options={[
{
value: "cloud",
label: isAws
? "Company AWS workspace"
: "Company Google Cloud workspace",
},
]}
/>
<p className="text-xs text-muted-foreground">
Uses the identity available inside the selected execution
environment. Agents running elsewhere need an identity there
too.
</p>
</>
)}
{isVertex && (
<>
<TextField
label="Google Cloud project"
value={project}
onChange={(event) => setProject(event.target.value)}
/>
<TextField
label="Location"
value={region}
onChange={(event) => setRegion(event.target.value)}
/>
</>
)}
{method === "header" && (
<TextField
label="Header name"
value={header}
onChange={(event) => setHeader(event.target.value)}
/>
)}
{method === "key" ? (
<ProviderApiKeyCard
providerName={providerLabel}
value={apiKey}
onChange={(value) => {
setApiKey(value);
setError(undefined);
}}
onSubmit={() => {
if (canConnect) connect();
}}
/>
) : (
needsKey && (
<TextField
label={
method === "aws-keys"
? "Access key ID"
: method === "header"
? "Header value"
: "API key"
}
type="password"
autoComplete="off"
value={apiKey}
onChange={(event) => {
setApiKey(event.target.value);
setError(undefined);
}}
placeholder="Enter credential"
hint="Stored securely with this connection."
/>
)
)}
{method === "aws-keys" && (
<>
<TextField
label="Secret access key"
type="password"
value={secret}
onChange={(event) => setSecret(event.target.value)}
/>
<TextField
label="Session token (optional)"
type="password"
value={sessionToken}
onChange={(event) => setSessionToken(event.target.value)}
/>
</>
)}
{provider === "custom" && (
<details className="space-y-3 text-sm">
<summary className="cursor-pointer">Advanced</summary>
<p className="text-xs text-muted-foreground">
Model aliases, context limits, and helper-model defaults can be
managed after connecting. API format determines which harnesses
can use this endpoint.
</p>
{method === "header" && (
<p>
Authentication header: <code>{header}</code>
</p>
)}
</details>
)}
{error && (
<Notice error>
{error === "credential"
? "The provider rejected this credential. Enter a replacement and try again."
: "Enter a valid HTTP or HTTPS base URL without embedded credentials."}
</Notice>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<KeyRound className="size-3" />
{ownership === "personal"
? "Personal connection"
: "Shared connection"}{" "}
· {providerLabel}
</div>
<Footer
onBack={() => setStep("access")}
next={method === "subscription" ? "Sign in" : "Connect"}
onNext={connect}
disabled={!canConnect}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,470 @@
import { useRef, useState } from "react";
import { ArrowLeft, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ConnectionCatalog } from "./ConnectionCatalog";
import { RoutingAccess } from "./RoutingAccess";
import { ConnectionSetup } from "./ConnectionSetup";
import {
AdvancedOptions,
Choice,
Footer,
Notice,
Surface,
TextField,
} from "./shared";
import {
connections,
formatLabels,
isAdvancedProvider,
type Connection,
} from "./model";
export function Management({
initialView = "list",
initialTab = "details",
readOnly = false,
includeAdvancedConnections = false,
}: {
initialView?: "list" | "details";
initialTab?: "details" | "models" | "access";
readOnly?: boolean;
includeAdvancedConnections?: boolean;
}) {
const [rows, setRows] = useState(
initialView === "details" || includeAdvancedConnections
? connections
: connections.filter(
(connection) => !isAdvancedProvider(connection.provider),
),
);
const [selected, setSelected] = useState<Connection | undefined>(
initialView === "details" ? connections[4] : undefined,
);
const [adding, setAdding] = useState(false);
const [addingProvider, setAddingProvider] =
useState<Connection["provider"]>();
const [tab, setTab] = useState(initialTab);
const [notice, setNotice] = useState("");
const [name, setName] = useState("Engineering gateway");
const [alias, setAlias] = useState("");
const [context, setContext] = useState("128000");
const [helper, setHelper] = useState("engineering-fast");
const [models, setModels] = useState([
"engineering-coder",
"engineering-fast",
]);
const [agents, setAgents] = useState("all");
const [rotate, setRotate] = useState(false);
const [credential, setCredential] = useState("");
const [revoke, setRevoke] = useState(false);
const [contextWindows, setContextWindows] = useState<Record<string, string>>({
"engineering-coder": "128000",
"engineering-fast": "128000",
});
const [preferences, setPreferences] = useState<
Record<
string,
{
helper: string;
agents: string;
contextWindows: Record<string, string>;
}
>
>({});
const rotateTrigger = useRef<HTMLButtonElement>(null);
const revokeTrigger = useRef<HTMLButtonElement>(null);
const open = (connection: Connection) => {
const saved = preferences[connection.id];
setSelected(connection);
setName(connection.name);
setModels(connection.models);
setHelper(saved?.helper ?? connection.models.at(-1)!);
setAgents(saved?.agents ?? "all");
setContextWindows(
saved?.contextWindows ??
Object.fromEntries(connection.models.map((model) => [model, "128000"])),
);
setAlias("");
setTab("details");
setNotice("");
};
const save = () => {
if (!selected) return;
const next = { ...selected, name, models };
setPreferences((current) => ({
...current,
[next.id]: { helper, agents, contextWindows },
}));
setSelected(next);
setRows((current) =>
current.map((row) => (row.id === next.id ? next : row)),
);
setNotice("Connection settings saved.");
};
if (adding)
return (
<ConnectionSetup
initialProvider={addingProvider ?? selected?.provider}
initialStep={selected || addingProvider ? "access" : "provider"}
onCancel={() => setAdding(false)}
onComplete={(connection) => {
setRows((current) => [
...current.filter((row) => row.id !== connection.id),
connection,
]);
setAdding(false);
open(connection);
}}
/>
);
if (!selected)
return (
<Surface
title="Connectors"
description="Manage the accounts your agents use."
>
<Button
className="self-start"
disabled={readOnly}
onClick={() => setAdding(true)}
>
<Plus />
Add connection
</Button>
<ConnectionCatalog
rows={rows}
onOpen={open}
readOnly={readOnly}
onAdd={(provider) => {
setAddingProvider(provider);
setAdding(true);
}}
onRemove={(id) =>
setRows((current) => current.filter((row) => row.id !== id))
}
/>
</Surface>
);
return (
<Surface
title={selected.name}
description={`${selected.ownership === "shared" ? "Company connection" : "Personal connection"} · ${selected.status === "connected" ? "Connected" : "Credential revoked"}`}
>
<Button
variant="ghost"
className="self-start"
onClick={() => setSelected(undefined)}
>
<ArrowLeft />
Connectors
</Button>
<nav aria-label="Connection settings" className="flex flex-wrap gap-2">
{(["details", "models", "access"] as const).map((item) => (
<Button
key={item}
size="sm"
variant={tab === item ? "secondary" : "ghost"}
aria-pressed={tab === item}
onClick={() => {
setTab(item);
setNotice("");
}}
>
{item === "details"
? "Connection"
: item === "models"
? "Models"
: "Access"}
</Button>
))}
</nav>
{tab === "details" ? (
<>
<TextField
label="Connection name"
value={name}
disabled={readOnly}
onChange={(event) => setName(event.target.value)}
/>
<p className="text-sm text-muted-foreground">
{selected.method} ·{" "}
{selected.status === "connected" ? "Connected" : "Revoked"}
</p>
<Button
ref={rotateTrigger}
variant="outline"
className="self-start"
disabled={readOnly}
onClick={() => setRotate(true)}
>
Replace credential
</Button>
<AdvancedOptions label="Advanced connection settings">
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
<dt className="text-muted-foreground">Destination</dt>
<dd className="break-all font-mono text-xs">
{selected.endpoint}
</dd>
<dt className="text-muted-foreground">API format</dt>
<dd>
{selected.protocols
.map((format) => formatLabels[format])
.join(", ")}
</dd>
</dl>
<div className="flex flex-wrap gap-2">
<Button
variant="ghost"
disabled={readOnly}
onClick={() => setAdding(true)}
>
Use a different endpoint
</Button>
</div>
<p className="text-xs text-muted-foreground">
A different endpoint creates a separate connection. Nova and Atlas
keep their current destination until you select the new connection
for them.
</p>
</AdvancedOptions>
{selected.id === "gateway" && (
<details className="text-sm">
<summary className="cursor-pointer">Used by 2 agents</summary>
<ul className="mt-3 space-y-2 text-muted-foreground">
<li>Nova · Codex · engineering-coder</li>
<li>Atlas · OpenCode · engineering-fast</li>
</ul>
</details>
)}
</>
) : tab === "models" ? (
<>
<p className="text-sm text-muted-foreground">
Add model IDs or gateway aliases this connection serves. Each agent
chooses its own model.
</p>
<div className="flex flex-col gap-3">
{models.map((model) => (
<div
key={model}
className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-3"
>
<code className="break-all text-xs">{model}</code>
<span className="text-xs text-muted-foreground">
{contextWindows[model] ?? "128000"} tokens · Streaming · Tools
</span>
</div>
))}
</div>
<TextField
label="Model ID or alias"
value={alias}
disabled={readOnly}
onChange={(event) => setAlias(event.target.value)}
placeholder="team-reviewer"
/>
<TextField
label="Context window (tokens)"
type="number"
min={1}
value={context}
disabled={readOnly}
onChange={(event) => setContext(event.target.value)}
hint="Use the limit supplied by your provider for this alias."
/>
<Button
variant="outline"
className="self-start"
disabled={
readOnly ||
!alias.trim() ||
!Number.isFinite(Number(context)) ||
Number(context) <= 0 ||
models.includes(alias.trim())
}
onClick={() => {
setModels((current) => [...current, alias.trim()]);
setContextWindows((current) => ({
...current,
[alias.trim()]: context,
}));
setAlias("");
setNotice(
"Model added to this draft. Save changes to make it available.",
);
}}
>
Add model
</Button>
<Choice
label="Default helper model"
value={helper}
disabled={readOnly}
onChange={setHelper}
options={models.map((model) => ({ value: model, label: model }))}
/>
<p className="text-xs text-muted-foreground">
Used for titles and other auxiliary requests when supported by the
harness. Keeps those requests on this connection.
</p>
</>
) : (
<>
<RoutingAccess
ownership={selected.ownership}
onOwnershipChange={() => {}}
fixedOwnership
agentIds={
agents === "all" ? new Set<string>() : new Set(agents.split(","))
}
onAgentIdsChange={(ids) => setAgents([...ids].join(","))}
allAgents={agents === "all"}
onAllAgentsChange={(all) => setAgents(all ? "all" : "nova")}
disabled={readOnly}
hideFooter
/>
<Notice>
Personal credentials are resolved for this destination. A user’s
default account does not replace a credential for {selected.name}.
</Notice>
<Button
ref={revokeTrigger}
variant="outline"
className="self-start"
disabled={readOnly || selected.status === "expired"}
onClick={() => setRevoke(true)}
>
Revoke credential
</Button>
</>
)}
{readOnly && (
<p className="text-sm text-muted-foreground">
Only the connection owner can edit these settings.
</p>
)}
{notice && <Notice>{notice}</Notice>}
<Footer
back="Cancel"
onBack={() => {
setSelected(undefined);
setNotice("");
}}
next="Save changes"
onNext={save}
disabled={readOnly || !name.trim()}
/>
<Dialog
open={rotate}
onOpenChange={(open) => {
setRotate(open);
if (!open) setCredential("");
}}
>
<DialogContent
onCloseAutoFocus={(event) => {
event.preventDefault();
rotateTrigger.current?.focus();
}}
>
<DialogHeader>
<DialogTitle>Replace credential</DialogTitle>
<DialogDescription>
{selected.name} · {selected.endpoint}. The destination and agent
models stay the same.
</DialogDescription>
</DialogHeader>
{selected.method === "Subscription" ? (
<p className="text-sm text-muted-foreground">
Sign in again to replace this subscription credential.
</p>
) : (
<TextField
label="New API key"
type="password"
value={credential}
onChange={(event) => setCredential(event.target.value)}
autoComplete="off"
/>
)}
<DialogFooter className="sm:justify-between">
<Button
variant="ghost"
onClick={() => {
setRotate(false);
setCredential("");
}}
>
Cancel
</Button>
<Button
disabled={
selected.method !== "Subscription" && !credential.trim()
}
onClick={() => {
const next = { ...selected, status: "connected" as const };
setSelected(next);
setRows((current) =>
current.map((row) => (row.id === next.id ? next : row)),
);
setCredential("");
setRotate(false);
setNotice("Credential replaced. New runs will use it.");
}}
>
{selected.method === "Subscription"
? "Sign in"
: "Replace credential"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={revoke} onOpenChange={setRevoke}>
<DialogContent
onCloseAutoFocus={(event) => {
event.preventDefault();
revokeTrigger.current?.focus();
}}
>
<DialogHeader>
<DialogTitle>Revoke this credential?</DialogTitle>
<DialogDescription>
Agents using this connection will need a valid credential before
their next run. Their selected connection will stay the same.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-between">
<Button variant="ghost" onClick={() => setRevoke(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
const next = { ...selected, status: "expired" as const };
setSelected(next);
setRows((current) =>
current.map((row) => (row.id === next.id ? next : row)),
);
setRevoke(false);
setNotice(
"Credential revoked. Reconnect to use this connection again.",
);
}}
>
Revoke credential
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Surface>
);
}
@@ -0,0 +1,113 @@
# Provider routing UI review
Open **AI Connections → Provider routing → 00 Overview → Start here**.
The seven numbered neighboring groups cover onboarding, provider setup, agent
configuration, connection management, and task recovery. Every preview includes
links to the other groups. The overview links to the existing stories reviewed;
the existing AI Connections review index links back here.
Groups 01–05 remain design fixtures: their credentials never reach a real provider.
**06 Production components** renders `AiProviderSetup` and `AiConnectionSelect`
directly from the app with fixture data. The running app now implements connection
routing, encrypted credentials, connection-aware model selection, reconnect, and
runner projection. See [AI Connections](../../../../doc/connections/AI-CONNECTIONS.md)
for the implemented compatibility matrix and validation evidence. Exploratory
Vertex, environment-identity, custom-header, and model-discovery prototypes are
not shipped options; the production group is the implementation reference.
Shared production harness/model pickers also receive presentation improvements:
the existing `AdapterMark` is extracted for reuse, and their triggers share the
`Select` trigger styling for consistent sizing.
Provider choices reuse `ConnectionChoiceList` with its optional icon slot and
the production `AppLogo` renderer. Google and Bedrock marks come from the
[LobeHub static SVG library](https://github.com/lobehub/lobe-icons/tree/master/packages/static-svg/icons),
under the existing `ui/public/brands/apps/LOBE-LICENSE`; custom gateways use the
shared network icon. Marks resolve locally through the app brand manifest.
## Existing UI reused
| Surface | Existing pieces | Proposed extension |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| Connectors list | `ConnectorCard` from `pages/apps/Browse`, including its `ConnectionAccountRow`, owner, status, and menus | Provider fixtures; compact account summaries |
| Provider setup | `StepHeader`, `AccessStepContent` (human access and agent picker), `ConnectionChoiceList`, `ProviderApiKeyCard` | Provider/endpoint, protocol, AWS/Vertex identity fields |
| Agent settings | `AgentSettingsPreview` with the real `AgentContextualSidebar`, breadcrumbs, and page header; `AdapterTypeDropdown`, `ModelDropdown`, `Select`, `AiConnectionLegacyNotice`, `RuntimeTestCard` | Connection-aware model selection and compatibility states |
| First onboarding | `ModelSourceTiles`, `CredentialModeLink`, `ConnectionChoiceList`, `ProviderApiKeyCard` | Optional provider/gateway entry |
| Connection detail | `AccessStepContent` and shared form/dialog primitives | Destination summary, model aliases, and credential management |
| Task recovery | `ConnectionIntentInteractionBody` and its inline Fix connection flow; `ProviderApiKeyCard` | Gateway-specific credential setup content |
**03 Agent is located at Agents → Nova → Harness / Runtime**
(`/agents/nova/runtime`). It renders inside the existing agent settings page;
the proposed controls fill its runtime section. This is not a separate page.
The Connectors story reuses the production provider cards and account rows,
while its new routing detail fields remain a prototype.
## Simple by default
- **Onboarding:** normal subscription/API-key path. The gateway entry is inside
collapsed **Advanced**. `Happy path walkthrough` completes setup without opening it.
- **Agent:** native Codex/Claude account by default. **Connection** is a dropdown
between Harness and Model, using the same shared `Select` as Reasoning and
Execution environment. It lists all saved connections compatible with the harness,
including configured gateways, and offers **Connect an account…**. Changing
the harness updates the choices; an incompatible current selection stays visible
with an error until the user chooses a replacement.
**Advanced model settings** exposes provider setup and enables manual model IDs.
Opening or closing it never changes the saved connection/model. Blocking errors
remain visible. `Configured gateway` shows a previously selected gateway with
the same compact view; `Connection dropdown` and `Advanced model settings` show
the expanded surfaces for review. `Change account walkthrough` covers switching
ordinary accounts, dismissing without a change, focus return, and invalidating a
previous test. `Harness connection filtering` checks that switching the harness
updates the available accounts.
- **Add connection:** familiar providers first, with OpenRouter, Bedrock, and
custom endpoints under **Advanced providers**.
- **Manage:** the default fixture shows ordinary connected accounts. `With advanced
connections` shows a company that already uses gateways. Saved gateways remain
visible; URLs and protocols live inside **Advanced connection settings** on detail.
Disclosures reuse the production `Collapsible` and `Button` primitives. The story
assertions cover the hidden advanced fields, completing ordinary onboarding,
explicitly opening advanced setup, and preserving a custom selection on collapse.
## Review sequence
1. **01 Onboarding:** current default sign-in shape, saved reuse, and optional
“Use another provider or gateway” entry under Advanced. Walk through setup into an agent.
2. **02 Connect:** provider → Access → Connect; OpenRouter API key, Bedrock region
with API key/AWS credentials/environment identity, Google Vertex identity,
custom Responses/Messages endpoints, custom auth header, and local endpoint.
3. **03 Agent:** harness/connection/model ownership, new and legacy runners,
model discovery and manual alias, incompatibility, loading/empty/denied states,
environment-specific tests, read-only, and adoption of existing settings.
4. **04 Manage:** list/detail, credential replacement, endpoint replacement as a
separate connection, model aliases/metadata, helper default, access, and revoke.
5. **05 Recovery:** expired/missing credential, inline repair, cancellation/focus,
invalid key retry, protocol mismatch, and quota. Successful inline repair uses
the existing connection request completion and task continuation flow.
Walkthrough stories execute interactions automatically. Other stories start at
the named state and remain interactive. Navigate to reset fixtures. The recovery
demo rejects the example key `invalid`; other nonempty example keys succeed.
Changing an agent connection preserves its model until an explicit replacement
is chosen. Changing settings invalidates the previous test.
## Scope exclusions
OpenClaw Gateway, Hermes Gateway, Claude Managed, AWS AgentCore, Process, HTTP,
and legacy `acpx_local` are excluded. Remote/custom integrations defer model
choices to their external agent/runtime; `acpx_local` is retired. Claude/Grok's
ACPX-backed new runner and Hermes **local** remain in scope. Cursor's custom
endpoint capability and native Pi/Copilot qualification are shown as boundaries,
not presented as shipped integrations.
## Validation
```sh
pnpm --filter @paperclipai/ui exec storybook dev --port 6143 --host 127.0.0.1 --no-open --ci -c storybook/.storybook
pnpm --filter @paperclipai/ui typecheck
pnpm check:token-gates
pnpm build-storybook
```
The walkthrough stories include interaction assertions. Review all story renders,
walkthrough results, internal links, and representative light/dark mobile layouts
in the browser.
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ConnectionIntentInteraction } from "@paperclipai/shared";
import { ConnectionIntentInteractionBody } from "@/features/connections/ConnectionIntentInteractionBody";
import type { ConnectionSetupFlowProps } from "@/features/connections/ConnectionSetupFlow";
import { ProviderApiKeyCard } from "@/components/AdapterLoginChrome";
import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadInteractionFixtures";
import { Footer, Notice, Surface } from "./shared";
type Problem = "expired" | "missing" | "protocol" | "quota";
const initial: ConnectionIntentInteraction = {
...pendingConnectionIntentInteraction,
id: "provider-routing-repair",
companyId: "company-storybook",
addresseeUserId: "dotta",
payload: {
version: 1,
purpose: "ai",
serviceSlug: "connection:gateway",
serviceName: "Engineering gateway",
requestingAgentId: "nova",
requestingAgentName: "Nova",
phase: "requested",
},
};
export function Recovery({ problem = "expired" }: { problem?: Problem }) {
return (
<Surface
title="Review the release checklist"
description="Task PC-142 · Assigned to Nova"
>
<p className="text-sm text-muted-foreground">
Codex · Engineering gateway · engineering-coder
</p>
{problem === "protocol" || problem === "quota" ? (
<Notice error>
<p>
{problem === "protocol"
? "This endpoint serves Chat Completions. Codex needs Responses with streaming and tool support."
: "Engineering gateway is at its usage limit. The credential is connected; wait for capacity or choose another authorized connection."}
</p>
<a
className="underline underline-offset-4"
href="/?path=/story/ai-connections-provider-routing-03-agent--codex-new-runner"
target="_top"
>
Review agent configuration
</a>
</Notice>
) : (
<TaskRepair problem={problem} />
)}
</Surface>
);
}
/** Use the production task card, inline setup, completion and focus lifecycle. Only API replies are fixtures. */
function TaskRepair({ problem }: { problem: "expired" | "missing" }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { retry: false, refetchOnWindowFocus: false },
mutations: { retry: false },
},
}),
);
const [interaction, setInteraction] = useState(initial);
const current = useRef(initial);
const [ready, setReady] = useState(false);
useEffect(() => {
const previous = window.fetch;
window.fetch = async (input, init) => {
const path = new URL(
input instanceof Request ? input.url : String(input),
window.location.origin,
).pathname;
if (!path.startsWith("/api/connection-intents/provider-routing-repair/"))
return previous(input, init);
if (path.endsWith("/complete")) {
current.current = {
...current.current,
status: "accepted",
result: { version: 1, outcome: "connected", connectionId: "gateway" },
};
setInteraction(current.current);
return Response.json(current.current);
}
if (path.endsWith("/setup-options"))
return Response.json({
version: 1,
interaction: current.current,
service: {
service: "connection:gateway",
name: "Engineering gateway",
methods: [],
state: "needs_user_action",
connectionId: "gateway",
},
requestedAgentId: "nova",
existingConnections: [],
});
return Response.json(
{ error: "Unsupported Storybook operation" },
{ status: 400 },
);
};
setReady(true);
return () => {
window.fetch = previous;
client.clear();
};
}, [client]);
return ready ? (
<QueryClientProvider client={client}>
<ConnectionIntentInteractionBody
interaction={interaction}
currentUserId="dotta"
addresseeLabel="Dotta"
renderSetup={(props) => (
<GatewayCredential {...props} problem={problem} />
)}
/>
</QueryClientProvider>
) : (
<p role="status">Loading connection request…</p>
);
}
function GatewayCredential({
problem,
onCancel,
onComplete,
}: ConnectionSetupFlowProps & { problem: Problem }) {
const [credential, setCredential] = useState("");
const [failed, setFailed] = useState(false);
const connect = () => {
if (!credential.trim()) return;
setCredential("");
if (credential === "invalid") {
setFailed(true);
return;
}
onComplete?.({ connectionId: "gateway" });
};
return (
<section aria-label="Gateway credential repair" className="space-y-4">
<p className="text-sm text-muted-foreground">
{problem === "missing"
? "Connect your own credential for this destination."
: "Replace the expired credential for this destination."}{" "}
The harness and selected model stay the same.
</p>
<p className="break-all font-mono text-xs">
https://models.example.com/v1 · OpenAI Responses
</p>
<ProviderApiKeyCard
providerName="Engineering gateway"
value={credential}
onChange={(value) => {
setCredential(value);
setFailed(false);
}}
onSubmit={connect}
autoFocus
/>
{failed && (
<Notice error>
The provider rejected this credential. Enter another key and retry.
</Notice>
)}
<Footer
back="Cancel"
onBack={() => {
setCredential("");
onCancel?.();
}}
next="Connect"
onNext={connect}
disabled={!credential.trim()}
/>
</section>
);
}
@@ -0,0 +1,168 @@
import { reviewGroups, reviewPath, Surface } from "./shared";
const existing = [
[
"AI Connections / Review",
"ai-connections-review--review-index",
"Catalog, account ownership, authentication, task repair, and legacy adoption",
],
[
"Onboarding / Agent arc",
"onboarding-agent-arc--connect-a-model",
"Current first onboarding, using the production wizard",
],
[
"Onboarding / Saved connections",
"onboarding-saved-connections--claude-api-keys",
"Existing account reuse",
],
[
"Onboarding / New agent",
"onboarding-new-agent--interactive-flow",
"Hire, connect, configure, and test",
],
[
"Agents / Configuration refresh",
"agents-configuration-refresh--runtime",
"Harness, model, environment, and save controls",
],
[
"AI Connections / Inline task",
"ai-connections-review--inline-task-connection",
"Current connection request and inline Fix connection flow",
],
] as const;
const descriptions = [
"Review scope, ownership, existing surfaces, and decisions.",
"Sign in, choose a model, test, and finish. Other providers stay under Advanced.",
"Familiar accounts first; OpenRouter, Bedrock, and custom endpoints under Advanced providers.",
"Everyday account and model selection, then optional advanced providers, aliases, and validation.",
"Connection list, destinations, credential replacement, models, helper defaults, and access.",
"Task-level reconnect, missing personal credentials, protocol failure, and quota failure.",
"Implemented provider setup and compatible connection selector, rendered directly from production components.",
];
export function ReviewIndex() {
return (
<Surface
title="Provider routing · Start here"
description="Review the neighboring groups below. Groups 01–05 explore the design; 06 renders the implemented production components with fixture data."
>
<ol className="flex flex-col gap-4">
{reviewGroups.slice(1).map(([group, story, label], index) => (
<li key={group} className="space-y-1">
<a
className="font-medium underline underline-offset-4"
href={reviewPath(group, story)}
target="_top"
>
{index + 1}. {label}
</a>
<p className="text-sm text-muted-foreground">
{descriptions[index + 1]}
</p>
</li>
))}
</ol>
<section className="space-y-3">
<h2 className="font-semibold">Simple by default</h2>
<p className="text-sm text-muted-foreground">
Start with Default path or Happy path walkthrough in Onboarding, then
Codex new runner or Claude default in Agent. Sign in with the normal
account, choose a model, and test. The Connection dropdown lists saved
connections compatible with the harness, including configured
gateways. Provider URLs, custom provider setup, and custom model entry
stay behind an explicit Advanced action.
</p>
<p className="text-sm text-muted-foreground">
Advanced providers stories show the expanded controls. Configured
gateway shows the everyday view after setup: the connection name and
selected model remain visible while its technical settings are folded
away. Closing Advanced preserves every selection and validation error.
</p>
</section>
<section className="space-y-3">
<h2 className="font-semibold">Where these changes live</h2>
<p className="text-sm text-muted-foreground">
Agent settings live at Agents → Nova → Harness / Runtime. The Agent
stories render the existing settings page, sidebar, and header, with
the proposed connection controls in its runtime section.
</p>
<p className="text-sm text-muted-foreground">
Manage uses the existing Connectors provider cards and account rows.
Setup reuses the Access cards, agent picker, and API key form. Agent
selection uses the existing harness dropdown, connection choices,
model picker, and test card. Recovery uses the existing inline Fix
connection flow. Destination and model routing fields are the proposed
additions.
</p>
</section>
<section className="space-y-3">
<h2 className="font-semibold">Decisions to review</h2>
<ul className="list-disc space-y-2 pl-5 text-sm">
<li>
The connection owns the destination and authentication. The agent
owns its harness and selected model.
</li>
<li>
First onboarding keeps its usual sign-in path. The optional provider
link is inside Advanced; additional setup uses Access → Connect.
</li>
<li>
Changing a connection preserves the harness and asks for a
compatible model when needed.
</li>
<li>
Tests run in the selected environment. A key being accepted does not
establish model/tool compatibility.
</li>
<li>
A new endpoint creates a new connection; changing a personal default
never silently selects another destination.
</li>
</ul>
</section>
<section className="space-y-3">
<h2 className="font-semibold">Included harnesses</h2>
<p className="text-sm text-muted-foreground">
Codex, Claude Code, OpenCode, Grok Build, Pi, Gemini CLI, Kimi CLI,
and Hermes local. Both runner paths are represented where available.
Cursor is shown as unverified for custom endpoints; native Pi and
Copilot show their qualification boundary. Fixture model IDs are
examples, not a validated provider catalog.
</p>
</section>
<section className="space-y-3">
<h2 className="font-semibold">Excluded from this update</h2>
<p className="text-sm text-muted-foreground">
OpenClaw Gateway, Hermes Gateway, Claude Managed, AWS AgentCore,
Process, HTTP, and legacy acpx_local are out of scope. Remote/custom
integrations retain model configuration in the agent service or
runtime they invoke. Legacy acpx_local is retired. This exclusion does
not remove Claude/Grok’s ACPX-backed new runner or Hermes local from
the review.
</p>
</section>
<section className="space-y-3">
<h2 className="font-semibold">Existing stories reviewed</h2>
{existing.map(([label, id, detail]) => (
<div key={id} className="space-y-1">
<a
href={`/?path=/story/${id}`}
target="_top"
className="text-sm underline underline-offset-4"
>
{label}
</a>
<p className="text-xs text-muted-foreground">{detail}</p>
</div>
))}
</section>
<p className="text-xs text-muted-foreground">
Use example credentials only. In the recovery demo, “invalid” simulates
rejection. Stories reset on navigation. Local state and intercepted
fixture APIs keep credentials, tests, and settings in the preview;
nothing is sent to a provider or Paperclip server.
</p>
</Surface>
);
}
@@ -0,0 +1,71 @@
import { AccessStepContent } from "@/features/connections/ConnectionSetupFlow";
import { Footer } from "./shared";
const agents = [
{ id: "nova", name: "Nova" },
{ id: "atlas", name: "Atlas" },
];
/** Offline state adapter for the same access cards and agent picker used by Connectors. */
export function RoutingAccess({
ownership,
onOwnershipChange,
allAgents,
onAllAgentsChange,
agentIds,
onAgentIdsChange,
fixedOwnership,
disabled,
hideFooter,
onBack = () => {},
onContinue = () => {},
}: {
ownership: "personal" | "shared";
onOwnershipChange: (value: "personal" | "shared") => void;
allAgents: boolean;
onAllAgentsChange: (value: boolean) => void;
agentIds: Set<string>;
onAgentIdsChange: (value: Set<string>) => void;
fixedOwnership?: boolean;
disabled?: boolean;
hideFooter?: boolean;
onBack?: () => void;
onContinue?: () => void;
}) {
return (
<div className="space-y-6">
<fieldset disabled={disabled} className="min-w-0">
<AccessStepContent
agents={agents}
authKind="api_key"
grantKind={ownership === "shared" ? "organization" : "user"}
grantKinds={
fixedOwnership
? [ownership === "shared" ? "organization" : "user"]
: ["user", "organization"]
}
setGrantKind={(kind) =>
onOwnershipChange(kind === "organization" ? "shared" : "personal")
}
installChoice={allAgents ? "all" : "specific"}
setInstallChoice={(choice) => onAllAgentsChange(choice === "all")}
installAgentIds={agentIds}
setInstallAgentIds={onAgentIdsChange}
submitLabel="Continue"
onBack={onBack}
onContinue={onContinue}
hideFooter
bare
/>
</fieldset>
{!hideFooter && (
<Footer
onBack={onBack}
next="Continue"
onNext={onContinue}
disabled={disabled || (!allAgents && !agentIds.size)}
/>
)}
</div>
);
}
@@ -0,0 +1,250 @@
/** Proposed UI fixtures only. These are deliberately separate from the production auth contract. */
export type Harness =
| "codex"
| "claude"
| "opencode"
| "pi"
| "gemini"
| "kimi"
| "grok"
| "hermes"
| "cursor"
| "copilot";
export type Provider =
| "openai"
| "anthropic"
| "openrouter"
| "bedrock"
| "google"
| "xai"
| "custom";
export type Protocol = "responses" | "messages" | "chat" | "gemini";
export const isAdvancedProvider = (provider: Provider) =>
["openrouter", "bedrock", "custom"].includes(provider);
export type Connection = {
id: string;
name: string;
provider: Provider;
endpoint: string;
protocols: Protocol[];
method: string;
ownership: "personal" | "shared";
models: string[];
status: "connected" | "expired" | "denied";
};
export const harnesses: {
value: Harness;
label: string;
native: boolean;
formats: Protocol[];
}[] = [
{ value: "codex", label: "Codex", native: true, formats: ["responses"] },
{
value: "claude",
label: "Claude Code",
native: true,
formats: ["messages"],
},
{
value: "opencode",
label: "OpenCode",
native: true,
formats: ["responses", "messages", "chat"],
},
{
value: "grok",
label: "Grok Build",
native: true,
formats: ["responses", "messages", "chat"],
},
{
value: "pi",
label: "Pi",
native: false,
formats: ["responses", "messages", "chat", "gemini"],
},
{ value: "gemini", label: "Gemini CLI", native: false, formats: ["gemini"] },
{
value: "kimi",
label: "Kimi CLI",
native: false,
formats: ["responses", "messages", "chat", "gemini"],
},
{ value: "hermes", label: "Hermes local", native: false, formats: ["chat"] },
{ value: "cursor", label: "Cursor", native: false, formats: [] },
{
value: "copilot",
label: "GitHub Copilot",
native: false,
formats: ["chat", "messages"],
},
];
export const providers: {
value: Provider;
label: string;
description: string;
}[] = [
{
value: "openai",
label: "OpenAI",
description: "ChatGPT subscription or API key",
},
{
value: "anthropic",
label: "Anthropic",
description: "Claude subscription or API key",
},
{
value: "openrouter",
label: "OpenRouter",
description: "One account, multiple model providers",
},
{
value: "bedrock",
label: "Amazon Bedrock",
description: "Your AWS account and region",
},
{ value: "google", label: "Google", description: "Gemini API or Vertex AI" },
{ value: "xai", label: "xAI", description: "Grok subscription or API key" },
{
value: "custom",
label: "Custom provider or gateway",
description: "A company endpoint or local model service",
},
];
export const formatLabels: Record<Protocol, string> = {
responses: "OpenAI Responses",
messages: "Anthropic Messages",
chat: "OpenAI Chat Completions",
gemini: "Google Gemini",
};
export const connections: Connection[] = [
{
id: "chatgpt",
name: "My ChatGPT subscription",
provider: "openai",
endpoint: "OpenAI",
protocols: ["responses"],
method: "Subscription",
ownership: "personal",
models: ["gpt-5.4"],
status: "connected",
},
{
id: "claude",
name: "My Claude subscription",
provider: "anthropic",
endpoint: "Anthropic",
protocols: ["messages"],
method: "Subscription",
ownership: "personal",
models: ["claude-sonnet-4-6"],
status: "connected",
},
{
id: "router",
name: "Company OpenRouter",
provider: "openrouter",
endpoint: "https://openrouter.ai/api",
protocols: ["responses", "messages", "chat"],
method: "API key",
ownership: "shared",
models: ["openai/gpt-5.4", "anthropic/claude-sonnet-4.6"],
status: "connected",
},
{
id: "bedrock",
name: "Company Bedrock",
provider: "bedrock",
endpoint: "us-east-1",
protocols: ["responses", "messages"],
method: "Environment identity",
ownership: "shared",
models: ["us.anthropic.claude-sonnet-4-6"],
status: "connected",
},
{
id: "gateway",
name: "Engineering gateway",
provider: "custom",
endpoint: "https://models.example.com/v1",
protocols: ["responses"],
method: "API key",
ownership: "shared",
models: ["engineering-coder", "engineering-fast"],
status: "connected",
},
{
id: "chat",
name: "Local model service",
provider: "custom",
endpoint: "http://localhost:11434/v1",
protocols: ["chat"],
method: "No authentication",
ownership: "personal",
models: ["qwen-coder"],
status: "connected",
},
{
id: "gemini",
name: "Company Gemini",
provider: "google",
endpoint: "Google Gemini API",
protocols: ["gemini"],
method: "API key",
ownership: "shared",
models: ["gemini-2.5-pro"],
status: "connected",
},
{
id: "openai-api",
name: "Company OpenAI",
provider: "openai",
endpoint: "https://api.openai.com/v1",
protocols: ["responses"],
method: "API key",
ownership: "shared",
models: ["gpt-5.4"],
status: "connected",
},
];
export function compatibility(
connection: Connection,
harness: Harness,
): string | undefined {
if (harness === "cursor")
return "Custom provider configuration has not been verified for Cursor.";
if (harness === "copilot")
return "This runner profile is awaiting qualification.";
if (
connection.method === "Subscription" &&
!(
(connection.provider === "openai" && harness === "codex") ||
(connection.provider === "anthropic" && harness === "claude")
)
)
return "This subscription is available through its own harness.";
if (
connection.provider === "bedrock" &&
!["codex", "claude", "opencode"].includes(harness)
)
return "Bedrock support has not been verified for this harness in Paperclip.";
if (
!harnesses
.find((item) => item.value === harness)!
.formats.some((format) => connection.protocols.includes(format))
)
return `${harnesses.find((item) => item.value === harness)!.label} needs a different API format.`;
}
export function modelsFor(connection: Connection, harness: Harness): string[] {
if (connection.id === "router" && harness === "claude")
return connection.models.filter((id) => id.startsWith("anthropic/"));
if (connection.provider === "bedrock" && harness === "codex")
return ["openai.gpt-5.4"];
return connection.models;
}
export function defaultConnection(harness: Harness): Connection {
return (
connections.find((item) => !compatibility(item, harness)) ?? connections[2]
);
}
@@ -0,0 +1,255 @@
import { useId, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ChevronDown } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export const reviewPath = (group: string, story: string) =>
`/?path=/story/ai-connections-provider-routing-${group}--${story}`;
export function AdvancedOptions({
children,
label = "Advanced",
open,
onOpenChange,
defaultOpen = false,
}: {
children: ReactNode;
label?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean;
}) {
return (
<Collapsible
open={open}
onOpenChange={onOpenChange}
defaultOpen={defaultOpen}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="group px-0 text-muted-foreground"
>
<ChevronDown className="size-4 -rotate-90 group-data-[state=open]:rotate-0" />
{label}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
{children}
</CollapsibleContent>
</Collapsible>
);
}
export const reviewGroups = [
["00-overview", "start-here", "Start here"],
["01-onboarding", "default-path", "Onboarding"],
["02-connect", "choose-provider", "Connect"],
["03-agent", "codex-new-runner", "Agent"],
["04-manage", "connection-list", "Manage"],
["05-recovery", "expired-credential", "Recovery"],
["06-production-components", "choose-provider", "Production components"],
] as const;
export function ReviewFrame({
children,
location,
}: {
children: ReactNode;
location: string;
}) {
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-4 sm:p-6">
<aside
aria-label="Prototype review context"
className="flex flex-col gap-3 rounded-lg border border-dashed border-border bg-muted p-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<strong className="text-sm">Provider routing · Design review</strong>
<span className="text-xs text-muted-foreground">
Storybook prototype · No live authentication
</span>
</div>
<p className="text-sm text-muted-foreground">
<strong>Location:</strong> {location}
</p>
<nav
aria-label="Provider routing review"
className="flex flex-wrap gap-x-4 gap-y-2"
>
{reviewGroups.map(([group, story, label]) => (
<a
key={group}
className="text-sm underline underline-offset-4"
href={reviewPath(group, story)}
target="_top"
>
{label}
</a>
))}
</nav>
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">
Production location and prototype scope
</summary>
<p className="mt-3">
Uses existing Paperclip components with local fixture state.
Provider support, accounts, model catalogs, and tests are
illustrative fixtures; these flows are not wired to the product.
</p>
</details>
</aside>
<div
className="min-w-0 rounded-lg border border-border bg-background"
data-testid="provider-routing-preview"
>
{children}
</div>
</div>
);
}
export function Surface({
title,
description,
children,
embedded = false,
}: {
title: string;
description?: string;
children: ReactNode;
embedded?: boolean;
}) {
return (
<section
className={
embedded
? "flex flex-col gap-6"
: "mx-auto flex max-w-2xl flex-col gap-6 p-4 sm:p-6"
}
>
{!embedded && (
<header className="space-y-2">
<h1 className="text-xl font-semibold">{title}</h1>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</header>
)}
{children}
</section>
);
}
export function TextField({
label,
hint,
...props
}: { label: string; hint?: string } & React.ComponentProps<typeof Input>) {
const id = useId();
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className="text-xs text-muted-foreground">
{label}
</label>
<Input
id={id}
aria-describedby={hint ? `${id}-hint` : undefined}
{...props}
/>
{hint && (
<p id={`${id}-hint`} className="text-xs text-muted-foreground">
{hint}
</p>
)}
</div>
);
}
export function Choice({
label,
value,
onChange,
options,
disabled = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string; disabled?: boolean }[];
disabled?: boolean;
}) {
const id = useId();
return (
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor={id} className="text-xs text-muted-foreground">
{label}
</label>
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export function Footer({
back,
next,
onBack,
onNext,
disabled,
}: {
back?: string;
next: string;
onBack: () => void;
onNext: () => void;
disabled?: boolean;
}) {
return (
<footer className="flex items-center justify-between gap-3 border-t border-border pt-4">
<Button variant="ghost" onClick={onBack}>
{back ?? "Back"}
</Button>
<Button onClick={onNext} disabled={disabled}>
{next}
</Button>
</footer>
);
}
export function Notice({
children,
error = false,
}: {
children: ReactNode;
error?: boolean;
}) {
return (
<div
role={error ? "alert" : "status"}
className={`rounded-md border p-3 text-sm ${error ? "border-destructive/30 text-destructive" : "border-border bg-muted text-foreground"}`}
>
{children}
</div>
);
}
@@ -0,0 +1,36 @@
import { addons } from "storybook/preview-api";
import { userEvent, within } from "storybook/test";
export const reviewLifecycle = {
beforeEach: () => {
delete document.body.dataset.providerRoutingReady;
delete document.body.dataset.providerRoutingError;
const channel = addons.getChannel();
const report = (error: unknown) => {
document.body.dataset.providerRoutingError = JSON.stringify(error);
};
channel.on("playFunctionThrewException", report);
channel.on("unhandledErrorsWhilePlaying", report);
return () => {
channel.off("playFunctionThrewException", report);
channel.off("unhandledErrorsWhilePlaying", report);
};
},
afterEach: ({ id }: { id: string }) => {
document.body.dataset.providerRoutingReady = id;
},
};
export async function choose(
canvasElement: HTMLElement,
label: string,
option: string,
) {
await userEvent.click(
within(canvasElement).getByRole("combobox", { name: label }),
);
await userEvent.click(
await within(canvasElement.ownerDocument.body).findByRole("option", {
name: option,
}),
);
}
@@ -75,6 +75,9 @@ export const ReviewIndex: Story = {
render: () => (
<main className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
<h1 className="text-xl font-semibold">AI Connections · Review index</h1>
<a className="text-sm underline underline-offset-2" href="/?path=/story/ai-connections-provider-routing-00-overview--start-here" target="_top">
Review the proposed provider routing flows: onboarding, connections, agents, management, and recovery
</a>
<p className="text-sm text-muted-foreground">
Milestone 1: shared UI, simulated accounts, no live authentication.
Start with the existing Connectors page, then account details, provider
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ReviewIndex } from "../prototypes/provider-routing/ReviewIndex";
import { ReviewFrame } from "../prototypes/provider-routing/shared";
import { reviewLifecycle } from "../prototypes/provider-routing/story-support";
const meta = {
title: "AI Connections/Provider routing/00 Overview",
component: ReviewIndex,
parameters: { layout: "fullscreen" },
...reviewLifecycle,
decorators: [
(Story) => (
<ReviewFrame location="Review map and scope; this page has no production app location.">
<Story />
</ReviewFrame>
),
],
} satisfies Meta<typeof ReviewIndex>;
export default meta;
export const StartHere: StoryObj<typeof meta> = {};
@@ -0,0 +1,94 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { Onboarding } from "../prototypes/provider-routing/AgentSetup";
import { ReviewFrame } from "../prototypes/provider-routing/shared";
import { reviewLifecycle } from "../prototypes/provider-routing/story-support";
const meta = {
title: "AI Connections/Provider routing/01 Onboarding",
component: Onboarding,
parameters: { layout: "fullscreen" },
...reviewLifecycle,
decorators: [
(Story) => (
<ReviewFrame location="First onboarding → connect provider, after choosing the harness.">
<Story />
</ReviewFrame>
),
],
render: (args) => <Onboarding key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof Onboarding>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DefaultPath: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("button", { name: "Sign in to OpenAI" }),
).toBeVisible();
await expect(
canvas.queryByRole("button", { name: "Use another provider or gateway" }),
).not.toBeInTheDocument();
await expect(canvas.queryByLabelText("Base URL")).not.toBeInTheDocument();
await expect(
canvas.queryByText("Use your ChatGPT subscription to get started."),
).not.toBeInTheDocument();
},
};
export const HappyPathWalkthrough: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Sign in to OpenAI" }),
);
await expect(
canvas.getByRole("combobox", { name: "Connection" }),
).toHaveTextContent("My ChatGPT subscription");
await expect(
canvas.getByRole("combobox", { name: "Connection" }),
).toBeVisible();
await expect(
canvas.queryByRole("region", { name: "Connection" }),
).not.toBeInTheDocument();
await expect(
canvas.queryByRole("button", {
name: "Connect another provider or gateway",
}),
).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Run test" }));
await expect(
await canvas.findByText("Connection successful"),
).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Finish setup" }));
await expect(canvas.getByText(/Nova’s configuration saved/)).toBeVisible();
},
};
export const ReuseSavedConnection: Story = { args: { savedConnection: true } };
export const OtherProvider: Story = { args: { initialAlternate: true } };
export const Mobile: Story = {
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const OptionalProviderWalkthrough: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Advanced" }));
await userEvent.click(
canvas.getByRole("button", { name: "Use another provider or gateway" }),
);
await userEvent.click(canvas.getByRole("button", { name: "OpenRouter" }));
await userEvent.click(canvas.getByRole("button", { name: "Continue" }));
await userEvent.type(canvas.getByLabelText("API key"), "storybook-example");
await userEvent.click(canvas.getByRole("button", { name: "Connect" }));
await userEvent.click(
canvas.getByRole("button", { name: "Use connection" }),
);
await expect(
canvas.getByRole("group", { name: "Harness selection" }),
).toHaveTextContent("Codex");
await expect(
canvas.getByRole("combobox", { name: "Connection" }),
).toHaveTextContent("My OpenRouter");
await expect(
canvas.getByRole("button", { name: "Advanced model settings" }),
).toHaveAttribute("aria-expanded", "false");
},
};
@@ -0,0 +1,134 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { ConnectionSetup } from "../prototypes/provider-routing/ConnectionSetup";
import { ReviewFrame } from "../prototypes/provider-routing/shared";
import {
choose,
reviewLifecycle,
} from "../prototypes/provider-routing/story-support";
const meta = {
title: "AI Connections/Provider routing/02 Connect",
component: ConnectionSetup,
parameters: { layout: "fullscreen" },
...reviewLifecycle,
decorators: [
(Story) => (
<ReviewFrame location="Apps → AI connections → Add connection. The same setup is opened from onboarding, agent settings, and task requests.">
<Story />
</ReviewFrame>
),
],
render: (args) => <ConnectionSetup key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof ConnectionSetup>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ChooseProvider: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("button", { name: "OpenAI" })).toBeVisible();
await expect(
canvas.queryByRole("button", { name: "Custom provider or gateway" }),
).not.toBeInTheDocument();
await expect(
canvas.queryByRole("button", { name: "Amazon Bedrock" }),
).not.toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: "Advanced providers" }),
);
await expect(
canvas.getByRole("button", { name: "Custom provider or gateway" }),
).toBeVisible();
await userEvent.click(
canvas.getByRole("button", { name: "Advanced providers" }),
);
},
};
export const AdvancedProviders: Story = { args: { initialAdvanced: true } };
export const Access: Story = { args: { initialStep: "access" } };
export const SharedAccess: Story = {
...Access,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("radio", { name: "Any human in the organization" }),
);
await expect(
canvas.getByRole("radio", { name: "Any human in the organization" }),
).toBeChecked();
},
};
export const OpenRouterKey: Story = { args: { initialStep: "connect" } };
export const BedrockApiKey: Story = {
args: { initialStep: "connect", initialProvider: "bedrock" },
};
export const BedrockEnvironmentIdentity: Story = {
args: {
initialStep: "connect",
initialProvider: "bedrock",
initialMethod: "identity",
},
};
export const BedrockAccessKeys: Story = {
args: {
initialStep: "connect",
initialProvider: "bedrock",
initialMethod: "aws-keys",
},
};
export const GoogleVertexIdentity: Story = {
args: {
initialStep: "connect",
initialProvider: "google",
initialMethod: "identity",
},
};
export const CustomResponses: Story = {
args: { initialStep: "connect", initialProvider: "custom" },
};
export const CustomAnthropic: Story = {
args: {
initialStep: "connect",
initialProvider: "custom",
initialProtocol: "messages",
},
};
export const CustomHeader: Story = {
args: {
initialStep: "connect",
initialProvider: "custom",
initialMethod: "header",
},
};
export const LocalEndpoint: Story = {
args: {
initialStep: "connect",
initialProvider: "custom",
initialMethod: "none",
initialProtocol: "chat",
},
};
export const InvalidCredential: Story = {
args: { initialStep: "connect", initialError: "credential" },
};
export const MobileBedrock: Story = {
...BedrockAccessKeys,
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const CustomEndpointWalkthrough: Story = {
...CustomResponses,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
canvas.getByLabelText("Base URL"),
"https://models.example.com/v1",
);
await userEvent.type(canvas.getByLabelText("API key"), "storybook-example");
await userEvent.click(canvas.getByRole("button", { name: "Connect" }));
await expect(
canvas.getByRole("heading", { name: "Connection ready" }),
).toBeVisible();
await expect(
canvas.queryByDisplayValue("storybook-example"),
).not.toBeInTheDocument();
},
};
@@ -0,0 +1,325 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { AgentSetup } from "../prototypes/provider-routing/AgentSetup";
import { ReviewFrame } from "../prototypes/provider-routing/shared";
import {
choose,
reviewLifecycle,
} from "../prototypes/provider-routing/story-support";
const meta = {
title: "AI Connections/Provider routing/03 Agent",
component: AgentSetup,
parameters: { layout: "fullscreen" },
...reviewLifecycle,
decorators: [
(Story) => (
<ReviewFrame location="Agents → Nova → Harness / Runtime (/agents/nova/runtime). Existing agent page and sidebar; proposed connection routing fields. The same fields are embedded in Create agent → Configure.">
<Story />
</ReviewFrame>
),
],
render: (args) => <AgentSetup key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof AgentSetup>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CodexNewRunner: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("combobox", { name: "Connection" }),
).toHaveTextContent("My ChatGPT subscription");
await expect(
canvas.queryByRole("option", { name: "Engineering gateway" }),
).not.toBeInTheDocument();
await expect(
canvas.queryByText("https://models.example.com/v1"),
).not.toBeInTheDocument();
await userEvent.click(
within(canvas.getByRole("group", { name: "Model selection" })).getByRole(
"button",
{ name: "gpt-5.4" },
),
);
const body = within(canvasElement.ownerDocument.body);
await expect(
await body.findByPlaceholderText("Search models..."),
).toBeVisible();
await expect(
body.queryByPlaceholderText("Search models... (type to create)"),
).not.toBeInTheDocument();
await userEvent.keyboard("{Escape}");
},
};
export const ClaudeDefault: Story = { args: { harness: "claude" } };
export const AdvancedProviders: Story = {
name: "Advanced model settings",
args: { initialAdvanced: true },
};
export const ChangeConnection: Story = {
name: "Connection dropdown",
play: async ({ canvasElement }) => {
await userEvent.click(
within(canvasElement).getByRole("combobox", {
name: "Connection",
}),
);
const options = within(
await within(canvasElement.ownerDocument.body).findByRole("listbox"),
);
await expect(
options.getByRole("option", { name: "My ChatGPT subscription" }),
).toHaveAttribute("aria-selected", "true");
await expect(
options.getByRole("option", { name: "Company OpenAI" }),
).toBeVisible();
await expect(
options.getByRole("option", { name: "Engineering gateway" }),
).toBeVisible();
await expect(
options.getByRole("option", { name: "Company OpenRouter" }),
).toBeVisible();
await expect(
options.getByRole("option", { name: "Company Bedrock" }),
).toBeVisible();
await expect(
options.queryByRole("option", { name: "My Claude subscription" }),
).not.toBeInTheDocument();
await expect(
options.queryByRole("option", { name: "Local model service" }),
).not.toBeInTheDocument();
},
};
export const ChangeAccountWalkthrough: Story = {
args: { initialTest: "pass" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const change = canvas.getByRole("combobox", { name: "Connection" });
await userEvent.click(change);
await body.findByRole("listbox");
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(change).toHaveFocus());
await expect(
canvas.getByRole("button", { name: "Save changes" }),
).toBeEnabled();
await userEvent.click(change);
await userEvent.click(
within(await body.findByRole("listbox")).getByRole("option", {
name: "Company OpenAI",
}),
);
await waitFor(() =>
expect(body.queryByRole("listbox")).not.toBeInTheDocument(),
);
await expect(
canvas.getByRole("combobox", { name: "Connection" }),
).toHaveTextContent("Company OpenAI");
await expect(
canvas.getByRole("group", { name: "Model selection" }),
).toHaveTextContent("gpt-5.4");
await expect(
canvas.getByRole("button", { name: "Save changes" }),
).toBeDisabled();
await expect(
canvas.getByRole("button", { name: "Advanced model settings" }),
).toHaveAttribute("aria-expanded", "false");
await expect(
canvas.queryByText("https://api.openai.com/v1"),
).not.toBeInTheDocument();
await waitFor(() => expect(change).toHaveFocus());
},
};
export const HarnessConnectionFiltering: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
within(
canvas.getByRole("group", { name: "Harness selection" }),
).getByRole("button", { name: "Codex" }),
);
await userEvent.click(
await body.findByRole("button", { name: "Claude Code" }),
);
await userEvent.click(canvas.getByRole("combobox", { name: "Connection" }));
const options = within(await body.findByRole("listbox"));
await expect(
options.getByRole("option", { name: "My Claude subscription" }),
).toBeVisible();
await expect(
options.getByRole("option", { name: "Company OpenRouter" }),
).toBeVisible();
await expect(
options.getByRole("option", { name: "Company Bedrock" }),
).toBeVisible();
for (const name of [
"My ChatGPT subscription",
"Company OpenAI",
"Engineering gateway",
"Company Gemini",
"Local model service",
]) {
await expect(
options.queryByRole("option", { name }),
).not.toBeInTheDocument();
}
await userEvent.click(
options.getByRole("option", { name: "My Claude subscription" }),
);
await userEvent.click(
within(canvas.getByRole("group", { name: "Model selection" })).getByRole(
"button",
{ name: "gpt-5.4" },
),
);
await userEvent.click(
await body.findByRole("button", { name: "claude-sonnet-4-6" }),
);
await expect(canvas.queryByRole("alert")).not.toBeInTheDocument();
},
};
export const ConfiguredGateway: Story = {
args: { initialConnectionId: "gateway" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("combobox", { name: "Connection" }),
).toHaveTextContent("Engineering gateway");
await expect(
canvas.getByRole("group", { name: "Model selection" }),
).toHaveTextContent("engineering-coder");
await expect(
canvas.queryByText("https://models.example.com/v1"),
).not.toBeInTheDocument();
await expect(
canvas.getByRole("button", { name: "Advanced model settings" }),
).toHaveAttribute("aria-expanded", "false");
},
};
export const CodexLegacy: Story = { args: { runner: "legacy" } };
export const ClaudeBedrock: Story = {
args: { harness: "claude", initialConnectionId: "bedrock" },
};
export const OpenCodeNewRunner: Story = { args: { harness: "opencode" } };
export const GrokNewRunner: Story = { args: { harness: "grok" } };
export const PiLegacy: Story = { args: { harness: "pi", runner: "legacy" } };
export const GeminiLegacy: Story = {
args: { harness: "gemini", runner: "legacy", initialConnectionId: "gemini" },
};
export const KimiLegacy: Story = {
args: { harness: "kimi", runner: "legacy" },
};
export const HermesLocal: Story = {
args: { harness: "hermes", runner: "legacy", initialConnectionId: "chat" },
};
export const CursorUnverified: Story = {
args: { harness: "cursor", runner: "legacy" },
};
export const NativePiPending: Story = { args: { harness: "pi" } };
export const NativeCopilotPending: Story = { args: { harness: "copilot" } };
export const NewAgent: Story = { args: { newAgent: true } };
export const NoConnections: Story = { args: { scenario: "empty" } };
export const Loading: Story = { args: { scenario: "loading" } };
export const ModelDiscoveryFailed: Story = {
args: { scenario: "catalog-error" },
};
export const IncompatibleProtocol: Story = {
args: { initialConnectionId: "chat" },
};
export const MissingPersonalCredential: Story = {
args: { initialConnectionId: "gateway", scenario: "missing-personal" },
};
export const SharedAccessDenied: Story = {
args: { scenario: "denied", initialConnectionId: "router" },
};
export const ReadOnly: Story = { args: { scenario: "readonly" } };
export const AdoptLegacySettings: Story = {
args: { runner: "legacy", scenario: "legacy" },
};
export const TestRunning: Story = { args: { initialTest: "running" } };
export const TestPassed: Story = { args: { initialTest: "pass" } };
export const ToolSupportFailed: Story = {
args: { initialTest: "fail", testFailure: "tools" },
};
export const EnvironmentUnreachable: Story = {
args: { initialTest: "fail", testFailure: "network" },
};
export const Mobile: Story = {
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const ChangeConnectionWalkthrough: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await choose(canvasElement, "Connection", "Engineering gateway");
const body = within(canvasElement.ownerDocument.body);
await waitFor(() =>
expect(body.queryByRole("listbox")).not.toBeInTheDocument(),
);
await expect(
canvas.getByRole("group", { name: "Harness selection" }),
).toHaveTextContent("Codex");
await expect(canvas.getByRole("alert")).toHaveTextContent(
"previous model is unavailable",
);
await expect(
canvas.getByRole("button", { name: "Save changes" }),
).toBeDisabled();
await userEvent.click(
within(canvas.getByRole("group", { name: "Model selection" })).getByRole(
"button",
{ name: "gpt-5.4" },
),
);
await userEvent.click(
await within(canvasElement.ownerDocument.body).findByRole("button", {
name: "engineering-coder",
}),
);
await userEvent.click(canvas.getByRole("button", { name: "Run test" }));
await expect(
await canvas.findByText("Connection successful"),
).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Save changes" }));
await expect(canvas.getByText(/Nova’s configuration saved/)).toBeVisible();
await choose(canvasElement, "Execution environment", "Local machine");
await expect(
canvas.getByRole("button", { name: "Save changes" }),
).toBeDisabled();
},
};
export const CustomModelAlias: Story = {
args: { initialConnectionId: "gateway" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Advanced model settings" }),
);
await userEvent.click(
within(canvas.getByRole("group", { name: "Model selection" })).getByRole(
"button",
{ name: "engineering-coder" },
),
);
const body = within(canvasElement.ownerDocument.body);
await userEvent.type(
await body.findByPlaceholderText("Search models... (type to create)"),
"team-reviewer",
);
await userEvent.click(body.getByRole("button", { name: /team-reviewer/ }));
await expect(
canvas.getByRole("group", { name: "Model selection" }),
).toHaveTextContent("team-reviewer");
await userEvent.click(
canvas.getByRole("button", { name: "Advanced model settings" }),
);
await expect(
canvas.getByRole("group", { name: "Model selection" }),
).toHaveTextContent("team-reviewer");
await expect(
canvas.getByRole("combobox", { name: "Connection" }),
).toHaveTextContent("Engineering gateway");
await expect(
canvas.queryByRole("region", { name: "Connection" }),
).not.toBeInTheDocument();
},
};
@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { Management } from "../prototypes/provider-routing/Management";
import { ReviewFrame } from "../prototypes/provider-routing/shared";
import { reviewLifecycle } from "../prototypes/provider-routing/story-support";
const meta = {
title: "AI Connections/Provider routing/04 Manage",
component: Management,
parameters: { layout: "fullscreen" },
...reviewLifecycle,
decorators: [
(Story) => (
<ReviewFrame location="Connectors (/apps) → provider account → connection details, models, and access. Uses the existing provider cards and account rows.">
<Story />
</ReviewFrame>
),
],
render: (args) => <Management key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof Management>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ConnectionList: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("button", {
name: "Open My ChatGPT subscription permissions",
}),
).toBeVisible();
await expect(
canvas.queryByRole("heading", { name: "Custom provider or gateway" }),
).not.toBeInTheDocument();
},
};
export const WithAdvancedConnections: Story = {
args: { includeAdvancedConnections: true },
};
export const ConnectionDetails: Story = { args: { initialView: "details" } };
export const ModelsAndHelperDefaults: Story = {
args: { initialView: "details", initialTab: "models" },
};
export const Access: Story = {
args: { initialView: "details", initialTab: "access" },
};
export const ReadOnly: Story = {
args: { initialView: "details", readOnly: true },
};
export const MobileModels: Story = {
...ModelsAndHelperDefaults,
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const ReplaceCredential: Story = {
...ConnectionDetails,
play: async ({ canvasElement }) => {
await userEvent.click(
within(canvasElement).getByRole("button", { name: "Replace credential" }),
);
},
};
export const NewEndpoint: Story = {
...ConnectionDetails,
play: async ({ canvasElement }) => {
await userEvent.click(
within(canvasElement).getByRole("button", {
name: "Advanced connection settings",
}),
);
await userEvent.click(
within(canvasElement).getByRole("button", {
name: "Use a different endpoint",
}),
);
await expect(
within(canvasElement).getByRole("heading", {
name: "Connect Custom provider or gateway",
}),
).toBeVisible();
},
};
export const AddModelWalkthrough: Story = {
...ModelsAndHelperDefaults,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
canvas.getByLabelText("Model ID or alias"),
"team-reviewer",
);
await userEvent.click(canvas.getByRole("button", { name: "Add model" }));
await expect(
canvas.getByText("team-reviewer", { exact: true }),
).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Save changes" }));
await expect(canvas.getByRole("status")).toHaveTextContent(
"Connection settings saved",
);
},
};
export const RevokeCredential: Story = {
...Access,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Revoke credential" }),
);
const dialog = within(
await within(canvasElement.ownerDocument.body).findByRole("dialog"),
);
await userEvent.click(
dialog.getByRole("button", { name: "Revoke credential" }),
);
await expect(
canvas.getByText(/Credential revoked. Reconnect/),
).toBeVisible();
},
};
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { Recovery } from "../prototypes/provider-routing/Recovery";
import { ReviewFrame } from "../prototypes/provider-routing/shared";
import { reviewLifecycle } from "../prototypes/provider-routing/story-support";
const meta = {
title: "AI Connections/Provider routing/05 Recovery",
component: Recovery,
parameters: { layout: "fullscreen" },
...reviewLifecycle,
decorators: [
(Story) => (
<ReviewFrame location="Task thread → existing connection request card → inline Fix connection. Reuses ConnectionIntentInteractionBody and its completion/focus behavior; the gateway credential fields are proposed.">
<Story />
</ReviewFrame>
),
],
render: (args) => <Recovery key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof Recovery>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ExpiredCredential: Story = {};
export const MissingPersonalCredential: Story = {
args: { problem: "missing" },
};
export const ProtocolMismatch: Story = { args: { problem: "protocol" } };
export const ProviderQuota: Story = { args: { problem: "quota" } };
export const Mobile: Story = {
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const ReconnectWalkthrough: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Fix connection" }),
);
const dialog = within(
await canvas.findByRole("region", { name: "Gateway credential repair" }),
);
await userEvent.type(dialog.getByLabelText("API key"), "invalid");
await userEvent.click(dialog.getByRole("button", { name: "Connect" }));
await expect(dialog.getByRole("alert")).toHaveTextContent("rejected");
await expect(dialog.getByLabelText("API key")).toHaveValue("");
await userEvent.type(dialog.getByLabelText("API key"), "storybook-example");
await userEvent.click(dialog.getByRole("button", { name: "Connect" }));
await expect(
await canvas.findByText("Engineering gateway connected", { exact: true }),
).toBeVisible();
},
};
export const CancelRestoresFocus: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole("button", { name: "Fix connection" });
await userEvent.click(trigger);
const dialog = within(
await canvas.findByRole("region", { name: "Gateway credential repair" }),
);
await userEvent.type(
dialog.getByLabelText("API key"),
"discard-this-example",
);
await userEvent.click(dialog.getByRole("button", { name: "Cancel" }));
await expect(
canvas.getByTestId("connection-intent-focus-target"),
).toHaveFocus();
await expect(
canvas.queryByDisplayValue("discard-this-example"),
).not.toBeInTheDocument();
},
};
@@ -0,0 +1,153 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup";
import { AiConnectionSelect } from "@/components/ai-connections/AiConnectionSelect";
import type {
AiConnectionBinding,
AiManagedConnectionSummary,
} from "@paperclipai/shared";
import { expect, userEvent, within } from "storybook/test";
import { ReviewFrame } from "../prototypes/provider-routing/shared";
const companyId = "00000000-0000-4000-8000-000000000001";
const accounts: AiManagedConnectionSummary[] = [
{
id: "00000000-0000-4000-8000-000000000002",
grantId: "00000000-0000-4000-8000-000000000003",
companyId,
provider: "openai",
method: "subscription",
name: "My ChatGPT subscription",
ownership: "personal",
ownerUserId: "you",
isDefault: true,
status: "connected",
},
{
id: "00000000-0000-4000-8000-000000000004",
grantId: "00000000-0000-4000-8000-000000000005",
companyId,
provider: "openrouter",
method: "api_key",
name: "Company OpenRouter",
ownership: "shared",
isDefault: false,
status: "connected",
routing: {
kind: "openrouter",
protocol: "responses",
auth: "bearer",
models: [{ id: "openai/gpt-5.4" }],
},
},
{
id: "00000000-0000-4000-8000-000000000006",
grantId: "00000000-0000-4000-8000-000000000007",
companyId,
provider: "anthropic",
method: "subscription",
name: "My Claude subscription",
ownership: "personal",
ownerUserId: "you",
isDefault: true,
status: "connected",
},
];
function Providers() {
const [client] = useState(() => {
const query = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
query.setQueryData(
["agents", companyId, "provider-access"],
[{ id: "nova", name: "Nova" }],
);
return query;
});
return (
<QueryClientProvider client={client}>
<AiProviderSetup
companyId={companyId}
onCancel={() => {}}
onComplete={() => {}}
/>
</QueryClientProvider>
);
}
function Connection() {
const [value, setValue] = useState<AiConnectionBinding>({
provider: "openai",
method: "subscription",
mode: "responsible_user",
});
return (
<AiConnectionSelect
requirement={{ companyId, provider: "openai" }}
adapterType="codex_local"
connections={accounts}
value={value}
onChange={setValue}
onConnect={() => {}}
currentUserId="you"
agentId="nova"
agentName="Nova"
/>
);
}
const meta = {
title: "AI Connections/Provider routing/06 Production components",
parameters: { layout: "fullscreen" },
decorators: [
(Story) => (
<ReviewFrame location="Production components: Connectors → Connect a model provider; Agents → Harness / Runtime. Fixtures supply data only.">
<Story />
</ReviewFrame>
),
],
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const ChooseProvider: Story = {
render: () => <Providers />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("button", { name: "OpenAI" })).toBeVisible();
await expect(
canvas.getByRole("button", { name: "OpenRouter", hidden: true }),
).not.toBeVisible();
await userEvent.click(
canvas.getByText("Advanced providers", { exact: true }),
);
await expect(
canvas.getByRole("button", { name: "OpenRouter" }),
).toBeVisible();
},
};
export const CompatibleConnectionDropdown: Story = {
render: () => <Connection />,
play: async ({ canvasElement }) => {
await expect(
within(canvasElement).getByRole("combobox", { name: "Connection" }),
).toHaveTextContent("Responsible user’s default");
await userEvent.click(
within(canvasElement).getByRole("combobox", { name: "Connection" }),
);
const body = within(canvasElement.ownerDocument.body);
await expect(
body.getByRole("option", { name: "Responsible user’s default" }),
).toHaveAttribute("data-state", "checked");
await expect(
body.getByRole("option", { name: "Company OpenRouter" }),
).toBeVisible();
await expect(
body.queryByRole("option", { name: "My Claude subscription" }),
).not.toBeInTheDocument();
await userEvent.click(
body.getByRole("option", { name: "Company OpenRouter" }),
);
await expect(
within(canvasElement).getByRole("combobox", { name: "Connection" }),
).toHaveTextContent("Company OpenRouter");
},
};