feat: move goose2 provider catalog behind ACP layer (#9030)
Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterStartupProvidersForDistro } from "./useAppStartup";
|
||||
|
||||
const providers = [
|
||||
{ id: "goose", label: "Goose" },
|
||||
{ id: "codex-acp", label: "Codex" },
|
||||
];
|
||||
|
||||
describe("filterStartupProvidersForDistro", () => {
|
||||
it("keeps providers when no allowlist is configured", () => {
|
||||
expect(filterStartupProvidersForDistro(providers, null, [])).toEqual(
|
||||
providers,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes Goose when an allowlist exists but no allowed model provider is known", () => {
|
||||
expect(
|
||||
filterStartupProvidersForDistro(providers, new Set(["anthropic"]), []),
|
||||
).toEqual([{ id: "codex-acp", label: "Codex" }]);
|
||||
});
|
||||
|
||||
it("keeps Goose when an allowed model provider exists", () => {
|
||||
expect(
|
||||
filterStartupProvidersForDistro(providers, new Set(["anthropic"]), [
|
||||
{ id: "anthropic" },
|
||||
]),
|
||||
).toEqual(providers);
|
||||
});
|
||||
|
||||
it("removes Goose when no model provider is allowed", () => {
|
||||
expect(
|
||||
filterStartupProvidersForDistro(providers, new Set(["anthropic"]), [
|
||||
{ id: "openai" },
|
||||
]),
|
||||
).toEqual([{ id: "codex-acp", label: "Codex" }]);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,40 @@ import { useEffect } from "react";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
|
||||
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
|
||||
import { discoverAcpProvidersFromEntries } from "@/shared/api/acp";
|
||||
import {
|
||||
discoverAcpProvidersFromEntries,
|
||||
type AcpProvider,
|
||||
} from "@/shared/api/acp";
|
||||
import { setNotificationHandler, getClient } from "@/shared/api/acpConnection";
|
||||
import notificationHandler from "@/shared/api/acpNotificationHandler";
|
||||
import { perfLog } from "@/shared/lib/perfLog";
|
||||
import { parseProviderAllowlist } from "@/features/providers/distroProviderConstraints";
|
||||
import {
|
||||
hasAllowedModelProvider,
|
||||
parseProviderAllowlist,
|
||||
} from "@/features/providers/distroProviderConstraints";
|
||||
import { getModelProviders } from "@/features/providers/providerCatalog";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import { useDistroStore } from "@/features/settings/stores/distroStore";
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
|
||||
export function filterStartupProvidersForDistro(
|
||||
providers: AcpProvider[],
|
||||
providerAllowlist: Set<string> | null,
|
||||
modelProviders: Pick<ProviderCatalogEntry, "id">[],
|
||||
): AcpProvider[] {
|
||||
if (!providerAllowlist) {
|
||||
return providers;
|
||||
}
|
||||
|
||||
const shouldKeepGoose = hasAllowedModelProvider(
|
||||
modelProviders,
|
||||
providerAllowlist,
|
||||
);
|
||||
|
||||
return providers.filter(
|
||||
(provider) => provider.id !== "goose" || shouldKeepGoose,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppStartup() {
|
||||
useEffect(() => {
|
||||
@@ -28,7 +55,26 @@ export function useAppStartup() {
|
||||
|
||||
const store = useAgentStore.getState();
|
||||
const inventoryStore = useProviderInventoryStore.getState();
|
||||
const catalogStore = useProviderCatalogStore.getState();
|
||||
const distroStore = useDistroStore.getState();
|
||||
|
||||
const applyProvidersFromInventory = (
|
||||
entries: Parameters<typeof discoverAcpProvidersFromEntries>[0],
|
||||
) => {
|
||||
const providers = discoverAcpProvidersFromEntries(entries);
|
||||
const providerAllowlist = parseProviderAllowlist(
|
||||
useDistroStore.getState().manifest,
|
||||
);
|
||||
store.setProviders(
|
||||
filterStartupProvidersForDistro(
|
||||
providers,
|
||||
providerAllowlist,
|
||||
getModelProviders(),
|
||||
),
|
||||
);
|
||||
return providers;
|
||||
};
|
||||
|
||||
const loadDistroBundle = async () => {
|
||||
try {
|
||||
const { getDistroBundle } = await import("@/shared/api/distro");
|
||||
@@ -57,6 +103,24 @@ export function useAppStartup() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadProviderCatalog = async () => {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const entries = await catalogStore.load();
|
||||
const inventoryEntries = [
|
||||
...useProviderInventoryStore.getState().entries.values(),
|
||||
];
|
||||
if (inventoryEntries.length > 0) {
|
||||
applyProvidersFromInventory(inventoryEntries);
|
||||
}
|
||||
perfLog(
|
||||
`[perf:startup] loadProviderCatalog done in ${(performance.now() - t0).toFixed(1)}ms (n=${entries.length})`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to load provider catalog on startup:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProvidersAndInventory = async () => {
|
||||
const t0 = performance.now();
|
||||
store.setProvidersLoading(true);
|
||||
@@ -71,23 +135,7 @@ export function useAppStartup() {
|
||||
inventoryStore.setEntries(entries);
|
||||
|
||||
// Derive ACP providers from the same response
|
||||
const providers = discoverAcpProvidersFromEntries(entries);
|
||||
const providerAllowlist = parseProviderAllowlist(
|
||||
useDistroStore.getState().manifest,
|
||||
);
|
||||
if (!providerAllowlist) {
|
||||
store.setProviders(providers);
|
||||
} else {
|
||||
const hasAllowedModelProvider = getModelProviders().some(
|
||||
(provider) => providerAllowlist.has(provider.id),
|
||||
);
|
||||
store.setProviders(
|
||||
providers.filter(
|
||||
(provider) =>
|
||||
provider.id !== "goose" || hasAllowedModelProvider,
|
||||
),
|
||||
);
|
||||
}
|
||||
const providers = applyProvidersFromInventory(entries);
|
||||
|
||||
perfLog(
|
||||
`[perf:startup] loadProvidersAndInventory done in ${(performance.now() - t0).toFixed(1)}ms (entries=${entries.length}, providers=${providers.length})`,
|
||||
@@ -117,6 +165,10 @@ export function useAppStartup() {
|
||||
setActiveSession(null);
|
||||
};
|
||||
|
||||
// Catalog loading has its own fallback/error state and should not block
|
||||
// sessions, personas, or configured provider inventory during startup.
|
||||
void loadProviderCatalog();
|
||||
|
||||
await loadDistroBundle();
|
||||
|
||||
const providersAndInventoryLoad = loadProvidersAndInventory();
|
||||
@@ -126,6 +178,8 @@ export function useAppStartup() {
|
||||
providersAndInventoryLoad,
|
||||
loadSessionState(),
|
||||
]);
|
||||
// Background refresh updates stale inventory after the first usable
|
||||
// provider list is available.
|
||||
void providersAndInventoryLoad.then(async (entries) => {
|
||||
try {
|
||||
const { backgroundRefreshInventory } = await import(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import { useAgentModelPickerState } from "../useAgentModelPickerState";
|
||||
|
||||
const mockUseProviderInventory = vi.fn();
|
||||
@@ -9,6 +10,11 @@ vi.mock("@/features/providers/hooks/useProviderInventory", () => ({
|
||||
}));
|
||||
|
||||
describe("useAgentModelPickerState", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useProviderCatalogStore.getState().reset();
|
||||
});
|
||||
|
||||
it("switches to goose when the current provider is goose-backed", () => {
|
||||
const onProviderSelected = vi.fn();
|
||||
|
||||
@@ -173,4 +179,183 @@ describe("useAgentModelPickerState", () => {
|
||||
recommended: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes unresolved model providers through Goose before the catalog loads", () => {
|
||||
const getModelsForAgent = vi.fn((agentId: string) =>
|
||||
agentId === "goose"
|
||||
? [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
providerId: "openai",
|
||||
providerName: "OpenAI",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4",
|
||||
name: "Claude Sonnet 4",
|
||||
providerId: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
mockUseProviderInventory.mockReturnValue({
|
||||
entries: new Map([
|
||||
[
|
||||
"openai",
|
||||
{
|
||||
providerId: "openai",
|
||||
providerName: "OpenAI",
|
||||
category: "model",
|
||||
configured: true,
|
||||
refreshing: false,
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
getEntry: (providerId: string) =>
|
||||
providerId === "openai"
|
||||
? {
|
||||
providerId: "openai",
|
||||
providerName: "OpenAI",
|
||||
category: "model",
|
||||
configured: true,
|
||||
refreshing: false,
|
||||
models: [],
|
||||
}
|
||||
: undefined,
|
||||
configuredModelProviderEntries: [],
|
||||
getModelsForAgent,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentModelPickerState({
|
||||
providers: [{ id: "goose", label: "Goose" }],
|
||||
selectedProvider: "openai",
|
||||
onProviderSelected: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.selectedAgentId).toBe("goose");
|
||||
expect(getModelsForAgent).toHaveBeenCalledWith("goose");
|
||||
expect(
|
||||
result.current.availableModels.map((model) => model.providerId),
|
||||
).toEqual(["openai", "anthropic"]);
|
||||
});
|
||||
|
||||
it("preserves unresolved agent providers before the catalog loads when inventory identifies an agent", () => {
|
||||
const getModelsForAgent = vi.fn(() => [
|
||||
{
|
||||
id: "current",
|
||||
name: "Current",
|
||||
providerId: "codex-acp",
|
||||
providerName: "Codex",
|
||||
},
|
||||
]);
|
||||
|
||||
mockUseProviderInventory.mockReturnValue({
|
||||
entries: new Map([
|
||||
[
|
||||
"codex-acp",
|
||||
{
|
||||
providerId: "codex-acp",
|
||||
providerName: "Codex",
|
||||
category: "agent",
|
||||
configured: true,
|
||||
refreshing: false,
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
getEntry: (providerId: string) =>
|
||||
providerId === "codex-acp"
|
||||
? {
|
||||
providerId: "codex-acp",
|
||||
providerName: "Codex",
|
||||
category: "agent",
|
||||
configured: true,
|
||||
refreshing: false,
|
||||
models: [],
|
||||
}
|
||||
: undefined,
|
||||
configuredModelProviderEntries: [],
|
||||
getModelsForAgent,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentModelPickerState({
|
||||
providers: [{ id: "codex-acp", label: "Codex" }],
|
||||
selectedProvider: "codex-acp",
|
||||
onProviderSelected: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.selectedAgentId).toBe("codex-acp");
|
||||
expect(getModelsForAgent).toHaveBeenCalledWith("codex-acp");
|
||||
});
|
||||
|
||||
it("shows configured inventory agent providers before the catalog loads", () => {
|
||||
mockUseProviderInventory.mockReturnValue({
|
||||
entries: new Map([
|
||||
[
|
||||
"codex-acp",
|
||||
{
|
||||
providerId: "codex-acp",
|
||||
providerName: "Codex",
|
||||
category: "agent",
|
||||
configured: true,
|
||||
refreshing: false,
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
"cursor-agent",
|
||||
{
|
||||
providerId: "cursor-agent",
|
||||
providerName: "Cursor",
|
||||
category: "agent",
|
||||
configured: true,
|
||||
refreshing: false,
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
"unconfigured-agent",
|
||||
{
|
||||
providerId: "unconfigured-agent",
|
||||
providerName: "Unconfigured",
|
||||
category: "agent",
|
||||
configured: false,
|
||||
refreshing: false,
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
getEntry: () => undefined,
|
||||
configuredModelProviderEntries: [],
|
||||
getModelsForAgent: () => [],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentModelPickerState({
|
||||
providers: [
|
||||
{ id: "codex-acp", label: "Codex" },
|
||||
{ id: "cursor-agent", label: "Cursor" },
|
||||
{ id: "unconfigured-agent", label: "Unconfigured" },
|
||||
],
|
||||
selectedProvider: "goose",
|
||||
onProviderSelected: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.pickerAgents).toEqual([
|
||||
{ id: "goose", label: "Goose" },
|
||||
{ id: "codex-acp", label: "Codex" },
|
||||
{ id: "cursor-agent", label: "Cursor" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import { useResolvedAgentModelPicker } from "../useResolvedAgentModelPicker";
|
||||
|
||||
const mockUseProviderInventory = vi.fn();
|
||||
const mockUseAgentModelPickerState = vi.fn();
|
||||
const mockGetClient = vi.fn();
|
||||
const mockAcpSetModel = vi.fn();
|
||||
|
||||
vi.mock("@/features/providers/hooks/useProviderInventory", () => ({
|
||||
useProviderInventory: () => mockUseProviderInventory(),
|
||||
@@ -19,10 +21,43 @@ vi.mock("@/shared/api/acpConnection", () => ({
|
||||
getClient: (...args: unknown[]) => mockGetClient(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/api/acp", () => ({
|
||||
acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args),
|
||||
}));
|
||||
|
||||
describe("useResolvedAgentModelPicker", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
useProviderCatalogStore.getState().reset();
|
||||
useProviderCatalogStore.getState().setEntries([
|
||||
{
|
||||
id: "codex-acp",
|
||||
displayName: "Codex CLI",
|
||||
category: "agent",
|
||||
description: "Codex CLI",
|
||||
setupMethod: "cli_auth",
|
||||
group: "default",
|
||||
aliases: ["codex-acp", "codex_cli", "codex"],
|
||||
},
|
||||
{
|
||||
id: "claude-acp",
|
||||
displayName: "Claude Code",
|
||||
category: "agent",
|
||||
description: "Claude Code",
|
||||
setupMethod: "cli_auth",
|
||||
group: "default",
|
||||
aliases: ["claude-acp", "claude_code", "claude"],
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
displayName: "OpenAI",
|
||||
category: "model",
|
||||
description: "OpenAI",
|
||||
setupMethod: "single_api_key",
|
||||
group: "default",
|
||||
},
|
||||
]);
|
||||
|
||||
mockGetClient.mockResolvedValue({
|
||||
goose: {
|
||||
@@ -32,12 +67,14 @@ describe("useResolvedAgentModelPicker", () => {
|
||||
}),
|
||||
},
|
||||
});
|
||||
mockAcpSetModel.mockResolvedValue(undefined);
|
||||
|
||||
mockUseProviderInventory.mockReturnValue({
|
||||
getEntry: (providerId: string) =>
|
||||
providerId === "codex-acp"
|
||||
? {
|
||||
providerId: "codex-acp",
|
||||
category: "agent",
|
||||
defaultModel: "gpt-5.4",
|
||||
models: [
|
||||
{
|
||||
@@ -396,4 +433,259 @@ describe("useResolvedAgentModelPicker", () => {
|
||||
|
||||
expect(result.current.effectiveModelSelection).toBeNull();
|
||||
});
|
||||
|
||||
it("enforces concrete provider compatibility from inventory before catalog loads", () => {
|
||||
useProviderCatalogStore.getState().reset();
|
||||
window.localStorage.setItem(
|
||||
"goose:preferredModelsByAgent",
|
||||
JSON.stringify({
|
||||
goose: {
|
||||
modelId: "claude-sonnet-4",
|
||||
modelName: "Claude Sonnet 4",
|
||||
providerId: "anthropic",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
mockUseProviderInventory.mockReturnValue({
|
||||
getEntry: (providerId: string) =>
|
||||
providerId === "openai"
|
||||
? {
|
||||
providerId: "openai",
|
||||
category: "model",
|
||||
defaultModel: "gpt-5.4",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
recommended: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
mockUseAgentModelPickerState.mockImplementation(() => ({
|
||||
pickerAgents: [{ id: "goose", label: "Goose" }],
|
||||
availableModels: [],
|
||||
modelsLoading: true,
|
||||
modelStatusMessage: null,
|
||||
handleProviderChange: vi.fn(),
|
||||
handleModelChange: vi.fn(),
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResolvedAgentModelPicker({
|
||||
providers: [
|
||||
{ id: "goose", label: "Goose" },
|
||||
{ id: "openai", label: "OpenAI" },
|
||||
],
|
||||
selectedProvider: "openai",
|
||||
sessionId: "session-1",
|
||||
session: {
|
||||
id: "session-1",
|
||||
title: "Chat",
|
||||
providerId: "openai",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
messageCount: 0,
|
||||
},
|
||||
pendingModelSelection: undefined,
|
||||
setPendingProviderId: vi.fn(),
|
||||
setPendingModelSelection: vi.fn(),
|
||||
setGlobalSelectedProvider: vi.fn(),
|
||||
prepareSelectedProvider: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.effectiveModelSelection).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves unresolved agent provider identity before catalog loads", async () => {
|
||||
useProviderCatalogStore.getState().reset();
|
||||
|
||||
mockUseAgentModelPickerState.mockImplementation(
|
||||
({
|
||||
onProviderSelected,
|
||||
onModelSelected,
|
||||
}: {
|
||||
onProviderSelected: (providerId: string) => void;
|
||||
onModelSelected?: (model: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
providerId?: string;
|
||||
}) => void;
|
||||
}) => ({
|
||||
pickerAgents: [
|
||||
{ id: "goose", label: "Goose" },
|
||||
{ id: "codex-acp", label: "Codex" },
|
||||
],
|
||||
availableModels: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
displayName: "GPT-5.4",
|
||||
providerId: "codex-acp",
|
||||
},
|
||||
],
|
||||
modelsLoading: false,
|
||||
modelStatusMessage: null,
|
||||
handleProviderChange: (providerId: string) =>
|
||||
onProviderSelected(providerId),
|
||||
handleModelChange: (modelId: string) =>
|
||||
onModelSelected?.({
|
||||
id: modelId,
|
||||
name: "GPT-5.4",
|
||||
displayName: "GPT-5.4",
|
||||
providerId: "codex-acp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResolvedAgentModelPicker({
|
||||
providers: [
|
||||
{ id: "goose", label: "Goose" },
|
||||
{ id: "codex-acp", label: "Codex" },
|
||||
],
|
||||
selectedProvider: "codex-acp",
|
||||
sessionId: "session-1",
|
||||
session: {
|
||||
id: "session-1",
|
||||
title: "Chat",
|
||||
providerId: "codex-acp",
|
||||
modelId: "current",
|
||||
modelName: "current",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
messageCount: 0,
|
||||
},
|
||||
pendingModelSelection: undefined,
|
||||
setPendingProviderId: vi.fn(),
|
||||
setPendingModelSelection: vi.fn(),
|
||||
setGlobalSelectedProvider: vi.fn(),
|
||||
prepareSelectedProvider: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleModelChange("gpt-5.4");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem("goose:preferredModelsByAgent") ?? "{}",
|
||||
),
|
||||
).toEqual({
|
||||
"codex-acp": {
|
||||
modelId: "gpt-5.4",
|
||||
modelName: "GPT-5.4",
|
||||
providerId: "codex-acp",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("routes unresolved model provider identity through Goose before catalog loads", async () => {
|
||||
useProviderCatalogStore.getState().reset();
|
||||
|
||||
mockUseProviderInventory.mockReturnValue({
|
||||
getEntry: (providerId: string) =>
|
||||
providerId === "openai"
|
||||
? {
|
||||
providerId: "openai",
|
||||
category: "model",
|
||||
defaultModel: "gpt-5.4",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
recommended: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
mockUseAgentModelPickerState.mockImplementation(
|
||||
({
|
||||
onModelSelected,
|
||||
}: {
|
||||
onModelSelected?: (model: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
providerId?: string;
|
||||
}) => void;
|
||||
}) => ({
|
||||
pickerAgents: [{ id: "goose", label: "Goose" }],
|
||||
availableModels: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
displayName: "GPT-5.4",
|
||||
providerId: "openai",
|
||||
},
|
||||
],
|
||||
modelsLoading: false,
|
||||
modelStatusMessage: null,
|
||||
handleProviderChange: vi.fn(),
|
||||
handleModelChange: (modelId: string) =>
|
||||
onModelSelected?.({
|
||||
id: modelId,
|
||||
name: "GPT-5.4",
|
||||
displayName: "GPT-5.4",
|
||||
providerId: "openai",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResolvedAgentModelPicker({
|
||||
providers: [
|
||||
{ id: "goose", label: "Goose" },
|
||||
{ id: "openai", label: "OpenAI" },
|
||||
],
|
||||
selectedProvider: "openai",
|
||||
sessionId: "session-1",
|
||||
session: {
|
||||
id: "session-1",
|
||||
title: "Chat",
|
||||
providerId: "openai",
|
||||
modelId: "current",
|
||||
modelName: "current",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
messageCount: 0,
|
||||
},
|
||||
pendingModelSelection: undefined,
|
||||
setPendingProviderId: vi.fn(),
|
||||
setPendingModelSelection: vi.fn(),
|
||||
setGlobalSelectedProvider: vi.fn(),
|
||||
prepareSelectedProvider: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.selectedAgentId).toBe("goose");
|
||||
|
||||
act(() => {
|
||||
result.current.handleModelChange("gpt-5.4");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem("goose:preferredModelsByAgent") ?? "{}",
|
||||
),
|
||||
).toEqual({
|
||||
goose: {
|
||||
modelId: "gpt-5.4",
|
||||
modelName: "GPT-5.4",
|
||||
providerId: "openai",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { AcpProvider } from "@/shared/api/acp";
|
||||
import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory";
|
||||
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
|
||||
import {
|
||||
getCatalogEntry,
|
||||
resolveAgentProviderCatalogIdStrict,
|
||||
getCatalogEntryFromEntries,
|
||||
resolveAgentProviderCatalogIdStrictFromEntries,
|
||||
} from "@/features/providers/providerCatalog";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import { resolveSelectedAgentId } from "../lib/agentProviderResolution";
|
||||
import type { ModelOption } from "../types";
|
||||
|
||||
interface UseAgentModelPickerStateOptions {
|
||||
@@ -23,6 +25,8 @@ export function useAgentModelPickerState({
|
||||
onProviderSelected,
|
||||
onModelSelected,
|
||||
}: UseAgentModelPickerStateOptions) {
|
||||
const catalogEntries = useProviderCatalogStore((state) => state.entries);
|
||||
const catalogLoaded = useProviderCatalogStore((state) => state.loaded);
|
||||
const {
|
||||
entries: providerInventoryEntries,
|
||||
getEntry: getProviderInventoryEntry,
|
||||
@@ -31,9 +35,21 @@ export function useAgentModelPickerState({
|
||||
loading: providerInventoryLoading,
|
||||
} = useProviderInventory();
|
||||
|
||||
const selectedAgentId = selectedProvider
|
||||
? (resolveAgentProviderCatalogIdStrict(selectedProvider) ?? "goose")
|
||||
: "goose";
|
||||
const selectedAgentId = useMemo(
|
||||
() =>
|
||||
resolveSelectedAgentId({
|
||||
catalogEntries,
|
||||
catalogLoaded,
|
||||
selectedProvider,
|
||||
getProviderInventoryEntry,
|
||||
}),
|
||||
[
|
||||
catalogEntries,
|
||||
catalogLoaded,
|
||||
getProviderInventoryEntry,
|
||||
selectedProvider,
|
||||
],
|
||||
);
|
||||
const selectedProviderInventory = getProviderInventoryEntry(selectedAgentId);
|
||||
|
||||
const pickerAgents = useMemo(() => {
|
||||
@@ -41,11 +57,21 @@ export function useAgentModelPickerState({
|
||||
|
||||
visible.set("goose", {
|
||||
id: "goose",
|
||||
label: getCatalogEntry("goose")?.displayName ?? "Goose",
|
||||
label:
|
||||
getCatalogEntryFromEntries(catalogEntries, "goose")?.displayName ??
|
||||
"Goose",
|
||||
});
|
||||
|
||||
for (const provider of providers) {
|
||||
const agentId = resolveAgentProviderCatalogIdStrict(provider.id);
|
||||
const agentId =
|
||||
resolveAgentProviderCatalogIdStrictFromEntries(
|
||||
catalogEntries,
|
||||
provider.id,
|
||||
) ??
|
||||
(!catalogLoaded &&
|
||||
providerInventoryEntries.get(provider.id)?.category === "agent"
|
||||
? provider.id
|
||||
: null);
|
||||
if (!agentId || agentId === "goose") {
|
||||
continue;
|
||||
}
|
||||
@@ -57,19 +83,29 @@ export function useAgentModelPickerState({
|
||||
|
||||
visible.set(agentId, {
|
||||
id: agentId,
|
||||
label: getCatalogEntry(agentId)?.displayName ?? provider.label,
|
||||
label:
|
||||
getCatalogEntryFromEntries(catalogEntries, agentId)?.displayName ??
|
||||
provider.label,
|
||||
});
|
||||
}
|
||||
|
||||
if (!visible.has(selectedAgentId)) {
|
||||
visible.set(selectedAgentId, {
|
||||
id: selectedAgentId,
|
||||
label: getCatalogEntry(selectedAgentId)?.displayName ?? selectedAgentId,
|
||||
label:
|
||||
getCatalogEntryFromEntries(catalogEntries, selectedAgentId)
|
||||
?.displayName ?? selectedAgentId,
|
||||
});
|
||||
}
|
||||
|
||||
return [...visible.values()];
|
||||
}, [providerInventoryEntries, providers, selectedAgentId]);
|
||||
}, [
|
||||
catalogEntries,
|
||||
catalogLoaded,
|
||||
providerInventoryEntries,
|
||||
providers,
|
||||
selectedAgentId,
|
||||
]);
|
||||
|
||||
const availableModels = useMemo(
|
||||
() => getModelsForAgent(selectedAgentId) ?? EMPTY_MODELS,
|
||||
|
||||
@@ -10,7 +10,8 @@ import { useChatSessionStore } from "../stores/chatSessionStore";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import { resolveAgentProviderCatalogIdStrict } from "@/features/providers/providerCatalog";
|
||||
import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import {
|
||||
buildProjectSystemPrompt,
|
||||
composeSystemPrompt,
|
||||
@@ -64,6 +65,7 @@ export function useChatSessionController({
|
||||
);
|
||||
const projects = useProjectStore((s) => s.projects);
|
||||
const projectsLoading = useProjectStore((s) => s.loading);
|
||||
const catalogEntries = useProviderCatalogStore((s) => s.entries);
|
||||
const [pendingPersonaId, setPendingPersonaId] = useState<string | null>();
|
||||
const [pendingProjectId, setPendingProjectId] = useState<string | null>();
|
||||
const [pendingProviderId, setPendingProviderId] = useState<string>();
|
||||
@@ -435,7 +437,7 @@ export function useChatSessionController({
|
||||
supportsContextCompactionControls(selectedAgentId);
|
||||
const isCompactingContext = chatState === "compacting";
|
||||
const resolveAutoCompactAgentId = useCallback(
|
||||
(overridePersona?: { id: string; name?: string }) => {
|
||||
(overridePersona?: { id: string; name?: string }): string | null => {
|
||||
if (!overridePersona?.id) {
|
||||
return selectedAgentId;
|
||||
}
|
||||
@@ -447,11 +449,22 @@ export function useChatSessionController({
|
||||
return selectedAgentId;
|
||||
}
|
||||
|
||||
return (
|
||||
resolveAgentProviderCatalogIdStrict(targetPersona.provider) ?? "goose"
|
||||
const targetAgentId = resolveAgentProviderCatalogIdStrictFromEntries(
|
||||
catalogEntries,
|
||||
targetPersona.provider,
|
||||
);
|
||||
if (targetAgentId) {
|
||||
return targetAgentId;
|
||||
}
|
||||
|
||||
const isGooseModelProvider = providers.some(
|
||||
(provider) =>
|
||||
provider.id === targetPersona.provider ||
|
||||
provider.label.toLowerCase().includes(targetPersona.provider ?? ""),
|
||||
);
|
||||
return isGooseModelProvider ? "goose" : null;
|
||||
},
|
||||
[personas, selectedAgentId],
|
||||
[catalogEntries, personas, providers, selectedAgentId],
|
||||
);
|
||||
const canAutoCompactBeforeSend = useCallback(
|
||||
(overridePersona?: { id: string; name?: string }) => {
|
||||
@@ -721,7 +734,8 @@ export function useChatSessionController({
|
||||
}
|
||||
if (pendingModelSelection?.source === "explicit") {
|
||||
const agentId =
|
||||
resolveAgentProviderCatalogIdStrict(
|
||||
resolveAgentProviderCatalogIdStrictFromEntries(
|
||||
catalogEntries,
|
||||
pendingModelSelection.providerId ?? nextProviderId,
|
||||
) ?? "goose";
|
||||
setStoredModelPreference(agentId, {
|
||||
@@ -764,6 +778,7 @@ export function useChatSessionController({
|
||||
};
|
||||
}, [
|
||||
activeWorkspace?.path,
|
||||
catalogEntries,
|
||||
pendingDraftValue,
|
||||
pendingSkillDrafts,
|
||||
pendingModelSelection,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { AcpProvider } from "@/shared/api/acp";
|
||||
import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory";
|
||||
import { resolveAgentProviderCatalogIdStrict } from "@/features/providers/providerCatalog";
|
||||
import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import { getClient } from "@/shared/api/acpConnection";
|
||||
import { acpSetModel } from "@/shared/api/acp";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
getStoredModelPreference,
|
||||
setStoredModelPreference,
|
||||
} from "../lib/modelPreferences";
|
||||
import { resolveSelectedAgentId } from "../lib/agentProviderResolution";
|
||||
|
||||
const MODEL_ALIAS_IDS = new Set(["current", "default"]);
|
||||
|
||||
@@ -56,16 +58,48 @@ export function useResolvedAgentModelPicker({
|
||||
setGlobalSelectedProvider,
|
||||
prepareSelectedProvider,
|
||||
}: UseResolvedAgentModelPickerOptions) {
|
||||
const catalogEntries = useProviderCatalogStore((state) => state.entries);
|
||||
const catalogLoaded = useProviderCatalogStore((state) => state.loaded);
|
||||
const { getEntry: getProviderInventoryEntry } = useProviderInventory();
|
||||
const [gooseDefaultSelection, setGooseDefaultSelection] =
|
||||
useState<PreferredModelSelection | null>(null);
|
||||
|
||||
const selectedAgentId =
|
||||
resolveAgentProviderCatalogIdStrict(selectedProvider) ?? "goose";
|
||||
const concreteSelectedProviderId =
|
||||
resolveAgentProviderCatalogIdStrict(selectedProvider) == null
|
||||
? selectedProvider
|
||||
: null;
|
||||
const selectedAgentId = useMemo(
|
||||
() =>
|
||||
resolveSelectedAgentId({
|
||||
catalogEntries,
|
||||
catalogLoaded,
|
||||
selectedProvider,
|
||||
getProviderInventoryEntry,
|
||||
}),
|
||||
[
|
||||
catalogEntries,
|
||||
catalogLoaded,
|
||||
getProviderInventoryEntry,
|
||||
selectedProvider,
|
||||
],
|
||||
);
|
||||
const concreteSelectedProviderId = useMemo(() => {
|
||||
const resolvedAgentId = resolveAgentProviderCatalogIdStrictFromEntries(
|
||||
catalogEntries,
|
||||
selectedProvider,
|
||||
);
|
||||
if (resolvedAgentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!catalogLoaded) {
|
||||
const inventoryEntry = getProviderInventoryEntry(selectedProvider);
|
||||
return inventoryEntry?.category === "model" ? selectedProvider : null;
|
||||
}
|
||||
|
||||
return selectedProvider;
|
||||
}, [
|
||||
catalogEntries,
|
||||
catalogLoaded,
|
||||
getProviderInventoryEntry,
|
||||
selectedProvider,
|
||||
]);
|
||||
const storedModelPreference = useMemo(
|
||||
() => getStoredModelPreference(selectedAgentId),
|
||||
[selectedAgentId],
|
||||
@@ -178,9 +212,20 @@ export function useResolvedAgentModelPicker({
|
||||
providers,
|
||||
selectedProvider,
|
||||
onProviderSelected: (providerId) => {
|
||||
const requestedAgentId = resolveAgentProviderCatalogIdStrict(providerId);
|
||||
const requestedAgentId = resolveAgentProviderCatalogIdStrictFromEntries(
|
||||
catalogEntries,
|
||||
providerId,
|
||||
);
|
||||
const resolvedRequestedAgentId =
|
||||
requestedAgentId ??
|
||||
resolveSelectedAgentId({
|
||||
catalogEntries,
|
||||
catalogLoaded,
|
||||
selectedProvider: providerId,
|
||||
getProviderInventoryEntry,
|
||||
});
|
||||
const preferredModelSelection = getPreferredSelectionForAgent(
|
||||
requestedAgentId ?? "goose",
|
||||
resolvedRequestedAgentId,
|
||||
providerId,
|
||||
);
|
||||
const nextProviderId = requestedAgentId
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk";
|
||||
import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog";
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
|
||||
interface ResolveSelectedAgentIdOptions {
|
||||
catalogEntries: ProviderCatalogEntry[];
|
||||
catalogLoaded: boolean;
|
||||
selectedProvider?: string;
|
||||
getProviderInventoryEntry: (
|
||||
providerId: string,
|
||||
) => ProviderInventoryEntryDto | undefined;
|
||||
}
|
||||
|
||||
export function resolveSelectedAgentId({
|
||||
catalogEntries,
|
||||
catalogLoaded,
|
||||
selectedProvider,
|
||||
getProviderInventoryEntry,
|
||||
}: ResolveSelectedAgentIdOptions): string {
|
||||
if (!selectedProvider) {
|
||||
return "goose";
|
||||
}
|
||||
|
||||
const resolvedAgentId = resolveAgentProviderCatalogIdStrictFromEntries(
|
||||
catalogEntries,
|
||||
selectedProvider,
|
||||
);
|
||||
if (resolvedAgentId) {
|
||||
return resolvedAgentId;
|
||||
}
|
||||
|
||||
if (!catalogLoaded) {
|
||||
const inventoryEntry = getProviderInventoryEntry(selectedProvider);
|
||||
if (inventoryEntry?.category === "agent") {
|
||||
return selectedProvider;
|
||||
}
|
||||
}
|
||||
|
||||
return "goose";
|
||||
}
|
||||
@@ -29,7 +29,8 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip";
|
||||
import { AgentModelPicker } from "./AgentModelPicker";
|
||||
import type { ModelOption } from "../types";
|
||||
import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons";
|
||||
import { getCatalogEntry } from "@/features/providers/providerCatalog";
|
||||
import { getCatalogEntryFromEntries } from "@/features/providers/providerCatalog";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import { supportsContextCompactionControls } from "../lib/autoCompact";
|
||||
import { requestOpenSettings } from "@/features/settings/lib/settingsEvents";
|
||||
import { ProjectSelectorIcon } from "./ProjectSelectorIcon";
|
||||
@@ -127,6 +128,7 @@ export function ChatInputToolbar({
|
||||
}: ChatInputToolbarProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const { formatNumber } = useLocaleFormatting();
|
||||
const catalogEntries = useProviderCatalogStore((state) => state.entries);
|
||||
const [isContextPopoverOpen, setIsContextPopoverOpen] = useState(false);
|
||||
const compactionControlsSupported =
|
||||
supportsCompactionControls ??
|
||||
@@ -142,7 +144,9 @@ export function ChatInputToolbar({
|
||||
seen.add(provider.id);
|
||||
available.push({
|
||||
id: provider.id,
|
||||
label: getCatalogEntry(provider.id)?.displayName ?? provider.label,
|
||||
label:
|
||||
getCatalogEntryFromEntries(catalogEntries, provider.id)
|
||||
?.displayName ?? provider.label,
|
||||
});
|
||||
}
|
||||
if (available.length > 0) return available;
|
||||
@@ -150,11 +154,11 @@ export function ChatInputToolbar({
|
||||
{
|
||||
id: selectedProvider,
|
||||
label:
|
||||
getCatalogEntry(selectedProvider)?.displayName ??
|
||||
formatProviderLabel(selectedProvider),
|
||||
getCatalogEntryFromEntries(catalogEntries, selectedProvider)
|
||||
?.displayName ?? formatProviderLabel(selectedProvider),
|
||||
},
|
||||
];
|
||||
}, [providers, selectedProvider]);
|
||||
}, [catalogEntries, providers, selectedProvider]);
|
||||
const selectedProject = availableProjects.find(
|
||||
(project) => project.id === selectedProjectId,
|
||||
);
|
||||
|
||||
@@ -7,7 +7,8 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { useLocaleFormatting } from "@/shared/i18n";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
|
||||
import { getCatalogEntry } from "@/features/providers/providerCatalog";
|
||||
import { getCatalogEntryFromEntries } from "@/features/providers/providerCatalog";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import {
|
||||
getProviderIcon,
|
||||
formatProviderLabel,
|
||||
@@ -329,6 +330,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
);
|
||||
const { isCopied: isCopyConfirmed, copyToClipboard } = useCopyToClipboard();
|
||||
const personaAvatarUrl = useAvatarSrc(persona?.avatar);
|
||||
const catalogEntries = useProviderCatalogStore((state) => state.entries);
|
||||
|
||||
// Skip empty user bubbles (all blocks filtered as assistant-only).
|
||||
if (role === "user" && content.length === 0) return null;
|
||||
@@ -357,8 +359,8 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
const isUser = role === "user";
|
||||
const assistantProviderId = message.metadata?.providerId;
|
||||
const assistantProviderName = assistantProviderId
|
||||
? (getCatalogEntry(assistantProviderId)?.displayName ??
|
||||
formatProviderLabel(assistantProviderId))
|
||||
? (getCatalogEntryFromEntries(catalogEntries, assistantProviderId)
|
||||
?.displayName ?? formatProviderLabel(assistantProviderId))
|
||||
: undefined;
|
||||
const assistantDisplayName =
|
||||
message.metadata?.personaName ??
|
||||
|
||||
@@ -3,10 +3,35 @@ import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MessageBubble } from "../MessageBubble";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import type { Message } from "@/shared/types/messages";
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
import { openPath } from "@tauri-apps/plugin-opener";
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const providerCatalogEntries: ProviderCatalogEntry[] = [
|
||||
{
|
||||
id: "claude-acp",
|
||||
displayName: "Claude Code",
|
||||
category: "agent",
|
||||
description: "Anthropic's agentic coding tool",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "claude-agent-acp",
|
||||
group: "default",
|
||||
aliases: ["claude-acp", "claude_code", "claude"],
|
||||
},
|
||||
{
|
||||
id: "codex-acp",
|
||||
displayName: "Codex",
|
||||
category: "agent",
|
||||
description: "OpenAI's coding agent",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "codex-acp",
|
||||
group: "default",
|
||||
aliases: ["codex-acp", "codex_cli", "codex"],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@mcp-ui/client", () => ({
|
||||
UI_EXTENSION_CONFIG: { mimeTypes: ["text/html;profile=mcp-app"] },
|
||||
AppRenderer: (props: { toolName?: string }) => (
|
||||
@@ -57,6 +82,7 @@ function assistantMessage(
|
||||
describe("MessageBubble", () => {
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState({ personas: [] });
|
||||
useProviderCatalogStore.getState().setEntries(providerCatalogEntries);
|
||||
vi.mocked(openPath).mockClear();
|
||||
mockWriteText.mockClear();
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
@@ -69,6 +95,7 @@ describe("MessageBubble", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
useProviderCatalogStore.getState().reset();
|
||||
});
|
||||
|
||||
it("renders user message with correct alignment", () => {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
listProviderSetupCatalog,
|
||||
mapProviderSetupCatalogEntryDto,
|
||||
} from "./catalog";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
catalogList: vi.fn(),
|
||||
getClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/api/acpConnection", () => ({
|
||||
getClient: () => mocks.getClient(),
|
||||
}));
|
||||
|
||||
describe("provider setup catalog API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getClient.mockResolvedValue({
|
||||
goose: {
|
||||
GooseProvidersSetupCatalogList: mocks.catalogList,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps setup catalog DTO fields to provider catalog entries", () => {
|
||||
expect(
|
||||
mapProviderSetupCatalogEntryDto({
|
||||
providerId: "claude-acp",
|
||||
name: "Claude Code",
|
||||
docUrl: "https://docs.anthropic.com/en/docs/claude-code",
|
||||
category: "agent",
|
||||
description: "Anthropic's agentic coding tool",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "claude-agent-acp",
|
||||
group: "default",
|
||||
showOnlyWhenInstalled: false,
|
||||
aliases: ["claude-acp", "claude_code", "claude"],
|
||||
supportsInstall: true,
|
||||
supportsAuth: true,
|
||||
supportsAuthStatus: true,
|
||||
}),
|
||||
).toEqual({
|
||||
id: "claude-acp",
|
||||
displayName: "Claude Code",
|
||||
category: "agent",
|
||||
description: "Anthropic's agentic coding tool",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "claude-agent-acp",
|
||||
docsUrl: "https://docs.anthropic.com/en/docs/claude-code",
|
||||
group: "default",
|
||||
showOnlyWhenInstalled: false,
|
||||
aliases: ["claude-acp", "claude_code", "claude"],
|
||||
supportsInstall: true,
|
||||
supportsAuth: true,
|
||||
supportsAuthStatus: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("requests the setup catalog through ACP", async () => {
|
||||
mocks.catalogList.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
providerId: "ollama",
|
||||
name: "Ollama",
|
||||
category: "model",
|
||||
description: "Run local models",
|
||||
setupMethod: "config_fields",
|
||||
fields: [
|
||||
{
|
||||
key: "OLLAMA_HOST",
|
||||
label: "Host",
|
||||
secret: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
group: "default",
|
||||
showOnlyWhenInstalled: false,
|
||||
supportsInstall: false,
|
||||
supportsAuth: false,
|
||||
supportsAuthStatus: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(listProviderSetupCatalog()).resolves.toEqual([
|
||||
{
|
||||
id: "ollama",
|
||||
displayName: "Ollama",
|
||||
category: "model",
|
||||
description: "Run local models",
|
||||
setupMethod: "config_fields",
|
||||
fields: [
|
||||
{
|
||||
key: "OLLAMA_HOST",
|
||||
label: "Host",
|
||||
secret: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
group: "default",
|
||||
showOnlyWhenInstalled: false,
|
||||
supportsInstall: false,
|
||||
supportsAuth: false,
|
||||
supportsAuthStatus: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.catalogList).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ProviderSetupCatalogEntryDto } from "@aaif/goose-sdk";
|
||||
import { getClient } from "@/shared/api/acpConnection";
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
import { perfLog } from "@/shared/lib/perfLog";
|
||||
|
||||
export function mapProviderSetupCatalogEntryDto(
|
||||
dto: ProviderSetupCatalogEntryDto,
|
||||
): ProviderCatalogEntry {
|
||||
return {
|
||||
id: dto.providerId,
|
||||
displayName: dto.name,
|
||||
category: dto.category,
|
||||
description: dto.description,
|
||||
setupMethod: dto.setupMethod,
|
||||
...(dto.nativeConnectQuery
|
||||
? { nativeConnectQuery: dto.nativeConnectQuery }
|
||||
: {}),
|
||||
...(dto.fields?.length ? { fields: dto.fields } : {}),
|
||||
...(dto.binaryName ? { binaryName: dto.binaryName } : {}),
|
||||
...(dto.docUrl ? { docsUrl: dto.docUrl } : {}),
|
||||
group: dto.group,
|
||||
showOnlyWhenInstalled: dto.showOnlyWhenInstalled,
|
||||
...(dto.aliases?.length ? { aliases: dto.aliases } : {}),
|
||||
supportsInstall: dto.supportsInstall,
|
||||
supportsAuth: dto.supportsAuth,
|
||||
supportsAuthStatus: dto.supportsAuthStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listProviderSetupCatalog(): Promise<
|
||||
ProviderCatalogEntry[]
|
||||
> {
|
||||
const client = await getClient();
|
||||
const t0 = performance.now();
|
||||
const response = await client.goose.GooseProvidersSetupCatalogList({});
|
||||
const providers = response.providers.map(mapProviderSetupCatalogEntryDto);
|
||||
|
||||
perfLog(
|
||||
`[perf:catalog] listProviderSetupCatalog done in ${(performance.now() - t0).toFixed(1)}ms (n=${providers.length})`,
|
||||
);
|
||||
return providers;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
CustomProviderDeleteResponse,
|
||||
CustomProviderReadResponse,
|
||||
CustomProviderUpdateResponse,
|
||||
ProviderCatalogEntryDto,
|
||||
ProviderTemplateCatalogEntryDto,
|
||||
ProviderTemplateDto,
|
||||
} from "@aaif/goose-sdk";
|
||||
import type {
|
||||
@@ -19,7 +19,7 @@ async function getProviderClient() {
|
||||
|
||||
export async function listCustomProviderCatalog(
|
||||
format?: CustomProviderFormat,
|
||||
): Promise<ProviderCatalogEntryDto[]> {
|
||||
): Promise<ProviderTemplateCatalogEntryDto[]> {
|
||||
const client = await getProviderClient();
|
||||
const response = await client.GooseProvidersCatalogList(
|
||||
format ? { format } : {},
|
||||
|
||||
@@ -23,6 +23,7 @@ function providerEntry(
|
||||
defaultModel: "",
|
||||
configured: false,
|
||||
providerType: "Preferred",
|
||||
category: "model",
|
||||
configKeys: [],
|
||||
setupSteps: [],
|
||||
supportsRefresh: false,
|
||||
|
||||
@@ -17,6 +17,7 @@ function inventoryEntry(
|
||||
defaultModel: "default-model",
|
||||
configured: true,
|
||||
providerType: "remote",
|
||||
category: "model",
|
||||
configKeys: [],
|
||||
setupSteps: [],
|
||||
supportsRefresh: true,
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("filterModelProvidersForDistro", () => {
|
||||
category: "model",
|
||||
description: "Claude models",
|
||||
setupMethod: "single_api_key",
|
||||
tier: "promoted",
|
||||
group: "default",
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
@@ -17,7 +17,7 @@ describe("filterModelProvidersForDistro", () => {
|
||||
category: "model",
|
||||
description: "GPT models",
|
||||
setupMethod: "single_api_key",
|
||||
tier: "promoted",
|
||||
group: "default",
|
||||
},
|
||||
{
|
||||
id: "ollama",
|
||||
@@ -25,7 +25,7 @@ describe("filterModelProvidersForDistro", () => {
|
||||
category: "model",
|
||||
description: "Local models",
|
||||
setupMethod: "local",
|
||||
tier: "promoted",
|
||||
group: "default",
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -32,3 +32,19 @@ export function filterModelProvidersForDistro(
|
||||
|
||||
return providers.filter((provider) => allowlist.has(provider.id));
|
||||
}
|
||||
|
||||
export function isProviderAllowedByAllowlist(
|
||||
providerId: string,
|
||||
allowlist: Set<string> | null,
|
||||
): boolean {
|
||||
return !allowlist || allowlist.has(providerId);
|
||||
}
|
||||
|
||||
export function hasAllowedModelProvider(
|
||||
providers: Pick<ProviderCatalogEntry, "id">[],
|
||||
allowlist: Set<string> | null,
|
||||
): boolean {
|
||||
return providers.some((provider) =>
|
||||
isProviderAllowedByAllowlist(provider.id, allowlist),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ import {
|
||||
checkAgentInstalled,
|
||||
checkAgentAuth,
|
||||
} from "@/features/providers/api/agentSetup";
|
||||
import {
|
||||
getAgentProviders,
|
||||
getCatalogEntry,
|
||||
} from "@/features/providers/providerCatalog";
|
||||
import { getAgentProvidersFromEntries } from "@/features/providers/providerCatalog";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
|
||||
interface UseAgentProviderStatusReturn {
|
||||
readyAgentIds: Set<string>;
|
||||
@@ -14,9 +13,10 @@ interface UseAgentProviderStatusReturn {
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function checkAgentProviderReady(providerId: string): Promise<boolean> {
|
||||
const provider = getCatalogEntry(providerId);
|
||||
if (!provider || provider.category !== "agent") {
|
||||
async function checkAgentProviderReady(
|
||||
provider: ProviderCatalogEntry,
|
||||
): Promise<boolean> {
|
||||
if (provider.category !== "agent") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ async function checkAgentProviderReady(providerId: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (provider.authStatusCommand) {
|
||||
if (provider.supportsAuthStatus) {
|
||||
return checkAgentAuth(provider.id);
|
||||
}
|
||||
|
||||
if (provider.authCommand) {
|
||||
if (provider.supportsAuth) {
|
||||
return (
|
||||
localStorage.getItem(`agent-provider-auth:${provider.id}`) === "true"
|
||||
);
|
||||
@@ -52,61 +52,57 @@ async function checkAgentProviderReady(providerId: string): Promise<boolean> {
|
||||
|
||||
const INITIAL_READY_AGENTS = new Set<string>(["goose"]);
|
||||
|
||||
async function checkReadyAgentIds(
|
||||
agents: ProviderCatalogEntry[],
|
||||
): Promise<Set<string>> {
|
||||
const readiness = await Promise.all(
|
||||
agents.map(async (provider) => ({
|
||||
id: provider.id,
|
||||
isReady: await checkAgentProviderReady(provider),
|
||||
})),
|
||||
);
|
||||
const readyIds = readiness
|
||||
.filter((provider) => provider.isReady)
|
||||
.map((provider) => provider.id);
|
||||
return new Set(["goose", ...readyIds]);
|
||||
}
|
||||
|
||||
export function useAgentProviderStatus(): UseAgentProviderStatusReturn {
|
||||
const catalogEntries = useProviderCatalogStore((state) => state.entries);
|
||||
const [readyAgentIds, setReadyAgentIds] =
|
||||
useState<Set<string>>(INITIAL_READY_AGENTS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const agentIds = getAgentProviders().map((provider) => provider.id);
|
||||
let remaining = agentIds.length;
|
||||
|
||||
for (const agentId of agentIds) {
|
||||
checkAgentProviderReady(agentId)
|
||||
.then((isReady) => {
|
||||
if (!cancelled && isReady) {
|
||||
setReadyAgentIds((current) => {
|
||||
if (current.has(agentId)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next = new Set(current);
|
||||
next.add(agentId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
remaining -= 1;
|
||||
if (!cancelled && remaining === 0) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
const agents = getAgentProvidersFromEntries(catalogEntries);
|
||||
setLoading(true);
|
||||
checkReadyAgentIds(agents)
|
||||
.then((nextReadyAgentIds) => {
|
||||
if (!cancelled) {
|
||||
setReadyAgentIds(nextReadyAgentIds);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [catalogEntries]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const readiness = await Promise.all(
|
||||
getAgentProviders().map(async (provider) => ({
|
||||
id: provider.id,
|
||||
isReady: await checkAgentProviderReady(provider.id),
|
||||
})),
|
||||
);
|
||||
const readyIds = readiness
|
||||
.filter((provider) => provider.isReady)
|
||||
.map((provider) => provider.id);
|
||||
setReadyAgentIds(new Set(["goose", ...readyIds]));
|
||||
const agents = getAgentProvidersFromEntries(catalogEntries);
|
||||
setReadyAgentIds(await checkReadyAgentIds(agents));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [catalogEntries]);
|
||||
|
||||
return {
|
||||
readyAgentIds,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useProviderInventoryStore } from "../stores/providerInventoryStore";
|
||||
import { useCustomProviders } from "./useCustomProviders";
|
||||
@@ -26,7 +27,7 @@ vi.mock("../api/inventorySync", () => ({
|
||||
syncProviderInventory: mocks.syncProviderInventory,
|
||||
}));
|
||||
|
||||
function providerEntry(providerId: string) {
|
||||
function providerEntry(providerId: string): ProviderInventoryEntryDto {
|
||||
return {
|
||||
providerId,
|
||||
providerName: "Acme AI",
|
||||
@@ -34,6 +35,7 @@ function providerEntry(providerId: string) {
|
||||
defaultModel: "acme-large",
|
||||
configured: true,
|
||||
providerType: "Custom",
|
||||
category: "model",
|
||||
configKeys: [],
|
||||
setupSteps: [],
|
||||
supportsRefresh: true,
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
CustomProviderReadResponse,
|
||||
CustomProviderUpdateResponse,
|
||||
CustomProviderUpsertRequest,
|
||||
ProviderCatalogEntryDto,
|
||||
ProviderTemplateCatalogEntryDto,
|
||||
ProviderTemplateDto,
|
||||
} from "../lib/customProviderTypes";
|
||||
import { useProviderInventoryStore } from "../stores/providerInventoryStore";
|
||||
@@ -35,7 +35,7 @@ interface SaveDraftOptions extends CustomProviderValidationOptions {
|
||||
}
|
||||
|
||||
interface UseCustomProvidersReturn {
|
||||
catalog: ProviderCatalogEntryDto[];
|
||||
catalog: ProviderTemplateCatalogEntryDto[];
|
||||
catalogLoading: boolean;
|
||||
saving: boolean;
|
||||
savingProviderIds: Set<string>;
|
||||
@@ -46,7 +46,7 @@ interface UseCustomProvidersReturn {
|
||||
configuredIds: Set<string>;
|
||||
loadCatalog: (
|
||||
format?: CustomProviderFormat,
|
||||
) => Promise<ProviderCatalogEntryDto[]>;
|
||||
) => Promise<ProviderTemplateCatalogEntryDto[]>;
|
||||
getTemplate: (providerId: string) => Promise<ProviderTemplateDto>;
|
||||
read: (providerId: string) => Promise<CustomProviderReadResponse>;
|
||||
create: (
|
||||
@@ -113,7 +113,7 @@ export function useCustomProviders(): UseCustomProvidersReturn {
|
||||
const catalogRequestIdRef = useRef(0);
|
||||
const operationIdRef = useRef(0);
|
||||
const deletedProviderIdsRef = useRef(new Set<string>());
|
||||
const [catalog, setCatalog] = useState<ProviderCatalogEntryDto[]>([]);
|
||||
const [catalog, setCatalog] = useState<ProviderTemplateCatalogEntryDto[]>([]);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
const [savingProviderIds, setProviderSaving] = useSetMembershipState();
|
||||
const [deletingProviderIds, setProviderDeleting] = useSetMembershipState();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useDistroStore } from "@/features/settings/stores/distroStore";
|
||||
import { useProviderCatalogStore } from "../stores/providerCatalogStore";
|
||||
import { useProviderInventoryStore } from "../stores/providerInventoryStore";
|
||||
import { useProviderInventory } from "./useProviderInventory";
|
||||
|
||||
@@ -16,6 +18,7 @@ function providerEntry(
|
||||
defaultModel: "",
|
||||
configured: true,
|
||||
providerType: "Preferred",
|
||||
category: "model",
|
||||
configKeys: [],
|
||||
setupSteps: [],
|
||||
supportsRefresh: true,
|
||||
@@ -28,6 +31,25 @@ function providerEntry(
|
||||
|
||||
describe("useProviderInventory", () => {
|
||||
beforeEach(() => {
|
||||
useProviderCatalogStore.getState().setEntries([
|
||||
{
|
||||
id: "openai",
|
||||
displayName: "OpenAI",
|
||||
category: "model",
|
||||
description: "GPT and o-series models",
|
||||
setupMethod: "config_fields",
|
||||
group: "default",
|
||||
},
|
||||
{
|
||||
id: "custom_deepseek",
|
||||
displayName: "DeepSeek",
|
||||
category: "model",
|
||||
description: "DeepSeek chat and reasoning models",
|
||||
setupMethod: "single_api_key",
|
||||
group: "additional",
|
||||
},
|
||||
]);
|
||||
useDistroStore.setState({ loaded: false, manifest: { present: false } });
|
||||
useProviderInventoryStore.setState({
|
||||
entries: new Map(),
|
||||
loading: false,
|
||||
@@ -83,6 +105,103 @@ describe("useProviderInventory", () => {
|
||||
).toEqual(["openai", "custom_acme_openai", "custom_deepseek"]);
|
||||
});
|
||||
|
||||
it("falls back to configured inventory providers before the catalog loads", () => {
|
||||
useProviderCatalogStore.getState().reset();
|
||||
useProviderInventoryStore.getState().setEntries([
|
||||
providerEntry({
|
||||
providerId: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "Preferred",
|
||||
models: [{ id: "gpt-4o", name: "GPT-4o", recommended: true }],
|
||||
}),
|
||||
providerEntry({
|
||||
providerId: "custom_acme_openai",
|
||||
providerName: "Acme OpenAI",
|
||||
providerType: "Custom",
|
||||
}),
|
||||
providerEntry({
|
||||
providerId: "codex-acp",
|
||||
providerName: "Codex",
|
||||
providerType: "Builtin",
|
||||
category: "agent",
|
||||
models: [{ id: "current", name: "Current", recommended: true }],
|
||||
}),
|
||||
providerEntry({
|
||||
providerId: "local",
|
||||
providerName: "Local",
|
||||
providerType: "Custom",
|
||||
}),
|
||||
providerEntry({
|
||||
providerId: "unconfigured_anthropic",
|
||||
providerName: "Anthropic",
|
||||
providerType: "Preferred",
|
||||
configured: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useProviderInventory());
|
||||
|
||||
expect(
|
||||
result.current.configuredModelProviderEntries.map(
|
||||
(entry) => entry.providerId,
|
||||
),
|
||||
).toEqual(["openai", "custom_acme_openai"]);
|
||||
expect(result.current.getModelsForAgent("goose")).toEqual([
|
||||
{
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
displayName: "GPT-4o",
|
||||
provider: undefined,
|
||||
providerId: "openai",
|
||||
providerName: "OpenAI",
|
||||
contextLimit: undefined,
|
||||
recommended: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies the provider allowlist before the catalog loads", () => {
|
||||
useProviderCatalogStore.getState().reset();
|
||||
useDistroStore.setState({
|
||||
loaded: true,
|
||||
manifest: { present: true, providerAllowlist: "anthropic" },
|
||||
});
|
||||
useProviderInventoryStore.getState().setEntries([
|
||||
providerEntry({
|
||||
providerId: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "Preferred",
|
||||
models: [{ id: "gpt-4o", name: "GPT-4o", recommended: true }],
|
||||
}),
|
||||
providerEntry({
|
||||
providerId: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
providerType: "Preferred",
|
||||
models: [{ id: "claude-sonnet", name: "Claude Sonnet" }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useProviderInventory());
|
||||
|
||||
expect(
|
||||
result.current.configuredModelProviderEntries.map(
|
||||
(entry) => entry.providerId,
|
||||
),
|
||||
).toEqual(["anthropic"]);
|
||||
expect(result.current.getModelsForAgent("goose")).toEqual([
|
||||
{
|
||||
id: "claude-sonnet",
|
||||
name: "Claude Sonnet",
|
||||
displayName: "Claude Sonnet",
|
||||
provider: undefined,
|
||||
providerId: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
contextLimit: undefined,
|
||||
recommended: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("aggregates custom provider models under Goose", () => {
|
||||
useProviderInventoryStore.getState().setEntries([
|
||||
providerEntry({
|
||||
|
||||
@@ -5,22 +5,37 @@ import type {
|
||||
ProviderInventoryEntryDto,
|
||||
ProviderInventoryModelDto,
|
||||
} from "@aaif/goose-sdk";
|
||||
import { getModelProviders } from "../providerCatalog";
|
||||
import { getModelProvidersFromEntries } from "../providerCatalog";
|
||||
import { useDistroStore } from "@/features/settings/stores/distroStore";
|
||||
import { filterModelProvidersForDistro } from "../distroProviderConstraints";
|
||||
import {
|
||||
filterModelProvidersForDistro,
|
||||
isProviderAllowedByAllowlist,
|
||||
parseProviderAllowlist,
|
||||
} from "../distroProviderConstraints";
|
||||
import { useProviderCatalogStore } from "../stores/providerCatalogStore";
|
||||
|
||||
function isConfiguredGooseModelProvider(
|
||||
entry: ProviderInventoryEntryDto,
|
||||
modelProviderIds: Set<string>,
|
||||
providerAllowlist: Set<string> | null,
|
||||
catalogLoaded: boolean,
|
||||
): boolean {
|
||||
if (!entry.configured) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.category === "agent") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.providerType === "Custom") {
|
||||
return entry.providerId.startsWith("custom_");
|
||||
}
|
||||
|
||||
if (!catalogLoaded) {
|
||||
return isProviderAllowedByAllowlist(entry.providerId, providerAllowlist);
|
||||
}
|
||||
|
||||
return modelProviderIds.has(entry.providerId);
|
||||
}
|
||||
|
||||
@@ -44,6 +59,12 @@ export function useProviderInventory() {
|
||||
const entries = useProviderInventoryStore((s) => s.entries);
|
||||
const loading = useProviderInventoryStore((s) => s.loading);
|
||||
const distro = useDistroStore((s) => s.manifest);
|
||||
const catalogEntries = useProviderCatalogStore((s) => s.entries);
|
||||
const catalogLoaded = useProviderCatalogStore((s) => s.loaded);
|
||||
const providerAllowlist = useMemo(
|
||||
() => parseProviderAllowlist(distro),
|
||||
[distro],
|
||||
);
|
||||
|
||||
const getEntry = useCallback(
|
||||
(providerId: string) => entries.get(providerId),
|
||||
@@ -62,19 +83,25 @@ export function useProviderInventory() {
|
||||
const modelProviderIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
filterModelProvidersForDistro(getModelProviders(), distro).map(
|
||||
(provider) => provider.id,
|
||||
),
|
||||
filterModelProvidersForDistro(
|
||||
getModelProvidersFromEntries(catalogEntries),
|
||||
distro,
|
||||
).map((provider) => provider.id),
|
||||
),
|
||||
[distro],
|
||||
[catalogEntries, distro],
|
||||
);
|
||||
|
||||
const configuredModelProviderEntries = useMemo(
|
||||
() =>
|
||||
[...entries.values()].filter((entry) =>
|
||||
isConfiguredGooseModelProvider(entry, modelProviderIds),
|
||||
isConfiguredGooseModelProvider(
|
||||
entry,
|
||||
modelProviderIds,
|
||||
providerAllowlist,
|
||||
catalogLoaded,
|
||||
),
|
||||
),
|
||||
[entries, modelProviderIds],
|
||||
[catalogLoaded, entries, modelProviderIds, providerAllowlist],
|
||||
);
|
||||
|
||||
const getModelsForAgent = useCallback(
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
CustomProviderDeleteResponse,
|
||||
CustomProviderReadResponse,
|
||||
CustomProviderUpdateResponse,
|
||||
ProviderCatalogEntryDto,
|
||||
ProviderTemplateCatalogEntryDto,
|
||||
ProviderTemplateDto,
|
||||
} from "@aaif/goose-sdk";
|
||||
|
||||
@@ -53,6 +53,6 @@ export type {
|
||||
CustomProviderDeleteResponse,
|
||||
CustomProviderReadResponse,
|
||||
CustomProviderUpdateResponse,
|
||||
ProviderCatalogEntryDto,
|
||||
ProviderTemplateCatalogEntryDto,
|
||||
ProviderTemplateDto,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function normalizeProviderKey(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "_")
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.join("_");
|
||||
}
|
||||
@@ -1,16 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
import {
|
||||
getAgentProviders,
|
||||
getCatalogEntry,
|
||||
getModelProviders,
|
||||
resolveAgentProviderCatalogId,
|
||||
} from "./providerCatalog";
|
||||
import { useProviderCatalogStore } from "./stores/providerCatalogStore";
|
||||
|
||||
describe("provider catalog", () => {
|
||||
it("exposes Ollama host configuration", () => {
|
||||
const ollama = getCatalogEntry("ollama");
|
||||
const catalogEntries: ProviderCatalogEntry[] = [
|
||||
{
|
||||
id: "goose",
|
||||
displayName: "Goose",
|
||||
category: "agent",
|
||||
description: "Block's open-source coding agent",
|
||||
setupMethod: "none",
|
||||
group: "default",
|
||||
aliases: ["goose"],
|
||||
},
|
||||
{
|
||||
id: "claude-acp",
|
||||
displayName: "Claude Code",
|
||||
category: "agent",
|
||||
description: "Anthropic's agentic coding tool",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "claude-agent-acp",
|
||||
group: "default",
|
||||
aliases: ["claude-acp", "claude_code", "claude"],
|
||||
supportsInstall: true,
|
||||
supportsAuth: true,
|
||||
supportsAuthStatus: true,
|
||||
},
|
||||
{
|
||||
id: "codex-acp",
|
||||
displayName: "Codex",
|
||||
category: "agent",
|
||||
description: "OpenAI's coding agent",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "codex-acp",
|
||||
group: "default",
|
||||
aliases: ["codex-acp", "codex_cli", "codex"],
|
||||
},
|
||||
{
|
||||
id: "ollama",
|
||||
displayName: "Ollama",
|
||||
category: "model",
|
||||
description: "Run local or self-hosted models",
|
||||
setupMethod: "config_fields",
|
||||
fields: [
|
||||
{
|
||||
key: "OLLAMA_HOST",
|
||||
label: "Host",
|
||||
secret: false,
|
||||
required: true,
|
||||
placeholder: "localhost or http://localhost:11434",
|
||||
defaultValue: "http://localhost:11434",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://ollama.com",
|
||||
group: "default",
|
||||
},
|
||||
];
|
||||
|
||||
expect(ollama?.setupMethod).toBe("config_fields");
|
||||
expect(ollama?.fields).toEqual([
|
||||
describe("provider catalog selectors", () => {
|
||||
beforeEach(() => {
|
||||
useProviderCatalogStore.getState().reset();
|
||||
});
|
||||
|
||||
it("returns the Goose fallback before the cache is loaded", () => {
|
||||
expect(getCatalogEntry("ollama")).toBeUndefined();
|
||||
expect(getAgentProviders().map((provider) => provider.id)).toEqual([
|
||||
"goose",
|
||||
]);
|
||||
expect(getModelProviders()).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses loaded cache entries for provider selectors", () => {
|
||||
useProviderCatalogStore.getState().setEntries(catalogEntries);
|
||||
|
||||
expect(getAgentProviders().map((provider) => provider.id)).toEqual([
|
||||
"goose",
|
||||
"claude-acp",
|
||||
"codex-acp",
|
||||
]);
|
||||
expect(getModelProviders().map((provider) => provider.id)).toEqual([
|
||||
"ollama",
|
||||
]);
|
||||
expect(getCatalogEntry("ollama")?.fields).toEqual([
|
||||
{
|
||||
key: "OLLAMA_HOST",
|
||||
label: "Host",
|
||||
@@ -22,87 +98,47 @@ describe("provider catalog", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses backend model provider ids for the curated catalog", () => {
|
||||
const ids = getModelProviders().map((provider) => provider.id);
|
||||
it("matches direct agent ids", () => {
|
||||
useProviderCatalogStore.getState().setEntries(catalogEntries);
|
||||
|
||||
expect(ids).toEqual([
|
||||
"anthropic",
|
||||
"google",
|
||||
"chatgpt_codex",
|
||||
"openai",
|
||||
"mistral",
|
||||
"ollama",
|
||||
"openrouter",
|
||||
"databricks",
|
||||
"github_copilot",
|
||||
"custom_deepseek",
|
||||
"xai",
|
||||
"groq",
|
||||
"azure_openai",
|
||||
"aws_bedrock",
|
||||
"gcp_vertex_ai",
|
||||
"litellm",
|
||||
"lmstudio",
|
||||
"nvidia",
|
||||
"cerebras",
|
||||
"snowflake",
|
||||
]);
|
||||
expect(ids).not.toContain("azure");
|
||||
expect(ids).not.toContain("bedrock");
|
||||
expect(ids).not.toContain("deepseek");
|
||||
expect(ids).not.toContain("local_inference");
|
||||
});
|
||||
|
||||
it("marks the planned promoted model providers", () => {
|
||||
const promotedIds = getModelProviders()
|
||||
.filter((provider) => provider.tier === "promoted")
|
||||
.map((provider) => provider.id);
|
||||
|
||||
expect(promotedIds).toEqual([
|
||||
"anthropic",
|
||||
"google",
|
||||
"chatgpt_codex",
|
||||
"openai",
|
||||
"mistral",
|
||||
"ollama",
|
||||
"openrouter",
|
||||
"databricks",
|
||||
"github_copilot",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentProviderCatalogId", () => {
|
||||
it("matches direct catalog ids", () => {
|
||||
expect(resolveAgentProviderCatalogId("cursor-agent", "Cursor Agent")).toBe(
|
||||
"cursor-agent",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches common agent aliases", () => {
|
||||
expect(resolveAgentProviderCatalogId("codex-cli", "Codex CLI")).toBe(
|
||||
"codex-acp",
|
||||
);
|
||||
expect(resolveAgentProviderCatalogId("claude-code", "Claude Code")).toBe(
|
||||
expect(resolveAgentProviderCatalogId("claude-acp", "Claude Code")).toBe(
|
||||
"claude-acp",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat model providers as agents", () => {
|
||||
expect(
|
||||
resolveAgentProviderCatalogId("databricks", "Databricks"),
|
||||
).toBeNull();
|
||||
});
|
||||
it("matches backend-provided agent aliases", () => {
|
||||
useProviderCatalogStore.getState().setEntries(catalogEntries);
|
||||
|
||||
it("matches fuzzy agent labels with extra suffixes", () => {
|
||||
expect(
|
||||
resolveAgentProviderCatalogId("custom-id", "Claude Code (ACP)"),
|
||||
).toBe("claude-acp");
|
||||
expect(resolveAgentProviderCatalogId("custom-id", "Codex CLI (ACP)")).toBe(
|
||||
expect(resolveAgentProviderCatalogId("codex-cli", "Codex CLI")).toBe(
|
||||
"codex-acp",
|
||||
);
|
||||
expect(
|
||||
resolveAgentProviderCatalogId("custom-id", "Cursor Agent Stable"),
|
||||
).toBe("cursor-agent");
|
||||
resolveAgentProviderCatalogId("custom-id", "Claude Code (ACP)"),
|
||||
).toBe("claude-acp");
|
||||
});
|
||||
|
||||
it("matches suffixed agent labels from backend aliases", () => {
|
||||
useProviderCatalogStore.getState().setEntries(catalogEntries);
|
||||
|
||||
expect(resolveAgentProviderCatalogId("custom-id", "Codex CLI (ACP)")).toBe(
|
||||
"codex-acp",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not match aliases embedded in unrelated labels", () => {
|
||||
useProviderCatalogStore.getState().setEntries(catalogEntries);
|
||||
|
||||
expect(
|
||||
resolveAgentProviderCatalogId("custom-id", "Acme Claude Tools"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveAgentProviderCatalogId("custom-id", "Codex compatible API"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat model providers as agents", () => {
|
||||
useProviderCatalogStore.getState().setEntries(catalogEntries);
|
||||
|
||||
expect(resolveAgentProviderCatalogId("ollama", "Ollama")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,47 +1,120 @@
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
import {
|
||||
AGENT_PROVIDER_ALIAS_MAP,
|
||||
AGENT_PROVIDER_FUZZY_MATCHERS,
|
||||
normalizeProviderKey,
|
||||
} from "./providerCatalogAliases";
|
||||
import {
|
||||
AGENT_PROVIDER_CATALOG,
|
||||
MODEL_PROVIDER_CATALOG,
|
||||
} from "./providerCatalogEntries";
|
||||
import { useProviderCatalogStore } from "./stores/providerCatalogStore";
|
||||
import { normalizeProviderKey } from "./lib/providerKey";
|
||||
|
||||
export const PROVIDER_CATALOG: ProviderCatalogEntry[] = [
|
||||
...AGENT_PROVIDER_CATALOG,
|
||||
...MODEL_PROVIDER_CATALOG,
|
||||
];
|
||||
export { normalizeProviderKey };
|
||||
|
||||
export function getProviderCatalog(): ProviderCatalogEntry[] {
|
||||
return useProviderCatalogStore.getState().entries;
|
||||
}
|
||||
|
||||
export function getCatalogEntry(
|
||||
providerId: string,
|
||||
): ProviderCatalogEntry | undefined {
|
||||
return PROVIDER_CATALOG.find((p) => p.id === providerId);
|
||||
return getCatalogEntryFromEntries(getProviderCatalog(), providerId);
|
||||
}
|
||||
|
||||
export function getAgentProviders(): ProviderCatalogEntry[] {
|
||||
return AGENT_PROVIDER_CATALOG;
|
||||
return getAgentProvidersFromEntries(getProviderCatalog());
|
||||
}
|
||||
|
||||
export function getModelProviders(): ProviderCatalogEntry[] {
|
||||
return MODEL_PROVIDER_CATALOG;
|
||||
return getModelProvidersFromEntries(getProviderCatalog());
|
||||
}
|
||||
|
||||
export function getCatalogEntryFromEntries(
|
||||
entries: ProviderCatalogEntry[],
|
||||
providerId: string,
|
||||
): ProviderCatalogEntry | undefined {
|
||||
return entries.find((provider) => provider.id === providerId);
|
||||
}
|
||||
|
||||
export function getAgentProvidersFromEntries(
|
||||
entries: ProviderCatalogEntry[],
|
||||
): ProviderCatalogEntry[] {
|
||||
return entries.filter((provider) => provider.category === "agent");
|
||||
}
|
||||
|
||||
export function getModelProvidersFromEntries(
|
||||
entries: ProviderCatalogEntry[],
|
||||
): ProviderCatalogEntry[] {
|
||||
return entries.filter((provider) => provider.category === "model");
|
||||
}
|
||||
|
||||
export function resolveAgentProviderCatalogIdStrictFromEntries(
|
||||
entries: ProviderCatalogEntry[],
|
||||
providerId: string,
|
||||
): string | null {
|
||||
const directMatch = entries.find((provider) => provider.id === providerId);
|
||||
if (directMatch?.category === "agent") {
|
||||
return directMatch.id;
|
||||
}
|
||||
|
||||
const normalized = normalizeProviderKey(providerId);
|
||||
for (const provider of entries) {
|
||||
if (provider.category !== "agent") {
|
||||
continue;
|
||||
}
|
||||
const aliases = [provider.id, ...(provider.aliases ?? [])];
|
||||
if (aliases.some((alias) => normalizeProviderKey(alias) === normalized)) {
|
||||
return provider.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveAgentProviderCatalogIdStrict(
|
||||
providerId: string,
|
||||
): string | null {
|
||||
const directMatch = getAgentProviders().find(
|
||||
(provider) => provider.id === providerId,
|
||||
return resolveAgentProviderCatalogIdStrictFromEntries(
|
||||
getProviderCatalog(),
|
||||
providerId,
|
||||
);
|
||||
if (directMatch) {
|
||||
return directMatch.id;
|
||||
}
|
||||
|
||||
function normalizedAliasMatchesCandidate(alias: string, candidate: string) {
|
||||
const normalizedAlias = normalizeProviderKey(alias);
|
||||
if (!normalizedAlias) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = normalizeProviderKey(providerId);
|
||||
const aliasMatch = AGENT_PROVIDER_ALIAS_MAP[normalized];
|
||||
if (aliasMatch) {
|
||||
return aliasMatch;
|
||||
const candidates = new Set([candidate]);
|
||||
if (candidate.endsWith("_acp")) {
|
||||
candidates.add(candidate.slice(0, -"_acp".length));
|
||||
}
|
||||
|
||||
return candidates.has(normalizedAlias);
|
||||
}
|
||||
|
||||
export function resolveAgentProviderCatalogIdFromEntries(
|
||||
entries: ProviderCatalogEntry[],
|
||||
providerId: string,
|
||||
label?: string,
|
||||
): string | null {
|
||||
const directMatch = resolveAgentProviderCatalogIdStrictFromEntries(
|
||||
entries,
|
||||
providerId,
|
||||
);
|
||||
if (directMatch) {
|
||||
return directMatch;
|
||||
}
|
||||
|
||||
const normalizedCandidates = [providerId, label ?? ""]
|
||||
.map((value) => normalizeProviderKey(value))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of normalizedCandidates) {
|
||||
for (const provider of entries) {
|
||||
if (provider.category !== "agent") {
|
||||
continue;
|
||||
}
|
||||
for (const alias of [provider.id, ...(provider.aliases ?? [])]) {
|
||||
if (normalizedAliasMatchesCandidate(alias, candidate)) {
|
||||
return provider.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -51,31 +124,9 @@ export function resolveAgentProviderCatalogId(
|
||||
providerId: string,
|
||||
label?: string,
|
||||
): string | null {
|
||||
const directMatch = getAgentProviders().find(
|
||||
(provider) => provider.id === providerId,
|
||||
return resolveAgentProviderCatalogIdFromEntries(
|
||||
getProviderCatalog(),
|
||||
providerId,
|
||||
label,
|
||||
);
|
||||
if (directMatch) {
|
||||
return directMatch.id;
|
||||
}
|
||||
|
||||
const normalizedCandidates = [providerId, label ?? ""]
|
||||
.map((value) => normalizeProviderKey(value))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of normalizedCandidates) {
|
||||
const aliasMatch = AGENT_PROVIDER_ALIAS_MAP[candidate];
|
||||
if (aliasMatch) {
|
||||
return aliasMatch;
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of normalizedCandidates) {
|
||||
for (const [needle, catalogId] of AGENT_PROVIDER_FUZZY_MATCHERS) {
|
||||
if (candidate.includes(needle)) {
|
||||
return catalogId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
export function normalizeProviderKey(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.join("_");
|
||||
}
|
||||
|
||||
export const AGENT_PROVIDER_ALIAS_MAP: Record<string, string> = {
|
||||
goose: "goose",
|
||||
claude_acp: "claude-acp",
|
||||
claude_code: "claude-acp",
|
||||
claude: "claude-acp",
|
||||
codex_acp: "codex-acp",
|
||||
codex_cli: "codex-acp",
|
||||
codex: "codex-acp",
|
||||
copilot_acp: "copilot-acp",
|
||||
github_copilot: "copilot-acp",
|
||||
github_copilot_cli: "copilot-acp",
|
||||
cursor_agent: "cursor-agent",
|
||||
cursor: "cursor-agent",
|
||||
amp_acp: "amp-acp",
|
||||
amp: "amp-acp",
|
||||
pi_acp: "pi-acp",
|
||||
pi: "pi-acp",
|
||||
};
|
||||
|
||||
export const AGENT_PROVIDER_FUZZY_MATCHERS: Array<[string, string]> = [
|
||||
["goose", "goose"],
|
||||
["claude", "claude-acp"],
|
||||
["codex", "codex-acp"],
|
||||
["cursor", "cursor-agent"],
|
||||
["copilot", "copilot-acp"],
|
||||
["amp", "amp-acp"],
|
||||
];
|
||||
@@ -1,481 +0,0 @@
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
|
||||
export const AGENT_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
|
||||
{
|
||||
id: "goose",
|
||||
displayName: "Goose",
|
||||
category: "agent",
|
||||
description: "Block's open-source coding agent",
|
||||
setupMethod: "none",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "claude-acp",
|
||||
displayName: "Claude Code",
|
||||
category: "agent",
|
||||
description: "Anthropic's agentic coding tool",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "claude-agent-acp",
|
||||
installCommand:
|
||||
"npm install -g @anthropic-ai/claude-code @agentclientprotocol/claude-agent-acp",
|
||||
authCommand: "claude auth login",
|
||||
authStatusCommand: "claude auth status",
|
||||
docsUrl: "https://docs.anthropic.com/en/docs/claude-code",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "codex-acp",
|
||||
displayName: "Codex",
|
||||
category: "agent",
|
||||
description: "OpenAI's coding agent",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "codex-acp",
|
||||
installCommand: "npm install -g @openai/codex @zed-industries/codex-acp",
|
||||
authCommand: "codex login",
|
||||
authStatusCommand: "codex login status",
|
||||
docsUrl: "https://github.com/openai/codex",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "copilot-acp",
|
||||
displayName: "GitHub Copilot",
|
||||
category: "agent",
|
||||
description: "GitHub's AI pair programmer",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "copilot",
|
||||
installCommand: "npm install -g @github/copilot",
|
||||
authCommand: "copilot login",
|
||||
docsUrl: "https://docs.github.com/en/copilot/github-copilot-in-the-cli",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "amp-acp",
|
||||
displayName: "Amp",
|
||||
category: "agent",
|
||||
description: "Sourcegraph's coding agent",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "amp-acp",
|
||||
installCommand: "npm install -g @sourcegraph/amp@latest amp-acp",
|
||||
authCommand: "amp login",
|
||||
authStatusCommand: "amp usage",
|
||||
docsUrl: "https://ampcode.com",
|
||||
tier: "standard",
|
||||
},
|
||||
{
|
||||
id: "cursor-agent",
|
||||
displayName: "Cursor Agent",
|
||||
category: "agent",
|
||||
description: "Cursor's AI agent",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "cursor-agent",
|
||||
installCommand: "curl -fsSL https://cursor.com/install | bash",
|
||||
authCommand: "cursor-agent login",
|
||||
authStatusCommand: "cursor-agent status",
|
||||
docsUrl: "https://docs.cursor.com/en/cli/overview",
|
||||
tier: "standard",
|
||||
},
|
||||
{
|
||||
id: "pi-acp",
|
||||
displayName: "Pi",
|
||||
category: "agent",
|
||||
description: "Open-source AI coding agent",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "pi-acp",
|
||||
docsUrl: "https://github.com/badlogic/pi-mono",
|
||||
tier: "standard",
|
||||
showOnlyWhenInstalled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const MODEL_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
category: "model",
|
||||
description: "Claude models",
|
||||
setupMethod: "single_api_key",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
fields: [
|
||||
{
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://console.anthropic.com/settings/keys",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
displayName: "Google Gemini",
|
||||
category: "model",
|
||||
description: "Gemini models",
|
||||
setupMethod: "single_api_key",
|
||||
envVar: "GOOGLE_API_KEY",
|
||||
fields: [
|
||||
{
|
||||
key: "GOOGLE_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://aistudio.google.com/apikey",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "chatgpt_codex",
|
||||
displayName: "ChatGPT",
|
||||
category: "model",
|
||||
description: "OpenAI via ChatGPT subscription",
|
||||
setupMethod: "oauth_device_code",
|
||||
nativeConnectQuery: "ChatGPT Codex",
|
||||
docsUrl: "https://chatgpt.com",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
displayName: "OpenAI",
|
||||
category: "model",
|
||||
description: "GPT and o-series models",
|
||||
setupMethod: "config_fields",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
fields: [
|
||||
{
|
||||
key: "OPENAI_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://platform.openai.com/api-keys",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "mistral",
|
||||
displayName: "Mistral AI",
|
||||
category: "model",
|
||||
description: "Frontier models from Mistral AI",
|
||||
setupMethod: "single_api_key",
|
||||
envVar: "MISTRAL_API_KEY",
|
||||
fields: [
|
||||
{
|
||||
key: "MISTRAL_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://console.mistral.ai/api-keys",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "ollama",
|
||||
displayName: "Ollama",
|
||||
category: "model",
|
||||
description: "Run local or self-hosted models",
|
||||
setupMethod: "config_fields",
|
||||
fields: [
|
||||
{
|
||||
key: "OLLAMA_HOST",
|
||||
label: "Host",
|
||||
secret: false,
|
||||
required: true,
|
||||
placeholder: "localhost or http://localhost:11434",
|
||||
defaultValue: "http://localhost:11434",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://ollama.com",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
displayName: "OpenRouter",
|
||||
category: "model",
|
||||
description: "Unified API for many models",
|
||||
setupMethod: "single_api_key",
|
||||
envVar: "OPENROUTER_API_KEY",
|
||||
fields: [
|
||||
{
|
||||
key: "OPENROUTER_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://openrouter.ai/keys",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "databricks",
|
||||
displayName: "Databricks",
|
||||
category: "model",
|
||||
description: "Databricks Foundation Models",
|
||||
setupMethod: "host_with_oauth_fallback",
|
||||
fields: [
|
||||
{
|
||||
key: "DATABRICKS_HOST",
|
||||
label: "Host URL",
|
||||
secret: false,
|
||||
required: true,
|
||||
placeholder: "https://dbc-...cloud.databricks.com",
|
||||
},
|
||||
{
|
||||
key: "DATABRICKS_TOKEN",
|
||||
label: "Access Token",
|
||||
secret: true,
|
||||
required: false,
|
||||
placeholder: "Paste your access token",
|
||||
},
|
||||
],
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "github_copilot",
|
||||
displayName: "GitHub Copilot Models",
|
||||
category: "model",
|
||||
description: "Models via GitHub Copilot subscription",
|
||||
setupMethod: "oauth_device_code",
|
||||
nativeConnectQuery: "GitHub Copilot",
|
||||
tier: "promoted",
|
||||
},
|
||||
{
|
||||
id: "custom_deepseek",
|
||||
displayName: "DeepSeek",
|
||||
category: "model",
|
||||
description: "DeepSeek chat and reasoning models",
|
||||
setupMethod: "single_api_key",
|
||||
envVar: "DEEPSEEK_API_KEY",
|
||||
fields: [
|
||||
{
|
||||
key: "DEEPSEEK_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://platform.deepseek.com/api_keys",
|
||||
tier: "advanced",
|
||||
},
|
||||
{
|
||||
id: "xai",
|
||||
displayName: "xAI",
|
||||
category: "model",
|
||||
description: "Grok models",
|
||||
setupMethod: "single_api_key",
|
||||
envVar: "XAI_API_KEY",
|
||||
fields: [
|
||||
{
|
||||
key: "XAI_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
tier: "advanced",
|
||||
},
|
||||
{
|
||||
id: "groq",
|
||||
displayName: "Groq",
|
||||
category: "model",
|
||||
description: "Fast inference with Groq hardware",
|
||||
setupMethod: "single_api_key",
|
||||
envVar: "GROQ_API_KEY",
|
||||
fields: [
|
||||
{
|
||||
key: "GROQ_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://console.groq.com/keys",
|
||||
tier: "advanced",
|
||||
},
|
||||
{
|
||||
id: "azure_openai",
|
||||
displayName: "Azure OpenAI",
|
||||
category: "model",
|
||||
description: "OpenAI models on Azure",
|
||||
setupMethod: "config_fields",
|
||||
fields: [
|
||||
{
|
||||
key: "AZURE_OPENAI_ENDPOINT",
|
||||
label: "Endpoint",
|
||||
secret: false,
|
||||
required: true,
|
||||
placeholder: "https://your-resource.openai.azure.com",
|
||||
},
|
||||
{
|
||||
key: "AZURE_OPENAI_DEPLOYMENT_NAME",
|
||||
label: "Deployment",
|
||||
secret: false,
|
||||
required: true,
|
||||
placeholder: "gpt-4o",
|
||||
},
|
||||
{
|
||||
key: "AZURE_OPENAI_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: false,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
tier: "advanced",
|
||||
},
|
||||
{
|
||||
id: "aws_bedrock",
|
||||
displayName: "AWS Bedrock",
|
||||
category: "model",
|
||||
description: "Models on AWS",
|
||||
setupMethod: "cloud_credentials",
|
||||
fields: [
|
||||
{
|
||||
key: "AWS_REGION",
|
||||
label: "AWS Region",
|
||||
secret: false,
|
||||
required: false,
|
||||
placeholder: "us-west-2",
|
||||
},
|
||||
],
|
||||
tier: "advanced",
|
||||
},
|
||||
{
|
||||
id: "gcp_vertex_ai",
|
||||
displayName: "GCP Vertex AI",
|
||||
category: "model",
|
||||
description: "Models on Google Cloud",
|
||||
setupMethod: "cloud_credentials",
|
||||
fields: [
|
||||
{
|
||||
key: "GCP_PROJECT_ID",
|
||||
label: "Project ID",
|
||||
secret: false,
|
||||
required: true,
|
||||
placeholder: "my-gcp-project",
|
||||
},
|
||||
{
|
||||
key: "GCP_LOCATION",
|
||||
label: "Location",
|
||||
secret: false,
|
||||
required: true,
|
||||
placeholder: "us-central1",
|
||||
},
|
||||
],
|
||||
tier: "advanced",
|
||||
},
|
||||
{
|
||||
id: "litellm",
|
||||
displayName: "LiteLLM",
|
||||
category: "model",
|
||||
description: "LiteLLM proxy gateway",
|
||||
setupMethod: "config_fields",
|
||||
envVar: "LITELLM_API_KEY",
|
||||
fields: [
|
||||
{
|
||||
key: "LITELLM_HOST",
|
||||
label: "Host URL",
|
||||
secret: false,
|
||||
required: true,
|
||||
placeholder: "https://your-proxy.example.com",
|
||||
},
|
||||
{
|
||||
key: "LITELLM_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: false,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
tier: "advanced",
|
||||
},
|
||||
{
|
||||
id: "lmstudio",
|
||||
displayName: "LM Studio",
|
||||
category: "model",
|
||||
description: "Run local models with LM Studio",
|
||||
setupMethod: "config_fields",
|
||||
fields: [
|
||||
{
|
||||
key: "LMSTUDIO_HOST",
|
||||
label: "Host URL",
|
||||
secret: false,
|
||||
required: false,
|
||||
placeholder: "http://localhost:1234/v1/chat/completions",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://lmstudio.ai/docs/app/api",
|
||||
tier: "advanced",
|
||||
},
|
||||
{
|
||||
id: "nvidia",
|
||||
displayName: "NVIDIA",
|
||||
category: "model",
|
||||
description: "Hosted NVIDIA NIM models",
|
||||
setupMethod: "single_api_key",
|
||||
envVar: "NVIDIA_API_KEY",
|
||||
fields: [
|
||||
{
|
||||
key: "NVIDIA_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://build.nvidia.com/models",
|
||||
tier: "advanced",
|
||||
},
|
||||
{
|
||||
id: "cerebras",
|
||||
displayName: "Cerebras",
|
||||
category: "model",
|
||||
description: "Fast inference on Cerebras wafer-scale engines",
|
||||
setupMethod: "single_api_key",
|
||||
envVar: "CEREBRAS_API_KEY",
|
||||
fields: [
|
||||
{
|
||||
key: "CEREBRAS_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
docsUrl: "https://cloud.cerebras.ai/platform",
|
||||
tier: "advanced",
|
||||
},
|
||||
{
|
||||
id: "snowflake",
|
||||
displayName: "Snowflake",
|
||||
category: "model",
|
||||
description: "Snowflake Cortex",
|
||||
setupMethod: "config_fields",
|
||||
fields: [
|
||||
{
|
||||
key: "SNOWFLAKE_HOST",
|
||||
label: "Host URL",
|
||||
secret: false,
|
||||
required: true,
|
||||
placeholder: "https://your-account.snowflakecomputing.com",
|
||||
},
|
||||
{
|
||||
key: "SNOWFLAKE_TOKEN",
|
||||
label: "Access Token",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your access token",
|
||||
},
|
||||
],
|
||||
tier: "advanced",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,99 @@
|
||||
import { create } from "zustand";
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
|
||||
export const GOOSE_PROVIDER_CATALOG_ENTRY: ProviderCatalogEntry = {
|
||||
id: "goose",
|
||||
displayName: "Goose",
|
||||
category: "agent",
|
||||
description: "Block's open-source coding agent",
|
||||
setupMethod: "none",
|
||||
group: "default",
|
||||
aliases: ["goose"],
|
||||
};
|
||||
|
||||
function withGooseFallback(
|
||||
entries: ProviderCatalogEntry[],
|
||||
): ProviderCatalogEntry[] {
|
||||
if (entries.some((entry) => entry.id === GOOSE_PROVIDER_CATALOG_ENTRY.id)) {
|
||||
return entries;
|
||||
}
|
||||
return [GOOSE_PROVIDER_CATALOG_ENTRY, ...entries];
|
||||
}
|
||||
|
||||
export interface ProviderCatalogState {
|
||||
entries: ProviderCatalogEntry[];
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface ProviderCatalogActions {
|
||||
load: () => Promise<ProviderCatalogEntry[]>;
|
||||
setEntries: (entries: ProviderCatalogEntry[]) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export type ProviderCatalogStore = ProviderCatalogState &
|
||||
ProviderCatalogActions;
|
||||
|
||||
let loadPromise: Promise<ProviderCatalogEntry[]> | null = null;
|
||||
|
||||
function emptyState(): ProviderCatalogState {
|
||||
return {
|
||||
entries: [GOOSE_PROVIDER_CATALOG_ENTRY],
|
||||
loading: false,
|
||||
loaded: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const useProviderCatalogStore = create<ProviderCatalogStore>(
|
||||
(set, get) => ({
|
||||
...emptyState(),
|
||||
|
||||
load: async () => {
|
||||
if (loadPromise) {
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
const current = get();
|
||||
if (current.loaded) {
|
||||
return current.entries;
|
||||
}
|
||||
|
||||
set({ loading: true, error: null });
|
||||
loadPromise = import("../api/catalog")
|
||||
.then(({ listProviderSetupCatalog }) => listProviderSetupCatalog())
|
||||
.then((entries) => {
|
||||
get().setEntries(entries);
|
||||
return entries;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load catalog";
|
||||
set({ loading: false, loaded: false, error: message });
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
loadPromise = null;
|
||||
});
|
||||
|
||||
return loadPromise;
|
||||
},
|
||||
|
||||
setEntries: (entries) => {
|
||||
const nextEntries = withGooseFallback(entries);
|
||||
set({
|
||||
entries: nextEntries,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
loadPromise = null;
|
||||
set(emptyState());
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -33,8 +33,9 @@ interface AgentProviderCardProps {
|
||||
export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
const { t } = useTranslation(["settings", "common"]);
|
||||
const isBuiltIn = provider.status === "built_in";
|
||||
const hasInstallCommand = !!provider.installCommand;
|
||||
const hasAuthCommand = !!provider.authCommand;
|
||||
const supportsInstall = provider.supportsInstall === true;
|
||||
const supportsAuth = provider.supportsAuth === true;
|
||||
const supportsAuthStatus = provider.supportsAuthStatus === true;
|
||||
const hasBinary = !!provider.binaryName;
|
||||
const [setupPhase, setSetupPhase] = useState<SetupPhase>("idle");
|
||||
const [setupOutput, setSetupOutput] = useState<OutputLine[]>([]);
|
||||
@@ -44,9 +45,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
hasBinary && !isBuiltIn ? "checking" : "installed",
|
||||
);
|
||||
const [authStatus, setAuthStatus] = useState<AuthStatus>(
|
||||
provider.authStatusCommand && hasBinary && !isBuiltIn
|
||||
? "checking"
|
||||
: "unknown",
|
||||
supportsAuthStatus && hasBinary && !isBuiltIn ? "checking" : "unknown",
|
||||
);
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
const outputLengthRef = useRef(0);
|
||||
@@ -93,13 +92,13 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
.then((installed) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setInstallStatus(installed ? "installed" : "missing");
|
||||
if (installed && provider.authStatusCommand) {
|
||||
if (installed && supportsAuthStatus) {
|
||||
return checkAgentAuth(provider.id).then((authenticated) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setAuthStatus(authenticated ? "authenticated" : "unauthenticated");
|
||||
});
|
||||
}
|
||||
if (installed && !provider.authStatusCommand) {
|
||||
if (installed && !supportsAuthStatus) {
|
||||
setAuthStatus(getAuthHint() ? "authenticated" : "unknown");
|
||||
}
|
||||
if (!installed) {
|
||||
@@ -118,7 +117,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
isBuiltIn,
|
||||
provider.id,
|
||||
provider.binaryName,
|
||||
provider.authStatusCommand,
|
||||
supportsAuthStatus,
|
||||
setAuthHint,
|
||||
]);
|
||||
|
||||
@@ -145,15 +144,15 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
setSetupOutput([]);
|
||||
lineCounterRef.current = 0;
|
||||
|
||||
if (hasInstallCommand && installStatus === "missing") {
|
||||
if (supportsInstall && installStatus === "missing") {
|
||||
await runInstall();
|
||||
} else if (hasAuthCommand) {
|
||||
} else if (supportsAuth) {
|
||||
await runAuth();
|
||||
}
|
||||
}
|
||||
|
||||
async function runInstall() {
|
||||
if (!provider.installCommand) return;
|
||||
if (!supportsInstall) return;
|
||||
setSetupPhase("installing");
|
||||
|
||||
clearListener();
|
||||
@@ -183,7 +182,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAuthCommand) {
|
||||
if (supportsAuth) {
|
||||
await runAuth();
|
||||
} else {
|
||||
if (!isMountedRef.current) return;
|
||||
@@ -198,7 +197,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
}
|
||||
|
||||
async function runAuth() {
|
||||
if (!provider.authCommand) return;
|
||||
if (!supportsAuth) return;
|
||||
setSetupPhase("authenticating");
|
||||
setSetupOutput([]);
|
||||
|
||||
@@ -232,14 +231,14 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
|
||||
const isReady =
|
||||
isBuiltIn ||
|
||||
(installStatus === "installed" && !hasAuthCommand) ||
|
||||
(installStatus === "installed" && !supportsAuth) ||
|
||||
(installStatus === "installed" && authStatus === "authenticated");
|
||||
const needsAuth =
|
||||
installStatus === "installed" &&
|
||||
hasAuthCommand &&
|
||||
supportsAuth &&
|
||||
authStatus !== "checking" &&
|
||||
authStatus !== "authenticated";
|
||||
const needsInstall = installStatus === "missing" && hasInstallCommand;
|
||||
const needsInstall = installStatus === "missing" && supportsInstall;
|
||||
const isChecking =
|
||||
(installStatus === "checking" && hasBinary) ||
|
||||
(installStatus === "installed" && authStatus === "checking");
|
||||
@@ -388,9 +387,9 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
: t("providers.agents.progress.verifyingInstallation");
|
||||
|
||||
const stepInfo =
|
||||
setupPhase === "installing" && hasAuthCommand
|
||||
setupPhase === "installing" && supportsAuth
|
||||
? t("providers.agents.progress.step", { step: 1, total: 2 })
|
||||
: setupPhase === "authenticating" && hasInstallCommand
|
||||
: setupPhase === "authenticating" && supportsInstall
|
||||
? t("providers.agents.progress.step", { step: 2, total: 2 })
|
||||
: null;
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export function ConnectedFieldsPanel({
|
||||
field.secret &&
|
||||
resolveFieldValue(field, fieldValueMap).isSet
|
||||
? getDisplayValue(field, fieldValueMap, t)
|
||||
: field.placeholder
|
||||
: (field.placeholder ?? undefined)
|
||||
}
|
||||
onChange={(event) =>
|
||||
onDraftChange(field.key, event.target.value)
|
||||
@@ -273,7 +273,7 @@ export function SetupFieldsPanel({
|
||||
placeholder={
|
||||
field.secret && fieldValue.isSet
|
||||
? getDisplayValue(field, fieldValueMap, t)
|
||||
: field.placeholder
|
||||
: (field.placeholder ?? undefined)
|
||||
}
|
||||
onChange={(event) => onDraftChange(field.key, event.target.value)}
|
||||
disabled={saving}
|
||||
|
||||
@@ -15,8 +15,8 @@ import { Separator } from "@/shared/ui/separator";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { IconChevronDown, IconPlus } from "@tabler/icons-react";
|
||||
import {
|
||||
getAgentProviders,
|
||||
getModelProviders,
|
||||
getAgentProvidersFromEntries,
|
||||
getModelProvidersFromEntries,
|
||||
} from "@/features/providers/providerCatalog";
|
||||
import { useCredentials } from "@/features/providers/hooks/useCredentials";
|
||||
import { useDistroStore } from "@/features/settings/stores/distroStore";
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
ProviderTemplate,
|
||||
} from "@/features/providers/ui/CustomProviderForm";
|
||||
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import { AgentProviderCard } from "./AgentProviderCard";
|
||||
import { ModelProviderRow } from "./ModelProviderRow";
|
||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||
@@ -120,6 +121,11 @@ export function ProvidersSettings() {
|
||||
const [pendingCustomProviderDelete, setPendingCustomProviderDelete] =
|
||||
useState<PendingCustomProviderDelete | null>(null);
|
||||
const inventoryEntries = useProviderInventoryStore((state) => state.entries);
|
||||
const catalogEntries = useProviderCatalogStore((state) => state.entries);
|
||||
const catalogLoading = useProviderCatalogStore((state) => state.loading);
|
||||
const catalogLoaded = useProviderCatalogStore((state) => state.loaded);
|
||||
const catalogError = useProviderCatalogStore((state) => state.error);
|
||||
const loadCatalog = useProviderCatalogStore((state) => state.load);
|
||||
|
||||
const {
|
||||
configuredIds,
|
||||
@@ -135,17 +141,24 @@ export function ProvidersSettings() {
|
||||
const customProvidersApi = useCustomProviders();
|
||||
|
||||
const agents = useMemo(
|
||||
() => toDisplayInfo(getAgentProviders(), configuredIds),
|
||||
[configuredIds],
|
||||
() =>
|
||||
toDisplayInfo(
|
||||
getAgentProvidersFromEntries(catalogEntries),
|
||||
configuredIds,
|
||||
),
|
||||
[configuredIds, catalogEntries],
|
||||
);
|
||||
|
||||
const allModels = useMemo(
|
||||
() =>
|
||||
toDisplayInfo(
|
||||
filterModelProvidersForDistro(getModelProviders(), distro),
|
||||
filterModelProvidersForDistro(
|
||||
getModelProvidersFromEntries(catalogEntries),
|
||||
distro,
|
||||
),
|
||||
configuredIds,
|
||||
),
|
||||
[configuredIds, distro],
|
||||
[configuredIds, distro, catalogEntries],
|
||||
);
|
||||
|
||||
const sortedModels = useMemo(() => {
|
||||
@@ -159,10 +172,10 @@ export function ProvidersSettings() {
|
||||
}, [allModels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && modelOrder === null) {
|
||||
if (!loading && catalogLoaded && modelOrder === null) {
|
||||
setModelOrder(sortedModels.map((model) => model.id));
|
||||
}
|
||||
}, [loading, modelOrder, sortedModels]);
|
||||
}, [loading, catalogLoaded, modelOrder, sortedModels]);
|
||||
|
||||
const orderedModels = useMemo(() => {
|
||||
if (!modelOrder) {
|
||||
@@ -190,11 +203,11 @@ export function ProvidersSettings() {
|
||||
});
|
||||
}, [allModels, modelOrder, sortedModels]);
|
||||
|
||||
const promotedModels = orderedModels.filter(
|
||||
(m) => m.tier === "promoted" || m.tier === "standard",
|
||||
const defaultModels = orderedModels.filter((m) => m.group === "default");
|
||||
const additionalModels = orderedModels.filter(
|
||||
(m) => m.group === "additional",
|
||||
);
|
||||
const advancedModels = orderedModels.filter((m) => m.tier === "advanced");
|
||||
const visibleModels = showAllModels ? orderedModels : promotedModels;
|
||||
const visibleModels = showAllModels ? orderedModels : defaultModels;
|
||||
|
||||
const customProviders = useMemo(
|
||||
() =>
|
||||
@@ -329,6 +342,35 @@ export function ProvidersSettings() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{catalogError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 rounded-md border border-danger/30 bg-danger/10 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-danger">{catalogError}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => void loadCatalog()}
|
||||
>
|
||||
{t("common:actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalogLoading && (
|
||||
<div
|
||||
role="status"
|
||||
className="mb-4 flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<Spinner className="size-3.5" />
|
||||
{t("providers.catalog.loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold">
|
||||
@@ -417,7 +459,7 @@ export function ProvidersSettings() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!showAllModels && advancedModels.length > 0 && (
|
||||
{!showAllModels && additionalModels.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -425,12 +467,12 @@ export function ProvidersSettings() {
|
||||
onClick={() => setShowAllModels(true)}
|
||||
className="mt-2 w-full text-muted-foreground"
|
||||
>
|
||||
{t("providers.showMore", { count: advancedModels.length })}
|
||||
{t("providers.showMore", { count: additionalModels.length })}
|
||||
<IconChevronDown className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showAllModels && advancedModels.length > 0 && (
|
||||
{showAllModels && additionalModels.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "@/test/render";
|
||||
import { AgentProviderCard } from "../AgentProviderCard";
|
||||
@@ -7,12 +6,11 @@ import type { ProviderDisplayInfo } from "@/shared/types/providers";
|
||||
|
||||
const checkAgentInstalled = vi.fn();
|
||||
const checkAgentAuth = vi.fn();
|
||||
const installAgent = vi.fn();
|
||||
|
||||
vi.mock("@/features/providers/api/agentSetup", () => ({
|
||||
checkAgentInstalled: (...args: unknown[]) => checkAgentInstalled(...args),
|
||||
checkAgentAuth: (...args: unknown[]) => checkAgentAuth(...args),
|
||||
installAgent: (...args: unknown[]) => installAgent(...args),
|
||||
installAgent: vi.fn(),
|
||||
authenticateAgent: vi.fn(),
|
||||
onAgentSetupOutput: vi.fn(async () => vi.fn()),
|
||||
}));
|
||||
@@ -25,9 +23,9 @@ function createProvider(): ProviderDisplayInfo {
|
||||
description: "Claude provider",
|
||||
setupMethod: "cli_auth",
|
||||
binaryName: "claude",
|
||||
authCommand: "claude auth login",
|
||||
authStatusCommand: "claude auth status",
|
||||
tier: "standard",
|
||||
supportsAuth: true,
|
||||
supportsAuthStatus: true,
|
||||
group: "default",
|
||||
status: "not_installed",
|
||||
};
|
||||
}
|
||||
@@ -57,7 +55,6 @@ describe("AgentProviderCard", () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(checkAgentAuth).toHaveBeenCalled();
|
||||
|
||||
expect(screen.queryByRole("status", { name: "Checking..." })).toBeNull();
|
||||
expect(screen.queryByText("Checking...")).not.toBeInTheDocument();
|
||||
@@ -101,31 +98,4 @@ describe("AgentProviderCard", () => {
|
||||
|
||||
expect(screen.queryByRole("status", { name: "Checking..." })).toBeNull();
|
||||
});
|
||||
|
||||
it("checks installation by provider id after installing", async () => {
|
||||
const user = userEvent.setup();
|
||||
checkAgentInstalled
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
installAgent.mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(
|
||||
<AgentProviderCard
|
||||
provider={{
|
||||
...createProvider(),
|
||||
authCommand: undefined,
|
||||
authStatusCommand: undefined,
|
||||
installCommand: "npm install -g claude-agent-acp",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /install claude/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkAgentInstalled).toHaveBeenNthCalledWith(2, "claude-acp");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import userEvent from "@testing-library/user-event";
|
||||
import { useState, type ComponentType } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getModelProviders } from "@/features/providers/providerCatalog";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
import { ModelProviderRow } from "../ModelProviderRow";
|
||||
|
||||
const Row = ModelProviderRow as unknown as ComponentType<
|
||||
@@ -20,6 +22,76 @@ function modelProvider(id: string, status: "connected" | "not_configured") {
|
||||
};
|
||||
}
|
||||
|
||||
const providerCatalog: ProviderCatalogEntry[] = [
|
||||
{
|
||||
id: "databricks",
|
||||
displayName: "Databricks",
|
||||
category: "model",
|
||||
description: "Databricks Foundation Models",
|
||||
setupMethod: "host_with_oauth_fallback",
|
||||
fields: [
|
||||
{
|
||||
key: "DATABRICKS_HOST",
|
||||
label: "Host URL",
|
||||
secret: false,
|
||||
required: true,
|
||||
placeholder: "https://dbc-...cloud.databricks.com",
|
||||
},
|
||||
{
|
||||
key: "DATABRICKS_TOKEN",
|
||||
label: "Access Token",
|
||||
secret: true,
|
||||
required: false,
|
||||
placeholder: "Paste your access token",
|
||||
},
|
||||
],
|
||||
group: "default",
|
||||
},
|
||||
{
|
||||
id: "ollama",
|
||||
displayName: "Ollama",
|
||||
category: "model",
|
||||
description: "Run local or self-hosted models",
|
||||
setupMethod: "config_fields",
|
||||
fields: [
|
||||
{
|
||||
key: "OLLAMA_HOST",
|
||||
label: "Host",
|
||||
secret: false,
|
||||
required: true,
|
||||
placeholder: "localhost or http://localhost:11434",
|
||||
defaultValue: "http://localhost:11434",
|
||||
},
|
||||
],
|
||||
group: "default",
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
category: "model",
|
||||
description: "Claude models",
|
||||
setupMethod: "single_api_key",
|
||||
group: "default",
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
displayName: "Google Gemini",
|
||||
category: "model",
|
||||
description: "Gemini models",
|
||||
setupMethod: "single_api_key",
|
||||
fields: [
|
||||
{
|
||||
key: "GOOGLE_API_KEY",
|
||||
label: "API Key",
|
||||
secret: true,
|
||||
required: true,
|
||||
placeholder: "Paste your API key",
|
||||
},
|
||||
],
|
||||
group: "default",
|
||||
},
|
||||
];
|
||||
|
||||
describe("ModelProviderRow", () => {
|
||||
const onGetConfig = vi.fn();
|
||||
const onSaveFields = vi.fn();
|
||||
@@ -28,6 +100,7 @@ describe("ModelProviderRow", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useProviderCatalogStore.getState().setEntries(providerCatalog);
|
||||
onGetConfig.mockResolvedValue([]);
|
||||
onSaveFields.mockResolvedValue(undefined);
|
||||
onRemoveConfig.mockResolvedValue(undefined);
|
||||
|
||||
@@ -3,6 +3,8 @@ import userEvent from "@testing-library/user-event";
|
||||
import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import type { ProviderCatalogEntry } from "@/shared/types/providers";
|
||||
import { ProvidersSettings } from "../ProvidersSettings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -28,6 +30,7 @@ function providerEntry(
|
||||
defaultModel: "",
|
||||
configured: true,
|
||||
providerType: "Custom",
|
||||
category: "model",
|
||||
configKeys: [],
|
||||
setupSteps: [],
|
||||
supportsRefresh: true,
|
||||
@@ -38,10 +41,46 @@ function providerEntry(
|
||||
};
|
||||
}
|
||||
|
||||
const providerCatalog: ProviderCatalogEntry[] = [
|
||||
{
|
||||
id: "goose",
|
||||
displayName: "Goose",
|
||||
category: "agent",
|
||||
description: "Block's open-source coding agent",
|
||||
setupMethod: "none",
|
||||
group: "default",
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
displayName: "OpenAI",
|
||||
category: "model",
|
||||
description: "GPT and o-series models",
|
||||
setupMethod: "config_fields",
|
||||
group: "default",
|
||||
},
|
||||
{
|
||||
id: "databricks",
|
||||
displayName: "Databricks",
|
||||
category: "model",
|
||||
description: "Databricks Foundation Models",
|
||||
setupMethod: "host_with_oauth_fallback",
|
||||
group: "default",
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
category: "model",
|
||||
description: "Claude models",
|
||||
setupMethod: "single_api_key",
|
||||
group: "default",
|
||||
},
|
||||
];
|
||||
|
||||
describe("ProvidersSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
useProviderCatalogStore.getState().setEntries(providerCatalog);
|
||||
useProviderInventoryStore.getState().setEntries([]);
|
||||
mocks.useCredentials.mockReturnValue({
|
||||
configuredIds: new Set<string>(),
|
||||
@@ -89,7 +128,7 @@ describe("ProvidersSettings", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the static provider catalog while credential status is loading", () => {
|
||||
it("renders the loaded provider catalog while credential status is loading", () => {
|
||||
mocks.useCredentials.mockReturnValue({
|
||||
configuredIds: new Set<string>(),
|
||||
loading: true,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
CustomProviderDraft,
|
||||
CustomProviderEngine,
|
||||
CustomProviderReadResponse,
|
||||
ProviderCatalogEntryDto,
|
||||
ProviderTemplateCatalogEntryDto,
|
||||
ProviderTemplateDto,
|
||||
} from "@/features/providers/lib/customProviderTypes";
|
||||
import type { CustomProviderMutationInput } from "@/features/providers/ui/CustomProviderDialog";
|
||||
@@ -47,7 +47,7 @@ export function templateToFormValue(
|
||||
}
|
||||
|
||||
export function catalogEntryToTemplate(
|
||||
entry: ProviderCatalogEntryDto,
|
||||
entry: ProviderTemplateCatalogEntryDto,
|
||||
): ProviderTemplate {
|
||||
return {
|
||||
id: entry.providerId,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import { discoverAcpProvidersFromEntries } from "../acp";
|
||||
|
||||
describe("discoverAcpProvidersFromEntries", () => {
|
||||
beforeEach(() => {
|
||||
useProviderCatalogStore.getState().reset();
|
||||
});
|
||||
|
||||
it("preserves agent inventory entries when the setup catalog has not loaded", () => {
|
||||
expect(
|
||||
discoverAcpProvidersFromEntries([
|
||||
{
|
||||
providerId: "codex-acp",
|
||||
providerName: "Codex",
|
||||
category: "agent",
|
||||
},
|
||||
{
|
||||
providerId: "openai",
|
||||
providerName: "OpenAI",
|
||||
category: "model",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{ id: "goose", label: "Goose" },
|
||||
{ id: "codex-acp", label: "Codex" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ export async function discoverAcpProviders(): Promise<AcpProvider[]> {
|
||||
* avoiding a duplicate `_goose/providers/list` RPC.
|
||||
*/
|
||||
export function discoverAcpProvidersFromEntries(
|
||||
entries: Array<{ providerId: string; providerName: string }>,
|
||||
entries: Parameters<typeof directAcp.buildProviderListFromEntries>[0],
|
||||
): AcpProvider[] {
|
||||
return resolveProvidersCatalog(
|
||||
directAcp.buildProviderListFromEntries(entries),
|
||||
@@ -60,13 +60,14 @@ function resolveProvidersCatalog(providers: AcpProvider[]): AcpProvider[] {
|
||||
provider.id,
|
||||
provider.label,
|
||||
);
|
||||
if (!catalogId || seen.has(catalogId)) {
|
||||
const resolvedId = catalogId ?? provider.id;
|
||||
if (seen.has(resolvedId)) {
|
||||
return null;
|
||||
}
|
||||
seen.add(catalogId);
|
||||
seen.add(resolvedId);
|
||||
return {
|
||||
id: catalogId,
|
||||
label: getCatalogEntry(catalogId)?.displayName ?? provider.label,
|
||||
id: resolvedId,
|
||||
label: getCatalogEntry(resolvedId)?.displayName ?? provider.label,
|
||||
};
|
||||
})
|
||||
.filter((provider): provider is AcpProvider => provider !== null);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
PromptResponse,
|
||||
SessionInfo,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk";
|
||||
import { getClient } from "./acpConnection";
|
||||
import { perfLog } from "@/shared/lib/perfLog";
|
||||
|
||||
@@ -46,12 +47,15 @@ export const DEFAULT_PROVIDER: AcpProvider = {
|
||||
* already-fetched entries at startup).
|
||||
*/
|
||||
export function buildProviderListFromEntries(
|
||||
entries: Array<{ providerId: string; providerName: string }>,
|
||||
entries: Array<
|
||||
Pick<ProviderInventoryEntryDto, "providerId" | "providerName" | "category">
|
||||
>,
|
||||
): AcpProvider[] {
|
||||
return [
|
||||
DEFAULT_PROVIDER,
|
||||
...entries
|
||||
.filter((entry) => !DEPRECATED_PROVIDER_IDS.has(entry.providerId))
|
||||
.filter((entry) => entry.category === "agent")
|
||||
.map((entry) => ({ id: entry.providerId, label: entry.providerName })),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -332,6 +332,9 @@
|
||||
"useTemplateDescription": "Start with endpoint and model defaults for a known provider."
|
||||
}
|
||||
},
|
||||
"catalog": {
|
||||
"loading": "Loading provider catalog..."
|
||||
},
|
||||
"disconnect": "Disconnect",
|
||||
"models": {
|
||||
"description": "AI models power your agents. Goose requires one to work, but some agents bring their own.",
|
||||
|
||||
@@ -332,6 +332,9 @@
|
||||
"useTemplateDescription": "Comienza con valores predeterminados de endpoint y modelos para un proveedor conocido."
|
||||
}
|
||||
},
|
||||
"catalog": {
|
||||
"loading": "Cargando catálogo de proveedores..."
|
||||
},
|
||||
"disconnect": "Desconectar",
|
||||
"models": {
|
||||
"description": "Necesitas al menos un proveedor de modelos para usar el agente Goose. Algunos agentes pueden traer sus propias conexiones de modelo.",
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
export type ProviderCategory = "agent" | "model";
|
||||
import type {
|
||||
ProviderSetupCatalogEntryDto,
|
||||
ProviderSetupCategoryDto,
|
||||
ProviderSetupFieldDto,
|
||||
ProviderSetupMethodDto,
|
||||
ProviderSetupGroupDto,
|
||||
} from "@aaif/goose-sdk";
|
||||
|
||||
export type ProviderSetupMethod =
|
||||
| "none"
|
||||
| "single_api_key"
|
||||
| "config_fields"
|
||||
| "host_with_oauth_fallback"
|
||||
| "oauth_browser"
|
||||
| "oauth_device_code"
|
||||
| "cloud_credentials"
|
||||
| "local"
|
||||
| "cli_auth";
|
||||
|
||||
export type ProviderTier = "promoted" | "standard" | "advanced";
|
||||
|
||||
export interface ProviderField {
|
||||
key: string;
|
||||
label: string;
|
||||
secret: boolean;
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
export type ProviderCategory = ProviderSetupCategoryDto;
|
||||
export type ProviderSetupMethod = ProviderSetupMethodDto;
|
||||
export type ProviderGroup = ProviderSetupGroupDto;
|
||||
export type ProviderField = ProviderSetupFieldDto;
|
||||
|
||||
export interface ProviderFieldValue {
|
||||
key: string;
|
||||
@@ -30,23 +19,30 @@ export interface ProviderFieldValue {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderCatalogEntry {
|
||||
export type ProviderCatalogEntry = Omit<
|
||||
ProviderSetupCatalogEntryDto,
|
||||
| "providerId"
|
||||
| "name"
|
||||
| "nativeConnectQuery"
|
||||
| "binaryName"
|
||||
| "docUrl"
|
||||
| "showOnlyWhenInstalled"
|
||||
| "supportsInstall"
|
||||
| "supportsAuth"
|
||||
| "supportsAuthStatus"
|
||||
> & {
|
||||
id: string;
|
||||
displayName: string;
|
||||
category: ProviderCategory;
|
||||
description: string;
|
||||
setupMethod: ProviderSetupMethod;
|
||||
nativeConnectQuery?: string;
|
||||
envVar?: string;
|
||||
fields?: ProviderField[];
|
||||
binaryName?: string;
|
||||
installCommand?: string;
|
||||
authCommand?: string;
|
||||
authStatusCommand?: string;
|
||||
docsUrl?: string;
|
||||
tier: ProviderTier;
|
||||
showOnlyWhenInstalled?: boolean;
|
||||
}
|
||||
nativeConnectQuery?: NonNullable<
|
||||
ProviderSetupCatalogEntryDto["nativeConnectQuery"]
|
||||
>;
|
||||
binaryName?: NonNullable<ProviderSetupCatalogEntryDto["binaryName"]>;
|
||||
docsUrl?: NonNullable<ProviderSetupCatalogEntryDto["docUrl"]>;
|
||||
showOnlyWhenInstalled?: ProviderSetupCatalogEntryDto["showOnlyWhenInstalled"];
|
||||
supportsInstall?: ProviderSetupCatalogEntryDto["supportsInstall"];
|
||||
supportsAuth?: ProviderSetupCatalogEntryDto["supportsAuth"];
|
||||
supportsAuthStatus?: ProviderSetupCatalogEntryDto["supportsAuthStatus"];
|
||||
};
|
||||
|
||||
export type ProviderSetupStatus =
|
||||
| "built_in"
|
||||
|
||||
Reference in New Issue
Block a user