From 2fd5ae432321d64dc3f50fe581ac9bf1be081754 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 30 Jun 2026 18:49:50 +0300 Subject: [PATCH] fix: stop Agents dead-ending on unsupported providers (#26841) Configuring only a GitHub Copilot provider left the Agents page stuck on "set up a provider then add a model", even with a provider and models configured. The catalog dropped any provider type that NormalizeProvider did not recognize, so a Copilot-only deployment looked identical to an empty one and never unlocked the page. The Agents harness cannot use Copilot: it needs a per-request token only an official Copilot client can mint, and the harness is not one. Instead of dropping such providers, the catalog now reports them as unsupported so the UI can explain the dead end and point elsewhere, rather than ask for setup that already happened. The providers stay usable through the AI Gateway proxy. Support is derived from the provider type, not stored, so there is no migration. codersdk.IsAgentsUnsupportedProviderType is the single source of truth, consulted by the chatd catalog and, through the generated AgentsUnsupportedProviderTypes list, the frontend. The diff also carries unrelated modernization of nearby db2sdk and chatprovider helpers (slices.SortFunc, strings.Cut, range-over-int). Closes CODAGT-627 Refs CODAGT-256 Refs CODAGT-682 --- coderd/apidoc/docs.go | 19 ++++ coderd/apidoc/swagger.json | 19 ++++ coderd/database/db2sdk/db2sdk.go | 24 ++--- coderd/database/db2sdk/db2sdk_test.go | 1 - coderd/exp_chats.go | 4 + coderd/exp_chats_test.go | 44 +++++++- coderd/x/chatd/chatprovider/chatprovider.go | 59 ++++++++-- .../x/chatd/chatprovider/chatprovider_test.go | 43 ++++++-- codersdk/aiproviders.go | 26 +++++ codersdk/chats.go | 11 ++ docs/ai-coder/agents/models.md | 6 ++ docs/reference/api/chats.md | 6 ++ docs/reference/api/schemas.md | 29 ++++- site/src/api/typesGenerated.ts | 25 +++++ .../modules/aiModels/providerStates.test.ts | 4 + .../components/ProviderRow.stories.tsx | 29 ++++- .../ProvidersPage/components/ProviderRow.tsx | 17 ++- .../AgentsPage/AgentChatPage.stories.tsx | 1 + site/src/pages/AgentsPage/AgentChatPage.tsx | 5 + .../pages/AgentsPage/AgentChatPageView.tsx | 3 + site/src/pages/AgentsPage/AgentCreatePage.tsx | 5 + .../AgentsPage/AgentsPageView.stories.tsx | 1 + .../AgentsPage/components/AgentChatInput.tsx | 4 + .../components/AgentCreateForm.stories.tsx | 4 +- .../AgentsPage/components/AgentCreateForm.tsx | 3 + .../components/AgentSetupNotice.stories.tsx | 101 ++++++++++++++++++ .../components/AgentSetupNotice.tsx | 54 ++++++++++ .../AgentsPage/components/ChatPageContent.tsx | 3 + .../AgentsPage/utils/modelOptions.test.ts | 47 ++++++++ .../pages/AgentsPage/utils/modelOptions.ts | 29 +++++ 30 files changed, 589 insertions(+), 37 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/AgentSetupNotice.stories.tsx 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 => {