mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+6
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Generated
+26
-3
@@ -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
|
||||
|
||||
Generated
+25
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<ProviderRowProps> = ({
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{provider.enabled && <Badge variant="default">Enabled</Badge>}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{provider.enabled && <Badge variant="default">Enabled</Badge>}
|
||||
{AgentsUnsupportedProviderTypes.some((t) => t === provider.type) && (
|
||||
<Badge
|
||||
variant="info"
|
||||
title="This provider works with the AI Gateway proxy but Coder Agents can't use it."
|
||||
>
|
||||
Not supported in Agents
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-10 text-center">
|
||||
<div className="flex justify-end items-center gap-8 pr-4">
|
||||
|
||||
@@ -112,6 +112,7 @@ const mockModelCatalog: TypesGen.ChatModelsResponse = {
|
||||
],
|
||||
},
|
||||
],
|
||||
unsupported_providers: [],
|
||||
};
|
||||
|
||||
const mockModelConfigs: TypesGen.ChatModelConfig[] = [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<AgentChatPageViewProps> = ({
|
||||
canConfigureAgentSetup,
|
||||
providerCount,
|
||||
modelCount,
|
||||
unsupportedProviderNames,
|
||||
hasModelOptions,
|
||||
isModelCatalogLoading = false,
|
||||
planModeEnabled,
|
||||
@@ -934,6 +936,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
canConfigureAgentSetup={canConfigureAgentSetup}
|
||||
providerCount={providerCount}
|
||||
modelCount={modelCount}
|
||||
unsupportedProviderNames={unsupportedProviderNames}
|
||||
selectedModel={effectiveSelectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
modelOptions={modelOptions}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -405,6 +405,7 @@ const meta: Meta<typeof AgentsPageView> = {
|
||||
],
|
||||
},
|
||||
],
|
||||
unsupported_providers: [],
|
||||
});
|
||||
spyOn(API.experimental, "getChatModelConfigs").mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -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<AgentChatInputProps> = ({
|
||||
canConfigureAgentSetup,
|
||||
providerCount,
|
||||
modelCount,
|
||||
unsupportedProviderNames = [],
|
||||
}) => {
|
||||
const [chatFullWidth] = useChatFullWidth();
|
||||
const showAgentSetupNotice = canConfigureAgentSetup
|
||||
@@ -1070,12 +1072,14 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
isAdmin
|
||||
providerCount={providerCount}
|
||||
modelCount={modelCount}
|
||||
unsupportedProviderNames={unsupportedProviderNames}
|
||||
/>
|
||||
) : (
|
||||
<AgentSetupNotice
|
||||
isAdmin={false}
|
||||
providerCount={0}
|
||||
modelCount={0}
|
||||
unsupportedProviderNames={unsupportedProviderNames}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AgentCreateFormProps> = ({
|
||||
canConfigureAgentSetup,
|
||||
providerCount,
|
||||
modelCount,
|
||||
unsupportedProviderNames,
|
||||
modelConfigs,
|
||||
isModelCatalogLoading,
|
||||
isModelConfigsLoading,
|
||||
@@ -548,6 +550,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
canConfigureAgentSetup={canConfigureAgentSetup}
|
||||
providerCount={providerCount}
|
||||
modelCount={modelCount}
|
||||
unsupportedProviderNames={unsupportedProviderNames}
|
||||
/>
|
||||
{modelSelectorHelp ? (
|
||||
<div className="px-3 pt-1 text-2xs text-content-secondary">
|
||||
|
||||
@@ -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<typeof AgentSetupNotice> = {
|
||||
title: "pages/AgentsPage/AgentSetupNotice",
|
||||
component: AgentSetupNotice,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentSetupNotice>;
|
||||
|
||||
// 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();
|
||||
},
|
||||
};
|
||||
@@ -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<AgentSetupNoticeProps> = ({
|
||||
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 = (
|
||||
<a
|
||||
href={docs("/ai-coder/agents/models#providers")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-content-link transition-colors hover:text-content-link/80"
|
||||
>
|
||||
not supported by Coder Agents
|
||||
</a>
|
||||
);
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<NoticeContainer>
|
||||
{providerList} {unsupportedProviderNames.length === 1 ? "is" : "are"}{" "}
|
||||
configured but {unsupportedLink}. Ask your admin to add a supported
|
||||
provider.
|
||||
</NoticeContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NoticeContainer>
|
||||
{providerList} {unsupportedProviderNames.length === 1 ? "is" : "are"}{" "}
|
||||
configured but {unsupportedLink}. Add a supported{" "}
|
||||
<Link
|
||||
to="/ai/settings/providers"
|
||||
className="text-content-link transition-colors hover:text-content-link/80"
|
||||
>
|
||||
provider
|
||||
</Link>{" "}
|
||||
to chat with Coder Agents.
|
||||
</NoticeContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Non-admin member: show a generic message
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
|
||||
@@ -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<ChatPageInputProps> = ({
|
||||
canConfigureAgentSetup,
|
||||
providerCount,
|
||||
modelCount,
|
||||
unsupportedProviderNames,
|
||||
planModeEnabled,
|
||||
onPlanModeToggle,
|
||||
isModelCatalogLoading = false,
|
||||
@@ -521,6 +523,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
canConfigureAgentSetup={canConfigureAgentSetup}
|
||||
providerCount={providerCount}
|
||||
modelCount={modelCount}
|
||||
unsupportedProviderNames={unsupportedProviderNames}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> => {
|
||||
|
||||
Reference in New Issue
Block a user