From ecfff8a7db2f6b6a62fd7bc3bcb047772c123c78 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 23 Jun 2026 12:33:28 +0100 Subject: [PATCH] feat: move model settings page to ai settings --- codersdk/chats.go | 8 +- docs/ai-coder/agents/getting-started.md | 7 +- docs/ai-coder/agents/models.md | 13 +- .../agents/tasks-to-chats-migration.md | 4 +- scripts/modeloptionsgen/main.go | 74 +- site/src/api/chatModelOptions.ts | 2 + site/src/api/chatModelOptionsGenerated.json | 14 +- site/src/api/queries/chats.ts | 78 - site/src/modules/aiModels/helpers.ts | 15 + .../modules/aiModels/providerStates.test.ts | 282 ++ site/src/modules/aiModels/providerStates.ts | 237 ++ .../management/AISettingsSidebarView.tsx | 3 + .../ModelsPage/AddModelPage/AddModelPage.tsx | 92 + .../AddModelPage/AddModelPageView.stories.tsx | 67 + .../AddModelPage/AddModelPageView.tsx | 73 + .../AISettingsPage/ModelsPage/ModelsPage.tsx | 56 + .../ModelsPage/ModelsPageView.stories.tsx | 99 + .../ModelsPage/ModelsPageView.tsx | 178 ++ .../UpdateModelPage/UpdateModelPage.tsx | 140 + .../UpdateModelPageView.stories.tsx | 40 + .../UpdateModelPage/UpdateModelPageView.tsx | 61 + .../components/ModelForm.stories.tsx | 201 ++ .../ModelsPage/components/ModelForm.tsx | 349 +++ .../components/ModelFormDialogs.tsx | 121 + .../ModelsPage/components/ModelFormFields.tsx | 349 +++ .../ModelsPage/components/ModelFormHeader.tsx | 153 ++ .../components/ModelFormProviderSelect.tsx | 55 + .../ModelsPage/components/ModelRow.tsx | 84 + .../AISettingsPage/ModelsPage/testFixtures.ts | 84 + .../UpdateProviderPageView.tsx | 2 +- .../AgentsPage/AgentChatPageView.stories.tsx | 4 +- .../AgentsPage/AgentSettingsModelsPage.tsx | 101 - .../components/AgentCreateForm.stories.tsx | 2 +- .../components/AgentSetupNotice.tsx | 4 +- .../ChatModelAdminPanel.stories.tsx | 2380 ----------------- .../ChatModelAdminPanel.tsx | 373 --- .../ChatModelAdminPanel/ModelConfigFields.tsx | 165 +- .../ChatModelAdminPanel/ModelForm.tsx | 662 ----- .../ModelIdentifierField.tsx | 30 +- .../ModelsSection.stories.tsx | 471 ---- .../ChatModelAdminPanel/ModelsSection.tsx | 471 ---- .../ChatModelAdminPanel/ProviderField.tsx | 35 + .../ChatModelAdminPanel/ProviderForm.tsx | 413 --- .../ChatModelAdminPanel/ProviderIcon.tsx | 2 +- .../ChatModelAdminPanel/ProvidersSection.tsx | 296 -- .../components/ChatModelAdminPanel/helpers.ts | 41 - .../ChatModelAdminPanel/knownModels/index.ts | 2 +- .../modelConfigFormLogic.test.ts | 136 + .../modelConfigFormLogic.ts | 38 + .../ChatsSidebar/settings/SettingsPanel.tsx | 6 +- .../components/MCPServerAdminPanel.tsx | 2 +- site/src/router.tsx | 24 +- site/src/testHelpers/chatModels.ts | 12 +- 53 files changed, 3166 insertions(+), 5445 deletions(-) create mode 100644 site/src/modules/aiModels/helpers.ts create mode 100644 site/src/modules/aiModels/providerStates.test.ts create mode 100644 site/src/modules/aiModels/providerStates.ts create mode 100644 site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPage.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/ModelsPage.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPage.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/components/ModelFormDialogs.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/components/ModelFormHeader.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts delete mode 100644 site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx delete mode 100644 site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx delete mode 100644 site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx delete mode 100644 site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx delete mode 100644 site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.stories.tsx delete mode 100644 site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderField.tsx delete mode 100644 site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx delete mode 100644 site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx delete mode 100644 site/src/pages/AgentsPage/components/ChatModelAdminPanel/helpers.ts diff --git a/codersdk/chats.go b/codersdk/chats.go index a8e01a481d..eadeec97ef 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1296,8 +1296,8 @@ type ChatModelOpenAIProviderOptions struct { StructuredOutputs *bool `json:"structured_outputs,omitempty" description:"Whether to enable structured JSON output mode" hidden:"true"` StrictJSONSchema *bool `json:"strict_json_schema,omitempty" description:"Whether to enforce strict adherence to the JSON schema" hidden:"true"` WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable OpenAI web search tool for grounding responses with real-time information"` - SearchContextSize *string `json:"search_context_size,omitempty" description:"Amount of search context to use" enum:"low,medium,high"` - AllowedDomains []string `json:"allowed_domains,omitempty" label:"Web Search: Allowed Domains" description:"Restrict web search to these domains"` + SearchContextSize *string `json:"search_context_size,omitempty" description:"Amount of search context to use" enum:"low,medium,high" visible_when:"web_search_enabled"` + AllowedDomains []string `json:"allowed_domains,omitempty" label:"Web Search: Allowed Domains" description:"Restrict web search to these domains" visible_when:"web_search_enabled"` } // ChatModelAnthropicThinkingOptions configures Anthropic thinking budget. @@ -1313,8 +1313,8 @@ type ChatModelAnthropicProviderOptions struct { ThinkingDisplay *string `json:"thinking_display,omitempty" label:"Thinking Display" description:"Controls how Anthropic returns thinking content" enum:"summarized,omitted"` DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty" description:"Whether to disable parallel tool execution"` WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable Anthropic web search tool for grounding responses with real-time information"` - AllowedDomains []string `json:"allowed_domains,omitempty" label:"Web Search: Allowed Domains" description:"Restrict web search to these domains (cannot be used with blocked_domains)"` - BlockedDomains []string `json:"blocked_domains,omitempty" label:"Web Search: Blocked Domains" description:"Block web search on these domains (cannot be used with allowed_domains)"` + AllowedDomains []string `json:"allowed_domains,omitempty" label:"Web Search: Allowed Domains" description:"Restrict web search to these domains (cannot be used with blocked_domains)" visible_when:"web_search_enabled" conflicts_with:"blocked_domains"` + BlockedDomains []string `json:"blocked_domains,omitempty" label:"Web Search: Blocked Domains" description:"Block web search on these domains (cannot be used with allowed_domains)" visible_when:"web_search_enabled" conflicts_with:"allowed_domains"` } // ChatModelGoogleThinkingConfig configures Google thinking behavior. diff --git a/docs/ai-coder/agents/getting-started.md b/docs/ai-coder/agents/getting-started.md index a513cba745..ad315ecc7c 100644 --- a/docs/ai-coder/agents/getting-started.md +++ b/docs/ai-coder/agents/getting-started.md @@ -40,8 +40,7 @@ To configure Coder Agents: 1. Navigate to **Admin settings** > **AI** and select **Providers**. 1. Add or update a provider with its credentials and upstream endpoint, then save it. -1. Navigate to the **Agents** page, open **Settings** > **Manage Agents**, and - select **Models**. +1. Navigate to **Admin settings** > **AI** > **Models**. 1. Click **Add** and configure at least one model with its identifier, display name, and context limit. 1. Click the **star icon** next to a model to set it as the default. @@ -217,8 +216,8 @@ sub-agent delegation, and complex multi-step work can consume significant token volume. Consider: - Starting with a single model to establish a cost baseline. -- Setting per-model token pricing under **Agents** > **Settings** > - **Manage Agents** > **Models** (Input Price, Output Price) to track spend. +- Setting per-model token pricing under **Admin settings** > **AI** > + **Models** (Input Price, Output Price) to track spend. - Monitoring provider dashboards for usage trends during the evaluation. ### Pilot with a small group diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index 9e29f621db..fe01a5c20a 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -1,9 +1,9 @@ # Models Administrators configure LLM providers from **Admin settings** > **AI** and -Coder Agents models from the **Agents** settings page. Providers, models, and -centrally managed credentials are deployment-wide settings managed by platform -teams. Developers select from the set of models that an administrator has +Coder Agents models from **Admin settings** > **AI** > **Models**. Providers, +models, and centrally managed credentials are deployment-wide settings managed +by platform teams. Developers select from the set of models that an administrator has enabled. Optionally, administrators can enable AI Gateway Bring Your Own Key (BYOK) @@ -49,9 +49,8 @@ settings page. [endpoint/base URL](#endpointbase-url-for-openai-compatible-providers). 1. Click **Save**. -After saving a provider, add an Agents model for it from **Agents** > -**Settings** > **Manage Agents** > **Models**. For provider-specific setup, -including AWS Bedrock, see +After saving a provider, add an Agents model for it from **Admin settings** > +**AI** > **Models**. For provider-specific setup, including AWS Bedrock, see [AI Gateway provider configuration](../ai-gateway/providers.md#provider-types). ## Endpoint/base URL for OpenAI-compatible providers @@ -122,7 +121,7 @@ generation parameters, and provider-specific options. ### Add a model -1. Open **Settings** > **Manage Agents** and select the **Models** tab. +1. Navigate to **Admin settings** > **AI** > **Models**. 1. Click **Add** and select the provider for the new model. 1. Enter the **Model Identifier**, the exact model string your provider expects (e.g., `claude-opus-4-6`, `gpt-5.3-codex`). diff --git a/docs/ai-coder/agents/tasks-to-chats-migration.md b/docs/ai-coder/agents/tasks-to-chats-migration.md index db31d2fb4f..8fb2b86322 100644 --- a/docs/ai-coder/agents/tasks-to-chats-migration.md +++ b/docs/ai-coder/agents/tasks-to-chats-migration.md @@ -71,8 +71,8 @@ configured once in the control plane: 1. Navigate to **Admin settings** > **AI** and select **Providers**. 1. Add or update a provider with its credentials and upstream endpoint, then save it. -1. Navigate to the **Agents** page, open **Settings** > **Manage Agents** > - **Models**, add at least one model, and set it as the default. +1. Navigate to **Admin settings** > **AI** > **Models**, add at least one model, + and set it as the default. You no longer pass API keys in template variables or workspace environment. See https://coder.com/docs/ai-coder/agents/getting-started for more information. diff --git a/scripts/modeloptionsgen/main.go b/scripts/modeloptionsgen/main.go index f7446bd335..89b6b0ffa9 100644 --- a/scripts/modeloptionsgen/main.go +++ b/scripts/modeloptionsgen/main.go @@ -8,21 +8,24 @@ import ( "strings" "github.com/shopspring/decimal" + "golang.org/x/xerrors" "github.com/coder/coder/v2/codersdk" ) // SchemaField describes a single form field in the generated schema. type SchemaField struct { - JSONName string `json:"json_name"` - GoName string `json:"go_name"` - Type string `json:"type"` - Description string `json:"description,omitempty"` - Label string `json:"label,omitempty"` - Required bool `json:"required"` - Enum []string `json:"enum,omitempty"` - InputType string `json:"input_type"` - Hidden bool `json:"hidden,omitempty"` + JSONName string `json:"json_name"` + GoName string `json:"go_name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Label string `json:"label,omitempty"` + Required bool `json:"required"` + Enum []string `json:"enum,omitempty"` + InputType string `json:"input_type"` + Hidden bool `json:"hidden,omitempty"` + VisibleWhen string `json:"visible_when,omitempty"` + ConflictsWith []string `json:"conflicts_with,omitempty"` } // FieldGroup holds the fields for a struct or provider. @@ -53,6 +56,10 @@ func main() { "", map[string]bool{"ProviderOptions": true}, ) + if err := validateFieldReferences("general", schema.General); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } // Provider-specific options. Each entry maps a provider key // to the concrete options struct used for that provider. @@ -70,6 +77,10 @@ func main() { for _, p := range providerTypes { schema.Providers[p.key] = extractFields(p.typ, "", nil) + if err := validateFieldReferences(p.key, schema.Providers[p.key]); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } } out, err := json.MarshalIndent(schema, "", "\t") @@ -83,6 +94,25 @@ func main() { _, _ = fmt.Println(string(out)) } +func validateFieldReferences(group string, fg FieldGroup) error { + names := make(map[string]bool, len(fg.Fields)) + for _, f := range fg.Fields { + names[f.JSONName] = true + } + + for _, f := range fg.Fields { + if f.VisibleWhen != "" && !names[f.VisibleWhen] { + return xerrors.Errorf("field %q in group %q has visible_when=%q referencing an unknown sibling field", f.JSONName, group, f.VisibleWhen) + } + for _, sibling := range f.ConflictsWith { + if !names[sibling] { + return xerrors.Errorf("field %q in group %q has conflicts_with entry %q referencing an unknown sibling field", f.JSONName, group, sibling) + } + } + } + return nil +} + // extractFields walks the struct fields of t and returns a FieldGroup. // prefix is used to build dot-separated json_name values for nested // structs. skip lists Go field names to exclude from output. @@ -138,6 +168,12 @@ func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGro description := f.Tag.Get("description") label := f.Tag.Get("label") enumTag := f.Tag.Get("enum") + visibleWhen := f.Tag.Get("visible_when") + + var conflictsWith []string + if conflictsTag := f.Tag.Get("conflicts_with"); conflictsTag != "" { + conflictsWith = strings.Split(conflictsTag, ",") + } var enumValues []string if enumTag != "" { @@ -148,15 +184,17 @@ func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGro inputType := inferInputType(typeName, enumValues) fields = append(fields, SchemaField{ - JSONName: fullJSONName, - GoName: goFieldPath(prefix, f.Name, t, fullJSONName), - Type: typeName, - Description: description, - Label: label, - Required: required, - Enum: enumValues, - InputType: inputType, - Hidden: hidden, + JSONName: fullJSONName, + GoName: goFieldPath(prefix, f.Name, t, fullJSONName), + Type: typeName, + Description: description, + Label: label, + Required: required, + Enum: enumValues, + InputType: inputType, + Hidden: hidden, + VisibleWhen: visibleWhen, + ConflictsWith: conflictsWith, }) } diff --git a/site/src/api/chatModelOptions.ts b/site/src/api/chatModelOptions.ts index 67c60da120..2f11abf2ad 100644 --- a/site/src/api/chatModelOptions.ts +++ b/site/src/api/chatModelOptions.ts @@ -23,6 +23,8 @@ export interface FieldSchema { enum?: string[]; /** If true, this field should not be rendered in admin UI forms. */ hidden?: boolean; + visible_when?: string; + conflicts_with?: string[]; } /** diff --git a/site/src/api/chatModelOptionsGenerated.json b/site/src/api/chatModelOptionsGenerated.json index d64f1f22e7..fb3bafadce 100644 --- a/site/src/api/chatModelOptionsGenerated.json +++ b/site/src/api/chatModelOptionsGenerated.json @@ -145,7 +145,9 @@ "description": "Restrict web search to these domains (cannot be used with blocked_domains)", "label": "Web Search: Allowed Domains", "required": false, - "input_type": "json" + "input_type": "json", + "visible_when": "web_search_enabled", + "conflicts_with": ["blocked_domains"] }, { "json_name": "blocked_domains", @@ -154,7 +156,9 @@ "description": "Block web search on these domains (cannot be used with allowed_domains)", "label": "Web Search: Blocked Domains", "required": false, - "input_type": "json" + "input_type": "json", + "visible_when": "web_search_enabled", + "conflicts_with": ["allowed_domains"] } ] }, @@ -404,7 +408,8 @@ "description": "Amount of search context to use", "required": false, "enum": ["low", "medium", "high"], - "input_type": "select" + "input_type": "select", + "visible_when": "web_search_enabled" }, { "json_name": "allowed_domains", @@ -413,7 +418,8 @@ "description": "Restrict web search to these domains", "label": "Web Search: Allowed Domains", "required": false, - "input_type": "json" + "input_type": "json", + "visible_when": "web_search_enabled" } ] }, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index a9acf46c5d..d3e1e94597 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -10,9 +10,7 @@ import { type CreateChatMessageRequestWithClearablePlanMode, } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; -import { type AIProviderType, AIProviderTypes } from "#/api/typesGenerated"; import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery"; -import { formatProviderLabel } from "#/utils/aiProviders"; import { projectEditedConversationIntoCache, reconcileEditedMessageInCache, @@ -1797,82 +1795,6 @@ const invalidateChatConfigurationQueries = async (queryClient: QueryClient) => { ]); }; -const generatedAIProviderName = (provider: string): string => { - const suffix = - globalThis.crypto?.randomUUID?.() ?? - `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; - return `${provider}-${suffix}`; -}; - -const normalizeAIProviderType = (provider: string): AIProviderType => { - const normalized = provider.trim().toLowerCase(); - const aliased = - normalized === "openai-compatible" || normalized === "openai_compatible" - ? "openai-compat" - : normalized; - const providerType = AIProviderTypes.find( - (candidate) => candidate === aliased, - ); - if (!providerType) { - throw new Error(`Unsupported AI provider type "${provider}".`); - } - return providerType; -}; - -export const createChatProviderConfig = (queryClient: QueryClient) => ({ - mutationFn: (req: TypesGen.CreateChatProviderConfigRequest) => { - const providerType = normalizeAIProviderType(req.provider); - const apiKey = req.api_key; - return API.experimental.createAIProvider({ - type: providerType, - name: generatedAIProviderName(providerType), - display_name: req.display_name || formatProviderLabel(providerType), - base_url: req.base_url ?? "", - enabled: req.enabled ?? true, - api_keys: apiKey ? [apiKey] : undefined, - }); - }, - onSuccess: async () => { - await invalidateChatConfigurationQueries(queryClient); - }, -}); - -type UpdateChatProviderConfigMutationArgs = { - providerConfigId: string; - req: TypesGen.UpdateChatProviderConfigRequest; -}; - -export const updateChatProviderConfig = (queryClient: QueryClient) => ({ - mutationFn: async ({ - providerConfigId, - req, - }: UpdateChatProviderConfigMutationArgs) => { - const apiKey = req.api_key; - return API.experimental.updateAIProvider(providerConfigId, { - display_name: req.display_name, - base_url: req.base_url, - enabled: req.enabled, - api_keys: - req.api_key === undefined - ? undefined - : apiKey - ? [{ api_key: apiKey }] - : [], - }); - }, - onSuccess: async () => { - await invalidateChatConfigurationQueries(queryClient); - }, -}); - -export const deleteChatProviderConfig = (queryClient: QueryClient) => ({ - mutationFn: (providerConfigId: string) => - API.experimental.deleteAIProvider(providerConfigId), - onSuccess: async () => { - await invalidateChatConfigurationQueries(queryClient); - }, -}); - export const createChatModelConfig = (queryClient: QueryClient) => ({ mutationFn: (req: TypesGen.CreateChatModelConfigRequest) => API.experimental.createChatModelConfig(req), diff --git a/site/src/modules/aiModels/helpers.ts b/site/src/modules/aiModels/helpers.ts new file mode 100644 index 0000000000..6c81519bf8 --- /dev/null +++ b/site/src/modules/aiModels/helpers.ts @@ -0,0 +1,15 @@ +export function normalizeProvider(provider: string): string { + return provider.trim().toLowerCase(); +} + +const canonicalProviderBaseURLs: Record = { + anthropic: "https://api.anthropic.com", + google: "https://generativelanguage.googleapis.com/v1beta", + openai: "https://api.openai.com/v1", + openrouter: "https://openrouter.ai/api/v1", + vercel: "https://ai-gateway.vercel.sh/v1", +}; + +export function getDefaultProviderBaseURL(provider: string): string { + return canonicalProviderBaseURLs[normalizeProvider(provider)] ?? ""; +} diff --git a/site/src/modules/aiModels/providerStates.test.ts b/site/src/modules/aiModels/providerStates.test.ts new file mode 100644 index 0000000000..9cf9ae4e9d --- /dev/null +++ b/site/src/modules/aiModels/providerStates.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest"; +import type * as TypesGen from "#/api/typesGenerated"; +import { + MockChatModelConfig, + MockChatModelProvider, + MockChatProviderConfig, +} from "#/testHelpers/chatModels"; +import { + canManageProviderModels, + deriveProviderStates, + type ProviderState, + resolveModelProviderKey, +} from "./providerStates"; + +const baseProviderState: ProviderState = { + key: "prov-openai", + provider: "openai", + label: "OpenAI", + providerConfig: MockChatProviderConfig, + modelConfigs: [], + catalogModelCount: 0, + hasManagedAPIKey: true, + hasCatalogAPIKey: false, + hasEffectiveAPIKey: true, + allowUserAPIKey: false, + isEnvPreset: false, + baseURL: "", +}; + +describe("deriveProviderStates", () => { + it("orders provider configs first, then catalog-only, then model-only providers", () => { + const providerConfigs = [ + { + ...MockChatProviderConfig, + id: "prov-anthropic", + provider: "anthropic", + display_name: "Anthropic", + }, + ]; + const catalog: TypesGen.ChatModelsResponse = { + providers: [ + { ...MockChatModelProvider, provider: "anthropic" }, + { ...MockChatModelProvider, provider: "google" }, + ], + }; + const modelConfigs = [ + { ...MockChatModelConfig, id: "m-vercel", provider: "vercel" }, + ]; + + const states = deriveProviderStates(modelConfigs, providerConfigs, catalog); + + expect(states.map((s) => s.provider)).toEqual([ + "anthropic", + "google", + "vercel", + ]); + expect(states[0].key).toBe("prov-anthropic"); + expect(states[1].key).toBe("google"); + expect(states[2].key).toBe("vercel"); + expect(states[0].hasEffectiveAPIKey).toBe(true); + expect(states[1].hasEffectiveAPIKey).toBe(true); + expect(states[2].hasEffectiveAPIKey).toBe(false); + }); + + it("matches model configs to provider configs by ai_provider_id", () => { + const providerConfigs = [ + { ...MockChatProviderConfig, id: "prov-openai", provider: "openai" }, + ]; + const modelConfigs = [ + { + ...MockChatModelConfig, + id: "m1", + provider: "openai", + ai_provider_id: "prov-openai", + }, + { ...MockChatModelConfig, id: "m2", provider: "openai" }, + ]; + + const states = deriveProviderStates(modelConfigs, providerConfigs, null); + + expect(states).toHaveLength(1); + expect(states[0].key).toBe("prov-openai"); + expect(states[0].modelConfigs.map((m) => m.id)).toEqual(["m1", "m2"]); + }); + + it("treats bedrock with central_api_key_enabled as having an effective key", () => { + const providerConfigs = [ + { + ...MockChatProviderConfig, + id: "prov-bedrock", + provider: "bedrock", + has_api_key: false, + central_api_key_enabled: true, + }, + ]; + + const states = deriveProviderStates([], providerConfigs, null); + + expect(states[0].hasEffectiveAPIKey).toBe(true); + }); + + it("drops models without ai_provider_id when multiple configs exist for the same provider", () => { + const providerConfigs = [ + { ...MockChatProviderConfig, id: "prov-a", provider: "openai" }, + { ...MockChatProviderConfig, id: "prov-b", provider: "openai" }, + ]; + const modelConfigs = [ + { ...MockChatModelConfig, id: "m1", provider: "openai" }, + ]; + + const states = deriveProviderStates(modelConfigs, providerConfigs, null); + + expect(states.flatMap((s) => s.modelConfigs)).toHaveLength(0); + }); + + it("detects env-preset providers from the catalog when no config exists", () => { + const catalog: TypesGen.ChatModelsResponse = { + providers: [ + { ...MockChatModelProvider, provider: "openai", available: true }, + ], + }; + + const states = deriveProviderStates([], null, catalog); + + expect(states).toHaveLength(1); + expect(states[0].provider).toBe("openai"); + expect(states[0].isEnvPreset).toBe(true); + expect(states[0].hasCatalogAPIKey).toBe(true); + }); + + it("flags env-preset providers via the provider config source", () => { + const providerConfigs = [ + { + ...MockChatProviderConfig, + id: "prov-openai", + provider: "openai", + source: "env_preset" as const, + }, + ]; + + const states = deriveProviderStates([], providerConfigs, null); + + expect(states[0].isEnvPreset).toBe(true); + expect(states[0].providerConfig).toBeUndefined(); + }); + + it("treats an unavailable catalog provider as having a key unless the api key is missing", () => { + const catalog: TypesGen.ChatModelsResponse = { + providers: [ + { + ...MockChatModelProvider, + provider: "openai", + available: false, + unavailable_reason: "fetch_failed", + }, + ], + }; + + const states = deriveProviderStates([], null, catalog); + + expect(states[0].hasCatalogAPIKey).toBe(true); + }); + + it("derives label, catalogModelCount, and baseURL from the inputs", () => { + const providerConfigs = [ + { + ...MockChatProviderConfig, + id: "prov-openai", + provider: "openai", + display_name: "Custom OpenAI", + base_url: "https://custom.example.com/v1", + }, + ]; + const catalog: TypesGen.ChatModelsResponse = { + providers: [ + { + ...MockChatModelProvider, + provider: "openai", + models: [ + { + id: "gpt-x", + provider: "openai", + model: "gpt-x", + display_name: "GPT-X", + }, + ], + }, + ], + }; + + const states = deriveProviderStates([], providerConfigs, catalog); + + expect(states[0].label).toBe("Custom OpenAI"); + expect(states[0].catalogModelCount).toBe(1); + expect(states[0].baseURL).toBe("https://custom.example.com/v1"); + }); +}); + +describe("canManageProviderModels", () => { + const baseState = baseProviderState; + + it("returns false without a managed provider config", () => { + expect( + canManageProviderModels({ ...baseState, providerConfig: undefined }), + ).toBe(false); + }); + + it("returns true when the provider has an effective API key", () => { + expect(canManageProviderModels(baseState)).toBe(true); + }); + + it("returns true when user-supplied API keys are allowed", () => { + expect( + canManageProviderModels({ + ...baseState, + hasEffectiveAPIKey: false, + providerConfig: { ...MockChatProviderConfig, allow_user_api_key: true }, + }), + ).toBe(true); + }); + + it("returns false with no key and user keys disallowed", () => { + expect( + canManageProviderModels({ + ...baseState, + hasEffectiveAPIKey: false, + providerConfig: { + ...MockChatProviderConfig, + allow_user_api_key: false, + }, + }), + ).toBe(false); + }); + + it("returns false for undefined provider state", () => { + expect(canManageProviderModels(undefined)).toBe(false); + }); +}); + +describe("resolveModelProviderKey", () => { + const states: ProviderState[] = [ + { ...baseProviderState, key: "prov-a", provider: "openai" }, + { ...baseProviderState, key: "prov-b", provider: "openai" }, + { ...baseProviderState, key: "prov-anthropic", provider: "anthropic" }, + ]; + + it("prefers ai_provider_id when present", () => { + expect( + resolveModelProviderKey( + { ...MockChatModelConfig, ai_provider_id: "prov-explicit" }, + states, + ), + ).toBe("prov-explicit"); + }); + + it("falls back to the single matching provider state key", () => { + expect( + resolveModelProviderKey( + { ...MockChatModelConfig, provider: "anthropic" }, + states, + ), + ).toBe("prov-anthropic"); + }); + + it("returns an empty key when multiple states match the provider", () => { + expect( + resolveModelProviderKey( + { ...MockChatModelConfig, provider: "openai" }, + states, + ), + ).toBe(""); + }); + + it("falls back to the provider name when no states match", () => { + expect( + resolveModelProviderKey( + { ...MockChatModelConfig, provider: "google" }, + states, + ), + ).toBe("google"); + }); +}); diff --git a/site/src/modules/aiModels/providerStates.ts b/site/src/modules/aiModels/providerStates.ts new file mode 100644 index 0000000000..79d8732142 --- /dev/null +++ b/site/src/modules/aiModels/providerStates.ts @@ -0,0 +1,237 @@ +import type * as TypesGen from "#/api/typesGenerated"; +import { + getDefaultProviderBaseURL, + normalizeProvider, +} from "#/modules/aiModels/helpers"; +import { formatProviderLabel } from "#/utils/aiProviders"; + +export type ProviderState = { + key: string; + provider: string; + label: string; + providerConfig: TypesGen.ChatProviderConfig | undefined; + modelConfigs: readonly TypesGen.ChatModelConfig[]; + catalogModelCount: number; + hasManagedAPIKey: boolean; + hasCatalogAPIKey: boolean; + hasEffectiveAPIKey: boolean; + allowUserAPIKey: boolean; + isEnvPreset: boolean; + baseURL: string; +}; + +type CatalogProvider = TypesGen.ChatModelsResponse["providers"][number]; + +const envPresetProviders = new Set(["openai", "anthropic"]); + +const readOptionalString = (value: unknown): string | undefined => { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +}; + +const isDatabaseProviderConfig = ( + providerConfig: TypesGen.ChatProviderConfig | undefined, + source: TypesGen.ChatProviderConfigSource | undefined, +): providerConfig is TypesGen.ChatProviderConfig => { + if (!providerConfig) return false; + if (providerConfig.id === "00000000-0000-0000-0000-000000000000") { + return false; + } + return source === undefined || source === "database"; +}; + +const getCatalogProviders = ( + catalog: TypesGen.ChatModelsResponse | null | undefined, +): readonly CatalogProvider[] => { + const providers = catalog?.providers; + return Array.isArray(providers) ? providers : []; +}; + +const providerHasCatalogAPIKey = (provider: CatalogProvider): boolean => + provider.available || + (Boolean(provider.unavailable_reason) && + provider.unavailable_reason !== "missing_api_key"); + +const getProviderModels = ( + provider: CatalogProvider | undefined, +): readonly CatalogProvider["models"][number][] => { + const models = provider?.models; + return Array.isArray(models) ? models : []; +}; + +const getProviderBaseURL = ( + providerConfig: TypesGen.ChatProviderConfig | undefined, +): string => { + return ( + readOptionalString(providerConfig?.base_url) ?? + getDefaultProviderBaseURL(providerConfig?.provider ?? "") + ); +}; + +const providerConfigStateKey = ( + providerConfig: TypesGen.ChatProviderConfig, +): string => { + const providerID = readOptionalString(providerConfig.id); + if (providerID && providerID !== "00000000-0000-0000-0000-000000000000") { + return providerID; + } + return normalizeProvider(providerConfig.provider); +}; + +type ProviderEntry = { + key: string; + provider: string; +}; + +export const deriveProviderStates = ( + modelConfigs: readonly TypesGen.ChatModelConfig[], + providerConfigs: TypesGen.ChatProviderConfig[] | null | undefined, + catalog: TypesGen.ChatModelsResponse | null | undefined, +): readonly ProviderState[] => { + const orderedEntries: ProviderEntry[] = []; + const seenEntries = new Set(); + const includeEntry = (keyValue: string, providerValue: string) => { + const key = readOptionalString(keyValue); + const provider = normalizeProvider(providerValue); + if (!key || !provider || seenEntries.has(key)) return; + seenEntries.add(key); + orderedEntries.push({ key, provider }); + }; + + const catalogProviders = getCatalogProviders(catalog); + const catalogProvidersByProvider = new Map(); + for (const cp of catalogProviders) { + const provider = normalizeProvider(cp.provider); + if (!provider) continue; + catalogProvidersByProvider.set(provider, cp); + } + + const providerConfigKeysByProvider = new Map(); + const providerTypesWithConfigs = new Set(); + for (const pc of providerConfigs ?? []) { + const provider = normalizeProvider(pc.provider); + if (!provider) continue; + const key = providerConfigStateKey(pc); + providerTypesWithConfigs.add(provider); + providerConfigKeysByProvider.set(provider, [ + ...(providerConfigKeysByProvider.get(provider) ?? []), + key, + ]); + includeEntry(key, provider); + } + const modelStateKey = (modelConfig: TypesGen.ChatModelConfig): string => { + const aiProviderID = readOptionalString(modelConfig.ai_provider_id); + if (aiProviderID) { + return aiProviderID; + } + const provider = normalizeProvider(modelConfig.provider); + const providerConfigKeys = providerConfigKeysByProvider.get(provider) ?? []; + if (providerConfigKeys.length === 1) { + return providerConfigKeys[0]; + } + return providerConfigKeys.length === 0 ? provider : ""; + }; + + for (const cp of catalogProviders) { + const provider = normalizeProvider(cp.provider); + if (!provider || providerTypesWithConfigs.has(provider)) continue; + includeEntry(provider, provider); + } + for (const mc of modelConfigs) { + includeEntry(modelStateKey(mc), mc.provider); + } + + const providerConfigsByKey = new Map(); + for (const pc of providerConfigs ?? []) { + const key = providerConfigStateKey(pc); + if (!key) continue; + providerConfigsByKey.set(key, pc); + } + + const modelConfigsByKey = new Map(); + for (const mc of modelConfigs) { + const key = modelStateKey(mc); + if (!key) continue; + const existing = modelConfigsByKey.get(key); + if (existing) { + existing.push(mc); + } else { + modelConfigsByKey.set(key, [mc]); + } + } + + return orderedEntries.map(({ key, provider }) => { + const providerConfigEntry = providerConfigsByKey.get(key); + const providerConfigSource = providerConfigEntry?.source; + const providerConfig = isDatabaseProviderConfig( + providerConfigEntry, + providerConfigSource, + ) + ? providerConfigEntry + : undefined; + const catalogProvider = catalogProvidersByProvider.get(provider); + const hasManagedAPIKey = providerConfig?.has_api_key ?? false; + const hasProviderEntryAPIKey = providerConfigEntry?.has_api_key ?? false; + const hasCatalogAPIKey = catalogProvider + ? providerHasCatalogAPIKey(catalogProvider) + : false; + const label = + readOptionalString(providerConfigEntry?.display_name) ?? + formatProviderLabel(provider); + const hasBedrockAmbientCredentials = + provider === "bedrock" && + providerConfig?.central_api_key_enabled === true; + const modelConfigsForProvider = modelConfigsByKey.get(key) ?? []; + const isCatalogEnvPreset = + !providerConfig && envPresetProviders.has(provider) && hasCatalogAPIKey; + const isEnvPreset = + providerConfigSource === "env_preset" || isCatalogEnvPreset; + + return { + key, + provider, + label, + providerConfig, + modelConfigs: modelConfigsForProvider, + catalogModelCount: getProviderModels(catalogProvider).length, + hasManagedAPIKey, + hasCatalogAPIKey, + hasEffectiveAPIKey: providerConfigEntry + ? hasProviderEntryAPIKey || hasBedrockAmbientCredentials + : hasManagedAPIKey || hasCatalogAPIKey, + allowUserAPIKey: providerConfigEntry?.allow_user_api_key ?? true, + isEnvPreset, + baseURL: getProviderBaseURL(providerConfigEntry), + }; + }); +}; + +export const canManageProviderModels = ( + providerState: ProviderState | undefined, +): boolean => { + return Boolean( + providerState?.providerConfig && + (providerState.hasEffectiveAPIKey || + providerState.providerConfig.allow_user_api_key), + ); +}; + +export const resolveModelProviderKey = ( + modelConfig: TypesGen.ChatModelConfig, + providerStates: readonly ProviderState[], +): string => { + const providerID = readOptionalString(modelConfig.ai_provider_id); + if (providerID) { + return providerID; + } + const provider = normalizeProvider(modelConfig.provider); + const matches = providerStates.filter((s) => s.provider === provider); + if (matches.length === 1) { + return matches[0].key; + } + if (matches.length > 1) { + return ""; + } + return provider; +}; diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index 664e4529cc..f3290bc4f4 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -32,6 +32,9 @@ const AISettingsSidebarView: FC = ({ AI Gateway Keys )} + {permissions.editDeploymentConfig && ( + Models + )} {permissions.editDeploymentConfig && (
diff --git a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPage.tsx b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPage.tsx new file mode 100644 index 0000000000..e65120b6a7 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPage.tsx @@ -0,0 +1,92 @@ +import { type FC, useMemo } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useNavigate, useSearchParams } from "react-router"; +import { toast } from "sonner"; +import { getErrorMessage } from "#/api/errors"; +import { + chatModelConfigs, + chatModels, + chatProviderConfigs, + createChatModelConfig, +} from "#/api/queries/chats"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { + canManageProviderModels, + deriveProviderStates, +} from "#/modules/aiModels/providerStates"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import { pageTitle } from "#/utils/page"; +import AddModelPageView from "./AddModelPageView"; + +const AddModelPage: FC = () => { + const { permissions } = useAuthenticated(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [searchParams] = useSearchParams(); + const providerKey = searchParams.get("provider") ?? ""; + const duplicateId = searchParams.get("duplicate"); + + const providerConfigsQuery = useQuery(chatProviderConfigs()); + const modelConfigsQuery = useQuery(chatModelConfigs()); + const modelCatalogQuery = useQuery(chatModels()); + + const createMutation = useMutation(createChatModelConfig(queryClient)); + + const providerStates = useMemo( + () => + deriveProviderStates( + modelConfigsQuery.data ?? [], + providerConfigsQuery.data, + modelCatalogQuery.data, + ), + [modelConfigsQuery.data, providerConfigsQuery.data, modelCatalogQuery.data], + ); + + const isLoading = + providerConfigsQuery.isLoading || + modelConfigsQuery.isLoading || + modelCatalogQuery.isLoading; + + const selectedProviderState = providerKey + ? (providerStates.find((ps) => ps.key === providerKey) ?? null) + : (providerStates.find(canManageProviderModels) ?? null); + const duplicateSourceModel = duplicateId + ? modelConfigsQuery.data?.find((m) => m.id === duplicateId) + : undefined; + const currentDefaultModel = modelConfigsQuery.data?.find((m) => m.is_default); + + return ( + + {pageTitle("Add model", "AI Settings")} + + { + const next = new URLSearchParams(searchParams); + next.set("provider", key); + void navigate(`/ai/settings/models/add?${next.toString()}`, { + replace: true, + }); + }} + onCreateModel={async (req) => { + try { + const created = await createMutation.mutateAsync(req); + toast.success( + `Model "${created.display_name || created.model}" added.`, + ); + await navigate(`/ai/settings/models/${created.id}`); + } catch (error) { + toast.error(getErrorMessage(error, "Failed to add model.")); + } + }} + /> + + ); +}; + +export default AddModelPage; diff --git a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx new file mode 100644 index 0000000000..901a886817 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { withToaster } from "#/testHelpers/storybook"; +import { + MockAnthropicProviderState, + MockOpenAIProviderState, +} from "../testFixtures"; +import AddModelPageView from "./AddModelPageView"; + +const meta: Meta = { + title: "pages/AISettingsPage/ModelsPage/AddModelPageView", + component: AddModelPageView, + decorators: [withToaster], + args: { + isLoading: false, + providerStates: [MockOpenAIProviderState, MockAnthropicProviderState], + selectedProviderState: MockOpenAIProviderState, + isSaving: false, + onProviderChange: fn(), + onCreateModel: fn(async () => undefined), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("heading", { name: /add an? OpenAI model/i }), + ).toBeInTheDocument(); + }, +}; + +export const WebSearchDependentFields: Story = { + args: { selectedProviderState: MockAnthropicProviderState }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: /provider configuration/i }), + ); + expect(canvas.queryByLabelText(/allowed domains/i)).not.toBeInTheDocument(); + const webSearchField = canvas.getByRole("radiogroup", { + name: /web search enabled/i, + }); + await userEvent.click( + within(webSearchField).getByRole("radio", { name: /on/i }), + ); + const allowed = await canvas.findByLabelText(/allowed domains/i); + const blocked = await canvas.findByLabelText(/blocked domains/i); + await userEvent.type(allowed, "example.com"); + expect(blocked).toBeDisabled(); + }, +}; + +export const ProviderNotFound: Story = { + args: { selectedProviderState: null }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Provider not found")).toBeInTheDocument(); + }, +}; + +export const Loading: Story = { + args: { isLoading: true }, +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx new file mode 100644 index 0000000000..76af38450b --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx @@ -0,0 +1,73 @@ +import { ArrowLeftIcon } from "lucide-react"; +import type { FC } from "react"; +import { Link } from "react-router"; +import type * as TypesGen from "#/api/typesGenerated"; +import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; +import { Button } from "#/components/Button/Button"; +import { Loader } from "#/components/Loader/Loader"; +import type { ProviderState } from "#/modules/aiModels/providerStates"; +import { ModelForm } from "../components/ModelForm"; + +interface AddModelPageViewProps { + isLoading: boolean; + providerStates: readonly ProviderState[]; + selectedProviderState: ProviderState | null; + duplicateSourceModel?: TypesGen.ChatModelConfig; + currentDefaultModel?: TypesGen.ChatModelConfig; + isSaving: boolean; + onProviderChange: (providerKey: string) => void; + onCreateModel: ( + req: TypesGen.CreateChatModelConfigRequest, + ) => Promise; +} + +const AddModelPageView: FC = ({ + isLoading, + providerStates, + selectedProviderState, + duplicateSourceModel, + currentDefaultModel, + isSaving, + onProviderChange, + onCreateModel, +}) => { + if (isLoading) { + return ; + } + + if (!selectedProviderState) { + return ( +
+ + + + + Provider not found + + The provider you are trying to add a model for is not available. + Please try again. + + +
+ ); + } + + return ( + {}} + /> + ); +}; + +export default AddModelPageView; diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelsPage.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelsPage.tsx new file mode 100644 index 0000000000..49a28aa98d --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelsPage.tsx @@ -0,0 +1,56 @@ +import type { FC } from "react"; +import { useQuery } from "react-query"; +import { + chatModelConfigs, + chatModels, + chatProviderConfigs, +} from "#/api/queries/chats"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { deriveProviderStates } from "#/modules/aiModels/providerStates"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import { pageTitle } from "#/utils/page"; +import ModelsPageView from "./ModelsPageView"; + +const ModelsPage: FC = () => { + const { permissions } = useAuthenticated(); + + const providerConfigsQuery = useQuery({ + ...chatProviderConfigs(), + enabled: permissions.editDeploymentConfig, + }); + const modelConfigsQuery = useQuery(chatModelConfigs()); + const modelCatalogQuery = useQuery(chatModels()); + + const models = (modelConfigsQuery.data ?? []).slice().sort((a, b) => { + const cmp = a.provider.localeCompare(b.provider); + return cmp !== 0 ? cmp : a.model.localeCompare(b.model); + }); + const providerStates = deriveProviderStates( + models, + providerConfigsQuery.data, + modelCatalogQuery.data, + ); + + return ( + + {pageTitle("Models", "AI Settings")} + + + + ); +}; + +export default ModelsPage; diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx new file mode 100644 index 0000000000..39994900f0 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import type { ChatModelConfig } from "#/api/typesGenerated"; +import ModelsPageView from "./ModelsPageView"; +import { + MockAnthropicProviderState, + MockOpenAIProviderState, + mockClaude, + mockDisabledModel, + mockGPT5, +} from "./testFixtures"; + +const meta: Meta = { + title: "pages/AISettingsPage/ModelsPage/ModelsPageView", + component: ModelsPageView, + args: { + isLoading: false, + error: null, + models: [mockGPT5, mockClaude, mockDisabledModel], + providerStates: [MockOpenAIProviderState, MockAnthropicProviderState], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/models" }, + routing: [ + { path: "/ai/settings/models", useStoryElement: true }, + { path: "/ai/settings/models/add", useStoryElement: true }, + { path: "/ai/settings/models/:modelId", useStoryElement: true }, + ], + }), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("button", { name: /add model/i }), + ).toBeInTheDocument(); + await expect(canvas.getByText("GPT-5")).toBeInTheDocument(); + await expect(canvas.getByText("Claude Sonnet 4.5")).toBeInTheDocument(); + await expect(canvas.getAllByText("OpenAI").length).toBeGreaterThan(0); + await expect(canvas.getByText("Anthropic")).toBeInTheDocument(); + await expect(canvas.getAllByText("Enabled").length).toBeGreaterThan(0); + await expect(canvas.getByText("Default")).toBeInTheDocument(); + await expect(canvas.getByText("Disabled")).toBeInTheDocument(); + }, +}; + +export const Loading: Story = { + args: { + isLoading: true, + models: [], + }, +}; + +export const Empty: Story = { + args: { + models: [], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("No models configured")).toBeInTheDocument(); + }, +}; + +export const LoadError: Story = { + args: { + error: new Error("Failed to load models"), + models: [], + }, +}; + +const manyModels: ChatModelConfig[] = Array.from({ length: 23 }, (_, i) => ({ + ...mockClaude, + id: `model-${i}`, + model: `model-${i}`, + display_name: `Model ${i}`, + is_default: false, +})); + +export const Paginated: Story = { + args: { + models: manyModels, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Model 0")).toBeInTheDocument(); + await expect(canvas.queryByText("Model 10")).not.toBeInTheDocument(); + await expect(canvas.getByText(/Showing/)).toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: /next page/i })); + await expect(canvas.getByText("Model 10")).toBeInTheDocument(); + await expect(canvas.queryByText("Model 0")).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx new file mode 100644 index 0000000000..2cf3f67f24 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx @@ -0,0 +1,178 @@ +import { ChevronDownIcon, PlusIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { useNavigate } from "react-router"; +import type { ChatModelConfig } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Button } from "#/components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; +import { TableLoader } from "#/components/TableLoader/TableLoader"; +import { + canManageProviderModels, + type ProviderState, +} from "#/modules/aiModels/providerStates"; +import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon"; +import { paginateItems } from "#/utils/paginateItems"; +import { ModelRow } from "./components/ModelRow"; + +const MODELS_PAGE_SIZE = 10; + +const AddModelDropdown: FC<{ providerStates: readonly ProviderState[] }> = ({ + providerStates, +}) => { + const navigate = useNavigate(); + const manageableProviderStates = providerStates.filter( + canManageProviderModels, + ); + + return ( + + + + + +
+ Select a provider +
+ {manageableProviderStates.length === 0 ? ( + No providers available + ) : ( + manageableProviderStates.map((providerState) => ( + + void navigate( + `/ai/settings/models/add?provider=${encodeURIComponent( + providerState.key, + )}`, + ) + } + > + + {providerState.label} + + )) + )} +
+
+ ); +}; + +interface ModelsPageViewProps { + isLoading: boolean; + error: unknown; + models: readonly ChatModelConfig[]; + providerStates: readonly ProviderState[]; +} + +const ModelsPageView: FC = ({ + isLoading, + error, + models, + providerStates, +}) => { + const navigate = useNavigate(); + const [page, setPage] = useState(1); + const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } = + paginateItems(models, MODELS_PAGE_SIZE, page); + + const providerLabelByModelId = new Map(); + for (const providerState of providerStates) { + for (const providerModel of providerState.modelConfigs) { + providerLabelByModelId.set(providerModel.id, providerState.label); + } + } + + return ( +
+ } + > + Models + + Choose which models from your configured providers are available for + users to select. You can set a default and adjust context limits. + + + {Boolean(error) && ( +
+ +
+ )} + + + + Name + Provider + Context limit + Status + + Open model + + + + + {isLoading ? ( + + ) : models.length === 0 ? ( + + ) : ( + pagedItems.map((model) => ( + void navigate(`/ai/settings/models/${model.id}`)} + /> + )) + )} + +
+ {models.length > 0 && ( +
+
+ +
+ + Showing {pagedItems.length}{" "} + of {models.length} models + +
+ )} +
+ ); +}; + +export default ModelsPageView; diff --git a/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPage.tsx b/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPage.tsx new file mode 100644 index 0000000000..e7d95aaabf --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPage.tsx @@ -0,0 +1,140 @@ +import { type FC, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Navigate, useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; +import { getErrorMessage } from "#/api/errors"; +import { + chatModelConfigs, + chatModels, + chatProviderConfigs, + deleteChatModelConfig, + updateChatModelConfig, +} from "#/api/queries/chats"; +import { Loader } from "#/components/Loader/Loader"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { deriveProviderStates } from "#/modules/aiModels/providerStates"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import { pageTitle } from "#/utils/page"; +import UpdateModelPageView from "./UpdateModelPageView"; + +const UpdateModelPage: FC = () => { + const { permissions } = useAuthenticated(); + const { modelId } = useParams<{ modelId: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const providerConfigsQuery = useQuery(chatProviderConfigs()); + const modelConfigsQuery = useQuery(chatModelConfigs()); + const modelCatalogQuery = useQuery(chatModels()); + + const updateMutation = useMutation(updateChatModelConfig(queryClient)); + const deleteMutation = useMutation(deleteChatModelConfig(queryClient)); + + const providerStates = useMemo( + () => + deriveProviderStates( + modelConfigsQuery.data ?? [], + providerConfigsQuery.data, + modelCatalogQuery.data, + ), + [modelConfigsQuery.data, providerConfigsQuery.data, modelCatalogQuery.data], + ); + + const isLoading = + providerConfigsQuery.isLoading || + modelConfigsQuery.isLoading || + modelCatalogQuery.isLoading; + + const model = modelConfigsQuery.data?.find((m) => m.id === modelId); + const currentDefaultModel = modelConfigsQuery.data?.find((m) => m.is_default); + const [providerKeyOverride, setProviderKeyOverride] = useState( + null, + ); + const selectedProviderState = + (providerKeyOverride + ? providerStates.find((ps) => ps.key === providerKeyOverride) + : undefined) ?? + providerStates.find((ps) => + ps.modelConfigs.some((m) => m.id === modelId), + ) ?? + null; + + return ( + + {!modelId ? ( + + ) : isLoading ? ( + <> + {pageTitle("Loading...", "AI Settings")} + + + ) : !model ? ( + + ) : ( + { + try { + const updated = await updateMutation.mutateAsync({ + modelConfigId: id, + req, + }); + toast.success( + `Model "${updated.display_name || updated.model}" updated.`, + ); + await navigate("/ai/settings/models"); + } catch (error) { + toast.error(getErrorMessage(error, "Failed to update model.")); + } + }} + onDeleteModel={async (id) => { + try { + await deleteMutation.mutateAsync(id); + toast.success( + `Model "${model.display_name || model.model}" deleted.`, + ); + await navigate("/ai/settings/models", { replace: true }); + } catch (error) { + toast.error(getErrorMessage(error, "Failed to delete model.")); + } + }} + onDuplicate={() => { + if (!selectedProviderState) return; + void navigate( + `/ai/settings/models/add?provider=${encodeURIComponent( + selectedProviderState.key, + )}&duplicate=${encodeURIComponent(model.id)}`, + ); + }} + onToggleEnabled={(enabled) => { + updateMutation.mutate( + { modelConfigId: model.id, req: { enabled } }, + { + onSuccess: () => { + toast.success( + `Model "${model.display_name || model.model}" ${ + enabled ? "enabled" : "disabled" + }.`, + ); + }, + onError: (error) => { + toast.error( + getErrorMessage(error, "Failed to update model."), + ); + }, + }, + ); + }} + /> + )} + + ); +}; + +export default UpdateModelPage; diff --git a/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.stories.tsx new file mode 100644 index 0000000000..cb416ad598 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { withToaster } from "#/testHelpers/storybook"; +import { + MockAnthropicProviderState, + MockOpenAIProviderState, + mockGPT5, +} from "../testFixtures"; +import UpdateModelPageView from "./UpdateModelPageView"; + +const meta: Meta = { + title: "pages/AISettingsPage/ModelsPage/UpdateModelPageView", + component: UpdateModelPageView, + decorators: [withToaster], + args: { + model: mockGPT5, + providerStates: [MockOpenAIProviderState, MockAnthropicProviderState], + selectedProviderState: MockOpenAIProviderState, + onProviderChange: fn(), + isSaving: false, + isDeleting: false, + onUpdateModel: fn(async () => undefined), + onDeleteModel: fn(async () => undefined), + onDuplicate: fn(), + onToggleEnabled: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("button", { name: /^update model$/i }), + ).toBeVisible(); + await expect(canvas.getByLabelText(/model identifier/i)).toBeEnabled(); + }, +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.tsx new file mode 100644 index 0000000000..5d4d017039 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.tsx @@ -0,0 +1,61 @@ +import type { FC } from "react"; +import type * as TypesGen from "#/api/typesGenerated"; +import type { ProviderState } from "#/modules/aiModels/providerStates"; +import { pageTitle } from "#/utils/page"; +import { ModelForm } from "../components/ModelForm"; + +interface UpdateModelPageViewProps { + model: TypesGen.ChatModelConfig; + currentDefaultModel?: TypesGen.ChatModelConfig; + providerStates: readonly ProviderState[]; + selectedProviderState: ProviderState | null; + onProviderChange: (providerKey: string) => void; + isSaving: boolean; + isDeleting: boolean; + onUpdateModel: ( + modelConfigId: string, + req: TypesGen.UpdateChatModelConfigRequest, + ) => Promise; + onDeleteModel: (modelConfigId: string) => Promise; + onDuplicate: () => void; + onToggleEnabled: (enabled: boolean) => void; +} + +const UpdateModelPageView: FC = ({ + model, + currentDefaultModel, + providerStates, + selectedProviderState, + onProviderChange, + isSaving, + isDeleting, + onUpdateModel, + onDeleteModel, + onDuplicate, + onToggleEnabled, +}) => { + return ( + <> + + {pageTitle(model.display_name || model.model, "AI Settings")} + + {}} + onUpdateModel={onUpdateModel} + onDeleteModel={onDeleteModel} + onDuplicate={onDuplicate} + onToggleEnabled={onToggleEnabled} + /> + + ); +}; + +export default UpdateModelPageView; diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx new file mode 100644 index 0000000000..53dc60d4e0 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -0,0 +1,201 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, screen, userEvent, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { withToaster } from "#/testHelpers/storybook"; +import { + MockAnthropicProviderState, + MockOpenAIProviderState, + mockGPT5, +} from "../testFixtures"; +import { ModelForm } from "./ModelForm"; + +const meta: Meta = { + title: "pages/AISettingsPage/ModelsPage/ModelForm", + component: ModelForm, + decorators: [withToaster], + args: { + providerStates: [MockOpenAIProviderState, MockAnthropicProviderState], + selectedProviderState: MockOpenAIProviderState, + onProviderChange: fn(), + isSaving: false, + isDeleting: false, + onCreateModel: fn(async () => undefined), + onUpdateModel: fn(async () => undefined), + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/models/add" }, + routing: [ + { path: "/ai/settings/models/add", useStoryElement: true }, + { path: "/ai/settings/models", element:
Models
}, + ], + }), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Add: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("heading", { name: /add an? OpenAI model/i }), + ).toBeInTheDocument(); + await expect( + canvas.getByRole("checkbox", { name: /set as default model/i }), + ).toBeInTheDocument(); + const submit = canvas.getByRole("button", { name: /add model/i }); + await expect(submit).toBeDisabled(); + }, +}; + +export const AddValidSubmit: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const modelInput = canvas.getByLabelText(/model identifier/i); + await userEvent.type(modelInput, "gpt-5"); + const contextLimit = canvas.getByLabelText(/context limit/i); + await userEvent.type(contextLimit, "200000"); + const submit = canvas.getByRole("button", { name: /add model/i }); + await expect(submit).toBeEnabled(); + await userEvent.click(submit); + await expect(args.onCreateModel).toHaveBeenCalledTimes(1); + }, +}; + +export const AddSetAsDefault: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5"); + await userEvent.type(canvas.getByLabelText(/context limit/i), "200000"); + await userEvent.click( + canvas.getByRole("checkbox", { name: /set as default model/i }), + ); + await userEvent.click(canvas.getByRole("button", { name: /add model/i })); + await expect(args.onCreateModel).toHaveBeenCalledTimes(1); + await expect(args.onCreateModel).toHaveBeenCalledWith( + expect.objectContaining({ is_default: true }), + ); + }, +}; + +export const LeaveWithUnsavedChanges: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5"); + await userEvent.click( + canvas.getByRole("link", { name: /back to models/i }), + ); + const dialog = await screen.findByRole("dialog", { + name: /unsaved changes/i, + }); + await expect(dialog).toBeInTheDocument(); + }, +}; + +export const ReplaceDefaultWarning: Story = { + args: { + currentDefaultModel: { ...mockGPT5, is_default: true }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.type( + canvas.getByLabelText(/model identifier/i), + "gpt-5-mini", + ); + await userEvent.type(canvas.getByLabelText(/context limit/i), "200000"); + await userEvent.click( + canvas.getByRole("checkbox", { name: /set as default model/i }), + ); + await userEvent.click(canvas.getByRole("button", { name: /add model/i })); + await expect( + await screen.findByText(/replace default model/i), + ).toBeInTheDocument(); + await expect(args.onCreateModel).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: /^confirm$/i })); + await expect(args.onCreateModel).toHaveBeenCalledWith( + expect.objectContaining({ is_default: true }), + ); + }, +}; + +export const Edit: Story = { + args: { + editingModel: mockGPT5, + onDeleteModel: fn(async () => undefined), + onDuplicate: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("button", { name: /^update model$/i }), + ).toBeVisible(); + await expect( + canvas.getByRole("button", { name: /^cancel$/i }), + ).toBeVisible(); + await expect( + canvas.getByRole("checkbox", { name: /set as default model/i }), + ).toBeInTheDocument(); + await expect(canvas.getByLabelText(/model identifier/i)).toBeEnabled(); + await expect( + canvas.getByRole("combobox", { name: /provider/i }), + ).toBeEnabled(); + await userEvent.click( + canvas.getByRole("button", { name: /model actions/i }), + ); + await expect( + screen.getByRole("menuitem", { name: /duplicate model/i }), + ).toBeInTheDocument(); + await expect( + screen.getByRole("menuitem", { name: /delete/i }), + ).toBeInTheDocument(); + }, +}; + +export const EditDefaultBadge: Story = { + args: { + editingModel: { ...mockGPT5, is_default: true, enabled: true }, + onDeleteModel: fn(async () => undefined), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/^default$/i)).toBeInTheDocument(); + }, +}; + +export const EditDisabledBadge: Story = { + args: { + editingModel: { ...mockGPT5, is_default: false, enabled: false }, + onDeleteModel: fn(async () => undefined), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/^disabled$/i)).toBeInTheDocument(); + }, +}; + +export const EditSaveSubmits: Story = { + args: { + editingModel: mockGPT5, + onDeleteModel: fn(async () => undefined), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const save = canvas.getByRole("button", { name: /^update model$/i }); + await userEvent.click(save); + await expect(args.onUpdateModel).toHaveBeenCalledTimes(1); + }, +}; + +export const CostTrackingExpanded: Story = { + args: { + editingModel: mockGPT5, + onDeleteModel: fn(async () => undefined), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = canvas.getByRole("button", { name: /cost tracking/i }); + await userEvent.click(toggle); + }, +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx new file mode 100644 index 0000000000..e20ac5673d --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx @@ -0,0 +1,349 @@ +import { useFormik } from "formik"; +import { type FC, useRef, useState } from "react"; +import * as Yup from "yup"; +import type * as TypesGen from "#/api/typesGenerated"; +import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader"; +import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; +import { + canManageProviderModels, + type ProviderState, +} from "#/modules/aiModels/providerStates"; +import { + buildInitialModelFormValues, + buildModelConfigFromForm, + type ModelFormValues, + parsePositiveInteger, + parseThresholdInteger, +} from "#/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic"; +import { getFormHelpers } from "#/utils/formUtils"; +import { ModelFormDialogs } from "./ModelFormDialogs"; +import { ModelFormFields } from "./ModelFormFields"; +import { ModelFormBackLink, ModelFormHeader } from "./ModelFormHeader"; +import { ModelFormProviderSelect } from "./ModelFormProviderSelect"; + +const indefiniteArticle = (word: string): string => + /^[aeiou]/i.test(word) ? "an" : "a"; + +const validationSchema = Yup.object({ + model: Yup.string().trim().required("Model ID is required."), + displayName: Yup.string(), + enabled: Yup.boolean(), + contextLimit: Yup.string() + .required("Context limit is required.") + .test( + "positive-integer", + "Context limit must be a positive integer.", + (value) => !value?.trim() || parsePositiveInteger(value) !== null, + ), + compressionThreshold: Yup.string().test( + "threshold-range", + "Compression threshold must be a number between 0 and 100.", + (value) => !value?.trim() || parseThresholdInteger(value) !== null, + ), + isDefault: Yup.boolean(), +}); + +interface ModelFormProps { + editingModel?: TypesGen.ChatModelConfig; + duplicateSourceModel?: TypesGen.ChatModelConfig; + providerStates: readonly ProviderState[]; + selectedProviderState: ProviderState | null; + onProviderChange: (providerKey: string) => void; + isSaving: boolean; + isDeleting: boolean; + onCreateModel: ( + req: TypesGen.CreateChatModelConfigRequest, + ) => Promise; + onUpdateModel: ( + modelConfigId: string, + req: TypesGen.UpdateChatModelConfigRequest, + ) => Promise; + onDeleteModel?: (modelConfigId: string) => Promise; + currentDefaultModel?: TypesGen.ChatModelConfig; + onSetDefault?: () => void; + onDuplicate?: () => void; + onToggleEnabled?: (enabled: boolean) => void; +} + +export const ModelForm: FC = ({ + editingModel, + duplicateSourceModel, + providerStates, + selectedProviderState, + onProviderChange, + isSaving, + isDeleting, + onCreateModel, + onUpdateModel, + onDeleteModel, + onDuplicate, + currentDefaultModel, + onToggleEnabled, +}) => { + const initialModel = editingModel ?? duplicateSourceModel; + const isEditing = Boolean(editingModel); + const isDuplicating = Boolean(duplicateSourceModel) && !isEditing; + const initialValues = { + ...buildInitialModelFormValues(initialModel), + ...(isDuplicating && { isDefault: false }), + }; + const [showAdvanced, setShowAdvanced] = useState(false); + const [showPricing, setShowPricing] = useState(false); + const [showProviderConfig, setShowProviderConfig] = useState(false); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [confirmingReplaceDefault, setConfirmingReplaceDefault] = + useState(false); + const replaceDefaultConfirmedRef = useRef(false); + + const canAddModelForSelectedProvider = canManageProviderModels( + selectedProviderState ?? undefined, + ); + const mode: "add" | "edit" | "duplicate" = isEditing + ? "edit" + : isDuplicating + ? "duplicate" + : "add"; + + const selectedProviderType = + selectedProviderState?.provider ?? selectedProviderState?.key ?? ""; + const selectedProviderKey = selectedProviderState?.key ?? ""; + + const form = useFormik({ + initialValues, + validationSchema, + validateOnMount: true, + validateOnBlur: false, + onSubmit: async (values) => { + if (isSaving) return; + + const replacingDefault = + values.isDefault && + currentDefaultModel != null && + currentDefaultModel.id !== editingModel?.id; + if (replacingDefault && !replaceDefaultConfirmedRef.current) { + setConfirmingReplaceDefault(true); + return; + } + replaceDefaultConfirmedRef.current = false; + + const trimmedModel = values.model.trim(); + if (!trimmedModel) return; + + const parsedContextLimit = parsePositiveInteger(values.contextLimit); + const parsedCompressionThreshold = parseThresholdInteger( + values.compressionThreshold, + ); + + const buildResult = buildModelConfigFromForm( + selectedProviderType, + values.config, + ); + if (Object.keys(buildResult.fieldErrors).length > 0) return; + + const trimmedDisplayName = values.displayName.trim(); + const builtModelConfig = buildResult.modelConfig; + + const selectedProviderConfigID = + selectedProviderState?.providerConfig?.id; + const editingProviderConfigID = + editingModel?.ai_provider_id?.trim() ?? ""; + + if (isEditing && editingModel) { + const req: TypesGen.UpdateChatModelConfigRequest = { + ...(selectedProviderConfigID && + selectedProviderConfigID !== editingProviderConfigID && { + provider: selectedProviderState.provider, + ai_provider_id: selectedProviderConfigID, + }), + ...(trimmedModel !== editingModel.model && { + model: trimmedModel, + }), + ...(trimmedDisplayName !== (editingModel.display_name ?? "") && { + display_name: trimmedDisplayName, + }), + ...(parsedContextLimit !== null && + parsedContextLimit !== editingModel.context_limit && { + context_limit: parsedContextLimit, + }), + ...(parsedCompressionThreshold !== null && + parsedCompressionThreshold !== + editingModel.compression_threshold && { + compression_threshold: parsedCompressionThreshold, + }), + ...(values.isDefault !== editingModel.is_default && { + is_default: values.isDefault, + }), + model_config: builtModelConfig, + }; + + await onUpdateModel(editingModel.id, req); + } else { + if (!selectedProviderState?.providerConfig) return; + + const req: TypesGen.CreateChatModelConfigRequest = { + provider: selectedProviderState.provider, + ai_provider_id: selectedProviderState.providerConfig.id, + model: trimmedModel, + enabled: values.enabled, + is_default: values.isDefault, + ...(parsedContextLimit !== null && { + context_limit: parsedContextLimit, + }), + ...(parsedCompressionThreshold !== null && { + compression_threshold: parsedCompressionThreshold, + }), + ...(trimmedDisplayName && { + display_name: trimmedDisplayName, + }), + ...(builtModelConfig && { + model_config: builtModelConfig, + }), + }; + + await onCreateModel(req); + } + }, + }); + + const getFieldHelpers = getFormHelpers(form); + + const unsavedChanges = useUnsavedChangesPrompt( + form.dirty && !form.isSubmitting, + ); + + const modelConfigFormBuildResult = buildModelConfigFromForm( + selectedProviderType, + form.values.config, + ); + const hasFieldErrors = + Object.keys(modelConfigFormBuildResult.fieldErrors).length > 0; + const enabledToggleDisabled = + isSaving || + (editingModel?.is_default === true && editingModel.enabled === true); + const setDefaultDisabled = + isSaving || + (isEditing && + (editingModel?.is_default === true || editingModel?.enabled === false)); + + const contextLimitValid = + parsePositiveInteger(form.values.contextLimit) !== null; + const compressionThresholdValid = + !form.values.compressionThreshold.trim() || + parseThresholdInteger(form.values.compressionThreshold) !== null; + const canSubmit = + !isSaving && + !hasFieldErrors && + form.values.model.trim().length > 0 && + contextLimitValid && + compressionThresholdValid; + + const handleConfirmReplaceDefault = () => { + replaceDefaultConfirmedRef.current = true; + setConfirmingReplaceDefault(false); + void form.submitForm(); + }; + + if ( + !selectedProviderState || + (!canAddModelForSelectedProvider && !isEditing) + ) { + return ( + <> + +
+ Add model +
+
+ + {selectedProviderState && ( +

+ {!selectedProviderState.providerConfig + ? "Create a managed provider before adding models." + : "Set an API key for this provider before adding models."} +

+ )} +
+
+
+ + ); + } + + const modelField = getFieldHelpers("model"); + const contextLimitField = getFieldHelpers("contextLimit"); + const compressionThresholdField = getFieldHelpers("compressionThreshold"); + const displayNameField = getFieldHelpers("displayName"); + + const providerLabel = selectedProviderState.label; + const title = isEditing + ? editingModel + ? editingModel.display_name || editingModel.model + : "Edit model" + : isDuplicating + ? `Duplicate ${providerLabel} model` + : `Add ${indefiniteArticle(providerLabel)} ${providerLabel} model`; + + return ( + <> + setConfirmingDelete(true)} + /> +
+ +
+ form.resetForm({ values })} + formValues={form.values} + unsavedChanges={unsavedChanges} + confirmingReplaceDefault={confirmingReplaceDefault} + setConfirmingReplaceDefault={setConfirmingReplaceDefault} + currentDefaultModel={currentDefaultModel} + onConfirmReplaceDefault={handleConfirmReplaceDefault} + /> + + ); +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormDialogs.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormDialogs.tsx new file mode 100644 index 0000000000..871bc09286 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormDialogs.tsx @@ -0,0 +1,121 @@ +import { TriangleAlertIcon } from "lucide-react"; +import type { FC } from "react"; +import type * as TypesGen from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; +import type { ModelFormValues } from "#/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic"; +import { ConfirmDeleteDialog } from "#/pages/AgentsPage/components/ConfirmDeleteDialog"; + +export const ModelFormDialogs: FC<{ + editingModel?: TypesGen.ChatModelConfig; + onDeleteModel?: (modelConfigId: string) => Promise; + isDeleting: boolean; + confirmingDelete: boolean; + setConfirmingDelete: (open: boolean) => void; + resetForm: (values: ModelFormValues) => void; + formValues: ModelFormValues; + unsavedChanges: { + isOpen: boolean; + onCancel: () => void; + onConfirm: () => void; + }; + confirmingReplaceDefault: boolean; + setConfirmingReplaceDefault: (open: boolean) => void; + currentDefaultModel?: TypesGen.ChatModelConfig; + onConfirmReplaceDefault: () => void; +}> = ({ + editingModel, + onDeleteModel, + isDeleting, + confirmingDelete, + setConfirmingDelete, + resetForm, + formValues, + unsavedChanges, + confirmingReplaceDefault, + setConfirmingReplaceDefault, + currentDefaultModel, + onConfirmReplaceDefault, +}) => { + return ( + <> + {editingModel && onDeleteModel && ( + !open && setConfirmingDelete(false)} + onConfirm={() => { + resetForm(formValues); + void onDeleteModel(editingModel.id); + }} + /> + )} + !open && unsavedChanges.onCancel()} + > + + + Unsaved changes + + + Your updates haven't been saved. Leave anyway? + + + + + + + + + !open && setConfirmingReplaceDefault(false)} + > + + + Replace default model + + + + + {currentDefaultModel?.display_name || + currentDefaultModel?.model} + {" "} + is currently the default. Replace it? + + + + + + + + + + + ); +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx new file mode 100644 index 0000000000..da1e3a1da6 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx @@ -0,0 +1,349 @@ +import type { FormikContextType } from "formik"; +import { ChevronDownIcon, ChevronRightIcon, InfoIcon } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import { Link } from "react-router"; +import type * as TypesGen from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; +import { Checkbox } from "#/components/Checkbox/Checkbox"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "#/components/Collapsible/Collapsible"; +import { Input } from "#/components/Input/Input"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "#/components/InputGroup/InputGroup"; +import { Label } from "#/components/Label/Label"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import type { ProviderState } from "#/modules/aiModels/providerStates"; +import { + GeneralModelConfigFields, + ModelConfigFields, + PricingModelConfigFields, +} from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields"; +import { ModelIdentifierField } from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelIdentifierField"; +import type { + ModelConfigFormBuildResult, + ModelFormValues, +} from "#/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic"; +import { cn } from "#/utils/cn"; +import type { FormHelpers } from "#/utils/formUtils"; +import { ModelFormProviderSelect } from "./ModelFormProviderSelect"; + +const CollapsibleSection: FC<{ + title: string; + description: string; + open: boolean; + onOpenChange: (open: boolean) => void; + className?: string; + contentClassName?: string; + children: ReactNode; +}> = ({ + title, + description, + open, + onOpenChange, + className, + contentClassName, + children, +}) => { + return ( + + + {open ? ( + + ) : ( + + )} +
+

