mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
fix(paseo-agent): polish provider settings UI
Keep provider instance keys stable for credentials and model addressing while allowing display-name edits.
This commit is contained in:
@@ -17,14 +17,11 @@ type PaseoAgentDaemonClient = Pick<
|
||||
|
||||
interface ApiKeyProviderInput {
|
||||
catalogId: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
models?: string[];
|
||||
}
|
||||
|
||||
interface OAuthProviderInput {
|
||||
catalogId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ExpectedProvider {
|
||||
@@ -54,11 +51,9 @@ export async function addApiKeyProvider(page: Page, provider: ApiKeyProviderInpu
|
||||
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
|
||||
|
||||
await page.getByLabel("Provider name").fill(provider.name);
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Models")).toHaveCount(0);
|
||||
await page.getByLabel("API key").fill(provider.apiKey);
|
||||
if (provider.models) {
|
||||
await page.getByLabel("Models").fill(provider.models.join("\n"));
|
||||
}
|
||||
await page.getByRole("button", { name: "Save provider", exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
@@ -72,9 +67,13 @@ export async function startOAuthProviderSignIn(
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
|
||||
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
|
||||
|
||||
await page.getByLabel("Provider name").fill(provider.name);
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByTestId("paseo-agent-oauth-sign-in")).toBeVisible();
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Sign in with browser", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Use a code instead", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-oauth-user-code")).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-oauth-verification-link")).toBeVisible();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
addApiKeyProvider,
|
||||
@@ -8,8 +9,11 @@ import {
|
||||
startOAuthProviderSignIn,
|
||||
} from "./helpers/paseo-agent";
|
||||
|
||||
const OPENROUTER_PROVIDER = "phase-e-openrouter-ui";
|
||||
const CHATGPT_PROVIDER = "phase-e-chatgpt-ui";
|
||||
const CLOSE_PROVIDER = "phase-e-close-ui";
|
||||
const DEFAULT_OPENROUTER_PROVIDER = "OpenRouter";
|
||||
const DEFAULT_CHATGPT_PROVIDER = "ChatGPT";
|
||||
const RENAME_PROVIDER = "phase-e-rename-ui";
|
||||
|
||||
test.describe("Paseo Agent provider configuration", () => {
|
||||
const providerNamesToCleanup = new Set<string>();
|
||||
@@ -20,34 +24,82 @@ test.describe("Paseo Agent provider configuration", () => {
|
||||
});
|
||||
|
||||
test("adds an OpenRouter model provider from Settings", async ({ page }) => {
|
||||
providerNamesToCleanup.add(OPENROUTER_PROVIDER);
|
||||
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await addApiKeyProvider(page, {
|
||||
catalogId: "openrouter",
|
||||
name: OPENROUTER_PROVIDER,
|
||||
apiKey: "sk-or-phase-e-write-only",
|
||||
models: ["openai/gpt-4o-mini", "anthropic/claude-3.7-sonnet"],
|
||||
});
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: OPENROUTER_PROVIDER,
|
||||
name: DEFAULT_OPENROUTER_PROVIDER,
|
||||
providerLabel: "OpenRouter",
|
||||
modelCount: 2,
|
||||
modelCount: 0,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("adds an API-key provider without prompting for a provider name", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await page.getByTestId("paseo-agent-catalog-select-openrouter").click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Models")).toHaveCount(0);
|
||||
await page.getByLabel("API key").fill("sk-or-default-name");
|
||||
await page.getByRole("button", { name: "Save provider", exact: true }).click();
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: DEFAULT_OPENROUTER_PROVIDER,
|
||||
providerLabel: "OpenRouter",
|
||||
modelCount: 0,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("starts a ChatGPT sign-in from Settings", async ({ page }) => {
|
||||
providerNamesToCleanup.add(CHATGPT_PROVIDER);
|
||||
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await startOAuthProviderSignIn(page, {
|
||||
catalogId: "chatgpt",
|
||||
name: CHATGPT_PROVIDER,
|
||||
});
|
||||
});
|
||||
|
||||
test("starts OAuth sign-in without prompting for a provider name", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await page.getByTestId("paseo-agent-catalog-select-chatgpt").click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows user-facing provider picker rows", async ({ page }) => {
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
|
||||
const picker = page.getByTestId("paseo-agent-provider-picker");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("paseo-agent-catalog-entry-openrouter")
|
||||
.getByText("API key", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-catalog-icon-openrouter")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("paseo-agent-catalog-entry-chatgpt").getByText("Sign in", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-catalog-icon-chatgpt")).toBeVisible();
|
||||
await expect(picker.getByText("openai-codex-responses")).toHaveCount(0);
|
||||
await expect(picker.getByText(/\b\d+ models?\b/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows a stored ChatGPT login as a read-only model provider row", async ({ page }) => {
|
||||
providerNamesToCleanup.add(CHATGPT_PROVIDER);
|
||||
|
||||
@@ -61,4 +113,41 @@ test.describe("Paseo Agent provider configuration", () => {
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("renames a configured provider row without changing its stored credential key", async ({
|
||||
page,
|
||||
}) => {
|
||||
providerNamesToCleanup.add(RENAME_PROVIDER);
|
||||
|
||||
await seedChatGptProvider(RENAME_PROVIDER);
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByTestId(`paseo-agent-provider-rename-${RENAME_PROVIDER}`).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-rename-form")).toBeVisible();
|
||||
await page.getByLabel("Provider name").fill("Work account");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: "Work account",
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("closes the Paseo Agent settings sheet after providers load", async ({ page }) => {
|
||||
providerNamesToCleanup.add(CLOSE_PROVIDER);
|
||||
|
||||
await seedChatGptProvider(CLOSE_PROVIDER);
|
||||
await openPaseoAgentSettings(page);
|
||||
await expectModelProviderListed(page, {
|
||||
name: CLOSE_PROVIDER,
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "Connected",
|
||||
});
|
||||
|
||||
await page.getByLabel("Close", { exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-settings-sheet")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface OpenRouterIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function OpenRouterIcon({ size = 16, color = "currentColor" }: OpenRouterIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M3 7H15.5L21 12L15.5 17H3M10 3V21M15 7V17"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getPaseoAgentApiKeyAuth,
|
||||
getPaseoAgentOAuthAuth,
|
||||
isPaseoAgentCatalogEntrySupported,
|
||||
nextPaseoAgentProviderName,
|
||||
parsePaseoAgentModelIds,
|
||||
paseoAgentAuthBadge,
|
||||
paseoAgentProviderLabel,
|
||||
@@ -95,6 +96,21 @@ describe("paseo-agent-settings-sheet-model", () => {
|
||||
).toEqual(["alpha/fast", "beta/steady", "gamma/deep"]);
|
||||
});
|
||||
|
||||
it("uses the catalog label for the first provider instance", () => {
|
||||
expect(nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [])).toBe(
|
||||
"Acme Cloud",
|
||||
);
|
||||
});
|
||||
|
||||
it("numbers later provider instances from the catalog label", () => {
|
||||
expect(
|
||||
nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [
|
||||
providerConfig({ name: "Acme Cloud" }),
|
||||
providerConfig({ name: "Acme Cloud (1)" }),
|
||||
]),
|
||||
).toBe("Acme Cloud (2)");
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with a trimmed explicit key", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
|
||||
@@ -93,6 +93,24 @@ export function parsePaseoAgentModelIds(raw: string): string[] {
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function nextPaseoAgentProviderName(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
providers: RedactedPaseoAgentProviderConfig[],
|
||||
): string {
|
||||
const baseName = entry.label.trim() || entry.id;
|
||||
const existingNames = new Set(providers.map((provider) => provider.name));
|
||||
if (!existingNames.has(baseName)) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
for (let index = 1; ; index += 1) {
|
||||
const candidate = `${baseName} (${index})`;
|
||||
if (!existingNames.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPaseoAgentProviderInput(input: {
|
||||
entry: PaseoAgentCatalogEntry;
|
||||
name: string;
|
||||
|
||||
@@ -400,24 +400,19 @@ describe("PaseoAgentSettingsSheet", () => {
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
|
||||
fireEvent.change(screen.getByLabelText("Provider name"), {
|
||||
target: { value: "alpha-main" },
|
||||
});
|
||||
expect(screen.queryByLabelText("Provider name")).toBeNull();
|
||||
expect(screen.queryByLabelText("Models")).toBeNull();
|
||||
fireEvent.change(screen.getByLabelText("API key"), {
|
||||
target: { value: " alpha-secret " },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Models"), {
|
||||
target: { value: "alpha/fast\nbeta/steady" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "alpha-main",
|
||||
name: "Catalog Alpha",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
models: [{ id: "alpha/fast" }, { id: "beta/steady" }],
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
});
|
||||
@@ -433,7 +428,7 @@ describe("PaseoAgentSettingsSheet", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "catalog-alpha",
|
||||
name: "Catalog Alpha",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "$ALPHA_API_KEY",
|
||||
@@ -470,7 +465,7 @@ describe("PaseoAgentSettingsSheet", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openExternalUrls).toEqual(["https://login.example.test/oauth/authorize?state=abc"]);
|
||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("catalog-login", "browser");
|
||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "browser");
|
||||
});
|
||||
expect(screen.getByText("https://login.example.test/oauth/authorize?state=abc")).toBeTruthy();
|
||||
expect(screen.getByTestId("paseo-agent-oauth-url").getAttribute("href")).toBe(
|
||||
@@ -512,14 +507,14 @@ describe("PaseoAgentSettingsSheet", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "catalog-login",
|
||||
name: "Catalog Login",
|
||||
providerType: "catalog-login",
|
||||
options: {
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("catalog-login", "device_code");
|
||||
expect(hookState.current.completeOAuth).toHaveBeenCalledWith("catalog-login");
|
||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "device_code");
|
||||
expect(hookState.current.completeOAuth).toHaveBeenCalledWith("Catalog Login");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -540,7 +535,7 @@ describe("PaseoAgentSettingsSheet", () => {
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
||||
|
||||
const actionButtons = screen
|
||||
.getByTestId("paseo-agent-provider-form")
|
||||
.getByTestId("paseo-agent-oauth-sign-in")
|
||||
.querySelectorAll("button");
|
||||
expect(Array.from(actionButtons).map((button) => button.textContent)).toEqual([
|
||||
"Providers",
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import type {
|
||||
PaseoAgentOAuthCompleteResult,
|
||||
PaseoAgentOAuthStartResult,
|
||||
PaseoAgentRenameProviderInput,
|
||||
PaseoAgentSetProviderInput,
|
||||
} from "@/hooks/use-paseo-agent-providers";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
getPaseoAgentApiKeyAuth,
|
||||
getPaseoAgentOAuthAuth,
|
||||
isPaseoAgentCatalogEntrySupported,
|
||||
parsePaseoAgentModelIds,
|
||||
nextPaseoAgentProviderName,
|
||||
paseoAgentAuthBadge,
|
||||
paseoAgentProviderLabel,
|
||||
preferredPaseoAgentOAuthMode,
|
||||
@@ -56,9 +57,12 @@ type AddSheetState =
|
||||
| {
|
||||
kind: "form";
|
||||
entry: PaseoAgentCatalogEntry;
|
||||
initialName?: string;
|
||||
lockName: boolean;
|
||||
name: string;
|
||||
returnToPicker: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "rename";
|
||||
provider: RedactedPaseoAgentProviderConfig;
|
||||
};
|
||||
|
||||
type OAuthState =
|
||||
@@ -80,17 +84,6 @@ type OAuthState =
|
||||
authorization: PaseoAgentOAuthStartResult["authorization"];
|
||||
};
|
||||
|
||||
interface ProviderNameAndModelsFieldsProps {
|
||||
entry: PaseoAgentCatalogEntry;
|
||||
name: string;
|
||||
models: string;
|
||||
resetKey: number;
|
||||
lockName: boolean;
|
||||
hasCatalogModels: boolean;
|
||||
onNameChange: (value: string) => void;
|
||||
onModelsChange: (value: string) => void;
|
||||
}
|
||||
|
||||
interface ApiKeyFieldsProps {
|
||||
auth: PaseoAgentApiKeyAuthManifest;
|
||||
apiKey: string;
|
||||
@@ -146,8 +139,14 @@ function modelCountLabel(count: number): string {
|
||||
return count === 1 ? "1 model" : `${count} models`;
|
||||
}
|
||||
|
||||
function catalogModelSummary(entry: PaseoAgentCatalogEntry): string {
|
||||
return entry.models.length > 0 ? modelCountLabel(entry.models.length) : "Custom models";
|
||||
function catalogAuthSummary(entry: PaseoAgentCatalogEntry): string | null {
|
||||
if (getPaseoAgentOAuthAuth(entry)) {
|
||||
return "Sign in";
|
||||
}
|
||||
if (getPaseoAgentApiKeyAuth(entry)) {
|
||||
return "API key";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasKnownProviderIcon(iconName: string | undefined): iconName is string {
|
||||
@@ -205,19 +204,22 @@ function ProviderRow({
|
||||
catalogEntry,
|
||||
isFirst,
|
||||
onReauth,
|
||||
onRename,
|
||||
}: {
|
||||
provider: RedactedPaseoAgentProviderConfig;
|
||||
catalogEntry: PaseoAgentCatalogEntry | undefined;
|
||||
isFirst: boolean;
|
||||
onReauth: (provider: RedactedPaseoAgentProviderConfig, entry: PaseoAgentCatalogEntry) => void;
|
||||
onRename: (provider: RedactedPaseoAgentProviderConfig) => void;
|
||||
}) {
|
||||
const modelLabel = modelCountLabel(provider.models.length);
|
||||
const authBadge = paseoAgentAuthBadge(provider.auth);
|
||||
const providerLabel = paseoAgentProviderLabel(provider, catalogEntry);
|
||||
const providerName = provider.displayName ?? provider.name;
|
||||
const canReauth = Boolean(catalogEntry && isPaseoAgentCatalogEntrySupported(catalogEntry));
|
||||
const accessibilityLabel = authBadge
|
||||
? `${provider.name}, ${providerLabel}, ${modelLabel}, ${authBadge.label}`
|
||||
: `${provider.name}, ${providerLabel}, ${modelLabel}`;
|
||||
? `${providerName}, ${providerLabel}, ${modelLabel}, ${authBadge.label}`
|
||||
: `${providerName}, ${providerLabel}, ${modelLabel}`;
|
||||
const rowStyle = useMemo(() => [styles.providerRow, !isFirst && styles.rowBorder], [isFirst]);
|
||||
|
||||
const handleReauth = useCallback(() => {
|
||||
@@ -225,6 +227,9 @@ function ProviderRow({
|
||||
onReauth(provider, catalogEntry);
|
||||
}
|
||||
}, [catalogEntry, onReauth, provider]);
|
||||
const handleRename = useCallback(() => {
|
||||
onRename(provider);
|
||||
}, [onRename, provider]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -237,13 +242,21 @@ function ProviderRow({
|
||||
<View style={provider.available ? styles.dotAvailable : styles.dotMuted} />
|
||||
<View style={styles.providerText}>
|
||||
<Text style={styles.providerName} numberOfLines={1}>
|
||||
{provider.name}
|
||||
{providerName}
|
||||
</Text>
|
||||
<Text style={styles.providerMeta} numberOfLines={1}>
|
||||
{providerLabel} · {modelLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{authBadge ? <StatusBadge label={authBadge.label} variant={authBadge.variant} /> : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onPress={handleRename}
|
||||
testID={`paseo-agent-provider-rename-${provider.name}`}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
{canReauth ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -268,6 +281,7 @@ function CatalogEntryRow({
|
||||
onSelect: (entry: PaseoAgentCatalogEntry) => void;
|
||||
}) {
|
||||
const supported = isPaseoAgentCatalogEntrySupported(entry);
|
||||
const authSummary = catalogAuthSummary(entry);
|
||||
const rowStyle = useMemo(
|
||||
() => [styles.catalogRow, !isFirst && styles.rowBorder, !supported && styles.disabledRow],
|
||||
[isFirst, supported],
|
||||
@@ -282,16 +296,18 @@ function CatalogEntryRow({
|
||||
accessibilityLabel={entry.label}
|
||||
testID={`paseo-agent-catalog-entry-${entry.id}`}
|
||||
>
|
||||
<View style={styles.catalogIcon}>
|
||||
<View style={styles.catalogIcon} testID={`paseo-agent-catalog-icon-${entry.id}`}>
|
||||
<CatalogEntryIcon entry={entry} />
|
||||
</View>
|
||||
<View style={styles.catalogText}>
|
||||
<Text style={styles.providerName} numberOfLines={1}>
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Text style={styles.providerMeta} numberOfLines={1}>
|
||||
{entry.api} · {catalogModelSummary(entry)}
|
||||
</Text>
|
||||
{authSummary ? (
|
||||
<Text style={styles.providerMeta} numberOfLines={1}>
|
||||
{authSummary}
|
||||
</Text>
|
||||
) : null}
|
||||
{entry.docsUrl ? (
|
||||
<ExternalLink
|
||||
href={entry.docsUrl}
|
||||
@@ -438,59 +454,6 @@ function OAuthAuthorizationPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderNameAndModelsFields({
|
||||
entry,
|
||||
name,
|
||||
models,
|
||||
resetKey,
|
||||
lockName,
|
||||
hasCatalogModels,
|
||||
onNameChange,
|
||||
onModelsChange,
|
||||
}: ProviderNameAndModelsFieldsProps) {
|
||||
const modelInputStyle = useMemo(() => [styles.formInput, styles.modelsInput], []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.formLabel}>Provider name</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="paseo-agent-provider-name"
|
||||
accessibilityLabel="Provider name"
|
||||
initialValue={name}
|
||||
resetKey={`paseo-agent-provider-name-${resetKey}`}
|
||||
value={name}
|
||||
onChangeText={onNameChange}
|
||||
placeholder={entry.id}
|
||||
editable={!lockName}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.formInput}
|
||||
/>
|
||||
{hasCatalogModels ? (
|
||||
<Text style={styles.formHint}>{modelCountLabel(entry.models.length)} from the catalog</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.formLabel}>Models</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="paseo-agent-models"
|
||||
accessibilityLabel="Models"
|
||||
initialValue={models}
|
||||
resetKey={`paseo-agent-models-${resetKey}`}
|
||||
value={models}
|
||||
onChangeText={onModelsChange}
|
||||
placeholder="provider/model-id"
|
||||
multiline
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={modelInputStyle}
|
||||
/>
|
||||
<Text style={styles.formHint}>One model id per line, or comma-separated.</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyFields({ auth, apiKey, resetKey, onApiKeyChange }: ApiKeyFieldsProps) {
|
||||
const envHint = `Leave blank to use $${auth.envVar} on the host.`;
|
||||
const apiKeyHint = auth.hint ?? "Stored on the host and never shown again.";
|
||||
@@ -638,8 +601,7 @@ function ProviderFormActions({
|
||||
function PaseoAgentProviderFormSheet({
|
||||
entry,
|
||||
visible,
|
||||
initialName,
|
||||
lockName,
|
||||
name,
|
||||
onBack,
|
||||
onClose,
|
||||
setProvider,
|
||||
@@ -649,8 +611,7 @@ function PaseoAgentProviderFormSheet({
|
||||
}: {
|
||||
entry: PaseoAgentCatalogEntry | null;
|
||||
visible: boolean;
|
||||
initialName: string | undefined;
|
||||
lockName: boolean;
|
||||
name: string | undefined;
|
||||
onBack: () => void;
|
||||
onClose: () => void;
|
||||
setProvider: (
|
||||
@@ -660,9 +621,7 @@ function PaseoAgentProviderFormSheet({
|
||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
||||
preferredOAuthMode: PaseoAgentOAuthMode;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [models, setModels] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [oauthState, setOAuthState] = useState<OAuthState>({ status: "idle" });
|
||||
@@ -670,15 +629,13 @@ function PaseoAgentProviderFormSheet({
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && entry) {
|
||||
setName(initialName ?? entry.id);
|
||||
setApiKey("");
|
||||
setModels("");
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
setOAuthState({ status: "idle" });
|
||||
bumpResetKey();
|
||||
}
|
||||
}, [entry, initialName, visible]);
|
||||
}, [entry, name, visible]);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
@@ -689,11 +646,8 @@ function PaseoAgentProviderFormSheet({
|
||||
);
|
||||
const apiKeyAuth = entry ? getPaseoAgentApiKeyAuth(entry) : null;
|
||||
const oauthAuth = entry ? getPaseoAgentOAuthAuth(entry) : null;
|
||||
const trimmedName = name.trim();
|
||||
const modelIds = useMemo(() => parsePaseoAgentModelIds(models), [models]);
|
||||
const hasCatalogModels = Boolean(entry && entry.models.length > 0);
|
||||
const hasConfiguredModels = hasCatalogModels || modelIds.length > 0;
|
||||
const canSubmit = Boolean(entry && trimmedName.length > 0 && hasConfiguredModels && !saving);
|
||||
const trimmedName = name?.trim() ?? "";
|
||||
const canSubmit = Boolean(entry && trimmedName.length > 0 && !saving);
|
||||
const activeAuthorization =
|
||||
oauthState.status === "authorizing" ||
|
||||
oauthState.status === "completing" ||
|
||||
@@ -712,7 +666,6 @@ function PaseoAgentProviderFormSheet({
|
||||
entry,
|
||||
name: trimmedName,
|
||||
apiKey,
|
||||
modelIds: hasCatalogModels ? undefined : modelIds,
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
@@ -724,7 +677,7 @@ function PaseoAgentProviderFormSheet({
|
||||
setError(describeError(err, "Failed to save provider"));
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}, [apiKey, canSubmit, entry, hasCatalogModels, modelIds, onClose, setProvider, trimmedName]);
|
||||
}, [apiKey, canSubmit, entry, onClose, setProvider, trimmedName]);
|
||||
|
||||
const handleStartOAuth = useCallback(
|
||||
(mode: PaseoAgentOAuthMode) => {
|
||||
@@ -737,7 +690,6 @@ function PaseoAgentProviderFormSheet({
|
||||
createPaseoAgentProviderInput({
|
||||
entry,
|
||||
name: trimmedName,
|
||||
modelIds: hasCatalogModels ? undefined : modelIds,
|
||||
}),
|
||||
)
|
||||
.then(() => startOAuth(trimmedName, mode))
|
||||
@@ -762,7 +714,7 @@ function PaseoAgentProviderFormSheet({
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
},
|
||||
[canSubmit, entry, hasCatalogModels, modelIds, setProvider, startOAuth, trimmedName],
|
||||
[canSubmit, entry, setProvider, startOAuth, trimmedName],
|
||||
);
|
||||
|
||||
const handleCompleteOAuth = useCallback(() => {
|
||||
@@ -807,20 +759,9 @@ function PaseoAgentProviderFormSheet({
|
||||
onClose={onClose}
|
||||
desktopMaxWidth={480}
|
||||
snapPoints={FORM_SNAP_POINTS}
|
||||
testID="paseo-agent-provider-form"
|
||||
testID={oauthAuth ? "paseo-agent-oauth-sign-in" : "paseo-agent-provider-form"}
|
||||
>
|
||||
<View style={styles.formGroup}>
|
||||
<ProviderNameAndModelsFields
|
||||
entry={entry}
|
||||
name={name}
|
||||
models={models}
|
||||
resetKey={resetKey}
|
||||
lockName={lockName}
|
||||
hasCatalogModels={hasCatalogModels}
|
||||
onNameChange={setName}
|
||||
onModelsChange={setModels}
|
||||
/>
|
||||
|
||||
{apiKeyAuth ? (
|
||||
<ApiKeyFields
|
||||
auth={apiKeyAuth}
|
||||
@@ -873,6 +814,103 @@ function PaseoAgentProviderFormSheet({
|
||||
);
|
||||
}
|
||||
|
||||
function PaseoAgentProviderRenameSheet({
|
||||
provider,
|
||||
visible,
|
||||
onClose,
|
||||
renameProvider,
|
||||
}: {
|
||||
provider: RedactedPaseoAgentProviderConfig | null;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
renameProvider: (
|
||||
input: PaseoAgentRenameProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [resetKey, bumpResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && provider) {
|
||||
setDisplayName(provider.displayName ?? provider.name);
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
bumpResetKey();
|
||||
}
|
||||
}, [provider, visible]);
|
||||
|
||||
const header = useMemo<SheetHeader>(() => ({ title: "Rename provider" }), []);
|
||||
const trimmedDisplayName = displayName.trim();
|
||||
const canSave = Boolean(provider && trimmedDisplayName.length > 0 && !saving);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!provider || !canSave) return;
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
void renameProvider({ name: provider.name, displayName: trimmedDisplayName })
|
||||
.then(() => {
|
||||
onClose();
|
||||
return undefined;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(describeError(err, "Failed to rename provider"));
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}, [canSave, onClose, provider, renameProvider, trimmedDisplayName]);
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
desktopMaxWidth={420}
|
||||
snapPoints={FORM_SNAP_POINTS}
|
||||
testID="paseo-agent-provider-rename-form"
|
||||
>
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.formLabel}>Provider name</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="paseo-agent-provider-display-name"
|
||||
accessibilityLabel="Provider name"
|
||||
initialValue={displayName}
|
||||
resetKey={`paseo-agent-provider-display-name-${resetKey}`}
|
||||
value={displayName}
|
||||
onChangeText={setDisplayName}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.formInput}
|
||||
/>
|
||||
{error ? (
|
||||
<Text style={styles.errorText} testID="paseo-agent-provider-rename-error">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.formActions}>
|
||||
<Button variant="secondary" size="sm" onPress={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={handleSave}
|
||||
disabled={!canSave}
|
||||
loading={saving}
|
||||
testID="paseo-agent-provider-rename-submit"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaseoAgentSettingsSheet({
|
||||
serverId,
|
||||
visible,
|
||||
@@ -890,6 +928,7 @@ export function PaseoAgentSettingsSheet({
|
||||
setProvider,
|
||||
startOAuth,
|
||||
completeOAuth,
|
||||
renameProvider,
|
||||
} = usePaseoAgentProviders(serverId);
|
||||
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
|
||||
const preferredOAuthMode = preferredPaseoAgentOAuthMode(activeConnection);
|
||||
@@ -911,21 +950,31 @@ export function PaseoAgentSettingsSheet({
|
||||
|
||||
const handleOpenPicker = useCallback(() => setAddSheet({ kind: "picker" }), []);
|
||||
const handleCloseSubSheet = useCallback(() => setAddSheet({ kind: "closed" }), []);
|
||||
const handleSelectEntry = useCallback((entry: PaseoAgentCatalogEntry) => {
|
||||
setAddSheet({ kind: "form", entry, lockName: false, returnToPicker: true });
|
||||
}, []);
|
||||
const handleSelectEntry = useCallback(
|
||||
(entry: PaseoAgentCatalogEntry) => {
|
||||
setAddSheet({
|
||||
kind: "form",
|
||||
entry,
|
||||
name: nextPaseoAgentProviderName(entry, providers),
|
||||
returnToPicker: true,
|
||||
});
|
||||
},
|
||||
[providers],
|
||||
);
|
||||
const handleReauth = useCallback(
|
||||
(provider: RedactedPaseoAgentProviderConfig, entry: PaseoAgentCatalogEntry) => {
|
||||
setAddSheet({
|
||||
kind: "form",
|
||||
entry,
|
||||
initialName: provider.name,
|
||||
lockName: true,
|
||||
name: provider.name,
|
||||
returnToPicker: false,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleRename = useCallback((provider: RedactedPaseoAgentProviderConfig) => {
|
||||
setAddSheet({ kind: "rename", provider });
|
||||
}, []);
|
||||
const handleBackFromForm = useCallback(() => {
|
||||
setAddSheet((current) =>
|
||||
current.kind === "form" && current.returnToPicker ? { kind: "picker" } : { kind: "closed" },
|
||||
@@ -983,6 +1032,7 @@ export function PaseoAgentSettingsSheet({
|
||||
catalogEntry={catalogById.get(provider.providerType)}
|
||||
isFirst={index === 0}
|
||||
onReauth={handleReauth}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -1015,8 +1065,7 @@ export function PaseoAgentSettingsSheet({
|
||||
<PaseoAgentProviderFormSheet
|
||||
entry={addSheet.kind === "form" ? addSheet.entry : null}
|
||||
visible={addSheet.kind === "form"}
|
||||
initialName={addSheet.kind === "form" ? addSheet.initialName : undefined}
|
||||
lockName={addSheet.kind === "form" ? addSheet.lockName : false}
|
||||
name={addSheet.kind === "form" ? addSheet.name : undefined}
|
||||
onBack={handleBackFromForm}
|
||||
onClose={handleCloseSubSheet}
|
||||
setProvider={setProvider}
|
||||
@@ -1025,6 +1074,14 @@ export function PaseoAgentSettingsSheet({
|
||||
preferredOAuthMode={preferredOAuthMode}
|
||||
/>
|
||||
) : null}
|
||||
{supported && catalogSupported ? (
|
||||
<PaseoAgentProviderRenameSheet
|
||||
provider={addSheet.kind === "rename" ? addSheet.provider : null}
|
||||
visible={addSheet.kind === "rename"}
|
||||
onClose={handleCloseSubSheet}
|
||||
renameProvider={renameProvider}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1151,10 +1208,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
modelsInput: {
|
||||
minHeight: 80,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
authorizationBox: {
|
||||
gap: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
|
||||
@@ -16,6 +16,16 @@ describe("resolveProviderIconName", () => {
|
||||
expect(resolveProviderIconName("paseo")).toEqual({ kind: "builtin", id: "paseo" });
|
||||
});
|
||||
|
||||
it("resolves Paseo Agent model-provider catalog icon names", () => {
|
||||
expect(resolveProviderIconName("openai")).toEqual({ kind: "builtin", id: "openai" });
|
||||
expect(resolveProviderIconName("openrouter")).toEqual({
|
||||
kind: "builtin",
|
||||
id: "openrouter",
|
||||
});
|
||||
expect(resolveProviderIconName("kimi")).toEqual({ kind: "catalog", id: "kimi" });
|
||||
expect(resolveProviderIconName("opencode")).toEqual({ kind: "builtin", id: "opencode" });
|
||||
});
|
||||
|
||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
|
||||
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { OpenRouterIcon } from "@/components/icons/openrouter-icon";
|
||||
import { OmpIcon } from "@/components/icons/omp-icon";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { PiIcon } from "@/components/icons/pi-icon";
|
||||
@@ -26,7 +27,9 @@ const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
|
||||
kiro: PackagePlus,
|
||||
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
|
||||
omp: OmpIcon as unknown as ProviderIconComponent,
|
||||
openai: CodexIcon as unknown as ProviderIconComponent,
|
||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||
openrouter: OpenRouterIcon as unknown as ProviderIconComponent,
|
||||
paseo: PaseoLogo as unknown as ProviderIconComponent,
|
||||
pi: PiIcon as unknown as ProviderIconComponent,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ProviderSettingsHost() {
|
||||
}
|
||||
|
||||
if (provider === PASEO_AGENT_PROVIDER) {
|
||||
return <PaseoAgentSettingsSheet serverId={serverId} visible onClose={handleClose} />;
|
||||
return <PaseoAgentSettingsSheet serverId={serverId} visible={visible} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
PaseoAgentOAuthCompleteResponse,
|
||||
PaseoAgentOAuthStartResponse,
|
||||
PaseoAgentRenameProviderRequest,
|
||||
PaseoAgentSetProviderRequest,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
@@ -27,6 +28,10 @@ function describeQueryError(error: unknown): string | null {
|
||||
}
|
||||
|
||||
export type PaseoAgentSetProviderInput = Omit<PaseoAgentSetProviderRequest, "type" | "requestId">;
|
||||
export type PaseoAgentRenameProviderInput = Omit<
|
||||
PaseoAgentRenameProviderRequest,
|
||||
"type" | "requestId"
|
||||
>;
|
||||
export type PaseoAgentOAuthStartResult = PaseoAgentOAuthStartResponse["payload"];
|
||||
export type PaseoAgentOAuthCompleteResult = PaseoAgentOAuthCompleteResponse["payload"];
|
||||
|
||||
@@ -44,6 +49,9 @@ interface UsePaseoAgentProvidersResult {
|
||||
setProvider: (
|
||||
input: PaseoAgentSetProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
renameProvider: (
|
||||
input: PaseoAgentRenameProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
|
||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
||||
}
|
||||
@@ -120,6 +128,28 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr
|
||||
[setProviderAsync],
|
||||
);
|
||||
|
||||
const renameProviderMutation = useMutation({
|
||||
mutationFn: async (input: PaseoAgentRenameProviderInput) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.renamePaseoAgentProvider(input);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result.provider;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: renameProviderAsync } = renameProviderMutation;
|
||||
|
||||
const renameProvider = useCallback(
|
||||
(input: PaseoAgentRenameProviderInput) => renameProviderAsync(input),
|
||||
[renameProviderAsync],
|
||||
);
|
||||
|
||||
const startOAuthMutation = useMutation({
|
||||
mutationFn: async (input: { name: string; mode?: string }) => {
|
||||
if (!client) {
|
||||
@@ -173,6 +203,7 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr
|
||||
catalogError,
|
||||
refresh,
|
||||
setProvider,
|
||||
renameProvider,
|
||||
startOAuth,
|
||||
completeOAuth,
|
||||
};
|
||||
|
||||
@@ -103,6 +103,8 @@ import type {
|
||||
PaseoAgentOAuthCredential,
|
||||
PaseoAgentOAuthStartResponse,
|
||||
PaseoAgentOAuthStoreCredentialResponse,
|
||||
PaseoAgentRenameProviderRequest,
|
||||
PaseoAgentRenameProviderResponse,
|
||||
PaseoAgentRemoveProviderResponse,
|
||||
PaseoAgentSetProviderRequest,
|
||||
PaseoAgentSetProviderResponse,
|
||||
@@ -3905,6 +3907,7 @@ export class DaemonClient {
|
||||
type: "config.paseo_agent.set_provider.request",
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
...(input.displayName ? { displayName: input.displayName } : {}),
|
||||
options: input.options,
|
||||
},
|
||||
timeout: 30000,
|
||||
@@ -3925,6 +3928,20 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async renamePaseoAgentProvider(
|
||||
input: Omit<PaseoAgentRenameProviderRequest, "type" | "requestId"> & { requestId?: string },
|
||||
): Promise<PaseoAgentRenameProviderResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: input.requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.rename_provider.request",
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async startPaseoAgentOAuth(
|
||||
name: string,
|
||||
options?: string | { mode?: string; requestId?: string },
|
||||
|
||||
@@ -228,6 +228,35 @@ describe("Paseo Agent config RPC schemas", () => {
|
||||
expect(JSON.stringify(parsed)).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("parses provider rename request and response with display name", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.rename_provider.request",
|
||||
requestId: "req-rename-provider",
|
||||
name: "subscription",
|
||||
displayName: "Work account",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.rename_provider.response",
|
||||
payload: {
|
||||
requestId: "req-rename-provider",
|
||||
success: true,
|
||||
provider: {
|
||||
name: "subscription",
|
||||
displayName: "Work account",
|
||||
providerType: "chatgpt",
|
||||
models: [{ id: "gpt-5.4-mini" }],
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.displayName).toBe("Work account");
|
||||
expect(response.payload.provider?.displayName).toBe("Work account");
|
||||
});
|
||||
|
||||
test("parses ChatGPT provider config separately from credential storage", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.set_provider.request",
|
||||
|
||||
@@ -2004,6 +2004,7 @@ export const PaseoAgentProviderAuthStateSchema = z
|
||||
export const RedactedPaseoAgentProviderConfigSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
displayName: z.string().min(1).optional(),
|
||||
providerType: PaseoAgentProviderTypeSchema,
|
||||
baseUrl: z.string().optional(),
|
||||
api: z.string().optional(),
|
||||
@@ -2028,6 +2029,7 @@ export const PaseoAgentSetProviderRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.set_provider.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1).optional(),
|
||||
providerType: PaseoAgentProviderTypeSchema,
|
||||
options: PaseoAgentSetProviderOptionsSchema,
|
||||
});
|
||||
@@ -2038,6 +2040,13 @@ export const PaseoAgentRemoveProviderRequestSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const PaseoAgentRenameProviderRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.rename_provider.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthStartRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.start.request"),
|
||||
requestId: z.string(),
|
||||
@@ -2213,6 +2222,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoAgentGetCatalogRequestSchema,
|
||||
PaseoAgentSetProviderRequestSchema,
|
||||
PaseoAgentRemoveProviderRequestSchema,
|
||||
PaseoAgentRenameProviderRequestSchema,
|
||||
PaseoAgentOAuthStartRequestSchema,
|
||||
PaseoAgentOAuthCompleteRequestSchema,
|
||||
PaseoAgentOAuthStoreCredentialRequestSchema,
|
||||
@@ -4056,6 +4066,16 @@ export const PaseoAgentRemoveProviderResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentRenameProviderResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.rename_provider.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
provider: RedactedPaseoAgentProviderConfigSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
const PaseoAgentOAuthStartAuthorizationSchema = z
|
||||
.object({
|
||||
kind: z.string().min(1),
|
||||
@@ -4500,6 +4520,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoAgentGetCatalogResponseSchema,
|
||||
PaseoAgentSetProviderResponseSchema,
|
||||
PaseoAgentRemoveProviderResponseSchema,
|
||||
PaseoAgentRenameProviderResponseSchema,
|
||||
PaseoAgentOAuthStartResponseSchema,
|
||||
PaseoAgentOAuthCompleteResponseSchema,
|
||||
PaseoAgentOAuthStoreCredentialResponseSchema,
|
||||
@@ -4647,6 +4668,9 @@ export type PaseoAgentSetProviderResponse = z.infer<typeof PaseoAgentSetProvider
|
||||
export type PaseoAgentRemoveProviderResponse = z.infer<
|
||||
typeof PaseoAgentRemoveProviderResponseSchema
|
||||
>;
|
||||
export type PaseoAgentRenameProviderResponse = z.infer<
|
||||
typeof PaseoAgentRenameProviderResponseSchema
|
||||
>;
|
||||
export type PaseoAgentOAuthStartResponse = z.infer<typeof PaseoAgentOAuthStartResponseSchema>;
|
||||
export type PaseoAgentOAuthCompleteResponse = z.infer<typeof PaseoAgentOAuthCompleteResponseSchema>;
|
||||
export type PaseoAgentOAuthStoreCredentialResponse = z.infer<
|
||||
@@ -4732,6 +4756,7 @@ export type PaseoAgentGetProvidersRequest = z.infer<typeof PaseoAgentGetProvider
|
||||
export type PaseoAgentGetCatalogRequest = z.infer<typeof PaseoAgentGetCatalogRequestSchema>;
|
||||
export type PaseoAgentSetProviderRequest = z.infer<typeof PaseoAgentSetProviderRequestSchema>;
|
||||
export type PaseoAgentRemoveProviderRequest = z.infer<typeof PaseoAgentRemoveProviderRequestSchema>;
|
||||
export type PaseoAgentRenameProviderRequest = z.infer<typeof PaseoAgentRenameProviderRequestSchema>;
|
||||
export type PaseoAgentOAuthStartRequest = z.infer<typeof PaseoAgentOAuthStartRequestSchema>;
|
||||
export type PaseoAgentOAuthCompleteRequest = z.infer<typeof PaseoAgentOAuthCompleteRequestSchema>;
|
||||
export type PaseoAgentOAuthStoreCredentialRequest = z.infer<
|
||||
|
||||
@@ -5,7 +5,9 @@ export const BUILTIN_PROVIDER_ICON_NAMES = [
|
||||
"kiro",
|
||||
"minimax",
|
||||
"omp",
|
||||
"openai",
|
||||
"opencode",
|
||||
"openrouter",
|
||||
"paseo",
|
||||
"pi",
|
||||
];
|
||||
|
||||
@@ -30,6 +30,7 @@ export const PASEO_AGENT_PROVIDER_CATALOG = [
|
||||
id: "openrouter",
|
||||
piProvider: "openrouter",
|
||||
label: "OpenRouter",
|
||||
iconName: "openrouter",
|
||||
auth: { kind: "api_key", envVar: "OPENROUTER_API_KEY" },
|
||||
defaultModels: false,
|
||||
},
|
||||
@@ -43,12 +44,14 @@ export const PASEO_AGENT_PROVIDER_CATALOG = [
|
||||
id: "kimi",
|
||||
piProvider: "kimi-coding",
|
||||
label: "Kimi Coding Plan",
|
||||
iconName: "kimi",
|
||||
auth: { kind: "api_key", envVar: "KIMI_API_KEY" },
|
||||
},
|
||||
{
|
||||
id: "opencode-go",
|
||||
piProvider: "opencode-go",
|
||||
label: "OpenCode Go",
|
||||
iconName: "opencode",
|
||||
auth: { kind: "api_key", envVar: "OPENCODE_API_KEY" },
|
||||
},
|
||||
] as const satisfies readonly PaseoAgentCatalogRef[];
|
||||
|
||||
@@ -321,6 +321,41 @@ describe("PaseoAgentConfigService", () => {
|
||||
expect(JSON.stringify(providers)).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("renames the provider display name without moving stored OAuth credentials", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "subscription",
|
||||
providerType: "chatgpt",
|
||||
options: {},
|
||||
});
|
||||
service.storeOAuthCredential("subscription", {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
});
|
||||
|
||||
const provider = service.renameProvider("subscription", "Work account");
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
name: "subscription",
|
||||
displayName: "Work account",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
available: true,
|
||||
});
|
||||
const stored = JSON.parse(
|
||||
readFileSync(paseoAgentAuthStoragePath({ PASEO_HOME: home }), "utf8"),
|
||||
);
|
||||
expect(Object.keys(stored)).toEqual(["subscription"]);
|
||||
expect(stored.subscription).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
test("reports OAuth as missing or needing attention for absent and mismatched credentials", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
|
||||
@@ -40,6 +40,7 @@ interface PaseoAgentConfigServiceOptions {
|
||||
interface SetProviderInput {
|
||||
name: string;
|
||||
providerType: string;
|
||||
displayName?: string;
|
||||
options: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
@@ -170,6 +171,9 @@ function redactedProviders(
|
||||
available: auth.configured,
|
||||
error: null,
|
||||
};
|
||||
if (entry.displayName) {
|
||||
provider.displayName = entry.displayName;
|
||||
}
|
||||
provider.baseUrl = settings.baseUrl;
|
||||
provider.api = settings.api;
|
||||
return provider;
|
||||
@@ -223,6 +227,9 @@ export class PaseoAgentConfigService {
|
||||
...current.providers,
|
||||
[input.name]: {
|
||||
type: catalogEntry.id,
|
||||
...((input.displayName ?? current.providers?.[input.name]?.displayName)
|
||||
? { displayName: input.displayName ?? current.providers?.[input.name]?.displayName }
|
||||
: {}),
|
||||
options: providerOptionsForPersist(input.options, catalogEntry),
|
||||
},
|
||||
},
|
||||
@@ -231,6 +238,27 @@ export class PaseoAgentConfigService {
|
||||
return this.requireRedactedProvider(next, input.name);
|
||||
}
|
||||
|
||||
renameProvider(name: string, displayName: string): RedactedPaseoAgentProviderConfig {
|
||||
const trimmedDisplayName = displayName.trim();
|
||||
const next = this.updateConfig((current) => {
|
||||
const provider = current.providers?.[name];
|
||||
if (!provider) {
|
||||
throw new Error(`Paseo Agent provider '${name}' is not configured.`);
|
||||
}
|
||||
return PaseoAgentConfigSchema.parse({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
[name]: {
|
||||
...provider,
|
||||
displayName: trimmedDisplayName,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return this.requireRedactedProvider(next, name);
|
||||
}
|
||||
|
||||
removeProvider(name: string): boolean {
|
||||
let removed = false;
|
||||
this.updateConfig((current) => {
|
||||
|
||||
@@ -154,6 +154,21 @@ describe("resolvePaseoAgentCatalogAuth", () => {
|
||||
restoreEnvVars(previousValues);
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns icon names to every Paseo Agent catalog entry", () => {
|
||||
const iconNames = new Map(
|
||||
PASEO_AGENT_PROVIDER_CATALOG.map((entry) => [entry.id, entry.iconName]),
|
||||
);
|
||||
|
||||
expect(iconNames).toEqual(
|
||||
new Map([
|
||||
["openrouter", "openrouter"],
|
||||
["chatgpt", "openai"],
|
||||
["kimi", "kimi"],
|
||||
["opencode-go", "opencode"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listPaseoAgentModels", () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ const PaseoAgentProviderOptionsSchema = z
|
||||
const PaseoAgentModelProviderSchema = z
|
||||
.object({
|
||||
type: z.string().min(1),
|
||||
displayName: z.string().min(1).optional(),
|
||||
options: PaseoAgentProviderOptionsSchema.default({}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -1808,6 +1808,8 @@ export class Session {
|
||||
return this.handlePaseoAgentSetProviderRequest(msg);
|
||||
case "config.paseo_agent.remove_provider.request":
|
||||
return this.handlePaseoAgentRemoveProviderRequest(msg);
|
||||
case "config.paseo_agent.rename_provider.request":
|
||||
return this.handlePaseoAgentRenameProviderRequest(msg);
|
||||
case "config.paseo_agent.oauth.start.request":
|
||||
return this.handlePaseoAgentOAuthStartRequest(msg);
|
||||
case "config.paseo_agent.oauth.complete.request":
|
||||
@@ -1931,6 +1933,7 @@ export class Session {
|
||||
try {
|
||||
const provider = this.createPaseoAgentConfigService().setProvider({
|
||||
name: msg.name,
|
||||
displayName: msg.displayName,
|
||||
providerType: msg.providerType,
|
||||
options: msg.options,
|
||||
});
|
||||
@@ -1993,6 +1996,41 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentRenameProviderRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.rename_provider.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const provider = this.createPaseoAgentConfigService().renameProvider(
|
||||
msg.name,
|
||||
msg.displayName,
|
||||
);
|
||||
await this.refreshPaseoAgentRuntimeSnapshot();
|
||||
this.emit({
|
||||
type: "config.paseo_agent.rename_provider.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: true,
|
||||
provider,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, providerName: msg.name },
|
||||
"Failed to rename Paseo Agent provider",
|
||||
);
|
||||
this.emit({
|
||||
type: "config.paseo_agent.rename_provider.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: false,
|
||||
provider: null,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentOAuthStartRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.oauth.start.request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user