feat(onboarding): connect a custom AI gateway (#17253)

## 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-23 06:26:39 -07:00
1 parent f5b6721ae0
commit fbaf3c6ed2
5 files changed
+204 -12

No files matched your search

+86 -1
View File
@@ -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<typeof createRoot>, queryClient: Quer
return act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
<TooltipProvider><OnboardingWizard /></TooltipProvider>
</QueryClientProvider>,
);
});
@@ -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: {
+57 -7
View File
@@ -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<string | null>(null);
const isCopilot = adapterType === "paperclip_runner";
const [copilotConnection, setCopilotConnection] = useState<AiConnectionBinding>();
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<string, unknown> | 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({
</OnboardingLoginCard>
) : credentialMode === "api" ? (
<OnboardingLoginCard
instruction={savedKeys.options.length ? "Choose a saved API key or enter a new one" : `Provide your ${
instruction={hasGatewaySelection || connectionModels ? "Choose a model for your saved connection" : savedKeys.options.length ? "Choose a saved API key or enter a new one" : `Provide your ${
CONNECT_SOURCE_NAMES[adapterType] ?? adapterType
} API key to connect`}
>
<SavedProviderKeySelect {...savedKeys} disabled={loading || adapterEnvLoading} value={selectedApiKey?.id ?? ""} onChange={(id) => {
{!hasGatewaySelection && <SavedProviderKeySelect {...savedKeys} disabled={loading || adapterEnvLoading} value={selectedApiKey?.id ?? ""} onChange={(id) => {
setGatewayConnection(undefined);
if (connectionModels) setModel("");
setSelectedSavedKey(createdCompanyId ? { companyId: createdCompanyId, envKey: apiKeyEnvKeyFor(adapterType), id } : null);
setApiKey("");
}} />
{!selectedApiKey && <OnboardingCardField
}} />}
{!selectedApiKey && !hasGatewaySelection && !connectionModels && <OnboardingCardField
label="API key"
placeholder="Enter API key here"
masked
@@ -3158,6 +3182,32 @@ function OnboardingWizardInner({
}}
onSubmit={() => handleConnectStepPrimary()}
/>}
{hasGatewaySelection && (gatewayLookupFailed ? <>
<p role="alert" className="text-sm text-destructive">Your saved gateway connection could not be loaded. Retry to choose a model and connect your agent.</p>
<Button type="button" variant="outline" disabled={loading || adapterEnvLoading} onClick={() => { void connectionModelState.refresh(); }}>
Retry loading connection
</Button>
</> : <p role="status" className="text-sm text-muted-foreground">{connectionModelState.refreshing
? "Loading your saved gateway connection…"
: "Gateway connection saved. Choose a model, then Connect to test it and finish setting up your agent."}</p>)}
{connectionModels && <ModelDropdown models={connectionModels.models} value={model}
onChange={setModel} open={modelOpen} onOpenChange={setModelOpen}
allowDefault={false} required groupByProvider={false}
creatable={connectionModels.models.length === 0} presentation="native" />}
{createdCompanyId && (adapterType === "codex_local" || adapterType === "claude_local") && <OnboardingGatewaySetup
companyId={createdCompanyId} agentId={setupAgentId ?? undefined} environmentId={resolvedLoginEnvironmentId ?? undefined}
adapterType={adapterType} disabled={loading || adapterEnvLoading}
onComplete={binding => {
setGatewayConnection({ companyId: createdCompanyId, adapterType, binding });
setApiKey("");
setModel("");
setError(null);
}} />}
{(hasGatewaySelection || connectionModels) && <Button type="button" variant="ghost" disabled={loading || adapterEnvLoading} onClick={() => {
setGatewayConnection(undefined);
setSelectedSavedKey({ companyId: createdCompanyId!, envKey: apiKeyEnvKeyFor(adapterType), id: "" });
setModel("");
}}>Use a different API key</Button>}
</OnboardingLoginCard>
) : connectStepNeedsLogin &&
createdCompanyId &&
@@ -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"}
</Button>
</div>
</form>
@@ -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;
}
@@ -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<AiConnectionBinding, { mode: "responsible_user" }>) => void;
}) {
const [open, setOpen] = useState(false);
return <>
<Button type="button" variant="ghost" disabled={disabled} onClick={() => setOpen(true)}>
Use a custom gateway
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-2xl" onKeyDown={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>Custom gateway connection</DialogTitle>
<DialogDescription>Save the connection, then choose a model and test it with your agent.</DialogDescription>
</DialogHeader>
<AiProviderSetup companyId={companyId} agentId={agentId} environmentId={environmentId}
initialProvider="gateway" initialProtocol={adapterType === "codex_local" ? "responses" : "messages"}
submitLabel="Save connection" onCancel={() => setOpen(false)}
onComplete={binding => { setOpen(false); onComplete(binding); }} />
</DialogContent>
</Dialog>
</>;
}