+ {title} +

+

{description}

+
+
+ +
{children}
+
+
+ ); +}; + +export const ModelFormFields: FC<{ + form: FormikContextType; + mode: "add" | "edit" | "duplicate"; + providerStates: readonly ProviderState[]; + selectedProviderState: ProviderState; + selectedProviderKey: string; + selectedProviderType: string; + onProviderChange: (providerKey: string) => void; + isDuplicating: boolean; + isEditing: boolean; + isSaving: boolean; + canSubmit: boolean; + initialModel?: TypesGen.ChatModelConfig; + modelField: FormHelpers; + contextLimitField: FormHelpers; + compressionThresholdField: FormHelpers; + displayNameField: FormHelpers; + setDefaultDisabled: boolean; + modelConfigFormBuildResult: ModelConfigFormBuildResult; + showPricing: boolean; + setShowPricing: (open: boolean) => void; + showProviderConfig: boolean; + setShowProviderConfig: (open: boolean) => void; + showAdvanced: boolean; + setShowAdvanced: (open: boolean) => void; +}> = ({ + form, + mode, + providerStates, + selectedProviderState, + selectedProviderKey, + selectedProviderType, + onProviderChange, + isDuplicating, + isEditing, + isSaving, + canSubmit, + initialModel, + modelField, + contextLimitField, + compressionThresholdField, + displayNameField, + setDefaultDisabled, + modelConfigFormBuildResult, + showPricing, + setShowPricing, + showProviderConfig, + setShowProviderConfig, + showAdvanced, + setShowAdvanced, +}) => { + return ( +
+
+
+ +
+ + +
+
+ +

+ Friendly name. Defaults to identifier if blank. +

+ +
+
+ + {contextLimitField.error ? ( +

+ {contextLimitField.helperText} +

+ ) : ( +

+ Max tokens in the context window. +

+ )} + + + + Tokens + + +
+
+ +
+ + + + + + + + + + +
+ + + + + % + + + {compressionThresholdField.error && ( +

+ {compressionThresholdField.helperText} +

+ )} +
+
+
+ +
+ + + + +
+
+
+ ); +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormHeader.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormHeader.tsx new file mode 100644 index 0000000000..b167bcef61 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormHeader.tsx @@ -0,0 +1,153 @@ +import { + ArrowLeftIcon, + CopyIcon, + EllipsisVerticalIcon, + TrashIcon, +} from "lucide-react"; +import type { FC } from "react"; +import { Link } from "react-router"; +import type * as TypesGen from "#/api/typesGenerated"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { Badge } from "#/components/Badge/Badge"; +import { Button } from "#/components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader"; +import { Switch } from "#/components/Switch/Switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import type { ProviderState } from "#/modules/aiModels/providerStates"; +import { getProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon"; +import { cn } from "#/utils/cn"; + +export const ModelFormBackLink: FC = () => { + return ( + + + + ); +}; + +export const ModelFormHeader: FC<{ + title: string; + selectedProviderState: ProviderState; + isEditing: boolean; + editingModel?: TypesGen.ChatModelConfig; + onDeleteModel?: (modelConfigId: string) => Promise; + onDuplicate?: () => void; + onToggleEnabled?: (enabled: boolean) => void; + isSaving: boolean; + enabledToggleDisabled: boolean; + onRequestDelete: () => void; +}> = ({ + title, + selectedProviderState, + isEditing, + editingModel, + onDeleteModel, + onDuplicate, + onToggleEnabled, + isSaving, + enabledToggleDisabled, + onRequestDelete, +}) => { + return ( + <> +
+ + {isEditing && editingModel && onDeleteModel && ( + + + + + + {onDuplicate && ( + + + Duplicate model + + )} + + + + Delete… + + + + )} +
+
+
+ + + + {title} + + + {isEditing && editingModel?.is_default && ( + Default + )} + {isEditing && + editingModel && + !editingModel.is_default && + !editingModel.enabled && Disabled} +
+ {isEditing && editingModel && ( +
+ + + + onToggleEnabled?.(checked)} + disabled={enabledToggleDisabled} + aria-label="Model enabled" + /> + + + + {editingModel.is_default && editingModel.enabled + ? "Default model cannot be disabled. Set another model as default first." + : editingModel.enabled + ? "Disable this model. It will be hidden from users." + : "Enable this model. It will be visible to users."} + + + Enable +
+ )} +
+ + ); +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx new file mode 100644 index 0000000000..9ae0b7d5c1 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx @@ -0,0 +1,55 @@ +import type { FC } from "react"; +import { Label } from "#/components/Label/Label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; +import type { ProviderState } from "#/modules/aiModels/providerStates"; +import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon"; + +export const ModelFormProviderSelect: FC<{ + providerStates: readonly ProviderState[]; + selectedProviderKey: string; + onProviderChange: (providerKey: string) => void; + disabled: boolean; +}> = ({ providerStates, selectedProviderKey, onProviderChange, disabled }) => { + return ( +
+ +

