diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fdbdb9f46d..91d7a80437 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17532,6 +17532,13 @@ const docTemplate = `{ "items": { "$ref": "#/definitions/codersdk.ChatModelProvider" } + }, + "unsupported_providers": { + "description": "UnsupportedProviders lists configured providers the Agents harness\ncannot use, so the UI can explain the empty state.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatUnsupportedProvider" + } } } }, @@ -17786,6 +17793,18 @@ const docTemplate = `{ } } }, + "codersdk.ChatUnsupportedProvider": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "provider": { + "description": "Provider is the provider type, e.g. \"copilot\".", + "type": "string" + } + } + }, "codersdk.ChatUser": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 93bfcc07bd..8faacf39c1 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15798,6 +15798,13 @@ "items": { "$ref": "#/definitions/codersdk.ChatModelProvider" } + }, + "unsupported_providers": { + "description": "UnsupportedProviders lists configured providers the Agents harness\ncannot use, so the UI can explain the empty state.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatUnsupportedProvider" + } } } }, @@ -16042,6 +16049,18 @@ } } }, + "codersdk.ChatUnsupportedProvider": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "provider": { + "description": "Provider is the provider type, e.g. \"copilot\".", + "type": "string" + } + } + }, "codersdk.ChatUser": { "type": "object", "required": ["id", "username"], diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 2e4698b67c..3072c4b290 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -2,12 +2,12 @@ package db2sdk import ( + "cmp" "database/sql" "encoding/json" "fmt" "net/url" "slices" - "sort" "strconv" "strings" "time" @@ -576,8 +576,8 @@ func WorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordinator, if node != nil { workspaceAgent.DERPLatency = map[string]codersdk.DERPRegion{} for rawRegion, latency := range node.DERPLatency { - regionParts := strings.SplitN(rawRegion, "-", 2) - regionID, err := strconv.Atoi(regionParts[0]) + regionIDStr, _, _ := strings.Cut(rawRegion, "-") + regionID, err := strconv.Atoi(regionIDStr) if err != nil { return codersdk.WorkspaceAgent{}, xerrors.Errorf("convert derp region id %q: %w", rawRegion, err) } @@ -667,14 +667,12 @@ func AppSubdomain(dbApp database.WorkspaceApp, agentName, workspaceName, ownerNa } func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus, agent database.WorkspaceAgent, ownerName string, workspace database.WorkspaceTable) []codersdk.WorkspaceApp { - sort.Slice(dbApps, func(i, j int) bool { - if dbApps[i].DisplayOrder != dbApps[j].DisplayOrder { - return dbApps[i].DisplayOrder < dbApps[j].DisplayOrder - } - if dbApps[i].DisplayName != dbApps[j].DisplayName { - return dbApps[i].DisplayName < dbApps[j].DisplayName - } - return dbApps[i].Slug < dbApps[j].Slug + slices.SortFunc(dbApps, func(a, b database.WorkspaceApp) int { + return cmp.Or( + cmp.Compare(a.DisplayOrder, b.DisplayOrder), + cmp.Compare(a.DisplayName, b.DisplayName), + cmp.Compare(a.Slug, b.Slug), + ) }) statusesByAppID := map[uuid.UUID][]database.WorkspaceAppStatus{} @@ -806,8 +804,8 @@ func RecentProvisionerDaemons(now time.Time, staleInterval time.Duration, daemon } // Ensure stable order for display and for tests - sort.Slice(results, func(i, j int) bool { - return results[i].Name < results[j].Name + slices.SortFunc(results, func(a, b codersdk.ProvisionerDaemon) int { + return cmp.Compare(a.Name, b.Name) }) return results diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 52eda2adc5..735e841082 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -951,7 +951,6 @@ func TestChat_LastErrorFallback(t *testing.T) { } for _, tc := range tests { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index a6f21e881d..9c43eaf5d4 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1351,6 +1351,10 @@ func (api *API) listChatModels(rw http.ResponseWriter, r *http.Request) { ) } + // Both catalog branches drop providers the harness cannot use, so + // attach them here for the empty state. + response.UnsupportedProviders = chatprovider.UnsupportedProviders(availability.configuredProviders) + httpapi.Write(ctx, rw, http.StatusOK, response) } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 8294f76d4b..69fb14d86b 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -1180,7 +1180,7 @@ func TestListChats(t *testing.T) { // shift the cursor position between page requests. const totalChats = 5 createdChatIDs := make([]uuid.UUID, 0, totalChats) - for i := 0; i < totalChats; i++ { + for i := range totalChats { dbChat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, OwnerID: firstUser.UserID, @@ -1718,6 +1718,46 @@ func TestListChatModels(t *testing.T) { requireSDKError(t, err, http.StatusUnauthorized) }) + t.Run("CopilotOnlyUnsupported", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + // Copilot is a valid AI Gateway provider but the Agents harness + // cannot use it. It must surface as an unsupported provider rather + // than vanish, so the empty state can explain why. + _ = createAIProviderForTest(t, client, string(codersdk.AIProviderTypeCopilot), "") + + models, err := client.ListChatModels(ctx) + require.NoError(t, err) + + require.False(t, slices.ContainsFunc(models.Providers, func(p codersdk.ChatModelProvider) bool { + return p.Provider == string(codersdk.AIProviderTypeCopilot) + }), "copilot must not appear in the supported model picker") + + require.Equal(t, []codersdk.ChatUnsupportedProvider{ + { + Provider: "copilot", + DisplayName: "GitHub Copilot", + }, + }, models.UnsupportedProviders) + }) + + t.Run("SupportedProviderHasNoUnsupportedEntry", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + models, err := client.ListChatModels(ctx) + require.NoError(t, err) + require.Empty(t, models.UnsupportedProviders) + }) + t.Run("CentralOnlyProviderAvailable", func(t *testing.T) { t.Parallel() @@ -4951,7 +4991,7 @@ func TestGetChatUserPrompts(t *testing.T) { }) require.NoError(t, err) - for i := 0; i < 5; i++ { + for i := range 5 { insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID, []codersdk.ChatMessagePart{ {Type: codersdk.ChatMessagePartTypeText, Text: fmt.Sprintf("prompt %d", i)}, diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index c23b8d79e0..4f116ef1b7 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -5,7 +5,7 @@ import ( "mime" "net/http" neturl "net/url" - "sort" + "slices" "strings" "charm.land/fantasy" @@ -46,17 +46,62 @@ var providerDisplayNameByName = map[string]string{ fantasyopenaicompat.Name: "OpenAI Compatible", fantasyopenrouter.Name: "OpenRouter", fantasyvercel.Name: "Vercel AI Gateway", + // Copilot is unsupported but still needs a display name for the + // unsupported list and AI Settings. + string(codersdk.AIProviderTypeCopilot): "GitHub Copilot", } // ProviderDisplayName returns a default display name for a provider. func ProviderDisplayName(provider string) string { normalized := NormalizeProvider(provider) + if normalized == "" { + // Fall back for providers the harness cannot normalize, like copilot. + normalized = strings.ToLower(strings.TrimSpace(provider)) + } if displayName, ok := providerDisplayNameByName[normalized]; ok { return displayName } return normalized } +// AgentsSupportsProvider reports whether the Agents harness can use the +// provider type. +func AgentsSupportsProvider(provider string) bool { + providerType := codersdk.AIProviderType(strings.ToLower(strings.TrimSpace(provider))) + if codersdk.IsAgentsUnsupportedProviderType(providerType) { + return false + } + return NormalizeProvider(provider) != "" +} + +// UnsupportedProviders returns the configured providers the Agents harness +// cannot use, deduplicated by provider type. +func UnsupportedProviders(configured []ConfiguredProvider) []codersdk.ChatUnsupportedProvider { + seen := make(map[string]struct{}, len(configured)) + unsupported := make([]codersdk.ChatUnsupportedProvider, 0) + for _, provider := range configured { + if AgentsSupportsProvider(provider.Provider) { + continue + } + key := strings.ToLower(strings.TrimSpace(provider.Provider)) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + unsupported = append(unsupported, codersdk.ChatUnsupportedProvider{ + Provider: key, + DisplayName: ProviderDisplayName(provider.Provider), + }) + } + slices.SortFunc(unsupported, func(a, b codersdk.ChatUnsupportedProvider) int { + return strings.Compare(a.Provider, b.Provider) + }) + return unsupported +} + // ProviderAllowsAmbientCredentials reports whether provider can use // ambient credentials from the Coder server instead of an explicit // API key. @@ -615,8 +660,8 @@ func newChatModel(provider, modelID, displayName string) codersdk.ChatModel { } func sortChatModels(models []codersdk.ChatModel) { - sort.Slice(models, func(i, j int) bool { - return models[i].Model < models[j].Model + slices.SortFunc(models, func(a, b codersdk.ChatModel) int { + return strings.Compare(a.Model, b.Model) }) } @@ -734,13 +779,13 @@ func parseCanonicalModelRef(modelRef string) (provider string, model string, ok } for _, separator := range []string{":", "/"} { - parts := strings.SplitN(modelRef, separator, 2) - if len(parts) != 2 { + before, after, found := strings.Cut(modelRef, separator) + if !found { continue } - provider := NormalizeProvider(parts[0]) - modelID := strings.TrimSpace(parts[1]) + provider := NormalizeProvider(before) + modelID := strings.TrimSpace(after) if provider != "" && modelID != "" { return provider, modelID, true } diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 033e0840d1..8a8904c8ef 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -265,7 +265,6 @@ func TestResolveUserProviderKeys(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -416,7 +415,6 @@ func TestReasoningEffortFromChat(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -509,7 +507,6 @@ func TestResolveUserProviderKeys_UnavailableReason(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -658,7 +655,6 @@ func TestListConfiguredModels_PolicyAwareAvailability(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -742,7 +738,6 @@ func TestListConfiguredProviderAvailability_PolicyAwareFiltering(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -890,7 +885,6 @@ func TestPruneDisabledProviderKeys(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -1743,3 +1737,40 @@ func TestResolveModelWithProviderHint(t *testing.T) { }) } } + +func TestUnsupportedProviders(t *testing.T) { + t.Parallel() + + t.Run("copilot only", func(t *testing.T) { + t.Parallel() + got := chatprovider.UnsupportedProviders([]chatprovider.ConfiguredProvider{ + {Provider: string(codersdk.AIProviderTypeCopilot)}, + }) + require.Equal(t, []codersdk.ChatUnsupportedProvider{ + { + Provider: "copilot", + DisplayName: "GitHub Copilot", + }, + }, got) + }) + + t.Run("supported provider omitted", func(t *testing.T) { + t.Parallel() + got := chatprovider.UnsupportedProviders([]chatprovider.ConfiguredProvider{ + {Provider: fantasyanthropic.Name}, + {Provider: fantasyopenai.Name}, + }) + require.Empty(t, got) + }) + + t.Run("dedup by type and skip supported", func(t *testing.T) { + t.Parallel() + got := chatprovider.UnsupportedProviders([]chatprovider.ConfiguredProvider{ + {Provider: fantasyanthropic.Name}, + {Provider: string(codersdk.AIProviderTypeCopilot)}, + {Provider: "Copilot"}, + }) + require.Len(t, got, 1) + require.Equal(t, "copilot", got[0].Provider) + }) +} diff --git a/codersdk/aiproviders.go b/codersdk/aiproviders.go index 6cf8fb3359..5632ff5684 100644 --- a/codersdk/aiproviders.go +++ b/codersdk/aiproviders.go @@ -48,6 +48,32 @@ const ( AIProviderTypeCopilot AIProviderType = "copilot" ) +// AgentsUnsupportedProviderType is an AIProviderType the Coder Agents harness +// cannot use. Declaring these as an enum exposes the generated +// AgentsUnsupportedProviderTypes list to the frontend, which labels these +// providers without a per-provider field on the AIProvider response. +type AgentsUnsupportedProviderType string + +const ( + // AgentsUnsupportedProviderTypeCopilot is GitHub Copilot: it authenticates + // with a per-request token only an official Copilot client can mint, which + // the server-side harness is not. + AgentsUnsupportedProviderTypeCopilot AgentsUnsupportedProviderType = AgentsUnsupportedProviderType(AIProviderTypeCopilot) +) + +// IsAgentsUnsupportedProviderType reports whether the Coder Agents harness +// cannot use the provider type. It is the single source of truth, shared by +// the chatd catalog predicate and, via the generated +// AgentsUnsupportedProviderTypes list, the frontend. +func IsAgentsUnsupportedProviderType(t AIProviderType) bool { + switch AgentsUnsupportedProviderType(t) { + case AgentsUnsupportedProviderTypeCopilot: + return true + default: + return false + } +} + // AIProviderSettings is the discriminated container for type-specific // provider settings stored in ai_providers.settings. Providers that // need no type-specific configuration (current OpenAI and standard diff --git a/codersdk/chats.go b/codersdk/chats.go index eadeec97ef..ff0165ec59 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -701,6 +701,17 @@ type ChatModelProvider struct { // ChatModelsResponse is the catalog returned from chat model discovery. type ChatModelsResponse struct { Providers []ChatModelProvider `json:"providers"` + // UnsupportedProviders lists configured providers the Agents harness + // cannot use, so the UI can explain the empty state. + UnsupportedProviders []ChatUnsupportedProvider `json:"unsupported_providers"` +} + +// ChatUnsupportedProvider is a configured provider the Agents harness cannot +// use. +type ChatUnsupportedProvider struct { + // Provider is the provider type, e.g. "copilot". + Provider string `json:"provider"` + DisplayName string `json:"display_name"` } // ChatSystemPromptResponse is the response body for the chat system prompt diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index b2fb6c8afc..ccdc6849b3 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -35,6 +35,12 @@ models, internal gateways, or third-party proxies like LiteLLM. Coder Agents route model requests through AI Gateway automatically by using the provider configuration stored in Coder's database. +Some provider types work as AI Gateway proxy targets but cannot back Coder +Agents. GitHub Copilot, for example, authenticates with a per-request token +that only an official Copilot client can mint, so the server-side Agents +harness cannot use it. Configuring such a provider does not unlock Agents; +add one of the supported provider types above instead. + ### Add a provider LLM providers are managed from the deployment AI settings, not from the Agents diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 2f215c50ce..96db32dbeb 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -604,6 +604,12 @@ Experimental: this endpoint is subject to change. "provider": "string", "unavailable_reason": "missing_api_key" } + ], + "unsupported_providers": [ + { + "display_name": "string", + "provider": "string" + } ] } ``` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 4e4a49745c..dd75727a65 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3190,15 +3190,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "provider": "string", "unavailable_reason": "missing_api_key" } + ], + "unsupported_providers": [ + { + "display_name": "string", + "provider": "string" + } ] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------|-------------------------------------------------------------------|----------|--------------|-------------| -| `providers` | array of [codersdk.ChatModelProvider](#codersdkchatmodelprovider) | false | | | +| Name | Type | Required | Restrictions | Description | +|-------------------------|-------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------| +| `providers` | array of [codersdk.ChatModelProvider](#codersdkchatmodelprovider) | false | | | +| `unsupported_providers` | array of [codersdk.ChatUnsupportedProvider](#codersdkchatunsupportedprovider) | false | | Unsupported providers lists configured providers the Agents harness cannot use, so the UI can explain the empty state. | ## codersdk.ChatPlanMode @@ -3824,6 +3831,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `tool_call_id` | string | false | | | | `tool_name` | string | false | | | +## codersdk.ChatUnsupportedProvider + +```json +{ + "display_name": "string", + "provider": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|--------|----------|--------------|------------------------------------------------| +| `display_name` | string | false | | | +| `provider` | string | false | | Provider is the provider type, e.g. "copilot". | + ## codersdk.ChatUser ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index cd7ca43186..6e068a7557 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1150,6 +1150,13 @@ export const AgentSubsystems: AgentSubsystem[] = [ "exectrace", ]; +// From codersdk/aiproviders.go +export type AgentsUnsupportedProviderType = "copilot"; + +export const AgentsUnsupportedProviderTypes: AgentsUnsupportedProviderType[] = [ + "copilot", +]; + // From codersdk/chats.go /** * AnthropicInlineImageCapBytes is Anthropic's documented per-image @@ -2702,6 +2709,11 @@ export interface ChatModelVercelProviderOptions { */ export interface ChatModelsResponse { readonly providers: readonly ChatModelProvider[]; + /** + * UnsupportedProviders lists configured providers the Agents harness + * cannot use, so the UI can explain the empty state. + */ + readonly unsupported_providers: readonly ChatUnsupportedProvider[]; } // From codersdk/chats.go @@ -3127,6 +3139,19 @@ export interface ChatToolResultPart { readonly created_at?: string; } +// From codersdk/chats.go +/** + * ChatUnsupportedProvider is a configured provider the Agents harness cannot + * use. + */ +export interface ChatUnsupportedProvider { + /** + * Provider is the provider type, e.g. "copilot". + */ + readonly provider: string; + readonly display_name: string; +} + // From codersdk/chats.go /** * ChatUsageLimitConfig is the deployment-wide default usage limit config. diff --git a/site/src/modules/aiModels/providerStates.test.ts b/site/src/modules/aiModels/providerStates.test.ts index 9cf9ae4e9d..8b24c5480a 100644 --- a/site/src/modules/aiModels/providerStates.test.ts +++ b/site/src/modules/aiModels/providerStates.test.ts @@ -42,6 +42,7 @@ describe("deriveProviderStates", () => { { ...MockChatModelProvider, provider: "anthropic" }, { ...MockChatModelProvider, provider: "google" }, ], + unsupported_providers: [], }; const modelConfigs = [ { ...MockChatModelConfig, id: "m-vercel", provider: "vercel" }, @@ -118,6 +119,7 @@ describe("deriveProviderStates", () => { providers: [ { ...MockChatModelProvider, provider: "openai", available: true }, ], + unsupported_providers: [], }; const states = deriveProviderStates([], null, catalog); @@ -154,6 +156,7 @@ describe("deriveProviderStates", () => { unavailable_reason: "fetch_failed", }, ], + unsupported_providers: [], }; const states = deriveProviderStates([], null, catalog); @@ -186,6 +189,7 @@ describe("deriveProviderStates", () => { ], }, ], + unsupported_providers: [], }; const states = deriveProviderStates([], providerConfigs, catalog); diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx index 902c22c7c5..82f3607b48 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { fn } from "storybook/test"; +import { expect, fn, within } from "storybook/test"; import { Table, TableBody, @@ -10,6 +10,7 @@ import { import { MockAIProviderAnthropic, MockAIProviderBedrock, + MockAIProviderCopilot, MockAIProviderOpenAI, } from "#/testHelpers/entities"; import { ProviderRow } from "./ProviderRow"; @@ -75,3 +76,29 @@ export const LongText: Story = { }, }, }; + +// Copilot is unsupported by Agents, so the row shows the label. +export const NotSupportedInAgents: Story = { + args: { + provider: MockAIProviderCopilot, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText("Not supported in Agents"), + ).toBeInTheDocument(); + }, +}; + +export const SupportedHasNoAgentsLabel: Story = { + args: { + provider: { ...MockAIProviderOpenAI, enabled: true }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("OpenAI")).toBeInTheDocument(); + await expect( + canvas.queryByText("Not supported in Agents"), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx index 169fa18bec..e574065a68 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx @@ -1,5 +1,8 @@ import { ChevronRightIcon } from "lucide-react"; -import type { AIProvider } from "#/api/typesGenerated"; +import { + AgentsUnsupportedProviderTypes, + type AIProvider, +} from "#/api/typesGenerated"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { Badge } from "#/components/Badge/Badge"; @@ -46,7 +49,17 @@ export const ProviderRow: React.FC = ({ - {provider.enabled && Enabled} +
+ {provider.enabled && Enabled} + {AgentsUnsupportedProviderTypes.some((t) => t === provider.type) && ( + + Not supported in Agents + + )} +
diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index ac6a4c6c07..efb313ff2f 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -112,6 +112,7 @@ const mockModelCatalog: TypesGen.ChatModelsResponse = { ], }, ], + unsupported_providers: [], }; const mockModelConfigs: TypesGen.ChatModelConfig[] = [ diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index e741730d7c..e3ad6a9d5a 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -90,6 +90,7 @@ import { countConfiguredProviderConfigs, getModelOptionsFromConfigs, getModelSelectorPlaceholder, + getUnsupportedProviderNames, hasConfiguredModelsInCatalog, hasUserFixableProviders, resolveModelOptionId, @@ -820,6 +821,9 @@ const AgentChatPage: FC = () => { chatModelConfigsQuery.isSuccess && chatModelsQuery.isSuccess ? modelOptions.length : undefined; + const unsupportedProviderNames = getUnsupportedProviderNames( + chatModelsQuery.data, + ); const modelCatalog = chatModelsQuery.data; const isModelCatalogLoading = chatModelsQuery.isLoading; @@ -1624,6 +1628,7 @@ const AgentChatPage: FC = () => { canConfigureAgentSetup={permissions.editDeploymentConfig} providerCount={providerCount} modelCount={modelCount} + unsupportedProviderNames={unsupportedProviderNames} hasModelOptions={hasModelOptions} isModelCatalogLoading={isModelCatalogLoading} planModeEnabled={planModeEnabled} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index bcfa3d4a7a..460c23d440 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -140,6 +140,7 @@ interface AgentChatPageViewProps { canConfigureAgentSetup: boolean; providerCount?: number; modelCount?: number; + unsupportedProviderNames?: readonly string[]; hasModelOptions: boolean; isModelCatalogLoading?: boolean; planModeEnabled?: boolean; @@ -327,6 +328,7 @@ export const AgentChatPageView: FC = ({ canConfigureAgentSetup, providerCount, modelCount, + unsupportedProviderNames, hasModelOptions, isModelCatalogLoading = false, planModeEnabled, @@ -934,6 +936,7 @@ export const AgentChatPageView: FC = ({ canConfigureAgentSetup={canConfigureAgentSetup} providerCount={providerCount} modelCount={modelCount} + unsupportedProviderNames={unsupportedProviderNames} selectedModel={effectiveSelectedModel} onModelChange={setSelectedModel} modelOptions={modelOptions} diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 5be7071336..b587b14121 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -28,6 +28,7 @@ import { getChimeEnabled, setChimeEnabled } from "./utils/chime"; import { countConfiguredProviderConfigs, getModelOptionsFromConfigs, + getUnsupportedProviderNames, } from "./utils/modelOptions"; import { buildAgentChatPath } from "./utils/navigation"; @@ -72,6 +73,9 @@ const AgentCreatePage: FC = () => { chatModelConfigsQuery.isSuccess && chatModelsQuery.isSuccess ? catalogModelOptions.length : undefined; + const unsupportedProviderNames = getUnsupportedProviderNames( + chatModelsQuery.data, + ); const handleCreateChat = async ({ message, @@ -160,6 +164,7 @@ const AgentCreatePage: FC = () => { canConfigureAgentSetup={permissions.editDeploymentConfig} providerCount={providerCount} modelCount={modelCount} + unsupportedProviderNames={unsupportedProviderNames} modelConfigs={chatModelConfigsQuery.data ?? []} isModelCatalogLoading={chatModelsQuery.isLoading} isModelConfigsLoading={chatModelConfigsQuery.isLoading} diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index a4ffe51671..4b72eedba4 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -405,6 +405,7 @@ const meta: Meta = { ], }, ], + unsupported_providers: [], }); spyOn(API.experimental, "getChatModelConfigs").mockResolvedValue([ { diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 809e829c6f..0ef3c7bb87 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -192,6 +192,7 @@ interface AgentChatInputProps { canConfigureAgentSetup: boolean; providerCount?: number; modelCount?: number; + unsupportedProviderNames?: readonly string[]; } export interface AttachedWorkspaceInfo { @@ -394,6 +395,7 @@ export const AgentChatInput: FC = ({ canConfigureAgentSetup, providerCount, modelCount, + unsupportedProviderNames = [], }) => { const [chatFullWidth] = useChatFullWidth(); const showAgentSetupNotice = canConfigureAgentSetup @@ -1070,12 +1072,14 @@ export const AgentChatInput: FC = ({ isAdmin providerCount={providerCount} modelCount={modelCount} + unsupportedProviderNames={unsupportedProviderNames} /> ) : ( )}
diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 71ce472948..52af7098aa 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -457,7 +457,7 @@ export const LoadingPersonalModelOverrides: Story = { export const NoModelsConfigured: Story = { args: { ...defaultArgs, - modelCatalog: { providers: [] }, + modelCatalog: { providers: [], unsupported_providers: [] }, modelOptions: [], isModelCatalogLoading: false, isModelConfigsLoading: false, @@ -470,7 +470,7 @@ export const MissingProviderAndModelSetup: Story = { canConfigureAgentSetup: true, providerCount: 0, modelCount: 0, - modelCatalog: { providers: [] }, + modelCatalog: { providers: [], unsupported_providers: [] }, modelOptions: [], isModelCatalogLoading: false, isModelConfigsLoading: false, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 8c8c9f291e..37d9565c04 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -128,6 +128,7 @@ interface AgentCreateFormProps { canConfigureAgentSetup: boolean; providerCount?: number; modelCount?: number; + unsupportedProviderNames?: readonly string[]; isModelCatalogLoading: boolean; modelConfigs: readonly TypesGen.ChatModelConfig[]; isModelConfigsLoading: boolean; @@ -152,6 +153,7 @@ export const AgentCreateForm: FC = ({ canConfigureAgentSetup, providerCount, modelCount, + unsupportedProviderNames, modelConfigs, isModelCatalogLoading, isModelConfigsLoading, @@ -548,6 +550,7 @@ export const AgentCreateForm: FC = ({ canConfigureAgentSetup={canConfigureAgentSetup} providerCount={providerCount} modelCount={modelCount} + unsupportedProviderNames={unsupportedProviderNames} /> {modelSelectorHelp ? (
diff --git a/site/src/pages/AgentsPage/components/AgentSetupNotice.stories.tsx b/site/src/pages/AgentsPage/components/AgentSetupNotice.stories.tsx new file mode 100644 index 0000000000..5e8541f116 --- /dev/null +++ b/site/src/pages/AgentsPage/components/AgentSetupNotice.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { AgentSetupNotice } from "./AgentSetupNotice"; + +const meta: Meta = { + title: "pages/AgentsPage/AgentSetupNotice", + component: AgentSetupNotice, +}; + +export default meta; +type Story = StoryObj; + +// Admin with nothing configured: prompt to set up a provider and a model. +export const AdminNoProvider: Story = { + args: { + isAdmin: true, + providerCount: 0, + modelCount: 0, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("link", { name: "provider" }), + ).toHaveAttribute("href", "/ai/settings/providers"); + await expect( + canvas.getByRole("link", { name: "model" }), + ).toBeInTheDocument(); + }, +}; + +// Admin with a provider but no model: prompt to add a model only. +export const AdminNoModel: Story = { + args: { + isAdmin: true, + providerCount: 1, + modelCount: 0, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("link", { name: "model" })).toHaveAttribute( + "href", + "/ai/settings/models", + ); + await expect( + canvas.queryByRole("link", { name: "provider" }), + ).not.toBeInTheDocument(); + }, +}; + +// Only a harness-unsupported provider (Copilot) is configured. The notice +// must explain why instead of implying nothing is configured. +export const AdminOnlyUnsupportedProvider: Story = { + args: { + isAdmin: true, + providerCount: 0, + modelCount: 0, + unsupportedProviderNames: ["GitHub Copilot"], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText(/not supported by Coder Agents/), + ).toBeInTheDocument(); + await expect(canvas.getByText(/GitHub Copilot/)).toBeInTheDocument(); + await expect( + canvas.getByRole("link", { name: "provider" }), + ).toHaveAttribute("href", "/ai/settings/providers"); + }, +}; + +// Non-admin sees an account-agnostic explanation without admin links. +export const MemberOnlyUnsupportedProvider: Story = { + args: { + isAdmin: false, + providerCount: 0, + modelCount: 0, + unsupportedProviderNames: ["GitHub Copilot"], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Members get the "learn more" docs link but no admin settings link. + await expect( + canvas.getByRole("link", { name: /not supported by Coder Agents/ }), + ).toBeInTheDocument(); + await expect( + canvas.queryByRole("link", { name: "provider" }), + ).not.toBeInTheDocument(); + }, +}; + +// Both a provider and a model are configured: the notice renders nothing. +export const Configured: Story = { + args: { + isAdmin: true, + providerCount: 1, + modelCount: 1, + }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toBeEmptyDOMElement(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentSetupNotice.tsx b/site/src/pages/AgentsPage/components/AgentSetupNotice.tsx index dccb235f16..bdcaca150f 100644 --- a/site/src/pages/AgentsPage/components/AgentSetupNotice.tsx +++ b/site/src/pages/AgentsPage/components/AgentSetupNotice.tsx @@ -1,24 +1,78 @@ import type { FC, ReactNode } from "react"; import { Link } from "react-router"; +import { docs } from "#/utils/docs"; interface AgentSetupNoticeProps { isAdmin: boolean; providerCount: number; modelCount: number; + // Names of configured providers the harness cannot use, populated by + // the page only when no supported provider is configured. + unsupportedProviderNames?: readonly string[]; } +const formatProviderList = (names: readonly string[]): string => { + if (names.length === 1) { + return names[0]; + } + if (names.length === 2) { + return `${names[0]} and ${names[1]}`; + } + return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`; +}; + export const AgentSetupNotice: FC = ({ isAdmin, providerCount, modelCount, + unsupportedProviderNames = [], }) => { const hasProvider = providerCount > 0; const hasModel = modelCount > 0; + const hasUnsupportedProviderNames = unsupportedProviderNames.length > 0; if (hasProvider && hasModel) { return null; } + // Configured providers exist but none are supported by Coder Agents + // (e.g. GitHub Copilot). Say so rather than asking to set up a provider. + if (hasUnsupportedProviderNames) { + const providerList = formatProviderList(unsupportedProviderNames); + const unsupportedLink = ( + + not supported by Coder Agents + + ); + if (!isAdmin) { + return ( + + {providerList} {unsupportedProviderNames.length === 1 ? "is" : "are"}{" "} + configured but {unsupportedLink}. Ask your admin to add a supported + provider. + + ); + } + return ( + + {providerList} {unsupportedProviderNames.length === 1 ? "is" : "are"}{" "} + configured but {unsupportedLink}. Add a supported{" "} + + provider + {" "} + to chat with Coder Agents. + + ); + } + // Non-admin member: show a generic message if (!isAdmin) { return ( diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 1c13aa1644..eb437a4620 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -176,6 +176,7 @@ interface ChatPageInputProps { canConfigureAgentSetup: boolean; providerCount?: number; modelCount?: number; + unsupportedProviderNames?: readonly string[]; planModeEnabled?: boolean; onPlanModeToggle?: (enabled: boolean) => void; isModelCatalogLoading?: boolean; @@ -245,6 +246,7 @@ export const ChatPageInput: FC = ({ canConfigureAgentSetup, providerCount, modelCount, + unsupportedProviderNames, planModeEnabled, onPlanModeToggle, isModelCatalogLoading = false, @@ -521,6 +523,7 @@ export const ChatPageInput: FC = ({ canConfigureAgentSetup={canConfigureAgentSetup} providerCount={providerCount} modelCount={modelCount} + unsupportedProviderNames={unsupportedProviderNames} /> ); diff --git a/site/src/pages/AgentsPage/utils/modelOptions.test.ts b/site/src/pages/AgentsPage/utils/modelOptions.test.ts index f5edd0da55..e8245f55bb 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.test.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.test.ts @@ -14,6 +14,7 @@ import { getModelOptionsFromConfigs, getModelSelectorPlaceholder, getNormalizedModelRef, + getUnsupportedProviderNames, hasConfiguredProviderConfigs, hasUserFixableProviders, resolveModelOptionId, @@ -33,8 +34,10 @@ const createConfig = ( const createCatalog = ( providers: ChatModelsResponse["providers"], + unsupportedProviders: ChatModelsResponse["unsupported_providers"] = [], ): ChatModelsResponse => ({ providers, + unsupported_providers: unsupportedProviders, }); const createProviderConfig = ( @@ -516,3 +519,47 @@ describe("getModelOptionsFromConfigs", () => { ]); }); }); + +describe("getUnsupportedProviderNames", () => { + const unsupportedCopilot: ChatModelsResponse["unsupported_providers"] = [ + { + provider: "copilot", + display_name: "GitHub Copilot", + }, + ]; + + it("returns names when no supported provider is configured", () => { + const catalog = createCatalog([], unsupportedCopilot); + expect(getUnsupportedProviderNames(catalog)).toEqual(["GitHub Copilot"]); + }); + + it("returns empty when a supported provider is also configured", () => { + const catalog = createCatalog( + [{ provider: "anthropic", available: false, models: [] }], + unsupportedCopilot, + ); + expect(getUnsupportedProviderNames(catalog)).toEqual([]); + }); + + it("returns empty when there are no unsupported providers", () => { + expect(getUnsupportedProviderNames(createCatalog([]))).toEqual([]); + }); + + it("falls back to the provider type when display_name is empty", () => { + const catalog = createCatalog( + [], + [ + { + provider: "copilot", + display_name: "", + }, + ], + ); + expect(getUnsupportedProviderNames(catalog)).toEqual(["copilot"]); + }); + + it("tolerates a missing catalog", () => { + expect(getUnsupportedProviderNames(undefined)).toEqual([]); + expect(getUnsupportedProviderNames(null)).toEqual([]); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/modelOptions.ts b/site/src/pages/AgentsPage/utils/modelOptions.ts index e8ec59f031..c33fc0abef 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.ts @@ -119,6 +119,35 @@ export const hasUserFixableProviders = ( ); }; +const getCatalogUnsupportedProviders = ( + catalog: TypesGen.ChatModelsResponse | null | undefined, +): readonly TypesGen.ChatUnsupportedProvider[] => { + const unsupported = catalog?.unsupported_providers; + return Array.isArray(unsupported) ? unsupported : []; +}; + +/** + * Display names of configured providers the Agents harness cannot serve, + * but only when no supported provider is configured. A supported provider + * missing its API key returns an empty list, keeping normal setup guidance. + */ +export const getUnsupportedProviderNames = ( + catalog: TypesGen.ChatModelsResponse | null | undefined, +): readonly string[] => { + const unsupported = getCatalogUnsupportedProviders(catalog); + if (unsupported.length === 0) { + return []; + } + if (getCatalogProviders(catalog).length > 0) { + return []; + } + return unsupported.map( + (provider) => + asString(provider.display_name).trim() || + asString(provider.provider).trim(), + ); +}; + const getAvailableProviders = ( catalog: TypesGen.ChatModelsResponse | null | undefined, ): ReadonlySet => {