mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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
This commit is contained in:
Generated
+19
@@ -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": [
|
||||
|
||||
Generated
+19
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user