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