diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index e86eeb75091..51b1b389c4a 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -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. diff --git a/packages/shared/src/ai-provider-routing.ts b/packages/shared/src/ai-provider-routing.ts index 65973b0bfb4..79bdc28fbdb 100644 --- a/packages/shared/src/ai-provider-routing.ts +++ b/packages/shared/src/ai-provider-routing.ts @@ -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): 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; + } +} diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index d85f7b9b369..341682b259c 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -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[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index aa038c32f61..608b5319c33 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -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), ); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 83410b49bff..322efe5bb84 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -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", diff --git a/packages/shared/src/app-definitions/anthropic.json b/packages/shared/src/app-definitions/anthropic.json index bd16bb3444e..52eb85f3600 100644 --- a/packages/shared/src/app-definitions/anthropic.json +++ b/packages/shared/src/app-definitions/anthropic.json @@ -71,5 +71,8 @@ "name": "ANTHROPIC_API_KEY" } } + ], + "tags": [ + "model-provider" ] } diff --git a/packages/shared/src/app-definitions/bedrock.json b/packages/shared/src/app-definitions/bedrock.json new file mode 100644 index 00000000000..962d8a722ac --- /dev/null +++ b/packages/shared/src/app-definitions/bedrock.json @@ -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" + ] +} diff --git a/packages/shared/src/app-definitions/chat-completions-api.json b/packages/shared/src/app-definitions/chat-completions-api.json new file mode 100644 index 00000000000..021e0df41be --- /dev/null +++ b/packages/shared/src/app-definitions/chat-completions-api.json @@ -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" + ] +} diff --git a/packages/shared/src/app-definitions/google.json b/packages/shared/src/app-definitions/google.json new file mode 100644 index 00000000000..794282359d2 --- /dev/null +++ b/packages/shared/src/app-definitions/google.json @@ -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" + ] +} diff --git a/packages/shared/src/app-definitions/local.json b/packages/shared/src/app-definitions/local.json new file mode 100644 index 00000000000..c9cd6dab363 --- /dev/null +++ b/packages/shared/src/app-definitions/local.json @@ -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" + ] +} diff --git a/packages/shared/src/app-definitions/messages-api.json b/packages/shared/src/app-definitions/messages-api.json new file mode 100644 index 00000000000..c5c8d855ee5 --- /dev/null +++ b/packages/shared/src/app-definitions/messages-api.json @@ -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" + ] +} diff --git a/packages/shared/src/app-definitions/openai.json b/packages/shared/src/app-definitions/openai.json index e3ef286564a..14708e9bacf 100644 --- a/packages/shared/src/app-definitions/openai.json +++ b/packages/shared/src/app-definitions/openai.json @@ -70,5 +70,8 @@ "name": "OPENAI_API_KEY" } } + ], + "tags": [ + "model-provider" ] } diff --git a/packages/shared/src/app-definitions/openrouter.json b/packages/shared/src/app-definitions/openrouter.json index 6f95ff8a8bc..d4d1be16fce 100644 --- a/packages/shared/src/app-definitions/openrouter.json +++ b/packages/shared/src/app-definitions/openrouter.json @@ -49,5 +49,8 @@ "name": "OPENROUTER_API_KEY" } } + ], + "tags": [ + "model-provider" ] } diff --git a/packages/shared/src/app-definitions/responses-api.json b/packages/shared/src/app-definitions/responses-api.json new file mode 100644 index 00000000000..29ad92e4c41 --- /dev/null +++ b/packages/shared/src/app-definitions/responses-api.json @@ -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" + ] +} diff --git a/packages/shared/src/app-definitions/xai.json b/packages/shared/src/app-definitions/xai.json index ee1f729db50..6b60b021112 100644 --- a/packages/shared/src/app-definitions/xai.json +++ b/packages/shared/src/app-definitions/xai.json @@ -70,5 +70,8 @@ "name": "XAI_API_KEY" } } + ], + "tags": [ + "model-provider" ] } diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts index de6cc3d98fe..316378d2a91 100644 --- a/packages/shared/src/types/app-definition.ts +++ b/packages/shared/src/types/app-definition.ts @@ -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}; 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> } +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> } export type SelfServeMcpAuthMode = | "dcr" diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts index d55b839e1f8..d0dc20d6b91 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -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",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); -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();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})}); diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 928085347b3..bddb2e0f494 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -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. diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 320d28d5829..e150f107753 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -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); diff --git a/server/src/services/ai-connections.ts b/server/src/services/ai-connections.ts index 68487354ccb..07001766727 100644 --- a/server/src/services/ai-connections.ts +++ b/server/src/services/ai-connections.ts @@ -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) diff --git a/server/src/services/openrouter-models.test.ts b/server/src/services/openrouter-models.test.ts index a4335bd2230..cb47ee32236 100644 --- a/server/src/services/openrouter-models.test.ts +++ b/server/src/services/openrouter-models.test.ts @@ -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); }); diff --git a/server/src/services/openrouter-models.ts b/server/src/services/openrouter-models.ts index a0c9414caff..0e1ae87be8c 100644 --- a/server/src/services/openrouter-models.ts +++ b/server/src/services/openrouter-models.ts @@ -8,13 +8,13 @@ export async function listOpenRouterModels(refresh = false): Promise 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; })(); diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index ba9beab8a46..cbb149dab32 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -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" } ] } diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index a8322ed5741..4e2d116e3e1 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -897,6 +897,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const modelProvider = adapterType === "opencode_local" && aiConnectionBindingSchema.safeParse( (overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? runtimeConfig.aiConnection, ).data?.provider === "openrouter" ? "openrouter" : runnerProvider; + const connectionModels = useConnectionModels(selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record | 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(null); const [refreshingModels, setRefreshingModels] = useState(false); - const connectionModels = useConnectionModels(selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record | 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 && (<> { 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) && (

