mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Add connection-owned model provider routing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6eaf218924
commit
304af99040
83 files changed
+5796
-359
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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=""
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user