mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 12:42:57 +00:00
feat(app): add Paseo Agent provider catalog UI
This commit is contained in:
@@ -15,16 +15,22 @@ type PaseoAgentDaemonClient = Pick<
|
||||
| "storePaseoAgentOAuthCredential"
|
||||
>;
|
||||
|
||||
interface OpenRouterProviderInput {
|
||||
interface ApiKeyProviderInput {
|
||||
catalogId: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
models: string[];
|
||||
models?: string[];
|
||||
}
|
||||
|
||||
interface OAuthProviderInput {
|
||||
catalogId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ExpectedProvider {
|
||||
name: string;
|
||||
providerType: "openai-codex" | "openrouter";
|
||||
auth: "API key configured" | "ChatGPT login stored";
|
||||
providerLabel: string;
|
||||
auth: "Connected" | "Needs attention";
|
||||
modelCount: number;
|
||||
}
|
||||
|
||||
@@ -42,22 +48,38 @@ export async function openPaseoAgentSettings(page: Page): Promise<void> {
|
||||
await expect(sheet.getByText("Paseo Agent", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addOpenRouterProvider(
|
||||
page: Page,
|
||||
provider: OpenRouterProviderInput,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add OpenRouter", exact: true }).click();
|
||||
await expect(page.getByText("Add OpenRouter provider", { exact: true })).toBeVisible();
|
||||
export async function addApiKeyProvider(page: Page, provider: ApiKeyProviderInput): Promise<void> {
|
||||
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 page.getByLabel("OpenRouter API key").fill(provider.apiKey);
|
||||
await page.getByLabel("OpenRouter models").fill(provider.models.join("\n"));
|
||||
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.getByText("Add OpenRouter provider", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByText(provider.apiKey, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function startOAuthProviderSignIn(
|
||||
page: Page,
|
||||
provider: OAuthProviderInput,
|
||||
): Promise<void> {
|
||||
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 page.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-oauth-user-code")).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-oauth-verification-link")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectModelProviderListed(
|
||||
page: Page,
|
||||
expected: ExpectedProvider,
|
||||
@@ -66,7 +88,7 @@ export async function expectModelProviderListed(
|
||||
await expect(
|
||||
page.getByRole("listitem", {
|
||||
name: new RegExp(
|
||||
`${expected.name}.*${expected.providerType}.*${modelLabel}.*${expected.auth}`,
|
||||
`${expected.name}.*${expected.providerLabel}.*${modelLabel}.*${expected.auth}`,
|
||||
),
|
||||
}),
|
||||
).toBeVisible();
|
||||
@@ -77,7 +99,7 @@ export async function seedChatGptProvider(providerName: string): Promise<void> {
|
||||
try {
|
||||
await client.setPaseoAgentProvider({
|
||||
name: providerName,
|
||||
providerType: "openai-codex",
|
||||
providerType: "chatgpt",
|
||||
options: {
|
||||
models: [{ id: "gpt-5.3-codex", reasoning: true }],
|
||||
},
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
addOpenRouterProvider,
|
||||
addApiKeyProvider,
|
||||
cleanupPaseoAgentProviders,
|
||||
expectModelProviderListed,
|
||||
openPaseoAgentSettings,
|
||||
seedChatGptProvider,
|
||||
startOAuthProviderSignIn,
|
||||
} from "./helpers/paseo-agent";
|
||||
|
||||
const OPENROUTER_PROVIDER = "phase-e-openrouter-ui";
|
||||
@@ -22,7 +23,8 @@ test.describe("Paseo Agent provider configuration", () => {
|
||||
providerNamesToCleanup.add(OPENROUTER_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await addOpenRouterProvider(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"],
|
||||
@@ -30,9 +32,19 @@ test.describe("Paseo Agent provider configuration", () => {
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: OPENROUTER_PROVIDER,
|
||||
providerType: "openrouter",
|
||||
providerLabel: "OpenRouter",
|
||||
modelCount: 2,
|
||||
auth: "API key configured",
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("starts a ChatGPT sign-in from Settings", async ({ page }) => {
|
||||
providerNamesToCleanup.add(CHATGPT_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await startOAuthProviderSignIn(page, {
|
||||
catalogId: "chatgpt",
|
||||
name: CHATGPT_PROVIDER,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,9 +56,9 @@ test.describe("Paseo Agent provider configuration", () => {
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: CHATGPT_PROVIDER,
|
||||
providerType: "openai-codex",
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "ChatGPT login stored",
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +1,171 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
import {
|
||||
createOpenRouterProviderInput,
|
||||
createPaseoAgentProviderInput,
|
||||
getPaseoAgentApiKeyAuth,
|
||||
getPaseoAgentOAuthAuth,
|
||||
isPaseoAgentCatalogEntrySupported,
|
||||
parsePaseoAgentModelIds,
|
||||
paseoAgentAuthLabel,
|
||||
paseoAgentAuthBadge,
|
||||
paseoAgentProviderLabel,
|
||||
} from "./paseo-agent-settings-sheet-model";
|
||||
|
||||
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
|
||||
return {
|
||||
id: "catalog-alpha",
|
||||
label: "Catalog Alpha",
|
||||
api: "responses",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function providerConfig(
|
||||
overrides: Partial<RedactedPaseoAgentProviderConfig>,
|
||||
): RedactedPaseoAgentProviderConfig {
|
||||
return {
|
||||
name: "catalog-alpha",
|
||||
providerType: "catalog-alpha",
|
||||
models: [{ id: "alpha-fast" }],
|
||||
available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("paseo-agent-settings-sheet-model", () => {
|
||||
it("recognizes supported catalog auth manifests", () => {
|
||||
const apiKeyEntry = catalogEntry({
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
envVar: "ALPHA_API_KEY",
|
||||
keyUrl: "https://alpha.example.test/key",
|
||||
placeholder: "alpha-key",
|
||||
hint: "Paste the key from Alpha",
|
||||
},
|
||||
});
|
||||
const oauthEntry = catalogEntry({
|
||||
auth: { kind: "oauth", flow: "alpha-oauth" },
|
||||
});
|
||||
|
||||
expect(getPaseoAgentApiKeyAuth(apiKeyEntry)).toEqual({
|
||||
kind: "api_key",
|
||||
envVar: "ALPHA_API_KEY",
|
||||
keyUrl: "https://alpha.example.test/key",
|
||||
placeholder: "alpha-key",
|
||||
hint: "Paste the key from Alpha",
|
||||
});
|
||||
expect(getPaseoAgentOAuthAuth(oauthEntry)).toEqual({
|
||||
kind: "oauth",
|
||||
flow: "alpha-oauth",
|
||||
});
|
||||
expect(isPaseoAgentCatalogEntrySupported(apiKeyEntry)).toBe(true);
|
||||
expect(isPaseoAgentCatalogEntrySupported(oauthEntry)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps unknown catalog auth kinds visible but unsupported", () => {
|
||||
const entry = catalogEntry({ auth: { kind: "future_auth", prompt: "later" } });
|
||||
|
||||
expect(getPaseoAgentApiKeyAuth(entry)).toBeNull();
|
||||
expect(getPaseoAgentOAuthAuth(entry)).toBeNull();
|
||||
expect(isPaseoAgentCatalogEntrySupported(entry)).toBe(false);
|
||||
});
|
||||
|
||||
it("parses model ids from comma and newline separated input", () => {
|
||||
expect(
|
||||
parsePaseoAgentModelIds(`
|
||||
anthropic/claude-3.7-sonnet, openai/gpt-4o
|
||||
anthropic/claude-3.7-sonnet
|
||||
openai/gpt-4o-mini
|
||||
alpha/fast, beta/steady
|
||||
alpha/fast
|
||||
gamma/deep
|
||||
`),
|
||||
).toEqual(["anthropic/claude-3.7-sonnet", "openai/gpt-4o", "openai/gpt-4o-mini"]);
|
||||
).toEqual(["alpha/fast", "beta/steady", "gamma/deep"]);
|
||||
});
|
||||
|
||||
it("builds the OpenRouter provider payload without an empty api key", () => {
|
||||
it("builds a generic provider payload with a trimmed explicit key", () => {
|
||||
expect(
|
||||
createOpenRouterProviderInput({
|
||||
name: " openrouter-main ",
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ id: "catalog-beta" }),
|
||||
name: " beta-main ",
|
||||
apiKey: " beta-secret ",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "beta-main",
|
||||
providerType: "catalog-beta",
|
||||
options: {
|
||||
apiKey: "beta-secret",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with custom model ids", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ models: [] }),
|
||||
name: "alpha-main",
|
||||
apiKey: "alpha-secret",
|
||||
modelIds: ["alpha/fast", "beta/steady"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "alpha-main",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
models: [{ id: "alpha/fast" }, { id: "beta/steady" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with an env reference for an empty key", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ auth: { kind: "api_key", envVar: "BETA_API_KEY" } }),
|
||||
name: "beta-main",
|
||||
apiKey: " ",
|
||||
modelIds: ["openai/gpt-4o-mini"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
name: "beta-main",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
models: [{ id: "openai/gpt-4o-mini" }],
|
||||
apiKey: "$BETA_API_KEY",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds the OpenRouter provider payload with a trimmed api key", () => {
|
||||
it("builds an oauth provider payload without key material", () => {
|
||||
expect(
|
||||
createOpenRouterProviderInput({
|
||||
name: "openrouter-main",
|
||||
apiKey: " sk-or-secret ",
|
||||
modelIds: ["openai/gpt-4o-mini", "anthropic/claude-3.7-sonnet"],
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ auth: { kind: "oauth", flow: "alpha-oauth" } }),
|
||||
name: "alpha-login",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
name: "alpha-login",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "sk-or-secret",
|
||||
models: [{ id: "openai/gpt-4o-mini" }, { id: "anthropic/claude-3.7-sonnet" }],
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("describes the provider auth state", () => {
|
||||
expect(paseoAgentAuthLabel({ kind: "api_key", configured: true })).toBe("API key configured");
|
||||
expect(paseoAgentAuthLabel({ kind: "api_key", configured: false })).toBe("API key required");
|
||||
expect(paseoAgentAuthLabel({ kind: "oauth", configured: true })).toBe("ChatGPT login stored");
|
||||
expect(paseoAgentAuthLabel({ kind: "oauth", configured: false })).toBe("Login required");
|
||||
expect(paseoAgentAuthLabel({ kind: "none", configured: false })).toBe("No auth");
|
||||
it("uses catalog labels and generic auth badges for instance rows", () => {
|
||||
expect(paseoAgentProviderLabel(providerConfig({}), catalogEntry({}))).toBe("Catalog Alpha");
|
||||
expect(
|
||||
paseoAgentProviderLabel(providerConfig({ providerType: "custom-alpha" }), undefined),
|
||||
).toBe("custom-alpha");
|
||||
expect(paseoAgentAuthBadge({ kind: "api_key", configured: true })).toEqual({
|
||||
label: "Connected",
|
||||
variant: "success",
|
||||
});
|
||||
expect(paseoAgentAuthBadge({ kind: "oauth", configured: false })).toEqual({
|
||||
label: "Needs attention",
|
||||
variant: "error",
|
||||
});
|
||||
expect(paseoAgentAuthBadge(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,71 @@
|
||||
import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PaseoAgentSetProviderInput } from "@/hooks/use-paseo-agent-providers";
|
||||
|
||||
export function paseoAgentAuthLabel(auth: RedactedPaseoAgentProviderConfig["auth"]): string {
|
||||
if (!auth) {
|
||||
return "Auth state unavailable";
|
||||
export interface PaseoAgentApiKeyAuthManifest {
|
||||
kind: "api_key";
|
||||
envVar: string;
|
||||
keyUrl?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentOAuthAuthManifest {
|
||||
kind: "oauth";
|
||||
flow: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentAuthBadge {
|
||||
label: string;
|
||||
variant: "success" | "error" | "muted";
|
||||
}
|
||||
|
||||
export function getPaseoAgentApiKeyAuth(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
): PaseoAgentApiKeyAuthManifest | null {
|
||||
if (entry.auth.kind !== "api_key" || typeof entry.auth.envVar !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (auth.kind === "oauth") {
|
||||
return auth.configured ? "ChatGPT login stored" : "Login required";
|
||||
return {
|
||||
kind: "api_key",
|
||||
envVar: entry.auth.envVar,
|
||||
...(typeof entry.auth.keyUrl === "string" ? { keyUrl: entry.auth.keyUrl } : {}),
|
||||
...(typeof entry.auth.placeholder === "string" ? { placeholder: entry.auth.placeholder } : {}),
|
||||
...(typeof entry.auth.hint === "string" ? { hint: entry.auth.hint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPaseoAgentOAuthAuth(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
): PaseoAgentOAuthAuthManifest | null {
|
||||
if (entry.auth.kind !== "oauth" || typeof entry.auth.flow !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (auth.kind === "none") {
|
||||
return "No auth";
|
||||
return { kind: "oauth", flow: entry.auth.flow };
|
||||
}
|
||||
|
||||
export function isPaseoAgentCatalogEntrySupported(entry: PaseoAgentCatalogEntry): boolean {
|
||||
return getPaseoAgentApiKeyAuth(entry) !== null || getPaseoAgentOAuthAuth(entry) !== null;
|
||||
}
|
||||
|
||||
export function paseoAgentProviderLabel(
|
||||
provider: RedactedPaseoAgentProviderConfig,
|
||||
catalogEntry: PaseoAgentCatalogEntry | undefined,
|
||||
): string {
|
||||
return catalogEntry?.label ?? provider.providerType;
|
||||
}
|
||||
|
||||
export function paseoAgentAuthBadge(
|
||||
auth: RedactedPaseoAgentProviderConfig["auth"],
|
||||
): PaseoAgentAuthBadge | null {
|
||||
if (!auth || auth.kind === "none") {
|
||||
return null;
|
||||
}
|
||||
return auth.configured ? "API key configured" : "API key required";
|
||||
return auth.configured
|
||||
? { label: "Connected", variant: "success" }
|
||||
: { label: "Needs attention", variant: "error" };
|
||||
}
|
||||
|
||||
export function parsePaseoAgentModelIds(raw: string): string[] {
|
||||
@@ -27,18 +81,29 @@ export function parsePaseoAgentModelIds(raw: string): string[] {
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function createOpenRouterProviderInput(input: {
|
||||
export function createPaseoAgentProviderInput(input: {
|
||||
entry: PaseoAgentCatalogEntry;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
modelIds: string[];
|
||||
apiKey?: string;
|
||||
modelIds?: string[];
|
||||
}): PaseoAgentSetProviderInput {
|
||||
const trimmedKey = input.apiKey.trim();
|
||||
const apiKeyAuth = getPaseoAgentApiKeyAuth(input.entry);
|
||||
const trimmedKey = input.apiKey?.trim() ?? "";
|
||||
let apiKey: string | undefined;
|
||||
if (apiKeyAuth) {
|
||||
apiKey = trimmedKey.length > 0 ? trimmedKey : `$${apiKeyAuth.envVar}`;
|
||||
}
|
||||
const models =
|
||||
input.modelIds && input.modelIds.length > 0
|
||||
? input.modelIds.map((id) => ({ id }))
|
||||
: input.entry.models.map((model) => ({ ...model }));
|
||||
|
||||
return {
|
||||
name: input.name.trim(),
|
||||
providerType: "openrouter",
|
||||
providerType: input.entry.id,
|
||||
options: {
|
||||
models: input.modelIds.map((id) => ({ id })),
|
||||
...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : {}),
|
||||
models,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { type ReactNode } from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
PaseoAgentOAuthCompleteResult,
|
||||
PaseoAgentOAuthStartResult,
|
||||
PaseoAgentSetProviderInput,
|
||||
} from "@/hooks/use-paseo-agent-providers";
|
||||
|
||||
interface PaseoAgentProvidersHookMock {
|
||||
supported: boolean;
|
||||
catalogSupported: boolean;
|
||||
providers: RedactedPaseoAgentProviderConfig[];
|
||||
catalog: PaseoAgentCatalogEntry[];
|
||||
defaultModel: string | null;
|
||||
isLoading: boolean;
|
||||
isCatalogLoading: boolean;
|
||||
error: string | null;
|
||||
catalogError: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
setProvider: (
|
||||
input: PaseoAgentSetProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
startOAuth: (name: string) => Promise<PaseoAgentOAuthStartResult>;
|
||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
||||
}
|
||||
|
||||
const { hookState, theme } = vi.hoisted(() => {
|
||||
const initialHookState: { current: PaseoAgentProvidersHookMock } = {
|
||||
current: {
|
||||
supported: true,
|
||||
catalogSupported: true,
|
||||
providers: [] as RedactedPaseoAgentProviderConfig[],
|
||||
catalog: [] as PaseoAgentCatalogEntry[],
|
||||
defaultModel: null as string | null,
|
||||
isLoading: false,
|
||||
isCatalogLoading: false,
|
||||
error: null as string | null,
|
||||
catalogError: null as string | null,
|
||||
refresh: vi.fn(async () => undefined),
|
||||
setProvider: vi.fn(async () => null as RedactedPaseoAgentProviderConfig | null),
|
||||
startOAuth: vi.fn(
|
||||
async (): Promise<PaseoAgentOAuthStartResult> => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: null,
|
||||
error: null,
|
||||
}),
|
||||
),
|
||||
completeOAuth: vi.fn(
|
||||
async (): Promise<PaseoAgentOAuthCompleteResult> => ({
|
||||
requestId: "oauth-complete",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
auth: { kind: "oauth", configured: true },
|
||||
error: null,
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
hookState: initialHookState,
|
||||
theme: {
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||
borderRadius: { md: 6, lg: 8 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400", medium: "500" },
|
||||
opacity: { 50: 0.5 },
|
||||
colors: {
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
border: "#444",
|
||||
destructive: "#f00",
|
||||
statusSuccess: "#0f0",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
View: ({
|
||||
children,
|
||||
testID,
|
||||
role,
|
||||
accessibilityRole,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
testID?: string;
|
||||
role?: string;
|
||||
accessibilityRole?: string;
|
||||
accessibilityLabel?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{
|
||||
"data-testid": testID,
|
||||
role: role ?? accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
},
|
||||
children,
|
||||
),
|
||||
Text: ({ children, testID }: { children?: ReactNode; testID?: string; numberOfLines?: number }) =>
|
||||
React.createElement("span", { "data-testid": testID }, children),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
withUnistyles:
|
||||
(Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
({
|
||||
uniProps,
|
||||
...rest
|
||||
}: {
|
||||
uniProps?: (theme: unknown) => Record<string, unknown>;
|
||||
} & Record<string, unknown>) => {
|
||||
const themed = uniProps ? uniProps(theme) : {};
|
||||
return React.createElement(Component, { ...rest, ...themed });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => {
|
||||
const Icon = () => React.createElement("span", { "data-icon": name });
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
Bot: icon("Bot"),
|
||||
Plus: icon("Plus"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
AdaptiveModalSheet: ({
|
||||
visible,
|
||||
header,
|
||||
children,
|
||||
footer,
|
||||
testID,
|
||||
}: {
|
||||
visible: boolean;
|
||||
header?: { title: string; back?: { label?: string; onPress: () => void } };
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
testID?: string;
|
||||
}) =>
|
||||
visible ? (
|
||||
<section data-testid={testID}>
|
||||
<h1>{header?.title}</h1>
|
||||
{header?.back ? (
|
||||
<button type="button" onClick={header.back.onPress}>
|
||||
{header.back.label ?? "Back"}
|
||||
</button>
|
||||
) : null}
|
||||
{children}
|
||||
{footer ? <footer>{footer}</footer> : null}
|
||||
</section>
|
||||
) : null,
|
||||
AdaptiveTextInput: ({
|
||||
value,
|
||||
onChangeText,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
placeholder,
|
||||
secureTextEntry,
|
||||
editable = true,
|
||||
multiline,
|
||||
}: {
|
||||
value?: string;
|
||||
onChangeText?: (value: string) => void;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
placeholder?: string;
|
||||
secureTextEntry?: boolean;
|
||||
editable?: boolean;
|
||||
multiline?: boolean;
|
||||
}) => {
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {
|
||||
onChangeText?.(event.currentTarget.value);
|
||||
}
|
||||
|
||||
const inputProps = {
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
disabled: !editable,
|
||||
onChange: handleChange,
|
||||
placeholder,
|
||||
value: value ?? "",
|
||||
};
|
||||
|
||||
if (multiline) {
|
||||
return React.createElement("textarea", inputProps);
|
||||
}
|
||||
|
||||
return React.createElement("input", {
|
||||
...inputProps,
|
||||
type: secureTextEntry ? "password" : "text",
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
getProviderIcon: (provider: string) => () =>
|
||||
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icon-name", () => ({
|
||||
resolveProviderIconName: (provider: string) =>
|
||||
provider === "known-icon" ? { kind: "builtin", id: provider } : { kind: "bot" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/external-link", () => ({
|
||||
ExternalLink: ({
|
||||
href,
|
||||
label,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
}) => (
|
||||
<a href={href} data-testid={testID} aria-label={accessibilityLabel ?? label}>
|
||||
{label}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onPress?: () => void;
|
||||
testID?: string;
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} data-testid={testID} onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/status-badge", () => ({
|
||||
StatusBadge: ({ label }: { label: string }) => <span>{label}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-paseo-agent-providers", () => ({
|
||||
usePaseoAgentProviders: () => hookState.current,
|
||||
}));
|
||||
|
||||
import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet";
|
||||
|
||||
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
|
||||
return {
|
||||
id: "catalog-alpha",
|
||||
label: "Catalog Alpha",
|
||||
iconName: "known-icon",
|
||||
docsUrl: "https://alpha.example.test/docs",
|
||||
api: "responses",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY", keyUrl: "https://alpha.example.test/key" },
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function providerConfig(
|
||||
overrides: Partial<RedactedPaseoAgentProviderConfig>,
|
||||
): RedactedPaseoAgentProviderConfig {
|
||||
return {
|
||||
name: "catalog-alpha",
|
||||
providerType: "catalog-alpha",
|
||||
models: [{ id: "alpha-fast" }],
|
||||
auth: { kind: "api_key", configured: true },
|
||||
available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSheet() {
|
||||
return render(<PaseoAgentSettingsSheet serverId="server-1" visible onClose={vi.fn()} />);
|
||||
}
|
||||
|
||||
function resetHookState() {
|
||||
hookState.current.supported = true;
|
||||
hookState.current.catalogSupported = true;
|
||||
hookState.current.providers = [];
|
||||
hookState.current.catalog = [];
|
||||
hookState.current.defaultModel = null;
|
||||
hookState.current.isLoading = false;
|
||||
hookState.current.isCatalogLoading = false;
|
||||
hookState.current.error = null;
|
||||
hookState.current.catalogError = null;
|
||||
hookState.current.refresh = vi.fn(async () => undefined);
|
||||
hookState.current.setProvider = vi.fn(async () => providerConfig({}));
|
||||
hookState.current.startOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: null,
|
||||
error: null,
|
||||
}));
|
||||
hookState.current.completeOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-complete",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
auth: { kind: "oauth", configured: true },
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("PaseoAgentSettingsSheet", () => {
|
||||
beforeEach(() => {
|
||||
resetHookState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows the catalog feature-gate message exactly once", () => {
|
||||
hookState.current.catalogSupported = false;
|
||||
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getAllByText("Update the Paseo daemon to use this.")).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: "Add model provider" })).toBeNull();
|
||||
});
|
||||
|
||||
it("lists catalog entries in the provider picker and disables unknown auth kinds", () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({ label: "Alpha Provider" }),
|
||||
catalogEntry({
|
||||
id: "catalog-future",
|
||||
label: "Future Provider",
|
||||
auth: { kind: "future_auth", prompt: "not yet" },
|
||||
}),
|
||||
];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
|
||||
expect(screen.getByText("Alpha Provider")).toBeTruthy();
|
||||
expect(screen.getByText("Future Provider")).toBeTruthy();
|
||||
expect(screen.getByTestId("paseo-agent-catalog-docs-catalog-alpha").getAttribute("href")).toBe(
|
||||
"https://alpha.example.test/docs",
|
||||
);
|
||||
expect(screen.getByText("Update the app to use this provider")).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByTestId("paseo-agent-catalog-select-catalog-future") as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("submits an api-key provider with an explicit key", async () => {
|
||||
hookState.current.catalog = [catalogEntry({ models: [] })];
|
||||
|
||||
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" },
|
||||
});
|
||||
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",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
models: [{ id: "alpha/fast" }, { id: "beta/steady" }],
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
});
|
||||
});
|
||||
|
||||
it("submits an empty api-key field as a host env reference", async () => {
|
||||
hookState.current.catalog = [catalogEntry({})];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "catalog-alpha",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "$ALPHA_API_KEY",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a device-code oauth authorization and completes it", async () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({
|
||||
id: "catalog-login",
|
||||
label: "Catalog Login",
|
||||
auth: { kind: "oauth", flow: "login-flow" },
|
||||
}),
|
||||
];
|
||||
hookState.current.startOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "CODE-123",
|
||||
verificationUri: "https://login.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 600,
|
||||
instructions: "Enter the code",
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
expect((await screen.findByTestId("paseo-agent-oauth-user-code")).textContent).toBe("CODE-123");
|
||||
expect(screen.getByText("https://login.example.test/device")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Complete sign in" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "catalog-login",
|
||||
providerType: "catalog-login",
|
||||
options: {
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("catalog-login");
|
||||
expect(hookState.current.completeOAuth).toHaveBeenCalledWith("catalog-login");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows auth-state badges from configured instances", () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({ id: "catalog-alpha", label: "Catalog Alpha" }),
|
||||
catalogEntry({ id: "catalog-beta", label: "Catalog Beta" }),
|
||||
];
|
||||
hookState.current.providers = [
|
||||
providerConfig({ name: "alpha", providerType: "catalog-alpha" }),
|
||||
providerConfig({
|
||||
name: "beta",
|
||||
providerType: "catalog-beta",
|
||||
auth: { kind: "api_key", configured: false },
|
||||
}),
|
||||
providerConfig({ name: "legacy", providerType: "legacy-provider", auth: undefined }),
|
||||
];
|
||||
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getByText("Catalog Alpha · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("Catalog Beta · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("legacy-provider · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("Connected")).toBeTruthy();
|
||||
expect(screen.getByText("Needs attention")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,9 @@ import { useCallback, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
PaseoAgentOAuthCompleteResponse,
|
||||
PaseoAgentOAuthStartResponse,
|
||||
PaseoAgentSetProviderRequest,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
@@ -12,6 +15,10 @@ export function paseoAgentProvidersQueryKey(serverId: string | null) {
|
||||
return ["paseo-agent-providers", serverId] as const;
|
||||
}
|
||||
|
||||
export function paseoAgentCatalogQueryKey(serverId: string | null) {
|
||||
return ["paseo-agent-catalog", serverId] as const;
|
||||
}
|
||||
|
||||
function describeQueryError(error: unknown): string | null {
|
||||
if (!error) {
|
||||
return null;
|
||||
@@ -20,17 +27,25 @@ function describeQueryError(error: unknown): string | null {
|
||||
}
|
||||
|
||||
export type PaseoAgentSetProviderInput = Omit<PaseoAgentSetProviderRequest, "type" | "requestId">;
|
||||
export type PaseoAgentOAuthStartResult = PaseoAgentOAuthStartResponse["payload"];
|
||||
export type PaseoAgentOAuthCompleteResult = PaseoAgentOAuthCompleteResponse["payload"];
|
||||
|
||||
interface UsePaseoAgentProvidersResult {
|
||||
supported: boolean;
|
||||
catalogSupported: boolean;
|
||||
providers: RedactedPaseoAgentProviderConfig[];
|
||||
catalog: PaseoAgentCatalogEntry[];
|
||||
defaultModel: string | null;
|
||||
isLoading: boolean;
|
||||
isCatalogLoading: boolean;
|
||||
error: string | null;
|
||||
catalogError: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
setProvider: (
|
||||
input: PaseoAgentSetProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
startOAuth: (name: string) => Promise<PaseoAgentOAuthStartResult>;
|
||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
||||
}
|
||||
|
||||
export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentProvidersResult {
|
||||
@@ -44,7 +59,12 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr
|
||||
const supported = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true,
|
||||
);
|
||||
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104.
|
||||
const catalogSupported = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentCatalog === true,
|
||||
);
|
||||
const queryKey = useMemo(() => paseoAgentProvidersQueryKey(serverId), [serverId]);
|
||||
const catalogQueryKey = useMemo(() => paseoAgentCatalogQueryKey(serverId), [serverId]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
@@ -58,11 +78,25 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr
|
||||
},
|
||||
});
|
||||
|
||||
const catalogQuery = useQuery({
|
||||
queryKey: catalogQueryKey,
|
||||
enabled: Boolean(supported && catalogSupported && serverId && client && isConnected),
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
return client.getPaseoAgentCatalog();
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
}, [queryClient, queryKey]);
|
||||
await queryClient.invalidateQueries({ queryKey: catalogQueryKey });
|
||||
}, [catalogQueryKey, queryClient, queryKey]);
|
||||
|
||||
const error = query.data?.error ?? describeQueryError(query.error);
|
||||
const catalogError = catalogQuery.data?.error ?? describeQueryError(catalogQuery.error);
|
||||
|
||||
const setProviderMutation = useMutation({
|
||||
mutationFn: async (input: PaseoAgentSetProviderInput) => {
|
||||
@@ -86,13 +120,57 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr
|
||||
[setProviderAsync],
|
||||
);
|
||||
|
||||
const startOAuthMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.startPaseoAgentOAuth(name);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
const { mutateAsync: startOAuthAsync } = startOAuthMutation;
|
||||
|
||||
const startOAuth = useCallback((name: string) => startOAuthAsync(name), [startOAuthAsync]);
|
||||
|
||||
const completeOAuthMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.completePaseoAgentOAuth(name);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: completeOAuthAsync } = completeOAuthMutation;
|
||||
|
||||
const completeOAuth = useCallback(
|
||||
(name: string) => completeOAuthAsync(name),
|
||||
[completeOAuthAsync],
|
||||
);
|
||||
|
||||
return {
|
||||
supported,
|
||||
catalogSupported,
|
||||
providers: query.data?.providers ?? [],
|
||||
catalog: catalogQuery.data?.catalog ?? [],
|
||||
defaultModel: query.data?.defaultModel ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isCatalogLoading: catalogQuery.isLoading,
|
||||
error,
|
||||
catalogError,
|
||||
refresh,
|
||||
setProvider,
|
||||
startOAuth,
|
||||
completeOAuth,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user