From fbaf3c6ed25e0abb0bf9ee838c0d8cd23a28414d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:26:39 -0700 Subject: [PATCH] feat(onboarding): connect a custom AI gateway (#17253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A person must connect an AI account when they set up the first agent. > - The existing connection form supports custom Responses and Messages gateways. > - First-agent onboarding does not expose that form or its saved model list. > - A person who uses a custom gateway cannot complete this setup through the normal controls. > - This pull request adds the existing gateway form to onboarding and requires a model before Connect. > - The person can save the account first, then choose Connect to test the environment and hire the agent. ## Linked Issues or Issue Description Refs #14104. This addresses the missing custom endpoint option during first-agent onboarding. Refs #15103. That issue also covers other adapters and the separate New Agent wizard. Related source review: - Refs #15884. That proposal adds a Local LLM source, changes private-network HTTP validation, and uses OpenCode. This change uses existing gateway routing and preserves the explicit environment test before hire. - Refs #14006. That proposal adds new providers and connection methods. This change uses the existing OpenAI and Anthropic managed connection contracts. **What existing behavior does this improve?** The API-key step of first-agent onboarding for Codex and Claude. **Current behavior** The wizard accepts a direct provider key but does not expose custom gateway setup or the saved gateway model list. **Proposed behavior** Choose Use a custom gateway. Save its URL, key, and models through the existing connection form. Select a saved model. Choose Connect to run the normal environment test and hire the agent with the managed connection binding. **Reason and benefit** People can complete onboarding with an existing custom gateway. They can save the connection before any agent test starts. **Breaking changes** No API, database, or routing schema changes. The new path applies to Codex and Claude API-key setup. Existing direct-key setup remains available. ## What Changed - Add a small onboarding dialog that reuses `AiProviderSetup` for Responses or Messages gateway connections. - Let the shared setup form use Save connection as its submit label in this dialog. - Bind the saved account to the current company and adapter. Clear its selection when the person changes companies or chooses another API key. - Load the saved connection's models. Require a model before Connect can test the environment and hire the agent. - Show a loading state, a visible error, and Retry loading connection when the saved connection lookup fails. Reuse the shared lookup cache and preserve the model hook contract for existing consumers. - Cover saving, model selection, delegated account use, secret exclusion from drafts and hire payloads, and Cmd+Enter in the gateway dialog. ## Verification - Focused UI verification: `pnpm --filter @paperclipai/ui exec vitest run src/components/OnboardingWizard.test.tsx src/components/ai-connections/AiProviderSetup.test.tsx` — 187 tests passed across two files. - The first focused attempt had one test-only failure because its fixture lacked `TooltipProvider`. The fixture now supplies that provider. The complete focused run then passed all 187 tests. - The new test confirms that saving the connection and pressing Cmd+Enter in its dialog do not call the adapter test or hire API. Connect remains disabled until model selection. Connect then uses the selected model and managed connection for both test and hire. - Rebased onto `origin/master` at `042051904d368f5514e287e5ca8e404dc8f78a0d`. The two focused UI files passed all 187 tests again on the rebased tree. - UI typecheck passed on the rebased tree. The first attempt found missing generated Plugin SDK declarations in the fresh worktree. `node scripts/ensure-plugin-build-deps.mjs` built the local shared and SDK artifacts. The Storybook catalog check and `tsc -b` then passed. - Reused installed third-party packages from the unchanged lockfile. Worktree-local workspace links pointed at the rebased source. No older workspace source was substituted. - The full token gate checks and `git diff --check` passed in this isolated worktree. - An earlier wider local repository run failed in a chat-channel test. That failure was not investigated in this isolated UI follow-up and remains part of the verification history. The complete CI suite for this PR later passed on the reviewed commit. Deployed gateway acceptance has not been run. - Review follow-up: reproduced the failed saved-connection refresh before the fix. The new test failed because the page did not show the lookup error. The page now shows the error and Retry loading connection. Connect stays disabled until lookup and model selection succeed. - The new failure-and-retry test uses separate response objects so cache mutation cannot hide a failed refresh. It also proves that save and retry do not probe or hire an agent. - The expanded regression initially needed a corrected mock return type. After that correction, all 199 tests passed across OnboardingWizard, AiProviderSetup, and useConnectionModels. UI typecheck, all token gate commands, and `git diff --check` passed. - The review fix is pushed at `12b4e9cb8528ba44af377315ba39fe3fbccae7bd`. All 53 active CI and security checks passed on that exact head. Two Storybook checks were skipped. Greptile completed with 5/5, and there are no unresolved review threads. The earlier local chat-channel failure remains recorded above. - No live Cloud gateway campaign or candidate deployment has been completed for this product change. ## Risks - Gateway setup adds a nested dialog inside onboarding. Its key events must not submit the parent wizard. The Cmd+Enter regression test covers this boundary. - A saved connection can offer no model list. That state permits an explicit model ID and still requires selection before Connect. - Saving a connection proves that it was stored. The normal Connect action remains responsible for runtime validation. A successful mocked UI test does not prove gateway reachability from a deployed runtime. - `ROADMAP.md` already includes personal and shared AI accounts and multi-model teams. This is a scoped extension of those capabilities. It does not add a provider, a protocol, or wider URL access. ## Model Used OpenAI Codex, GPT-6 family. The exact deployment model ID and context window are not exposed in this session. The agent used reasoning, tool use, code editing, and local test execution. No unverified model ID or context size is claimed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/components/OnboardingWizard.test.tsx | 87 ++++++++++++++++++- ui/src/components/OnboardingWizard.tsx | 64 ++++++++++++-- .../ai-connections/AiProviderSetup.tsx | 6 +- .../ai-connections/useConnectionModels.ts | 19 +++- .../onboarding/OnboardingGatewaySetup.tsx | 40 +++++++++ 5 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 ui/src/components/onboarding/OnboardingGatewaySetup.tsx diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 98753282009..9396c605028 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -277,6 +277,7 @@ import { ADAPTER_AUTH_MISSING_CHECK_CODE, getEnvironmentCapabilities } from "@pa import { CLAUDE_OAUTH_TOKEN_ENV_KEY } from "./environment-variables-editor/model"; import { ONBOARDING_STORAGE_KEY, OnboardingWizard } from "./OnboardingWizard"; import { CONNECTED_HOLD_MS } from "./onboarding/onboarding-motion"; +import { TooltipProvider } from "./ui/tooltip"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -302,7 +303,7 @@ function renderWizardView(root: ReturnType, queryClient: Quer return act(async () => { root.render( - + , ); }); @@ -2155,6 +2156,90 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( return { root, queryClient }; } + it.each([["successful refresh", false], ["failed refresh and retry", true]] as const)("saves a custom gateway without probing, then explicitly tests and hires with its selected model: %s", async (_scenario, failRefresh) => { + const gatewayAccounts: import("@paperclipai/shared").AiConnectionList = { + currentUserId: "user-1", canManageConnections: true, connections: [], + }; + let lookupFails = false; + managedApi.list.mockImplementation(async () => { + if (lookupFails) throw new Error("connection lookup failed"); + return structuredClone(gatewayAccounts) as never; + }); + managedApi.create.mockImplementationOnce(async () => { + gatewayAccounts.connections = [{ + id: "managed-connection", grantId: "managed-grant", companyId: "company-new", + name: "My gateway.example.test", provider: "openai", method: "api_key", ownership: "personal", + ownerUserId: "user-1", isDefault: false, status: "connected", + routing: { kind: "gateway", protocol: "responses", auth: "bearer", baseUrl: "https://gateway.example.test/v1", models: [{ id: "gpt-5.4-mini" }] }, + }]; + lookupFails = failRefresh; + return { connectionId: "managed-connection", grantId: "managed-grant" }; + }); + mockAdapterBuild.buildAdapterConfig.mockImplementation(values => ({ provider: "codex", model: values?.model })); + const handles = await openStep4({ adapterType: "codex_local", runner: "paperclip", credentialMode: "api" }); + const click = async (label: string) => { + const button = [...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === label)!; + expect(button).toBeTruthy(); + await act(async () => button.click()); + await flushReact(); + }; + try { + await pickSource(/OpenAI.*API/); + await click("Use a custom gateway"); + const gatewayDialog = document.querySelector('input[aria-label="Provider URL"]')!.closest('[role="dialog"]')!; + await act(async () => { + setControlledValue(gatewayDialog.querySelector('input[aria-label="Provider URL"]')!, "https://gateway.example.test/v1"); + setControlledValue(gatewayDialog.querySelector('input[aria-label="API key"]')!, "example-gateway-key"); + }); + await click("Change"); + await act(async () => setControlledValue(document.querySelector('input[aria-label="Model IDs"]')!, "gpt-5.4-mini")); + await act(async () => gatewayDialog.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }))); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + await click("Save connection"); + expect(managedApi.create).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({ + provider: "openai", method: "api_key", apiKey: "example-gateway-key", ownership: "personal", + agentIds: [expect.any(String)], routing: expect.objectContaining({ kind: "gateway", protocol: "responses", models: [{ id: "gpt-5.4-mini" }] }), + })); + if (failRefresh) { + await vi.waitFor(() => expect(document.body.textContent).toContain("Your saved gateway connection could not be loaded.")); + expect(document.querySelector('select[aria-label="Model"]')).toBeNull(); + expect([...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === "Connect")!.disabled).toBe(true); + expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + lookupFails = false; + await click("Retry loading connection"); + } + await vi.waitFor(() => expect(document.querySelector('select[aria-label="Model"]'), document.body.textContent ?? "").toBeTruthy()); + expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Gateway connection saved."); + const connect = [...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === "Connect")!; + expect(connect.disabled).toBe(true); + const select = document.querySelector('select[aria-label="Model"]') as HTMLSelectElement; + expect([...select.options].map(option => option.value)).toEqual(["", "gpt-5.4-mini"]); + await act(async () => { select.value = "gpt-5.4-mini"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + await flushReact(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(JSON.stringify(window.localStorage)).not.toContain("example-gateway-key"); + await click("Connect"); + const binding = { provider: "openai", method: "api_key", mode: "delegated", connectionId: "managed-connection", grantId: "managed-grant" }; + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledExactlyOnceWith("company-new", "codex_local", expect.objectContaining({ + environmentId: "env-sandbox-1", aiConnection: binding, adapterConfig: expect.objectContaining({ model: "gpt-5.4-mini" }), + })); + expect(mockAgentsApi.hire).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({ + runtimeConfig: expect.objectContaining({ aiConnection: binding }), adapterConfig: expect.objectContaining({ model: "gpt-5.4-mini" }), + })); + expect(JSON.stringify(mockAgentsApi.hire.mock.calls)).not.toContain("example-gateway-key"); + } finally { + await act(async () => handles.root.unmount()); + managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [] }); + } + }, 15_000); + it.each([["paperclip", "image"], ["legacy", "image"], ["paperclip", "snapshot"], ["legacy", "snapshot"]] as const)("qualifies managed Claude during Connect and tests/hire with measured %s configuration from %s", async (measured, source) => { mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index cb74154cc40..9516dc8aef8 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -9,11 +9,13 @@ import { LocalProviderLoginInstructions } from "./AdapterLoginChrome"; import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin"; import { aiConnectionsApi } from "@/api/ai-connections"; import { AiConnectionField, aiProviderForAdapter } from "./ai-connections/AiConnectionField"; +import { useConnectionModelsState } from "./ai-connections/useConnectionModels"; import { PAPERCLIP_RUNNER_ACPX_PROFILES } from "@paperclipai/adapter-utils"; import { aiConnectionBindingSchema } from "@paperclipai/shared"; import type { AiConnectionBinding } from "@paperclipai/shared"; import { storeProviderApiKey } from "../lib/provider-credential"; import { SavedProviderKeySelect, useSavedProviderKeys } from "./onboarding/SavedProviderKeySelect"; +import { OnboardingGatewaySetup } from "./onboarding/OnboardingGatewaySetup"; import { randomAgentAppearance, resolveAgentAppearance, agentAppearanceSchema } from "@paperclipai/shared"; import { OnboardingCharacter } from "./onboarding/OnboardingCharacter"; import { useEffect, useState, useMemo, useRef } from "react"; @@ -29,7 +31,7 @@ import type { InstanceSettings, } from "@paperclipai/shared"; import { AGENT_ROLES, AGENT_ROLE_LABELS, ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; -import { AdapterLoginPanel } from "./AgentConfigForm"; +import { AdapterLoginPanel, ModelDropdown } from "./AgentConfigForm"; import { CONNECT_SOURCE_NAMES, OnboardingCardField, @@ -640,6 +642,9 @@ function OnboardingWizardInner({ const [setupNameConflict, setSetupNameConflict] = useState(null); const isCopilot = adapterType === "paperclip_runner"; const [copilotConnection, setCopilotConnection] = useState(); + const [gatewayConnection, setGatewayConnection] = useState<{ + companyId: string; adapterType: string; binding: AiConnectionBinding; + }>(); const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner" && !(saved?.onboardingCopilot === true && copilotOnboardingQualified); const [cwd, setCwd] = useState((saved?.cwd as string) ?? ""); const [model, setModel] = useState( @@ -790,6 +795,7 @@ function OnboardingWizardInner({ const managedSubscriptionProvider = managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? managedProvider : undefined; function managedBindingForStep(): AiConnectionBinding | undefined { if (isCopilot) return copilotConnection; + if (credentialMode === "api" && gatewayConnection?.companyId === createdCompanyId && gatewayConnection.adapterType === adapterType) return gatewayConnection.binding; const restoredBinding = restoredSetup?.companyId === createdCompanyId && agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType, {}) ? restoredSetup.runtimeConfig?.aiConnection as AiConnectionBinding | undefined : undefined; @@ -805,6 +811,17 @@ function OnboardingWizardInner({ ? apiKeySecretRef.current.aiConnection : undefined); return savedSubscription?.aiConnection ?? (managedSubscriptionRef.current?.companyId === createdCompanyId && managedSubscriptionRef.current.binding.provider === managedProvider ? managedSubscriptionRef.current.binding : undefined); } + const connectionModelState = useConnectionModelsState( + effectiveOnboardingOpen && step === 4 && credentialMode === "api" ? createdCompanyId : null, + managedBindingForStep(), adapterType, + ); + const connectionModels = connectionModelState.connectionModels; + const hasGatewaySelection = credentialMode === "api" && gatewayConnection?.companyId === createdCompanyId && gatewayConnection.adapterType === adapterType; + const gatewayLookupFailed = hasGatewaySelection && !connectionModelState.refreshing + && Boolean(connectionModelState.error || (!connectionModels && !connectionModelState.loading)); + const connectionModelReady = (!hasGatewaySelection && !connectionModels) || Boolean(connectionModels + && (!hasGatewaySelection || (!connectionModelState.error && !connectionModelState.refreshing)) + && model.trim() && (connectionModels.models.length === 0 || connectionModels.models.some(entry => entry.id === model.trim()))); createdCompanyIdRef.current = createdCompanyId; // The step the request wants, mirrored for the same reason. `initialStep` is @@ -834,6 +851,7 @@ function OnboardingWizardInner({ function clearCompanyScopedState() { setRestoredSetup(null); setCopilotConnection(undefined); + setGatewayConnection(undefined); setAgentAppearance(randomAgentAppearance()); setCreatedCompanyPrefix(null); setCompanyName(""); @@ -1292,7 +1310,7 @@ function OnboardingWizardInner({ const restoredApiCredential = restoredSetup?.companyId === createdCompanyId && agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType, isCopilot ? { provider: "acpx", acpxAgent: "copilot" } : {}) && !selectedSavedKey && (restoredSetup.adapterConfig.env as Record | undefined)?.[apiKeyEnvKeyFor(adapterType)]; - const connectStepReady = Boolean(setupPrepared || createdAgentId) && sourceSelected && !adapterEnvLoading && !savedKeys.loading && runnerDiscoveryReady && (!isCopilot || copilotModelReady); + const connectStepReady = Boolean(setupPrepared || createdAgentId) && sourceSelected && !adapterEnvLoading && !savedKeys.loading && runnerDiscoveryReady && (!isCopilot || copilotModelReady) && connectionModelReady; useEffect(() => { if (step !== 4 || !createdCompanyId || !agentName.trim() || !adapterRegistryLoaded @@ -1744,7 +1762,7 @@ function OnboardingWizardInner({ setAdapterEnvResult(null); adapterEnvResultAppliedStoredLoginRef.current = false; if (adapterEnvErrorSelectionRef.current !== adapterSetupSelectionRef.current) setAdapterEnvError(null); - }, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, copilotConnection, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : undefined]); + }, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, copilotConnection, gatewayConnection, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : undefined]); /** * Leaving the step puts the row back to a question. @@ -2332,6 +2350,10 @@ function OnboardingWizardInner({ async function handleGiveHeartbeat() { if (!createdCompanyId) return; if (!setupPrepared && !createdAgentId) return; + if (!connectionModelReady) { + setError("Choose a model for this gateway connection before connecting your agent."); + return; + } if (isCopilot && (!copilotOnboardingEnabled || !sourceSelected || !copilotModelReady)) { setError("Connect GitHub Copilot and select an available model before creating this agent."); return; @@ -3135,15 +3157,17 @@ function OnboardingWizardInner({ ) : credentialMode === "api" ? ( - { + {!hasGatewaySelection && { + setGatewayConnection(undefined); + if (connectionModels) setModel(""); setSelectedSavedKey(createdCompanyId ? { companyId: createdCompanyId, envKey: apiKeyEnvKeyFor(adapterType), id } : null); setApiKey(""); - }} /> - {!selectedApiKey && } + {!selectedApiKey && !hasGatewaySelection && !connectionModels && handleConnectStepPrimary()} />} + {hasGatewaySelection && (gatewayLookupFailed ? <> +

Your saved gateway connection could not be loaded. Retry to choose a model and connect your agent.

+ + :

{connectionModelState.refreshing + ? "Loading your saved gateway connection…" + : "Gateway connection saved. Choose a model, then Connect to test it and finish setting up your agent."}

)} + {connectionModels && } + {createdCompanyId && (adapterType === "codex_local" || adapterType === "claude_local") && { + setGatewayConnection({ companyId: createdCompanyId, adapterType, binding }); + setApiKey(""); + setModel(""); + setError(null); + }} />} + {(hasGatewaySelection || connectionModels) && }
) : connectStepNeedsLogin && createdCompanyId && diff --git a/ui/src/components/ai-connections/AiProviderSetup.tsx b/ui/src/components/ai-connections/AiProviderSetup.tsx index 1899864550e..4c1dda0cdf8 100644 --- a/ui/src/components/ai-connections/AiProviderSetup.tsx +++ b/ui/src/components/ai-connections/AiProviderSetup.tsx @@ -73,6 +73,7 @@ export function AiProviderSetup({ initialProtocol, providerLabel, advancedOnly = false, + submitLabel, reconnect, onCancel, onComplete, @@ -84,6 +85,7 @@ export function AiProviderSetup({ initialProtocol?: AiProviderRouting["protocol"]; providerLabel?: string; advancedOnly?: boolean; + submitLabel?: string; reconnect?: AiManagedConnectionSummary; onCancel: () => void; onComplete: ( @@ -455,10 +457,10 @@ export function AiProviderSetup({ } > {save.isPending - ? "Connecting…" + ? submitLabel ? "Saving…" : "Connecting…" : reconnect ? "Reconnect" - : "Connect"} + : submitLabel ?? "Connect"} diff --git a/ui/src/components/ai-connections/useConnectionModels.ts b/ui/src/components/ai-connections/useConnectionModels.ts index 0e53b0842bd..8202bfa6a04 100644 --- a/ui/src/components/ai-connections/useConnectionModels.ts +++ b/ui/src/components/ai-connections/useConnectionModels.ts @@ -4,7 +4,7 @@ import { aiConnectionsApi } from "@/api/ai-connections"; import { agentsApi } from "@/api/agents"; import { queryKeys } from "@/lib/queryKeys"; -export function useConnectionModels( +export function useConnectionModelsState( companyId: string | null | undefined, binding: AiConnectionBinding | undefined, harness: string, @@ -38,7 +38,7 @@ export function useConnectionModels( const modelOptions = discover ? (catalog.data ?? []).map((m) => ({ ...m, id: ["opencode_local", "pi_local"].includes(harness) ? m.id : m.id.replace(/^openrouter\//, "") })) : routing?.models ?? []; - return effectiveRouting + const connectionModels = effectiveRouting ? { models: modelOptions.map((m) => ({ id: aiRoutingModel(effectiveRouting, harness, m.id), @@ -52,4 +52,19 @@ export function useConnectionModels( aiRoutingModel(effectiveRouting, harness, model), } : undefined; + return { + connectionModels, + loading: accounts.isLoading, + refreshing: accounts.isFetching, + error: accounts.error, + refresh: async () => { await accounts.refetch(); }, + }; +} + +export function useConnectionModels( + companyId: string | null | undefined, + binding: AiConnectionBinding | undefined, + harness: string, +) { + return useConnectionModelsState(companyId, binding, harness).connectionModels; } diff --git a/ui/src/components/onboarding/OnboardingGatewaySetup.tsx b/ui/src/components/onboarding/OnboardingGatewaySetup.tsx new file mode 100644 index 00000000000..e38fabca600 --- /dev/null +++ b/ui/src/components/onboarding/OnboardingGatewaySetup.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import type { AiConnectionBinding } from "@paperclipai/shared"; +import { AiProviderSetup } from "../ai-connections/AiProviderSetup"; +import { Button } from "../ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; + +export function OnboardingGatewaySetup({ + companyId, + agentId, + environmentId, + adapterType, + disabled, + onComplete, +}: { + companyId: string; + agentId?: string; + environmentId?: string; + adapterType: "codex_local" | "claude_local"; + disabled?: boolean; + onComplete: (binding: Exclude) => void; +}) { + const [open, setOpen] = useState(false); + return <> + + + event.stopPropagation()}> + + Custom gateway connection + Save the connection, then choose a model and test it with your agent. + + setOpen(false)} + onComplete={binding => { setOpen(false); onComplete(binding); }} /> + + + ; +}