Refine provider connector catalog and agent setup

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-02 21:56:08 -05:00
1 parent 110aee57f5
commit c66927fa41
44 files changed
+980 -264

No files matched your search

+17 -5
View File
@@ -518,12 +518,17 @@ 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.
Use the regular rows on **Connectors** to add OpenRouter, Amazon Bedrock,
Google Gemini, a Responses API, Messages API, Chat Completions API, or local
endpoint connection. Each row has its own Connect action and saved accounts.
The catalog tags these entries `model-provider`; no category UI is shown.
Responses-compatible gateways such as Emissary use the Responses API row.
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
URLs, protocol, AWS region, and credential fields appear only after choosing the
corresponding connector. New connections default to everyone in the organization
and all agents when the actor has permission; the existing Advanced disclosure
contains the controls to narrow access, without a separate Access step. New-agent setup also offers the connection picker under
**Use another connection**.
At **Agents → [agent] → Harness / Runtime**, **Connection** is a dropdown of
@@ -548,7 +553,14 @@ 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.
catalog discovery for custom gateways are not part of this implementation.
OpenRouter connections without an explicit model list automatically load its public
[model catalog](https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties),
ordered with `sort=most-popular`. New-agent setup and agent settings share this
discovery path, preserve the provider's ordering, and adapt model IDs to the selected
harness. Explicit connection model lists take precedence. Catalog discovery sends
no credentials; a failed request offers refresh and manual model entry.
`config.ai.routing` stores only kind, protocol, URL, auth method, region, and
optional model IDs/labels. The vault stores provider API keys, including Bedrock API keys.
@@ -131,3 +131,28 @@ export function aiRoutingModel(
const prefix = route.kind === "openrouter" ? "openrouter" : "paperclip";
return model.startsWith(`${prefix}/`) ? model : `${prefix}/${model}`;
}
/** Catalog identity is derived from routing, including accounts saved before these rows existed. */
export function aiConnectionCatalogSlug(provider: string, routing?: Pick<AiProviderRouting, "kind" | "protocol">): string {
if (routing?.kind === "gateway") {
return routing.protocol === "messages" ? "messages-api"
: routing.protocol === "chat" ? "chat-completions-api" : "responses-api";
}
return routing?.kind ?? provider;
}
/** Direct catalog setup presets; legacy gateway links still open their existing setup. */
export function aiProviderSetupPreset(source: string | null): {
provider: "openrouter" | "bedrock" | "gateway" | "local" | "google";
protocol?: AiProviderRouting["protocol"];
label?: string;
} | undefined {
switch (source) {
case "responses-api": return { provider: "gateway", protocol: "responses", label: "Responses API" };
case "messages-api": return { provider: "gateway", protocol: "messages", label: "Messages API" };
case "chat-completions-api": return { provider: "gateway", protocol: "chat", label: "Chat Completions API" };
case "google-ai": return { provider: "google" };
case "google": case "openrouter": case "bedrock": case "gateway": case "local": return { provider: source };
default: return undefined;
}
}
@@ -80,5 +80,11 @@ import a78 from "./app-definitions/google-workspace-search.json" with { type: "j
import a79 from "./app-definitions/openai.json" with { type: "json" };
import a80 from "./app-definitions/openrouter.json" with { type: "json" };
import a81 from "./app-definitions/xai.json" with { type: "json" };
import a82 from "./app-definitions/google.json" with { type: "json" };
import a83 from "./app-definitions/bedrock.json" with { type: "json" };
import a84 from "./app-definitions/responses-api.json" with { type: "json" };
import a85 from "./app-definitions/messages-api.json" with { type: "json" };
import a86 from "./app-definitions/chat-completions-api.json" with { type: "json" };
import a87 from "./app-definitions/local.json" with { type: "json" };
import type { AppDefinition } from "./types/app-definition.js";
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81] as AppDefinition[];
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87] as AppDefinition[];
+10 -1
View File
@@ -232,6 +232,15 @@ describe("AppDefinition catalog", () => {
it("validates all Wave 1 definitions", () =>
expect(() => appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow());
it("lists model providers as regular tagged catalog entries", () => {
const slugs = ["openai", "anthropic", "openrouter", "xai", "google", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"];
expect(APP_STORE_DEFINITIONS.filter(app => app.tags?.includes("model-provider")).map(app => app.slug).sort()).toEqual(slugs.sort());
for (const slug of slugs) {
const app = APP_STORE_DEFINITIONS.find(app => app.slug === slug)!;
expect(app.methods.some(method => method.purpose === "ai" && method.transport === "runtime_auth")).toBe(true);
}
});
it("contains every established provider plus the reviewed self-serve catalog", () => {
expect(APP_DEFINITIONS.map((app) => app.slug)).toEqual(
expect.arrayContaining([
@@ -728,7 +737,7 @@ describe("AppDefinition catalog", () => {
"ticktick",
"xero",
]);
expect(APP_STORE_DEFINITIONS).toHaveLength(59);
expect(APP_STORE_DEFINITIONS).toHaveLength(65);
const connectableSlugs = new Set(
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
);
+2 -1
View File
@@ -4,7 +4,8 @@ import type { AppDefinition, ConnectionMethodDef, FieldDef } from "./types/app-d
import type { ToolConnectionOwnership } from "./types/tool-access.js";
export const CONNECTABLE_APP_SLUGS = new Set([
"anthropic", "openai", "openrouter", "xai",
"anthropic", "openai", "openrouter", "xai", "google", "bedrock",
"responses-api", "messages-api", "chat-completions-api", "local",
"agentmail",
"browser-use-cloud",
"cognee",
@@ -71,5 +71,8 @@
"name": "ANTHROPIC_API_KEY"
}
}
],
"tags": [
"model-provider"
]
}
@@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"slug": "bedrock",
"name": "Amazon Bedrock",
"description": "Use Claude through Amazon Bedrock with a Bedrock API key and AWS region.",
"categories": [
"ai"
],
"branding": {
"logoUrl": "/brands/apps/bedrock.svg"
},
"urlPatterns": [],
"methods": [
{
"key": "ai-api_key",
"label": "Amazon Bedrock API key",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "api_key",
"ai": {
"provider": "anthropic",
"method": "api_key"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Use Claude through Amazon Bedrock with a Bedrock API key and AWS region.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "API key",
"type": "password",
"required": true,
"placeholder": "Enter API key",
"secret": true
}
],
"keyPlacement": {
"location": "env",
"name": "AWS_BEARER_TOKEN_BEDROCK"
}
}
],
"tags": [
"model-provider"
]
}
@@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"slug": "chat-completions-api",
"name": "Chat Completions API",
"description": "Connect any compatible harness to a Chat Completions-compatible provider or gateway.",
"categories": [
"ai"
],
"branding": {
"logoUrl": "/brands/apps/api-key-generic.svg"
},
"urlPatterns": [],
"methods": [
{
"key": "ai-api_key",
"label": "Chat Completions API API key",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "api_key",
"ai": {
"provider": "openai",
"method": "api_key"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Connect any compatible harness to a Chat Completions-compatible provider or gateway.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "API key",
"type": "password",
"required": true,
"placeholder": "Enter API key",
"secret": true
}
],
"keyPlacement": {
"location": "env",
"name": "OPENAI_API_KEY"
}
}
],
"tags": [
"model-provider"
]
}
@@ -0,0 +1,55 @@
{
"schemaVersion": 1,
"slug": "google",
"name": "Google Gemini",
"description": "Connect Google Gemini accounts for your agents.",
"categories": [
"ai"
],
"branding": {
"logoUrl": "/brands/apps/google.svg"
},
"urlPatterns": [
"https://generativelanguage.googleapis.com/*"
],
"methods": [
{
"key": "ai-api_key",
"label": "Google Gemini API key",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "api_key",
"ai": {
"provider": "google",
"method": "api_key"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Authenticate an agent with this account.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "API key",
"type": "password",
"required": true,
"placeholder": "Enter API key",
"secret": true
}
],
"keyPlacement": {
"location": "env",
"name": "GEMINI_API_KEY"
}
}
],
"tags": [
"model-provider"
]
}
@@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"slug": "local",
"name": "Local endpoint",
"description": "Use a local model server in the agent’s execution environment.",
"categories": [
"ai"
],
"branding": {
"logoUrl": "/brands/apps/api-key-generic.svg"
},
"urlPatterns": [],
"methods": [
{
"key": "ai-api_key",
"label": "Local endpoint API key",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "api_key",
"ai": {
"provider": "openai",
"method": "api_key"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Use a local model server in the agent’s execution environment.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "API key",
"type": "password",
"required": true,
"placeholder": "Enter API key",
"secret": true
}
],
"keyPlacement": {
"location": "env",
"name": "OPENAI_API_KEY"
}
}
],
"tags": [
"model-provider"
]
}
@@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"slug": "messages-api",
"name": "Messages API",
"description": "Connect any compatible harness to an Anthropic Messages-compatible provider or gateway.",
"categories": [
"ai"
],
"branding": {
"logoUrl": "/brands/apps/api-key-generic.svg"
},
"urlPatterns": [],
"methods": [
{
"key": "ai-api_key",
"label": "Messages API API key",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "api_key",
"ai": {
"provider": "anthropic",
"method": "api_key"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Connect any compatible harness to an Anthropic Messages-compatible provider or gateway.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "API key",
"type": "password",
"required": true,
"placeholder": "Enter API key",
"secret": true
}
],
"keyPlacement": {
"location": "env",
"name": "ANTHROPIC_API_KEY"
}
}
],
"tags": [
"model-provider"
]
}
@@ -70,5 +70,8 @@
"name": "OPENAI_API_KEY"
}
}
],
"tags": [
"model-provider"
]
}
@@ -49,5 +49,8 @@
"name": "OPENROUTER_API_KEY"
}
}
],
"tags": [
"model-provider"
]
}
@@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"slug": "responses-api",
"name": "Responses API",
"description": "Connect any compatible harness to an OpenAI Responses-compatible provider or gateway, including Emissary.",
"categories": [
"ai"
],
"branding": {
"logoUrl": "/brands/apps/api-key-generic.svg"
},
"urlPatterns": [],
"methods": [
{
"key": "ai-api_key",
"label": "Responses API API key",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "api_key",
"ai": {
"provider": "openai",
"method": "api_key"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Connect any compatible harness to an OpenAI Responses-compatible provider or gateway, including Emissary.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "API key",
"type": "password",
"required": true,
"placeholder": "Enter API key",
"secret": true
}
],
"keyPlacement": {
"location": "env",
"name": "OPENAI_API_KEY"
}
}
],
"tags": [
"model-provider"
]
}
@@ -70,5 +70,8 @@
"name": "XAI_API_KEY"
}
}
],
"tags": [
"model-provider"
]
}
+1 -1
View File
@@ -3,7 +3,7 @@ export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|
export type OAuthRedirectConstraints = "https-or-loopback-http";
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} }
export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; oauthClientSecretRequired?:boolean; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record<string,unknown>}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; tags?:string[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
export type SelfServeMcpAuthMode =
| "dcr"
@@ -7,5 +7,5 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{
},{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"});
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})});
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",mLine truncated
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),tags:z.array(z.string().min(1)).optional(),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});
+30 -7
View File
@@ -1714,13 +1714,36 @@ const inferState = (slug, state) => {
};
};
// Runtime credentials share the provider catalog, but never expose tool actions.
for (const [slug, name, subscription, envKey] of [["anthropic", "Claude", true, "ANTHROPIC_API_KEY"], ["openai", "OpenAI", true, "OPENAI_API_KEY"], ["openrouter", "OpenRouter", false, "OPENROUTER_API_KEY"], ["xai", "Grok", true, "XAI_API_KEY"]]) {
let app=apps.find(a=>a.slug===slug);
if(!app){app={schemaVersion:1,slug,name,description:`Connect ${name} accounts for your agents.`,categories:["ai"],branding:brandingFor(slug),urlPatterns:[{"openai":"https://api.openai.com/*","openrouter":"https://openrouter.ai/api/*","xai":"https://api.x.ai/*"}[slug]],methods:[]};apps.push(app);}
const methods=(subscription?["subscription","api_key"]:["api_key"]).map(authMethod=>({key:`ai-${authMethod}`,label:authMethod==="subscription"?`${name} subscription`:`${name} API key`,purpose:"ai",transport:"runtime_auth",auth:authMethod==="subscription"?"oauth":"api_key",ai:{provider:slug,method:authMethod},grantKinds:["user","organization"],ownershipModes:["customer"],whenToUse:"Authenticate an agent with this account.",guidanceMd:"Use your personal account or an explicitly shared company account.",riskTier:"S3",...(authMethod==="api_key"?{credentialFields:[field("apiKey","API key","Enter API key")],keyPlacement:{location:"env",name:envKey}}:{})}));
// Legacy REST entries have no tool execution adapter. Only offer the supported
// AI account flow; saved REST connections remain removable through Connections.
app.methods = [...methods, ...app.methods.filter(method => method.transport !== "rest_api")];
const aiCatalogEntries = [
{ slug: "anthropic", name: "Claude", provider: "anthropic", subscription: true, envKey: "ANTHROPIC_API_KEY" },
{ slug: "openai", name: "OpenAI", provider: "openai", subscription: true, envKey: "OPENAI_API_KEY", url: "https://api.openai.com/*" },
{ slug: "openrouter", name: "OpenRouter", provider: "openrouter", envKey: "OPENROUTER_API_KEY", url: "https://openrouter.ai/api/*" },
{ slug: "xai", name: "Grok", provider: "xai", subscription: true, envKey: "XAI_API_KEY", url: "https://api.x.ai/*" },
{ slug: "google", name: "Google Gemini", provider: "google", envKey: "GEMINI_API_KEY", url: "https://generativelanguage.googleapis.com/*" },
{ slug: "bedrock", name: "Amazon Bedrock", provider: "anthropic", envKey: "AWS_BEARER_TOKEN_BEDROCK", description: "Use Claude through Amazon Bedrock with a Bedrock API key and AWS region." },
{ slug: "responses-api", name: "Responses API", provider: "openai", envKey: "OPENAI_API_KEY", description: "Connect any compatible harness to an OpenAI Responses-compatible provider or gateway, including Emissary." },
{ slug: "messages-api", name: "Messages API", provider: "anthropic", envKey: "ANTHROPIC_API_KEY", description: "Connect any compatible harness to an Anthropic Messages-compatible provider or gateway." },
{ slug: "chat-completions-api", name: "Chat Completions API", provider: "openai", envKey: "OPENAI_API_KEY", description: "Connect any compatible harness to a Chat Completions-compatible provider or gateway." },
{ slug: "local", name: "Local endpoint", provider: "openai", envKey: "OPENAI_API_KEY", description: "Use a local model server in the agent’s execution environment." },
];
for (const { slug, name, provider, subscription, envKey, url, description } of aiCatalogEntries) {
let app = apps.find(a => a.slug === slug);
if (!app) {
app = { schemaVersion: 1, slug, name, description: description ?? `Connect ${name} accounts for your agents.`, categories: ["ai"], branding: brandingFor(slug), urlPatterns: url ? [url] : [], methods: [] };
apps.push(app);
}
app.tags = [...new Set([...(app.tags ?? []), "model-provider"])];
const methods = (subscription ? ["subscription", "api_key"] : ["api_key"]).map(authMethod => ({
key: `ai-${authMethod}`, label: authMethod === "subscription" ? `${name} subscription` : `${name} API key`,
purpose: "ai", transport: "runtime_auth", auth: authMethod === "subscription" ? "oauth" : "api_key",
ai: { provider, method: authMethod }, grantKinds: ["user", "organization"], ownershipModes: ["customer"],
whenToUse: description ?? "Authenticate an agent with this account.",
guidanceMd: "Use your personal account or an explicitly shared company account.", riskTier: "S3",
...(authMethod === "api_key" ? { credentialFields: [field("apiKey", "API key", "Enter API key")], keyPlacement: { location: "env", name: envKey } } : {}),
}));
// Legacy REST entries have no tool execution adapter. Only offer the supported
// AI account flow; saved REST connections remain removable through Connections.
app.methods = [...methods, ...app.methods.filter(method => method.transport !== "rest_api")];
}
// Every tool method has a checked-in permission review. Discovery metadata is
// evidence for reviewers, never a runtime instruction to request more scopes.
+4 -1
View File
@@ -8,7 +8,7 @@ import { mkdtemp, rm, access, readFile, writeFile, stat } from "node:fs/promises
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, principalPermissionGrants } from "@paperclipai/db";
import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, toolApplications, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, principalPermissionGrants } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { aiConnectionService } from "../services/ai-connections.js";
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
@@ -219,6 +219,9 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
const saved = await service.save(companyId, "alice", { ...input, agentIds: [...input.agentIds] }, "old-gateway-credential");
const [stored] = await db.select().from(toolConnections).where(eq(toolConnections.id, saved.connectionId));
expect(stored!.config.ai).toMatchObject({ routing });
expect(stored!.config.sourceTemplateKey).toBe("responses-api");
const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, stored!.applicationId));
expect(application).toMatchObject({ applicationKey: "app-gallery:responses-api", metadata: { sourceTemplateKey: "responses-api" } });
// PostgreSQL JSONB reorders object keys; compare values, not serialization.
const reordered = { models: [...routing.models], auth: routing.auth, baseUrl: routing.baseUrl, protocol: routing.protocol, kind: routing.kind };
expect(await service.save(companyId, "alice", { ...input, agentIds: [], allAgents: true, connectionId: saved.connectionId, routing: reordered }, "new-gateway-credential")).toEqual(saved);
+4 -2
View File
@@ -20,6 +20,8 @@ import {
} from "@paperclipai/db";
import {
AI_CONNECTION_CAPABILITIES,
aiConnectionCatalogSlug,
getAppStoreDefinition,
aiConnectionMetadataSchema,
aiSubscriptionNeedsIsolatedLogin,
isAiConnectionCompatible,
@@ -624,8 +626,8 @@ export function aiConnectionService(db: Db) {
if (targets.length !== new Set(input.agentIds).size)
throw forbidden("Agent does not belong to this company");
}
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 source = aiConnectionCatalogSlug(input.provider, routing);
const providerName = getAppStoreDefinition(source)?.name ?? AI_CONNECTION_CAPABILITIES[input.provider].name;
const key = `app-gallery:${source}`;
await tx
.insert(toolApplications)
@@ -2,16 +2,20 @@ import { afterEach, expect, it, vi } from "vitest";
afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); });
it("lists and caches public OpenRouter models without sending credentials", async () => {
it("lists and caches public OpenRouter models in popularity order without sending credentials", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [
{ id: "z-ai/glm-5", name: "Z.AI GLM" },
{ id: "anthropic/claude-sonnet-4.5", name: "Claude Sonnet" }, { id: 42 },
] }) });
vi.stubGlobal("fetch", fetch);
const { listOpenRouterModels } = await import("./openrouter-models.js");
expect(await listOpenRouterModels()).toEqual([{ id: "openrouter/anthropic/claude-sonnet-4.5", label: "Claude Sonnet" }]);
expect(await listOpenRouterModels()).toEqual([
{ id: "openrouter/z-ai/glm-5", label: "Z.AI GLM" },
{ id: "openrouter/anthropic/claude-sonnet-4.5", label: "Claude Sonnet" },
]);
await listOpenRouterModels();
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith("https://openrouter.ai/api/v1/models", { signal: expect.any(AbortSignal) });
expect(fetch).toHaveBeenCalledWith("https://openrouter.ai/api/v1/models?sort=most-popular", { signal: expect.any(AbortSignal) });
await listOpenRouterModels(true);
expect(fetch).toHaveBeenCalledTimes(2);
});
+2 -2
View File
@@ -8,13 +8,13 @@ export async function listOpenRouterModels(refresh = false): Promise<AdapterMode
if (!refresh && cached && cached.until > Date.now()) return cached.models;
if (pending) return pending;
pending = (async () => {
const response = await fetch("https://openrouter.ai/api/v1/models", { signal: AbortSignal.timeout(10_000) });
const response = await fetch("https://openrouter.ai/api/v1/models?sort=most-popular", { signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error("Could not load OpenRouter models. Retry or enter a model ID manually.");
const body = await response.json() as { data?: Array<{ id?: unknown; name?: unknown }> };
if (!Array.isArray(body.data)) throw new Error("OpenRouter returned an invalid model catalog.");
const models = body.data.flatMap(model => typeof model.id === "string" && model.id.includes("/")
? [{ id: `openrouter/${model.id}`, label: typeof model.name === "string" ? model.name : model.id }]
: []).sort((a, b) => a.label.localeCompare(b.label));
: []);
cached = { until: Date.now() + 60_000, models };
return models;
})();
+26 -2
View File
@@ -35,7 +35,7 @@
{
"slug": "bedrock",
"provider": "Amazon Bedrock",
"catalogVisible": false,
"catalogVisible": true,
"localAsset": "/brands/apps/bedrock.svg"
},
{
@@ -163,7 +163,7 @@
{
"slug": "google",
"provider": "Google",
"catalogVisible": false,
"catalogVisible": true,
"localAsset": "/brands/apps/google.svg"
},
{
@@ -548,6 +548,30 @@
"localAsset": "/brands/apps/browser-use.svg",
"sourceUrl": "https://browser-use.com/logo-primary.svg",
"retrievedAt": "2026-09-29"
},
{
"slug": "chat-completions-api",
"provider": "Chat Completions API",
"catalogVisible": true,
"localAsset": "/brands/apps/api-key-generic.svg"
},
{
"slug": "local",
"provider": "Local endpoint",
"catalogVisible": true,
"localAsset": "/brands/apps/api-key-generic.svg"
},
{
"slug": "messages-api",
"provider": "Messages API",
"catalogVisible": true,
"localAsset": "/brands/apps/api-key-generic.svg"
},
{
"slug": "responses-api",
"provider": "Responses API",
"catalogVisible": true,
"localAsset": "/brands/apps/api-key-generic.svg"
}
]
}
+18 -12
View File
@@ -897,6 +897,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const modelProvider = adapterType === "opencode_local" && aiConnectionBindingSchema.safeParse(
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection,
).data?.provider === "openrouter" ? "openrouter" : runnerProvider;
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));
// Fetch adapter models for the effective provider, including unsaved changes.
const modelQueryKey = selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, modelProvider)
@@ -904,18 +905,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const {
data: fetchedModels,
error: fetchedModelsError,
isLoading: fetchingModels,
} = useQuery({
queryKey: modelQueryKey,
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, {
environmentId: currentDefaultEnvironmentId || null,
provider: modelProvider,
}),
enabled: Boolean(selectedCompanyId),
enabled: Boolean(selectedCompanyId) && !connectionModels,
});
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
const [refreshingModels, setRefreshingModels] = useState(false);
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 modelError = connectionModels ? connectionModels.error : fetchedModelsError;
const adapterCommandField = "command";
const {
data: detectedModelData,
@@ -1724,6 +1726,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{isLocal && (<>
<ModelDropdown
models={models}
loadingModels={connectionModels?.isLoading ?? fetchingModels}
value={currentModelId}
onChange={(v) => {
const supportedEfforts = setupEfforts(adapterType, v);
@@ -1748,35 +1751,36 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
defaultLabel={adapterType === "claude_local" ? `Default (${DEFAULT_CLAUDE_LOCAL_MODEL})` : undefined}
allowDefault={adapterType !== "opencode_local" && adapterType !== "pi_local" && adapterType !== "paperclip_runner"}
required={adapterType === "opencode_local" || adapterType === "pi_local"}
groupByProvider={adapterType === "opencode_local" || adapterType === "pi_local"}
preserveOrder={adapterCuratesModelOrder(adapterType)}
groupByProvider={!connectionModels && (adapterType === "opencode_local" || adapterType === "pi_local")}
preserveOrder={Boolean(connectionModels) || adapterCuratesModelOrder(adapterType)}
creatable
detectedModel={detectedModel}
detectedModel={connectionModels ? undefined : detectedModel}
detectedModelCandidates={[]}
onDetectModel={adapterType === "opencode_local" || adapterType === "paperclip_runner"
onDetectModel={connectionModels || adapterType === "opencode_local" || adapterType === "paperclip_runner"
? undefined
: async () => {
const result = await refetchDetectedModel();
return result.data?.model ?? null;
}}
onRefreshModels={
supportsAdapterModelRefresh(adapterType)
connectionModels ? connectionModels.refreshModels : supportsAdapterModelRefresh(adapterType)
? handleRefreshModels
: undefined
}
refreshingModels={refreshingModels}
refreshingModels={connectionModels?.refreshing ?? refreshingModels}
detectModelLabel="Detect model"
emptyDetectHint="No model detected. Select or enter one manually."
/>
{(refreshModelsError || fetchedModelsError) && (
{(refreshModelsError || modelError) && (
<p className="text-xs text-destructive">
{refreshModelsError
?? (fetchedModelsError instanceof Error
? fetchedModelsError.message
?? (modelError instanceof Error
? modelError.message
: "Failed to load adapter models.")}
</p>
)}
{adapterType === "opencode_local"
&& !connectionModels
&& currentDefaultEnvironment
&& currentDefaultEnvironment.driver !== "local" && (
<p className="text-xs text-muted-foreground">
@@ -3747,6 +3751,7 @@ export function ModelDropdown({
onDetectModel,
onRefreshModels,
refreshingModels,
loadingModels,
detectModelLabel,
emptyDetectHint,
defaultLabel,
@@ -3767,6 +3772,7 @@ export function ModelDropdown({
onDetectModel?: () => Promise<string | null>;
onRefreshModels?: () => Promise<void>;
refreshingModels?: boolean;
loadingModels?: boolean;
detectModelLabel?: string;
emptyDetectHint?: string;
defaultLabel?: string;
@@ -4042,7 +4048,7 @@ export function ModelDropdown({
{filteredModels.length === 0 && !canCreateManualModel && promotedModelIds.size === 0 && (
<div className="px-2 py-2 space-y-2">
<p className="text-xs text-muted-foreground">
{onDetectModel
{loadingModels ? "Loading models…" : onDetectModel
? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.")
: "No models found."}
</p>
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { type AiProvider, type AiAuthMethod, type AiConnectionLoginIntent } from "@paperclipai/shared";
import { AgentProviderConnection } from "@/components/new-agent/AgentProviderConnection";
@@ -26,6 +26,8 @@ type Props = {
agentIds: string[];
allAgents: boolean;
environmentId?: string;
defaults?: ReactNode;
disabled?: boolean;
onComplete: (result: { connectionId: string; grantId: string; method: AiAuthMethod }) => void;
onCancel: () => void;
};
@@ -36,7 +38,7 @@ export function AiConnectionCredentialStep(props: Props) {
return <SubscriptionConnectionStep {...props} />;
}
function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedMethod, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, environmentId: suppliedEnvironmentId, onComplete, onCancel }: Props) {
function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedMethod, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, environmentId: suppliedEnvironmentId, onComplete, onCancel, defaults, disabled }: Props) {
const [name, setName] = useState(initialName);
const [chosenEnvironment, setChosenEnvironment] = useState<string>();
const client = useQueryClient();
@@ -82,6 +84,7 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
<SelectContent>{loginEnvironments.map((env) => <SelectItem key={env.id} value={env.id}>{env.name}</SelectItem>)}</SelectContent>
</Select>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
{defaults}
{loading ? <p role="status" className="text-sm text-muted-foreground">Preparing sign-in…</p> : <AgentProviderConnection
key={environmentId ?? "local"}
companyId={companyId}
@@ -92,24 +95,25 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
onBack={onCancel}
onConnected={() => {}}
testConnection={async () => false}
managedAccount={{ intent, nameForMethod, initialMethod, fixedMethod: fixedMethod ?? Boolean(connectionId), disabled: loading || Boolean(error) || !name.trim(), onComplete: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); onComplete(result); } }}
managedAccount={{ intent, nameForMethod, initialMethod, fixedMethod: fixedMethod ?? Boolean(connectionId), disabled: disabled || loading || Boolean(error) || !name.trim(), onComplete: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); void client.invalidateQueries({ queryKey: ["tools"] }); onComplete(result); } }}
/>}
</div>;
}
function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, onComplete, onCancel }: Props) {
function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, onComplete, onCancel, defaults, disabled }: Props) {
const [name, setName] = useState(initialName);
const [apiKey, setApiKey] = useState("");
const client = useQueryClient();
const save = useMutation({
mutationFn: () => aiConnectionsApi.create(companyId, { provider, method: "api_key", name: connectionId ? name : nameForMethod?.("api_key") ?? name, ownership, agentIds, allAgents, connectionId, apiKey }),
onSuccess: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); onComplete({ ...result, method: "api_key" }); },
onSuccess: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); void client.invalidateQueries({ queryKey: ["tools"] }); onComplete({ ...result, method: "api_key" }); },
onSettled: () => setApiKey(""),
});
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={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>
<ProviderApiKeyCard providerName={provider === "google" ? "Google" : "OpenRouter"} value={apiKey} onChange={setApiKey} onSubmit={() => save.mutate()} disabled={disabled || save.isPending} placeholder="Enter API key here" autoFocus />
{defaults}
<div className="flex justify-between gap-2"><Button variant="ghost" onClick={onCancel}>Cancel</Button><Button disabled={disabled || !name.trim() || !apiKey.trim() || save.isPending} onClick={() => save.mutate()}>{save.isPending ? "Connecting…" : "Connect"}</Button></div>
</div>;
}
@@ -1,5 +1,7 @@
import { useId } from "react";
import { Plus, UserRound } from "lucide-react";
import {
aiConnectionCatalogSlug,
isAiConnectionCompatible,
type AiConnectionBinding,
} from "@paperclipai/shared";
@@ -14,6 +16,16 @@ import { AppLogo } from "@/pages/apps/AppLogo";
import { Button } from "@/components/ui/button";
import { aiConnectionProblem, personalAiDefault } from "./model";
import type { AiConnectionPickerProps } from "./AiConnectionPicker";
import type { AiConnectionSummary } from "./model";
function ConnectionLabel({ connection, label }: { connection: AiConnectionSummary; label?: string }) {
return (
<span className="flex min-w-0 items-center gap-2">
<AppLogo name={connection.name} brandKey={aiConnectionCatalogSlug(connection.provider, connection.routing)} size={20} compact />
<span className="truncate">{label ?? connection.name}</span>
</span>
);
}
/** The same connection control on Create agent and Harness / Runtime. */
export function AiConnectionSelect({
@@ -110,16 +122,17 @@ export function AiConnectionSelect({
loading ? "Loading connections…" : "Choose a connection"
}
>
{value?.mode === "responsible_user"
? "Responsible user’s default"
: (selected?.name ??
(value ? "Unavailable connection" : undefined))}
{selected
? <ConnectionLabel connection={selected} label={value?.mode === "responsible_user" ? "Responsible user’s default" : undefined} />
: value?.mode === "responsible_user"
? "Responsible user’s default"
: value ? "Unavailable connection" : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{incompatible && selected && (
<SelectItem value={selected.id} disabled>
{selected.name} · Incompatible
<ConnectionLabel connection={selected} label={`${selected.name} · Incompatible`} />
</SelectItem>
)}
{compatible.map((c) => (
@@ -128,25 +141,19 @@ export function AiConnectionSelect({
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>
<ConnectionLabel connection={c} label={`${c.name}${c.status !== "connected" ? " · Reconnect" : ""}`} />
</SelectItem>
))}
{(canUseDefault || value?.mode === "responsible_user") && (
<SelectItem value="responsible_user" disabled={!canUseDefault}>
<span className="flex size-5 shrink-0 items-center justify-center"><UserRound className="size-4" /></span>
Responsible user’s default
</SelectItem>
)}
<SelectItem value="connect">Connect an account…</SelectItem>
<SelectItem value="connect">
<span className="flex size-5 shrink-0 items-center justify-center"><Plus className="size-4" /></span>
Connect an account…
</SelectItem>
</SelectContent>
</Select>
{!readOnly && onReconnect && !loading && !error && (
@@ -4,10 +4,13 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { expect, it, vi } from "vitest";
import { AiProviderSetup } from "./AiProviderSetup";
import { aiConnectionsApi } from "@/api/ai-connections";
import { TooltipProvider } from "@/components/ui/tooltip";
import { aiProviderSetupPreset } from "@paperclipai/shared";
import type { AiConnectionCredentialStep } from "./AiConnectionCredentialStep";
let credentialProps: ComponentProps<typeof AiConnectionCredentialStep> | undefined;
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: { list: vi.fn(async () => ({ currentUserId: "owner", connections: [], canManageConnections: true })) } }));
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: { create: vi.fn(async () => ({ connectionId: "connection", grantId: "grant" })), list: vi.fn(async () => ({ currentUserId: "owner", connections: [], canManageConnections: true })) } }));
vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(async () => []) } }));
vi.mock("./AiConnectionCredentialStep", () => ({ AiConnectionCredentialStep: (props: ComponentProps<typeof AiConnectionCredentialStep>) => { credentialProps = props; return <div>Existing credential flow</div>; } }));
@@ -28,3 +31,53 @@ it("reconnects an older OpenRouter account without adding routing metadata", asy
client.clear();
}
});
const providerSources = ["openrouter", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"];
it.each(providerSources)("opens %s directly with shared access folded under Advanced", async source => {
const preset = aiProviderSetupPreset(source)!;
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(["ai-connections", "company", undefined], { canManageConnections: true, connections: [] });
client.setQueryData(["agents", "company", "provider-access"], [{ id: "agent", name: "Nova" }]);
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const onCancel = vi.fn();
try {
await act(async () => root.render(<QueryClientProvider client={client}><TooltipProvider><AiProviderSetup companyId="company" initialProvider={preset.provider} initialProtocol={preset.protocol} providerLabel={preset.label} onCancel={onCancel} onComplete={() => {}} /></TooltipProvider></QueryClientProvider>));
expect(container.textContent).not.toContain("Connect a model provider");
expect(container.textContent).toContain("Connects for everyone in your organization, available to all agents.");
expect(container.querySelector('[role="radio"]')).toBeNull();
const change = Array.from(container.querySelectorAll('button')).find(b => b.textContent === "Change")!;
expect(change.getAttribute("aria-expanded")).toBe("false");
await act(async () => change.click());
const radio = (text: string) => Array.from(container.querySelectorAll('[role="radio"]')).find(b => b.textContent?.includes(text)) as HTMLButtonElement;
expect(radio("Any human in the organization").getAttribute("aria-checked")).toBe("true");
expect(radio("Any agent").getAttribute("aria-checked")).toBe("true");
await act(async () => radio("Just me").click());
expect(container.textContent).toContain("Connects as you, available to all agents.");
await act(async () => Array.from(container.querySelectorAll('button')).find(b => b.textContent === "Back")!.click());
expect(onCancel).toHaveBeenCalledOnce();
} finally {
await act(async () => root.unmount()); container.remove(); client.clear();
}
});
it("saves the default organization grant and all-agent installation from the direct connect screen", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(["ai-connections", "company", undefined], { canManageConnections: true, connections: [] });
client.setQueryData(["agents", "company", "provider-access"], []);
const container = document.createElement("div");
const root = createRoot(container);
try {
await act(async () => root.render(<QueryClientProvider client={client}><AiProviderSetup companyId="company" initialProvider="openrouter" onCancel={() => {}} onComplete={() => {}} /></QueryClientProvider>));
const input = container.querySelector('input[aria-label="API key"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-key");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => container.querySelector('form')!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
expect(aiConnectionsApi.create).toHaveBeenCalledWith("company", expect.objectContaining({ ownership: "shared", allAgents: true, agentIds: [], apiKey: "fixture-key", routing: expect.objectContaining({ kind: "openrouter" }) }));
} finally {
await act(async () => root.unmount()); client.clear();
}
});
@@ -12,7 +12,7 @@ import {
import { aiConnectionsApi } from "@/api/ai-connections";
import { agentsApi } from "@/api/agents";
import { ConnectionChoiceList } from "@/features/connections/ConnectionChoiceList";
import { AccessStepContent } from "@/features/connections/ConnectionSetupFlow";
import { ConnectionAccessDefaults, connectionDefaultSummarySentence } from "@/features/connections/ConnectionSetupFlow";
import { AppLogo } from "@/pages/apps/AppLogo";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -70,6 +70,8 @@ export function AiProviderSetup({
agentId,
environmentId,
initialProvider,
initialProtocol,
providerLabel,
reconnect,
onCancel,
onComplete,
@@ -78,6 +80,8 @@ export function AiProviderSetup({
agentId?: string;
environmentId?: string;
initialProvider?: ProviderChoice;
initialProtocol?: AiProviderRouting["protocol"];
providerLabel?: string;
reconnect?: AiManagedConnectionSummary;
onCancel: () => void;
onComplete: (
@@ -94,15 +98,13 @@ export function AiProviderSetup({
: initialProvider,
);
const [step, setStep] = useState(
reconnect ? "connect" : initialProvider ? "access" : "provider",
);
const [ownership, setOwnership] = useState<"personal" | "shared">(
reconnect?.ownership ?? "personal",
reconnect || initialProvider ? "connect" : "provider",
);
const [ownershipChoice, setOwnership] = useState<"personal" | "shared">();
const [allAgentsChoice, setAllAgents] = useState<boolean>();
const [agentIds, setAgentIds] = useState(new Set(agentId ? [agentId] : []));
const [protocol, setProtocol] = useState<AiProviderRouting["protocol"]>(
reconnect?.routing?.protocol ?? "responses",
reconnect?.routing?.protocol ?? initialProtocol ?? "responses",
);
const [auth, setAuth] = useState<AiProviderRouting["auth"]>(
reconnect?.routing?.auth ?? "bearer",
@@ -121,12 +123,13 @@ export function AiProviderSetup({
queryFn: () => aiConnectionsApi.list(companyId, agentId),
});
const canManageConnections = accounts.data?.canManageConnections ?? false;
const ownership = reconnect?.ownership ?? ownershipChoice ?? (!agentId && canManageConnections ? "shared" : "personal");
const allAgents = allAgentsChoice ?? (!agentId && canManageConnections);
const agents = useQuery({
queryKey: ["agents", companyId, "provider-access"],
queryFn: () => agentsApi.list(companyId),
});
const label = providers.find((p) => p.id === provider)?.name ?? "provider";
const label = providerLabel ?? providers.find((p) => p.id === provider)?.name ?? "provider";
const advanced = reconnect ? Boolean(reconnect.routing) : ["openrouter", "bedrock", "gateway", "local"].includes(provider ?? "");
const nativeProvider: AiProvider =
provider === "bedrock"
@@ -223,13 +226,50 @@ export function AiProviderSetup({
}))}
onSelect={(id) => {
setProvider(id as ProviderChoice);
setStep("access");
setStep("connect");
setApiKey("");
setAuth("bearer");
save.reset();
}}
/>
);
const modelSettings = (
<label className="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>
);
const accessDefaults = !reconnect ? (
<ConnectionAccessDefaults
companyId={companyId}
extra={advanced ? modelSettings : undefined}
agents={agents.data ?? []}
sentence={connectionDefaultSummarySentence({ grantKind: ownership === "shared" ? "organization" : "user", authKind: auth === "none" ? "none" : "api_key", installChoice: allAgents ? "all" : "specific", installCount: agentIds.size })}
capabilities={{ canCreateOrganizationGrant: canManageConnections, canSetCompanyInstall: canManageConnections }}
authKind={auth === "none" ? "none" : "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}
disabled={save.isPending}
notice={!canManageConnections ? ["Only a connection manager can share this credential with everyone or give every agent access."] : undefined}
/>
) : undefined;
const cancel = () => reconnect || initialProvider ? onCancel() : setStep("provider");
if (!reconnect && accounts.isPending) return <p role="status">Loading connection permissions…</p>;
if (!reconnect && accounts.isError) return <p role="alert">Could not load connection permissions. <Button type="button" variant="ghost" onClick={() => void accounts.refetch()}>Retry</Button></p>;
return (
<div className="mx-auto w-full max-w-2xl space-y-6" onSubmit={(event) => event.stopPropagation()}>
<div className="space-y-2">
@@ -238,12 +278,8 @@ export function AiProviderSetup({
? "Connect a model provider"
: `${reconnect ? "Reconnect" : "Connect"} ${label}`}
</h2>
{step !== "provider" && (
<p className="text-xs text-muted-foreground">
{step === "access" ? "Access" : "Connect"}
</p>
)}
</div>
{agents.isError && <p role="alert" className="text-sm text-destructive">Could not load agents. <Button type="button" variant="ghost" onClick={() => void agents.refetch()}>Retry</Button></p>}
{step === "provider" ? (
<>
{choices(false)}
@@ -257,34 +293,6 @@ export function AiProviderSetup({
Cancel
</Button>
</>
) : step === "access" ? (
<>
{accounts.error && <p role="alert" className="text-sm text-destructive">Could not load connection permissions. <Button type="button" variant="ghost" onClick={() => void accounts.refetch()}>Retry</Button></p>}
{agents.error && (
<p role="alert" className="text-sm text-destructive">
Could not load agents. Try again.
</p>
)}
<AccessStepContent
agents={agents.data ?? []}
pending={accounts.isPending || Boolean(accounts.error)}
capabilities={{ canCreateOrganizationGrant: canManageConnections, canSetCompanyInstall: canManageConnections }}
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}
@@ -298,7 +306,9 @@ export function AiProviderSetup({
allAgents={allAgents}
agentIds={[...agentIds]}
environmentId={environmentId}
onCancel={() => (reconnect ? onCancel() : setStep("access"))}
onCancel={cancel}
defaults={accessDefaults}
disabled={!reconnect && !allAgents && agentIds.size === 0}
onComplete={complete}
/>
) : (
@@ -334,9 +344,9 @@ export function AiProviderSetup({
if (v !== "messages" && auth === "api_key")
setAuth("bearer");
}}
disabled={Boolean(reconnect)}
disabled={Boolean(reconnect) || Boolean(initialProtocol)}
>
<SelectTrigger aria-label="API format">
<SelectTrigger aria-label="API format" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -373,7 +383,7 @@ export function AiProviderSetup({
onValueChange={(v) => setAuth(v as typeof auth)}
disabled={Boolean(reconnect)}
>
<SelectTrigger aria-label="Authentication">
<SelectTrigger aria-label="Authentication" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -410,24 +420,7 @@ export function AiProviderSetup({
</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>
{reconnect && <details><summary className="cursor-pointer text-sm text-muted-foreground">Model settings</summary>{modelSettings}</details>}
<p className="text-xs text-muted-foreground">
Test the connection with your chosen model in the agent’s execution
environment after connecting.
@@ -439,11 +432,12 @@ export function AiProviderSetup({
: save.error.message}
</p>
)}
{accessDefaults}
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="ghost"
onClick={() => (reconnect ? onCancel() : setStep("access"))}
onClick={cancel}
disabled={save.isPending}
>
Back
@@ -451,7 +445,8 @@ export function AiProviderSetup({
<Button
type="submit"
disabled={
save.isPending ||
save.isPending || accounts.isPending || accounts.isError ||
(!reconnect && !allAgents && agentIds.size === 0) ||
(auth !== "none" && !apiKey.trim())
}
>
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { AiConnectionBinding, AiManagedConnectionSummary } from "@paperclipai/shared";
import { aiConnectionsApi } from "@/api/ai-connections";
import { agentsApi } from "@/api/agents";
import { useConnectionModels } from "./useConnectionModels";
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: { list: vi.fn() } }));
vi.mock("@/api/agents", () => ({ agentsApi: { adapterModels: vi.fn() } }));
const binding: AiConnectionBinding = { provider: "openrouter", method: "api_key", mode: "shared", connectionId: "router", grantId: "grant" };
const account: AiManagedConnectionSummary = {
id: "router", grantId: "grant", companyId: "company", provider: "openrouter", method: "api_key",
name: "Company OpenRouter", ownership: "shared", isDefault: false, status: "connected",
routing: { kind: "openrouter", protocol: "chat", auth: "bearer", models: [] },
};
const catalog = [
{ id: "openrouter/z-ai/glm-5", label: "Z.AI GLM" },
{ id: "openrouter/anthropic/claude-sonnet-4.5", label: "Claude Sonnet" },
{ id: "openrouter/openrouter/auto", label: "Auto Router" },
];
let root: Root;
let client: QueryClient;
let result: ReturnType<typeof useConnectionModels>;
let render: (value: AiConnectionBinding | undefined) => Promise<void>;
beforeEach(() => {
vi.mocked(agentsApi.adapterModels).mockResolvedValue(catalog);
vi.mocked(aiConnectionsApi.list).mockResolvedValue({ currentUserId: "you", connections: [account], canManageConnections: true });
});
afterEach(async () => {
await act(async () => root?.unmount());
client?.clear();
vi.resetAllMocks();
});
async function mount(accounts: AiManagedConnectionSummary[] = [account], harness = "opencode_local", value: AiConnectionBinding | undefined = binding) {
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(["ai-connections", "company"], { currentUserId: "you", connections: accounts });
root = createRoot(document.createElement("div"));
function Probe({ value }: { value: AiConnectionBinding | undefined }) {
result = useConnectionModels("company", value, harness);
return null;
}
render = async next => { await act(async () => root.render(<QueryClientProvider client={client}><Probe value={next} /></QueryClientProvider>)); };
await render(value);
}
it.each(["opencode_local", "codex_local", "claude_local", "hermes_local"])("automatically discovers an empty OpenRouter connection's models for %s", async harness => {
await mount([account], harness);
await vi.waitFor(() => expect(result?.models).toHaveLength(3));
expect(agentsApi.adapterModels).toHaveBeenCalledWith("company", "opencode_local", { provider: "openrouter" });
expect(result?.models.map(m => m.id)).toEqual(catalog.map(m => harness === "opencode_local" ? m.id : m.id.slice("openrouter/".length)));
expect(result?.models.map(m => m.label)).toEqual(catalog.map(m => m.label));
expect(result?.resolveModel("anthropic/custom-model")).toBe(harness === "opencode_local" ? "openrouter/anthropic/custom-model" : "anthropic/custom-model");
});
it("also discovers models for legacy OpenRouter connections without routing metadata", async () => {
await mount([{ ...account, routing: undefined }]);
await vi.waitFor(() => expect(result?.models).toEqual(catalog));
});
it("preserves an explicit connection model list instead of replacing it with the public catalog", async () => {
await mount([{ ...account, routing: { ...account.routing!, models: [{ id: "company/model", label: "Approved model" }] } }]);
expect(result?.models).toEqual([{ id: "openrouter/company/model", label: "Approved model" }]);
expect(agentsApi.adapterModels).not.toHaveBeenCalled();
});
it("does not reuse public OpenRouter models after switching to a custom gateway", async () => {
const gateway: AiManagedConnectionSummary = { ...account, id: "gateway", grantId: "gateway-grant", provider: "openai", routing: { kind: "gateway", protocol: "chat", auth: "bearer", baseUrl: "https://example.com/v1", models: [] } };
await mount([account, gateway]);
await vi.waitFor(() => expect(result?.models).toEqual(catalog));
await render({ provider: "openai", method: "api_key", mode: "shared", connectionId: gateway.id, grantId: gateway.grantId });
expect(result?.models).toEqual([]);
expect(result?.resolveModel("private-model")).toBe("paperclip/private-model");
expect(result?.refreshModels).toBeUndefined();
});
it("leaves native model discovery alone", async () => {
await mount([], "codex_local", { provider: "openai", method: "subscription", mode: "responsible_user" });
expect(result).toBeUndefined();
expect(agentsApi.adapterModels).not.toHaveBeenCalled();
});
it("only uses the current person's default connection models", async () => {
await mount([
{ ...account, ownership: "personal", ownerUserId: "someone-else", isDefault: true, routing: { ...account.routing!, models: [{ id: "other/private-model" }] } },
{ ...account, ownership: "personal", ownerUserId: "you", isDefault: true },
], "opencode_local", { provider: "openrouter", method: "api_key", mode: "responsible_user" });
await vi.waitFor(() => expect(result?.models).toEqual(catalog));
});
it("exposes loading and allows retry after a catalog failure", async () => {
let reject!: (error: Error) => void;
vi.mocked(agentsApi.adapterModels).mockReturnValueOnce(new Promise((_, rejectPromise) => { reject = rejectPromise; }));
await mount();
expect(result?.isLoading).toBe(true);
await act(async () => reject(new Error("Catalog unavailable")));
await vi.waitFor(() => expect(result?.error?.message).toBe("Catalog unavailable"));
expect(result?.isLoading).toBe(false);
expect(result?.resolveModel("manual/model")).toBe("openrouter/manual/model");
await act(async () => { await result?.refreshModels?.(); });
await vi.waitFor(() => expect(result?.models).toEqual(catalog));
expect(result?.error).toBeNull();
});
@@ -1,6 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { aiRoutingModel, type AiConnectionBinding } from "@paperclipai/shared";
import { aiRoutingModel, type AiConnectionBinding, type AiProviderRouting } from "@paperclipai/shared";
import { aiConnectionsApi } from "@/api/ai-connections";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
export function useConnectionModels(
companyId: string | null | undefined,
@@ -14,18 +16,40 @@ export function useConnectionModels(
});
const connection = accounts.data?.connections.find((c) =>
binding?.mode === "responsible_user"
? c.provider === binding.provider && c.isDefault
? c.provider === binding.provider && c.isDefault && c.ownerUserId === accounts.data?.currentUserId && c.ownership === "personal"
: c.id === binding?.connectionId && c.grantId === binding?.grantId,
);
const routing = connection?.routing;
return routing
const openRouter = routing?.kind === "openrouter" || (!routing && binding?.provider === "openrouter");
const discover = openRouter && !routing?.models.length;
// Reuse the existing public catalog and its query cache. It returns OpenCode IDs;
// strip only that transport prefix for other harnesses (openrouter/auto is
// itself a valid upstream model ID).
const catalog = useQuery({
queryKey: queryKeys.agents.adapterModels(companyId ?? "none", "opencode_local", null, "openrouter"),
queryFn: () => agentsApi.adapterModels(companyId!, "opencode_local", { provider: "openrouter" }),
enabled: Boolean(companyId && discover),
staleTime: 60_000,
retry: false,
});
const effectiveRouting: AiProviderRouting | undefined = routing ?? (openRouter
? { kind: "openrouter", protocol: "chat", auth: "bearer", models: [] }
: undefined);
const modelOptions = discover
? (catalog.data ?? []).map((m) => ({ ...m, id: harness === "opencode_local" ? m.id : m.id.replace(/^openrouter\//, "") }))
: routing?.models ?? [];
return effectiveRouting
? {
models: routing.models.map((m) => ({
id: aiRoutingModel(routing, harness, m.id),
models: modelOptions.map((m) => ({
id: aiRoutingModel(effectiveRouting, harness, m.id),
label: m.label ?? m.id,
})),
isLoading: discover && catalog.isLoading,
error: discover ? catalog.error : null,
refreshing: discover && catalog.isFetching,
refreshModels: discover ? async () => { await catalog.refetch(); } : undefined,
resolveModel: (model: string) =>
aiRoutingModel(routing, harness, model),
aiRoutingModel(effectiveRouting, harness, model),
}
: undefined;
}
+7 -30
View File
@@ -220,7 +220,7 @@ function Setup({
const models = useQuery({
queryKey: queryKeys.agents.adapterModels(companyId, brandType, null, aiBinding?.provider),
queryFn: () => agentsApi.adapterModels(companyId, brandType, { provider: aiBinding?.provider }),
enabled: Boolean(brandType) && showModel,
enabled: Boolean(brandType) && showModel && !connectionModels,
retry: false,
});
const companySecrets = useQuery({
@@ -721,26 +721,6 @@ function Setup({
center
/>
</div>
<div className="mb-5">
<Field label="Environment">
<select
aria-label="Environment"
className={controlClass}
value={environmentOverride}
disabled={busy || forced.forced}
onChange={(event) => {
setEnvironmentOverride(event.target.value);
setConnection(null);
resetTest();
}}
>
<option value="">Default: {environmentLabel}</option>
{(envs.data ?? []).filter((env) => env.status === "active" && (!managedOnly || env.driver !== "local")).map((env) => (
<option key={env.id} value={env.id}>{environmentDisplayLabel(env)}</option>
))}
</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>
@@ -843,13 +823,16 @@ function Setup({
<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>}
{(connectionModels ? connectionModels.error : models.error) && <p role="alert" className="text-sm text-destructive">Could not load models. Retry or enter a model ID manually.</p>}
{((showModel && !usingKimiApi) ||
efforts.length > 0) && (
<div className="grid items-start gap-5 sm:grid-cols-2">
{showModel && !usingKimiApi && (
<ModelDropdown
models={connectionModels?.models ?? models.data ?? []}
loadingModels={connectionModels?.isLoading ?? models.isLoading}
onRefreshModels={connectionModels?.refreshModels}
refreshingModels={connectionModels?.refreshing}
value={model}
onChange={(value) => {
setModel(connectionModels?.resolveModel(value) ?? value);
@@ -877,8 +860,8 @@ function Setup({
allowDefault={!multiProvider}
required={multiProvider}
creatable
groupByProvider={multiProvider}
preserveOrder={adapterCuratesModelOrder(brandType)}
groupByProvider={multiProvider && !connectionModels}
preserveOrder={Boolean(connectionModels) || adapterCuratesModelOrder(brandType)}
/>
)}
{efforts.length > 0 && (
@@ -908,12 +891,6 @@ function Setup({
{SETUP_LOGIN_HINTS[adapterType]}
</p>
)}
{showModel && models.error && (
<p className="text-xs text-muted-foreground">
Couldn’t load models. You can enter a model ID
manually.
</p>
)}
{hasCredentialField && !aiBinding && (
<div className="grid gap-5 sm:grid-cols-2">
{chooseProvider && (
@@ -2207,6 +2207,7 @@ function StandardConnectionSetupFlow({
const aiMethod = reconnectAiMethod ?? entry?.methods.find(method => method.key === connectionMethodKey)?.ai
?? (!connectionMethodKey && entry?.methods.every(method => method.ai) ? entry.methods[0]?.ai : undefined);
const credentialStep = entry ? renderCredentialStep?.({ app: entry, name: galleryName || entry.name, grantKind: effectiveGrantKind, agentIds: [...installAgentIds], allAgents: installChoice === "all", onBack: () => backToGallery() }) ?? (aiMethod && selectedCompanyId ? <><AiConnectionCredentialStep
defaults={reconnectConnection ? undefined : connectionDefaults}
companyId={selectedCompanyId} provider={aiMethod.provider} fixedMethod={Boolean(aiConnection && aiConnection.mode !== "responsible_user")} initialMethod={reconnectConnection?.connectionPurpose === "ai" ? (reconnectConnection.config?.ai as { method: "subscription" | "api_key" }).method : aiMethod.method}
connectionId={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.id : undefined}
name={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.name : galleryName || `My ${entry.name} ${aiMethod.method === "subscription" ? "subscription" : "API"}`}
+15 -3
View File
@@ -2,7 +2,7 @@ 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 { aiProviderSetupPreset, type ToolConnectionCredentialSource } from "@paperclipai/shared";
import { useCompany } from "@/context/CompanyContext";
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
import { consumeSkillSourceReturn, skillSourceReturnPath } from "@/lib/skill-source-connect-return";
@@ -20,7 +20,8 @@ export function AppsConnect({ byoOnly = false, credentialSource = "paperclip_vau
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 preset = aiProviderSetupPreset(source);
const aiReconnectRequested = Boolean(reconnectId && (preset || ["openai", "anthropic", "xai"].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);
@@ -31,7 +32,18 @@ export function AppsConnect({ byoOnly = false, credentialSource = "paperclip_vau
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`)} />;
if (selectedCompanyId && (preset || aiReconnect?.routing)) return (
<AiProviderSetup
key={reconnectId ?? source}
companyId={selectedCompanyId}
reconnect={aiReconnect}
initialProvider={preset?.provider}
initialProtocol={preset?.protocol}
providerLabel={preset?.label}
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} />;
+23
View File
@@ -194,6 +194,29 @@ describe("Connectors landing page", () => {
return client;
}
it("shows provider catalog rows and splits legacy gateway accounts by API format", async () => {
const slugs = ["notion", "openrouter", "bedrock", "google", "responses-api", "messages-api", "chat-completions-api", "local"];
listGalleryMock.mockResolvedValue({ apps: slugs.map(slug => getAppStoreDefinition(slug)) });
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Model gateway", applicationKey: "app-gallery:gateway", metadata: { sourceTemplateKey: "gateway" } })] });
listConnectionsMock.mockResolvedValue({ connections: ["responses", "messages"].map(protocol => connection({
id: protocol, name: `${protocol} account`, applicationId: "gateway-app", connectionPurpose: "ai",
config: { sourceTemplateKey: "gateway", ai: { provider: protocol === "messages" ? "anthropic" : "openai", method: "api_key", routing: { kind: "gateway", protocol, auth: "bearer", baseUrl: "https://models.example.com/v1", models: [] } } },
})) });
await renderBrowse();
expect(container.textContent).not.toContain("Connect a model provider");
expect(container.textContent).not.toContain("Model gateway");
for (const slug of slugs) expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull();
const responses = container.querySelector('[data-app-slug="responses-api"]')!;
const messages = container.querySelector('[data-app-slug="messages-api"]')!;
expect(responses.textContent).toContain("responses account");
expect(responses.textContent).not.toContain("messages account");
expect(messages.textContent).toContain("messages account");
const bedrock = container.querySelector('[data-app-slug="bedrock"]')!;
const connect = Array.from(bedrock.querySelectorAll('button')).find(button => button.textContent?.includes("Connect"))!;
await act(() => connect.click());
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=bedrock");
});
it("shows retirement guidance before paused state for an obsolete Composio account", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "old-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "old-app", enabled: false, healthStatus: "error", transport: "rest_api", config: { sourceTemplateKey: "composio", connectionMethodKey: "api-key" } })] });
+18 -5
View File
@@ -262,11 +262,11 @@ function connectorAction(
};
}
if (chatHref) return { label: "Connect", href: chatHref };
// PAP-659 C4: the card's verb comes from the same four-state resolver the
// connect screen uses, so "Connect" never turns out to mean "paste a key".
// AI accounts share the Connect action across subscription and key methods.
// Tool connectors retain their capability-specific setup verbs.
if (row.entry) {
return {
label: connectionSetupVerbForApp(row.entry),
label: row.entry.methods?.some(method => method.purpose === "ai") ? "Connect" : connectionSetupVerbForApp(row.entry),
href: connectHrefFor(row.entry),
};
}
@@ -505,7 +505,8 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
const applicationSlug = appApplicationSourceSlug(application);
const savedAppConnections =
connectionsByApplicationId.get(application.id) ?? [];
const appConnections = savedAppConnections.filter(
if (applicationSlug === "gateway" && savedAppConnections.length === 0) continue;
let appConnections = savedAppConnections.filter(
(connection) => !GOOGLE_CONNECTOR_SLUGS.has(appConnectionSourceSlug(connection) ?? ""),
);
// Hide source-only Google rows, but keep independently identified connectors.
@@ -514,6 +515,19 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
savedAppConnections.length > 0 &&
appConnections.length === 0
) continue;
// One legacy gateway application may contain several API formats. Group
// each saved AI account by its actual routing, not the old application slug.
const ungroupedCount = appConnections.length;
appConnections = appConnections.filter((connection) => {
if (connection.connectionPurpose !== "ai") return true;
const slug = appConnectionSourceSlug(connection);
const row = slug ? rowsBySlug.get(slug) : undefined;
if (!row) return true;
if (!row.applications.some((item) => item.id === application.id)) row.applications.push(application);
row.connections.push(connection);
return false;
});
if (ungroupedCount > 0 && appConnections.length === 0) continue;
const configuredConnectionSlug = appConnections
.map(
(connection) =>
@@ -679,7 +693,6 @@ 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 ? (
@@ -32,6 +32,10 @@ describe("app connect policy", () => {
expect(canEnterAppsConnect(new URLSearchParams("source=context7"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=zapier"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=unknown"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=model-provider"))).toBe(false);
for (const source of ["responses-api", "messages-api", "chat-completions-api", "bedrock", "local", "google", "openrouter"]) {
expect(canEnterAppsConnect(new URLSearchParams({ source })), source).toBe(true);
}
expect(canEnterAppsConnect(new URLSearchParams("byo=1"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("byo=1&source=zapier"))).toBe(false);
});
+1 -1
View File
@@ -67,7 +67,7 @@ export function canEnterAppsConnect(
);
}
const source = searchParams.get("source") ?? "";
if (["model-provider", "google-ai", "google", "bedrock", "gateway", "local"].includes(source)) return true;
if (["google-ai", "gateway"].includes(source)) return true;
const entry = getAppStoreDefinition(source);
if (
!chatConnectorsEnabled &&
+5 -1
View File
@@ -1,4 +1,4 @@
import type { AppDefinition, ToolApplication, ToolConnection } from "@paperclipai/shared";
import { aiConnectionCatalogSlug, aiConnectionMetadataSchema, type AppDefinition, type ToolApplication, type ToolConnection } from "@paperclipai/shared";
export type AppGalleryDisplayEntry = AppDefinition & {
key?: string;
@@ -47,6 +47,10 @@ export function appApplicationSourceSlug(application: ToolApplication | null | u
export function appConnectionSourceSlug(connection: ToolConnection | null | undefined): string | null {
if (!connection) return null;
if (connection.connectionPurpose === "ai") {
const metadata = aiConnectionMetadataSchema.safeParse(connection.config?.ai);
if (metadata.success) return aiConnectionCatalogSlug(metadata.data.provider, metadata.data.routing);
}
const source = connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey;
return typeof source === "string" && source.trim() ? source.trim() : null;
}
@@ -1,8 +1,12 @@
import { useState, type ComponentProps } from "react";
import {
getAppStoreDefinition,
APP_STORE_DEFINITIONS,
aiProviderSetupPreset,
type ToolConnection,
} from "@paperclipai/shared";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup";
import { appDefinitionLogoUrl } from "@/pages/apps/app-definition-display";
import { ConnectorCard } from "@/pages/apps/Browse";
import { Input } from "@/components/ui/input";
@@ -194,3 +198,32 @@ export function ConnectionCatalog({
</div>
);
}
/** Regular catalog rows and direct setup, using the production components. */
export function ProviderCatalogReview() {
const [source, setSource] = useState<string>();
const [query, setQuery] = useState("");
const [client] = useState(() => {
const value = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
value.setQueryData(["ai-connections", "company-storybook", undefined], { canManageConnections: true, connections: [] });
value.setQueryData(["agents", "company-storybook", "provider-access"], [{ id: "nova", name: "Nova" }]);
return value;
});
const preset = source ? aiProviderSetupPreset(source) : undefined;
if (source) return <QueryClientProvider client={client}><AiProviderSetup
companyId="company-storybook" initialProvider={preset?.provider ?? source as "openai" | "anthropic" | "xai"}
initialProtocol={preset?.protocol} providerLabel={preset?.label}
onCancel={() => setSource(undefined)} onComplete={() => setSource(undefined)}
/></QueryClientProvider>;
return <div className="space-y-5">
<Input aria-label="Search connectors" placeholder="Search connectors…" value={query} onChange={event => setQuery(event.target.value)} />
<div role="list" aria-label="Connector list" className="space-y-3">
{APP_STORE_DEFINITIONS.filter(entry => entry.tags?.includes("model-provider") && `${entry.name} ${entry.description}`.toLowerCase().includes(query.toLowerCase())).sort((a, b) => a.name.localeCompare(b.name)).map(entry => <ConnectorCard
key={entry.slug}
row={{ key: entry.slug, slug: entry.slug, name: entry.name, description: entry.description, brandKey: entry.slug, entry, logoUrl: entry.branding.logoUrl, darkLogoUrl: entry.branding.darkLogoUrl, applications: [], connections: [], chatEndpoints: [] }}
userProfileById={new Map()} chatConnectorsEnabled={false}
onNavigate={() => setSource(entry.slug)} onRequestRemove={() => {}}
/>)}
</div>
</div>;
}
@@ -1,10 +1,9 @@
import { useState } from "react";
import { KeyRound, Network } from "lucide-react";
import { Network } from "lucide-react";
import { ConnectionChoiceList } from "@/features/connections/ConnectionChoiceList";
import { StepHeader } from "@/features/connections/ConnectionSetupFlow";
import { StepHeader, ConnectionAccessDefaults, connectionDefaultSummarySentence } 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,
@@ -46,7 +45,7 @@ export function ConnectionSetup({
onComplete,
onCancel,
}: SetupProps) {
const [step, setStep] = useState<string>(initialStep);
const [step, setStep] = useState<string>(initialStep === "access" ? "connect" : initialStep);
const [provider, setProvider] = useState(initialProvider);
const [method, setMethod] = useState(initialMethod);
const [protocol, setProtocol] = useState<Protocol>(initialProtocol);
@@ -59,7 +58,7 @@ export function ConnectionSetup({
);
const [project, setProject] = useState("");
const [header, setHeader] = useState("X-API-Key");
const [ownership, setOwnership] = useState<"personal" | "shared">("personal");
const [ownership, setOwnership] = useState<"personal" | "shared">("shared");
const [allAgents, setAllAgents] = useState(true);
const [agentIds, setAgentIds] = useState(new Set(["nova"]));
const [error, setError] = useState(initialError);
@@ -167,7 +166,7 @@ export function ConnectionSetup({
setEndpoint("");
setMethod("key");
setError(undefined);
setStep("access");
setStep("connect");
}}
/>
);
@@ -228,24 +227,12 @@ export function ConnectionSetup({
<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."
subtitle="Connect now — permissions and access are yours to change afterwards."
step="key"
activeIndex={step === "access" ? 0 : 1}
labels={["Access", "Connect"]}
activeIndex={0}
labels={["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"
@@ -406,21 +393,6 @@ export function ConnectionSetup({
/>
</>
)}
{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"
@@ -428,21 +400,26 @@ export function ConnectionSetup({
: "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>
<ConnectionAccessDefaults
companyId="company-storybook"
agents={[{ id: "nova", name: "Nova" }, { id: "atlas", name: "Atlas" }]}
sentence={connectionDefaultSummarySentence({ grantKind: ownership === "shared" ? "organization" : "user", authKind: method === "none" ? "none" : "api_key", installChoice: allAgents ? "all" : "specific", installCount: agentIds.size })}
authKind={method === "none" ? "none" : "api_key"}
grantKinds={["user", "organization"]}
grantKind={ownership === "shared" ? "organization" : "user"}
setGrantKind={kind => setOwnership(kind === "organization" ? "shared" : "personal")}
installChoice={allAgents ? "all" : "specific"}
setInstallChoice={choice => setAllAgents(choice === "all")}
installAgentIds={agentIds}
setInstallAgentIds={setAgentIds}
/>
<Footer
onBack={() => setStep("access")}
onBack={cancel}
next={method === "subscription" ? "Sign in" : "Connect"}
onNext={connect}
disabled={!canConnect}
/>
</>
)}
</>
</div>
);
}
@@ -35,7 +35,7 @@ const existing = [
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.",
"Regular provider connector rows; each opens setup with shared permissions folded under Advanced.",
"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.",
@@ -105,7 +105,7 @@ export function ReviewIndex() {
</li>
<li>
First onboarding keeps its usual sign-in path. The optional provider
link is inside Advanced; additional setup uses Access → Connect.
link is inside Advanced; the catalog opens each provider directly. Permissions stay under Advanced.
</li>
<li>
Changing a connection preserves the harness and asks for a
@@ -75,7 +75,6 @@ export const OptionalProviderWalkthrough: Story = {
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(
@@ -86,7 +85,7 @@ export const OptionalProviderWalkthrough: Story = {
).toHaveTextContent("Codex");
await expect(
canvas.getByRole("combobox", { name: "Connection" }),
).toHaveTextContent("My OpenRouter");
).toHaveTextContent("Company OpenRouter");
await expect(
canvas.getByRole("button", { name: "Advanced model settings" }),
).toHaveAttribute("aria-expanded", "false");
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { ProviderCatalogReview } from "../prototypes/provider-routing/ConnectionCatalog";
import { ConnectionSetup } from "../prototypes/provider-routing/ConnectionSetup";
import { ReviewFrame } from "../prototypes/provider-routing/shared";
import {
@@ -13,7 +14,7 @@ const meta = {
...reviewLifecycle,
decorators: [
(Story) => (
<ReviewFrame location="Apps → AI connections → Add connection. The same setup is opened from onboarding, agent settings, and task requests.">
<ReviewFrame location="Apps → Connectors → Connect on a provider row. Permissions default to everyone and all agents under Advanced.">
<Story />
</ReviewFrame>
),
@@ -23,35 +24,23 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const ChooseProvider: Story = {
name: "Connector catalog",
render: () => <ProviderCatalogReview />,
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" }),
);
await expect(canvas.queryByText("Connect a model provider")).not.toBeInTheDocument();
for (const slug of ["openrouter", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"]) {
const row = canvasElement.querySelector(`[data-app-slug="${slug}"]`)! as HTMLElement;
await expect(within(row).getByRole("button", { name: /^Connect / })).toBeVisible();
}
},
};
export const AdvancedProviders: Story = { args: { initialAdvanced: true } };
export const Access: Story = { args: { initialStep: "access" } };
export const Access: Story = { name: "Permissions defaults", 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 userEvent.click(canvas.getByRole("button", { name: "Change" }));
await expect(
canvas.getByRole("radio", { name: "Any human in the organization" }),
).toBeChecked();
@@ -116,7 +105,7 @@ export const CustomEndpointWalkthrough: Story = {
"https://models.example.com/v1",
);
await userEvent.type(canvas.getByLabelText("API key"), "storybook-example");
await userEvent.click(canvas.getByRole("button", { name: "Connect" }));
await userEvent.click(canvas.getByRole("button", { name: /^Connect / }));
await expect(
canvas.getByRole("heading", { name: "Connection ready" }),
).toBeVisible();
@@ -3,11 +3,15 @@ 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 { useConnectionModels } from "@/components/ai-connections/useConnectionModels";
import { ModelDropdown } from "@/components/AgentConfigForm";
import { queryKeys } from "@/lib/queryKeys";
import type {
AiConnectionBinding,
AiManagedConnectionSummary,
} from "@paperclipai/shared";
import { expect, userEvent, within } from "storybook/test";
import { ProviderCatalogReview } from "../prototypes/provider-routing/ConnectionCatalog";
import { ReviewFrame } from "../prototypes/provider-routing/shared";
const companyId = "00000000-0000-4000-8000-000000000001";
@@ -71,6 +75,7 @@ function Providers({ canManageConnections = true }: { canManageConnections?: boo
<AiProviderSetup
companyId={companyId}
agentId="nova"
initialProvider="openrouter"
onCancel={() => {}}
onComplete={() => {}}
/>
@@ -97,12 +102,39 @@ function Connection() {
/>
);
}
function OpenRouterModelPicker() {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const connection = useConnectionModels(companyId, {
provider: "openrouter", method: "api_key", mode: "shared",
connectionId: accounts[1].id, grantId: accounts[1].grantId,
}, "opencode_local");
return <ModelDropdown models={connection?.models ?? []} value={value} onChange={setValue}
open={open} onOpenChange={setOpen} allowDefault={false} required groupByProvider={false}
preserveOrder creatable loadingModels={connection?.isLoading} />;
}
function OpenRouterModels() {
const [client] = useState(() => {
const query = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
query.setQueryData(["ai-connections", companyId], {
currentUserId: "you",
connections: [{ ...accounts[1], routing: { ...accounts[1].routing!, models: [] } }],
});
query.setQueryData(queryKeys.agents.adapterModels(companyId, "opencode_local", null, "openrouter"), [
{ id: "openrouter/z-ai/glm-5", label: "Z.AI GLM 5" },
{ id: "openrouter/anthropic/claude-sonnet-4.5", label: "Claude Sonnet 4.5" },
{ id: "openrouter/openai/gpt-5.4", label: "OpenAI GPT-5.4" },
]);
return query;
});
return <QueryClientProvider client={client}><OpenRouterModelPicker /></QueryClientProvider>;
}
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.">
<ReviewFrame location="Production components: Connectors → provider row → Connect; Agents → Harness / Runtime. Fixtures supply data only.">
<Story />
</ReviewFrame>
),
@@ -110,20 +142,34 @@ const meta = {
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const ChooseProvider: Story = {
render: () => <Providers />,
export const OpenRouterPopularModels: Story = {
name: "OpenRouter popular models",
parameters: { docs: { description: { story: "Production model picker and connection discovery hook. The connection has no custom model list; the public catalog fixture supplies models in popularity order." } } },
render: () => <OpenRouterModels />,
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();
await userEvent.click(canvas.getByRole("button", { name: "Select model (required)" }));
const body = within(canvasElement.ownerDocument.body);
const labels = Array.from(canvasElement.ownerDocument.querySelectorAll('span[title^="openrouter/"]')).map(element => element.textContent);
await expect(labels).toEqual(["Z.AI GLM 5", "Claude Sonnet 4.5", "OpenAI GPT-5.4"]);
await userEvent.type(body.getByPlaceholderText("Search models... (type to create)"), "claude");
await userEvent.click(body.getByRole("button", { name: "Claude Sonnet 4.5" }));
await expect(canvas.getByRole("button", { name: "Claude Sonnet 4.5" })).toBeVisible();
},
};
export const ChooseProvider: Story = {
name: "Connector catalog",
render: () => <ProviderCatalogReview />,
play: async ({ canvasElement }) => {
const row = canvasElement.querySelector('[data-app-slug="responses-api"]')! as HTMLElement;
await userEvent.click(within(row).getByRole("button", { name: "Connect Responses API" }));
const canvas = within(canvasElement);
await expect(canvas.getByRole("heading", { name: "Connect Responses API" })).toBeVisible();
await expect(canvas.getByText("Connects for everyone in your organization, available to all agents.")).toBeVisible();
await expect(canvas.queryByRole("radio")).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Change" }));
await expect(canvas.getByRole("radio", { name: "Any human in the organization" })).toBeChecked();
await expect(canvas.getByRole("radio", { name: "Any agent" })).toBeChecked();
},
};
export const CompatibleConnectionDropdown: Story = {
@@ -158,8 +204,8 @@ export const MemberProviderAccess: Story = {
render: () => <Providers canManageConnections={false} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "OpenAI" }));
await userEvent.click(canvas.getByRole("button", { name: "Change" }));
await expect(canvas.getByText("Which agents can use this connection?", { exact: true })).toBeVisible();
await expect(canvas.getByRole("button", { name: /^Continue$/ })).toBeEnabled();
await expect(canvas.getByRole("radio", { name: "Any human in the organization" })).toBeDisabled();
},
};