Files
coder/coderd/exp_chats_model_config_list_test.go
T
Ethan 3809314b31 refactor!: consolidate chat model availability (#28440)
## Summary

Consolidate chat model management and runtime availability into a single
organization model collection contract. The organization model response
now includes provider availability, unavailable reasons, and
unsupported-provider details, so callers no longer need a separate
availability request.

Depends on #28439.

## Problem

The organization-scoped API exposes two overlapping representations of
the same chat models:

```text
GET /api/experimental/organizations/{organization}/chats/models
GET /api/experimental/organizations/{organization}/chats/models/available
```

The collection route returns organization-owned model configurations and
redacted provider descriptors. The availability route returns a second
catalog-oriented response with provider availability and synthetic
catalog entries. Callers must fetch, reconcile, and cache both responses
even though they describe the same effective set of models.

This creates duplicated server logic and separate SDK, OpenAPI, and
frontend types. It can also produce inconsistent client state when one
request succeeds while the other fails or when the two responses are
refreshed at different times.

## Fix

Return all model-management and runtime-availability information from
the organization model collection route:

```text
GET /api/experimental/organizations/{organization}/chats/models
```

`OrganizationChatModelsResponse` continues to return the caller-readable
organization model configurations and redacted provider descriptors.
Provider descriptors now also include:

- `available`
- `unavailable_reason`

The response additionally includes `unsupported_providers`, allowing
clients to explain configurations that the Agents harness cannot use.

Frontend model settings, agent creation, existing chat recovery, and
model override surfaces now consume this single collection query. This
keeps model ownership, caller visibility, provider availability, and
unsupported-provider guidance in one cache entry and one authorization
path.

The default-organization compatibility route remains available:

```text
GET /api/experimental/chats/models
```

It now returns the same consolidated `OrganizationChatModelsResponse`
contract instead of the former availability response.

## Breaking change

This removes the separate organization model availability endpoint:

```text
GET /api/experimental/organizations/{organization}/chats/models/available
```

Clients using that endpoint must migrate to:

```text
GET /api/experimental/organizations/{organization}/chats/models
```

This PR also removes the corresponding public experimental client and
schema surface:

- `ExperimentalClient.ChatModelAvailability`
- `ChatModelAvailabilityResponse`
- `ChatModelProvider`
- `ChatModelCatalogEntry`
- The generated OpenAPI schema and frontend API/query types for the
availability endpoint

Callers must use `ExperimentalClient.ChatModels` and
`OrganizationChatModelsResponse` instead. Availability is now reported
on each `ChatModelProviderDescriptor`, and unsupported providers are
returned in `OrganizationChatModelsResponse.UnsupportedProviders`.

The default-organization compatibility route retains its URL, but its
response schema changes from `ChatModelAvailabilityResponse` to
`OrganizationChatModelsResponse`. Direct HTTP clients and generated
clients that decode the previous response type must be updated even if
they do not use the removed organization-scoped `/available` route.
2026-08-24 11:20:26 +01:00

311 lines
10 KiB
Go

package coderd_test
import (
"context"
"net/http"
"slices"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
// TestChatModelConfigListReadContracts pins the visible config set for each
// role after chat model config reads become organization-scoped.
func TestChatModelConfigListReadContracts(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.Database = rawDB
opts.Pubsub = pubsub
})
_ = coderdtest.CreateFirstUser(t, client.Client)
defaultOrg, err := rawDB.GetDefaultOrganization(ctx)
require.NoError(t, err)
otherOrg := dbgen.Organization(t, rawDB, database.Organization{IsDefault: false})
seedEveryoneGroup(t, rawDB, otherOrg.ID)
ownEnabled := dbgen.ChatModelConfig(t, rawDB, database.ChatModelConfig{
OrganizationID: defaultOrg.ID,
GroupACL: database.ChatACL{
defaultOrg.ID.String(): {Permissions: []policy.Action{policy.ActionRead}},
},
})
ownDisabled := dbgen.ChatModelConfig(t, rawDB, database.ChatModelConfig{
OrganizationID: defaultOrg.ID,
GroupACL: database.ChatACL{
defaultOrg.ID.String(): {Permissions: []policy.Action{policy.ActionRead}},
},
}, func(params *database.InsertChatModelConfigParams) {
params.Enabled = false
})
disabledProvider := dbgen.AIProvider(t, rawDB, database.AIProvider{
Type: database.AIProviderTypeAnthropic,
}, func(params *database.InsertAIProviderParams) {
params.Enabled = false
})
providerDisabled := dbgen.ChatModelConfig(t, rawDB, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: disabledProvider.ID, Valid: true},
OrganizationID: defaultOrg.ID,
GroupACL: database.ChatACL{
defaultOrg.ID.String(): {Permissions: []policy.Action{policy.ActionRead}},
},
})
denied := dbgen.ChatModelConfig(t, rawDB, database.ChatModelConfig{
OrganizationID: defaultOrg.ID,
GroupACL: database.ChatACL{},
UserACL: database.ChatACL{},
})
otherEnabled := dbgen.ChatModelConfig(t, rawDB, database.ChatModelConfig{
OrganizationID: otherOrg.ID,
GroupACL: database.ChatACL{
otherOrg.ID.String(): {Permissions: []policy.Action{policy.ActionRead}},
},
})
require.True(t, ownEnabled.Enabled)
require.False(t, ownDisabled.Enabled)
require.False(t, disabledProvider.Enabled)
require.True(t, providerDisabled.Enabled)
require.True(t, denied.Enabled)
require.True(t, otherEnabled.Enabled)
for _, testCase := range []struct {
name string
scopes []codersdk.APIKeyScope
wantCollectionStatus int
wantItemStatus int
wantACLStatus int
}{
{
name: "ModelReadWithOrganizationRead",
scopes: []codersdk.APIKeyScope{
codersdk.APIKeyScopeOrganizationRead,
codersdk.APIKeyScopeChatModelConfigRead,
},
wantACLStatus: http.StatusNotFound,
},
{
name: "WorkspaceRead",
scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeOrganizationRead, codersdk.APIKeyScopeWorkspaceRead},
wantCollectionStatus: http.StatusForbidden,
wantItemStatus: http.StatusNotFound,
wantACLStatus: http.StatusNotFound,
},
} {
t.Run("TokenScope/"+testCase.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Scopes: testCase.scopes,
})
require.NoError(t, err)
scopedClient := codersdk.New(
client.URL,
codersdk.WithSessionToken(token.Key),
codersdk.WithHTTPClient(coderdtest.NewIsolatedHTTPClient(client.URL)),
)
t.Cleanup(scopedClient.HTTPClient.CloseIdleConnections)
experimentalClient := codersdk.NewExperimentalClient(scopedClient)
response, listErr := experimentalClient.ChatModels(ctx, defaultOrg.ID)
model, itemErr := experimentalClient.ChatModel(ctx, defaultOrg.ID, ownEnabled.ID)
_, aclErr := experimentalClient.ChatModelACL(ctx, defaultOrg.ID, ownEnabled.ID)
if testCase.wantCollectionStatus != 0 {
requireSDKError(t, listErr, testCase.wantCollectionStatus)
} else {
require.NoError(t, listErr)
require.NotEmpty(t, response.Models)
}
if testCase.wantItemStatus != 0 {
requireSDKError(t, itemErr, testCase.wantItemStatus)
} else {
require.NoError(t, itemErr)
require.Equal(t, ownEnabled.ID, model.ID)
}
requireSDKError(t, aclErr, testCase.wantACLStatus)
})
}
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
for _, endpoint := range []struct {
name string
call func(context.Context, uuid.UUID) error
}{
{
name: "Collection",
call: func(ctx context.Context, organizationID uuid.UUID) error {
_, err := memberClient.ChatModels(ctx, organizationID)
return err
},
},
{
name: "Item",
call: func(ctx context.Context, organizationID uuid.UUID) error {
_, err := memberClient.ChatModel(ctx, organizationID, otherEnabled.ID)
return err
},
},
{
name: "ACL",
call: func(ctx context.Context, organizationID uuid.UUID) error {
_, err := memberClient.ChatModelACL(ctx, organizationID, otherEnabled.ID)
return err
},
},
} {
t.Run("ConcealsHiddenOrganization/"+endpoint.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
var concealedMessage string
for _, organizationID := range []uuid.UUID{otherOrg.ID, uuid.New()} {
err := endpoint.call(ctx, organizationID)
sdkErr := requireSDKError(t, err, http.StatusNotFound)
if concealedMessage == "" {
concealedMessage = sdkErr.Message
}
require.Equal(t, concealedMessage, sdkErr.Message)
}
})
}
testCases := []struct {
name string
client func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient
seesDenied bool
}{
{
name: "AgentsAccess",
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(
t,
client.Client,
defaultOrg.ID,
rbac.ScopedRoleAgentsAccess(defaultOrg.ID),
)
return codersdk.NewExperimentalClient(rawClient)
},
},
{
name: "Owner",
seesDenied: true,
client: func(*testing.T, context.Context) *codersdk.ExperimentalClient {
return client
},
},
{
name: "SiteAuditor",
seesDenied: true,
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.RoleAuditor())
return codersdk.NewExperimentalClient(rawClient)
},
},
{
name: "CustomSiteReadRole",
seesDenied: true,
client: func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient {
return newSiteCustomRoleClient(ctx, t, client, rawDB, defaultOrg.ID, database.CustomRolePermission{
ResourceType: rbac.ResourceChatModelConfig.Type,
Action: policy.ActionRead,
})
},
},
{
name: "OrgAdmin",
seesDenied: true,
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAdmin(defaultOrg.ID))
return codersdk.NewExperimentalClient(rawClient)
},
},
{
name: "OrgAuditor",
seesDenied: true,
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAuditor(defaultOrg.ID))
return codersdk.NewExperimentalClient(rawClient)
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
models, err := testCase.client(t, ctx).ChatModels(ctx, defaultOrg.ID)
require.NoError(t, err)
require.True(t, containsChatModel(models.Models, ownEnabled.ID))
require.True(t, containsChatModel(models.Models, ownDisabled.ID))
require.True(t, containsChatModel(models.Models, providerDisabled.ID))
require.Equal(t, testCase.seesDenied, containsChatModel(models.Models, denied.ID))
require.False(t, containsChatModel(models.Models, otherEnabled.ID))
disabledProviderIndex := slices.IndexFunc(models.Providers, func(provider codersdk.ChatModelProviderDescriptor) bool {
return provider.ID == disabledProvider.ID
})
require.NotEqual(t, -1, disabledProviderIndex)
require.False(t, models.Providers[disabledProviderIndex].Enabled)
require.False(t, models.Providers[disabledProviderIndex].Available)
})
}
}
func containsChatModel(models []codersdk.ChatModel, id uuid.UUID) bool {
return slices.ContainsFunc(models, func(model codersdk.ChatModel) bool {
return model.ID == id
})
}
func seedEveryoneGroup(t testing.TB, db database.Store, organizationID uuid.UUID) {
t.Helper()
dbgen.Group(t, db, database.Group{
ID: organizationID,
Name: database.EveryoneGroup,
OrganizationID: organizationID,
})
}
// newSiteCustomRoleClient seeds a null-org role through the raw store because
// public custom-role APIs create organization roles and reject site permissions.
func newSiteCustomRoleClient(
ctx context.Context,
t testing.TB,
ownerClient *codersdk.ExperimentalClient,
db database.Store,
organizationID uuid.UUID,
permissions ...database.CustomRolePermission,
) *codersdk.ExperimentalClient {
t.Helper()
role, err := db.InsertCustomRole(ctx, database.InsertCustomRoleParams{
Name: testutil.GetRandomName(t),
DisplayName: "Site Custom Test Role",
OrganizationID: uuid.NullUUID{},
SitePermissions: permissions,
})
require.NoError(t, err)
rawClient, user := coderdtest.CreateAnotherUser(t, ownerClient.Client, organizationID)
_, err = ownerClient.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{
Roles: []string{role.Name},
})
require.NoError(t, err)
return codersdk.NewExperimentalClient(rawClient)
}