{refreshModelsError - ?? (fetchedModelsError instanceof Error - ? fetchedModelsError.message + ?? (modelError instanceof Error + ? modelError.message : "Failed to load adapter models.")}

)} {adapterType === "opencode_local" + && !connectionModels && currentDefaultEnvironment && currentDefaultEnvironment.driver !== "local" && (

@@ -3747,6 +3751,7 @@ export function ModelDropdown({ onDetectModel, onRefreshModels, refreshingModels, + loadingModels, detectModelLabel, emptyDetectHint, defaultLabel, @@ -3767,6 +3772,7 @@ export function ModelDropdown({ onDetectModel?: () => Promise; onRefreshModels?: () => Promise; refreshingModels?: boolean; + loadingModels?: boolean; detectModelLabel?: string; emptyDetectHint?: string; defaultLabel?: string; @@ -4042,7 +4048,7 @@ export function ModelDropdown({ {filteredModels.length === 0 && !canCreateManualModel && promotedModelIds.size === 0 && (

- {onDetectModel + {loadingModels ? "Loading models…" : onDetectModel ? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.") : "No models found."}

diff --git a/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx b/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx index 02d0da475c1..2043569e512 100644 --- a/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx +++ b/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx @@ -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 ; } -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(); const client = useQueryClient(); @@ -82,6 +84,7 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM {loginEnvironments.map((env) => {env.name})} } {error &&

{error}

} + {defaults} {loading ?

Preparing sign-in…

: {}} 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); } }} />}
; } -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
{!hideName && } {save.error &&

{save.error.message}

} - save.mutate()} disabled={save.isPending} placeholder="Enter API key here" autoFocus /> -
+ save.mutate()} disabled={disabled || save.isPending} placeholder="Enter API key here" autoFocus /> + {defaults} +
; } diff --git a/ui/src/components/ai-connections/AiConnectionSelect.tsx b/ui/src/components/ai-connections/AiConnectionSelect.tsx index 3726eefc0fe..bb5fbbe04ec 100644 --- a/ui/src/components/ai-connections/AiConnectionSelect.tsx +++ b/ui/src/components/ai-connections/AiConnectionSelect.tsx @@ -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 ( + + + {label ?? connection.name} + + ); +} /** 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 + ? + : value?.mode === "responsible_user" + ? "Responsible user’s default" + : value ? "Unavailable connection" : undefined} {incompatible && selected && ( - {selected.name} · Incompatible + )} {compatible.map((c) => ( @@ -128,25 +141,19 @@ export function AiConnectionSelect({ value={c.id} disabled={Boolean(aiConnectionProblem(c))} > - - - {c.name} - {c.status !== "connected" ? " · Reconnect" : ""} - + ))} {(canUseDefault || value?.mode === "responsible_user") && ( + Responsible user’s default )} - Connect an account… + + + Connect an account… + {!readOnly && onReconnect && !loading && !error && ( diff --git a/ui/src/components/ai-connections/AiProviderSetup.test.tsx b/ui/src/components/ai-connections/AiProviderSetup.test.tsx index 7d8666efef2..31335841a46 100644 --- a/ui/src/components/ai-connections/AiProviderSetup.test.tsx +++ b/ui/src/components/ai-connections/AiProviderSetup.test.tsx @@ -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 | 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) => { credentialProps = props; return
Existing credential flow
; } })); @@ -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( {}} />)); + 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( {}} onComplete={() => {}} />)); + 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(); + } +}); diff --git a/ui/src/components/ai-connections/AiProviderSetup.tsx b/ui/src/components/ai-connections/AiProviderSetup.tsx index 868cdcf80f8..cb17432ca49 100644 --- a/ui/src/components/ai-connections/AiProviderSetup.tsx +++ b/ui/src/components/ai-connections/AiProviderSetup.tsx @@ -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(); const [agentIds, setAgentIds] = useState(new Set(agentId ? [agentId] : [])); const [protocol, setProtocol] = useState( - reconnect?.routing?.protocol ?? "responses", + reconnect?.routing?.protocol ?? initialProtocol ?? "responses", ); const [auth, setAuth] = useState( 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 = ( + + ); + const accessDefaults = !reconnect ? ( + 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

Loading connection permissions…

; + if (!reconnect && accounts.isError) return

Could not load connection permissions.

; return (
event.stopPropagation()}>
@@ -238,12 +278,8 @@ export function AiProviderSetup({ ? "Connect a model provider" : `${reconnect ? "Reconnect" : "Connect"} ${label}`} - {step !== "provider" && ( -

- {step === "access" ? "Access" : "Connect"} -

- )}
+ {agents.isError &&

Could not load agents.

} {step === "provider" ? ( <> {choices(false)} @@ -257,34 +293,6 @@ export function AiProviderSetup({ Cancel - ) : step === "access" ? ( - <> - {accounts.error &&

Could not load connection permissions.

} - {agents.error && ( -

- Could not load agents. Try again. -

- )} - - 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 ? ( (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)} > - + @@ -373,7 +383,7 @@ export function AiProviderSetup({ onValueChange={(v) => setAuth(v as typeof auth)} disabled={Boolean(reconnect)} > - + @@ -410,24 +420,7 @@ export function AiProviderSetup({ ) } -
- - Advanced model settings - - -
+ {reconnect &&
Model settings{modelSettings}
}

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}

)} + {accessDefaults}
-
- - - -
Use another connection
{ setConnection({ env: {}, aiConnection: binding }); setRuntimeAiBinding(undefined); resetTest(); setScreen("runtime"); }} />
@@ -843,13 +823,16 @@ function Setup({ { setRuntimeAiBinding(binding); resetTest(); }} /> )} - {models.error &&

Could not load models. Retry or enter a model ID manually.

} + {(connectionModels ? connectionModels.error : models.error) &&

Could not load models. Retry or enter a model ID manually.

} {((showModel && !usingKimiApi) || efforts.length > 0) && (
{showModel && !usingKimiApi && ( { 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]}

)} - {showModel && models.error && ( -

- Couldn’t load models. You can enter a model ID - manually. -

- )} {hasCredentialField && !aiBinding && (
{chooseProvider && ( diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 548d4a8681e..6b0f36a308f 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -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 ? <> 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

Loading connection…

; if (aiReconnectRequested && aiAccounts.isError) return

Could not load this connection. Refresh to try again.

; if (aiReconnectRequested && !aiReconnect) return

This connection is unavailable. Return to Connectors to choose an account.

; - if (selectedCompanyId && (source === "model-provider" || source === "openrouter" || source === "google-ai" || source === "google" || source === "bedrock" || source === "gateway" || source === "local" || aiReconnect?.routing)) return navigate("/apps")} onComplete={binding => navigate(`/apps/${binding.connectionId}/permissions`)} />; + if (selectedCompanyId && (preset || aiReconnect?.routing)) return ( + navigate("/apps")} + onComplete={binding => navigate(`/apps/${binding.connectionId}/permissions`)} + /> + ); return ; diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 35ab90ed2ff..e11c15beac5 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -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" } })] }); diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index d1a9909afd8..8a7b2984ac2 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -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" />
- {loadFailed ? ( diff --git a/ui/src/pages/apps/app-connect-policy.test.ts b/ui/src/pages/apps/app-connect-policy.test.ts index 5756946ae54..a4089b5d19f 100644 --- a/ui/src/pages/apps/app-connect-policy.test.ts +++ b/ui/src/pages/apps/app-connect-policy.test.ts @@ -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); }); diff --git a/ui/src/pages/apps/app-connect-policy.ts b/ui/src/pages/apps/app-connect-policy.ts index 3af33d0cbb2..e4c443fc43c 100644 --- a/ui/src/pages/apps/app-connect-policy.ts +++ b/ui/src/pages/apps/app-connect-policy.ts @@ -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 && diff --git a/ui/src/pages/apps/app-definition-display.ts b/ui/src/pages/apps/app-definition-display.ts index 77740ad9105..2a389934a1b 100644 --- a/ui/src/pages/apps/app-definition-display.ts +++ b/ui/src/pages/apps/app-definition-display.ts @@ -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; } diff --git a/ui/storybook/prototypes/provider-routing/ConnectionCatalog.tsx b/ui/storybook/prototypes/provider-routing/ConnectionCatalog.tsx index 949326da2db..72072450128 100644 --- a/ui/storybook/prototypes/provider-routing/ConnectionCatalog.tsx +++ b/ui/storybook/prototypes/provider-routing/ConnectionCatalog.tsx @@ -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({
); } + +/** Regular catalog rows and direct setup, using the production components. */ +export function ProviderCatalogReview() { + const [source, setSource] = useState(); + 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 setSource(undefined)} onComplete={() => setSource(undefined)} + />; + return
+ setQuery(event.target.value)} /> +
+ {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 => setSource(entry.slug)} onRequestRemove={() => {}} + />)} +
+
; +} diff --git a/ui/storybook/prototypes/provider-routing/ConnectionSetup.tsx b/ui/storybook/prototypes/provider-routing/ConnectionSetup.tsx index a34a2f4d241..392f58906b0 100644 --- a/ui/storybook/prototypes/provider-routing/ConnectionSetup.tsx +++ b/ui/storybook/prototypes/provider-routing/ConnectionSetup.tsx @@ -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(initialStep); + const [step, setStep] = useState(initialStep === "access" ? "connect" : initialStep); const [provider, setProvider] = useState(initialProvider); const [method, setMethod] = useState(initialMethod); const [protocol, setProtocol] = useState(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({
- {step === "access" ? ( - setStep("provider")} - onContinue={() => setStep("connect")} - /> - ) : ( - <> + <> {isAws && ( )} - {provider === "custom" && ( -
- Advanced -

- Model aliases, context limits, and helper-model defaults can be - managed after connecting. API format determines which harnesses - can use this endpoint. -

- {method === "header" && ( -

- Authentication header: {header} -

- )} -
- )} {error && ( {error === "credential" @@ -428,21 +400,26 @@ export function ConnectionSetup({ : "Enter a valid HTTP or HTTPS base URL without embedded credentials."} )} -
- - {ownership === "personal" - ? "Personal connection" - : "Shared connection"}{" "} - · {providerLabel} -
+ setOwnership(kind === "organization" ? "shared" : "personal")} + installChoice={allAgents ? "all" : "specific"} + setInstallChoice={choice => setAllAgents(choice === "all")} + installAgentIds={agentIds} + setInstallAgentIds={setAgentIds} + />
setStep("access")} + onBack={cancel} next={method === "subscription" ? "Sign in" : "Connect"} onNext={connect} disabled={!canConnect} /> - - )} +
); } diff --git a/ui/storybook/prototypes/provider-routing/ReviewIndex.tsx b/ui/storybook/prototypes/provider-routing/ReviewIndex.tsx index a1176235f99..bed5cd9ad5c 100644 --- a/ui/storybook/prototypes/provider-routing/ReviewIndex.tsx +++ b/ui/storybook/prototypes/provider-routing/ReviewIndex.tsx @@ -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() {
  • 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.
  • Changing a connection preserves the harness and asks for a diff --git a/ui/storybook/stories/provider-routing-01-onboarding.stories.tsx b/ui/storybook/stories/provider-routing-01-onboarding.stories.tsx index 9ebb1bfb6ad..1fa29f8a343 100644 --- a/ui/storybook/stories/provider-routing-01-onboarding.stories.tsx +++ b/ui/storybook/stories/provider-routing-01-onboarding.stories.tsx @@ -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"); diff --git a/ui/storybook/stories/provider-routing-02-connect.stories.tsx b/ui/storybook/stories/provider-routing-02-connect.stories.tsx index b331dee6697..527c81b1ef0 100644 --- a/ui/storybook/stories/provider-routing-02-connect.stories.tsx +++ b/ui/storybook/stories/provider-routing-02-connect.stories.tsx @@ -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) => ( - + ), @@ -23,35 +24,23 @@ const meta = { export default meta; type Story = StoryObj; export const ChooseProvider: Story = { + name: "Connector catalog", + render: () => , 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(); diff --git a/ui/storybook/stories/provider-routing-06-production.stories.tsx b/ui/storybook/stories/provider-routing-06-production.stories.tsx index b56f55fd4d0..292036a0064 100644 --- a/ui/storybook/stories/provider-routing-06-production.stories.tsx +++ b/ui/storybook/stories/provider-routing-06-production.stories.tsx @@ -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 {}} 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 ; +} +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 ; +} const meta = { title: "AI Connections/Provider routing/06 Production components", parameters: { layout: "fullscreen" }, decorators: [ (Story) => ( - + ), @@ -110,20 +142,34 @@ const meta = { } satisfies Meta; export default meta; type Story = StoryObj; -export const ChooseProvider: Story = { - render: () => , +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: () => , 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: () => , + 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: () => , 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(); }, };