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:
Kalvin C
2026-05-05 18:36:19 -07:00
committed by GitHub
parent 8464718588
commit b17fd4f287
56 changed files with 3235 additions and 895 deletions
@@ -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" }]);
});
});
+73 -19
View File
@@ -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" },
]);
});
});
+6 -5
View File
@@ -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 -1
View File
@@ -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.",
+33 -37
View File
@@ -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"