+ The provider this model belongs to. +

+ +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx new file mode 100644 index 0000000000..73b1f1a6b3 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx @@ -0,0 +1,84 @@ +import { ChevronRightIcon } from "lucide-react"; +import type { FC } from "react"; +import type { ChatModelConfig } from "#/api/typesGenerated"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { Badge } from "#/components/Badge/Badge"; +import { TableCell, TableRow } from "#/components/Table/Table"; +import { useClickableTableRow } from "#/hooks/useClickableTableRow"; +import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon"; +import { cn } from "#/utils/cn"; + +type ModelRowProps = { + model: ChatModelConfig; + providerLabel: string; + onClick: () => void; +}; + +const formatContextLimit = (contextLimit: number): string => { + if (!Number.isFinite(contextLimit)) { + return "N/A"; + } + return `${contextLimit.toLocaleString("en-US")} tokens`; +}; + +export const ModelRow: FC = ({ + model, + providerLabel, + onClick, +}) => { + const clickableProps = useClickableTableRow({ onClick }); + const displayName = model.display_name || model.model; + + return ( + + +
+ + + + + {displayName} + +
+
+ + + {providerLabel || "N/A"} + + + + + {formatContextLimit(model.context_limit)} + + + +
+ {model.is_default && Default} + + {model.enabled ? "Enabled" : "Disabled"} + +
+
+ +
+ +
+
+
+ ); +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts new file mode 100644 index 0000000000..e4aada6716 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts @@ -0,0 +1,84 @@ +import type { ChatModelConfig, ChatProviderConfig } from "#/api/typesGenerated"; +import type { ProviderState } from "#/modules/aiModels/providerStates"; + +const now = "2026-02-18T12:00:00.000Z"; + +const MockOpenAIProviderConfig: ChatProviderConfig = { + id: "prov-openai", + provider: "openai", + display_name: "OpenAI", + enabled: true, + has_api_key: true, + central_api_key_enabled: true, + allow_user_api_key: false, + allow_central_api_key_fallback: true, + base_url: "", + source: "database", + created_at: now, + updated_at: now, +}; + +const MockAnthropicProviderConfig: ChatProviderConfig = { + ...MockOpenAIProviderConfig, + id: "prov-anthropic", + provider: "anthropic", + display_name: "Anthropic", +}; + +export const mockGPT5: ChatModelConfig = { + id: "model-gpt5", + provider: "openai", + ai_provider_id: "prov-openai", + model: "gpt-5", + display_name: "GPT-5", + enabled: true, + is_default: true, + context_limit: 200000, + compression_threshold: 70, + created_at: now, + updated_at: now, +}; + +export const mockClaude: ChatModelConfig = { + ...mockGPT5, + id: "model-claude", + provider: "anthropic", + ai_provider_id: "prov-anthropic", + model: "claude-sonnet-4-5", + display_name: "Claude Sonnet 4.5", + is_default: false, +}; + +export const mockDisabledModel: ChatModelConfig = { + ...mockGPT5, + id: "model-disabled", + model: "gpt-4o-mini", + display_name: "GPT-4o mini", + is_default: false, + enabled: false, + context_limit: 128000, +}; + +export const MockOpenAIProviderState: ProviderState = { + key: "prov-openai", + provider: "openai", + label: "OpenAI", + providerConfig: MockOpenAIProviderConfig, + modelConfigs: [mockGPT5, mockDisabledModel], + catalogModelCount: 0, + hasManagedAPIKey: true, + hasCatalogAPIKey: true, + hasEffectiveAPIKey: true, + allowUserAPIKey: false, + isEnvPreset: false, + baseURL: "", +}; + +export const MockAnthropicProviderState: ProviderState = { + ...MockOpenAIProviderState, + key: "prov-anthropic", + provider: "anthropic", + label: "Anthropic", + providerConfig: MockAnthropicProviderConfig, + modelConfigs: [mockClaude], +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx index 005ece6744..009902322a 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx @@ -158,7 +158,7 @@ const UpdateProviderPageView: React.FC = () => {

Add or update models for this provider.{" "} Model settings diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 8cfa4dd4b1..d8ebbad85d 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -548,7 +548,7 @@ export const MissingProviderAndModelSetup: Story = { ); expect(canvas.getByRole("link", { name: "model" })).toHaveAttribute( "href", - "/agents/settings/models", + "/ai/settings/models", ); }, }; @@ -579,7 +579,7 @@ export const MissingModelSetup: Story = { }); expect(canvas.getByRole("link", { name: "model" })).toHaveAttribute( "href", - "/agents/settings/models", + "/ai/settings/models", ); }, }; diff --git a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx deleted file mode 100644 index 4b8c50fd96..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { FC } from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { - chatModelConfigs, - chatModels, - chatProviderConfigs, - createChatModelConfig, - createChatProviderConfig, - deleteChatModelConfig, - deleteChatProviderConfig, - updateChatModelConfig, - updateChatProviderConfig, -} from "#/api/queries/chats"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { RequirePermission } from "#/modules/permissions/RequirePermission"; -import { ChatModelAdminPanel } from "./components/ChatModelAdminPanel/ChatModelAdminPanel"; - -const AgentSettingsModelsPage: FC = () => { - const { permissions } = useAuthenticated(); - - const queryClient = useQueryClient(); - - // Queries. - const providerConfigsQuery = useQuery({ - ...chatProviderConfigs(), - enabled: permissions.editDeploymentConfig, - }); - const modelConfigsQuery = useQuery(chatModelConfigs()); - const modelCatalogQuery = useQuery(chatModels()); - - // Mutations. - const createProviderMutation = useMutation( - createChatProviderConfig(queryClient), - ); - const updateProviderMutation = useMutation( - updateChatProviderConfig(queryClient), - ); - const deleteProviderMutation = useMutation( - deleteChatProviderConfig(queryClient), - ); - const createModelMutation = useMutation(createChatModelConfig(queryClient)); - const updateModelMutation = useMutation(updateChatModelConfig(queryClient)); - const deleteModelMutation = useMutation(deleteChatModelConfig(queryClient)); - - return ( - - createProviderMutation.mutateAsync(req)} - onUpdateProvider={(providerConfigId, req) => - updateProviderMutation.mutateAsync({ providerConfigId, req }) - } - onDeleteProvider={(id) => deleteProviderMutation.mutateAsync(id)} - isProviderMutationPending={ - createProviderMutation.isPending || - updateProviderMutation.isPending || - deleteProviderMutation.isPending - } - providerMutationError={ - createProviderMutation.error ?? - updateProviderMutation.error ?? - deleteProviderMutation.error - } - onCreateModel={(req) => createModelMutation.mutateAsync(req)} - onUpdateModel={(modelConfigId, req) => - updateModelMutation.mutateAsync({ modelConfigId, req }) - } - onDeleteModel={(id) => deleteModelMutation.mutateAsync(id)} - isCreatingModel={createModelMutation.isPending} - isUpdatingModel={updateModelMutation.isPending} - isDeletingModel={deleteModelMutation.isPending} - modelMutationError={ - createModelMutation.error ?? - updateModelMutation.error ?? - deleteModelMutation.error - } - /> - - ); -}; - -export default AgentSettingsModelsPage; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 0e54638ac4..8ba97d6b84 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -494,7 +494,7 @@ export const MissingProviderAndModelSetup: Story = { ); expect(canvas.getByRole("link", { name: "model" })).toHaveAttribute( "href", - "/agents/settings/models", + "/ai/settings/models", ); }, }; diff --git a/site/src/pages/AgentsPage/components/AgentSetupNotice.tsx b/site/src/pages/AgentsPage/components/AgentSetupNotice.tsx index b2932b48c9..dbd1966d20 100644 --- a/site/src/pages/AgentsPage/components/AgentSetupNotice.tsx +++ b/site/src/pages/AgentsPage/components/AgentSetupNotice.tsx @@ -45,7 +45,7 @@ export const AgentSetupNotice: FC = ({ {" "} then add a{" "} model @@ -62,7 +62,7 @@ export const AgentSetupNotice: FC = ({ To chat with Coder Agents, set up a{" "} model diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx deleted file mode 100644 index 9a44057ff4..0000000000 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ /dev/null @@ -1,2380 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { type ComponentProps, useState } from "react"; -import { - expect, - fireEvent, - fn, - spyOn, - userEvent, - waitFor, - within, -} from "storybook/test"; -import { API } from "#/api/api"; -import type * as TypesGen from "#/api/typesGenerated"; -import { - MockChatModelConfig, - MockChatProviderConfig, -} from "#/testHelpers/chatModels"; -import { - ChatModelAdminPanel, - type ChatModelAdminSection, -} from "./ChatModelAdminPanel"; -import { formatContextBadge, getKnownModelsForProvider } from "./knownModels"; - -const now = "2026-02-18T12:00:00.000Z"; -const nilProviderConfigID = "00000000-0000-0000-0000-000000000000"; - -const createProviderConfig = ( - overrides: Partial & - Pick, -): TypesGen.ChatProviderConfig => ({ - ...MockChatProviderConfig, - display_name: "", - has_api_key: false, - allow_central_api_key_fallback: false, - created_at: now, - updated_at: now, - ...overrides, -}); - -const createProviderKey = (providerId: string): TypesGen.AIProviderKey => ({ - id: `key-${providerId}`, - masked: "sk-...test", - created_at: now, -}); - -const toAIProvider = ( - providerConfig: TypesGen.ChatProviderConfig, -): TypesGen.AIProvider => ({ - id: providerConfig.id, - type: providerConfig.provider as TypesGen.AIProviderType, - name: providerConfig.provider, - display_name: providerConfig.display_name, - enabled: providerConfig.enabled, - base_url: providerConfig.base_url ?? "", - api_keys: providerConfig.has_api_key - ? [createProviderKey(providerConfig.id)] - : [], - settings: {}, - created_at: providerConfig.created_at ?? now, - updated_at: providerConfig.updated_at ?? now, -}); - -const createModelConfig = ( - overrides: Partial & - Pick, -): TypesGen.ChatModelConfig => ({ - ...MockChatModelConfig, - created_at: now, - updated_at: now, - ...overrides, -}); - -type ChatModelAdminPanelStoryProps = ComponentProps; - -/** - * Set up spies for all chat admin API methods. The mutable `state` - * object lets mutation spies update what queries return on refetch, - * mimicking the real server round-trip. - */ -const setupChatSpies = (state: { - providerConfigs: TypesGen.ChatProviderConfig[]; - modelConfigs: TypesGen.ChatModelConfig[]; - modelCatalog: TypesGen.ChatModelsResponse; -}) => { - spyOn(API.experimental, "listAIProviders").mockImplementation(async () => { - return state.providerConfigs.map(toAIProvider); - }); - spyOn(API.experimental, "getChatModelConfigs").mockImplementation( - async () => { - return state.modelConfigs; - }, - ); - spyOn(API.experimental, "getChatModels").mockImplementation(async () => { - return state.modelCatalog; - }); - - spyOn(API.experimental, "createAIProvider").mockImplementation( - async (req) => { - const created = createProviderConfig({ - id: `provider-${Date.now()}`, - provider: req.type ?? "openai", - display_name: req.display_name ?? "", - has_api_key: - req.api_keys?.some((apiKey) => apiKey.trim().length > 0) ?? false, - base_url: req.base_url ?? "", - enabled: req.enabled ?? true, - source: "database", - }); - state.providerConfigs = [ - ...state.providerConfigs.filter((p) => p.id !== created.id), - created, - ]; - return toAIProvider(created); - }, - ); - spyOn(API.experimental, "updateAIProvider").mockImplementation( - async (providerConfigId, req) => { - const idx = state.providerConfigs.findIndex( - (p) => p.id === providerConfigId, - ); - if (idx < 0) { - throw new Error("Provider config not found."); - } - const current = state.providerConfigs[idx]; - const updated: TypesGen.ChatProviderConfig = { - ...current, - display_name: - typeof req.display_name === "string" - ? req.display_name - : current.display_name, - has_api_key: - req.api_keys === undefined - ? current.has_api_key - : req.api_keys.some((apiKey) => - apiKey.api_key !== undefined - ? apiKey.api_key.trim().length > 0 - : apiKey.id !== undefined, - ), - base_url: - typeof req.base_url === "string" ? req.base_url : current.base_url, - enabled: - typeof req.enabled === "boolean" ? req.enabled : current.enabled, - updated_at: now, - }; - state.providerConfigs = state.providerConfigs.map((p, i) => - i === idx ? updated : p, - ); - return toAIProvider(updated); - }, - ); - spyOn(API.experimental, "deleteAIProvider").mockImplementation( - async (providerConfigId) => { - state.providerConfigs = state.providerConfigs.filter( - (p) => p.id !== providerConfigId, - ); - }, - ); - - spyOn(API.experimental, "createChatModelConfig").mockImplementation( - async (req) => { - const created = createModelConfig({ - id: `model-${state.modelConfigs.length + 1}`, - provider: req.provider ?? "", - model: req.model, - ai_provider_id: req.ai_provider_id, - display_name: req.display_name || req.model, - enabled: req.enabled ?? true, - context_limit: - typeof req.context_limit === "number" && - Number.isFinite(req.context_limit) - ? req.context_limit - : 200000, - compression_threshold: - typeof req.compression_threshold === "number" && - Number.isFinite(req.compression_threshold) - ? req.compression_threshold - : 70, - model_config: req.model_config, - }); - state.modelConfigs = [...state.modelConfigs, created]; - return created; - }, - ); - - spyOn(API.experimental, "deleteChatModelConfig").mockImplementation( - async (modelConfigId) => { - state.modelConfigs = state.modelConfigs.filter( - (m) => m.id !== modelConfigId, - ); - }, - ); - - spyOn(API.experimental, "updateChatModelConfig").mockImplementation( - async (modelConfigId, req) => { - const idx = state.modelConfigs.findIndex((m) => m.id === modelConfigId); - if (idx < 0) { - throw new Error("Model config not found."); - } - - const current = state.modelConfigs[idx]; - const updated = createModelConfig({ - ...current, - ...req, - id: current.id, - provider: current.provider, - model: current.model, - updated_at: now, - }); - - state.modelConfigs = state.modelConfigs.map((modelConfig, i) => - i === idx ? updated : modelConfig, - ); - - return updated; - }, - ); -}; - -const meta: Meta = { - title: "pages/AgentsPage/ChatModelAdminPanel", - component: ChatModelAdminPanel, - args: { - providerConfigsData: [], - modelConfigsData: [], - modelCatalogData: { providers: [] }, - isLoading: false, - providerConfigsError: null, - modelConfigsError: null, - modelCatalogError: null, - onCreateProvider: fn(async () => ({ id: "" })), - onUpdateProvider: fn(async () => ({})), - onDeleteProvider: fn(async () => undefined), - isProviderMutationPending: false, - providerMutationError: null, - onCreateModel: fn(async () => ({})), - onUpdateModel: fn(async () => ({})), - onDeleteModel: fn(async () => undefined), - isCreatingModel: false, - isUpdatingModel: false, - isDeletingModel: false, - modelMutationError: null, - }, -}; - -export default meta; -type Story = StoryObj; - -export const ProviderAccordionCards: Story = { - args: { - section: "providers" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: nilProviderConfigID, - provider: "openrouter", - display_name: "OpenRouter", - source: "supported", - enabled: false, - }), - ], - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await expect(await body.findByText("OpenRouter")).toBeInTheDocument(); - // OpenAI should not be rendered. - expect(body.queryByText("OpenAI")).not.toBeInTheDocument(); - - await userEvent.click(body.getByRole("button", { name: /OpenRouter/i })); - await expect(await body.findByLabelText("Base URL")).toBeInTheDocument(); - }, -}; - -export const AddProviderFromMenu: Story = { - render: function AddProviderFromMenu(args) { - const [providerConfigsData, setProviderConfigsData] = useState( - args.providerConfigsData, - ); - - const handleCreateProvider: ChatModelAdminPanelStoryProps["onCreateProvider"] = - async (req) => { - const created = createProviderConfig({ - id: `provider-${req.provider}`, - provider: req.provider ?? "", - display_name: req.display_name ?? "", - has_api_key: (req.api_key ?? "").trim().length > 0, - central_api_key_enabled: req.central_api_key_enabled ?? true, - allow_user_api_key: req.allow_user_api_key ?? true, - allow_central_api_key_fallback: - req.allow_central_api_key_fallback ?? true, - base_url: req.base_url ?? "", - source: "database", - }); - await args.onCreateProvider(req); - setProviderConfigsData((current) => [...(current ?? []), created]); - return created; - }; - - return ( - - ); - }, - args: { - section: "providers" as ChatModelAdminSection, - sectionLabel: "Providers", - sectionDescription: - "Connect third-party LLM services like OpenAI, Anthropic, or Google.", - providerConfigsData: [ - createProviderConfig({ - id: "provider-anthropic", - provider: "anthropic", - display_name: "Anthropic Migration Test", - has_api_key: true, - allow_user_api_key: true, - }), - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI Migration Test", - has_api_key: true, - allow_user_api_key: true, - }), - createProviderConfig({ - id: "provider-openai-compatible", - provider: "openai-compat", - display_name: "OpenAI Compatible Migration Test", - has_api_key: true, - allow_user_api_key: true, - }), - ], - modelCatalogData: { providers: [] }, - }, - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - await userEvent.click( - await body.findByRole("button", { name: "Add provider" }), - ); - await userEvent.click( - await body.findByRole("menuitem", { name: /Google/i }), - ); - - expect(await body.findByLabelText(/^API Key$/i)).toBeInTheDocument(); - await userEvent.click( - await body.findByRole("button", { name: "Create provider config" }), - ); - - await waitFor(() => { - expect(args.onCreateProvider).toHaveBeenCalledWith( - expect.objectContaining({ provider: "google" }), - ); - }); - await waitFor(() => { - expect(body.getByRole("button", { name: "Save changes" })).toBeDisabled(); - }); - - await userEvent.click(body.getByText("Back")); - expect( - await body.findByRole("button", { name: "Google" }), - ).toBeInTheDocument(); - }, -}; - -export const EnvPresetProviders: Story = { - args: { - section: "providers" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: nilProviderConfigID, - provider: "openai", - display_name: "OpenAI", - has_api_key: true, - source: "env_preset", - enabled: true, - }), - createProviderConfig({ - id: nilProviderConfigID, - provider: "anthropic", - display_name: "Anthropic", - has_api_key: true, - source: "env_preset", - enabled: true, - }), - ], - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - // Both providers should be visible in the list. - await expect( - await body.findByRole("button", { name: /OpenAI/i }), - ).toBeInTheDocument(); - expect( - body.getByRole("button", { name: /Anthropic/i }), - ).toBeInTheDocument(); - - // Navigate to OpenAI detail view. - await userEvent.click(body.getByRole("button", { name: /OpenAI/i })); - - // In the detail view we should see the env-managed alert. - await expect( - await body.findByText( - "This provider key is configured from deployment environment settings and cannot be edited in this UI.", - ), - ).toBeVisible(); - // No API key input or create button should be present. - expect(body.queryByLabelText(/^API Key$/i)).not.toBeInTheDocument(); - expect( - body.queryByRole("button", { - name: "Create provider config", - }), - ).not.toBeInTheDocument(); - - // Navigate back to the list. - await userEvent.click(body.getByText("Back")); - - // Verify Anthropic is visible in the list again. - await expect( - await body.findByRole("button", { name: /Anthropic/i }), - ).toBeInTheDocument(); - - // Navigate to Anthropic detail view and verify it's also env-managed. - await userEvent.click( - await body.findByRole("button", { name: /Anthropic/i }), - ); - await expect( - await body.findByText( - "This provider key is configured from deployment environment settings and cannot be edited in this UI.", - ), - ).toBeVisible(); - }, -}; - -export const CreateAndUpdateProvider: Story = { - render: function CreateAndUpdateProvider(args) { - const [providerConfigsData, setProviderConfigsData] = useState( - args.providerConfigsData, - ); - - const handleCreateProvider: ChatModelAdminPanelStoryProps["onCreateProvider"] = - async (req) => { - const created = createProviderConfig({ - id: `provider-${Date.now()}`, - provider: req.provider ?? "", - display_name: req.display_name ?? "", - has_api_key: (req.api_key ?? "").trim().length > 0, - central_api_key_enabled: req.central_api_key_enabled ?? true, - allow_user_api_key: req.allow_user_api_key ?? false, - allow_central_api_key_fallback: - req.allow_central_api_key_fallback ?? false, - base_url: req.base_url ?? "", - source: "database", - }); - await args.onCreateProvider(req); - setProviderConfigsData((current) => [ - ...(current ?? []).filter( - (p) => - !(p.id === nilProviderConfigID && p.provider === req.provider), - ), - created, - ]); - return created; - }; - - const handleUpdateProvider: ChatModelAdminPanelStoryProps["onUpdateProvider"] = - async (providerConfigId, req) => { - const result = await args.onUpdateProvider(providerConfigId, req); - setProviderConfigsData((current) => { - if (!current) { - return current; - } - return current.map((providerConfig) => - providerConfig.id === providerConfigId - ? { - ...providerConfig, - display_name: - typeof req.display_name === "string" - ? req.display_name - : providerConfig.display_name, - has_api_key: - typeof req.api_key === "string" - ? req.api_key.trim().length > 0 - : providerConfig.has_api_key, - central_api_key_enabled: - typeof req.central_api_key_enabled === "boolean" - ? req.central_api_key_enabled - : providerConfig.central_api_key_enabled, - allow_user_api_key: - typeof req.allow_user_api_key === "boolean" - ? req.allow_user_api_key - : providerConfig.allow_user_api_key, - allow_central_api_key_fallback: - typeof req.allow_central_api_key_fallback === "boolean" - ? req.allow_central_api_key_fallback - : providerConfig.allow_central_api_key_fallback, - base_url: - typeof req.base_url === "string" - ? req.base_url - : providerConfig.base_url, - updated_at: "2026-02-18T12:00:00.000Z", - } - : providerConfig, - ); - }); - return result; - }; - - return ( - - ); - }, - args: { - section: "providers" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: nilProviderConfigID, - provider: "openai", - display_name: "OpenAI", - source: "supported", - enabled: false, - has_api_key: false, - }), - ], - modelCatalogData: { - providers: [ - { - provider: "openai", - available: false, - unavailable_reason: "missing_api_key", - models: [], - }, - ], - }, - }, - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); - - await userEvent.type( - await body.findByLabelText(/^API Key$/i), - "sk-provider-key", - ); - const createBaseURLInput = await body.findByLabelText("Base URL"); - await userEvent.clear(createBaseURLInput); - await userEvent.type(createBaseURLInput, "https://proxy.example.com/v1"); - await userEvent.click( - await body.findByRole("button", { name: "Create provider config" }), - ); - - await waitFor(() => { - expect(args.onCreateProvider).toHaveBeenCalledTimes(1); - }); - await waitFor(() => { - expect(body.getByRole("button", { name: "Save changes" })).toBeDisabled(); - }); - expect(args.onCreateProvider).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "openai", - api_key: "sk-provider-key", - base_url: "https://proxy.example.com/v1", - }), - ); - - await waitFor(() => { - expect( - body.getByRole("button", { name: "Save changes" }), - ).toBeInTheDocument(); - }); - - const apiKeyInput = body.getByLabelText(/^API Key$/i); - await userEvent.clear(apiKeyInput); - await userEvent.type(apiKeyInput, "sk-updated-provider-key"); - const baseURLInput = body.getByLabelText("Base URL"); - await userEvent.clear(baseURLInput); - await userEvent.type(baseURLInput, "https://internal-proxy.example.com/v2"); - await userEvent.click(body.getByRole("button", { name: "Save changes" })); - - await waitFor(() => { - expect(args.onUpdateProvider).toHaveBeenCalledTimes(1); - }); - expect(args.onUpdateProvider).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - api_key: "sk-updated-provider-key", - base_url: "https://internal-proxy.example.com/v2", - }), - ); - }, -}; - -export const ProviderApiKeyInputMasked: Story = { - args: { - section: "providers" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai-mask", - provider: "openai", - display_name: "OpenAI", - has_api_key: true, - }), - ], - modelCatalogData: { providers: [] }, - }, - beforeEach: () => { - setupChatSpies({ - providerConfigs: [ - createProviderConfig({ - id: "provider-openai-mask", - provider: "openai", - display_name: "OpenAI", - has_api_key: true, - }), - ], - modelConfigs: [], - modelCatalog: { providers: [] }, - }); - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); - await expect(await body.findByLabelText(/^API Key$/i)).toHaveAttribute( - "type", - "password", - ); - }, -}; - -export const ModelFormUserKeyOnlyProvider: Story = { - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-google-user-only-models", - provider: "google", - display_name: "Google", - has_api_key: false, - central_api_key_enabled: false, - allow_user_api_key: true, - allow_central_api_key_fallback: false, - }), - ], - modelCatalogData: { providers: [] }, - }, - beforeEach: () => { - setupChatSpies({ - providerConfigs: [ - createProviderConfig({ - id: "provider-google-user-only-models", - provider: "google", - display_name: "Google", - has_api_key: false, - central_api_key_enabled: false, - allow_user_api_key: true, - allow_central_api_key_fallback: false, - }), - ], - modelConfigs: [], - modelCatalog: { providers: [] }, - }); - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Google"); - await expect( - await body.findByLabelText(/Model Identifier/i), - ).toBeInTheDocument(); - expect( - body.queryByText( - "Set an API key for this provider on the Providers tab before adding models.", - ), - ).not.toBeInTheDocument(); - }, -}; - -export const ProviderFormBedrockAmbientCredentials: Story = { - args: { - section: "providers" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: nilProviderConfigID, - provider: "bedrock", - display_name: "AWS Bedrock", - source: "supported", - enabled: false, - }), - ], - modelCatalogData: { providers: [] }, - }, - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - await body.findByRole("button", { name: /AWS Bedrock/i }), - ); - - const apiKeyInput = await body.findByLabelText(/^API Key$/i); - const baseURLInput = await body.findByLabelText("Base URL"); - const createButton = body.getByRole("button", { - name: "Create provider config", - }); - - await expect(apiKeyInput).not.toBeRequired(); - await expect(apiKeyInput).toHaveAttribute( - "placeholder", - "Enter bearer token", - ); - await expect( - body.findByText( - "Bearer token for Bedrock authentication. Leave empty to use ambient AWS credentials.", - ), - ).resolves.toBeInTheDocument(); - await expect( - body.findByText( - /Bedrock runtime endpoint\.\s+Use the AWS region for the models this provider should call\./i, - ), - ).resolves.toBeInTheDocument(); - await expect(createButton).toBeDisabled(); - await userEvent.type( - baseURLInput, - "https://bedrock-runtime.us-east-1.amazonaws.com", - ); - await waitFor(() => { - expect(createButton).toBeEnabled(); - }); - - await userEvent.click(createButton); - await waitFor(() => { - expect(args.onCreateProvider).toHaveBeenCalledTimes(1); - }); - const createProviderMock = args.onCreateProvider as ReturnType; - const createRequest = createProviderMock.mock.calls[0][0] as Record< - string, - unknown - >; - expect(createRequest).toMatchObject({ - provider: "bedrock", - base_url: "https://bedrock-runtime.us-east-1.amazonaws.com", - }); - expect(createRequest).not.toHaveProperty("api_key"); - }, -}; - -export const ProviderFormBedrockBearerToken: Story = { - args: { - section: "providers" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-bedrock-bearer", - provider: "bedrock", - display_name: "AWS Bedrock", - base_url: "https://bedrock-runtime.us-east-1.amazonaws.com", - has_api_key: true, - central_api_key_enabled: true, - allow_user_api_key: false, - allow_central_api_key_fallback: false, - }), - ], - modelCatalogData: { providers: [] }, - }, - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - await body.findByRole("button", { name: /AWS Bedrock/i }), - ); - - const apiKeyInput = await body.findByLabelText(/^API Key$/i); - const saveButton = body.getByRole("button", { name: "Save changes" }); - - await expect(apiKeyInput).not.toBeRequired(); - await expect(apiKeyInput).toHaveValue("••••••••••••••••"); - - await userEvent.click(apiKeyInput); - await userEvent.type(apiKeyInput, "bedrock-bearer-token"); - await waitFor(() => { - expect(saveButton).toBeEnabled(); - }); - await userEvent.click(saveButton); - - await waitFor(() => { - expect(args.onUpdateProvider).toHaveBeenCalledTimes(1); - }); - expect(args.onUpdateProvider).toHaveBeenCalledWith( - "provider-bedrock-bearer", - expect.objectContaining({ api_key: "bedrock-bearer-token" }), - ); - }, -}; - -export const ProviderFormBedrockClearBearerToken: Story = { - args: { - section: "providers" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-bedrock-clear", - provider: "bedrock", - display_name: "AWS Bedrock", - base_url: "https://bedrock-runtime.us-east-1.amazonaws.com", - has_api_key: true, - central_api_key_enabled: true, - allow_user_api_key: false, - allow_central_api_key_fallback: false, - }), - ], - modelCatalogData: { providers: [] }, - }, - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - await body.findByRole("button", { name: /AWS Bedrock/i }), - ); - - const apiKeyInput = await body.findByLabelText(/^API Key$/i); - const clearStoredTokenButton = body.getByRole("button", { - name: /Clear stored token/i, - }); - const saveButton = body.getByRole("button", { name: "Save changes" }); - - await expect(apiKeyInput).toHaveValue("••••••••••••••••"); - await userEvent.click(clearStoredTokenButton); - await waitFor(() => { - expect(apiKeyInput).toHaveValue(""); - expect(saveButton).toBeEnabled(); - }); - await userEvent.click(saveButton); - - await waitFor(() => { - expect(args.onUpdateProvider).toHaveBeenCalledTimes(1); - }); - expect(args.onUpdateProvider).toHaveBeenCalledWith( - "provider-bedrock-clear", - expect.objectContaining({ api_key: "" }), - ); - }, -}; - -const openAddModelForm = async ( - body: ReturnType, - providerLabel: string, -) => { - // Click the dropdown trigger to open the provider menu. - const trigger = await body.findByRole("button", { name: "Add model" }); - await userEvent.click(trigger); - // Radix portals dropdown content into the document body. - // Wait for the menu to appear and click the provider item. - await waitFor(async () => { - const item = body.getByRole("menuitem", { - name: new RegExp(providerLabel, "i"), - }); - await userEvent.click(item); - }); -}; - -/** Expand a collapsible section by clicking its header button. */ -const expandSection = async (body: ReturnType, name: string) => { - const btn = await body.findByRole("button", { - name: new RegExp(name, "i"), - }); - await userEvent.click(btn); -}; - -const enterModelIdentifier = async ( - body: ReturnType, - value: string, -) => { - const field = await body.findByLabelText(/Model Identifier/i); - if (field instanceof HTMLInputElement) { - await userEvent.type(field, value); - return; - } - - await userEvent.click(field); - await userEvent.type(await body.findByRole("combobox"), value); -}; - -export const NoModelConfigByDefault: Story = { - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - }, - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - // Open "Add model" dropdown and select the OpenAI provider. - await openAddModelForm(body, "OpenAI"); - - await enterModelIdentifier(body, "gpt-5-pro"); - await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); - - // Max output tokens is under the "Advanced" toggle. - await userEvent.click(body.getByText("Advanced")); - await expect(await body.findByLabelText(/Max output tokens/i)).toHaveValue( - "", - ); - - // The submit button in ModelForm also says "Add model". - await userEvent.click(body.getByRole("button", { name: "Add model" })); - await waitFor(() => { - expect(args.onCreateModel).toHaveBeenCalledTimes(1); - }); - expect(args.onCreateModel).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "openai", - model: "gpt-5-pro", - }), - ); - // Blank pricing fields should remain unset in the payload. - const createModelMock = args.onCreateModel as ReturnType; - const callArgs = createModelMock.mock.calls[0][0] as Record< - string, - unknown - >; - expect(callArgs).not.toHaveProperty("model_config"); - }, -}; - -export const SubmitModelConfigExplicitly: Story = { - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - }, - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - // Open "Add model" dropdown and select the OpenAI provider. - await openAddModelForm(body, "OpenAI"); - - await enterModelIdentifier(body, "gpt-5-pro-custom"); - await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); - // Max output tokens is under "Advanced". - await expandSection(body, "Advanced"); - await userEvent.type( - await body.findByLabelText(/Max output tokens/i), - "32000", - ); - await expandSection(body, "Provider configuration"); - const effortGroup = await body.findByRole("radiogroup", { - name: "Reasoning Effort", - }); - await userEvent.click(within(effortGroup).getByText("High")); - - await userEvent.click(body.getByRole("button", { name: "Add model" })); - await waitFor(() => { - expect(args.onCreateModel).toHaveBeenCalledTimes(1); - }); - expect(args.onCreateModel).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "openai", - model: "gpt-5-pro-custom", - model_config: expect.objectContaining({ - max_output_tokens: 32000, - provider_options: { - openai: { - reasoning_effort: "high", - }, - }, - }), - }), - ); - }, -}; - -export const UpdateModelEnabledToggle: Story = { - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - modelConfigsData: [ - createModelConfig({ - id: "model-enabled", - provider: "openai", - model: "gpt-test-enabled", - display_name: "GPT Test Enabled", - enabled: true, - }), - ], - }, - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - await userEvent.click(await body.findByText("GPT Test Enabled")); - - const enabledSwitch = await body.findByRole("switch", { name: "Enabled" }); - await expect(enabledSwitch).toBeChecked(); - await userEvent.click(enabledSwitch); - await expect(enabledSwitch).not.toBeChecked(); - - await userEvent.click(body.getByRole("button", { name: "Save" })); - - await waitFor(() => { - expect(args.onUpdateModel).toHaveBeenCalledTimes(1); - }); - expect(args.onUpdateModel).toHaveBeenCalledWith( - "model-enabled", - expect.objectContaining({ enabled: false }), - ); - }, -}; - -// Each story opens the "Add model" form for a specific provider -// so you can visually verify the schema-driven fields render. - -const providerFormSetup = (provider: string, displayName: string) => ({ - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: `provider-${provider}`, - provider, - display_name: displayName, - source: "database", - has_api_key: true, - }), - ], - }, -}); - -const findOptionByText = (options: HTMLElement[], text: string) => { - for (const option of options) { - if (option.textContent?.includes(text)) { - return option; - } - } - throw new Error(`Expected visible option containing ${text}.`); -}; - -const expectKnownModelOptionsInOrder = async ( - body: ReturnType, - provider: string, -) => { - const knownModels = getKnownModelsForProvider(provider); - const options = await body.findAllByRole("option"); - expect(options.length).toBeGreaterThanOrEqual(knownModels.length); - - for (const [index, knownModel] of knownModels.entries()) { - const option = options[index]; - if (!option) { - throw new Error(`Expected option at index ${index}.`); - } - expect(option).toHaveTextContent(knownModel.displayName); - expect(option).toHaveTextContent(knownModel.modelIdentifier); - if (knownModel.contextLimit !== undefined) { - expect(option).toHaveTextContent( - formatContextBadge(knownModel.contextLimit), - ); - } - } - - return options; -}; - -const knownModelDefaultsFeedback = (displayName: string) => - `Defaults applied from ${displayName}. Review and adjust before saving.`; - -const noMatchingKnownModelsText = - "No matching known models. You can still use this identifier."; - -const openKnownModelPopover = async (body: ReturnType) => { - await userEvent.click(await body.findByLabelText(/Model Identifier/i)); - const input = await body.findByRole("combobox"); - await expect(input).toHaveFocus(); - return input; -}; - -const expectKnownModelPopoverClosed = async ( - body: ReturnType, -) => { - await waitFor(() => { - expect(body.queryByRole("listbox")).not.toBeInTheDocument(); - expect(body.queryAllByRole("option")).toHaveLength(0); - expect(body.queryByText(noMatchingKnownModelsText)).not.toBeInTheDocument(); - }); -}; - -const closeKnownModelPopoverToContextLimit = async ( - body: ReturnType, -) => { - await userEvent.click(body.getByLabelText(/Context limit/i)); - await expectKnownModelPopoverClosed(body); -}; - -const selectKnownModel = async ( - body: ReturnType, - modelIdentifier: string, -) => { - const input = await openKnownModelPopover(body); - await userEvent.clear(input); - await expect(input).toHaveValue(""); - const options = await body.findAllByRole("option"); - await userEvent.click(findOptionByText(options, modelIdentifier)); - await expectModelIdentifierValue(body, modelIdentifier); -}; - -const clearAndTypeKnownModelSearch = async ( - body: ReturnType, - value: string, -) => { - let input = await body.findByRole("combobox"); - await userEvent.clear(input); - input = await body.findByRole("combobox"); - await expect(input).toHaveValue(""); - await expect(input).toHaveFocus(); - await userEvent.keyboard(value); - input = await body.findByRole("combobox"); - await expect(input).toHaveValue(value); - return input; -}; - -const expectModelIdentifierValue = async ( - body: ReturnType, - value: string, -) => { - const control = await body.findByLabelText(/Model Identifier/i); - if (control.matches("input,textarea")) { - await waitFor(() => expect(control).toHaveValue(value)); - return; - } - - await waitFor(() => expect(control).toHaveTextContent(value)); -}; - -const getDefaultsFeedback = ( - body: ReturnType, - message: string, -) => - body - .queryAllByRole("status") - .filter((el: HTMLElement) => el.textContent === message); - -const expectDefaultsFeedbackCount = ( - body: ReturnType, - message: string, - count: number, -) => { - expect(getDefaultsFeedback(body, message)).toHaveLength(count); -}; - -const expectOffCatalogModelCommitted = async ( - body: ReturnType, - value: string, -) => { - await openKnownModelPopover(body); - await clearAndTypeKnownModelSearch(body, value); - await closeKnownModelPopoverToContextLimit(body); - - await expectModelIdentifierValue(body, value); - expect(body.queryByRole("status")).not.toBeInTheDocument(); - expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument(); -}; - -const ensureCostTrackingOpen = async (body: ReturnType) => { - if (body.queryByLabelText(/^Input$/i)) { - return; - } - await expandSection(body, "Cost tracking"); - await body.findByLabelText(/^Input$/i); -}; - -const expectPricingValue = async ( - body: ReturnType, - label: RegExp, - value: string, -) => { - await expect(await body.findByLabelText(label)).toHaveValue(value); -}; - -const expectReasoningEffort = async ( - body: ReturnType, - value: string, -) => { - const reasoningEffortGroup = await body.findByRole("radiogroup", { - name: "Reasoning Effort", - }); - - if (value === "") { - for (const option of within(reasoningEffortGroup).getAllByRole("radio")) { - await expect(option).toHaveAttribute("aria-checked", "false"); - } - return; - } - - const label = value.charAt(0).toUpperCase() + value.slice(1); - await expect( - within(reasoningEffortGroup).getByRole("radio", { name: label }), - ).toHaveAttribute("aria-checked", "true"); -}; - -type OpenAIDefaultExpectations = { - modelIdentifier: string; - contextLimit: string; - maxCompletionTokens: string; - reasoningEffort: string; - inputCost: string; - outputCost: string; - cacheReadCost?: string; - cacheWriteCost?: string; -}; - -const gpt55Defaults = { - modelIdentifier: "gpt-5.5", - contextLimit: "1050000", - maxCompletionTokens: "128000", - reasoningEffort: "medium", - inputCost: "5", - outputCost: "30", - cacheReadCost: "0.5", -} satisfies OpenAIDefaultExpectations; - -const gpt55ProDefaults = { - modelIdentifier: "gpt-5.5-pro", - contextLimit: "1050000", - maxCompletionTokens: "128000", - reasoningEffort: "high", - inputCost: "30", - outputCost: "180", -} satisfies OpenAIDefaultExpectations; - -const gpt54MiniDefaults = { - modelIdentifier: "gpt-5.4-mini", - contextLimit: "400000", - maxCompletionTokens: "128000", - reasoningEffort: "medium", - inputCost: "0.75", - outputCost: "4.5", - cacheReadCost: "0.075", -} satisfies OpenAIDefaultExpectations; - -const ensureProviderConfigurationOpen = async ( - body: ReturnType, -) => { - if (body.queryByLabelText(/Max Completion Tokens/i)) { - return; - } - await expandSection(body, "Provider configuration"); - await body.findByLabelText(/Max Completion Tokens/i); -}; - -const expectOpenAIKnownModelDefaults = async ( - body: ReturnType, - expectations: OpenAIDefaultExpectations, -) => { - await expectModelIdentifierValue(body, expectations.modelIdentifier); - await expect(body.getByLabelText(/Context limit/i)).toHaveValue( - expectations.contextLimit, - ); - - await ensureProviderConfigurationOpen(body); - await expect( - await body.findByLabelText(/Max Completion Tokens/i), - ).toHaveValue(expectations.maxCompletionTokens); - await expectReasoningEffort(body, expectations.reasoningEffort); - - await ensureCostTrackingOpen(body); - await expectPricingValue(body, /^Input$/i, expectations.inputCost); - await expectPricingValue(body, /^Output$/i, expectations.outputCost); - await expectPricingValue( - body, - /^Cache Read$/i, - expectations.cacheReadCost ?? "", - ); - await expectPricingValue( - body, - /^Cache Write$/i, - expectations.cacheWriteCost ?? "", - ); -}; - -export const OpenAIKnownModelHappyPath: Story = { - ...providerFormSetup("openai", "OpenAI"), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - await openKnownModelPopover(body); - const options = await expectKnownModelOptionsInOrder(body, "openai"); - await userEvent.click(findOptionByText(options, "gpt-5.5")); - - await expectModelIdentifierValue(body, "gpt-5.5"); - await expect(await body.findByRole("status")).toHaveTextContent( - "Defaults applied from GPT-5.5. Review and adjust before saving.", - ); - await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1050000"); - - await expandSection(body, "Provider configuration"); - await expect( - await body.findByLabelText(/Max Completion Tokens/i), - ).toHaveValue("128000"); - }, -}; - -export const OpenAIKnownModelKeyboardSelection: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-25: keyboard selection applies defaults", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - const input = await openKnownModelPopover(body); - fireEvent.keyDown(input, { key: "ArrowDown" }); - await userEvent.keyboard("{Enter}"); - - await expectOpenAIKnownModelDefaults(body, gpt55ProDefaults); - await expect(await body.findByRole("status")).toHaveTextContent( - knownModelDefaultsFeedback("GPT-5.5 Pro"), - ); - }, -}; - -export const OpenAIKnownModelReclickSelectedDoesNotClearField: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-26: re-clicking selected Known Model does not clear field", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - await selectKnownModel(body, "gpt-5.5"); - await openKnownModelPopover(body); - const options = await body.findAllByRole("option"); - await userEvent.click(findOptionByText(options, "gpt-5.5")); - - await expectModelIdentifierValue(body, "gpt-5.5"); - await expectKnownModelPopoverClosed(body); - expect( - body.queryByRole("button", { name: /clear/i }), - ).not.toBeInTheDocument(); - }, -}; - -export const AnthropicKnownModelHappyPath: Story = { - ...providerFormSetup("anthropic", "Anthropic"), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Anthropic"); - - await openKnownModelPopover(body); - const options = await body.findAllByRole("option"); - await userEvent.click(findOptionByText(options, "claude-opus-4-8")); - - await expectModelIdentifierValue(body, "claude-opus-4-8"); - await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1000000"); - - await expandSection(body, "Advanced"); - await expect(await body.findByLabelText(/Max Output Tokens/i)).toHaveValue( - "128000", - ); - - await expandSection(body, "Provider configuration"); - const sendReasoningGroup = await body.findByRole("radiogroup", { - name: "Send Reasoning", - }); - await expect( - within(sendReasoningGroup).getByRole("radio", { name: "On" }), - ).toHaveAttribute("aria-checked", "false"); - await expect( - within(sendReasoningGroup).getByRole("radio", { name: "Off" }), - ).toHaveAttribute("aria-checked", "false"); - await expect( - await body.findByLabelText(/Thinking Budget Tokens/i), - ).toHaveValue(""); - await expectReasoningEffort(body, "high"); - }, -}; - -export const AnthropicHaikuKnownModelUsesThinkingBudgetNotEffort: Story = { - ...providerFormSetup("anthropic", "Anthropic"), - name: "Add mode / DEREM-43: Haiku 4.5 sets thinking budget instead of effort", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Anthropic"); - await selectKnownModel(body, "claude-haiku-4-5"); - - await expandSection(body, "Provider configuration"); - - // Reasoning Effort should remain empty because Haiku 4.5 uses the - // thinking budget path instead of Anthropic adaptive thinking. - await expectReasoningEffort(body, ""); - await expect( - await body.findByLabelText(/Thinking Budget Tokens/i), - ).toHaveValue("8192"); - }, -}; - -export const OpenAIKnownModelDoesNotPreFireRequiredError: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-3: open does not pre-fire required error", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - await openKnownModelPopover(body); - - expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument(); - }, -}; - -export const OpenAIKnownModelOpenDoesNotFlashInvalidBorder: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-31: open does not flash invalid border on trigger", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - const trigger = await body.findByLabelText(/Model Identifier/i); - await openKnownModelPopover(body); - - expect([null, "false"]).toContain(trigger.getAttribute("aria-invalid")); - expect(trigger).not.toHaveClass("border-content-destructive"); - expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument(); - }, -}; - -export const KnownModelClickOffEmptyDoesNotFireRequired: Story = { - ...providerFormSetup("anthropic", "Anthropic"), - name: "Add mode / DEREM-47: clicking off empty model does not fire required error", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Anthropic"); - - const trigger = await body.findByLabelText(/Model Identifier/i); - await openKnownModelPopover(body); - - // Click another field to close the popover without typing or selecting. - // Mirrors the QA-reported flow: focus the field, change your mind, click - // elsewhere; the empty value should NOT surface "Model ID is required." - // before the user has actually attempted to commit anything. - await closeKnownModelPopoverToContextLimit(body); - - expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument(); - expect([null, "false"]).toContain(trigger.getAttribute("aria-invalid")); - expect(trigger).not.toHaveClass("border-content-destructive"); - }, -}; - -export const OpenAIKnownModelEscapeCancelsSearch: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-5: Escape cancels and preserves committed value", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - const feedback = knownModelDefaultsFeedback("GPT-5.5"); - await openAddModelForm(body, "OpenAI"); - - await selectKnownModel(body, "gpt-5.5"); - await expectModelIdentifierValue(body, "gpt-5.5"); - expectDefaultsFeedbackCount(body, feedback, 1); - - await openKnownModelPopover(body); - await clearAndTypeKnownModelSearch(body, "cod"); - await userEvent.keyboard("{Escape}"); - - await expectKnownModelPopoverClosed(body); - await expectModelIdentifierValue(body, "gpt-5.5"); - expectDefaultsFeedbackCount(body, feedback, 1); - - const reopenedInput = await openKnownModelPopover(body); - await expect(reopenedInput).toHaveValue("gpt-5.5"); - await userEvent.keyboard("{Escape}"); - }, -}; - -export const OpenAIKnownModelEscapeDoesNotReapplyDefaultsFeedback: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-30: type-then-Escape does not re-apply defaults feedback", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - const feedback = knownModelDefaultsFeedback("GPT-5.5"); - await openAddModelForm(body, "OpenAI"); - - await selectKnownModel(body, "gpt-5.5"); - expectDefaultsFeedbackCount(body, feedback, 1); - const initialFeedback = getDefaultsFeedback(body, feedback)[0]; - if (!initialFeedback) { - throw new Error("Expected Known Model defaults feedback."); - } - - await openKnownModelPopover(body); - await clearAndTypeKnownModelSearch(body, "a"); - await userEvent.keyboard("{Escape}"); - - await expectKnownModelPopoverClosed(body); - await userEvent.click(body.getByLabelText(/Context limit/i)); - - expectDefaultsFeedbackCount(body, feedback, 1); - expect(getDefaultsFeedback(body, feedback)[0]).toBe(initialFeedback); - await expectModelIdentifierValue(body, "gpt-5.5"); - await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1050000"); - }, -}; - -export const OpenAIKnownModelSequentialSelectionReplacesDefaults: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-4, DEREM-10: sequential selection replaces catalog defaults", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - await selectKnownModel(body, "gpt-5.5"); - await selectKnownModel(body, "gpt-5.4-mini"); - - await expectModelIdentifierValue(body, "gpt-5.4-mini"); - await expect(body.getByLabelText(/Context limit/i)).toHaveValue("400000"); - await ensureCostTrackingOpen(body); - await expectPricingValue(body, /^Input$/i, "0.75"); - await expectPricingValue(body, /^Output$/i, "4.5"); - }, -}; - -export const OpenAIKnownModelReasoningEffortClearsForNonReasoningModel: Story = - { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-31: reasoningEffort clears when switching to non-reasoning model", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - await selectKnownModel(body, "gpt-5.5"); - await ensureProviderConfigurationOpen(body); - await expectReasoningEffort(body, "medium"); - - await selectKnownModel(body, "gpt-5.4"); - await expectReasoningEffort(body, ""); - }, - }; - -export const OpenAIKnownModelStaleCostFieldDoesNotPersist: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-24: stale cost fields do not persist", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - await selectKnownModel(body, "gpt-5.5-pro"); - await selectKnownModel(body, "gpt-5.4-mini"); - await selectKnownModel(body, "gpt-5.5"); - - await expectOpenAIKnownModelDefaults(body, gpt55Defaults); - }, -}; - -export const OpenAIKnownModelOffCatalogInterleavingKeepsTracking: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-24: off-catalog interleaving keeps tracking", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - await selectKnownModel(body, "gpt-5.5"); - await openKnownModelPopover(body); - await clearAndTypeKnownModelSearch(body, "my-custom-fine-tune"); - await closeKnownModelPopoverToContextLimit(body); - await expectModelIdentifierValue(body, "my-custom-fine-tune"); - - await selectKnownModel(body, "gpt-5.4-mini"); - - await expectOpenAIKnownModelDefaults(body, gpt54MiniDefaults); - }, -}; - -export const OpenAIKnownModelChainTrackingDoesNotLoseFields: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-24: chained selections retain tracking", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - await selectKnownModel(body, "gpt-5.5"); - await selectKnownModel(body, "gpt-5.5-pro"); - await expectOpenAIKnownModelDefaults(body, gpt55ProDefaults); - await selectKnownModel(body, "gpt-5.4-mini"); - - await expectOpenAIKnownModelDefaults(body, gpt54MiniDefaults); - }, -}; - -export const OpenAIKnownModelDoubleApplyGuard: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-10: double-apply guard keeps defaults stable", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - const feedback = knownModelDefaultsFeedback("GPT-5.5"); - await openAddModelForm(body, "OpenAI"); - - await selectKnownModel(body, "gpt-5.5"); - await expect(await body.findByRole("status")).toHaveTextContent(feedback); - await ensureCostTrackingOpen(body); - await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1050000"); - await expectPricingValue(body, /^Input$/i, "5"); - await expectPricingValue(body, /^Output$/i, "30"); - - await openKnownModelPopover(body); - await closeKnownModelPopoverToContextLimit(body); - - expectDefaultsFeedbackCount(body, feedback, 1); - await expectModelIdentifierValue(body, "gpt-5.5"); - await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1050000"); - await expectPricingValue(body, /^Input$/i, "5"); - await expectPricingValue(body, /^Output$/i, "30"); - }, -}; - -export const OpenAIKnownModelExactCanonicalBlurAppliesDefaults: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-10: exact canonical blur applies defaults", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - await openKnownModelPopover(body); - await clearAndTypeKnownModelSearch(body, "gpt-5.5-pro"); - await closeKnownModelPopoverToContextLimit(body); - - await expectModelIdentifierValue(body, "gpt-5.5-pro"); - await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1050000"); - await ensureCostTrackingOpen(body); - await expectPricingValue(body, /^Input$/i, "30"); - await expectPricingValue(body, /^Output$/i, "180"); - await expect(await body.findByRole("status")).toHaveTextContent( - knownModelDefaultsFeedback("GPT-5.5 Pro"), - ); - }, -}; - -export const AnthropicKnownModelAliasTypedValueCancels: Story = { - ...providerFormSetup("anthropic", "Anthropic"), - name: "Add mode / DEREM-10: alias typed value cancels", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Anthropic"); - - await openKnownModelPopover(body); - await clearAndTypeKnownModelSearch(body, "custom-anthropic-model"); - await closeKnownModelPopoverToContextLimit(body); - await expectModelIdentifierValue(body, "custom-anthropic-model"); - - await openKnownModelPopover(body); - await clearAndTypeKnownModelSearch(body, "claude-haiku-4-5-20251001"); - const filteredOptions = await body.findAllByRole("option"); - expect( - findOptionByText(filteredOptions, "Claude Haiku 4.5"), - ).toHaveTextContent("claude-haiku-4-5"); - await closeKnownModelPopoverToContextLimit(body); - - await expectModelIdentifierValue(body, "custom-anthropic-model"); - expect(body.queryByRole("status")).not.toBeInTheDocument(); - await expect(body.getByLabelText(/Context limit/i)).toHaveValue(""); - }, -}; - -export const AnthropicKnownModelPunctuationVariantCommits: Story = { - ...providerFormSetup("anthropic", "Anthropic"), - name: "Add mode / DEREM-27: off-catalog with punctuation variant commits", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Anthropic"); - - await expectOffCatalogModelCommitted(body, "claude.haiku.4.5.20251001"); - }, -}; - -export const KnownModelOffCatalogSubstringCommits: Story = { - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-anthropic-known-model-substring", - provider: "anthropic", - display_name: "Anthropic", - source: "database", - has_api_key: true, - }), - createProviderConfig({ - id: "provider-openai-known-model-substring", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - }, - name: "Add mode / DEREM-19: off-catalog identifier substring-matching catalog metadata commits", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - await openAddModelForm(body, "Anthropic"); - await expectOffCatalogModelCommitted(body, "haiku"); - await userEvent.click(body.getByRole("button", { name: /^Cancel$/i })); - await waitFor(() => { - expect( - body.queryByLabelText(/Model Identifier/i), - ).not.toBeInTheDocument(); - }); - - await openAddModelForm(body, "OpenAI"); - await expectOffCatalogModelCommitted(body, "mini"); - await expectOffCatalogModelCommitted(body, "pro"); - await expectOffCatalogModelCommitted(body, "gpt-5"); - }, -}; - -export const KnownModelProviderChangeResetsDefaultsFeedback: Story = { - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai-known-model-reset", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - createProviderConfig({ - id: "provider-anthropic-known-model-reset", - provider: "anthropic", - display_name: "Anthropic", - source: "database", - has_api_key: true, - }), - ], - }, - name: "Add mode / DEREM-10: provider change resets Known Model defaults", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - await selectKnownModel(body, "gpt-5.5"); - await expect(await body.findByRole("status")).toHaveTextContent( - knownModelDefaultsFeedback("GPT-5.5"), - ); - - await userEvent.click(body.getByRole("button", { name: /^Cancel$/i })); - await waitFor(() => { - expect( - body.queryByLabelText(/Model Identifier/i), - ).not.toBeInTheDocument(); - }); - await openAddModelForm(body, "Anthropic"); - await selectKnownModel(body, "claude-haiku-4-5"); - - await expectModelIdentifierValue(body, "claude-haiku-4-5"); - await expect(body.getByLabelText(/Context limit/i)).toHaveValue("200000"); - await ensureCostTrackingOpen(body); - await expectPricingValue(body, /^Input$/i, "1"); - await expectPricingValue(body, /^Output$/i, "5"); - await expect(await body.findByRole("status")).toHaveTextContent( - knownModelDefaultsFeedback("Claude Haiku 4.5"), - ); - }, -}; - -export const OpenAIKnownModelTriggerAriaParity: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-1: aria parity on autocomplete trigger", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - // Surface the required-field error through a real user action: - // open the popover, type then clear the search, and click off. The - // off-catalog close path commits an empty string and marks the field - // touched, so Formik validation renders "Model ID is required." This - // verifies the inline-search trigger forwards aria-invalid + - // aria-describedby with the same parity as the plain - // fallback used in edit/duplicate modes. - await openKnownModelPopover(body); - const input = await clearAndTypeKnownModelSearch(body, "x"); - await userEvent.clear(input); - await closeKnownModelPopoverToContextLimit(body); - - const trigger = await body.findByLabelText(/Model Identifier/i); - const error = await body.findByText("Model ID is required."); - expect(error.id).toBeTruthy(); - await expect(trigger).toHaveAttribute("aria-invalid", "true"); - await expect(trigger).toHaveAttribute("aria-describedby", error.id); - }, -}; - -export const OpenAIKnownModelNoOptionsCopy: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-6: no-options auto-hides popover", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - await openKnownModelPopover(body); - await clearAndTypeKnownModelSearch(body, "zzzzzzz"); - - await expectKnownModelPopoverClosed(body); - expect(body.queryByText(noMatchingKnownModelsText)).not.toBeInTheDocument(); - }, -}; - -export const AnthropicKnownModelEnterCommitsOffCatalogIdentifier: Story = { - ...providerFormSetup("anthropic", "Anthropic"), - name: "Add mode / DEREM-34: Enter commits off-catalog identifier", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Anthropic"); - - const input = await openKnownModelPopover(body); - await userEvent.type(input, "claude-opus-4-5"); - await expectKnownModelPopoverClosed(body); - await expect(input).toHaveAttribute("aria-expanded", "false"); - await userEvent.keyboard("{Enter}"); - - await expectKnownModelPopoverClosed(body); - await expectModelIdentifierValue(body, "claude-opus-4-5"); - expect(body.queryByRole("status")).not.toBeInTheDocument(); - }, -}; - -export const KnownModelAutoHidePopoverWhenNoMatches: Story = { - ...providerFormSetup("anthropic", "Anthropic"), - name: "Add mode / DEREM-42: popover auto-hides when search has no matches", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Anthropic"); - - const input = await body.findByRole("combobox", { - name: /Model Identifier/i, - }); - await userEvent.click(input); - await expect(await body.findByText("Claude Opus 4.8")).toBeInTheDocument(); - - await userEvent.clear(input); - await userEvent.type(input, "claude-opus-4-5"); - - await waitFor(() => { - expect(body.queryByRole("listbox")).not.toBeInTheDocument(); - }); - expect( - body.queryByText(/No matching known models/i), - ).not.toBeInTheDocument(); - await expect(input).toHaveAttribute("aria-expanded", "false"); - - await userEvent.keyboard("{Enter}"); - await expectModelIdentifierValue(body, "claude-opus-4-5"); - }, -}; - -export const KnownModelBlurAfterAutoHideCommitsOffCatalog: Story = { - ...providerFormSetup("anthropic", "Anthropic"), - name: "Add mode / DEREM-45: blur after auto-hide commits off-catalog identifier", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Anthropic"); - - const input = await body.findByRole("combobox", { - name: /Model Identifier/i, - }); - await userEvent.click(input); - await userEvent.clear(input); - await userEvent.type(input, "claude-opus-4-5"); - - // Popover should auto-hide for the unmatched query. - await waitFor(() => { - expect(body.queryByRole("listbox")).not.toBeInTheDocument(); - }); - await expect(input).toHaveAttribute("aria-expanded", "false"); - - // Blur via Tab: focus moves to the next field, exercising the - // handleBlur auto-hide path that calls handleOpenChange(false). - await userEvent.tab(); - - await expectModelIdentifierValue(body, "claude-opus-4-5"); - // No defaults feedback for off-catalog identifiers. - expect(body.queryByRole("status")).not.toBeInTheDocument(); - // Critical: in the buggy variant where handleBlur skips the - // handleOpenChange(false) branch for the auto-hidden popover, - // the inline input still visually shows the typed search text - // (via the controlled inputValue prop), so any DOM-value - // assertion would pass vacuously. The committed form value is - // what diverges, surfaced here via the required-field error: - // markTouched() runs in the buggy path with form.values.model - // still empty, producing "Model ID is required." The fixed path - // commits the typed text via setFieldValue first, clearing the - // validation error. - expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument(); - }, -}; - -export const OpenAIKnownModelTriggerInputIsTypedField: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-35: trigger input is the typed field", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - const input = await openKnownModelPopover(body); - await userEvent.type(input, "5.4"); - - await expect(input).toHaveFocus(); - await expect(input).toHaveValue("5.4"); - const options = await body.findAllByRole("option"); - expect(findOptionByText(options, "gpt-5.4")).toBeInTheDocument(); - expect(findOptionByText(options, "gpt-5.4-mini")).toBeInTheDocument(); - expect(findOptionByText(options, "gpt-5.4-nano")).toBeInTheDocument(); - expect(body.queryByText("gpt-5.5")).not.toBeInTheDocument(); - expect(body.queryByText("gpt-5.5-pro")).not.toBeInTheDocument(); - expect(body.queryByText("gpt-5.3-codex")).not.toBeInTheDocument(); - }, -}; - -export const OpenAIKnownModelArrowDownEnterSelectsHighlighted: Story = { - ...providerFormSetup("openai", "OpenAI"), - name: "Add mode / DEREM-36: ArrowDown Enter selects highlighted option", - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - - const input = await openKnownModelPopover(body); - fireEvent.keyDown(input, { key: "ArrowDown" }); - await userEvent.keyboard("{Enter}"); - - await expectModelIdentifierValue(body, "gpt-5.5-pro"); - await expectOpenAIKnownModelDefaults(body, gpt55ProDefaults); - await expect(await body.findByRole("status")).toHaveTextContent( - knownModelDefaultsFeedback("GPT-5.5 Pro"), - ); - }, -}; - -export const UnsupportedProviderFallback: Story = { - ...providerFormSetup("google", "Google"), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Google"); - - const modelInput = await body.findByLabelText(/Model Identifier/i); - await userEvent.click(modelInput); - expect(body.queryByRole("option")).not.toBeInTheDocument(); - await userEvent.type(modelInput, "gemini-custom-model"); - await userEvent.tab(); - - await expect(modelInput).toHaveValue("gemini-custom-model"); - expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument(); - expect(body.queryByRole("status")).not.toBeInTheDocument(); - }, -}; - -export const ModelFormOpenAI: Story = { - ...providerFormSetup("openai", "OpenAI"), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI"); - await expandSection(body, "Provider configuration"); - await expect( - await body.findByLabelText(/Reasoning Effort/i), - ).toBeInTheDocument(); - await expect( - await body.findByLabelText(/Parallel Tool Calls/i), - ).toBeInTheDocument(); - }, -}; - -export const ModelFormAnthropic: Story = { - ...providerFormSetup("anthropic", "Anthropic"), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Anthropic"); - await expandSection(body, "Provider configuration"); - await expect( - await body.findByLabelText(/Send Reasoning/i), - ).toBeInTheDocument(); - await expect( - await body.findByLabelText(/Thinking Budget Tokens/i), - ).toBeInTheDocument(); - }, -}; - -export const ModelFormGoogle: Story = { - ...providerFormSetup("google", "Google"), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Google"); - await expandSection(body, "Provider configuration"); - await expect( - await body.findByLabelText(/Thinking Config Thinking Budget/i), - ).toBeInTheDocument(); - await expect( - await body.findByLabelText(/Thinking Config Include Thoughts/i), - ).toBeInTheDocument(); - }, -}; - -export const ModelFormOpenAICompat: Story = { - ...providerFormSetup("openaicompat", "OpenAI-compatible"), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenAI-compatible"); - await expandSection(body, "Provider configuration"); - await expect( - await body.findByLabelText(/Reasoning Effort/i), - ).toBeInTheDocument(); - }, -}; - -export const ModelFormOpenRouter: Story = { - ...providerFormSetup("openrouter", "OpenRouter"), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "OpenRouter"); - await expandSection(body, "Provider configuration"); - await expect( - await body.findByLabelText(/Reasoning Enabled/i), - ).toBeInTheDocument(); - await expect( - await body.findByLabelText(/Reasoning Max Tokens/i), - ).toBeInTheDocument(); - }, -}; - -export const ModelFormVercel: Story = { - ...providerFormSetup("vercel", "Vercel AI Gateway"), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Vercel AI Gateway"); - await expandSection(body, "Provider configuration"); - await expect( - await body.findByLabelText(/Reasoning Enabled/i), - ).toBeInTheDocument(); - await expect( - await body.findByLabelText(/Parallel Tool Calls/i), - ).toBeInTheDocument(); - }, -}; - -export const ModelFormAzure: Story = { - ...providerFormSetup("azure", "Azure OpenAI"), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "Azure OpenAI"); - await expandSection(body, "Provider configuration"); - // Azure aliases to OpenAI fields. - await expect( - await body.findByLabelText(/Reasoning Effort/i), - ).toBeInTheDocument(); - await expect( - await body.findByLabelText(/Service Tier/i), - ).toBeInTheDocument(); - }, -}; - -export const ModelFormBedrock: Story = { - ...providerFormSetup("bedrock", "AWS Bedrock"), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await openAddModelForm(body, "AWS Bedrock"); - await expandSection(body, "Provider configuration"); - // Bedrock aliases to Anthropic fields. - await expect( - await body.findByLabelText(/Send Reasoning/i), - ).toBeInTheDocument(); - await expect(await body.findByLabelText(/Effort/i)).toBeInTheDocument(); - }, -}; - -export const ModelPricingWarningInList: Story = { - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - modelConfigsData: [ - createModelConfig({ - id: "model-warning", - provider: "openai", - model: "gpt-4.1", - display_name: "GPT-4.1", - }), - ], - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await expect(await body.findByText("GPT-4.1")).toBeInTheDocument(); - await expect( - body.getByText("Model pricing is not defined"), - ).toBeInTheDocument(); - }, -}; - -export const ModelDeleteConfirmation: Story = { - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - modelConfigsData: [ - createModelConfig({ - id: "model-1", - provider: "openai", - model: "gpt-4o", - display_name: "GPT-4o", - }), - ], - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - // Click the model row to open the edit form. - await userEvent.click(await body.findByText("GPT-4o")); - - // The Delete button should be visible in the footer. - const deleteButton = await body.findByRole("button", { name: "Delete" }); - await expect(deleteButton).toBeInTheDocument(); - - // Click Delete to show the confirmation dialog. - await userEvent.click(deleteButton); - - // The confirmation dialog should appear - leave it visible - // so the Chromatic snapshot captures this state. - await expect( - await body.findByText(/Are you sure you want to delete this model/i), - ).toBeInTheDocument(); - await expect(body.getByRole("dialog")).toBeInTheDocument(); - await expect( - body.getByRole("button", { name: "Delete model" }), - ).toBeInTheDocument(); - await expect( - body.getByRole("button", { name: "Cancel" }), - ).toBeInTheDocument(); - }, -}; - -export const ModelDeleteCancelled: Story = { - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - modelConfigsData: [ - createModelConfig({ - id: "model-1", - provider: "openai", - model: "gpt-4o", - display_name: "GPT-4o", - }), - ], - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - // Navigate to edit form, open delete dialog, then cancel. - await userEvent.click(await body.findByText("GPT-4o")); - await userEvent.click(await body.findByRole("button", { name: "Delete" })); - await body.findByText(/Are you sure/i); - await userEvent.click(body.getByRole("button", { name: "Cancel" })); - - // The dialog should be closed and the form footer restored. - await waitFor(() => { - expect(body.queryByRole("dialog")).not.toBeInTheDocument(); - }); - await expect( - body.findByRole("button", { name: "Delete" }), - ).resolves.toBeInTheDocument(); - expect(body.getByRole("button", { name: "Save" })).toBeInTheDocument(); - }, -}; - -export const ModelDeleteConfirmed: Story = { - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - modelConfigsData: [ - createModelConfig({ - id: "model-1", - provider: "openai", - model: "gpt-4o", - display_name: "GPT-4o", - }), - ], - }, - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - // Navigate to edit form, open delete dialog, then confirm. - await userEvent.click(await body.findByText("GPT-4o")); - await userEvent.click(await body.findByRole("button", { name: "Delete" })); - await userEvent.click( - await body.findByRole("button", { name: "Delete model" }), - ); - - // The delete callback should have been called. - await waitFor(() => { - expect(args.onDeleteModel).toHaveBeenCalledTimes(1); - }); - expect(args.onDeleteModel).toHaveBeenCalledWith("model-1"); - }, -}; - -export const ProviderDeleteConfirmation: Story = { - args: { - section: "providers" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - // Navigate to the provider detail view. - await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); - - // Click Delete to show the confirmation dialog. - const deleteButton = await body.findByRole("button", { name: "Delete" }); - await userEvent.click(deleteButton); - - // The confirmation dialog should appear - leave it visible - // so the Chromatic snapshot captures this state. - await expect( - await body.findByText(/Are you sure you want to delete this provider/i), - ).toBeInTheDocument(); - await expect(body.getByRole("dialog")).toBeInTheDocument(); - await expect( - body.getByRole("button", { name: "Delete provider" }), - ).toBeInTheDocument(); - await expect( - body.getByRole("button", { name: "Cancel" }), - ).toBeInTheDocument(); - }, -}; - -export const ProviderDeleteCancelled: Story = { - args: { - section: "providers" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - // Navigate to provider detail, open delete dialog, then cancel. - await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); - await userEvent.click(await body.findByRole("button", { name: "Delete" })); - await body.findByText(/Are you sure/i); - await userEvent.click(body.getByRole("button", { name: "Cancel" })); - - // The dialog should be closed and the form footer restored. - await waitFor(() => { - expect(body.queryByRole("dialog")).not.toBeInTheDocument(); - }); - await expect( - body.findByRole("button", { name: "Delete" }), - ).resolves.toBeInTheDocument(); - expect( - body.getByRole("button", { name: /Save changes/i }), - ).toBeInTheDocument(); - }, -}; - -export const ProviderDeleteConfirmed: Story = { - args: { - section: "providers" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - }, - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - // Navigate to provider detail, open delete dialog, then confirm. - await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); - await userEvent.click(await body.findByRole("button", { name: "Delete" })); - await userEvent.click( - await body.findByRole("button", { name: "Delete provider" }), - ); - - // The delete callback should have been called. - await waitFor(() => { - expect(args.onDeleteProvider).toHaveBeenCalledTimes(1); - }); - expect(args.onDeleteProvider).toHaveBeenCalledWith("provider-openai"); - }, -}; - -export const ValidatesModelConfigFields: Story = { - args: { - section: "models" as ChatModelAdminSection, - providerConfigsData: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - ], - }, - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - // Open "Add model" dropdown and select the OpenAI provider. - await openAddModelForm(body, "OpenAI"); - - await enterModelIdentifier(body, "gpt-5-pro"); - await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); - // Max output tokens is under the "Advanced" toggle. - await userEvent.click(body.getByText("Advanced")); - const maxOutputTokensInput = - await body.findByLabelText(/Max output tokens/i); - await userEvent.type(maxOutputTokensInput, "not-a-number"); - await waitFor(() => { - expect(body.getByRole("button", { name: "Add model" })).toBeDisabled(); - }); - // No callback should have been invoked. - expect(args.onCreateModel).not.toHaveBeenCalled(); - }, -}; diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx deleted file mode 100644 index 6ecb1b0cc9..0000000000 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx +++ /dev/null @@ -1,373 +0,0 @@ -import type { FC } from "react"; - -import type * as TypesGen from "#/api/typesGenerated"; -import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; -import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { cn } from "#/utils/cn"; -import { formatProviderLabel } from "../../utils/modelOptions"; -import { - getDefaultProviderBaseURL, - normalizeProvider, - readOptionalString, -} from "./helpers"; -import { ModelsSection } from "./ModelsSection"; -import { ProvidersSection } from "./ProvidersSection"; - -export type CreateProviderResult = { id: string }; - -export type ProviderState = { - key: string; - provider: string; - label: string; - providerConfig: TypesGen.ChatProviderConfig | undefined; - modelConfigs: readonly TypesGen.ChatModelConfig[]; - catalogModelCount: number; - hasManagedAPIKey: boolean; - hasCatalogAPIKey: boolean; - hasEffectiveAPIKey: boolean; - allowUserAPIKey: boolean; - isEnvPreset: boolean; - baseURL: string; -}; - -export type ChatModelAdminSection = "providers" | "models"; - -type CatalogProvider = TypesGen.ChatModelsResponse["providers"][number]; - -const nilUUID = "00000000-0000-0000-0000-000000000000"; -const envPresetProviders = new Set(["openai", "anthropic"]); - -const hasProviderAPIKey = ( - providerConfig: TypesGen.ChatProviderConfig | undefined, -): boolean => { - if (!providerConfig) return false; - return providerConfig.has_api_key; -}; - -const getProviderConfigSource = ( - providerConfig: TypesGen.ChatProviderConfig | undefined, -): TypesGen.ChatProviderConfigSource | undefined => { - return providerConfig?.source; -}; - -const isDatabaseProviderConfig = ( - providerConfig: TypesGen.ChatProviderConfig | undefined, - source: TypesGen.ChatProviderConfigSource | undefined, -): providerConfig is TypesGen.ChatProviderConfig => { - if (!providerConfig) return false; - if (providerConfig.id === nilUUID) return false; - return source === undefined || source === "database"; -}; - -const getCatalogProviders = ( - catalog: TypesGen.ChatModelsResponse | null | undefined, -): readonly CatalogProvider[] => { - const providers = catalog?.providers; - return Array.isArray(providers) ? providers : []; -}; - -const providerHasCatalogAPIKey = (provider: CatalogProvider): boolean => - provider.available || - (Boolean(provider.unavailable_reason) && - provider.unavailable_reason !== "missing_api_key"); - -const getProviderModels = ( - provider: CatalogProvider | undefined, -): readonly CatalogProvider["models"][number][] => { - const models = provider?.models; - return Array.isArray(models) ? models : []; -}; - -const getProviderBaseURL = ( - providerConfig: TypesGen.ChatProviderConfig | undefined, -): string => { - return ( - readOptionalString(providerConfig?.base_url) ?? - getDefaultProviderBaseURL(providerConfig?.provider ?? "") - ); -}; - -const providerConfigStateKey = ( - providerConfig: TypesGen.ChatProviderConfig, -): string => { - const providerID = readOptionalString(providerConfig.id); - if (providerID && providerID !== nilUUID) { - return providerID; - } - return normalizeProvider(providerConfig.provider); -}; - -type ProviderEntry = { - key: string; - provider: string; -}; - -const useProviderStates = ( - modelConfigs: readonly TypesGen.ChatModelConfig[], - providerConfigsData: TypesGen.ChatProviderConfig[] | null | undefined, - catalogData: TypesGen.ChatModelsResponse | null | undefined, -): readonly ProviderState[] => { - const orderedEntries: ProviderEntry[] = []; - const seenEntries = new Set(); - const includeEntry = (keyValue: string, providerValue: string) => { - const key = readOptionalString(keyValue); - const provider = normalizeProvider(providerValue); - if (!key || !provider || seenEntries.has(key)) return; - seenEntries.add(key); - orderedEntries.push({ key, provider }); - }; - - const catalogProviders = getCatalogProviders(catalogData); - const catalogProvidersByProvider = new Map(); - for (const cp of catalogProviders) { - const provider = normalizeProvider(cp.provider); - if (!provider) continue; - catalogProvidersByProvider.set(provider, cp); - } - - const providerConfigKeysByProvider = new Map(); - const providerTypesWithConfigs = new Set(); - for (const pc of providerConfigsData ?? []) { - const provider = normalizeProvider(pc.provider); - if (!provider) continue; - const key = providerConfigStateKey(pc); - providerTypesWithConfigs.add(provider); - providerConfigKeysByProvider.set(provider, [ - ...(providerConfigKeysByProvider.get(provider) ?? []), - key, - ]); - includeEntry(key, provider); - } - const modelStateKey = (modelConfig: TypesGen.ChatModelConfig): string => { - const aiProviderID = readOptionalString(modelConfig.ai_provider_id); - if (aiProviderID) { - return aiProviderID; - } - const provider = normalizeProvider(modelConfig.provider); - const providerConfigKeys = providerConfigKeysByProvider.get(provider) ?? []; - if (providerConfigKeys.length === 1) { - return providerConfigKeys[0]; - } - return providerConfigKeys.length === 0 ? provider : ""; - }; - - for (const cp of catalogProviders) { - const provider = normalizeProvider(cp.provider); - if (!provider || providerTypesWithConfigs.has(provider)) continue; - includeEntry(provider, provider); - } - for (const mc of modelConfigs) { - includeEntry(modelStateKey(mc), mc.provider); - } - - const providerConfigsByKey = new Map(); - for (const pc of providerConfigsData ?? []) { - const key = providerConfigStateKey(pc); - if (!key) continue; - providerConfigsByKey.set(key, pc); - } - - const modelConfigsByKey = new Map(); - for (const mc of modelConfigs) { - const key = modelStateKey(mc); - if (!key) continue; - const existing = modelConfigsByKey.get(key); - if (existing) { - existing.push(mc); - } else { - modelConfigsByKey.set(key, [mc]); - } - } - - return orderedEntries.map(({ key, provider }) => { - const providerConfigEntry = providerConfigsByKey.get(key); - const providerConfigSource = getProviderConfigSource(providerConfigEntry); - const providerConfig = isDatabaseProviderConfig( - providerConfigEntry, - providerConfigSource, - ) - ? providerConfigEntry - : undefined; - const catalogProvider = catalogProvidersByProvider.get(provider); - const hasManagedAPIKey = hasProviderAPIKey(providerConfig); - const hasProviderEntryAPIKey = hasProviderAPIKey(providerConfigEntry); - const hasCatalogAPIKey = catalogProvider - ? providerHasCatalogAPIKey(catalogProvider) - : false; - const label = - readOptionalString(providerConfigEntry?.display_name) ?? - formatProviderLabel(provider); - const hasBedrockAmbientCredentials = - provider === "bedrock" && - providerConfig?.central_api_key_enabled === true; - const modelConfigsForProvider = modelConfigsByKey.get(key) ?? []; - const isCatalogEnvPreset = - !providerConfig && envPresetProviders.has(provider) && hasCatalogAPIKey; - const isEnvPreset = - providerConfigSource === "env_preset" || isCatalogEnvPreset; - - return { - key, - provider, - label, - providerConfig, - modelConfigs: modelConfigsForProvider, - catalogModelCount: getProviderModels(catalogProvider).length, - hasManagedAPIKey, - hasCatalogAPIKey, - hasEffectiveAPIKey: providerConfigEntry - ? hasProviderEntryAPIKey || hasBedrockAmbientCredentials - : hasManagedAPIKey || hasCatalogAPIKey, - allowUserAPIKey: providerConfigEntry?.allow_user_api_key ?? true, - isEnvPreset, - baseURL: getProviderBaseURL(providerConfigEntry), - }; - }); -}; - -interface ChatModelAdminPanelProps { - className?: string; - section?: ChatModelAdminSection; - sectionLabel?: string; - sectionDescription?: string; - // Data from queries. - providerConfigsData: TypesGen.ChatProviderConfig[] | undefined; - modelConfigsData: TypesGen.ChatModelConfig[] | undefined; - modelCatalogData: TypesGen.ChatModelsResponse | undefined; - isLoading: boolean; - // Query error states. - providerConfigsError: Error | null; - modelConfigsError: Error | null; - modelCatalogError: Error | null; - // Provider mutation handlers. - onCreateProvider: ( - req: TypesGen.CreateChatProviderConfigRequest, - ) => Promise; - onUpdateProvider: ( - providerConfigId: string, - req: TypesGen.UpdateChatProviderConfigRequest, - ) => Promise; - onDeleteProvider: (providerConfigId: string) => Promise; - isProviderMutationPending: boolean; - providerMutationError: Error | null; - // Model mutation handlers. - onCreateModel: ( - req: TypesGen.CreateChatModelConfigRequest, - ) => Promise; - onUpdateModel: ( - modelConfigId: string, - req: TypesGen.UpdateChatModelConfigRequest, - ) => Promise; - onDeleteModel: (modelConfigId: string) => Promise; - isCreatingModel: boolean; - isUpdatingModel: boolean; - isDeletingModel: boolean; - modelMutationError: Error | null; -} - -export const ChatModelAdminPanel: FC = ({ - className, - section = "providers", - sectionLabel, - sectionDescription, - providerConfigsData, - modelConfigsData, - modelCatalogData, - isLoading, - providerConfigsError, - modelConfigsError, - modelCatalogError, - onCreateProvider, - onUpdateProvider, - onDeleteProvider, - isProviderMutationPending, - providerMutationError, - onCreateModel, - onUpdateModel, - onDeleteModel, - isCreatingModel, - isUpdatingModel, - isDeletingModel, - modelMutationError, -}) => { - const modelConfigs = (modelConfigsData ?? []).slice().sort((a, b) => { - const cmp = a.provider.localeCompare(b.provider); - return cmp !== 0 ? cmp : a.model.localeCompare(b.model); - }); - const providerStates = useProviderStates( - modelConfigs, - providerConfigsData, - modelCatalogData, - ); - - const providerConfigsUnavailable = providerConfigsData === null; - const modelConfigsUnavailable = modelConfigsData === null; - - return ( -

- ); -}; diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx index 5c253f3254..b9ea8337c9 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx @@ -29,17 +29,24 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { normalizeProvider } from "#/modules/aiModels/helpers"; import { cn } from "#/utils/cn"; -import { normalizeProvider } from "./helpers"; -import type { - ModelConfigFormBuildResult, - ModelFormValues, +import { + isFieldConflictDisabled, + isVisibleWhenSatisfied, + type ModelConfigFormBuildResult, + type ModelFormValues, } from "./modelConfigFormLogic"; import { getPricingPlaceholderForField, pricingFieldNames, } from "./pricingFields"; +const booleanFieldOptions = [ + { label: "On", value: "true" }, + { label: "Off", value: "false" }, +] as const; + /** Sentinel value for Select components to represent "no selection". */ const unsetSelectValue = "__unset__"; @@ -143,7 +150,7 @@ const FieldLabel: FC<{ }> = ({ htmlFor, label, description }) => (