diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 2d7b20132b..abe3d5f28c 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -23,6 +23,7 @@ import { chatMessagesForInfiniteScroll, chatModelConfigs, chatModels, + chatProviderConfigs, createChatMessage, deleteChatQueuedMessage, editChatMessage, @@ -57,6 +58,7 @@ import { } from "./AgentChatPageView"; import type { AgentsOutletContext } from "./AgentsPage"; import type { ChatMessageInputRef } from "./components/AgentChatInput"; +import { AgentSetupNotice } from "./components/AgentSetupNotice"; import { normalizeChatErrorPayload } from "./components/ChatConversation/chatError"; import { getParentChatID, @@ -80,6 +82,7 @@ import { getModelSelectorHelp } from "./components/ModelSelectorHelp"; import { useGitWatcher } from "./hooks/useGitWatcher"; import { type ParsedDraft, parseStoredDraft } from "./utils/draftStorage"; import { + countConfiguredProviderConfigs, getModelOptionsFromConfigs, getModelSelectorPlaceholder, hasConfiguredModelsInCatalog, @@ -645,7 +648,7 @@ const AgentChatPage: FC = () => { scrollContainerRef, } = useOutletContext(); const queryClient = useQueryClient(); - const { user: currentUser } = useAuthenticated(); + const { permissions, user: currentUser } = useAuthenticated(); const [selectedModel, setSelectedModel] = useState(""); const scrollToBottomRef = useRef<(() => void) | null>(null); const chatInputRef = useRef(null); @@ -698,6 +701,10 @@ const AgentChatPage: FC = () => { const chatModelsQuery = useQuery(chatModels()); const chatModelConfigsQuery = useQuery(chatModelConfigs()); + const chatProviderConfigsQuery = useQuery({ + ...chatProviderConfigs(), + enabled: permissions.editDeploymentConfig, + }); const userThresholdsQuery = useQuery(userCompactionThresholds()); const desktopEnabledQuery = useQuery(chatDesktopEnabled()); const userDebugLoggingQuery = useQuery(userChatDebugLogging()); @@ -731,6 +738,19 @@ const AgentChatPage: FC = () => { chatModelsQuery.data, ); const modelConfigs = chatModelConfigsQuery.data ?? []; + const providerCount = + permissions.editDeploymentConfig && + chatProviderConfigsQuery.isSuccess && + chatModelsQuery.isSuccess + ? countConfiguredProviderConfigs( + chatProviderConfigsQuery.data, + chatModelsQuery.data, + ) + : undefined; + const modelCount = + chatModelConfigsQuery.isSuccess && chatModelsQuery.isSuccess + ? modelOptions.length + : undefined; const modelCatalog = chatModelsQuery.data; const isModelCatalogLoading = chatModelsQuery.isLoading; @@ -1035,6 +1055,12 @@ const AgentChatPage: FC = () => { hasConfiguredModels, hasUserFixableModelProviders, }); + const agentSetupNotice = + providerCount !== undefined && + modelCount !== undefined && + (providerCount === 0 || modelCount === 0) ? ( + + ) : undefined; const isSubmissionPending = isSendPending || isEditPending || isInterruptPending; const isChatSettingsPending = @@ -1483,6 +1509,7 @@ const AgentChatPage: FC = () => { modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} modelSelectorHelp={modelSelectorHelp} + agentSetupNotice={agentSetupNotice} hasModelOptions={hasModelOptions} isModelCatalogLoading={isModelCatalogLoading} planModeEnabled={planModeEnabled} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index ce06574414..bc8021b180 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -22,6 +22,7 @@ import { AgentChatPageNotFoundView, AgentChatPageView, } from "./AgentChatPageView"; +import { AgentSetupNotice } from "./components/AgentSetupNotice"; import { createChatStore, useChatSelector, @@ -442,6 +443,96 @@ export const NoModelOptions: Story = { ), }; +export const MissingProviderAndModelSetup: Story = { + render: () => ( + } + hasModelOptions={false} + modelOptions={[]} + isInputDisabled + /> + ), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const dialog = within( + body.getByRole("dialog", { name: "Welcome to Coder Agents" }), + ); + + await waitFor(() => { + expect(dialog.getByText("Welcome to Coder Agents")).toBeVisible(); + }); + expect(dialog.getByText("Connect a chat provider")).toBeVisible(); + expect(dialog.getByText("Add a chat model")).toBeVisible(); + expect(dialog.queryByLabelText("Complete")).not.toBeInTheDocument(); + expect( + dialog.getByRole("link", { name: "Go to Providers" }), + ).toHaveAttribute("href", "/agents/settings/providers"); + expect(dialog.getByRole("link", { name: "Go to Models" })).toHaveAttribute( + "href", + "/agents/settings/models", + ); + }, +}; + +export const MissingModelSetup: Story = { + render: () => ( + } + hasModelOptions={false} + modelOptions={[]} + isInputDisabled + /> + ), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const dialog = within( + body.getByRole("dialog", { name: "Welcome to Coder Agents" }), + ); + + await waitFor(() => { + expect(dialog.getByText("Welcome to Coder Agents")).toBeVisible(); + }); + expect(dialog.getByText("Connect a chat provider")).toBeVisible(); + expect(dialog.getByText("Add a chat model")).toBeVisible(); + expect(dialog.getAllByLabelText("Complete")).toHaveLength(1); + expect( + dialog.getByRole("link", { name: "Go to Providers" }), + ).toHaveAttribute("href", "/agents/settings/providers"); + expect(dialog.getByRole("link", { name: "Go to Models" })).toHaveAttribute( + "href", + "/agents/settings/models", + ); + }, +}; + +export const MissingProviderSetup: Story = { + render: () => ( + } + /> + ), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const dialog = within( + body.getByRole("dialog", { name: "Welcome to Coder Agents" }), + ); + + await waitFor(() => { + expect(dialog.getByText("Welcome to Coder Agents")).toBeVisible(); + }); + expect(dialog.getByText("Connect a chat provider")).toBeVisible(); + expect(dialog.getByText("Add a chat model")).toBeVisible(); + expect(dialog.getAllByLabelText("Complete")).toHaveLength(1); + expect( + dialog.getByRole("link", { name: "Go to Providers" }), + ).toHaveAttribute("href", "/agents/settings/providers"); + expect(dialog.getByRole("link", { name: "Go to Models" })).toHaveAttribute( + "href", + "/agents/settings/models", + ); + }, +}; + export const WithWorkspace: Story = { render: () => ( = ({ modelOptions, modelSelectorPlaceholder, modelSelectorHelp, + agentSetupNotice, hasModelOptions, isModelCatalogLoading = false, planModeEnabled, @@ -534,6 +536,7 @@ export const AgentChatPageView: FC = ({ modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} modelSelectorHelp={modelSelectorHelp} + agentSetupNotice={agentSetupNotice} planModeEnabled={planModeEnabled} onPlanModeToggle={onPlanModeToggle} isModelCatalogLoading={isModelCatalogLoading} diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 1bdd01d810..2b957d8d27 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -6,6 +6,7 @@ import { getErrorMessage } from "#/api/errors"; import { chatModelConfigs, chatModels, + chatProviderConfigs, createChat, mcpServerConfigs, userChatPersonalModelOverrides, @@ -19,10 +20,14 @@ import { type CreateChatOptions, } from "./components/AgentCreateForm"; import { AgentPageHeader } from "./components/AgentPageHeader"; +import { AgentSetupNotice } from "./components/AgentSetupNotice"; import { ChimeButton } from "./components/ChimeButton"; import { WebPushButton } from "./components/WebPushButton"; import { getChimeEnabled, setChimeEnabled } from "./utils/chime"; -import { getModelOptionsFromConfigs } from "./utils/modelOptions"; +import { + countConfiguredProviderConfigs, + getModelOptionsFromConfigs, +} from "./utils/modelOptions"; import { buildAgentChatPath } from "./utils/navigation"; const lastModelConfigIDStorageKey = "agents.last-model-config-id"; @@ -34,6 +39,10 @@ const AgentCreatePage: FC = () => { const chatModelsQuery = useQuery(chatModels()); const chatModelConfigsQuery = useQuery(chatModelConfigs()); + const chatProviderConfigsQuery = useQuery({ + ...chatProviderConfigs(), + enabled: permissions.editDeploymentConfig, + }); const personalModelOverridesQuery = useQuery( userChatPersonalModelOverrides(), ); @@ -47,6 +56,25 @@ const AgentCreatePage: FC = () => { chatModelConfigsQuery.data, chatModelsQuery.data, ); + const providerCount = + permissions.editDeploymentConfig && + chatProviderConfigsQuery.isSuccess && + chatModelsQuery.isSuccess + ? countConfiguredProviderConfigs( + chatProviderConfigsQuery.data, + chatModelsQuery.data, + ) + : undefined; + const modelCount = + chatModelConfigsQuery.isSuccess && chatModelsQuery.isSuccess + ? catalogModelOptions.length + : undefined; + const agentSetupNotice = + providerCount !== undefined && + modelCount !== undefined && + (providerCount === 0 || modelCount === 0) ? ( + + ) : undefined; const handleCreateChat = async ({ message, @@ -125,6 +153,7 @@ const AgentCreatePage: FC = () => { canCreateChat={permissions.createChat} modelCatalog={chatModelsQuery.data} modelOptions={catalogModelOptions} + agentSetupNotice={agentSetupNotice} modelConfigs={chatModelConfigsQuery.data ?? []} isModelCatalogLoading={chatModelsQuery.isLoading} isModelConfigsLoading={chatModelConfigsQuery.isLoading} diff --git a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx index 0eb0b759fd..b7a8b36ba7 100644 --- a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx @@ -21,7 +21,10 @@ const AgentSettingsModelsPage: FC = () => { const queryClient = useQueryClient(); // Queries. - const providerConfigsQuery = useQuery(chatProviderConfigs()); + const providerConfigsQuery = useQuery({ + ...chatProviderConfigs(), + enabled: permissions.editDeploymentConfig, + }); const modelConfigsQuery = useQuery(chatModelConfigs()); const modelCatalogQuery = useQuery(chatModels()); diff --git a/site/src/pages/AgentsPage/AgentSettingsProvidersPage.tsx b/site/src/pages/AgentsPage/AgentSettingsProvidersPage.tsx index b186822a0c..7fc3f3396c 100644 --- a/site/src/pages/AgentsPage/AgentSettingsProvidersPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsProvidersPage.tsx @@ -21,7 +21,10 @@ const AgentSettingsProvidersPage: FC = () => { const queryClient = useQueryClient(); // Queries. - const providerConfigsQuery = useQuery(chatProviderConfigs()); + const providerConfigsQuery = useQuery({ + ...chatProviderConfigs(), + enabled: permissions.editDeploymentConfig, + }); const modelConfigsQuery = useQuery(chatModelConfigs()); const modelCatalogQuery = useQuery(chatModels()); diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 767f107ad4..3e7a7a52a4 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -18,6 +18,7 @@ import { } from "#/testHelpers/entities"; import { withDashboardProvider } from "#/testHelpers/storybook"; import { AgentCreateForm } from "./AgentCreateForm"; +import { AgentSetupNotice } from "./AgentSetupNotice"; // Query key used by permittedOrganizations() in the form. const permittedOrgsKey = [ @@ -466,6 +467,37 @@ export const NoModelsConfigured: Story = { }, }; +export const MissingProviderAndModelSetup: Story = { + args: { + ...defaultArgs, + agentSetupNotice: , + modelCatalog: { providers: [] }, + modelOptions: [], + isModelCatalogLoading: false, + isModelConfigsLoading: false, + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const dialog = within( + body.getByRole("dialog", { name: "Welcome to Coder Agents" }), + ); + + await waitFor(() => { + expect(dialog.getByText("Welcome to Coder Agents")).toBeVisible(); + }); + expect(dialog.getByText("Connect a chat provider")).toBeVisible(); + expect(dialog.getByText("Add a chat model")).toBeVisible(); + expect(dialog.queryByLabelText("Complete")).not.toBeInTheDocument(); + expect( + dialog.getByRole("link", { name: "Go to Providers" }), + ).toHaveAttribute("href", "/agents/settings/providers"); + expect(dialog.getByRole("link", { name: "Go to Models" })).toHaveAttribute( + "href", + "/agents/settings/models", + ); + }, +}; + export const PreservesAttachmentsOnFailedSend: Story = { args: { ...defaultArgs, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index a5a6d20e92..3f99da8111 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -1,4 +1,11 @@ -import { type FC, useEffect, useEffectEvent, useRef, useState } from "react"; +import { + type FC, + type ReactNode, + useEffect, + useEffectEvent, + useRef, + useState, +} from "react"; import { useQuery } from "react-query"; import { Link } from "react-router"; import { toast } from "sonner"; @@ -122,6 +129,7 @@ interface AgentCreateFormProps { canCreateChat: boolean; modelCatalog: TypesGen.ChatModelsResponse | null | undefined; modelOptions: readonly ChatModelOption[]; + agentSetupNotice?: ReactNode; isModelCatalogLoading: boolean; modelConfigs: readonly TypesGen.ChatModelConfig[]; isModelConfigsLoading: boolean; @@ -142,6 +150,7 @@ export const AgentCreateForm: FC = ({ canCreateChat, modelCatalog, modelOptions, + agentSetupNotice, modelConfigs, isModelCatalogLoading, isModelConfigsLoading, @@ -493,6 +502,7 @@ export const AgentCreateForm: FC = ({ }} /> )} + {agentSetupNotice} = ({ + providerCount, + modelCount, +}) => { + const hasProvider = providerCount > 0; + const hasModel = modelCount > 0; + + if (hasProvider && hasModel) { + return null; + } + + return ( + + { + event.preventDefault(); + }} + onPointerDownOutside={(event) => { + event.preventDefault(); + }} + > + + Welcome to Coder Agents + + Complete 2 quick steps to get started. + + + +
+ + +
+
+
+ ); +}; + +interface AgentSetupStepProps { + isComplete: boolean; + stepNumber: number; + label: string; + linkTo: string; + linkText: string; +} + +const AgentSetupStep: FC = ({ + isComplete, + stepNumber, + label, + linkTo, + linkText, +}) => { + return ( +
+ + {isComplete ? ( + + ) : ( + `${stepNumber}.` + )} + + {label} + + {linkText} + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.stories.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.stories.tsx index 3f79b4d404..66a46530fc 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.stories.tsx @@ -150,6 +150,21 @@ export const HidesPricingWarningForExplicitZeroPricing: Story = { }, }; +export const LinksToProvidersFromEmptyState: Story = { + args: { + providerStates: [providerStateWithoutAPIKey], + modelConfigs: [], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const providerLink = canvas.getByRole("link", { name: /provider/i }); + + await expect(canvas.getByText("No models configured yet.")).toBeVisible(); + await expect(providerLink).toBeVisible(); + expect(providerLink).toHaveAttribute("href", "/agents/settings/providers"); + }, +}; + export const ShowsExplicitRowActions: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx index 4d7bab50ee..5de36aabed 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx @@ -7,7 +7,7 @@ import { TriangleAlertIcon, } from "lucide-react"; import type { FC } from "react"; -import { useLocation, useNavigate, useSearchParams } from "react-router"; +import { Link, useLocation, useSearchParams } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; @@ -85,16 +85,8 @@ export const ModelsSection: FC = ({ onDeleteModel, }) => { const [searchParams, setSearchParams] = useSearchParams(); - const navigate = useNavigate(); const location = useLocation(); - // Whether the current form entry was pushed by an in-app click - // (as opposed to a direct-entry URL like a bookmark or shared link). - // When true, navigate(-1) is safe; otherwise we fall back to - // clearing params with replace to avoid leaving the app. - const canGoBack = - (location.state as { pushed?: boolean } | null)?.pushed === true; - // Derive the current view from URL search params so that // browser back/forward navigation works as expected. const view: ModelView = (() => { @@ -140,23 +132,15 @@ export const ModelsSection: FC = ({ }); }; - // Navigate back to the list after a destructive or - // completion action (create/delete) where the form entry - // is stale. Uses navigate(-1) when safe, otherwise clears - // the params with replace. const exitModelView = () => { - if (canGoBack) { - navigate(-1); - } else { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - clearModelViewParams(next); - return next; - }, - { replace: true }, - ); - } + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + clearModelViewParams(next); + return next; + }, + { replace: true }, + ); }; // When the form is open it takes over the full panel. @@ -277,7 +261,14 @@ export const ModelsSection: FC = ({ {addableProviders.length > 0 && addButton} {addableProviders.length === 0 && (

- Connect a provider first to add models. + Connect a{" "} + + provider + {" "} + first to add models.

)} diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx index ae11125844..7ebbad3b41 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx @@ -7,6 +7,7 @@ import { useId, useState, } from "react"; +import { useNavigate } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { Button } from "#/components/Button/Button"; @@ -55,6 +56,7 @@ export const ProviderForm: FC = ({ onDeleteProvider, onBack, }) => { + const navigate = useNavigate(); const { provider, providerConfig, baseURL, isEnvPreset } = providerState; const apiKeyInputId = useId(); @@ -169,6 +171,17 @@ export const ProviderForm: FC = ({ isDirty && hasCredentialSource && (!requiresAPIKey || hasTypedAPIKey); + const canAddModel = + Boolean(providerConfig) && + (providerState.hasEffectiveAPIKey || + providerConfig?.allow_user_api_key === true); + + const handleAddModel = () => { + const params = new URLSearchParams({ newModel: provider }); + navigate(`/agents/settings/models?${params.toString()}`, { + state: { pushed: true }, + }); + }; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); @@ -432,12 +445,24 @@ export const ProviderForm: FC = ({ ) : (
)} - )} - {providerConfig ? "Save changes" : "Create provider config"} - + +
diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 6477e2af5e..3fe58140d0 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -161,6 +161,7 @@ interface ChatPageInputProps { modelOptions: readonly ModelSelectorOption[]; modelSelectorPlaceholder: string; modelSelectorHelp?: ReactNode; + agentSetupNotice?: ReactNode; planModeEnabled?: boolean; onPlanModeToggle?: (enabled: boolean) => void; isModelCatalogLoading?: boolean; @@ -228,6 +229,7 @@ export const ChatPageInput: FC = ({ modelOptions, modelSelectorPlaceholder, modelSelectorHelp, + agentSetupNotice, planModeEnabled, onPlanModeToggle, isModelCatalogLoading = false, @@ -393,6 +395,8 @@ export const ChatPageInput: FC = ({ const isStreaming = hasStreamState || chatStatus === "running" || chatStatus === "pending"; + const [chatFullWidth] = useChatFullWidth(); + const inputElement = ( { @@ -493,12 +497,19 @@ export const ChatPageInput: FC = ({ /> ); - if (!modelSelectorHelp) { + if (!agentSetupNotice && !modelSelectorHelp) { return inputElement; } return (
+ {agentSetupNotice && ( +
+ {agentSetupNotice} +
+ )} {inputElement} {modelSelectorHelp && (
diff --git a/site/src/pages/AgentsPage/utils/modelOptions.test.ts b/site/src/pages/AgentsPage/utils/modelOptions.test.ts index 47b5e7775f..a58b5910f3 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.test.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "vitest"; -import type { ChatModelConfig, ChatModelsResponse } from "#/api/typesGenerated"; +import type { + ChatModelConfig, + ChatModelsResponse, + ChatProviderConfig, +} from "#/api/typesGenerated"; import { + countConfiguredProviderConfigs, formatProviderLabel, getModelOptionsFromConfigs, getModelSelectorPlaceholder, getNormalizedModelRef, + hasConfiguredProviderConfigs, hasUserFixableProviders, resolveModelOptionId, } from "./modelOptions"; @@ -48,6 +54,25 @@ const createCatalog = ( providers, }); +const createProviderConfig = ( + overrides: Pick & + Partial, +): ChatProviderConfig => { + const { provider, source, ...rest } = overrides; + return { + id: "provider-config-1", + provider, + display_name: provider, + enabled: true, + has_api_key: false, + central_api_key_enabled: true, + allow_user_api_key: false, + allow_central_api_key_fallback: false, + source, + ...rest, + }; +}; + describe("getNormalizedModelRef", () => { it("returns empty strings for malformed values", () => { expect(getNormalizedModelRef({ provider: undefined, model: null })).toEqual( @@ -90,6 +115,112 @@ describe("hasUserFixableProviders", () => { }); }); +describe("hasConfiguredProviderConfigs", () => { + it("ignores supported provider placeholders", () => { + const catalog = createCatalog([ + { provider: "openai", available: true, models: [] }, + ]); + + expect( + hasConfiguredProviderConfigs( + [createProviderConfig({ provider: "openai", source: "supported" })], + catalog, + ), + ).toBe(false); + }); + + it("returns true for database and env preset provider configs", () => { + const catalog = createCatalog([ + { provider: "openai", available: true, models: [] }, + ]); + + expect( + hasConfiguredProviderConfigs( + [createProviderConfig({ provider: "openai", source: "database" })], + catalog, + ), + ).toBe(true); + expect( + hasConfiguredProviderConfigs( + [createProviderConfig({ provider: "openai", source: "env_preset" })], + catalog, + ), + ).toBe(true); + }); + + it("excludes disabled and unavailable provider configs", () => { + const catalog = createCatalog([ + { provider: "openai", available: true, models: [] }, + { + provider: "anthropic", + available: false, + unavailable_reason: "missing_api_key", + models: [], + }, + ]); + + expect( + hasConfiguredProviderConfigs( + [ + createProviderConfig({ + provider: "openai", + source: "database", + enabled: false, + }), + createProviderConfig({ + provider: "anthropic", + source: "database", + }), + ], + catalog, + ), + ).toBe(false); + }); +}); + +describe("countConfiguredProviderConfigs", () => { + it("counts only enabled provider configs available in the catalog", () => { + const catalog = createCatalog([ + { provider: "openai", available: true, models: [] }, + { provider: "anthropic", available: true, models: [] }, + { provider: "google", available: true, models: [] }, + { provider: "azure", available: true, models: [] }, + { + provider: "bedrock", + available: false, + unavailable_reason: "missing_api_key", + models: [], + }, + ]); + + expect( + countConfiguredProviderConfigs( + [ + createProviderConfig({ provider: "openai", source: "database" }), + createProviderConfig({ provider: "anthropic", source: "env_preset" }), + createProviderConfig({ provider: "google", source: "supported" }), + createProviderConfig({ + provider: "azure", + source: "database", + enabled: false, + }), + createProviderConfig({ provider: "bedrock", source: "database" }), + ], + catalog, + ), + ).toBe(2); + }); + + it("returns zero while provider availability is unknown", () => { + expect( + countConfiguredProviderConfigs( + [createProviderConfig({ provider: "openai", source: "database" })], + undefined, + ), + ).toBe(0); + }); +}); + describe("formatProviderLabel", () => { it("formats OpenAI compatible providers", () => { expect(formatProviderLabel("openai-compatible")).toBe("OpenAI-compatible"); diff --git a/site/src/pages/AgentsPage/utils/modelOptions.ts b/site/src/pages/AgentsPage/utils/modelOptions.ts index 8d35df72ad..55658ea555 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.ts @@ -39,6 +39,32 @@ type ModelOptionConfigLike = readonly context_limit?: unknown; }); +export const hasConfiguredProviderConfigs = ( + providerConfigs: readonly TypesGen.ChatProviderConfig[] | null | undefined, + catalog: TypesGen.ChatModelsResponse | null | undefined, +): boolean => { + return countConfiguredProviderConfigs(providerConfigs, catalog) > 0; +}; + +export const countConfiguredProviderConfigs = ( + providerConfigs: readonly TypesGen.ChatProviderConfig[] | null | undefined, + catalog: TypesGen.ChatModelsResponse | null | undefined, +): number => { + const availableProviders = getAvailableProviders(catalog); + return ( + providerConfigs?.filter((providerConfig) => { + if ( + providerConfig.source === "supported" || + providerConfig.enabled !== true + ) { + return false; + } + const provider = asString(providerConfig.provider).trim().toLowerCase(); + return provider !== "" && availableProviders.has(provider); + }).length ?? 0 + ); +}; + export const getNormalizedModelRef = ( value: ModelRefLike, ): { readonly provider: string; readonly model: string } => {