feat(site/src/pages/AgentsPage): guide users when chat providers or models are missing (#24863)

<img width="674" height="508" alt="Screenshot 2026-05-04 at 20 43 11"
src="https://github.com/user-attachments/assets/de33dba9-33f5-4dbe-a1af-9bff5f048b8f"
/>


When the agents chat page loads with no chat providers or no chat models
configured, new users currently get no in-product guidance about the
missing setup step.

also adds a Add model button on the provider page after a provider is
setup

This adds a setup notice rendered as a no dismissable modalthat explains
both a provider and a model must be configured before agents can be
used. The notice conditionally links to `/agents/settings/providers`
and/or `/agents/settings/models` depending on which is missing, and only
renders after the relevant config queries succeed (no flash during
loading).
This commit is contained in:
Jaayden Halko
2026-05-05 14:08:35 +01:00
committed by GitHub
parent a24ebb9d38
commit dd2b121b20
15 changed files with 538 additions and 38 deletions
+28 -1
View File
@@ -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<AgentsOutletContext>();
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<ChatMessageInputRef | null>(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) ? (
<AgentSetupNotice providerCount={providerCount} modelCount={modelCount} />
) : 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}
@@ -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: () => (
<StoryAgentChatPageView
agentSetupNotice={<AgentSetupNotice providerCount={0} modelCount={0} />}
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: () => (
<StoryAgentChatPageView
agentSetupNotice={<AgentSetupNotice providerCount={1} modelCount={0} />}
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: () => (
<StoryAgentChatPageView
agentSetupNotice={<AgentSetupNotice providerCount={0} modelCount={1} />}
/>
),
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: () => (
<StoryAgentChatPageView
@@ -104,6 +104,7 @@ interface AgentChatPageViewProps {
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
modelSelectorHelp?: ReactNode;
agentSetupNotice?: ReactNode;
hasModelOptions: boolean;
isModelCatalogLoading?: boolean;
planModeEnabled?: boolean;
@@ -200,6 +201,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
modelOptions,
modelSelectorPlaceholder,
modelSelectorHelp,
agentSetupNotice,
hasModelOptions,
isModelCatalogLoading = false,
planModeEnabled,
@@ -534,6 +536,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
modelSelectorHelp={modelSelectorHelp}
agentSetupNotice={agentSetupNotice}
planModeEnabled={planModeEnabled}
onPlanModeToggle={onPlanModeToggle}
isModelCatalogLoading={isModelCatalogLoading}
+30 -1
View File
@@ -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) ? (
<AgentSetupNotice providerCount={providerCount} modelCount={modelCount} />
) : 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}
@@ -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());
@@ -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());
@@ -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: <AgentSetupNotice providerCount={0} modelCount={0} />,
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,
@@ -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<AgentCreateFormProps> = ({
canCreateChat,
modelCatalog,
modelOptions,
agentSetupNotice,
modelConfigs,
isModelCatalogLoading,
isModelConfigsLoading,
@@ -493,6 +502,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
}}
/>
)}
{agentSetupNotice}
<AgentChatInput
onSend={handleSendWithAttachments}
placeholder="Ask Coder to build, fix bugs, or explore your project..."
@@ -0,0 +1,103 @@
import { CheckIcon } from "lucide-react";
import type { FC } from "react";
import { Link } from "react-router";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
const agentSetupLinkClassName =
"text-content-link transition-colors hover:text-content-link/80";
interface AgentSetupNoticeProps {
providerCount: number;
modelCount: number;
}
export const AgentSetupNotice: FC<AgentSetupNoticeProps> = ({
providerCount,
modelCount,
}) => {
const hasProvider = providerCount > 0;
const hasModel = modelCount > 0;
if (hasProvider && hasModel) {
return null;
}
return (
<Dialog open>
<DialogContent
className="w-fit max-w-[calc(100vw-2rem)] gap-8"
onEscapeKeyDown={(event) => {
event.preventDefault();
}}
onPointerDownOutside={(event) => {
event.preventDefault();
}}
>
<DialogHeader className="space-y-5 text-left sm:text-left">
<DialogTitle className="text-xl">Welcome to Coder Agents</DialogTitle>
<DialogDescription className="text-base">
Complete 2 quick steps to get started.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 text-base text-content-secondary">
<AgentSetupStep
isComplete={hasProvider}
stepNumber={1}
label="Connect a chat provider"
linkTo="/agents/settings/providers"
linkText="Go to Providers"
/>
<AgentSetupStep
isComplete={hasModel}
stepNumber={2}
label="Add a chat model"
linkTo="/agents/settings/models"
linkText="Go to Models"
/>
</div>
</DialogContent>
</Dialog>
);
};
interface AgentSetupStepProps {
isComplete: boolean;
stepNumber: number;
label: string;
linkTo: string;
linkText: string;
}
const AgentSetupStep: FC<AgentSetupStepProps> = ({
isComplete,
stepNumber,
label,
linkTo,
linkText,
}) => {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="flex w-7 shrink-0 justify-end text-content-secondary">
{isComplete ? (
<CheckIcon
aria-label="Complete"
className="h-5 w-5 text-content-success"
/>
) : (
`${stepNumber}.`
)}
</span>
<span className="text-content-secondary">{label}</span>
<Link to={linkTo} className={agentSetupLinkClassName}>
{linkText}
</Link>
</div>
);
};
@@ -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);
@@ -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<ModelsSectionProps> = ({
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<ModelsSectionProps> = ({
});
};
// 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<ModelsSectionProps> = ({
{addableProviders.length > 0 && addButton}
{addableProviders.length === 0 && (
<p className="m-0 text-xs text-content-secondary">
Connect a provider first to add models.
Connect a{" "}
<Link
to="/agents/settings/providers"
className="underline transition-colors hover:text-content-primary"
>
provider
</Link>{" "}
first to add models.
</p>
)}
</div>
@@ -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<ProviderFormProps> = ({
onDeleteProvider,
onBack,
}) => {
const navigate = useNavigate();
const { provider, providerConfig, baseURL, isEnvPreset } = providerState;
const apiKeyInputId = useId();
@@ -169,6 +171,17 @@ export const ProviderForm: FC<ProviderFormProps> = ({
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<ProviderFormProps> = ({
) : (
<div />
)}
<Button size="lg" type="submit" disabled={!canSave}>
{isProviderMutationPending && (
<Spinner className="h-4 w-4" loading />
<div className="flex items-center gap-2">
{canAddModel && (
<Button size="lg" type="button" onClick={handleAddModel}>
Add model
</Button>
)}
{providerConfig ? "Save changes" : "Create provider config"}
</Button>
<Button
size="lg"
type="submit"
variant={canAddModel ? "outline" : undefined}
disabled={!canSave}
>
{isProviderMutationPending && (
<Spinner className="h-4 w-4" loading />
)}
{providerConfig ? "Save changes" : "Create provider config"}
</Button>
</div>
</div>
</div>
</form>
@@ -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<ChatPageInputProps> = ({
modelOptions,
modelSelectorPlaceholder,
modelSelectorHelp,
agentSetupNotice,
planModeEnabled,
onPlanModeToggle,
isModelCatalogLoading = false,
@@ -393,6 +395,8 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
const isStreaming =
hasStreamState || chatStatus === "running" || chatStatus === "pending";
const [chatFullWidth] = useChatFullWidth();
const inputElement = (
<AgentChatInput
onSend={(message) => {
@@ -493,12 +497,19 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
/>
);
if (!modelSelectorHelp) {
if (!agentSetupNotice && !modelSelectorHelp) {
return inputElement;
}
return (
<div>
{agentSetupNotice && (
<div
className={cn("mx-auto w-full pb-2", chatWidthClass(chatFullWidth))}
>
{agentSetupNotice}
</div>
)}
{inputElement}
{modelSelectorHelp && (
<div className="px-3 pt-1 text-2xs text-content-secondary">
@@ -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<ChatProviderConfig, "provider" | "source"> &
Partial<ChatProviderConfig>,
): 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");
@@ -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 } => {