From f7d93e4e698bf7f725ee2fd8c16c709b05f4936d Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 21 Aug 2026 18:02:02 +0000 Subject: [PATCH 1/5] feat(coderd): add organization chat model APIs --- cli/exp_scaletest_chat.go | 7 +- cli/exp_scaletest_chat_test.go | 8 +- coderd/apidoc/docs.go | 1735 ++++++++++--- coderd/apidoc/swagger.json | 1711 +++++++++--- coderd/audit/diff.go | 1 + coderd/audit/request.go | 8 + coderd/coderd.go | 57 +- coderd/coderdtest/chat.go | 4 +- coderd/database/chatmodelconfigs.go | 21 + coderd/database/dbauthz/dbauthz.go | 166 +- coderd/database/dbauthz/dbauthz_test.go | 109 +- coderd/database/dbmetrics/querymetrics.go | 32 +- coderd/database/dbmock/dbmock.go | 61 +- coderd/database/dbpurge/dbpurge_test.go | 15 +- coderd/database/modelqueries.go | 14 +- coderd/database/querier.go | 8 +- coderd/database/querier_test.go | 8 +- coderd/database/queries.sql.go | 133 +- coderd/database/queries/chatmodelconfigs.sql | 27 +- coderd/exp_chats.go | 791 +++--- coderd/exp_chats_model_acl.go | 299 +++ coderd/exp_chats_model_acl_test.go | 436 ++++ coderd/exp_chats_model_config_list_test.go | 181 +- coderd/exp_chats_test.go | 2304 +++++++++++------ coderd/httpmw/chatmodelconfigparam.go | 60 + coderd/httpmw/organizationparam.go | 116 +- coderd/x/chatd/advisor_internal_test.go | 2 +- coderd/x/chatd/chatd.go | 199 +- coderd/x/chatd/chatd_internal_test.go | 30 +- coderd/x/chatd/chatprovider/chatprovider.go | 3 + .../x/chatd/chatprovider/chatprovider_test.go | 69 + coderd/x/chatd/compaction_override.go | 2 +- .../compaction_override_internal_test.go | 8 +- coderd/x/chatd/configcache.go | 206 +- coderd/x/chatd/configcache_internal_test.go | 448 +--- coderd/x/chatd/generation_preparer.go | 2 +- coderd/x/chatd/integration_test.go | 6 +- coderd/x/chatd/provider_switch_sanitize.go | 5 +- coderd/x/chatd/subagent.go | 32 +- coderd/x/chatd/title_override.go | 11 +- .../x/chatd/title_override_internal_test.go | 34 +- codersdk/chats.go | 142 +- codersdk/chats_model_acl_test.go | 88 + codersdk/toolsdk/chats.go | 82 +- codersdk/toolsdk/chats_test.go | 73 +- docs/admin/security/audit-logs.md | 1 + docs/reference/api/chats.md | 283 +- docs/reference/api/schemas.md | 1803 ++++++++++++- enterprise/audit/table.go | 21 + enterprise/coderd/exp_chats_test.go | 151 +- scaletest/chat/client.go | 4 +- scaletest/chat/provider.go | 44 +- site/src/api/typesGenerated.ts | 70 +- .../AISettingsPage/ModelsPage/testFixtures.ts | 1 + .../AgentsPage/AgentsPageLayout.stories.tsx | 2 + .../ChatsSidebar/ChatsSidebar.stories.tsx | 1 + .../ChatsSidebar/ChatsSidebar.test.tsx | 2 + site/src/testHelpers/chatModels.ts | 1 + 58 files changed, 8796 insertions(+), 3342 deletions(-) create mode 100644 coderd/database/chatmodelconfigs.go create mode 100644 coderd/exp_chats_model_acl.go create mode 100644 coderd/exp_chats_model_acl_test.go create mode 100644 coderd/httpmw/chatmodelconfigparam.go create mode 100644 codersdk/chats_model_acl_test.go diff --git a/cli/exp_scaletest_chat.go b/cli/exp_scaletest_chat.go index 8de6809e72204..1a671c4636f41 100644 --- a/cli/exp_scaletest_chat.go +++ b/cli/exp_scaletest_chat.go @@ -77,7 +77,7 @@ func (r *RootCmd) scaletestChat() *serpent.Command { } logger := inv.Logger - modelID, err := chat.EnsureScaletestChatModel(ctx, client, logger, llmMockURL, providerPropagationWait) + modelForOrg, err := chat.EnsureScaletestChatModel(ctx, client, logger, llmMockURL, providerPropagationWait) if err != nil { return err } @@ -124,6 +124,11 @@ func (r *RootCmd) scaletestChat() *serpent.Command { turnStartReadyWaitGroup.Add(1) } + modelID, err := modelForOrg(targetWorkspace.OrganizationID) + if err != nil { + return xerrors.Errorf("ensure scaletest model config for organization %s: %w", targetWorkspace.OrganizationID, err) + } + cfg := chat.Config{ OrganizationID: targetWorkspace.OrganizationID, WorkspaceID: targetWorkspace.ID, diff --git a/cli/exp_scaletest_chat_test.go b/cli/exp_scaletest_chat_test.go index b83c7bfdbacba..3294af4e584fa 100644 --- a/cli/exp_scaletest_chat_test.go +++ b/cli/exp_scaletest_chat_test.go @@ -77,9 +77,11 @@ func TestScaleTestChat(t *testing.T) { require.Equal(t, mockURL, provider.BaseURL) expClient := codersdk.NewExperimentalClient(client) - configs, err := expClient.ChatModels(ctx) + defaultOrg, err := client.OrganizationByName(ctx, codersdk.DefaultOrganization) require.NoError(t, err) - matchingConfigs := scaletestModelsForProvider(configs, provider.ID) + configs, err := expClient.ChatModels(ctx, defaultOrg.ID) + require.NoError(t, err) + matchingConfigs := scaletestModelConfigsForProvider(configs.Models, provider.ID) require.Len(t, matchingConfigs, 1) require.True(t, matchingConfigs[0].Enabled) @@ -125,7 +127,7 @@ func chatMessageText(messages []codersdk.ChatMessage, role codersdk.ChatMessageR return b.String(), found } -func scaletestModelsForProvider(configs []codersdk.ChatModel, providerID uuid.UUID) []codersdk.ChatModel { +func scaletestModelConfigsForProvider(configs []codersdk.ChatModel, providerID uuid.UUID) []codersdk.ChatModel { matches := make([]codersdk.ChatModel, 0, 1) for _, config := range configs { if config.AIProviderID != providerID { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 98905869138cc..8e63c7ee70c5b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -482,32 +482,6 @@ const docTemplate = `{ } } }, - "/api/experimental/chats/models": { - "get": { - "description": "Experimental: this endpoint is subject to change.", - "produces": [ - "application/json" - ], - "tags": [ - "Chats" - ], - "summary": "List chat models", - "operationId": "list-chat-models", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.ChatModelAvailabilityResponse" - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, "/api/experimental/chats/watch": { "get": { "description": "Experimental: this endpoint is subject to change.", @@ -1385,21 +1359,20 @@ const docTemplate = `{ } } }, - "/api/experimental/organizations/{organization}/mcp-servers": { + "/api/experimental/organizations/{organization}/chats/models": { "get": { "produces": [ "application/json" ], "tags": [ - "MCP" + "Chats" ], - "summary": "List MCP server configs", - "operationId": "list-mcp-server-configs", + "summary": "List AI models and provider descriptors in an organization", + "operationId": "list-ai-models-by-organization", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true @@ -1409,10 +1382,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.MCPServerConfig" - } + "$ref": "#/definitions/codersdk.OrganizationChatModelsResponse" } } }, @@ -1433,26 +1403,25 @@ const docTemplate = `{ "application/json" ], "tags": [ - "MCP" + "Chats" ], - "summary": "Create MCP server config", - "operationId": "create-mcp-server-config", + "summary": "Create an AI model in an organization", + "operationId": "create-ai-model", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true }, { - "description": "Create MCP server config request", + "description": "Model", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateMCPServerConfigRequest" + "$ref": "#/definitions/codersdk.CreateChatModelRequest" } } ], @@ -1460,7 +1429,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/codersdk.MCPServerConfig" + "$ref": "#/definitions/codersdk.ChatModel" } } }, @@ -1474,30 +1443,63 @@ const docTemplate = `{ } } }, - "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}": { + "/api/experimental/organizations/{organization}/chats/models/available": { "get": { + "description": "Experimental: this endpoint is subject to change.", "produces": [ "application/json" ], "tags": [ - "MCP" + "Chats" ], - "summary": "Get MCP server config", - "operationId": "get-mcp-server-config", + "summary": "List available chat models in an organization", + "operationId": "list-chat-model-availability", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModelAvailabilityResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/experimental/organizations/{organization}/chats/models/{model}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "Get an AI model", + "operationId": "get-ai-model", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true }, { "type": "string", - "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", + "description": "Model ID", + "name": "model", "in": "path", "required": true } @@ -1506,7 +1508,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.MCPServerConfig" + "$ref": "#/definitions/codersdk.ChatModel" } } }, @@ -1521,24 +1523,22 @@ const docTemplate = `{ }, "delete": { "tags": [ - "MCP" + "Chats" ], - "summary": "Delete MCP server config", - "operationId": "delete-mcp-server-config", + "summary": "Delete an AI model", + "operationId": "delete-ai-model", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true }, { "type": "string", - "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", + "description": "Model ID", + "name": "model", "in": "path", "required": true } @@ -1565,34 +1565,32 @@ const docTemplate = `{ "application/json" ], "tags": [ - "MCP" + "Chats" ], - "summary": "Update MCP server config", - "operationId": "update-mcp-server-config", + "summary": "Update an AI model", + "operationId": "update-ai-model", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true }, { "type": "string", - "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", + "description": "Model ID", + "name": "model", "in": "path", "required": true }, { - "description": "Update MCP server config request", + "description": "Model updates", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.UpdateMCPServerConfigRequest" + "$ref": "#/definitions/codersdk.UpdateChatModelRequest" } } ], @@ -1600,7 +1598,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.MCPServerConfig" + "$ref": "#/definitions/codersdk.ChatModel" } } }, @@ -1614,21 +1612,20 @@ const docTemplate = `{ } } }, - "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl": { + "/api/experimental/organizations/{organization}/chats/models/{model}/acl": { "get": { "produces": [ "application/json" ], "tags": [ - "MCP" + "Chats" ], - "summary": "Get MCP server config ACL", - "operationId": "get-mcp-server-config-acl", + "summary": "Get an AI model ACL", + "operationId": "get-ai-model-acl", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true @@ -1636,8 +1633,8 @@ const docTemplate = `{ { "type": "string", "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", + "description": "Model ID", + "name": "model", "in": "path", "required": true } @@ -1646,7 +1643,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.MCPServerConfigACL" + "$ref": "#/definitions/codersdk.ChatModelACL" } } }, @@ -1664,15 +1661,14 @@ const docTemplate = `{ "application/json" ], "tags": [ - "MCP" + "Chats" ], - "summary": "Update MCP server config ACL", - "operationId": "update-mcp-server-config-acl", + "summary": "Update an AI model ACL", + "operationId": "update-ai-model-acl", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true @@ -1680,18 +1676,18 @@ const docTemplate = `{ { "type": "string", "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", + "description": "Model ID", + "name": "model", "in": "path", "required": true }, { - "description": "Update MCP server config ACL request", + "description": "Sparse model ACL update", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.UpdateMCPServerConfigACLRequest" + "$ref": "#/definitions/codersdk.UpdateChatModelACLRequest" } } ], @@ -1710,13 +1706,16 @@ const docTemplate = `{ } } }, - "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": { + "/api/experimental/organizations/{organization}/mcp-servers": { "get": { + "produces": [ + "application/json" + ], "tags": [ "MCP" ], - "summary": "Initiate MCP server OAuth2 connect", - "operationId": "initiate-mcp-server-oauth2-connect", + "summary": "List MCP server configs", + "operationId": "list-mcp-server-configs", "parameters": [ { "type": "string", @@ -1725,48 +1724,6 @@ const docTemplate = `{ "name": "organization", "in": "path", "required": true - }, - { - "type": "string", - "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", - "in": "path", - "required": true - } - ], - "responses": { - "307": { - "description": "Temporary Redirect" - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ], - "x-apidocgen": { - "skip": true - } - } - }, - "/api/experimental/users/{user}/skills": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "List user skills", - "operationId": "list-user-skills", - "parameters": [ - { - "type": "string", - "description": "User ID, username, or me", - "name": "user", - "in": "path", - "required": true } ], "responses": { @@ -1775,7 +1732,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.UserSkillMetadata" + "$ref": "#/definitions/codersdk.MCPServerConfig" } } } @@ -1797,25 +1754,26 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Users" + "MCP" ], - "summary": "Create a user skill", - "operationId": "create-a-user-skill", + "summary": "Create MCP server config", + "operationId": "create-mcp-server-config", "parameters": [ { "type": "string", - "description": "User ID, username, or me", - "name": "user", + "format": "uuid", + "description": "Organization ID", + "name": "organization", "in": "path", "required": true }, { - "description": "Create user skill request", + "description": "Create MCP server config request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateUserSkillRequest" + "$ref": "#/definitions/codersdk.CreateMCPServerConfigRequest" } } ], @@ -1823,7 +1781,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/codersdk.UserSkill" + "$ref": "#/definitions/codersdk.MCPServerConfig" } } }, @@ -1837,28 +1795,30 @@ const docTemplate = `{ } } }, - "/api/experimental/users/{user}/skills/{skillName}": { + "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}": { "get": { "produces": [ "application/json" ], "tags": [ - "Users" + "MCP" ], - "summary": "Get a user skill by name", - "operationId": "get-a-user-skill-by-name", + "summary": "Get MCP server config", + "operationId": "get-mcp-server-config", "parameters": [ { "type": "string", - "description": "User ID, username, or me", - "name": "user", + "format": "uuid", + "description": "Organization ID", + "name": "organization", "in": "path", "required": true }, { "type": "string", - "description": "Skill name", - "name": "skillName", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", "in": "path", "required": true } @@ -1867,7 +1827,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.UserSkill" + "$ref": "#/definitions/codersdk.MCPServerConfig" } } }, @@ -1882,22 +1842,24 @@ const docTemplate = `{ }, "delete": { "tags": [ - "Users" + "MCP" ], - "summary": "Delete a user skill", - "operationId": "delete-a-user-skill", + "summary": "Delete MCP server config", + "operationId": "delete-mcp-server-config", "parameters": [ { "type": "string", - "description": "User ID, username, or me", - "name": "user", + "format": "uuid", + "description": "Organization ID", + "name": "organization", "in": "path", "required": true }, { "type": "string", - "description": "Skill name", - "name": "skillName", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", "in": "path", "required": true } @@ -1924,32 +1886,34 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Users" + "MCP" ], - "summary": "Update a user skill", - "operationId": "update-a-user-skill", + "summary": "Update MCP server config", + "operationId": "update-mcp-server-config", "parameters": [ { "type": "string", - "description": "User ID, username, or me", - "name": "user", + "format": "uuid", + "description": "Organization ID", + "name": "organization", "in": "path", "required": true }, { "type": "string", - "description": "Skill name", - "name": "skillName", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", "in": "path", "required": true }, { - "description": "Update user skill request", + "description": "Update MCP server config request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.UpdateUserSkillRequest" + "$ref": "#/definitions/codersdk.UpdateMCPServerConfigRequest" } } ], @@ -1957,7 +1921,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.UserSkill" + "$ref": "#/definitions/codersdk.MCPServerConfig" } } }, @@ -1971,19 +1935,40 @@ const docTemplate = `{ } } }, - "/api/experimental/watch-all-workspacebuilds": { + "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl": { "get": { "produces": [ "application/json" ], "tags": [ - "Workspaces" + "MCP" + ], + "summary": "Get MCP server config ACL", + "operationId": "get-mcp-server-config-acl", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", + "in": "path", + "required": true + } ], - "summary": "Watch all workspace builds", - "operationId": "watch-all-workspace-builds", "responses": { - "101": { - "description": "Switching Protocols" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.MCPServerConfigACL" + } } }, "security": [ @@ -1994,162 +1979,124 @@ const docTemplate = `{ "x-apidocgen": { "skip": true } - } - }, - "/api/v2/": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "General" - ], - "summary": "API root handler", - "operationId": "api-root-handler", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.Response" - } - } - } - } - }, - "/api/v2/agent-firewall/sessions/{id}": { - "get": { - "produces": [ + }, + "patch": { + "consumes": [ "application/json" ], "tags": [ - "Enterprise" + "MCP" ], - "summary": "Get agent firewall session by ID", - "operationId": "get-agent-firewall-session-by-id", + "summary": "Update MCP server config ACL", + "operationId": "update-mcp-server-config-acl", "parameters": [ { "type": "string", "format": "uuid", - "description": "Agent firewall session ID", - "name": "id", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", "in": "path", "required": true + }, + { + "description": "Update MCP server config ACL request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateMCPServerConfigACLRequest" + } } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.AgentFirewallSession" - } + "204": { + "description": "No Content" } }, "security": [ { "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } } }, - "/api/v2/agent-firewall/sessions/{id}/logs": { + "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": { "get": { - "produces": [ - "application/json" - ], "tags": [ - "Enterprise" + "MCP" ], - "summary": "Get agent firewall session logs", - "operationId": "get-agent-firewall-session-logs", + "summary": "Initiate MCP server OAuth2 connect", + "operationId": "initiate-mcp-server-oauth2-connect", "parameters": [ { "type": "string", "format": "uuid", - "description": "Agent firewall session ID", - "name": "id", + "description": "Organization ID", + "name": "organization", "in": "path", "required": true }, { - "type": "integer", - "description": "Inclusive lower bound on sequence number", - "name": "seq_after", - "in": "query" - }, - { - "type": "integer", - "description": "Exclusive upper bound on sequence number", - "name": "seq_before", - "in": "query" - }, - { - "type": "integer", - "description": "Maximum number of logs to return (default 100)", - "name": "limit", - "in": "query" + "type": "string", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", + "in": "path", + "required": true } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.AgentFirewallSessionLogsResponse" - } + "307": { + "description": "Temporary Redirect" } }, "security": [ { "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } } }, - "/api/v2/ai-gateway/clients": { + "/api/experimental/users/{user}/skills": { "get": { - "description": "Alias: also available at /api/v2/aibridge/clients for backward compatibility.", "produces": [ "application/json" ], "tags": [ - "AI Gateway" + "Users" ], - "summary": "List AI Gateway clients", - "operationId": "list-ai-gateway-clients", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "security": [ + "summary": "List user skills", + "operationId": "list-user-skills", + "parameters": [ { - "CoderSessionToken": [] + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true } - ] - } - }, - "/api/v2/ai-gateway/keys": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Enterprise" ], - "summary": "List AI Gateway keys", - "operationId": "list-ai-gateway-keys", "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.AIGatewayKey" + "$ref": "#/definitions/codersdk.UserSkillMetadata" } } } @@ -2158,7 +2105,10 @@ const docTemplate = `{ { "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } }, "post": { "consumes": [ @@ -2168,18 +2118,25 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Enterprise" + "Users" ], - "summary": "Create AI Gateway key", - "operationId": "create-ai-gateway-key", + "summary": "Create a user skill", + "operationId": "create-a-user-skill", "parameters": [ { - "description": "Create AI Gateway key request", + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Create user skill request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest" + "$ref": "#/definitions/codersdk.CreateUserSkillRequest" } } ], @@ -2187,7 +2144,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse" + "$ref": "#/definitions/codersdk.UserSkill" } } }, @@ -2195,57 +2152,133 @@ const docTemplate = `{ { "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } } }, - "/api/v2/ai-gateway/keys/{key}": { - "delete": { + "/api/experimental/users/{user}/skills/{skillName}": { + "get": { + "produces": [ + "application/json" + ], "tags": [ - "Enterprise" + "Users" ], - "summary": "Delete AI Gateway key", - "operationId": "delete-ai-gateway-key", + "summary": "Get a user skill by name", + "operationId": "get-a-user-skill-by-name", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Key ID", - "name": "key", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Skill name", + "name": "skillName", "in": "path", "required": true } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserSkill" + } } }, "security": [ { "CoderSessionToken": [] } - ] - } - }, - "/api/v2/ai-gateway/models": { - "get": { - "description": "Alias: also available at /api/v2/aibridge/models for backward compatibility.", + ], + "x-apidocgen": { + "skip": true + } + }, + "delete": { + "tags": [ + "Users" + ], + "summary": "Delete a user skill", + "operationId": "delete-a-user-skill", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Skill name", + "name": "skillName", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + }, + "patch": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "AI Gateway" + "Users" + ], + "summary": "Update a user skill", + "operationId": "update-a-user-skill", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Skill name", + "name": "skillName", + "in": "path", + "required": true + }, + { + "description": "Update user skill request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserSkillRequest" + } + } ], - "summary": "List AI Gateway models", - "operationId": "list-ai-gateway-models", "responses": { "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "type": "string" - } + "$ref": "#/definitions/codersdk.UserSkill" } } }, @@ -2253,16 +2286,22 @@ const docTemplate = `{ { "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } } }, - "/api/v2/ai-gateway/serve": { + "/api/experimental/watch-all-workspacebuilds": { "get": { + "produces": [ + "application/json" + ], "tags": [ - "Enterprise" + "Workspaces" ], - "summary": "AI Gateway serve", - "operationId": "ai-gateway-serve", + "summary": "Watch all workspace builds", + "operationId": "watch-all-workspace-builds", "responses": { "101": { "description": "Switching Protocols" @@ -2270,53 +2309,59 @@ const docTemplate = `{ }, "security": [ { - "AIGatewayKey": [] + "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } } }, - "/api/v2/ai-gateway/sessions": { + "/api/v2/": { "get": { - "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.", "produces": [ "application/json" ], "tags": [ - "AI Gateway" + "General" ], - "summary": "List AI Gateway sessions", - "operationId": "list-ai-gateway-sessions", + "summary": "API root handler", + "operationId": "api-root-handler", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, + "/api/v2/agent-firewall/sessions/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get agent firewall session by ID", + "operationId": "get-agent-firewall-session-by-id", "parameters": [ { "type": "string", - "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.", - "name": "q", - "in": "query" - }, - { - "type": "integer", - "description": "Page limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Cursor pagination after session ID (cannot be used with offset)", - "name": "after_session_id", - "in": "query" - }, - { - "type": "integer", - "description": "Offset pagination (cannot be used with after_session_id)", - "name": "offset", - "in": "query" + "format": "uuid", + "description": "Agent firewall session ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse" + "$ref": "#/definitions/codersdk.AgentFirewallSession" } } }, @@ -2327,40 +2372,40 @@ const docTemplate = `{ ] } }, - "/api/v2/ai-gateway/sessions/{session_id}": { + "/api/v2/agent-firewall/sessions/{id}/logs": { "get": { - "description": "Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility.", "produces": [ "application/json" ], "tags": [ - "AI Gateway" + "Enterprise" ], - "summary": "Get AI Gateway session threads", - "operationId": "get-ai-gateway-session-threads", + "summary": "Get agent firewall session logs", + "operationId": "get-agent-firewall-session-logs", "parameters": [ { "type": "string", - "description": "Session ID (client_session_id or interception UUID)", - "name": "session_id", + "format": "uuid", + "description": "Agent firewall session ID", + "name": "id", "in": "path", "required": true }, { - "type": "string", - "description": "Thread pagination cursor (forward/older)", - "name": "after_id", + "type": "integer", + "description": "Inclusive lower bound on sequence number", + "name": "seq_after", "in": "query" }, { - "type": "string", - "description": "Thread pagination cursor (backward/newer)", - "name": "before_id", + "type": "integer", + "description": "Exclusive upper bound on sequence number", + "name": "seq_before", "in": "query" }, { "type": "integer", - "description": "Number of threads per page (default 50)", + "description": "Maximum number of logs to return (default 100)", "name": "limit", "in": "query" } @@ -2369,7 +2414,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse" + "$ref": "#/definitions/codersdk.AgentFirewallSessionLogsResponse" } } }, @@ -2380,23 +2425,52 @@ const docTemplate = `{ ] } }, - "/api/v2/ai/providers": { + "/api/v2/ai-gateway/clients": { "get": { + "description": "Alias: also available at /api/v2/aibridge/clients for backward compatibility.", "produces": [ "application/json" ], "tags": [ - "AI Providers" + "AI Gateway" ], - "summary": "List AI providers", - "operationId": "list-ai-providers", + "summary": "List AI Gateway clients", + "operationId": "list-ai-gateway-clients", "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.AIProvider" + "type": "string" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/keys": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "List AI Gateway keys", + "operationId": "list-ai-gateway-keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIGatewayKey" } } } @@ -2415,18 +2489,18 @@ const docTemplate = `{ "application/json" ], "tags": [ - "AI Providers" + "Enterprise" ], - "summary": "Create an AI provider", - "operationId": "create-an-ai-provider", + "summary": "Create AI Gateway key", + "operationId": "create-ai-gateway-key", "parameters": [ { - "description": "Create AI provider request", + "description": "Create AI Gateway key request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateAIProviderRequest" + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest" } } ], @@ -2434,7 +2508,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/codersdk.AIProvider" + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse" } } }, @@ -2445,13 +2519,260 @@ const docTemplate = `{ ] } }, - "/api/v2/ai/providers/{idOrName}": { - "get": { - "produces": [ - "application/json" - ], + "/api/v2/ai-gateway/keys/{key}": { + "delete": { "tags": [ - "AI Providers" + "Enterprise" + ], + "summary": "Delete AI Gateway key", + "operationId": "delete-ai-gateway-key", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Key ID", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/models": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/models for backward compatibility.", + "produces": [ + "application/json" + ], + "tags": [ + "AI Gateway" + ], + "summary": "List AI Gateway models", + "operationId": "list-ai-gateway-models", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/serve": { + "get": { + "tags": [ + "Enterprise" + ], + "summary": "AI Gateway serve", + "operationId": "ai-gateway-serve", + "responses": { + "101": { + "description": "Switching Protocols" + } + }, + "security": [ + { + "AIGatewayKey": [] + } + ] + } + }, + "/api/v2/ai-gateway/sessions": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.", + "produces": [ + "application/json" + ], + "tags": [ + "AI Gateway" + ], + "summary": "List AI Gateway sessions", + "operationId": "list-ai-gateway-sessions", + "parameters": [ + { + "type": "string", + "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Cursor pagination after session ID (cannot be used with offset)", + "name": "after_session_id", + "in": "query" + }, + { + "type": "integer", + "description": "Offset pagination (cannot be used with after_session_id)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/sessions/{session_id}": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility.", + "produces": [ + "application/json" + ], + "tags": [ + "AI Gateway" + ], + "summary": "Get AI Gateway session threads", + "operationId": "get-ai-gateway-session-threads", + "parameters": [ + { + "type": "string", + "description": "Session ID (client_session_id or interception UUID)", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Thread pagination cursor (forward/older)", + "name": "after_id", + "in": "query" + }, + { + "type": "string", + "description": "Thread pagination cursor (backward/newer)", + "name": "before_id", + "in": "query" + }, + { + "type": "integer", + "description": "Number of threads per page (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai/providers": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "AI Providers" + ], + "summary": "List AI providers", + "operationId": "list-ai-providers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIProvider" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AI Providers" + ], + "summary": "Create an AI provider", + "operationId": "create-an-ai-provider", + "parameters": [ + { + "description": "Create AI provider request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateAIProviderRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.AIProvider" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai/providers/{idOrName}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "AI Providers" ], "summary": "Get an AI provider", "operationId": "get-an-ai-provider", @@ -18587,77 +18908,451 @@ const docTemplate = `{ "cache_creation_tokens": { "type": "integer" }, - "cache_read_tokens": { - "type": "integer" + "cache_read_tokens": { + "type": "integer" + }, + "context_limit": { + "type": "integer" + }, + "input_tokens": { + "type": "integer" + }, + "output_tokens": { + "type": "integer" + }, + "reasoning_tokens": { + "type": "integer" + }, + "total_tokens": { + "type": "integer" + } + } + }, + "codersdk.ChatMessagesResponse": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatMessage" + } + }, + "queued_messages": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatQueuedMessage" + } + } + } + }, + "codersdk.ChatModel": { + "type": "object", + "properties": { + "ai_provider_id": { + "type": "string", + "format": "uuid" + }, + "compression_threshold": { + "type": "integer" + }, + "context_limit": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "display_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_default": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "model_config": { + "$ref": "#/definitions/codersdk.ChatModelCallConfig" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "reasoning_efforts": { + "description": "ReasoningEfforts lists selectable reasoning effort values through\nthe model's configured maximum.", + "type": "array", + "items": { + "type": "string" + } + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.ChatModelACL": { + "type": "object", + "properties": { + "group_roles": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.ChatRole" + } + }, + "user_roles": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.ChatRole" + } + } + } + }, + "codersdk.ChatModelAnthropicProviderOptions": { + "type": "object", + "properties": { + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "context_1m_enabled": { + "type": "boolean" + }, + "disable_parallel_tool_use": { + "type": "boolean" + }, + "send_reasoning": { + "type": "boolean" + }, + "thinking": { + "$ref": "#/definitions/codersdk.ChatModelAnthropicThinkingOptions" + }, + "thinking_display": { + "type": "string" + }, + "web_search_enabled": { + "type": "boolean" + } + } + }, + "codersdk.ChatModelAnthropicThinkingOptions": { + "type": "object", + "properties": { + "budget_tokens": { + "type": "integer" + } + } + }, + "codersdk.ChatModelAvailabilityResponse": { + "type": "object", + "properties": { + "models": { + "description": "Models contains the effective runtime model configs for the requested\norganization. Each config belongs to that organization.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatModel" + } + }, + "providers": { + "type": "array", + "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" + } + } + } + }, + "codersdk.ChatModelCallConfig": { + "type": "object", + "properties": { + "frequency_penalty": { + "type": "number" + }, + "max_output_tokens": { + "type": "integer" + }, + "openai_config": { + "$ref": "#/definitions/codersdk.ChatModelOpenAIConfig" + }, + "presence_penalty": { + "type": "number" + }, + "provider_options": { + "$ref": "#/definitions/codersdk.ChatModelProviderOptions" + }, + "reasoning_effort": { + "$ref": "#/definitions/codersdk.ChatModelReasoningEffortConfig" + }, + "temperature": { + "type": "number" + }, + "top_k": { + "type": "integer" + }, + "top_p": { + "type": "number" + } + } + }, + "codersdk.ChatModelCatalogEntry": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "codersdk.ChatModelGoogleProviderOptions": { + "type": "object", + "properties": { + "cached_content": { + "type": "string" + }, + "safety_settings": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatModelGoogleSafetySetting" + } + }, + "thinking_config": { + "$ref": "#/definitions/codersdk.ChatModelGoogleThinkingConfig" + }, + "threshold": { + "type": "string" + }, + "web_search_enabled": { + "type": "boolean" + } + } + }, + "codersdk.ChatModelGoogleSafetySetting": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "threshold": { + "type": "string" + } + } + }, + "codersdk.ChatModelGoogleThinkingConfig": { + "type": "object", + "properties": { + "include_thoughts": { + "type": "boolean" + }, + "thinking_budget": { + "type": "integer" + }, + "thinking_level": { + "type": "string" + } + } + }, + "codersdk.ChatModelOpenAICompatProviderOptions": { + "type": "object", + "properties": { + "user": { + "type": "string" + } + } + }, + "codersdk.ChatModelOpenAIConfig": { + "type": "object", + "properties": { + "use_responses_api": { + "type": "boolean" + } + } + }, + "codersdk.ChatModelOpenAIProviderOptions": { + "type": "object", + "properties": { + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "type": "array", + "items": { + "type": "string" + } + }, + "instructions": { + "type": "string" + }, + "log_probs": { + "type": "boolean" + }, + "logit_bias": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + }, + "max_completion_tokens": { + "type": "integer" + }, + "max_tool_calls": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "prediction": { + "type": "object", + "additionalProperties": {} + }, + "prompt_cache_key": { + "type": "string" + }, + "reasoning_summary": { + "type": "string" + }, + "safety_identifier": { + "type": "string" + }, + "search_context_size": { + "type": "string" }, - "context_limit": { - "type": "integer" + "service_tier": { + "type": "string" }, - "input_tokens": { - "type": "integer" + "store": { + "type": "boolean" }, - "output_tokens": { - "type": "integer" + "strict_json_schema": { + "type": "boolean" }, - "reasoning_tokens": { - "type": "integer" + "structured_outputs": { + "type": "boolean" }, - "total_tokens": { + "text_verbosity": { + "type": "string" + }, + "top_log_probs": { "type": "integer" + }, + "user": { + "type": "string" + }, + "web_search_enabled": { + "type": "boolean" } } }, - "codersdk.ChatMessagesResponse": { + "codersdk.ChatModelOpenRouterProvider": { "type": "object", "properties": { - "has_more": { + "allow_fallbacks": { "type": "boolean" }, - "messages": { + "data_collection": { + "type": "string" + }, + "ignore": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.ChatMessage" + "type": "string" } }, - "queued_messages": { + "only": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.ChatQueuedMessage" + "type": "string" } - } - } - }, - "codersdk.ChatModelAvailabilityResponse": { - "type": "object", - "properties": { - "providers": { + }, + "order": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.ChatModelProvider" + "type": "string" } }, - "unsupported_providers": { - "description": "UnsupportedProviders lists configured providers the Agents harness\ncannot use, so the UI can explain the empty state.", + "quantizations": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.ChatUnsupportedProvider" + "type": "string" } + }, + "require_parameters": { + "type": "boolean" + }, + "sort": { + "type": "string" } } }, - "codersdk.ChatModelCatalogEntry": { + "codersdk.ChatModelOpenRouterProviderOptions": { "type": "object", "properties": { - "display_name": { - "type": "string" + "extra_body": { + "type": "object", + "additionalProperties": {} }, - "id": { - "type": "string" + "include_usage": { + "type": "boolean" }, - "model": { - "type": "string" + "log_probs": { + "type": "boolean" + }, + "logit_bias": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + }, + "parallel_tool_calls": { + "type": "boolean" }, "provider": { + "$ref": "#/definitions/codersdk.ChatModelOpenRouterProvider" + }, + "reasoning": { + "$ref": "#/definitions/codersdk.ChatModelReasoningOptions" + }, + "user": { "type": "string" } } @@ -18682,6 +19377,62 @@ const docTemplate = `{ } } }, + "codersdk.ChatModelProviderDescriptor": { + "type": "object", + "properties": { + "allow_user_api_key": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "has_api_key": { + "type": "boolean" + }, + "has_effective_api_key": { + "type": "boolean" + }, + "has_user_api_key": { + "type": "boolean" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string" + } + } + }, + "codersdk.ChatModelProviderOptions": { + "type": "object", + "properties": { + "anthropic": { + "$ref": "#/definitions/codersdk.ChatModelAnthropicProviderOptions" + }, + "google": { + "$ref": "#/definitions/codersdk.ChatModelGoogleProviderOptions" + }, + "openai": { + "$ref": "#/definitions/codersdk.ChatModelOpenAIProviderOptions" + }, + "openaicompat": { + "$ref": "#/definitions/codersdk.ChatModelOpenAICompatProviderOptions" + }, + "openrouter": { + "$ref": "#/definitions/codersdk.ChatModelOpenRouterProviderOptions" + }, + "vercel": { + "$ref": "#/definitions/codersdk.ChatModelVercelProviderOptions" + } + } + }, "codersdk.ChatModelProviderUnavailableReason": { "type": "string", "enum": [ @@ -18695,6 +19446,82 @@ const docTemplate = `{ "ChatModelProviderUnavailableReasonUserAPIKeyRequired" ] }, + "codersdk.ChatModelReasoningEffortConfig": { + "type": "object", + "properties": { + "default": { + "type": "string" + }, + "max": { + "type": "string" + } + } + }, + "codersdk.ChatModelReasoningOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "exclude": { + "type": "boolean" + }, + "max_tokens": { + "type": "integer" + } + } + }, + "codersdk.ChatModelVercelGatewayProviderOptions": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "codersdk.ChatModelVercelProviderOptions": { + "type": "object", + "properties": { + "extra_body": { + "type": "object", + "additionalProperties": {} + }, + "logit_bias": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + }, + "logprobs": { + "type": "boolean" + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "providerOptions": { + "$ref": "#/definitions/codersdk.ChatModelVercelGatewayProviderOptions" + }, + "reasoning": { + "$ref": "#/definitions/codersdk.ChatModelReasoningOptions" + }, + "top_logprobs": { + "type": "integer" + }, + "user": { + "type": "string" + } + } + }, "codersdk.ChatPlanMode": { "type": "string", "enum": [ @@ -19342,6 +20169,36 @@ const docTemplate = `{ } } }, + "codersdk.CreateChatModelRequest": { + "type": "object", + "properties": { + "ai_provider_id": { + "type": "string", + "format": "uuid" + }, + "compression_threshold": { + "type": "integer" + }, + "context_limit": { + "type": "integer" + }, + "display_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "is_default": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "model_config": { + "$ref": "#/definitions/codersdk.ChatModelCallConfig" + } + } + }, "codersdk.CreateChatRequest": { "type": "object", "properties": { @@ -23246,6 +24103,23 @@ const docTemplate = `{ } } }, + "codersdk.OrganizationChatModelsResponse": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatModel" + } + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatModelProviderDescriptor" + } + } + } + }, "codersdk.OrganizationGroupAISpend": { "type": "object", "properties": { @@ -26652,6 +27526,53 @@ const docTemplate = `{ } } }, + "codersdk.UpdateChatModelACLRequest": { + "type": "object", + "properties": { + "group_roles": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.ChatRole" + } + }, + "user_roles": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.ChatRole" + } + } + } + }, + "codersdk.UpdateChatModelRequest": { + "type": "object", + "properties": { + "ai_provider_id": { + "type": "string", + "format": "uuid" + }, + "compression_threshold": { + "type": "integer" + }, + "context_limit": { + "type": "integer" + }, + "display_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "is_default": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "model_config": { + "$ref": "#/definitions/codersdk.ChatModelCallConfig" + } + } + }, "codersdk.UpdateChatRequest": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 039748a88c258..77033188ce838 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -425,28 +425,6 @@ } } }, - "/api/experimental/chats/models": { - "get": { - "description": "Experimental: this endpoint is subject to change.", - "produces": ["application/json"], - "tags": ["Chats"], - "summary": "List chat models", - "operationId": "list-chat-models", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.ChatModelAvailabilityResponse" - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, "/api/experimental/chats/watch": { "get": { "description": "Experimental: this endpoint is subject to change.", @@ -1232,17 +1210,16 @@ } } }, - "/api/experimental/organizations/{organization}/mcp-servers": { + "/api/experimental/organizations/{organization}/chats/models": { "get": { "produces": ["application/json"], - "tags": ["MCP"], - "summary": "List MCP server configs", - "operationId": "list-mcp-server-configs", + "tags": ["Chats"], + "summary": "List AI models and provider descriptors in an organization", + "operationId": "list-ai-models-by-organization", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true @@ -1252,10 +1229,7 @@ "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.MCPServerConfig" - } + "$ref": "#/definitions/codersdk.OrganizationChatModelsResponse" } } }, @@ -1271,25 +1245,24 @@ "post": { "consumes": ["application/json"], "produces": ["application/json"], - "tags": ["MCP"], - "summary": "Create MCP server config", - "operationId": "create-mcp-server-config", + "tags": ["Chats"], + "summary": "Create an AI model in an organization", + "operationId": "create-ai-model", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true }, { - "description": "Create MCP server config request", + "description": "Model", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateMCPServerConfigRequest" + "$ref": "#/definitions/codersdk.CreateChatModelRequest" } } ], @@ -1297,7 +1270,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/codersdk.MCPServerConfig" + "$ref": "#/definitions/codersdk.ChatModel" } } }, @@ -1311,26 +1284,55 @@ } } }, - "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}": { + "/api/experimental/organizations/{organization}/chats/models/available": { "get": { + "description": "Experimental: this endpoint is subject to change.", "produces": ["application/json"], - "tags": ["MCP"], - "summary": "Get MCP server config", - "operationId": "get-mcp-server-config", + "tags": ["Chats"], + "summary": "List available chat models in an organization", + "operationId": "list-chat-model-availability", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModelAvailabilityResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/experimental/organizations/{organization}/chats/models/{model}": { + "get": { + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "Get an AI model", + "operationId": "get-ai-model", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true }, { "type": "string", - "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", + "description": "Model ID", + "name": "model", "in": "path", "required": true } @@ -1339,7 +1341,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.MCPServerConfig" + "$ref": "#/definitions/codersdk.ChatModel" } } }, @@ -1353,23 +1355,21 @@ } }, "delete": { - "tags": ["MCP"], - "summary": "Delete MCP server config", - "operationId": "delete-mcp-server-config", + "tags": ["Chats"], + "summary": "Delete an AI model", + "operationId": "delete-ai-model", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true }, { "type": "string", - "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", + "description": "Model ID", + "name": "model", "in": "path", "required": true } @@ -1391,33 +1391,31 @@ "patch": { "consumes": ["application/json"], "produces": ["application/json"], - "tags": ["MCP"], - "summary": "Update MCP server config", - "operationId": "update-mcp-server-config", + "tags": ["Chats"], + "summary": "Update an AI model", + "operationId": "update-ai-model", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true }, { "type": "string", - "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", + "description": "Model ID", + "name": "model", "in": "path", "required": true }, { - "description": "Update MCP server config request", + "description": "Model updates", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.UpdateMCPServerConfigRequest" + "$ref": "#/definitions/codersdk.UpdateChatModelRequest" } } ], @@ -1425,7 +1423,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.MCPServerConfig" + "$ref": "#/definitions/codersdk.ChatModel" } } }, @@ -1439,17 +1437,16 @@ } } }, - "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl": { + "/api/experimental/organizations/{organization}/chats/models/{model}/acl": { "get": { "produces": ["application/json"], - "tags": ["MCP"], - "summary": "Get MCP server config ACL", - "operationId": "get-mcp-server-config-acl", + "tags": ["Chats"], + "summary": "Get an AI model ACL", + "operationId": "get-ai-model-acl", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true @@ -1457,8 +1454,8 @@ { "type": "string", "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", + "description": "Model ID", + "name": "model", "in": "path", "required": true } @@ -1467,7 +1464,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.MCPServerConfigACL" + "$ref": "#/definitions/codersdk.ChatModelACL" } } }, @@ -1482,14 +1479,13 @@ }, "patch": { "consumes": ["application/json"], - "tags": ["MCP"], - "summary": "Update MCP server config ACL", - "operationId": "update-mcp-server-config-acl", + "tags": ["Chats"], + "summary": "Update an AI model ACL", + "operationId": "update-ai-model-acl", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Organization ID", + "description": "Organization name or ID", "name": "organization", "in": "path", "required": true @@ -1497,18 +1493,18 @@ { "type": "string", "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", + "description": "Model ID", + "name": "model", "in": "path", "required": true }, { - "description": "Update MCP server config ACL request", + "description": "Sparse model ACL update", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.UpdateMCPServerConfigACLRequest" + "$ref": "#/definitions/codersdk.UpdateChatModelACLRequest" } } ], @@ -1527,11 +1523,12 @@ } } }, - "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": { + "/api/experimental/organizations/{organization}/mcp-servers": { "get": { + "produces": ["application/json"], "tags": ["MCP"], - "summary": "Initiate MCP server OAuth2 connect", - "operationId": "initiate-mcp-server-oauth2-connect", + "summary": "List MCP server configs", + "operationId": "list-mcp-server-configs", "parameters": [ { "type": "string", @@ -1540,44 +1537,6 @@ "name": "organization", "in": "path", "required": true - }, - { - "type": "string", - "format": "uuid", - "description": "MCP server config ID", - "name": "mcpserverconfig", - "in": "path", - "required": true - } - ], - "responses": { - "307": { - "description": "Temporary Redirect" - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ], - "x-apidocgen": { - "skip": true - } - } - }, - "/api/experimental/users/{user}/skills": { - "get": { - "produces": ["application/json"], - "tags": ["Users"], - "summary": "List user skills", - "operationId": "list-user-skills", - "parameters": [ - { - "type": "string", - "description": "User ID, username, or me", - "name": "user", - "in": "path", - "required": true } ], "responses": { @@ -1586,7 +1545,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.UserSkillMetadata" + "$ref": "#/definitions/codersdk.MCPServerConfig" } } } @@ -1603,24 +1562,25 @@ "post": { "consumes": ["application/json"], "produces": ["application/json"], - "tags": ["Users"], - "summary": "Create a user skill", - "operationId": "create-a-user-skill", + "tags": ["MCP"], + "summary": "Create MCP server config", + "operationId": "create-mcp-server-config", "parameters": [ { "type": "string", - "description": "User ID, username, or me", - "name": "user", + "format": "uuid", + "description": "Organization ID", + "name": "organization", "in": "path", "required": true }, { - "description": "Create user skill request", + "description": "Create MCP server config request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateUserSkillRequest" + "$ref": "#/definitions/codersdk.CreateMCPServerConfigRequest" } } ], @@ -1628,7 +1588,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/codersdk.UserSkill" + "$ref": "#/definitions/codersdk.MCPServerConfig" } } }, @@ -1642,24 +1602,26 @@ } } }, - "/api/experimental/users/{user}/skills/{skillName}": { + "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}": { "get": { "produces": ["application/json"], - "tags": ["Users"], - "summary": "Get a user skill by name", - "operationId": "get-a-user-skill-by-name", + "tags": ["MCP"], + "summary": "Get MCP server config", + "operationId": "get-mcp-server-config", "parameters": [ { "type": "string", - "description": "User ID, username, or me", - "name": "user", + "format": "uuid", + "description": "Organization ID", + "name": "organization", "in": "path", "required": true }, { "type": "string", - "description": "Skill name", - "name": "skillName", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", "in": "path", "required": true } @@ -1668,7 +1630,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.UserSkill" + "$ref": "#/definitions/codersdk.MCPServerConfig" } } }, @@ -1682,21 +1644,23 @@ } }, "delete": { - "tags": ["Users"], - "summary": "Delete a user skill", - "operationId": "delete-a-user-skill", + "tags": ["MCP"], + "summary": "Delete MCP server config", + "operationId": "delete-mcp-server-config", "parameters": [ { "type": "string", - "description": "User ID, username, or me", - "name": "user", + "format": "uuid", + "description": "Organization ID", + "name": "organization", "in": "path", "required": true }, { "type": "string", - "description": "Skill name", - "name": "skillName", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", "in": "path", "required": true } @@ -1718,31 +1682,33 @@ "patch": { "consumes": ["application/json"], "produces": ["application/json"], - "tags": ["Users"], - "summary": "Update a user skill", - "operationId": "update-a-user-skill", + "tags": ["MCP"], + "summary": "Update MCP server config", + "operationId": "update-mcp-server-config", "parameters": [ { "type": "string", - "description": "User ID, username, or me", - "name": "user", + "format": "uuid", + "description": "Organization ID", + "name": "organization", "in": "path", "required": true }, { "type": "string", - "description": "Skill name", - "name": "skillName", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", "in": "path", "required": true }, { - "description": "Update user skill request", + "description": "Update MCP server config request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.UpdateUserSkillRequest" + "$ref": "#/definitions/codersdk.UpdateMCPServerConfigRequest" } } ], @@ -1750,7 +1716,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.UserSkill" + "$ref": "#/definitions/codersdk.MCPServerConfig" } } }, @@ -1764,55 +1730,26 @@ } } }, - "/api/experimental/watch-all-workspacebuilds": { + "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl": { "get": { "produces": ["application/json"], - "tags": ["Workspaces"], - "summary": "Watch all workspace builds", - "operationId": "watch-all-workspace-builds", - "responses": { - "101": { - "description": "Switching Protocols" - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ], - "x-apidocgen": { - "skip": true - } - } - }, - "/api/v2/": { - "get": { - "produces": ["application/json"], - "tags": ["General"], - "summary": "API root handler", - "operationId": "api-root-handler", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.Response" - } - } - } - } - }, - "/api/v2/agent-firewall/sessions/{id}": { - "get": { - "produces": ["application/json"], - "tags": ["Enterprise"], - "summary": "Get agent firewall session by ID", - "operationId": "get-agent-firewall-session-by-id", + "tags": ["MCP"], + "summary": "Get MCP server config ACL", + "operationId": "get-mcp-server-config-acl", "parameters": [ { "type": "string", "format": "uuid", - "description": "Agent firewall session ID", - "name": "id", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", "in": "path", "required": true } @@ -1821,7 +1758,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.AgentFirewallSession" + "$ref": "#/definitions/codersdk.MCPServerConfigACL" } } }, @@ -1829,96 +1766,118 @@ { "CoderSessionToken": [] } - ] - } - }, - "/api/v2/agent-firewall/sessions/{id}/logs": { - "get": { - "produces": ["application/json"], - "tags": ["Enterprise"], - "summary": "Get agent firewall session logs", - "operationId": "get-agent-firewall-session-logs", + ], + "x-apidocgen": { + "skip": true + } + }, + "patch": { + "consumes": ["application/json"], + "tags": ["MCP"], + "summary": "Update MCP server config ACL", + "operationId": "update-mcp-server-config-acl", "parameters": [ { "type": "string", "format": "uuid", - "description": "Agent firewall session ID", - "name": "id", + "description": "Organization ID", + "name": "organization", "in": "path", "required": true }, { - "type": "integer", - "description": "Inclusive lower bound on sequence number", - "name": "seq_after", - "in": "query" - }, - { - "type": "integer", - "description": "Exclusive upper bound on sequence number", - "name": "seq_before", - "in": "query" + "type": "string", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", + "in": "path", + "required": true }, { - "type": "integer", - "description": "Maximum number of logs to return (default 100)", - "name": "limit", - "in": "query" + "description": "Update MCP server config ACL request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateMCPServerConfigACLRequest" + } } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.AgentFirewallSessionLogsResponse" - } + "204": { + "description": "No Content" } }, "security": [ { "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } } }, - "/api/v2/ai-gateway/clients": { + "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": { "get": { - "description": "Alias: also available at /api/v2/aibridge/clients for backward compatibility.", - "produces": ["application/json"], - "tags": ["AI Gateway"], - "summary": "List AI Gateway clients", - "operationId": "list-ai-gateway-clients", + "tags": ["MCP"], + "summary": "Initiate MCP server OAuth2 connect", + "operationId": "initiate-mcp-server-oauth2-connect", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", + "in": "path", + "required": true + } + ], "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } + "307": { + "description": "Temporary Redirect" } }, "security": [ { "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } } }, - "/api/v2/ai-gateway/keys": { + "/api/experimental/users/{user}/skills": { "get": { "produces": ["application/json"], - "tags": ["Enterprise"], - "summary": "List AI Gateway keys", - "operationId": "list-ai-gateway-keys", + "tags": ["Users"], + "summary": "List user skills", + "operationId": "list-user-skills", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + } + ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.AIGatewayKey" + "$ref": "#/definitions/codersdk.UserSkillMetadata" } } } @@ -1927,22 +1886,32 @@ { "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } }, "post": { "consumes": ["application/json"], "produces": ["application/json"], - "tags": ["Enterprise"], - "summary": "Create AI Gateway key", - "operationId": "create-ai-gateway-key", + "tags": ["Users"], + "summary": "Create a user skill", + "operationId": "create-a-user-skill", "parameters": [ { - "description": "Create AI Gateway key request", + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Create user skill request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest" + "$ref": "#/definitions/codersdk.CreateUserSkillRequest" } } ], @@ -1950,7 +1919,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse" + "$ref": "#/definitions/codersdk.UserSkill" } } }, @@ -1958,51 +1927,39 @@ { "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } } }, - "/api/v2/ai-gateway/keys/{key}": { - "delete": { - "tags": ["Enterprise"], - "summary": "Delete AI Gateway key", - "operationId": "delete-ai-gateway-key", + "/api/experimental/users/{user}/skills/{skillName}": { + "get": { + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Get a user skill by name", + "operationId": "get-a-user-skill-by-name", "parameters": [ { "type": "string", - "format": "uuid", - "description": "Key ID", - "name": "key", + "description": "User ID, username, or me", + "name": "user", "in": "path", "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - } - }, - "security": [ + }, { - "CoderSessionToken": [] + "type": "string", + "description": "Skill name", + "name": "skillName", + "in": "path", + "required": true } - ] - } - }, - "/api/v2/ai-gateway/models": { - "get": { - "description": "Alias: also available at /api/v2/aibridge/models for backward compatibility.", - "produces": ["application/json"], - "tags": ["AI Gateway"], - "summary": "List AI Gateway models", - "operationId": "list-ai-gateway-models", + ], "responses": { "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "type": "string" - } + "$ref": "#/definitions/codersdk.UserSkill" } } }, @@ -2010,64 +1967,152 @@ { "CoderSessionToken": [] } - ] - } - }, - "/api/v2/ai-gateway/serve": { - "get": { - "tags": ["Enterprise"], - "summary": "AI Gateway serve", - "operationId": "ai-gateway-serve", + ], + "x-apidocgen": { + "skip": true + } + }, + "delete": { + "tags": ["Users"], + "summary": "Delete a user skill", + "operationId": "delete-a-user-skill", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Skill name", + "name": "skillName", + "in": "path", + "required": true + } + ], "responses": { - "101": { - "description": "Switching Protocols" + "204": { + "description": "No Content" } }, "security": [ { - "AIGatewayKey": [] + "CoderSessionToken": [] } - ] - } - }, - "/api/v2/ai-gateway/sessions": { - "get": { - "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.", + ], + "x-apidocgen": { + "skip": true + } + }, + "patch": { + "consumes": ["application/json"], "produces": ["application/json"], - "tags": ["AI Gateway"], - "summary": "List AI Gateway sessions", - "operationId": "list-ai-gateway-sessions", + "tags": ["Users"], + "summary": "Update a user skill", + "operationId": "update-a-user-skill", "parameters": [ { "type": "string", - "description": "Search query in the format `key:value`. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.", - "name": "q", - "in": "query" - }, - { - "type": "integer", - "description": "Page limit", - "name": "limit", - "in": "query" + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true }, { "type": "string", - "description": "Cursor pagination after session ID (cannot be used with offset)", - "name": "after_session_id", - "in": "query" + "description": "Skill name", + "name": "skillName", + "in": "path", + "required": true }, { - "type": "integer", - "description": "Offset pagination (cannot be used with after_session_id)", - "name": "offset", - "in": "query" + "description": "Update user skill request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserSkillRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse" + "$ref": "#/definitions/codersdk.UserSkill" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, + "/api/experimental/watch-all-workspacebuilds": { + "get": { + "produces": ["application/json"], + "tags": ["Workspaces"], + "summary": "Watch all workspace builds", + "operationId": "watch-all-workspace-builds", + "responses": { + "101": { + "description": "Switching Protocols" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, + "/api/v2/": { + "get": { + "produces": ["application/json"], + "tags": ["General"], + "summary": "API root handler", + "operationId": "api-root-handler", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, + "/api/v2/agent-firewall/sessions/{id}": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get agent firewall session by ID", + "operationId": "get-agent-firewall-session-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Agent firewall session ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AgentFirewallSession" } } }, @@ -2078,36 +2123,36 @@ ] } }, - "/api/v2/ai-gateway/sessions/{session_id}": { + "/api/v2/agent-firewall/sessions/{id}/logs": { "get": { - "description": "Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility.", "produces": ["application/json"], - "tags": ["AI Gateway"], - "summary": "Get AI Gateway session threads", - "operationId": "get-ai-gateway-session-threads", + "tags": ["Enterprise"], + "summary": "Get agent firewall session logs", + "operationId": "get-agent-firewall-session-logs", "parameters": [ { "type": "string", - "description": "Session ID (client_session_id or interception UUID)", - "name": "session_id", + "format": "uuid", + "description": "Agent firewall session ID", + "name": "id", "in": "path", "required": true }, { - "type": "string", - "description": "Thread pagination cursor (forward/older)", - "name": "after_id", + "type": "integer", + "description": "Inclusive lower bound on sequence number", + "name": "seq_after", "in": "query" }, { - "type": "string", - "description": "Thread pagination cursor (backward/newer)", - "name": "before_id", + "type": "integer", + "description": "Exclusive upper bound on sequence number", + "name": "seq_before", "in": "query" }, { "type": "integer", - "description": "Number of threads per page (default 50)", + "description": "Maximum number of logs to return (default 100)", "name": "limit", "in": "query" } @@ -2116,7 +2161,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse" + "$ref": "#/definitions/codersdk.AgentFirewallSessionLogsResponse" } } }, @@ -2127,19 +2172,44 @@ ] } }, - "/api/v2/ai/providers": { + "/api/v2/ai-gateway/clients": { "get": { + "description": "Alias: also available at /api/v2/aibridge/clients for backward compatibility.", "produces": ["application/json"], - "tags": ["AI Providers"], - "summary": "List AI providers", - "operationId": "list-ai-providers", + "tags": ["AI Gateway"], + "summary": "List AI Gateway clients", + "operationId": "list-ai-gateway-clients", "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.AIProvider" + "type": "string" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/keys": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "List AI Gateway keys", + "operationId": "list-ai-gateway-keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIGatewayKey" } } } @@ -2153,17 +2223,17 @@ "post": { "consumes": ["application/json"], "produces": ["application/json"], - "tags": ["AI Providers"], - "summary": "Create an AI provider", - "operationId": "create-an-ai-provider", + "tags": ["Enterprise"], + "summary": "Create AI Gateway key", + "operationId": "create-ai-gateway-key", "parameters": [ { - "description": "Create AI provider request", + "description": "Create AI Gateway key request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateAIProviderRequest" + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest" } } ], @@ -2171,7 +2241,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/codersdk.AIProvider" + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse" } } }, @@ -2182,27 +2252,24 @@ ] } }, - "/api/v2/ai/providers/{idOrName}": { - "get": { - "produces": ["application/json"], - "tags": ["AI Providers"], - "summary": "Get an AI provider", - "operationId": "get-an-ai-provider", + "/api/v2/ai-gateway/keys/{key}": { + "delete": { + "tags": ["Enterprise"], + "summary": "Delete AI Gateway key", + "operationId": "delete-ai-gateway-key", "parameters": [ { "type": "string", - "description": "Provider ID or name", - "name": "idOrName", + "format": "uuid", + "description": "Key ID", + "name": "key", "in": "path", "required": true } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.AIProvider" - } + "204": { + "description": "No Content" } }, "security": [ @@ -2210,8 +2277,232 @@ "CoderSessionToken": [] } ] - }, - "delete": { + } + }, + "/api/v2/ai-gateway/models": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/models for backward compatibility.", + "produces": ["application/json"], + "tags": ["AI Gateway"], + "summary": "List AI Gateway models", + "operationId": "list-ai-gateway-models", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/serve": { + "get": { + "tags": ["Enterprise"], + "summary": "AI Gateway serve", + "operationId": "ai-gateway-serve", + "responses": { + "101": { + "description": "Switching Protocols" + } + }, + "security": [ + { + "AIGatewayKey": [] + } + ] + } + }, + "/api/v2/ai-gateway/sessions": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.", + "produces": ["application/json"], + "tags": ["AI Gateway"], + "summary": "List AI Gateway sessions", + "operationId": "list-ai-gateway-sessions", + "parameters": [ + { + "type": "string", + "description": "Search query in the format `key:value`. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Cursor pagination after session ID (cannot be used with offset)", + "name": "after_session_id", + "in": "query" + }, + { + "type": "integer", + "description": "Offset pagination (cannot be used with after_session_id)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/sessions/{session_id}": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility.", + "produces": ["application/json"], + "tags": ["AI Gateway"], + "summary": "Get AI Gateway session threads", + "operationId": "get-ai-gateway-session-threads", + "parameters": [ + { + "type": "string", + "description": "Session ID (client_session_id or interception UUID)", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Thread pagination cursor (forward/older)", + "name": "after_id", + "in": "query" + }, + { + "type": "string", + "description": "Thread pagination cursor (backward/newer)", + "name": "before_id", + "in": "query" + }, + { + "type": "integer", + "description": "Number of threads per page (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai/providers": { + "get": { + "produces": ["application/json"], + "tags": ["AI Providers"], + "summary": "List AI providers", + "operationId": "list-ai-providers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIProvider" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "post": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["AI Providers"], + "summary": "Create an AI provider", + "operationId": "create-an-ai-provider", + "parameters": [ + { + "description": "Create AI provider request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateAIProviderRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.AIProvider" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai/providers/{idOrName}": { + "get": { + "produces": ["application/json"], + "tags": ["AI Providers"], + "summary": "Get an AI provider", + "operationId": "get-an-ai-provider", + "parameters": [ + { + "type": "string", + "description": "Provider ID or name", + "name": "idOrName", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AIProvider" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "delete": { "tags": ["AI Providers"], "summary": "Delete an AI provider", "operationId": "delete-an-ai-provider", @@ -16769,77 +17060,451 @@ "cache_creation_tokens": { "type": "integer" }, - "cache_read_tokens": { - "type": "integer" + "cache_read_tokens": { + "type": "integer" + }, + "context_limit": { + "type": "integer" + }, + "input_tokens": { + "type": "integer" + }, + "output_tokens": { + "type": "integer" + }, + "reasoning_tokens": { + "type": "integer" + }, + "total_tokens": { + "type": "integer" + } + } + }, + "codersdk.ChatMessagesResponse": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatMessage" + } + }, + "queued_messages": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatQueuedMessage" + } + } + } + }, + "codersdk.ChatModel": { + "type": "object", + "properties": { + "ai_provider_id": { + "type": "string", + "format": "uuid" + }, + "compression_threshold": { + "type": "integer" + }, + "context_limit": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "display_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_default": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "model_config": { + "$ref": "#/definitions/codersdk.ChatModelCallConfig" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "reasoning_efforts": { + "description": "ReasoningEfforts lists selectable reasoning effort values through\nthe model's configured maximum.", + "type": "array", + "items": { + "type": "string" + } + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.ChatModelACL": { + "type": "object", + "properties": { + "group_roles": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.ChatRole" + } + }, + "user_roles": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.ChatRole" + } + } + } + }, + "codersdk.ChatModelAnthropicProviderOptions": { + "type": "object", + "properties": { + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "context_1m_enabled": { + "type": "boolean" + }, + "disable_parallel_tool_use": { + "type": "boolean" + }, + "send_reasoning": { + "type": "boolean" + }, + "thinking": { + "$ref": "#/definitions/codersdk.ChatModelAnthropicThinkingOptions" + }, + "thinking_display": { + "type": "string" + }, + "web_search_enabled": { + "type": "boolean" + } + } + }, + "codersdk.ChatModelAnthropicThinkingOptions": { + "type": "object", + "properties": { + "budget_tokens": { + "type": "integer" + } + } + }, + "codersdk.ChatModelAvailabilityResponse": { + "type": "object", + "properties": { + "models": { + "description": "Models contains the effective runtime model configs for the requested\norganization. Each config belongs to that organization.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatModel" + } + }, + "providers": { + "type": "array", + "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" + } + } + } + }, + "codersdk.ChatModelCallConfig": { + "type": "object", + "properties": { + "frequency_penalty": { + "type": "number" + }, + "max_output_tokens": { + "type": "integer" + }, + "openai_config": { + "$ref": "#/definitions/codersdk.ChatModelOpenAIConfig" + }, + "presence_penalty": { + "type": "number" + }, + "provider_options": { + "$ref": "#/definitions/codersdk.ChatModelProviderOptions" + }, + "reasoning_effort": { + "$ref": "#/definitions/codersdk.ChatModelReasoningEffortConfig" + }, + "temperature": { + "type": "number" + }, + "top_k": { + "type": "integer" + }, + "top_p": { + "type": "number" + } + } + }, + "codersdk.ChatModelCatalogEntry": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "codersdk.ChatModelGoogleProviderOptions": { + "type": "object", + "properties": { + "cached_content": { + "type": "string" + }, + "safety_settings": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatModelGoogleSafetySetting" + } + }, + "thinking_config": { + "$ref": "#/definitions/codersdk.ChatModelGoogleThinkingConfig" + }, + "threshold": { + "type": "string" + }, + "web_search_enabled": { + "type": "boolean" + } + } + }, + "codersdk.ChatModelGoogleSafetySetting": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "threshold": { + "type": "string" + } + } + }, + "codersdk.ChatModelGoogleThinkingConfig": { + "type": "object", + "properties": { + "include_thoughts": { + "type": "boolean" + }, + "thinking_budget": { + "type": "integer" + }, + "thinking_level": { + "type": "string" + } + } + }, + "codersdk.ChatModelOpenAICompatProviderOptions": { + "type": "object", + "properties": { + "user": { + "type": "string" + } + } + }, + "codersdk.ChatModelOpenAIConfig": { + "type": "object", + "properties": { + "use_responses_api": { + "type": "boolean" + } + } + }, + "codersdk.ChatModelOpenAIProviderOptions": { + "type": "object", + "properties": { + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "include": { + "type": "array", + "items": { + "type": "string" + } + }, + "instructions": { + "type": "string" + }, + "log_probs": { + "type": "boolean" + }, + "logit_bias": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + }, + "max_completion_tokens": { + "type": "integer" + }, + "max_tool_calls": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "prediction": { + "type": "object", + "additionalProperties": {} + }, + "prompt_cache_key": { + "type": "string" + }, + "reasoning_summary": { + "type": "string" + }, + "safety_identifier": { + "type": "string" + }, + "search_context_size": { + "type": "string" }, - "context_limit": { - "type": "integer" + "service_tier": { + "type": "string" }, - "input_tokens": { - "type": "integer" + "store": { + "type": "boolean" }, - "output_tokens": { - "type": "integer" + "strict_json_schema": { + "type": "boolean" }, - "reasoning_tokens": { - "type": "integer" + "structured_outputs": { + "type": "boolean" }, - "total_tokens": { + "text_verbosity": { + "type": "string" + }, + "top_log_probs": { "type": "integer" + }, + "user": { + "type": "string" + }, + "web_search_enabled": { + "type": "boolean" } } }, - "codersdk.ChatMessagesResponse": { + "codersdk.ChatModelOpenRouterProvider": { "type": "object", "properties": { - "has_more": { + "allow_fallbacks": { "type": "boolean" }, - "messages": { + "data_collection": { + "type": "string" + }, + "ignore": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.ChatMessage" + "type": "string" } }, - "queued_messages": { + "only": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.ChatQueuedMessage" + "type": "string" } - } - } - }, - "codersdk.ChatModelAvailabilityResponse": { - "type": "object", - "properties": { - "providers": { + }, + "order": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.ChatModelProvider" + "type": "string" } }, - "unsupported_providers": { - "description": "UnsupportedProviders lists configured providers the Agents harness\ncannot use, so the UI can explain the empty state.", + "quantizations": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.ChatUnsupportedProvider" + "type": "string" } + }, + "require_parameters": { + "type": "boolean" + }, + "sort": { + "type": "string" } } }, - "codersdk.ChatModelCatalogEntry": { + "codersdk.ChatModelOpenRouterProviderOptions": { "type": "object", "properties": { - "display_name": { - "type": "string" + "extra_body": { + "type": "object", + "additionalProperties": {} }, - "id": { - "type": "string" + "include_usage": { + "type": "boolean" }, - "model": { - "type": "string" + "log_probs": { + "type": "boolean" + }, + "logit_bias": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + }, + "parallel_tool_calls": { + "type": "boolean" }, "provider": { + "$ref": "#/definitions/codersdk.ChatModelOpenRouterProvider" + }, + "reasoning": { + "$ref": "#/definitions/codersdk.ChatModelReasoningOptions" + }, + "user": { "type": "string" } } @@ -16864,6 +17529,62 @@ } } }, + "codersdk.ChatModelProviderDescriptor": { + "type": "object", + "properties": { + "allow_user_api_key": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "has_api_key": { + "type": "boolean" + }, + "has_effective_api_key": { + "type": "boolean" + }, + "has_user_api_key": { + "type": "boolean" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string" + } + } + }, + "codersdk.ChatModelProviderOptions": { + "type": "object", + "properties": { + "anthropic": { + "$ref": "#/definitions/codersdk.ChatModelAnthropicProviderOptions" + }, + "google": { + "$ref": "#/definitions/codersdk.ChatModelGoogleProviderOptions" + }, + "openai": { + "$ref": "#/definitions/codersdk.ChatModelOpenAIProviderOptions" + }, + "openaicompat": { + "$ref": "#/definitions/codersdk.ChatModelOpenAICompatProviderOptions" + }, + "openrouter": { + "$ref": "#/definitions/codersdk.ChatModelOpenRouterProviderOptions" + }, + "vercel": { + "$ref": "#/definitions/codersdk.ChatModelVercelProviderOptions" + } + } + }, "codersdk.ChatModelProviderUnavailableReason": { "type": "string", "enum": ["missing_api_key", "fetch_failed", "user_api_key_required"], @@ -16873,6 +17594,82 @@ "ChatModelProviderUnavailableReasonUserAPIKeyRequired" ] }, + "codersdk.ChatModelReasoningEffortConfig": { + "type": "object", + "properties": { + "default": { + "type": "string" + }, + "max": { + "type": "string" + } + } + }, + "codersdk.ChatModelReasoningOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "exclude": { + "type": "boolean" + }, + "max_tokens": { + "type": "integer" + } + } + }, + "codersdk.ChatModelVercelGatewayProviderOptions": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "codersdk.ChatModelVercelProviderOptions": { + "type": "object", + "properties": { + "extra_body": { + "type": "object", + "additionalProperties": {} + }, + "logit_bias": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + }, + "logprobs": { + "type": "boolean" + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "providerOptions": { + "$ref": "#/definitions/codersdk.ChatModelVercelGatewayProviderOptions" + }, + "reasoning": { + "$ref": "#/definitions/codersdk.ChatModelReasoningOptions" + }, + "top_logprobs": { + "type": "integer" + }, + "user": { + "type": "string" + } + } + }, "codersdk.ChatPlanMode": { "type": "string", "enum": ["plan"], @@ -17497,6 +18294,36 @@ } } }, + "codersdk.CreateChatModelRequest": { + "type": "object", + "properties": { + "ai_provider_id": { + "type": "string", + "format": "uuid" + }, + "compression_threshold": { + "type": "integer" + }, + "context_limit": { + "type": "integer" + }, + "display_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "is_default": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "model_config": { + "$ref": "#/definitions/codersdk.ChatModelCallConfig" + } + } + }, "codersdk.CreateChatRequest": { "type": "object", "properties": { @@ -21246,6 +22073,23 @@ } } }, + "codersdk.OrganizationChatModelsResponse": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatModel" + } + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatModelProviderDescriptor" + } + } + } + }, "codersdk.OrganizationGroupAISpend": { "type": "object", "properties": { @@ -24495,6 +25339,53 @@ } } }, + "codersdk.UpdateChatModelACLRequest": { + "type": "object", + "properties": { + "group_roles": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.ChatRole" + } + }, + "user_roles": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.ChatRole" + } + } + } + }, + "codersdk.UpdateChatModelRequest": { + "type": "object", + "properties": { + "ai_provider_id": { + "type": "string", + "format": "uuid" + }, + "compression_threshold": { + "type": "integer" + }, + "context_limit": { + "type": "integer" + }, + "display_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "is_default": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "model_config": { + "$ref": "#/definitions/codersdk.ChatModelCallConfig" + } + } + }, "codersdk.UpdateChatRequest": { "type": "object", "properties": { diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 8564f33a964f9..6fa6fc7ff4ba3 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -39,6 +39,7 @@ type Auditable interface { database.AIProviderKey | database.AIGatewayKey | database.Chat | + database.ChatModelConfig | database.MCPServerConfig | database.AuditableGroupAIBudget | database.AuditableUserAIBudgetOverride | diff --git a/coderd/audit/request.go b/coderd/audit/request.go index fed41ba5df931..e83b5d7fb2c4d 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -155,6 +155,8 @@ func ResourceTarget[T Auditable](tgt T) string { // for display; collisions affect the display label and search // filter but not the primary resource identifier. return typed.ID.String()[:8] + case database.ChatModelConfig: + return cmp.Or(typed.DisplayName, typed.Model, typed.ID.String()) case database.MCPServerConfig: // Updates can persist an empty display name; fall back to the slug, or // the ID if both are empty, so the audit entry stays identifiable. @@ -262,6 +264,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.UserID case database.Chat: return typed.ID + case database.ChatModelConfig: + return typed.ID case database.MCPServerConfig: return typed.ID case database.UserSecret: @@ -342,6 +346,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeUserAIBudgetOverride case database.Chat: return database.ResourceTypeChat + case database.ChatModelConfig: + return database.ResourceTypeChatModelConfig case database.MCPServerConfig: return database.ResourceTypeMCPServerConfig case database.UserSecret: @@ -434,6 +440,8 @@ func ResourceRequiresOrgID[T Auditable]() bool { // Chats always have a non-null organization_id (since // migration 000467). return true + case database.ChatModelConfig: + return true case database.MCPServerConfig: // MCP server configs always carry a non-null organization_id. return true diff --git a/coderd/coderd.go b/coderd/coderd.go index 1ce598fcecda9..842b74e7b15cf 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1408,6 +1408,53 @@ func New(options *Options) *API { }) }) }) + // Organization-scoped ChatModel management and runtime discovery. + // Keep the previous default-organization collection routes until the + // frontend uses the organization-scoped routes. + r.Route("/chats/model-configs", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + chi.RouteContext(req.Context()).URLParams.Add("organization", codersdk.DefaultOrganization) + next.ServeHTTP(rw, req) + }) + }, + httpmw.ExtractOrganizationParam(options.Database), + ) + r.Get("/", api.listDefaultOrganizationChatModelConfigs) + r.Post("/", api.createChatModelConfig) + }) + r.With( + apiKeyMiddleware, + func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + chi.RouteContext(req.Context()).URLParams.Add("organization", codersdk.DefaultOrganization) + next.ServeHTTP(rw, req) + }) + }, + httpmw.ExtractOrganizationParam(options.Database), + ).Get("/chats/models", api.listChatModelAvailability) + + r.Route("/organizations/{organization}/chats/models", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.With(httpmw.ExtractChatModelOrganizationParam(options.Database)).Get("/", api.listChatModelConfigsByOrganization) + r.With(httpmw.ExtractOrganizationParam(options.Database)).Post("/", api.createChatModelConfig) + r.With(httpmw.ExtractChatModelOrganizationParam(options.Database)).Get("/available", api.listChatModelAvailability) + r.Route("/{model}", func(r chi.Router) { + r.Use( + httpmw.ExtractChatModelOrganizationParam(options.Database), + httpmw.ExtractChatModelConfigParam(options.Database), + ) + r.Get("/", api.getChatModelConfig) + r.Patch("/", api.updateChatModelConfig) + r.Delete("/", api.deleteChatModelConfig) + r.Route("/acl", func(r chi.Router) { + r.Get("/", api.chatModelConfigACLHandler) + r.Patch("/", api.updateChatModelConfigACL) + }) + }) + }) r.Route("/chats", func(r chi.Router) { r.Use( apiKeyMiddleware, @@ -1415,7 +1462,6 @@ func New(options *Options) *API { r.Get("/by-workspace", api.chatsByWorkspace) r.Get("/", api.listChats) r.Post("/", api.postChats) - r.Get("/models", api.listChatModels) r.Get("/watch", api.watchChats) r.Route("/files", func(r chi.Router) { r.Use(httpmw.RateLimit(options.FilesRateLimit, time.Minute)) @@ -1471,15 +1517,6 @@ func New(options *Options) *API { r.Delete("/", api.deleteChatProvider) }) }) - // TODO(cian): place under /api/experimental/chats/config - r.Route("/model-configs", func(r chi.Router) { - r.Get("/", api.listChatModelConfigs) - r.Post("/", api.createChatModelConfig) - r.Route("/{modelConfig}", func(r chi.Router) { - r.Patch("/", api.updateChatModelConfig) - r.Delete("/", api.deleteChatModelConfig) - }) - }) r.Route("/user-provider-configs", func(r chi.Router) { r.Get("/", api.listUserChatProviderConfigs) r.Route("/{providerConfig}", func(r chi.Router) { diff --git a/coderd/coderdtest/chat.go b/coderd/coderdtest/chat.go index ebb4501c25c7b..ae18a4cb9ca83 100644 --- a/coderd/coderdtest/chat.go +++ b/coderd/coderdtest/chat.go @@ -70,9 +70,11 @@ func CreateOpenAICompatChatModel( APIKeys: []string{TestChatProviderAPIKey}, }) require.NoError(t, err) + defaultOrg, err := client.Client.OrganizationByName(ctx, codersdk.DefaultOrganization) + require.NoError(t, err) contextLimit := int64(4096) isDefault := true - model, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + model, err := client.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: TestChatModelOpenAICompat, ContextLimit: &contextLimit, diff --git a/coderd/database/chatmodelconfigs.go b/coderd/database/chatmodelconfigs.go new file mode 100644 index 0000000000000..241e3edfb674e --- /dev/null +++ b/coderd/database/chatmodelconfigs.go @@ -0,0 +1,21 @@ +package database + +// EffectiveChatModelConfigs contains enabled configs for one organization. +type EffectiveChatModelConfigs struct { + Configs []GetEnabledChatModelConfigsByOrganizationRow + DefaultConfig ChatModelConfig +} + +// DeriveEffectiveChatModelConfigs selects the stored default from enabled +// configs in one organization. +func DeriveEffectiveChatModelConfigs( + rows []GetEnabledChatModelConfigsByOrganizationRow, +) EffectiveChatModelConfigs { + result := EffectiveChatModelConfigs{Configs: rows} + for _, row := range rows { + if row.ChatModelConfig.IsDefault { + result.DefaultConfig = row.ChatModelConfig + } + } + return result +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d3b15f8374eb8..b243230bcfb4b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -34,9 +34,11 @@ const wrapname = "dbauthz.querier" // ErrNoActor is returned if no actor is present in the context. var ErrNoActor = xerrors.Errorf("no authorization actor in context") -// NotAuthorizedError is a sentinel error that unwraps to sql.ErrNoRows. -// This allows the internal error to be read by the caller if needed. Otherwise -// it will be handled as a 404. +// NotAuthorizedError is a sentinel error wrapping the underlying RBAC denial. +// HTTP layers map it to a 404 (via IsUnauthorized) so denials are +// indistinguishable from missing resources, but it does NOT unwrap to +// sql.ErrNoRows: 'errors.Is(err, sql.ErrNoRows)' is false for it, so callers +// distinguishing "not found" from "denied" must use httpapi.Is404Error. type NotAuthorizedError struct { Err error } @@ -57,8 +59,7 @@ func (NotAuthorizedError) IsUnauthorized() bool { return true } -// Unwrap will always unwrap to a sql.ErrNoRows so the API returns a 404. -// So 'errors.Is(err, sql.ErrNoRows)' will always be true. +// Unwrap returns the wrapped RBAC denial, never sql.ErrNoRows. func (e NotAuthorizedError) Unwrap() error { return e.Err } @@ -824,8 +825,8 @@ var ( // user's personal data; token writes use the per-user // AsChatdTokenOwner subject instead. rbac.ResourceUser.Type: {policy.ActionReadPersonal}, - // TODO(mafredri): remove this organization read after CODAGT-709 M3. - // exp_chats and chatd use it for the pre-cutover default-org fallback. + // Organization reads support organization-scoped model + // resolution and user-global settings in the default org. rbac.ResourceOrganization.Type: {policy.ActionRead}, }), User: []rbac.Permission{}, @@ -2222,9 +2223,16 @@ func (q *querier) DeleteChatDebugDataByChatID(ctx context.Context, arg database. } func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { - // Interim write gate: see InsertChatModelConfig. - // TODO(mafredri): swap to ResourceChatModelConfig object delete after CODAGT-709 M3 (org-scoping cutover) - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + config, err := q.db.GetChatModelConfigByID(ctx, id) + if err != nil { + return uuid.Nil, err + } + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + if err := q.authorizeContext(ctx, policy.ActionDelete, object); err != nil { return uuid.Nil, err } return q.db.DeleteChatModelConfigByID(ctx, id) @@ -3512,20 +3520,41 @@ func (q *querier) GetChatMessagesForPromptByChatID(ctx context.Context, chatID u } func (q *querier) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) { - // Interim read gate: see InsertChatModelConfig. - // TODO(mafredri): swap to ResourceChatModelConfig object read after CODAGT-709 M3 (org-scoping cutover) - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + config, err := q.db.GetChatModelConfigByID(ctx, id) + if err != nil { return database.ChatModelConfig{}, err } - return q.db.GetChatModelConfigByID(ctx, id) + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil { + return database.ChatModelConfig{}, err + } + return config, nil } -func (q *querier) GetChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) { +func (q *querier) GetChatModelConfigs(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) { prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceChatModelConfig.Type) if err != nil { return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) } - return q.db.GetAuthorizedChatModelConfigs(ctx, prep) + return q.db.GetAuthorizedChatModelConfigs(ctx, organizationID, prep) +} + +func (q *querier) GetChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) { + // Consumed only by ensureDefaultChatModelConfig's default-election read + // inside the write transaction. Every path that reaches the election + // already requires update-in-org (insert/update/delete and the election's + // own UnsetDefaultChatModelConfigs/UpdateChatModelConfig), so the read + // authorizes update-in-org rather than read; a create-only principal never + // reaches it because create short-circuits when the inserted config is the + // org's first. + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChatModelConfig.InOrg(organizationID)); err != nil { + return nil, err + } + return q.db.GetChatModelConfigsByOrganization(ctx, organizationID) } func (q *querier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]database.GetChatModelConfigsForTelemetryRow, error) { @@ -3761,12 +3790,19 @@ func (q *querier) GetDatabaseNow(ctx context.Context) (time.Time, error) { } func (q *querier) GetDefaultChatModelConfig(ctx context.Context, organizationID uuid.UUID) (database.ChatModelConfig, error) { - // Chat creation authorizes the organization-scoped chat before this lookup. - // TODO(mafredri): remove after CODAGT-709 M3. - if _, ok := ActorFromContext(ctx); !ok { - return database.ChatModelConfig{}, ErrNoActor + config, err := q.db.GetDefaultChatModelConfig(ctx, organizationID) + if err != nil { + return database.ChatModelConfig{}, err } - return q.db.GetDefaultChatModelConfig(ctx, organizationID) + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil { + return database.ChatModelConfig{}, err + } + return config, nil } func (q *querier) GetDefaultOrganization(ctx context.Context) (database.Organization, error) { @@ -3802,28 +3838,49 @@ func (q *querier) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.C } func (q *querier) GetEnabledChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) { - // TODO(mafredri): authorize the fetched object after CODAGT-709 M3 - // (org-scoping cutover). - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatModelConfig); err != nil { + config, err := q.db.GetEnabledChatModelConfigByID(ctx, id) + if err != nil { return database.ChatModelConfig{}, err } - return q.db.GetEnabledChatModelConfigByID(ctx, id) -} - -func (q *querier) GetEnabledChatModelConfigs(ctx context.Context) ([]database.GetEnabledChatModelConfigsRow, error) { - // TODO(mafredri): replace this deployment-wide query after CODAGT-709 M3 - // (org-scoping cutover). - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatModelConfig); err != nil { - return nil, err + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil { + return database.ChatModelConfig{}, err } - return q.db.GetEnabledChatModelConfigs(ctx) + return config, nil } func (q *querier) GetEnabledChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatModelConfig.InOrg(organizationID)); err != nil { + rows, err := q.db.GetEnabledChatModelConfigsByOrganization(ctx, organizationID) + if err != nil { return nil, err } - return q.db.GetEnabledChatModelConfigsByOrganization(ctx, organizationID) + act, ok := ActorFromContext(ctx) + if !ok { + return nil, ErrNoActor + } + out := make([]database.GetEnabledChatModelConfigsByOrganizationRow, 0, len(rows)) + for _, row := range rows { + config := row.ChatModelConfig + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + err := q.auth.Authorize(ctx, act, policy.ActionRead, object) + switch { + case err == nil: + out = append(out, row) + case rbac.IsUnauthorizedError(err): + continue + default: + return nil, err + } + } + return out, nil } func (q *querier) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { @@ -6116,13 +6173,7 @@ func (q *querier) InsertChatMessages(ctx context.Context, arg database.InsertCha } func (q *querier) InsertChatModelConfig(ctx context.Context, arg database.InsertChatModelConfigParams) (database.ChatModelConfig, error) { - // The routes create configs in the default organization. Organization - // administrators must not manage configs that other organizations can read. - // TODO(mafredri): swap to ResourceChatModelConfig create-in-org after CODAGT-709 M3 (org-scoping cutover) - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return database.ChatModelConfig{}, err - } - return q.db.InsertChatModelConfig(ctx, arg) + return insert(q.log, q.auth, rbac.ResourceChatModelConfig.InOrg(arg.OrganizationID), q.db.InsertChatModelConfig)(ctx, arg) } func (q *querier) InsertChatQueuedMessage(ctx context.Context, arg database.InsertChatQueuedMessageParams) (database.ChatQueuedMessage, error) { @@ -7322,9 +7373,7 @@ func (q *querier) UnpinChatByID(ctx context.Context, id uuid.UUID) error { } func (q *querier) UnsetDefaultChatModelConfigs(ctx context.Context, organizationID uuid.UUID) error { - // Interim write gate: see InsertChatModelConfig. - // TODO(mafredri): swap to ResourceChatModelConfig update-in-org after CODAGT-709 M3 (org-scoping cutover) - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChatModelConfig.InOrg(organizationID)); err != nil { return err } return q.db.UnsetDefaultChatModelConfigs(ctx, organizationID) @@ -7503,9 +7552,16 @@ func (q *querier) UpdateChatMCPServerIDs(ctx context.Context, arg database.Updat } func (q *querier) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { - // Interim write gate: see InsertChatModelConfig. - // TODO(mafredri): swap to ResourceChatModelConfig object update after CODAGT-709 M3 (org-scoping cutover) - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + existing, err := q.db.GetChatModelConfigByID(ctx, arg.ID) + if err != nil { + return database.ChatModelConfig{}, err + } + object := rbac.ResourceChatModelConfig. + WithID(existing.ID). + InOrg(existing.OrganizationID). + WithACLUserList(existing.UserACL.RBACACL()). + WithGroupACL(existing.GroupACL.RBACACL()) + if err := q.authorizeContext(ctx, policy.ActionUpdate, object); err != nil { return database.ChatModelConfig{}, err } return q.db.UpdateChatModelConfig(ctx, arg) @@ -9478,18 +9534,8 @@ func (q *querier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uui return q.db.GetAuthorizedChatsByChatFileID(ctx, fileID, prepared) } -func (q *querier) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) { - return q.db.GetAuthorizedChatModelConfigs(ctx, prepared) -} - -// GetDefaultChatModelConfigCandidates returns all non-deleted configs. -// A filtered result can leave an organization without a default. -// TODO(mafredri): remove after CODAGT-709 M3. -func (q *querier) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]database.ChatModelConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err - } - return q.db.GetDefaultChatModelConfigCandidates(ctx) +func (q *querier) GetAuthorizedChatModelConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) { + return q.db.GetAuthorizedChatModelConfigs(ctx, organizationID, prepared) } func (q *querier) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e5b7766b19c4f..469a9180772ec 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -802,10 +802,16 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), msg.ID).Return(nil).AnyTimes() check.Args(msg.ID).Asserts(chat, policy.ActionUpdate).Returns() })) - s.Run("DeleteChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - configID := uuid.New() - dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), configID).Return(configID, nil).AnyTimes() - check.Args(configID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(configID) + s.Run("DeleteChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) + dbm.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() + dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), config.ID).Return(config.ID, nil).AnyTimes() + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + check.Args(config.ID).Asserts(object, policy.ActionDelete).Returns(config.ID) })) s.Run("DeleteChatQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) @@ -1175,30 +1181,40 @@ func (s *MethodTestSuite) TestChats() { s.Run("GetChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) dbm.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() - check.Args(config.ID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config) + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + check.Args(config.ID).Asserts(object, policy.ActionRead).Returns(config) })) s.Run("GetDefaultChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) dbm.EXPECT().GetDefaultChatModelConfig(gomock.Any(), config.OrganizationID).Return(config, nil).AnyTimes() - check.Args(config.OrganizationID).Asserts().Returns(config) + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + check.Args(config.OrganizationID).Asserts(object, policy.ActionRead).Returns(config) })) s.Run("GetChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes() + orgID := uuid.New() + dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), orgID, gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes() // No asserts here because SQLFilter. - check.Args().Asserts() + check.Args(orgID).Asserts() + })) + s.Run("GetChatModelConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + orgID := uuid.New() + dbm.EXPECT().GetChatModelConfigsByOrganization(gomock.Any(), orgID).Return([]database.ChatModelConfig{}, nil).AnyTimes() + check.Args(orgID).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), policy.ActionUpdate).Returns([]database.ChatModelConfig{}) })) s.Run("GetAuthorizedChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes() + orgID := uuid.New() + dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), orgID, gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes() // No asserts here because callers provide the SQL filter. - check.Args(emptyPreparedAuthorized{}).Asserts() - })) - s.Run("GetDefaultChatModelConfigCandidates", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) - configB := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) - dbm.EXPECT().GetDefaultChatModelConfigCandidates(gomock.Any()).Return([]database.ChatModelConfig{configA, configB}, nil).AnyTimes() - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.ChatModelConfig{configA, configB}) + check.Args(orgID, emptyPreparedAuthorized{}).Asserts() })) - s.Run("GetChats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { params := database.GetChatsParams{} dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.GetChatsRow{}, nil).AnyTimes() @@ -1303,21 +1319,33 @@ func (s *MethodTestSuite) TestChats() { s.Run("GetEnabledChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) dbm.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() - check.Args(config.ID).Asserts(rbac.ResourceChatModelConfig, policy.ActionRead).Returns(config) - })) - s.Run("GetEnabledChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - rowA := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsRow{}) - rowB := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsRow{}) - dbm.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return([]database.GetEnabledChatModelConfigsRow{rowA, rowB}, nil).AnyTimes() - check.Args().Asserts(rbac.ResourceChatModelConfig, policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsRow{rowA, rowB}) + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + check.Args(config.ID).Asserts(object, policy.ActionRead).Returns(config) })) - s.Run("GetEnabledChatModelConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { orgID := uuid.New() - rowA := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{}) - rowB := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{}) + rowA := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{ + ChatModelConfig: database.ChatModelConfig{OrganizationID: orgID}, + }) + rowB := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{ + ChatModelConfig: database.ChatModelConfig{OrganizationID: orgID}, + }) dbm.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), orgID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB}, nil).AnyTimes() - check.Args(orgID).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB}) + objectA := rbac.ResourceChatModelConfig. + WithID(rowA.ChatModelConfig.ID). + InOrg(rowA.ChatModelConfig.OrganizationID). + WithACLUserList(rowA.ChatModelConfig.UserACL.RBACACL()). + WithGroupACL(rowA.ChatModelConfig.GroupACL.RBACACL()) + objectB := rbac.ResourceChatModelConfig. + WithID(rowB.ChatModelConfig.ID). + InOrg(rowB.ChatModelConfig.OrganizationID). + WithACLUserList(rowB.ChatModelConfig.UserACL.RBACACL()). + WithGroupACL(rowB.ChatModelConfig.GroupACL.RBACACL()) + check.Args(orgID).Asserts(objectA, policy.ActionRead, objectB, policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB}) })) s.Run("GetStaleChats", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { @@ -1360,13 +1388,14 @@ func (s *MethodTestSuite) TestChats() { })) s.Run("InsertChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.InsertChatModelConfigParams{ - Model: "test-model", - DisplayName: "Test Model", - Enabled: true, + Model: "test-model", + DisplayName: "Test Model", + Enabled: true, + OrganizationID: uuid.New(), } config := testutil.Fake(s.T(), faker, database.ChatModelConfig{Model: arg.Model, DisplayName: arg.DisplayName, Enabled: arg.Enabled}) dbm.EXPECT().InsertChatModelConfig(gomock.Any(), arg).Return(config, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config) + check.Args(arg).Asserts(rbac.ResourceChatModelConfig.InOrg(arg.OrganizationID), policy.ActionCreate).Returns(config) })) s.Run("PopNextQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { @@ -1594,8 +1623,14 @@ func (s *MethodTestSuite) TestChats() { DisplayName: "Updated Model", Enabled: true, } + dbm.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() dbm.EXPECT().UpdateChatModelConfig(gomock.Any(), arg).Return(config, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config) + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + check.Args(arg).Asserts(object, policy.ActionUpdate).Returns(config) })) s.Run("UpdateChatModelConfigACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { @@ -1668,7 +1703,7 @@ func (s *MethodTestSuite) TestChats() { s.Run("UnsetDefaultChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { orgID := uuid.New() dbm.EXPECT().UnsetDefaultChatModelConfigs(gomock.Any(), orgID).Return(nil).AnyTimes() - check.Args(orgID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + check.Args(orgID).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), policy.ActionUpdate) })) s.Run("UpsertChatDiffStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) @@ -7760,6 +7795,10 @@ func TestAsChatd(t *testing.T) { err = auth.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig) require.Error(t, err, "deployment config update should not be allowed") + // Chatd resolves organization model configs during background work. + err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceChatModelConfig.InOrg(uuid.New())) + require.NoError(t, err, "chat model config read should be allowed") + // Pin the complete ResourceUser action set: read_personal only. // Token refresh persistence uses the per-user AsChatdTokenOwner subject. for _, action := range rbac.ResourceUser.AvailableActions() { @@ -7771,7 +7810,7 @@ func TestAsChatd(t *testing.T) { } } - // Organization read is temporarily needed for the pre-cutover default-org fallback. + // Organization reads support the pre-cutover default-org fallback. err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceOrganization) require.NoError(t, err, "organization read should be allowed") }) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 33f5560ada77e..1f728f1693f87 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1696,14 +1696,22 @@ func (m queryMetricsStore) GetChatModelConfigByID(ctx context.Context, id uuid.U return r0, r1 } -func (m queryMetricsStore) GetChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) { +func (m queryMetricsStore) GetChatModelConfigs(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) { start := time.Now() - r0, r1 := m.s.GetChatModelConfigs(ctx) + r0, r1 := m.s.GetChatModelConfigs(ctx, organizationID) m.queryLatencies.WithLabelValues("GetChatModelConfigs").Observe(time.Since(start).Seconds()) m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatModelConfigs").Inc() return r0, r1 } +func (m queryMetricsStore) GetChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) { + start := time.Now() + r0, r1 := m.s.GetChatModelConfigsByOrganization(ctx, organizationID) + m.queryLatencies.WithLabelValues("GetChatModelConfigsByOrganization").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatModelConfigsByOrganization").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatModelConfigsForTelemetry(ctx context.Context) ([]database.GetChatModelConfigsForTelemetryRow, error) { start := time.Now() r0, r1 := m.s.GetChatModelConfigsForTelemetry(ctx) @@ -2008,14 +2016,6 @@ func (m queryMetricsStore) GetEnabledChatModelConfigByID(ctx context.Context, id return r0, r1 } -func (m queryMetricsStore) GetEnabledChatModelConfigs(ctx context.Context) ([]database.GetEnabledChatModelConfigsRow, error) { - start := time.Now() - r0, r1 := m.s.GetEnabledChatModelConfigs(ctx) - m.queryLatencies.WithLabelValues("GetEnabledChatModelConfigs").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetEnabledChatModelConfigs").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetEnabledChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { start := time.Now() r0, r1 := m.s.GetEnabledChatModelConfigsByOrganization(ctx, organizationID) @@ -6840,22 +6840,14 @@ func (m queryMetricsStore) GetAuthorizedChatsByChatFileID(ctx context.Context, f return r0, r1 } -func (m queryMetricsStore) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) { +func (m queryMetricsStore) GetAuthorizedChatModelConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) { start := time.Now() - r0, r1 := m.s.GetAuthorizedChatModelConfigs(ctx, prepared) + r0, r1 := m.s.GetAuthorizedChatModelConfigs(ctx, organizationID, prepared) m.queryLatencies.WithLabelValues("GetAuthorizedChatModelConfigs").Observe(time.Since(start).Seconds()) m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedChatModelConfigs").Inc() return r0, r1 } -func (m queryMetricsStore) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]database.ChatModelConfig, error) { - start := time.Now() - r0, r1 := m.s.GetDefaultChatModelConfigCandidates(ctx) - m.queryLatencies.WithLabelValues("GetDefaultChatModelConfigCandidates").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetDefaultChatModelConfigCandidates").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { start := time.Now() r0, r1 := m.s.GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 856f1ba49a20d..b2f091c868cd8 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2476,18 +2476,18 @@ func (mr *MockStoreMockRecorder) GetAuthorizedAuditLogsOffset(ctx, arg, prepared } // GetAuthorizedChatModelConfigs mocks base method. -func (m *MockStore) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) { +func (m *MockStore) GetAuthorizedChatModelConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAuthorizedChatModelConfigs", ctx, prepared) + ret := m.ctrl.Call(m, "GetAuthorizedChatModelConfigs", ctx, organizationID, prepared) ret0, _ := ret[0].([]database.ChatModelConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAuthorizedChatModelConfigs indicates an expected call of GetAuthorizedChatModelConfigs. -func (mr *MockStoreMockRecorder) GetAuthorizedChatModelConfigs(ctx, prepared any) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAuthorizedChatModelConfigs(ctx, organizationID, prepared any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedChatModelConfigs), ctx, prepared) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedChatModelConfigs), ctx, organizationID, prepared) } // GetAuthorizedChats mocks base method. @@ -3181,18 +3181,33 @@ func (mr *MockStoreMockRecorder) GetChatModelConfigByID(ctx, id any) *gomock.Cal } // GetChatModelConfigs mocks base method. -func (m *MockStore) GetChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) { +func (m *MockStore) GetChatModelConfigs(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetChatModelConfigs", ctx) + ret := m.ctrl.Call(m, "GetChatModelConfigs", ctx, organizationID) ret0, _ := ret[0].([]database.ChatModelConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // GetChatModelConfigs indicates an expected call of GetChatModelConfigs. -func (mr *MockStoreMockRecorder) GetChatModelConfigs(ctx any) *gomock.Call { +func (mr *MockStoreMockRecorder) GetChatModelConfigs(ctx, organizationID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigs), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigs), ctx, organizationID) +} + +// GetChatModelConfigsByOrganization mocks base method. +func (m *MockStore) GetChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatModelConfigsByOrganization", ctx, organizationID) + ret0, _ := ret[0].([]database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatModelConfigsByOrganization indicates an expected call of GetChatModelConfigsByOrganization. +func (mr *MockStoreMockRecorder) GetChatModelConfigsByOrganization(ctx, organizationID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigsByOrganization", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigsByOrganization), ctx, organizationID) } // GetChatModelConfigsForTelemetry mocks base method. @@ -3645,21 +3660,6 @@ func (mr *MockStoreMockRecorder) GetDefaultChatModelConfig(ctx, organizationID a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultChatModelConfig", reflect.TypeOf((*MockStore)(nil).GetDefaultChatModelConfig), ctx, organizationID) } -// GetDefaultChatModelConfigCandidates mocks base method. -func (m *MockStore) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]database.ChatModelConfig, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetDefaultChatModelConfigCandidates", ctx) - ret0, _ := ret[0].([]database.ChatModelConfig) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetDefaultChatModelConfigCandidates indicates an expected call of GetDefaultChatModelConfigCandidates. -func (mr *MockStoreMockRecorder) GetDefaultChatModelConfigCandidates(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultChatModelConfigCandidates", reflect.TypeOf((*MockStore)(nil).GetDefaultChatModelConfigCandidates), ctx) -} - // GetDefaultOrganization mocks base method. func (m *MockStore) GetDefaultOrganization(ctx context.Context) (database.Organization, error) { m.ctrl.T.Helper() @@ -3780,21 +3780,6 @@ func (mr *MockStoreMockRecorder) GetEnabledChatModelConfigByID(ctx, id any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledChatModelConfigByID", reflect.TypeOf((*MockStore)(nil).GetEnabledChatModelConfigByID), ctx, id) } -// GetEnabledChatModelConfigs mocks base method. -func (m *MockStore) GetEnabledChatModelConfigs(ctx context.Context) ([]database.GetEnabledChatModelConfigsRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetEnabledChatModelConfigs", ctx) - ret0, _ := ret[0].([]database.GetEnabledChatModelConfigsRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetEnabledChatModelConfigs indicates an expected call of GetEnabledChatModelConfigs. -func (mr *MockStoreMockRecorder) GetEnabledChatModelConfigs(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetEnabledChatModelConfigs), ctx) -} - // GetEnabledChatModelConfigsByOrganization mocks base method. func (m *MockStore) GetEnabledChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 242f9c33324ee..c369e3f4048a8 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -2234,8 +2234,9 @@ func TestPurgeChatDebugRuns(t *testing.T) { DisplayName: "OpenAI", }) modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - Model: "test-model", - ContextLimit: 8192, + Model: "test-model", + ContextLimit: 8192, + OrganizationID: org.ID, }) return chatDebugDeps{user: user, org: org, modelConfig: modelConfig} } @@ -2460,8 +2461,9 @@ func TestDeleteOldChatFiles(t *testing.T) { DisplayName: "OpenAI", }) mc := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - Model: "test-model", - ContextLimit: 8192, + Model: "test-model", + ContextLimit: 8192, + OrganizationID: org.ID, }) return chatDeps{user: user, org: org, modelConfig: mc} } @@ -3051,8 +3053,9 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { DisplayName: "OpenAI", }) modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - Model: "test-model", - ContextLimit: 8192, + Model: "test-model", + ContextLimit: 8192, + OrganizationID: org.ID, }) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: org.ID, diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 663c0a97e923a..23956a7ab4029 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -58,18 +58,10 @@ type customQuerier interface { } type chatModelConfigQuerier interface { - GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]ChatModelConfig, error) - // GetDefaultChatModelConfigCandidates returns every non-deleted config in - // every organization without an authorization filter. The caller selects - // one organization's promotion candidate from this deployment-wide list. - GetDefaultChatModelConfigCandidates(ctx context.Context) ([]ChatModelConfig, error) + GetAuthorizedChatModelConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]ChatModelConfig, error) } -func (q *sqlQuerier) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]ChatModelConfig, error) { - return q.GetChatModelConfigs(ctx) -} - -func (q *sqlQuerier) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]ChatModelConfig, error) { +func (q *sqlQuerier) GetAuthorizedChatModelConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]ChatModelConfig, error) { authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigChatModelConfigs()) if err != nil { return nil, xerrors.Errorf("compile authorized filter: %w", err) @@ -82,7 +74,7 @@ func (q *sqlQuerier) GetAuthorizedChatModelConfigs(ctx context.Context, prepared // The name comment is for metric tracking query := fmt.Sprintf("-- name: GetAuthorizedChatModelConfigs :many\n%s", filtered) - rows, err := q.db.QueryContext(ctx, query) + rows, err := q.db.QueryContext(ctx, query, organizationID) if err != nil { return nil, err } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 00dd4cc0545e2..167f9210f76d3 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -482,7 +482,12 @@ type sqlcQuerier interface { // results remain after their assistant calls. GetChatMessagesForPromptByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error) - GetChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error) + GetChatModelConfigs(ctx context.Context, organizationID uuid.UUID) ([]ChatModelConfig, error) + // All live configs in one organization, unfiltered. Consumed ONLY by + // ensureDefaultChatModelConfig's default-election read inside the write + // transaction; authorization is the caller's update-in-org check that every + // path reaching the election already requires. No @authorize_filter. + GetChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ChatModelConfig, error) // deleted = false guarantees ai_provider_id is non-null, so INNER JOIN is safe. GetChatModelConfigsForTelemetry(ctx context.Context) ([]GetChatModelConfigsForTelemetryRow, error) // GetChatPersonalModelOverridesEnabled returns whether users may configure @@ -562,7 +567,6 @@ type sqlcQuerier interface { // Providers can be disabled independently of their model configs. // Check both to ensure the selected config is actually usable. GetEnabledChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error) - GetEnabledChatModelConfigs(ctx context.Context) ([]GetEnabledChatModelConfigsRow, error) GetEnabledChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]GetEnabledChatModelConfigsByOrganizationRow, error) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg GetEnabledMCPServerConfigsByOrganizationAndIDsParams) ([]MCPServerConfig, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 6bc0b0eeabd71..2af1b17da1c00 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12483,15 +12483,15 @@ func TestGetEnabledChatModelConfigsUsesAIProviders(t *testing.T) { params.Enabled = false }) - configs, err := store.GetEnabledChatModelConfigs(ctx) + configs, err := store.GetEnabledChatModelConfigsByOrganization(ctx, enabledConfig.OrganizationID) require.NoError(t, err) - require.True(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsRow) bool { + require.True(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsByOrganizationRow) bool { return row.ChatModelConfig.ID == enabledConfig.ID })) - require.False(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsRow) bool { + require.False(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsByOrganizationRow) bool { return row.ChatModelConfig.ID == disabledProviderConfig.ID })) - require.False(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsRow) bool { + require.False(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsByOrganizationRow) bool { return row.ChatModelConfig.ID == disabledModelConfig.ID })) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c3759bea71e3d..bc76ac7179a71 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6200,6 +6200,7 @@ LEFT JOIN ai_providers ap ON ap.id = cmc.ai_provider_id WHERE cmc.deleted = FALSE + AND cmc.organization_id = $1::uuid -- Authorize Filter clause will be injected below in GetAuthorizedChatModelConfigs -- @authorize_filter ORDER BY @@ -6209,8 +6210,68 @@ ORDER BY cmc.id DESC ` -func (q *sqlQuerier) GetChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error) { - rows, err := q.db.QueryContext(ctx, getChatModelConfigs) +func (q *sqlQuerier) GetChatModelConfigs(ctx context.Context, organizationID uuid.UUID) ([]ChatModelConfig, error) { + rows, err := q.db.QueryContext(ctx, getChatModelConfigs, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatModelConfig + for rows.Next() { + var i ChatModelConfig + if err := rows.Scan( + &i.ID, + &i.Model, + &i.DisplayName, + &i.CreatedBy, + &i.UpdatedBy, + &i.Enabled, + &i.IsDefault, + &i.Deleted, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextLimit, + &i.CompressionThreshold, + &i.Options, + &i.AIProviderID, + &i.OrganizationID, + &i.GroupACL, + &i.UserACL, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChatModelConfigsByOrganization = `-- name: GetChatModelConfigsByOrganization :many +SELECT + id, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options, ai_provider_id, organization_id, group_acl, user_acl +FROM + chat_model_configs +WHERE + organization_id = $1::uuid + AND deleted = FALSE +ORDER BY + model ASC, + updated_at DESC, + id DESC +` + +// All live configs in one organization, unfiltered. Consumed ONLY by +// ensureDefaultChatModelConfig's default-election read inside the write +// transaction; authorization is the caller's update-in-org check that every +// path reaching the election already requires. No @authorize_filter. +func (q *sqlQuerier) GetChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ChatModelConfig, error) { + rows, err := q.db.QueryContext(ctx, getChatModelConfigsByOrganization, organizationID) if err != nil { return nil, err } @@ -6331,74 +6392,6 @@ func (q *sqlQuerier) GetEnabledChatModelConfigByID(ctx context.Context, id uuid. return i, err } -const getEnabledChatModelConfigs = `-- name: GetEnabledChatModelConfigs :many -SELECT - cmc.id, cmc.model, cmc.display_name, cmc.created_by, cmc.updated_by, cmc.enabled, cmc.is_default, cmc.deleted, cmc.deleted_at, cmc.created_at, cmc.updated_at, cmc.context_limit, cmc.compression_threshold, cmc.options, cmc.ai_provider_id, cmc.organization_id, cmc.group_acl, cmc.user_acl, - ap.type::text AS provider -FROM - chat_model_configs cmc -JOIN - ai_providers ap ON ap.id = cmc.ai_provider_id -WHERE - cmc.enabled = TRUE - AND cmc.deleted = FALSE - AND ap.enabled = TRUE - AND ap.deleted = FALSE -ORDER BY - ap.type::text ASC, - cmc.model ASC, - cmc.updated_at DESC, - cmc.id DESC -` - -type GetEnabledChatModelConfigsRow struct { - ChatModelConfig ChatModelConfig `db:"chat_model_config" json:"chat_model_config"` - Provider string `db:"provider" json:"provider"` -} - -func (q *sqlQuerier) GetEnabledChatModelConfigs(ctx context.Context) ([]GetEnabledChatModelConfigsRow, error) { - rows, err := q.db.QueryContext(ctx, getEnabledChatModelConfigs) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetEnabledChatModelConfigsRow - for rows.Next() { - var i GetEnabledChatModelConfigsRow - if err := rows.Scan( - &i.ChatModelConfig.ID, - &i.ChatModelConfig.Model, - &i.ChatModelConfig.DisplayName, - &i.ChatModelConfig.CreatedBy, - &i.ChatModelConfig.UpdatedBy, - &i.ChatModelConfig.Enabled, - &i.ChatModelConfig.IsDefault, - &i.ChatModelConfig.Deleted, - &i.ChatModelConfig.DeletedAt, - &i.ChatModelConfig.CreatedAt, - &i.ChatModelConfig.UpdatedAt, - &i.ChatModelConfig.ContextLimit, - &i.ChatModelConfig.CompressionThreshold, - &i.ChatModelConfig.Options, - &i.ChatModelConfig.AIProviderID, - &i.ChatModelConfig.OrganizationID, - &i.ChatModelConfig.GroupACL, - &i.ChatModelConfig.UserACL, - &i.Provider, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getEnabledChatModelConfigsByOrganization = `-- name: GetEnabledChatModelConfigsByOrganization :many SELECT cmc.id, cmc.model, cmc.display_name, cmc.created_by, cmc.updated_by, cmc.enabled, cmc.is_default, cmc.deleted, cmc.deleted_at, cmc.created_at, cmc.updated_at, cmc.context_limit, cmc.compression_threshold, cmc.options, cmc.ai_provider_id, cmc.organization_id, cmc.group_acl, cmc.user_acl, diff --git a/coderd/database/queries/chatmodelconfigs.sql b/coderd/database/queries/chatmodelconfigs.sql index 671440a47be5d..902661648a551 100644 --- a/coderd/database/queries/chatmodelconfigs.sql +++ b/coderd/database/queries/chatmodelconfigs.sql @@ -26,6 +26,7 @@ LEFT JOIN ai_providers ap ON ap.id = cmc.ai_provider_id WHERE cmc.deleted = FALSE + AND cmc.organization_id = @organization_id::uuid -- Authorize Filter clause will be injected below in GetAuthorizedChatModelConfigs -- @authorize_filter ORDER BY @@ -34,24 +35,22 @@ ORDER BY cmc.updated_at DESC, cmc.id DESC; --- name: GetEnabledChatModelConfigs :many +-- name: GetChatModelConfigsByOrganization :many +-- All live configs in one organization, unfiltered. Consumed ONLY by +-- ensureDefaultChatModelConfig's default-election read inside the write +-- transaction; authorization is the caller's update-in-org check that every +-- path reaching the election already requires. No @authorize_filter. SELECT - sqlc.embed(cmc), - ap.type::text AS provider + * FROM - chat_model_configs cmc -JOIN - ai_providers ap ON ap.id = cmc.ai_provider_id + chat_model_configs WHERE - cmc.enabled = TRUE - AND cmc.deleted = FALSE - AND ap.enabled = TRUE - AND ap.deleted = FALSE + organization_id = @organization_id::uuid + AND deleted = FALSE ORDER BY - ap.type::text ASC, - cmc.model ASC, - cmc.updated_at DESC, - cmc.id DESC; + model ASC, + updated_at DESC, + id DESC; -- name: GetEnabledChatModelConfigsByOrganization :many SELECT diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index e450ab3157e30..7c7b3c0fa8b9d 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -675,28 +675,7 @@ func lookupEnabledChatModelConfigByID( db database.Store, id uuid.UUID, ) (database.ChatModelConfig, error) { - //nolint:gocritic // Validation lookup uses AsChatd to check model - // availability independently of the caller's read permissions. - return db.GetEnabledChatModelConfigByID(dbauthz.AsChatd(ctx), id) -} - -func lookupEnabledDefaultOrgChatModelConfigByID( - ctx context.Context, - db database.Store, - id uuid.UUID, -) (database.ChatModelConfig, error) { - modelConfig, err := lookupEnabledChatModelConfigByID(ctx, db, id) - if err != nil { - return database.ChatModelConfig{}, err - } - defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, db) - if err != nil { - return database.ChatModelConfig{}, err - } - if modelConfig.OrganizationID != defaultOrg.ID { - return database.ChatModelConfig{}, sql.ErrNoRows - } - return modelConfig, nil + return db.GetEnabledChatModelConfigByID(ctx, id) } func parseChatModelCallConfig(options json.RawMessage) (*codersdk.ChatModelCallConfig, error) { @@ -749,6 +728,7 @@ func validateChatModelOverrideEffort( func validateChatModelOverride( ctx context.Context, db database.Store, + organizationID uuid.UUID, id *uuid.UUID, effort *string, ) (int, *codersdk.Response) { @@ -765,7 +745,10 @@ func validateChatModelOverride( Message: "Invalid model_config_id.", } } - modelConfig, err := lookupEnabledDefaultOrgChatModelConfigByID(ctx, db, *id) + modelConfig, err := lookupEnabledChatModelConfigByID(ctx, db, *id) + if err == nil && modelConfig.OrganizationID != organizationID { + err = sql.ErrNoRows + } if err != nil { if xerrors.Is(err, sql.ErrNoRows) { return http.StatusBadRequest, &codersdk.Response{ @@ -1010,7 +993,8 @@ func (api *API) chatPersonalModelOverrideDeploymentDefaults( type userChatModelAvailability struct { configuredProviders []chatprovider.ConfiguredProvider configuredModels []chatprovider.ConfiguredModel - enabledModels []database.GetEnabledChatModelConfigsByOrganizationRow + enabledModels []database.ChatModelConfig + providerTypeByID map[uuid.UUID]string providerStatus map[string]chatprovider.ProviderAvailability providerStatusByID map[uuid.UUID]chatprovider.ProviderAvailability enabledProviderNames map[string]struct{} @@ -1030,24 +1014,41 @@ const ( chatModelConfigUnavailableOutsideOrganization chatModelConfigUnavailableReason = "outside_organization" ) +func defaultOrganizationID( + ctx context.Context, + db database.Store, +) (uuid.UUID, error) { + //nolint:gocritic // Runtime configuration must be independent of caller permissions. + organization, err := db.GetDefaultOrganization(dbauthz.AsChatd(ctx)) + if err != nil { + return uuid.Nil, err + } + return organization.ID, nil +} + // getUserChatProviderAvailability returns the enabled chat providers and models -// the user can access. Deployment-level configuration is read as chatd, while -// user key lookups still use the caller's authorization context. +// the user can access in one organization. Provider configuration uses Chatd +// access. Model configs and user keys use the caller's authorization context. func (api *API) getUserChatProviderAvailability( ctx context.Context, userID uuid.UUID, organizationID uuid.UUID, ) (userChatModelAvailability, error) { - //nolint:gocritic // Chatd context is required to read enabled chat config. + //nolint:gocritic // Chatd context is required to read enabled chat providers. chatdCtx := dbauthz.AsChatd(ctx) enabledProviders, err := api.Database.GetAIProviders(chatdCtx, database.GetAIProvidersParams{}) if err != nil { return userChatModelAvailability{}, err } - enabledModels, err := api.Database.GetEnabledChatModelConfigsByOrganization(chatdCtx, organizationID) + modelRows, err := api.Database.GetEnabledChatModelConfigsByOrganization(ctx, organizationID) if err != nil { return userChatModelAvailability{}, err } + effectiveModels := database.DeriveEffectiveChatModelConfigs(modelRows) + enabledModels := make([]database.ChatModelConfig, 0, len(effectiveModels.Configs)) + for _, row := range effectiveModels.Configs { + enabledModels = append(enabledModels, row.ChatModelConfig) + } configuredProviders, err := api.configuredProvidersFromAIProviders(chatdCtx, enabledProviders) if err != nil { @@ -1057,10 +1058,18 @@ func (api *API) getUserChatProviderAvailability( configuredProviders: configuredProviders, configuredModels: make([]chatprovider.ConfiguredModel, 0, len(enabledModels)), enabledModels: enabledModels, + providerTypeByID: make(map[uuid.UUID]string, len(enabledProviders)), enabledProviderNames: make(map[string]struct{}, len(enabledProviders)), enabledProviderIDs: make(map[uuid.UUID]struct{}, len(enabledProviders)), providerStatusByID: make(map[uuid.UUID]chatprovider.ProviderAvailability, len(enabledProviders)), } + // Model configs carry no provider type; resolve it from the enabled + // providers. A config under a disabled or deleted provider is skipped + // for status purposes, mirroring the provider join on the enabled + // models query. + for _, provider := range enabledProviders { + availability.providerTypeByID[provider.ID] = string(provider.Type) + } for _, configuredProvider := range configuredProviders { normalizedProvider := chatprovider.NormalizeProvider(configuredProvider.Provider) if normalizedProvider != "" { @@ -1072,15 +1081,18 @@ func (api *API) getUserChatProviderAvailability( } userKeys := []chatprovider.UserProviderKey{} if api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value() { - userKeyRows, err := api.Database.GetUserAIProviderKeysByUserID(ctx, userID) + userKeyStatus, err := api.userAIProviderKeyStatusByProviderID(ctx, userID) if err != nil { return userChatModelAvailability{}, err } - userKeys = make([]chatprovider.UserProviderKey, 0, len(userKeyRows)) - for _, userKey := range userKeyRows { + userKeys = make([]chatprovider.UserProviderKey, 0, len(userKeyStatus)) + for providerID, configured := range userKeyStatus { + if !configured { + continue + } userKeys = append(userKeys, chatprovider.UserProviderKey{ - ChatProviderID: userKey.AIProviderID, - APIKey: userKey.APIKey, + ChatProviderID: providerID, + APIKey: "configured", }) } } @@ -1120,18 +1132,20 @@ func (api *API) getUserChatProviderAvailability( modelStatusByType := make(map[string]chatprovider.ProviderAvailability, len(enabledModels)) for _, model := range enabledModels { - normalizedProvider := chatprovider.NormalizeProvider(model.Provider) - if normalizedProvider == "" { + if !model.AIProviderID.Valid { continue } - if model.ChatModelConfig.AIProviderID.Valid { - status, ok := availability.providerStatusByID[model.ChatModelConfig.AIProviderID.UUID] - if ok { - mergeProviderStatus(modelStatusByType, normalizedProvider, status) - } + providerID := model.AIProviderID.UUID + providerType, ok := availability.providerTypeByID[providerID] + if !ok { + continue + } + normalizedProvider := chatprovider.NormalizeProvider(providerType) + if normalizedProvider == "" { continue } - if status, ok := providerStatusByType[normalizedProvider]; ok { + status, ok := availability.providerStatusByID[providerID] + if ok { mergeProviderStatus(modelStatusByType, normalizedProvider, status) } } @@ -1141,20 +1155,21 @@ func (api *API) getUserChatProviderAvailability( } for _, model := range enabledModels { - normalizedProvider := chatprovider.NormalizeProvider(model.Provider) - if model.ChatModelConfig.AIProviderID.Valid { - status, ok := availability.providerStatusByID[model.ChatModelConfig.AIProviderID.UUID] - if !ok { - continue - } - if aggregateStatus, ok := availability.providerStatus[normalizedProvider]; ok && aggregateStatus.Available && !status.Available { - continue - } + if !model.AIProviderID.Valid { + continue + } + providerType, ok := availability.providerTypeByID[model.AIProviderID.UUID] + if !ok { + continue + } + status, ok := availability.providerStatusByID[model.AIProviderID.UUID] + if !ok || !status.Available { + continue } availability.configuredModels = append(availability.configuredModels, chatprovider.ConfiguredModel{ - Provider: model.Provider, - Model: model.ChatModelConfig.Model, - DisplayName: model.ChatModelConfig.DisplayName, + Provider: providerType, + Model: model.Model, + DisplayName: model.DisplayName, }) } return availability, nil @@ -1172,11 +1187,7 @@ func (api *API) userCanUseChatModelConfig( if modelConfigID == uuid.Nil { return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil } - //nolint:gocritic // Non-admin users need deployment config validation. - model, err := api.Database.GetChatModelConfigByID( - dbauthz.AsSystemRestricted(ctx), - modelConfigID, - ) + model, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID) if err != nil { if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) { return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil @@ -1214,19 +1225,10 @@ func (api *API) userCanUseChatModelConfig( return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil } -func (api *API) validateUserChatModelConfigAvailable( - ctx context.Context, - userID uuid.UUID, - organizationID uuid.UUID, - modelConfigID uuid.UUID, +func validateUserChatModelConfigAvailability( + modelConfig database.ChatModelConfig, + reason chatModelConfigUnavailableReason, ) (database.ChatModelConfig, int, *codersdk.Response) { - modelConfig, reason, err := api.userCanUseChatModelConfig(ctx, userID, organizationID, modelConfigID) - if err != nil { - return database.ChatModelConfig{}, http.StatusInternalServerError, &codersdk.Response{ - Message: "Internal error validating model config override.", - Detail: err.Error(), - } - } switch reason { case chatModelConfigAvailable: return modelConfig, 0, nil @@ -1244,16 +1246,38 @@ func (api *API) validateUserChatModelConfigAvailable( Message: "Invalid model_config_id: provider is not enabled for this model.", } default: + return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{ + Message: "Invalid model_config_id.", + } + } +} + +func (api *API) validateUserChatModelConfigAvailable( + ctx context.Context, + userID uuid.UUID, + organizationID uuid.UUID, + modelConfigID uuid.UUID, +) (database.ChatModelConfig, int, *codersdk.Response) { + modelConfig, reason, err := api.userCanUseChatModelConfig(ctx, userID, organizationID, modelConfigID) + if err != nil { + return database.ChatModelConfig{}, http.StatusInternalServerError, &codersdk.Response{ + Message: "Internal error validating model config override.", + Detail: err.Error(), + } + } + if reason != chatModelConfigAvailable && + reason != chatModelConfigUnavailableModelNotFoundOrDisabled && + reason != chatModelConfigUnavailableCredentialsMissing && + reason != chatModelConfigUnavailableProviderDisabled && + reason != chatModelConfigUnavailableOutsideOrganization { api.Logger.Warn(ctx, "unknown chat model config availability reason", slog.F("user_id", userID), slog.F("model_config_id", modelConfigID), slog.F("reason", reason), ) - return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{ - Message: "Invalid model_config_id.", - } } + return validateUserChatModelConfigAvailability(modelConfig, reason) } // validateExplicitChatModelConfigAvailable validates a caller-supplied @@ -1599,26 +1623,33 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { // EXPERIMENTAL: this endpoint is experimental and is subject to change. // -// @Summary List chat models -// @ID list-chat-models +// @Summary List available chat models in an organization +// @ID list-chat-model-availability // @Security CoderSessionToken // @Tags Chats // @Produce json +// @Param organization path string true "Organization name or ID" // @Success 200 {object} codersdk.ChatModelAvailabilityResponse -// @Router /api/experimental/chats/models [get] +// @Router /api/experimental/organizations/{organization}/chats/models/available [get] // @Description Experimental: this endpoint is subject to change. -func (api *API) listChatModels(rw http.ResponseWriter, r *http.Request) { +func (api *API) listChatModelAvailability(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - defaultOrganization, err := defaultOrganizationForChatModelConfigs(ctx, api.Database) + if !chatModelConfigReadScope(apiKey.Scopes) { + httpapi.Forbidden(rw) + return + } + organization := httpmw.OrganizationParam(r) + visible, err := api.canReadChatModelsInOrganization(ctx, r, organization) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to load the default organization.", - Detail: err.Error(), - }) + httpapi.InternalServerError(rw, err) + return + } + if !visible { + httpapi.ResourceNotFound(rw) return } - availability, err := api.getUserChatProviderAvailability(ctx, apiKey.UserID, defaultOrganization.ID) + availability, err := api.getUserChatProviderAvailability(ctx, apiKey.UserID, organization.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to load chat model configuration.", @@ -1642,6 +1673,11 @@ func (api *API) listChatModels(rw http.ResponseWriter, r *http.Request) { ) } + response.Models = make([]codersdk.ChatModel, 0, len(availability.enabledModels)) + for _, model := range availability.enabledModels { + response.Models = append(response.Models, convertChatModelConfig(model)) + } + // Both catalog branches drop providers the harness cannot use, so // attach them here for the empty state. response.UnsupportedProviders = chatprovider.UnsupportedProviders(availability.configuredProviders) @@ -2721,6 +2757,17 @@ func writeChatInvalidState(ctx context.Context, rw http.ResponseWriter, err erro return true } +func noLocalChatModelResponse() *codersdk.Response { + return &codersdk.Response{ + Message: "No chat model is available in this organization.", + Detail: "Ask an organization administrator to configure and enable a chat model.", + } +} + +func writeNoLocalChatModelResponse(ctx context.Context, rw http.ResponseWriter) { + httpapi.Write(ctx, rw, http.StatusBadRequest, *noLocalChatModelResponse()) +} + // writeCommonChatMutationError writes responses shared by chat // mutation endpoints. Returns true when a response has been written. func writeCommonChatMutationError(ctx context.Context, rw http.ResponseWriter, err error, archivedMessage string) bool { @@ -2926,9 +2973,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { return } if xerrors.Is(sendErr, chatd.ErrNoDefaultChatModelConfig) { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "No default chat model config is configured.", - }) + writeNoLocalChatModelResponse(ctx, rw) return } if errors.Is(sendErr, chatstate.ErrChatNotFound) { @@ -3091,9 +3136,7 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { Message: "Invalid model config ID.", }) case xerrors.Is(editErr, chatd.ErrNoDefaultChatModelConfig): - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "No default chat model config is configured.", - }) + writeNoLocalChatModelResponse(ctx, rw) case errors.Is(editErr, chatstate.ErrChatNotFound): httpapi.ResourceNotFound(rw) case writeChatInvalidState(ctx, rw, editErr): @@ -3240,6 +3283,8 @@ func (api *API) promoteChatQueuedMessage(rw http.ResponseWriter, r *http.Request httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ Message: "Queued message not found.", }) + case xerrors.Is(txErr, chatd.ErrNoDefaultChatModelConfig): + writeNoLocalChatModelResponse(ctx, rw) case errors.Is(txErr, chatstate.ErrChatNotFound): httpapi.ResourceNotFound(rw) case writeChatInvalidState(ctx, rw, txErr): @@ -3652,9 +3697,7 @@ func (api *API) regenerateChatTitle(rw http.ResponseWriter, r *http.Request) { updatedChat, err := api.chatDaemon.RegenerateChatTitle(ctx, chat) if err != nil { if errors.Is(err, chatd.ErrNoDefaultChatModelConfig) { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "No default chat model config is configured.", - }) + writeNoLocalChatModelResponse(ctx, rw) return } if httpapi.Is404Error(err) { @@ -3701,9 +3744,7 @@ func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) { title, err := api.chatDaemon.ProposeChatTitle(ctx, chat) if err != nil { if errors.Is(err, chatd.ErrNoDefaultChatModelConfig) { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "No default chat model config is configured.", - }) + writeNoLocalChatModelResponse(ctx, rw) return } if httpapi.Is404Error(err) { @@ -4439,20 +4480,7 @@ func (api *API) resolveCreateChatModelConfigID( Message: "Invalid model config ID.", } } - _, reason, err := api.userCanUseChatModelConfig(ctx, userID, req.OrganizationID, *req.ModelConfigID) - if err != nil { - return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{ - Message: "Failed to resolve chat model config.", - Detail: err.Error(), - } - } - if reason == chatModelConfigUnavailableOutsideOrganization { - return uuid.Nil, nil, http.StatusBadRequest, &codersdk.Response{ - Message: "Model config does not belong to the specified organization.", - } - } - if reason != chatModelConfigAvailable { - _, status, resp := api.validateUserChatModelConfigAvailable(ctx, userID, req.OrganizationID, *req.ModelConfigID) + if _, status, resp := api.validateUserChatModelConfigAvailable(ctx, userID, req.OrganizationID, *req.ModelConfigID); resp != nil { return uuid.Nil, nil, status, resp } return *req.ModelConfigID, nil, 0, nil @@ -4533,60 +4561,23 @@ func (api *API) resolveCreateChatModelConfigID( return id, nil, status, resp } -// defaultOrganizationForChatModelConfigs resolves the organization used by -// deployment-wide model configuration routes and overrides. -func defaultOrganizationForChatModelConfigs( - ctx context.Context, - db database.Store, -) (database.Organization, error) { - //nolint:gocritic // Deployment-wide routes need internal default organization metadata. - defaultOrg, err := db.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx)) - if err != nil { - return database.Organization{}, xerrors.Errorf("get default organization: %w", err) - } - return defaultOrg, nil -} - func (api *API) defaultCreateChatModelConfigID( ctx context.Context, organizationID uuid.UUID, ) (uuid.UUID, int, *codersdk.Response) { - defaultModelConfig, err := api.Database.GetDefaultChatModelConfig(ctx, organizationID) + rows, err := api.Database.GetEnabledChatModelConfigsByOrganization(ctx, organizationID) if err != nil { - if xerrors.Is(err, sql.ErrNoRows) { - return uuid.Nil, http.StatusBadRequest, &codersdk.Response{ - Message: "No default chat model config is configured.", - } - } return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{ Message: "Failed to resolve chat model config.", Detail: err.Error(), } } - - // The resolved default may itself be disabled or under a disabled - // provider. - enabledDefault, err := lookupEnabledChatModelConfigByID(ctx, api.Database, defaultModelConfig.ID) - if err != nil { - if xerrors.Is(err, sql.ErrNoRows) { - return uuid.Nil, http.StatusBadRequest, &codersdk.Response{ - Message: "No default chat model config is configured.", - Detail: "The default chat model or its provider is disabled.", - } - } - return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{ - Message: "Failed to resolve chat model config.", - Detail: err.Error(), - } + effective := database.DeriveEffectiveChatModelConfigs(rows) + if effective.DefaultConfig.ID != uuid.Nil { + return effective.DefaultConfig.ID, 0, nil } - if enabledDefault.OrganizationID != organizationID { - return uuid.Nil, http.StatusBadRequest, &codersdk.Response{ - Message: "No default chat model config is configured.", - } - } - - return defaultModelConfig.ID, 0, nil + return uuid.Nil, http.StatusBadRequest, noLocalChatModelResponse() } // validateChatCompressionThreshold enforces the chat_model_configs CHECK @@ -5084,7 +5075,15 @@ func (api *API) putChatModelOverride(rw http.ResponseWriter, r *http.Request) { modelConfigID = &parsedModelConfigID } - status, resp := validateChatModelOverride(ctx, api.Database, modelConfigID, req.ReasoningEffort) + defaultOrgID, err := defaultOrganizationID(ctx, api.Database) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error validating model config override.", + Detail: err.Error(), + }) + return + } + status, resp := validateChatModelOverride(ctx, api.Database, defaultOrgID, modelConfigID, req.ReasoningEffort) if resp != nil { httpapi.Write(ctx, rw, status, *resp) return @@ -5324,7 +5323,7 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http }) return } - defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, api.Database) + defaultOrgID, err := defaultOrganizationID(ctx, api.Database) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to resolve default organization.", @@ -5335,7 +5334,7 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http modelConfig, status, resp := api.validateUserChatModelConfigAvailable( ctx, apiKey.UserID, - defaultOrg.ID, + defaultOrgID, parsedModelConfigID, ) if resp != nil { @@ -5619,7 +5618,18 @@ func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) { return } } else { + defaultOrgID, err := defaultOrganizationID(ctx, api.Database) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error validating advisor model config.", + Detail: err.Error(), + }) + return + } modelConfig, err := lookupEnabledChatModelConfigByID(ctx, api.Database, req.ModelConfigID) + if err == nil && modelConfig.OrganizationID != defaultOrgID { + err = sql.ErrNoRows + } if err != nil { if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -6074,10 +6084,8 @@ func (api *API) putUserChatCompactionThreshold(rw http.ResponseWriter, r *http.R return } - // Use system context because GetChatModelConfigByID requires - // deployment-config read access, which non-admin users lack. - // The user is only checking if the model exists and is enabled - // before writing their own personal preference. + // The preference is personal, so model existence checks must not depend on + // whether the user can read the organization model configuration. //nolint:gocritic // Non-admin users need this lookup to save their own setting. modelConfig, err := api.Database.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), modelConfigID) if err != nil { @@ -6849,6 +6857,22 @@ func (api *API) deleteUserAIProviderKey(rw http.ResponseWriter, r *http.Request) httpapi.Write(ctx, rw, http.StatusNoContent, nil) } +// userAIProviderKeyStatusByProviderID returns only credential presence for the +// requesting user. The privileged lookup is contained here so callers never +// receive key material or require user:read_personal. +func (api *API) userAIProviderKeyStatusByProviderID(ctx context.Context, userID uuid.UUID) (map[uuid.UUID]bool, error) { + //nolint:gocritic // The result is reduced to credential presence for exactly the requesting user. + rows, err := api.Database.GetUserAIProviderKeysByUserID(dbauthz.AsChatd(ctx), userID) + if err != nil { + return nil, err + } + statusByProviderID := make(map[uuid.UUID]bool, len(rows)) + for _, row := range rows { + statusByProviderID[row.AIProviderID] = strings.TrimSpace(row.APIKey) != "" + } + return statusByProviderID, nil +} + func (api *API) configuredProvidersFromAIProviders(ctx context.Context, providers []database.AIProvider) ([]chatprovider.ConfiguredProvider, error) { if len(providers) == 0 { return nil, nil @@ -6926,8 +6950,9 @@ func (*API) deleteUserChatProviderKey(rw http.ResponseWriter, r *http.Request) { writeLegacyChatProviderGone(rw, r) } -func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) { +func (api *API) listDefaultOrganizationChatModelConfigs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + organization := httpmw.OrganizationParam(r) apiKey := httpmw.APIKey(r) if !chatModelConfigReadScope(apiKey.Scopes) { @@ -6935,16 +6960,53 @@ func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) { return } - defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, api.Database) + configs, err := api.Database.GetChatModelConfigs(ctx, organization.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to resolve default organization.", + Message: "Failed to list chat model configs.", Detail: err.Error(), }) return } - allConfigs, err := api.Database.GetChatModelConfigs(ctx) + resp := make([]codersdk.ChatModel, 0, len(configs)) + for _, config := range configs { + resp = append(resp, convertChatModelConfig(config)) + } + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + +// @Summary List AI models and provider descriptors in an organization +// @ID list-ai-models-by-organization +// @Security CoderSessionToken +// @Tags Chats +// @Produce json +// @Param organization path string true "Organization name or ID" +// @Success 200 {object} codersdk.OrganizationChatModelsResponse +// @Router /api/experimental/organizations/{organization}/chats/models [get] +// @x-apidocgen {"skip": true} +func (api *API) listChatModelConfigsByOrganization(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + organization := httpmw.OrganizationParam(r) + apiKey := httpmw.APIKey(r) + + // Keep the token scope gate separate from the authorized database filter. + // The filter applies each config ACL and the organization predicate. + if !chatModelConfigReadScope(apiKey.Scopes) { + httpapi.Forbidden(rw) + return + } + visible, err := api.canReadChatModelsInOrganization(ctx, r, organization) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + if !visible { + httpapi.ResourceNotFound(rw) + return + } + + configs, err := api.Database.GetChatModelConfigs(ctx, organization.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to list chat model configs.", @@ -6953,21 +7015,140 @@ func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) { return } - resp := make([]codersdk.ChatModel, 0, len(allConfigs)) - for _, config := range allConfigs { - if config.OrganizationID == defaultOrg.ID { - resp = append(resp, convertChatModelConfig(config)) - } + providers, err := api.chatModelProviderDescriptors(ctx, apiKey.UserID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to list AI providers.", + Detail: err.Error(), + }) + return + } + + resp := codersdk.OrganizationChatModelsResponse{ + Models: make([]codersdk.ChatModel, 0, len(configs)), + Providers: providers, + } + for _, config := range configs { + resp.Models = append(resp.Models, convertChatModelConfig(config)) } httpapi.Write(ctx, rw, http.StatusOK, resp) } +func (api *API) canReadChatModelsInOrganization( + ctx context.Context, + r *http.Request, + organization database.Organization, +) (bool, error) { + configs, err := api.Database.GetChatModelConfigs(ctx, organization.ID) + if err != nil { + return false, err + } + for _, config := range configs { + if api.Authorize(r, policy.ActionRead, chatModelConfigRBACObject(config)) { + return true, nil + } + } + return api.Authorize(r, policy.ActionRead, organization.RBACObject()), nil +} + func chatModelConfigReadScope(scopes database.APIKeyScopes) bool { return scopes.Has(database.ApiKeyScopeCoderAll) || scopes.Has(database.ApiKeyScopeChatModelConfigRead) } +// chatModelProviderDescriptors assembles the redacted provider descriptors +// for the org model collection. The caller already passed the org's model +// read gate; providers are deployment-scoped and an org admin cannot read +// them directly, so the fetch runs under a narrow AsChatd context scoped to +// exactly these two reads and the result is projected to the fixed redacted +// fields (no key material, base URLs, or headers). Disclosure matches what +// /api/experimental/chats/models already shows any authenticated caller. +func (api *API) chatModelProviderDescriptors( + ctx context.Context, + userID uuid.UUID, +) ([]codersdk.ChatModelProviderDescriptor, error) { + //nolint:gocritic // Fixed redacted projection under the model read gate; see function doc. + providers, err := api.Database.GetAIProviders(dbauthz.AsChatd(ctx), database.GetAIProvidersParams{}) + if err != nil { + return nil, err + } + + //nolint:gocritic // Key presence is boolean metadata, not key material; same redaction as the deployment endpoint. + keysByProvider, err := loadAIProviderKeysByProvider(dbauthz.AsChatd(ctx), api.Database) + if err != nil { + return nil, err + } + + userKeyStatus := make(map[uuid.UUID]bool) + if api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value() { + userKeyStatus, err = api.userAIProviderKeyStatusByProviderID(ctx, userID) + if err != nil { + return nil, err + } + } + + out := make([]codersdk.ChatModelProviderDescriptor, 0, len(providers)) + for _, provider := range providers { + display := provider.Name + if provider.DisplayName.Valid && provider.DisplayName.String != "" { + display = provider.DisplayName.String + } + hasKey := false + for _, key := range keysByProvider[provider.ID] { + if key.APIKey != "" { + hasKey = true + break + } + } + hasUserKey := userKeyStatus[provider.ID] + out = append(out, codersdk.ChatModelProviderDescriptor{ + ID: provider.ID, + Type: string(provider.Type), + DisplayName: display, + Icon: provider.Icon, + Enabled: provider.Enabled, + HasAPIKey: hasKey, + HasUserAPIKey: hasUserKey, + HasEffectiveAPIKey: hasKey || hasUserKey || provider.Type == database.AIProviderTypeBedrock, + AllowUserAPIKey: api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value(), + }) + } + return out, nil +} + +func chatModelConfigRBACObject(config database.ChatModelConfig) rbac.Object { + return rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) +} + +// getChatModelConfig returns one chat model config after the organization and +// model identities have been resolved by route middleware. +// @Summary Get an AI model +// @ID get-ai-model +// @Security CoderSessionToken +// @Tags Chats +// @Produce json +// @Param organization path string true "Organization name or ID" +// @Param model path string true "Model ID" +// @Success 200 {object} codersdk.ChatModel +// @Router /api/experimental/organizations/{organization}/chats/models/{model} [get] +// @x-apidocgen {"skip": true} +// +//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. +func (api *API) getChatModelConfig(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + config := httpmw.ChatModelConfigParam(r) + if !api.Authorize(r, policy.ActionRead, chatModelConfigRBACObject(config)) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusOK, convertChatModelConfig(config)) +} + type chatModelConfigProviderModelError struct { Response codersdk.Response } @@ -7014,43 +7195,42 @@ func (api *API) inChatModelConfigWriteTx( }, &database.TxOptions{Isolation: sql.LevelReadCommitted}) } +// @Summary Create an AI model in an organization +// @ID create-ai-model +// @Security CoderSessionToken +// @Tags Chats +// @Accept json +// @Produce json +// @Param organization path string true "Organization name or ID" +// @Param request body codersdk.CreateChatModelRequest true "Model" +// @Success 201 {object} codersdk.ChatModel +// @Router /api/experimental/organizations/{organization}/chats/models [post] +// @x-apidocgen {"skip": true} func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - // Interim write gate: see the dbauthz InsertChatModelConfig check. - // TODO(mafredri): remove after CODAGT-709 M3 (org-scoping cutover) - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { - httpapi.Forbidden(rw) - return - } + organization := httpmw.OrganizationParam(r) - var req codersdk.CreateChatModelRequest - if !httpapi.Read(ctx, rw, r, &req) { - return + var req struct { + codersdk.CreateChatModelRequest + GroupACL json.RawMessage `json:"group_acl"` + UserACL json.RawMessage `json:"user_acl"` } - - if req.AIProviderID == nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "AI provider ID is required."}) + if !httpapi.Read(ctx, rw, r, &req) { return } - //nolint:gocritic // The route already authorized chat model config updates. - aiProvider, err := api.Database.GetAIProviderByID(dbauthz.AsChatd(ctx), *req.AIProviderID) - if err != nil { - if httpapi.Is404Error(err) { - writeChatProviderPreconditionError(ctx, rw, errChatProviderMissing) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get AI provider.", - Detail: err.Error(), + if req.GroupACL != nil || req.UserACL != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Model ACLs cannot be set here. Use the nested /acl endpoint after creating the model.", }) return } - if !aiProvider.Enabled { - writeChatProviderPreconditionError(ctx, rw, errChatProviderDisabled) + + if req.AIProviderID == nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "AI provider ID is required."}) return } - aiProviderID := uuid.NullUUID{UUID: aiProvider.ID, Valid: true} + aiProviderID := uuid.NullUUID{UUID: *req.AIProviderID, Valid: true} model := strings.TrimSpace(req.Model) if model == "" { @@ -7060,11 +7240,6 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } - if validationErr := validateChatModelConfigProviderModel(aiProvider, model); validationErr != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, validationErr.Response) - return - } - enabled := true if req.Enabled != nil { enabled = *req.Enabled @@ -7104,16 +7279,11 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } - defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, api.Database) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to resolve default organization.", - Detail: err.Error(), - }) - return - } + // Seed the everyone-in-org read entry so the config stays visible to + // members once ACLs are enforced; the Everyone group shares the + // organization's ID. everyoneReadACL := database.ChatACL{ - defaultOrg.ID.String(): {Permissions: []policy.Action{policy.ActionRead}}, + organization.ID.String(): {Permissions: []policy.Action{policy.ActionRead}}, } insertParams := database.InsertChatModelConfigParams{ @@ -7127,42 +7297,21 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { AIProviderID: aiProviderID, CreatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil}, UpdatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil}, - OrganizationID: defaultOrg.ID, + OrganizationID: organization.ID, GroupACL: everyoneReadACL, UserACL: database.ChatACL{}, } var inserted database.ChatModelConfig - err = api.inChatModelConfigWriteTx(ctx, insertParams.OrganizationID, func(tx database.Store) error { - //nolint:gocritic // The route already authorized chat model config updates. - lockedAIProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), insertParams.AIProviderID.UUID) - if err != nil { - if xerrors.Is(err, sql.ErrNoRows) { - return errChatProviderMissing - } - return xerrors.Errorf("get AI provider for create: %w", err) - } - if !lockedAIProvider.Enabled { - return errChatProviderDisabled - } - if err := validateChatModelConfigProviderModel(lockedAIProvider, insertParams.Model); err != nil { - return err + err := api.inChatModelConfigWriteTx(ctx, insertParams.OrganizationID, func(tx database.Store) error { + _, err := tx.GetDefaultChatModelConfig(ctx, insertParams.OrganizationID) + defaultExists := err == nil + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("get default model config: %w", err) } + insertAsDefault := isDefault || !defaultExists - insertAsDefault := isDefault - if !insertAsDefault { - _, err := tx.GetDefaultChatModelConfig(ctx, insertParams.OrganizationID) - switch { - case err == nil: - // A default already exists. - case xerrors.Is(err, sql.ErrNoRows): - insertAsDefault = true - default: - return xerrors.Errorf("get default model config: %w", err) - } - } - - if insertAsDefault { + if insertAsDefault && defaultExists { if err := tx.UnsetDefaultChatModelConfigs(ctx, insertParams.OrganizationID); err != nil { return xerrors.Errorf("unset default model configs: %w", err) } @@ -7171,10 +7320,28 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { config, err := tx.InsertChatModelConfig(ctx, insertParams) if err != nil { + if database.IsForeignKeyViolation(err, database.ForeignKeyChatModelConfigsAIProviderID) { + return errChatProviderMissing + } return err } inserted = config + //nolint:gocritic // The provider fetch only reads the redacted descriptor fields. + lockedAIProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), insertParams.AIProviderID.UUID) + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return errChatProviderMissing + } + return xerrors.Errorf("get AI provider for create: %w", err) + } + if !lockedAIProvider.Enabled { + return errChatProviderDisabled + } + if err := validateChatModelConfigProviderModel(lockedAIProvider, insertParams.Model); err != nil { + return err + } + if err := ensureDefaultChatModelConfig(ctx, tx, insertParams.OrganizationID); err != nil { return err } @@ -7201,6 +7368,11 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { Detail: err.Error(), }) return + case dbauthz.IsNotAuthorizedError(err): + // The dbauthz create-in-org check is the write access boundary; + // surface its denial as 403, not a concealed 404 or a 500. + httpapi.Forbidden(rw) + return default: httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to create chat model config.", @@ -7215,50 +7387,41 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusCreated, convertChatModelConfig(inserted)) } +// @Summary Update an AI model +// @ID update-ai-model +// @Security CoderSessionToken +// @Tags Chats +// @Accept json +// @Produce json +// @Param organization path string true "Organization name or ID" +// @Param model path string true "Model ID" +// @Param request body codersdk.UpdateChatModelRequest true "Model updates" +// @Success 200 {object} codersdk.ChatModel +// @Router /api/experimental/organizations/{organization}/chats/models/{model} [patch] +// @x-apidocgen {"skip": true} func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - // Interim write gate: see createChatModelConfig. - // TODO(mafredri): remove after CODAGT-709 M3 (org-scoping cutover) - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { - httpapi.Forbidden(rw) + existing := httpmw.ChatModelConfigParam(r) + if !api.Authorize(r, policy.ActionUpdate, chatModelConfigRBACObject(existing)) { + httpapi.ResourceNotFound(rw) return } - modelConfigID, ok := parseChatModelConfigID(rw, r) - if !ok { - return + var req struct { + codersdk.UpdateChatModelRequest + GroupACL json.RawMessage `json:"group_acl"` + UserACL json.RawMessage `json:"user_acl"` } - - existing, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID) - if err != nil { - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get chat model config.", - Detail: err.Error(), - }) + if !httpapi.Read(ctx, rw, r, &req) { return } - defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, api.Database) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to resolve default organization.", - Detail: err.Error(), + if req.GroupACL != nil || req.UserACL != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Model ACLs cannot be updated here. Use the nested /acl endpoint.", }) return } - if existing.OrganizationID != defaultOrg.ID { - httpapi.ResourceNotFound(rw) - return - } - - var req codersdk.UpdateChatModelRequest - if !httpapi.Read(ctx, rw, r, &req) { - return - } if req.ContextLimit != nil && *req.ContextLimit <= 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -7295,11 +7458,12 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { } var updated database.ChatModelConfig - err = api.inChatModelConfigWriteTx(ctx, existing.OrganizationID, func(tx database.Store) error { - // The unlocked read above only rejects unknown IDs; a concurrent writer - // can change or delete the row between the two reads, so merge against - // this copy. - lockedExisting, err := tx.GetChatModelConfigByID(ctx, modelConfigID) + err := api.inChatModelConfigWriteTx(ctx, existing.OrganizationID, func(tx database.Store) error { + // The middleware lookup above only rejects unknown IDs; a concurrent + // writer can change or delete the row between the two reads, so merge + // against this copy. + //nolint:gocritic // The model update below reauthorizes the locked row for update. + lockedExisting, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), existing.ID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { return errChatModelConfigNotFound @@ -7357,7 +7521,7 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { // invalidate the stored provider/model pair. revalidateProviderModel := updateParams.AIProviderID.Valid && (req.AIProviderID != nil || strings.TrimSpace(req.Model) != "") if revalidateProviderModel { - //nolint:gocritic // The route already authorized chat model config updates. + //nolint:gocritic // The provider fetch only reads the redacted descriptor fields. aiProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), updateParams.AIProviderID.UUID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { @@ -7402,7 +7566,8 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { return err } - refreshedConfig, err := tx.GetChatModelConfigByID(ctx, lockedExisting.ID) + //nolint:gocritic // The update above reauthorized the locked row. + refreshedConfig, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), lockedExisting.ID) if err != nil { return xerrors.Errorf("refresh updated chat model config: %w", err) } @@ -7424,7 +7589,12 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { Detail: err.Error(), }) return - case xerrors.Is(err, errChatModelConfigNotFound): + case dbauthz.IsNotAuthorizedError(err): + // The dbauthz object update check is the write access boundary; + // surface its denial as 403, not a concealed 404 or a 500. + httpapi.Forbidden(rw) + return + case xerrors.Is(err, errChatModelConfigNotFound), httpapi.Is404Error(err): httpapi.ResourceNotFound(rw) return default: @@ -7441,47 +7611,25 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, convertChatModelConfig(updated)) } +// @Summary Delete an AI model +// @ID delete-ai-model +// @Security CoderSessionToken +// @Tags Chats +// @Param organization path string true "Organization name or ID" +// @Param model path string true "Model ID" +// @Success 204 +// @Router /api/experimental/organizations/{organization}/chats/models/{model} [delete] +// @x-apidocgen {"skip": true} func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Interim write gate: see createChatModelConfig. - // TODO(mafredri): remove after CODAGT-709 M3 (org-scoping cutover) - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { - httpapi.Forbidden(rw) - return - } - - modelConfigID, ok := parseChatModelConfigID(rw, r) - if !ok { - return - } - - existing, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID) - if err != nil { - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get chat model config.", - Detail: err.Error(), - }) - return - } - defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, api.Database) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to resolve default organization.", - Detail: err.Error(), - }) - return - } - if existing.OrganizationID != defaultOrg.ID { + existing := httpmw.ChatModelConfigParam(r) + if !api.Authorize(r, policy.ActionDelete, chatModelConfigRBACObject(existing)) { httpapi.ResourceNotFound(rw) return } if err := api.inChatModelConfigWriteTx(ctx, existing.OrganizationID, func(tx database.Store) error { - if _, err := tx.DeleteChatModelConfigByID(ctx, modelConfigID); err != nil { + if _, err := tx.DeleteChatModelConfigByID(ctx, existing.ID); err != nil { if xerrors.Is(err, sql.ErrNoRows) { return errChatModelConfigNotFound } @@ -7489,7 +7637,13 @@ func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) { } return ensureDefaultChatModelConfig(ctx, tx, existing.OrganizationID) }); err != nil { - if xerrors.Is(err, errChatModelConfigNotFound) { + if dbauthz.IsNotAuthorizedError(err) { + // The dbauthz object delete check is the write access boundary; + // surface its denial as 403, not a concealed 404 or a 500. + httpapi.Forbidden(rw) + return + } + if xerrors.Is(err, errChatModelConfigNotFound) || httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } @@ -7500,7 +7654,7 @@ func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } - publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, modelConfigID) + publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, existing.ID) rw.WriteHeader(http.StatusNoContent) } @@ -7513,7 +7667,8 @@ func ensureDefaultChatModelConfig( organizationID uuid.UUID, excludedConfigIDs ...uuid.UUID, ) error { - _, err := tx.GetDefaultChatModelConfig(ctx, organizationID) + //nolint:gocritic // Default election is contained by action-authorized writes. + _, err := tx.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), organizationID) switch { case err == nil: return nil @@ -7521,16 +7676,10 @@ func ensureDefaultChatModelConfig( return xerrors.Errorf("get default model config: %w", err) } - modelConfigs, err := tx.GetDefaultChatModelConfigCandidates(ctx) + orgModelConfigs, err := tx.GetChatModelConfigsByOrganization(ctx, organizationID) if err != nil { return xerrors.Errorf("list default chat model config candidates: %w", err) } - orgModelConfigs := make([]database.ChatModelConfig, 0, len(modelConfigs)) - for _, config := range modelConfigs { - if config.OrganizationID == organizationID { - orgModelConfigs = append(orgModelConfigs, config) - } - } if len(orgModelConfigs) == 0 { return nil } @@ -7539,8 +7688,7 @@ func ensureDefaultChatModelConfig( // enabled provider) so the promoted default does not reject // omitted-model chat creation. Fall back to any non-excluded config // when no usable candidate exists. - //nolint:gocritic // Candidate usability depends on deployment-wide provider state, not the caller's permissions. - enabledRows, err := tx.GetEnabledChatModelConfigsByOrganization(dbauthz.AsChatd(ctx), organizationID) + enabledRows, err := tx.GetEnabledChatModelConfigsByOrganization(ctx, organizationID) if err != nil { return xerrors.Errorf("list enabled chat model configs: %w", err) } @@ -7634,6 +7782,7 @@ func convertChatModelConfig(config database.ChatModelConfig) codersdk.ChatModel // chat_model_configs_ai_provider_required_when_active). return codersdk.ChatModel{ ID: config.ID, + OrganizationID: config.OrganizationID, AIProviderID: config.AIProviderID.UUID, Model: config.Model, DisplayName: config.DisplayName, diff --git a/coderd/exp_chats_model_acl.go b/coderd/exp_chats_model_acl.go new file mode 100644 index 0000000000000..de1922d41799b --- /dev/null +++ b/coderd/exp_chats_model_acl.go @@ -0,0 +1,299 @@ +package coderd + +import ( + "context" + "database/sql" + "errors" + "fmt" + "maps" + "net/http" + "slices" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/pubsub" + "github.com/coder/coder/v2/coderd/rbac/acl" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" +) + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +// @Summary Get an AI model ACL +// @ID get-ai-model-acl +// @Security CoderSessionToken +// @Tags Chats +// @Produce json +// @Param organization path string true "Organization name or ID" +// @Param model path string true "Model ID" format(uuid) +// @Success 200 {object} codersdk.ChatModelACL +// @Router /api/experimental/organizations/{organization}/chats/models/{model}/acl [get] +// @x-apidocgen {"skip": true} +func (api *API) chatModelConfigACLHandler(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + config := httpmw.ChatModelConfigParam(r) + if !api.Authorize(r, policy.ActionShare, chatModelConfigRBACObject(config)) { + httpapi.ResourceNotFound(rw) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, chatModelConfigACL(config)) +} + +type chatModelACLValidationError struct { + Validations []codersdk.ValidationError +} + +func (*chatModelACLValidationError) Error() string { + return "invalid chat model ACL" +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +// @Summary Update an AI model ACL +// @ID update-ai-model-acl +// @Security CoderSessionToken +// @Tags Chats +// @Accept json +// @Param organization path string true "Organization name or ID" +// @Param model path string true "Model ID" format(uuid) +// @Param request body codersdk.UpdateChatModelACLRequest true "Sparse model ACL update" +// @Success 204 +// @Router /api/experimental/organizations/{organization}/chats/models/{model}/acl [patch] +// @x-apidocgen {"skip": true} +func (api *API) updateChatModelConfigACL(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + config := httpmw.ChatModelConfigParam(r) + if !api.Authorize(r, policy.ActionShare, chatModelConfigRBACObject(config)) { + httpapi.ResourceNotFound(rw) + return + } + + auditor := api.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.ChatModelConfig](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + OrganizationID: config.OrganizationID, + }) + defer commitAudit() + aReq.Old = config + + var req codersdk.UpdateChatModelACLRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + userRoles, validations := canonicalChatModelACLRoles("user_roles", req.UserRoles) + groupRoles, duplicateErrors := canonicalChatModelACLRoles("group_roles", req.GroupRoles) + validations = append(validations, duplicateErrors...) + if len(validations) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request to update chat model ACL.", + Validations: validations, + }) + return + } + + var updated database.ChatModelConfig + err := api.inChatModelConfigWriteTx(ctx, config.OrganizationID, func(tx database.Store) error { + //nolint:gocritic // The ACL write below reauthorizes the locked row for share. + current, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), config.ID) + if err != nil { + return xerrors.Errorf("get chat model config for ACL update: %w", err) + } + aReq.Old = current + + validations := acl.Validate(ctx, tx, ChatModelACLUpdateValidator(req)) + validations = append(validations, validateChatModelACLOrganization( + ctx, + tx, + config.OrganizationID, + userRoles, + groupRoles, + )...) + if len(validations) > 0 { + return &chatModelACLValidationError{Validations: validations} + } + + userACL := maps.Clone(current.UserACL) + if userACL == nil { + userACL = database.ChatACL{} + } + groupACL := maps.Clone(current.GroupACL) + if groupACL == nil { + groupACL = database.ChatACL{} + } + applyChatModelACLRoles(userACL, userRoles) + applyChatModelACLRoles(groupACL, groupRoles) + + updated, err = tx.UpdateChatModelConfigACLByID(ctx, database.UpdateChatModelConfigACLByIDParams{ + ID: config.ID, + UserACL: userACL, + GroupACL: groupACL, + UpdatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil}, + }) + return err + }) + if err != nil { + var validationErr *chatModelACLValidationError + switch { + case errors.As(err, &validationErr): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request to update chat model ACL.", + Validations: validationErr.Validations, + }) + case dbauthz.IsNotAuthorizedError(err): + httpapi.Forbidden(rw) + case xerrors.Is(err, sql.ErrNoRows), xerrors.Is(err, errChatModelConfigNotFound), httpapi.Is404Error(err): + httpapi.ResourceNotFound(rw) + default: + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update chat model ACL.", + Detail: err.Error(), + }) + } + return + } + + aReq.New = updated + publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, updated.ID) + rw.WriteHeader(http.StatusNoContent) +} + +func chatModelConfigACL(config database.ChatModelConfig) codersdk.ChatModelACL { + return codersdk.ChatModelACL{ + UserRoles: chatModelACLRoles(config.UserACL), + GroupRoles: chatModelACLRoles(config.GroupACL), + } +} + +func chatModelACLRoles(entries database.ChatACL) map[string]codersdk.ChatRole { + roles := make(map[string]codersdk.ChatRole, len(entries)) + for id, entry := range entries { + roles[id] = convertToChatRole(entry.Permissions) + } + return roles +} + +func applyChatModelACLRoles(entries database.ChatACL, roles map[string]codersdk.ChatRole) { + for id, role := range roles { + if role == codersdk.ChatRoleDeleted { + delete(entries, id) + continue + } + entries[id] = database.ChatACLEntry{Permissions: db2sdk.ChatRoleActions(role)} + } +} + +func canonicalChatModelACLRoles(field string, roles map[string]codersdk.ChatRole) (map[string]codersdk.ChatRole, []codersdk.ValidationError) { + canonical := make(map[string]codersdk.ChatRole, len(roles)) + var validations []codersdk.ValidationError + for rawID, role := range roles { + parsed, err := uuid.Parse(rawID) + if err != nil { + continue + } + id := parsed.String() + if _, ok := canonical[id]; ok { + validations = append(validations, codersdk.ValidationError{ + Field: field, + Detail: fmt.Sprintf("duplicate entries for ID %s", id), + }) + continue + } + canonical[id] = role + } + return canonical, validations +} + +func validateChatModelACLOrganization( + ctx context.Context, + db database.Store, + organizationID uuid.UUID, + userRoles map[string]codersdk.ChatRole, + groupRoles map[string]codersdk.ChatRole, +) []codersdk.ValidationError { + var validations []codersdk.ValidationError + userIDs := chatModelACLRoleIDs(userRoles) + if len(userIDs) > 0 { + //nolint:gocritic // Principal validation requires organization membership visibility. + memberships, err := db.GetOrganizationIDsByMemberIDs(dbauthz.AsSystemRestricted(ctx), userIDs) + if err != nil { + validations = append(validations, codersdk.ValidationError{Field: "user_roles", Detail: err.Error()}) + } else { + byUser := make(map[uuid.UUID][]uuid.UUID, len(memberships)) + for _, membership := range memberships { + byUser[membership.UserID] = membership.OrganizationIDs + } + for _, id := range userIDs { + if !slices.Contains(byUser[id], organizationID) { + validations = append(validations, codersdk.ValidationError{ + Field: "user_roles", + Detail: "user " + id.String() + " does not belong to organization " + organizationID.String(), + }) + } + } + } + } + + groupIDs := chatModelACLRoleIDs(groupRoles) + if len(groupIDs) > 0 { + //nolint:gocritic // Principal validation requires group organization visibility. + groups, err := db.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs}) + if err != nil { + validations = append(validations, codersdk.ValidationError{Field: "group_roles", Detail: err.Error()}) + } else { + for _, group := range groups { + if group.Group.OrganizationID != organizationID { + validations = append(validations, codersdk.ValidationError{ + Field: "group_roles", + Detail: "group " + group.Group.ID.String() + " does not belong to organization " + organizationID.String(), + }) + } + } + } + } + return validations +} + +func chatModelACLRoleIDs(roles map[string]codersdk.ChatRole) []uuid.UUID { + ids := make([]uuid.UUID, 0, len(roles)) + for rawID, role := range roles { + if role == codersdk.ChatRoleDeleted { + continue + } + if id, err := uuid.Parse(rawID); err == nil { + ids = append(ids, id) + } + } + return ids +} + +type ChatModelACLUpdateValidator codersdk.UpdateChatModelACLRequest + +var _ acl.UpdateValidator[codersdk.ChatRole] = ChatModelACLUpdateValidator{} + +func (c ChatModelACLUpdateValidator) Users() (map[string]codersdk.ChatRole, string) { + return c.UserRoles, "user_roles" +} + +func (c ChatModelACLUpdateValidator) Groups() (map[string]codersdk.ChatRole, string) { + return c.GroupRoles, "group_roles" +} + +func (ChatModelACLUpdateValidator) ValidateRole(role codersdk.ChatRole) error { + if role == codersdk.ChatRoleRead || role == codersdk.ChatRoleDeleted { + return nil + } + return xerrors.Errorf("role %q is not a valid chat model role", role) +} diff --git a/coderd/exp_chats_model_acl_test.go b/coderd/exp_chats_model_acl_test.go new file mode 100644 index 0000000000000..e874cf8e1cb30 --- /dev/null +++ b/coderd/exp_chats_model_acl_test.go @@ -0,0 +1,436 @@ +package coderd_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestChatModelACL(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + mAudit := audit.NewMock() + adminClient, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + model := createChatModel(t, adminClient) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + groupMemberClientRaw, groupMember := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + groupMemberClient := codersdk.NewExperimentalClient(groupMemberClientRaw) + group := dbgen.Group(t, db, database.Group{OrganizationID: firstUser.OrganizationID}) + dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: groupMember.ID}) + + initialACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Equal(t, map[string]codersdk.ChatRole{}, initialACL.UserRoles) + require.Equal(t, map[string]codersdk.ChatRole{ + firstUser.OrganizationID.String(): codersdk.ChatRoleRead, + }, initialACL.GroupRoles) + + mAudit.ResetLogs() + err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{ + member.ID.String(): codersdk.ChatRoleRead, + }, + GroupRoles: map[string]codersdk.ChatRole{ + firstUser.OrganizationID.String(): codersdk.ChatRoleDeleted, + group.ID.String(): codersdk.ChatRoleRead, + }, + }) + require.NoError(t, err) + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, database.AuditActionWrite, logs[0].Action) + require.Equal(t, database.ResourceTypeChatModelConfig, logs[0].ResourceType) + require.Equal(t, model.ID, logs[0].ResourceID) + require.Equal(t, firstUser.UserID, logs[0].UserID) + require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + + updatedACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Equal(t, map[string]codersdk.ChatRole{ + member.ID.String(): codersdk.ChatRoleRead, + }, updatedACL.UserRoles) + require.Equal(t, map[string]codersdk.ChatRole{ + group.ID.String(): codersdk.ChatRoleRead, + }, updatedACL.GroupRoles) + + _, err = memberClient.ChatModel(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + _, err = groupMemberClient.ChatModel(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + + _, err = memberClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + requireSDKError(t, err, http.StatusNotFound) + err = memberClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{}) + requireSDKError(t, err, http.StatusNotFound) + + err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{ + member.ID.String(): codersdk.ChatRoleDeleted, + }, + GroupRoles: map[string]codersdk.ChatRole{ + group.ID.String(): codersdk.ChatRoleDeleted, + }, + }) + require.NoError(t, err) + + emptyACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Empty(t, emptyACL.UserRoles) + require.Empty(t, emptyACL.GroupRoles) + + _, err = memberClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + requireSDKError(t, err, http.StatusNotFound) + err = memberClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{}) + requireSDKError(t, err, http.StatusNotFound) + _, err = groupMemberClient.ChatModel(ctx, firstUser.OrganizationID, model.ID) + requireSDKError(t, err, http.StatusNotFound) + + otherOrganization := dbgen.Organization(t, db, database.Organization{}) + _, err = adminClient.ChatModelACL(ctx, otherOrganization.ID, model.ID) + requireSDKError(t, err, http.StatusNotFound) +} + +//nolint:tparallel,paralleltest // Subtests share one model ACL and run sequentially. +func TestChatModelACLSparseUpdate(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + model := createChatModel(t, adminClient) + _, firstMember := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + _, secondMember := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + group := dbgen.Group(t, db, database.Group{OrganizationID: firstUser.OrganizationID}) + + err := adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{firstMember.ID.String(): codersdk.ChatRoleRead}, + GroupRoles: map[string]codersdk.ChatRole{group.ID.String(): codersdk.ChatRoleRead}, + }) + require.NoError(t, err) + + err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{secondMember.ID.String(): codersdk.ChatRoleRead}, + }) + require.NoError(t, err) + + expected := codersdk.ChatModelACL{ + UserRoles: map[string]codersdk.ChatRole{ + firstMember.ID.String(): codersdk.ChatRoleRead, + secondMember.ID.String(): codersdk.ChatRoleRead, + }, + GroupRoles: map[string]codersdk.ChatRole{ + firstUser.OrganizationID.String(): codersdk.ChatRoleRead, + group.ID.String(): codersdk.ChatRoleRead, + }, + } + modelACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Equal(t, expected, modelACL) + + path := fmt.Sprintf( + "/api/experimental/organizations/%s/chats/models/%s/acl", + firstUser.OrganizationID, + model.ID, + ) + for _, test := range []struct { + name string + body json.RawMessage + }{ + {name: "omitted maps", body: json.RawMessage(`{}`)}, + {name: "empty maps", body: json.RawMessage(`{"user_roles":{},"group_roles":{}}`)}, + {name: "null maps", body: json.RawMessage(`{"user_roles":null,"group_roles":null}`)}, + } { + t.Run(test.name, func(t *testing.T) { + res, err := adminClient.Request(ctx, http.MethodPatch, path, test.body) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusNoContent, res.StatusCode) + + modelACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Equal(t, expected, modelACL) + }) + } + + err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{ + firstMember.ID.String(): codersdk.ChatRoleDeleted, + uuid.NewString(): codersdk.ChatRoleDeleted, + }, + GroupRoles: map[string]codersdk.ChatRole{ + group.ID.String(): codersdk.ChatRoleDeleted, + }, + }) + require.NoError(t, err) + + modelACL, err = adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Equal(t, map[string]codersdk.ChatRole{ + secondMember.ID.String(): codersdk.ChatRoleRead, + }, modelACL.UserRoles) + require.Equal(t, map[string]codersdk.ChatRole{ + firstUser.OrganizationID.String(): codersdk.ChatRoleRead, + }, modelACL.GroupRoles) + + err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{firstMember.ID.String(): codersdk.ChatRoleRead}, + }) + require.NoError(t, err) + //nolint:gocritic // Seeding a former member requires system access. + err = db.DeleteOrganizationMember(dbauthz.AsSystemRestricted(ctx), database.DeleteOrganizationMemberParams{ + OrganizationID: firstUser.OrganizationID, + UserID: firstMember.ID, + }) + require.NoError(t, err) + err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{firstMember.ID.String(): codersdk.ChatRoleDeleted}, + }) + require.NoError(t, err) + + otherOrganization := dbgen.Organization(t, db, database.Organization{}) + foreignUser := dbgen.User(t, db, database.User{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: otherOrganization.ID, + UserID: foreignUser.ID, + }) + foreignGroup := dbgen.Group(t, db, database.Group{OrganizationID: otherOrganization.ID}) + err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{foreignUser.ID.String(): codersdk.ChatRoleDeleted}, + GroupRoles: map[string]codersdk.ChatRole{foreignGroup.ID.String(): codersdk.ChatRoleDeleted}, + }) + require.NoError(t, err) +} + +//nolint:tparallel,paralleltest // Subtests verify the same ACL remains unchanged. +func TestChatModelACLValidationIsAtomic(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + model := createChatModel(t, adminClient) + _, member := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + err := adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{member.ID.String(): codersdk.ChatRoleRead}, + }) + require.NoError(t, err) + initialACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + + otherOrganization := dbgen.Organization(t, db, database.Organization{}) + foreignUser := dbgen.User(t, db, database.User{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: otherOrganization.ID, + UserID: foreignUser.ID, + }) + foreignGroup := dbgen.Group(t, db, database.Group{OrganizationID: otherOrganization.ID}) + duplicateBody := json.RawMessage(fmt.Sprintf( + `{"user_roles":{%q:"read",%q:""}}`, + member.ID.String(), + strings.ToUpper(member.ID.String()), + )) + + tests := []struct { + name string + body any + }{ + { + name: "foreign user", + body: codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{foreignUser.ID.String(): codersdk.ChatRoleRead}, + }, + }, + { + name: "foreign group", + body: codersdk.UpdateChatModelACLRequest{ + GroupRoles: map[string]codersdk.ChatRole{foreignGroup.ID.String(): codersdk.ChatRoleRead}, + }, + }, + { + name: "missing user", + body: codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{uuid.NewString(): codersdk.ChatRoleRead}, + }, + }, + { + name: "missing group", + body: codersdk.UpdateChatModelACLRequest{ + GroupRoles: map[string]codersdk.ChatRole{uuid.NewString(): codersdk.ChatRoleRead}, + }, + }, + { + name: "invalid user permission", + body: codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{member.ID.String(): "write"}, + }, + }, + { + name: "invalid group permission", + body: codersdk.UpdateChatModelACLRequest{ + GroupRoles: map[string]codersdk.ChatRole{firstUser.OrganizationID.String(): "share"}, + }, + }, + {name: "duplicate canonical user ID", body: duplicateBody}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + res, err := adminClient.Request(ctx, http.MethodPatch, fmt.Sprintf( + "/api/experimental/organizations/%s/chats/models/%s/acl", + firstUser.OrganizationID, + model.ID, + ), test.body) + require.NoError(t, err) + defer res.Body.Close() + err = codersdk.ReadBodyAsError(res) + requireSDKError(t, err, http.StatusBadRequest) + + modelACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Equal(t, initialACL, modelACL) + }) + } +} + +func TestChatModelACLLockFailure(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextAcquireLockStore(rawDB, 0) + adminClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Database = store + opts.Pubsub = pubsub + }) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + model := createChatModel(t, adminClient) + initialACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + + store.lockID = database.GenLockID("chat_model_config_writes:" + firstUser.OrganizationID.String()) + store.failNextAcquireLock.Store(true) + err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{}) + requireSDKError(t, err, http.StatusInternalServerError) + + modelACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Equal(t, initialACL, modelACL) +} + +//nolint:tparallel,paralleltest // Subtests share one model and run sequentially. +func TestChatModelActionOnlyScopes(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + model := createChatModel(t, adminClient) + + newScopedClient := func(t *testing.T, scope database.APIKeyScope) *codersdk.ExperimentalClient { + t.Helper() + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: firstUser.UserID, + Scopes: database.APIKeyScopes{scope}, + }) + client := codersdk.New( + adminClient.URL, + codersdk.WithSessionToken(token), + codersdk.WithHTTPClient(coderdtest.NewIsolatedHTTPClient(adminClient.URL)), + ) + t.Cleanup(client.HTTPClient.CloseIdleConnections) + return codersdk.NewExperimentalClient(client) + } + + t.Run("UpdateWithoutRead", func(t *testing.T) { + updateClient := newScopedClient(t, database.ApiKeyScopeChatModelConfigUpdate) + updated, err := updateClient.UpdateChatModel(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelRequest{ + DisplayName: "Updated with update-only scope", + }) + require.NoError(t, err) + require.Equal(t, "Updated with update-only scope", updated.DisplayName) + + _, err = updateClient.ChatModel(ctx, firstUser.OrganizationID, model.ID) + requireSDKError(t, err, http.StatusNotFound) + }) + + t.Run("ShareWithoutRead", func(t *testing.T) { + shareClient := newScopedClient(t, database.ApiKeyScopeChatModelConfigShare) + _, err := shareClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + err = shareClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{}) + require.NoError(t, err) + + _, err = shareClient.ChatModel(ctx, firstUser.OrganizationID, model.ID) + requireSDKError(t, err, http.StatusNotFound) + }) +} + +func TestChatModelACLShareDenied(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + model := createChatModel(t, adminClient) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + _, err := memberClient.ChatModel(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + _, err = memberClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + requireSDKError(t, err, http.StatusNotFound) + err = memberClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{}) + requireSDKError(t, err, http.StatusNotFound) +} + +func TestCreateChatModelRejectsACLKeys(t *testing.T) { + t.Parallel() + + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + + for _, test := range []struct { + name string + key string + value any + }{ + {name: "group_acl/object", key: "group_acl", value: map[string]any{}}, + {name: "group_acl/null", key: "group_acl", value: nil}, + {name: "user_acl/object", key: "user_acl", value: map[string]any{}}, + {name: "user_acl/null", key: "user_acl", value: nil}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + res, err := client.Request(ctx, http.MethodPost, fmt.Sprintf( + "/api/experimental/organizations/%s/chats/models", + firstUser.OrganizationID, + ), map[string]any{test.key: test.value}) + require.NoError(t, err) + defer res.Body.Close() + err = codersdk.ReadBodyAsError(res) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Message, "nested /acl endpoint") + }) + } +} diff --git a/coderd/exp_chats_model_config_list_test.go b/coderd/exp_chats_model_config_list_test.go index 2cf19fcc2f8a3..2f7339cc4df63 100644 --- a/coderd/exp_chats_model_config_list_test.go +++ b/coderd/exp_chats_model_config_list_test.go @@ -20,7 +20,7 @@ import ( ) // TestChatModelConfigListReadContracts pins the visible config set for each -// role. The legacy endpoint reads only the default organization's configs. +// role after chat model config reads become organization-scoped. func TestChatModelConfigListReadContracts(t *testing.T) { t.Parallel() @@ -68,19 +68,31 @@ func TestChatModelConfigListReadContracts(t *testing.T) { require.True(t, otherEnabled.Enabled) for _, testCase := range []struct { - name string - scope codersdk.APIKeyScope - wantStatus int + name string + scopes []codersdk.APIKeyScope + wantCollectionStatus int + wantItemStatus int + wantACLStatus int }{ - {name: "ModelRead", scope: codersdk.APIKeyScopeChatModelConfigRead}, - {name: "WorkspaceRead", scope: codersdk.APIKeyScopeWorkspaceRead, wantStatus: http.StatusForbidden}, + { + name: "ModelReadOnly", + scopes: []codersdk.APIKeyScope{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: []codersdk.APIKeyScope{testCase.scope}, + Scopes: testCase.scopes, }) require.NoError(t, err) scopedClient := codersdk.New( @@ -90,52 +102,87 @@ func TestChatModelConfigListReadContracts(t *testing.T) { ) t.Cleanup(scopedClient.HTTPClient.CloseIdleConnections) - configs, err := codersdk.NewExperimentalClient(scopedClient).ChatModels(ctx) - if testCase.wantStatus != 0 { - requireSDKError(t, err, testCase.wantStatus) - return + experimentalClient := codersdk.NewExperimentalClient(scopedClient) + response, listErr := experimentalClient.ChatModels(ctx, defaultOrg.ID) + _, availabilityErr := experimentalClient.ChatModelAvailability(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) + requireSDKError(t, availabilityErr, testCase.wantCollectionStatus) + } else { + require.NoError(t, listErr) + require.NotEmpty(t, response.Models) + require.NoError(t, availabilityErr) } - require.NoError(t, err) - require.NotEmpty(t, configs) + 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) }) } - testCases := []struct { - name string - client func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient - visible []uuid.UUID - hidden []uuid.UUID + 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: "OwnerSeesDefaultOrgConfigs", - client: func(*testing.T, context.Context) *codersdk.ExperimentalClient { - return client + name: "Collection", + call: func(ctx context.Context, organizationID uuid.UUID) error { + _, err := memberClient.ChatModels(ctx, organizationID) + return err }, - visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, - hidden: []uuid.UUID{otherEnabled.ID}, }, { - name: "SiteAuditorSeesDefaultOrgConfigs", - client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { - rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.RoleAuditor()) - return codersdk.NewExperimentalClient(rawClient) + name: "Availability", + call: func(ctx context.Context, organizationID uuid.UUID) error { + _, err := memberClient.ChatModelAvailability(ctx, organizationID) + return err }, - visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, - hidden: []uuid.UUID{otherEnabled.ID}, }, { - name: "CustomSiteReadRoleSeesDefaultOrgConfigs", - 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: "Item", + call: func(ctx context.Context, organizationID uuid.UUID) error { + _, err := memberClient.ChatModel(ctx, organizationID, otherEnabled.ID) + return err }, - visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, - hidden: []uuid.UUID{otherEnabled.ID}, }, { - name: "AgentsAccessUsesRowACL", + 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, @@ -145,53 +192,47 @@ func TestChatModelConfigListReadContracts(t *testing.T) { ) return codersdk.NewExperimentalClient(rawClient) }, - visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID}, - hidden: []uuid.UUID{denied.ID, otherEnabled.ID}, }, { - name: "DeploymentConfigReadOnlyUsesRowACL", - client: func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient { - return newSiteCustomRoleClient(ctx, t, client, rawDB, defaultOrg.ID, database.CustomRolePermission{ - ResourceType: rbac.ResourceDeploymentConfig.Type, - Action: policy.ActionRead, - }) + name: "Owner", + seesDenied: true, + client: func(*testing.T, context.Context) *codersdk.ExperimentalClient { + return client }, - visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID}, - hidden: []uuid.UUID{denied.ID, otherEnabled.ID}, }, { - name: "DefaultOrgAdminKeepsEnabledList", + name: "SiteAuditor", + seesDenied: true, client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { - rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAdmin(defaultOrg.ID)) + rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.RoleAuditor()) return codersdk.NewExperimentalClient(rawClient) }, - visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, - hidden: []uuid.UUID{otherEnabled.ID}, }, { - name: "DefaultOrgAuditorKeepsEnabledList", - 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) + 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, + }) }, - visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, - hidden: []uuid.UUID{otherEnabled.ID}, }, { - name: "NonDefaultOrgAdminSeesNoDefaultOrgConfigs", + name: "OrgAdmin", + seesDenied: true, client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { - rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleOrgAdmin(otherOrg.ID)) + rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAdmin(defaultOrg.ID)) return codersdk.NewExperimentalClient(rawClient) }, - hidden: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID, otherEnabled.ID}, }, { - name: "NonDefaultOrgAuditorSeesNoDefaultOrgConfigs", + name: "OrgAuditor", + seesDenied: true, client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { - rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleOrgAuditor(otherOrg.ID)) + rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAuditor(defaultOrg.ID)) return codersdk.NewExperimentalClient(rawClient) }, - hidden: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID, otherEnabled.ID}, }, } @@ -200,14 +241,12 @@ func TestChatModelConfigListReadContracts(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - models, err := testCase.client(t, ctx).ChatModels(ctx) + models, err := testCase.client(t, ctx).ChatModels(ctx, defaultOrg.ID) require.NoError(t, err) - for _, id := range testCase.visible { - require.True(t, containsChatModel(models, id), "must see ChatModel %s", id) - } - for _, id := range testCase.hidden { - require.False(t, containsChatModel(models, id), "must not see ChatModel %s", id) - } + require.True(t, containsChatModel(models.Models, ownEnabled.ID)) + require.True(t, containsChatModel(models.Models, ownDisabled.ID)) + require.Equal(t, testCase.seesDenied, containsChatModel(models.Models, denied.ID)) + require.False(t, containsChatModel(models.Models, otherEnabled.ID)) }) } } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index f8c9528a9a296..2b6092a4a0078 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -604,122 +604,150 @@ func insertAssistantMessage( func TestPostChats(t *testing.T) { t.Parallel() - t.Run("NonDefaultOrgDoesNotUseDeploymentDefault", func(t *testing.T) { + t.Run("SuccessNonDefaultOrgUsesOrgDefault", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) _ = coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + aiProvider := createAIProviderForTest(t, client, "openai-compat", "test-api-key") + + org := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + // The org's Everyone group shares the org ID (migration 000058). + dbgen.Group(t, db, database.Group{ + ID: org.ID, + Name: database.EveryoneGroup, + OrganizationID: org.ID, + }) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + contextLimit := int64(4096) + modelConfig, err := client.CreateChatModel(ctx, org.ID, codersdk.CreateChatModelRequest{ + AIProviderID: &aiProvider.ID, + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + require.True(t, modelConfig.IsDefault, "first config in the org must self-elect as default") + + chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: org.ID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "hello from a non-default org", + }}, + }) + require.NoError(t, err) + require.Equal(t, modelConfig.ID, chat.LastModelConfigID) + }) + t.Run("NonDefaultOrgDoesNotUseDeploymentDefault", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModel(t, client) org := dbgen.Organization(t, db, database.Organization{IsDefault: false}) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: org.ID, - Content: []codersdk.ChatInputPart{ - { - Type: codersdk.ChatInputPartTypeText, - Text: "hello from a non-default org", - }, - }, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "require a local model", + }}, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "No default chat model config is configured.", sdkErr.Message) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) }) - t.Run("NonDefaultOrgUsesLocalDefault", func(t *testing.T) { + t.Run("DisabledLocalDefaultDoesNotUseDefaultOrgDefault", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) _ = coderdtest.CreateFirstUser(t, client.Client) - deploymentDefault := createChatModelConfig(t, client) + defaultConfig := createChatModel(t, client) org := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - localDefault := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - AIProviderID: uuid.NullUUID{UUID: deploymentDefault.AIProviderID, Valid: true}, + dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true}, + Model: "disabled-local-default-" + uuid.NewString(), OrganizationID: org.ID, IsDefault: true, + }, func(params *database.InsertChatModelConfigParams) { + params.Enabled = false }) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: org.ID, Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeText, - Text: "hello with a local default", + Text: "do not use the deployment default model", }}, }) - require.NoError(t, err) - require.Equal(t, localDefault.ID, chat.LastModelConfigID) - require.NotEqual(t, deploymentDefault.ID, chat.LastModelConfigID) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) }) - t.Run("RejectsExplicitForeignModel", func(t *testing.T) { + t.Run("DefaultOrgExplicitModelRejectedForNonDefaultOrg", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) _ = coderdtest.CreateFirstUser(t, client.Client) - foreignModel := createChatModelConfig(t, client) - org := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID)) + defaultConfig := createChatModel(t, client) + otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleAgentsAccess(otherOrg.ID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - _, err := client.UpdateAIProvider(ctx, foreignModel.AIProviderID.String(), codersdk.UpdateAIProviderRequest{ - Enabled: ptr.Ref(false), - }) - require.NoError(t, err) - _, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: org.ID, - ModelConfigID: &foreignModel.ID, + _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: otherOrg.ID, Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeText, - Text: "reject the foreign model", + Text: "reject an explicit deployment model", }}, + ModelConfigID: ptr.Ref(defaultConfig.ID), }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Model config does not belong to the specified organization.", sdkErr.Message) + require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message) }) - t.Run("ForeignPersonalOverrideUsesLocalDefault", func(t *testing.T) { + t.Run("ThirdOrgExplicitModelRejected", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) _ = coderdtest.CreateFirstUser(t, client.Client) - foreignModel := createChatModelConfig(t, client) - org := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - localDefault := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - AIProviderID: uuid.NullUUID{UUID: foreignModel.AIProviderID, Valid: true}, - OrganizationID: org.ID, - IsDefault: true, - }) - memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID)) + defaultConfig := createChatModel(t, client) + chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + thirdOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - err := client.UpdateChatPersonalModelOverridesAdminSettings(ctx, codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest{ - AllowUsers: true, - }) - require.NoError(t, err) - err = db.UpsertUserChatPersonalModelOverride(dbauthz.AsSystemRestricted(ctx), database.UpsertUserChatPersonalModelOverrideParams{ - UserID: member.ID, - Key: chatd.ChatPersonalModelOverrideKey(codersdk.ChatPersonalModelOverrideContextRoot), - Value: "model:" + foreignModel.ID.String(), + thirdConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true}, + Model: "third-org-" + uuid.NewString(), + Enabled: true, + IsDefault: true, + OrganizationID: thirdOrg.ID, }) - require.NoError(t, err) - chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: org.ID, + _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: chatOrg.ID, Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeText, - Text: "ignore the foreign personal override", + Text: "reject a third organization's model", }}, + ModelConfigID: ptr.Ref(thirdConfig.ID), }) - require.NoError(t, err) - require.Equal(t, localDefault.ID, chat.LastModelConfigID) - require.NotEqual(t, foreignModel.ID, chat.LastModelConfigID) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message) }) t.Run("Success", func(t *testing.T) { @@ -731,7 +759,7 @@ func TestPostChats(t *testing.T) { opts.Auditor = mAudit }) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Use a member with agents-access instead of the owner to // verify least-privilege access. @@ -793,7 +821,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - deploymentDefault := createChatModelConfig(t, client) + deploymentDefault := createChatModel(t, client) defaultOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: firstUser.OrganizationID, @@ -830,7 +858,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) coderdtest.CreateFirstUser(t, client.Client) - deploymentDefault := createChatModelConfig(t, client) + deploymentDefault := createChatModel(t, client) secondOrg := dbgen.Organization(t, db, database.Organization{}) _ = dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ @@ -880,7 +908,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) enabledCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: firstUser.OrganizationID, @@ -939,7 +967,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) cfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: firstUser.OrganizationID, @@ -1013,7 +1041,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) coderdtest.CreateFirstUser(t, client.Client) - deploymentDefault := createChatModelConfig(t, client) + deploymentDefault := createChatModel(t, client) thirdOrg := dbgen.Organization(t, db, database.Organization{}) thirdOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ @@ -1051,7 +1079,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Member without agents-access should be denied. memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) @@ -1075,7 +1103,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, @@ -1110,7 +1138,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) user := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, @@ -1134,7 +1162,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -1160,8 +1188,8 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) - disabledConfig := createDisabledChatModelConfig( + _ = createChatModel(t, client) + disabledConfig := createDisabledChatModel( t, client, coderdtest.TestChatProviderOpenAICompat, @@ -1186,8 +1214,8 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) - providerDisabledConfig := createProviderDisabledChatModelConfig( + _ = createChatModel(t, client) + providerDisabledConfig := createProviderDisabledChatModel( t, client, "openai", @@ -1212,7 +1240,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - defaultConfig := createChatModelConfig(t, client) + defaultConfig := createChatModel(t, client) _, err := client.UpdateAIProvider(ctx, defaultConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{ Enabled: ptr.Ref(false), }) @@ -1228,8 +1256,76 @@ func TestPostChats(t *testing.T) { }}, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "No default chat model config is configured.", sdkErr.Message) - require.Equal(t, "The default chat model or its provider is disabled.", sdkErr.Detail) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) + }) + + t.Run("DeniedSameOrganizationDefaultIsRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key") + _ = dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, + Model: "private-default-" + uuid.NewString(), + Enabled: true, + IsDefault: true, + OrganizationID: firstUser.OrganizationID, + GroupACL: database.ChatACL{}, + }) + memberClientRaw, _ := coderdtest.CreateAnotherUser( + t, + client.Client, + firstUser.OrganizationID, + rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), + ) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "do not select the private default", + }}, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + }) + + t.Run("DeniedSameOrganizationExplicitModelIsRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key") + privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, + Model: "private-explicit-" + uuid.NewString(), + Enabled: true, + OrganizationID: firstUser.OrganizationID, + GroupACL: database.ChatACL{}, + }) + memberClientRaw, _ := coderdtest.CreateAnotherUser( + t, + client.Client, + firstUser.OrganizationID, + rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), + ) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "do not admit the private model", + }}, + ModelConfigID: &privateConfig.ID, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message) }) t.Run("WithPerChatSystemPrompt", func(t *testing.T) { @@ -1238,7 +1334,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, @@ -1291,7 +1387,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, @@ -1420,7 +1516,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: user.OrganizationID, @@ -1460,7 +1556,8 @@ func TestPostChats(t *testing.T) { }, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "No default chat model config is configured.", sdkErr.Message) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) }) t.Run("EmptyContent", func(t *testing.T) { @@ -1527,7 +1624,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -1549,7 +1646,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -1575,7 +1672,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: firstUser.OrganizationID, @@ -1614,7 +1711,7 @@ func TestChats_ForceOnMCPServerEnforced(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // An admin marks an MCP server as Force On. forced, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ @@ -1669,7 +1766,7 @@ func TestPostChats_ClientType(t *testing.T) { client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -1752,7 +1849,7 @@ func TestListChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) firstChatA, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -1847,7 +1944,7 @@ func TestListChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) ownerClientRaw, owner := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleOwner()) ownerClient := codersdk.NewExperimentalClient(ownerClientRaw) memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) @@ -1931,7 +2028,7 @@ func TestListChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Create a member without agents-access and insert a chat // owned by them via system context. Without agents-access, @@ -1978,7 +2075,7 @@ func TestListChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Insert chats with a terminal status so the chatd // processor never acquires them and never bumps @@ -2065,7 +2162,7 @@ func TestListChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Insert chats directly with a terminal status: see // the Pagination subtest for the cursor-race rationale. @@ -2124,7 +2221,7 @@ func TestListChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Insert chats directly with a terminal status: see // the Pagination subtest for the cursor-race rationale. @@ -2211,7 +2308,7 @@ func TestListChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Create a parent chat via the API. parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -2309,7 +2406,7 @@ func TestListChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Create 3 root chats, each with 2 children. for i := range 3 { @@ -2354,7 +2451,7 @@ func TestListChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Helper that creates a chat (root or child) with a diff status URL. create := func(title, url string, parentID uuid.NullUUID) database.Chat { @@ -2487,10 +2584,10 @@ func TestListChatModels(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModel(t, client) - models, err := client.ChatModelAvailability(ctx) + models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) var openAIProvider *codersdk.ChatModelProvider @@ -2513,15 +2610,102 @@ func TestListChatModels(t *testing.T) { require.True(t, foundModel) }) + t.Run("DeniedSameOrganizationEnabledModelIsHidden", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key") + privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, + Model: "private-discovery-" + uuid.NewString(), + Enabled: true, + OrganizationID: firstUser.OrganizationID, + GroupACL: database.ChatACL{}, + }) + memberClientRaw, _ := coderdtest.CreateAnotherUser( + t, + client.Client, + firstUser.OrganizationID, + rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), + ) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + models, err := memberClient.ChatModelAvailability(ctx, firstUser.OrganizationID) + require.NoError(t, err) + for _, model := range models.Models { + require.NotEqual(t, privateConfig.ID, model.ID) + } + for _, availableProvider := range models.Providers { + for _, model := range availableProvider.Models { + require.NotEqual(t, privateConfig.Model, model.Model) + } + } + }) + + t.Run("NonDefaultOrgIncludesOnlyLocalModels", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key") + defaultConfig := createChatModel(t, client) + chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + thirdOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + contextLimit := int64(4096) + localConfig, err := client.CreateChatModel(ctx, chatOrg.ID, codersdk.CreateChatModelRequest{ + AIProviderID: &provider.ID, + Model: "local-" + uuid.NewString(), + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + localConfig, err = client.UpdateChatModel(ctx, localConfig.OrganizationID, localConfig.ID, codersdk.UpdateChatModelRequest{ + IsDefault: ptr.Ref(true), + }) + require.NoError(t, err) + thirdConfig, err := client.CreateChatModel(ctx, thirdOrg.ID, codersdk.CreateChatModelRequest{ + AIProviderID: &provider.ID, + Model: "third-" + uuid.NewString(), + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + models, err := memberClient.ChatModelAvailability(ctx, chatOrg.ID) + require.NoError(t, err) + var modelNames []string + for _, availableProvider := range models.Providers { + for _, model := range availableProvider.Models { + modelNames = append(modelNames, model.Model) + } + } + require.Contains(t, modelNames, localConfig.Model) + require.NotContains(t, modelNames, defaultConfig.Model) + require.NotContains(t, modelNames, thirdConfig.Model) + + modelConfigIDs := make([]uuid.UUID, 0, len(models.Models)) + for _, model := range models.Models { + modelConfigIDs = append(modelConfigIDs, model.ID) + } + require.Contains(t, modelConfigIDs, localConfig.ID) + require.NotContains(t, modelConfigIDs, defaultConfig.ID) + require.NotContains(t, modelConfigIDs, thirdConfig.ID) + require.Len(t, models.Models, 1) + require.True(t, models.Models[0].IsDefault) + }) + t.Run("Unauthenticated", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) unauthenticatedClient := codersdk.NewExperimentalClient(codersdk.New(client.URL)) - _, err := unauthenticatedClient.ChatModelAvailability(ctx) + _, err := unauthenticatedClient.ChatModelAvailability(ctx, firstUser.OrganizationID) requireSDKError(t, err, http.StatusUnauthorized) }) @@ -2530,14 +2714,14 @@ func TestListChatModels(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := 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.ChatModelAvailability(ctx) + models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) require.False(t, slices.ContainsFunc(models.Providers, func(p codersdk.ChatModelProvider) bool { @@ -2557,10 +2741,10 @@ func TestListChatModels(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModel(t, client) - models, err := client.ChatModelAvailability(ctx) + models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Empty(t, models.UnsupportedProviders) }) @@ -2570,10 +2754,10 @@ func TestListChatModels(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModel(t, client) - models, err := client.ChatModelAvailability(ctx) + models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) var openAIProvider *codersdk.ChatModelProvider @@ -2592,20 +2776,20 @@ func TestListChatModels(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) providerType := database.AIProviderTypeAnthropic provider := createAIProviderForTest(t, client, string(providerType), "") contextLimit := int64(4096) - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "claude-sonnet", ContextLimit: &contextLimit, }) require.NoError(t, err) - models, err := client.ChatModelAvailability(ctx) + models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) var anthropicProvider *codersdk.ChatModelProvider @@ -2624,7 +2808,7 @@ func TestListChatModels(t *testing.T) { }) require.NoError(t, err) - models, err = client.ChatModelAvailability(ctx) + models, err = client.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) anthropicProvider = nil @@ -2643,19 +2827,19 @@ func TestListChatModels(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) provider := createAIProviderForTest(t, client, "google", "provider-api-key") contextLimit := int64(4096) - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "gemini-1.5-pro", ContextLimit: &contextLimit, }) require.NoError(t, err) - models, err := client.ChatModelAvailability(ctx) + models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) var googleProvider *codersdk.ChatModelProvider @@ -2673,7 +2857,7 @@ func TestListChatModels(t *testing.T) { }) require.NoError(t, err) - models, err = client.ChatModelAvailability(ctx) + models, err = client.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) googleProvider = nil @@ -2687,15 +2871,107 @@ func TestListChatModels(t *testing.T) { require.True(t, googleProvider.Available) }) + t.Run("SameTypeProvidersUseExactCredentialStatus", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + availableProvider := createAIProviderForTest(t, client, "openai", "available-key") + unavailableProvider := createAIProviderForTest(t, client, "openai", "") + contextLimit := int64(4096) + + availableModel, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ + AIProviderID: &availableProvider.ID, + Model: "available-" + uuid.NewString(), + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + unavailableModel, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ + AIProviderID: &unavailableProvider.ID, + Model: "unavailable-" + uuid.NewString(), + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + + models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID) + require.NoError(t, err) + var catalogModels []string + for _, provider := range models.Providers { + for _, model := range provider.Models { + catalogModels = append(catalogModels, model.Model) + } + } + require.Contains(t, catalogModels, availableModel.Model) + require.NotContains(t, catalogModels, unavailableModel.Model) + }) + + t.Run("UserCredentialsAreCallerIsolated", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + provider := createAIProviderForTest(t, client, "anthropic", "") + contextLimit := int64(4096) + modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ + AIProviderID: &provider.ID, + Model: "caller-isolated-" + uuid.NewString(), + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + otherClientRaw, _ := coderdtest.CreateAnotherUser( + t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), + ) + otherClient := codersdk.NewExperimentalClient(otherClientRaw) + + _, err = client.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "owner-key"}) + require.NoError(t, err) + + ownerModels, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID) + require.NoError(t, err) + otherModels, err := otherClient.ChatModelAvailability(ctx, firstUser.OrganizationID) + require.NoError(t, err) + ownerConfigs, err := client.ChatModels(ctx, firstUser.OrganizationID) + require.NoError(t, err) + otherConfigs, err := otherClient.ChatModels(ctx, firstUser.OrganizationID) + require.NoError(t, err) + + hasUserAPIKey := func(response codersdk.OrganizationChatModelsResponse) bool { + for _, descriptor := range response.Providers { + if descriptor.ID == provider.ID { + return descriptor.HasUserAPIKey + } + } + t.Fatal("provider descriptor not found") + return false + } + require.True(t, hasUserAPIKey(ownerConfigs)) + require.False(t, hasUserAPIKey(otherConfigs)) + + containsModel := func(response codersdk.ChatModelAvailabilityResponse) bool { + for _, availableProvider := range response.Providers { + for _, model := range availableProvider.Models { + if model.Model == modelConfig.Model { + return true + } + } + } + return false + } + require.True(t, containsModel(ownerModels)) + require.False(t, containsModel(otherModels)) + }) + t.Run("IncludesOnlyDefaultOrganizationModels", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key") contextLimit := int64(4096) - defaultConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + defaultConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "default-" + uuid.NewString(), ContextLimit: &contextLimit, @@ -2708,7 +2984,7 @@ func TestListChatModels(t *testing.T) { OrganizationID: otherOrganization.ID, }) - models, err := client.ChatModelAvailability(ctx) + models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) var catalogModels []string @@ -2728,32 +3004,51 @@ func TestListChatModels(t *testing.T) { values := coderdtest.DeploymentValues(t) values.AI.BridgeConfig.LegacyOpenAI.Key = serpent.String("deployment-openai-key") client := newChatClientWithDeploymentValues(t, values) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) provider := createAIProviderForTest(t, client, "openai", "test-key") contextLimit := int64(4096) - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, }) require.NoError(t, err) - models, err := client.ChatModelAvailability(ctx) + models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, models.Providers, 1) require.Equal(t, "openai", models.Providers[0].Provider) require.Len(t, models.Providers[0].Models, 1) require.Equal(t, "gpt-4o-mini", models.Providers[0].Models[0].Model) - enabled := false - _, err = client.UpdateAIProvider(ctx, provider.ID.String(), codersdk.UpdateAIProviderRequest{ - Enabled: &enabled, + // A disabled model under an enabled provider must not appear in + // availability, even though the management list still returns it. + disabledModel, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ + AIProviderID: &provider.ID, + Model: "gpt-4o", + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + enabled := false + _, err = client.UpdateChatModel(ctx, disabledModel.OrganizationID, disabledModel.ID, codersdk.UpdateChatModelRequest{ + Enabled: &enabled, }) require.NoError(t, err) - models, err = client.ChatModelAvailability(ctx) + models, err = client.ChatModelAvailability(ctx, firstUser.OrganizationID) + require.NoError(t, err) + require.Len(t, models.Providers, 1) + require.Len(t, models.Providers[0].Models, 1) + require.Equal(t, "gpt-4o-mini", models.Providers[0].Models[0].Model) + + _, err = client.UpdateAIProvider(ctx, provider.ID.String(), codersdk.UpdateAIProviderRequest{ + Enabled: &enabled, + }) + require.NoError(t, err) + + models, err = client.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Empty(t, models.Providers) }) @@ -2767,7 +3062,7 @@ func TestListChats_Search(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) return ctx, client, db, firstUser, modelConfig } @@ -2943,7 +3238,7 @@ func TestWatchChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil) require.NoError(t, err) @@ -2977,7 +3272,7 @@ func TestWatchChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil) require.NoError(t, err) @@ -3033,7 +3328,7 @@ func TestWatchChats(t *testing.T) { db := api.Database chatDaemon := api.ChatDaemonForTest() user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Insert a chat and a diff status row. chat := dbgen.Chat(t, db, database.Chat{ @@ -3107,7 +3402,7 @@ func TestWatchChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithAPIAndDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, @@ -3402,7 +3697,7 @@ func TestListChatProviders(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) providers, err := client.ListChatProviders(ctx) require.NoError(t, err) @@ -4537,14 +4832,14 @@ func TestListChatModelConfigs(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) - configs, err := client.ChatModels(ctx) + configs, err := client.ChatModels(ctx, modelConfig.OrganizationID) require.NoError(t, err) - require.NotEmpty(t, configs) + require.NotEmpty(t, configs.Models) found := false - for _, config := range configs { + for _, config := range configs.Models { if config.ID == modelConfig.ID { found = true require.Equal(t, modelConfig.AIProviderID, config.AIProviderID) @@ -4555,18 +4850,61 @@ func TestListChatModelConfigs(t *testing.T) { require.True(t, found) }) - t.Run("AdminIncludesDisabledModelConfigs", func(t *testing.T) { + t.Run("CompatibilityCollectionRoutesUseDefaultOrganization", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModel(t, client) + + res, err := client.Request(ctx, http.MethodGet, "/api/experimental/chats/model-configs", nil) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + var configs []codersdk.ChatModel + require.NoError(t, codersdk.ReadBodyAsJSON(res, &configs)) + require.Contains(t, configs, modelConfig) + + availabilityRes, err := client.Request(ctx, http.MethodGet, "/api/experimental/chats/models", nil) + require.NoError(t, err) + defer availabilityRes.Body.Close() + require.Equal(t, http.StatusOK, availabilityRes.StatusCode) + var availability codersdk.ChatModelAvailabilityResponse + require.NoError(t, codersdk.ReadBodyAsJSON(availabilityRes, &availability)) + require.Contains(t, availability.Models, modelConfig) + + contextLimit := int64(8192) + createdRes, err := client.Request(ctx, http.MethodPost, "/api/experimental/chats/model-configs", codersdk.CreateChatModelRequest{ + AIProviderID: &modelConfig.AIProviderID, + Model: "compatibility-model", + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + defer createdRes.Body.Close() + require.Equal(t, http.StatusCreated, createdRes.StatusCode) + var created codersdk.ChatModel + require.NoError(t, codersdk.ReadBodyAsJSON(createdRes, &created)) + + got, err := client.ChatModel(ctx, created.OrganizationID, created.ID) + require.NoError(t, err) + require.Equal(t, created.ID, got.ID) + + require.NoError(t, client.DeleteChatModel(ctx, created.OrganizationID, created.ID)) + }) + + t.Run("AdminIncludesDisabledModelConfigs", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") contextLimit := int64(4096) enabled := false - disabledConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + disabledConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-disabled", DisplayName: "GPT-4o Disabled", @@ -4576,11 +4914,11 @@ func TestListChatModelConfigs(t *testing.T) { require.NoError(t, err) require.False(t, disabledConfig.Enabled) - configs, err := client.ChatModels(ctx) + configs, err := client.ChatModels(ctx, firstUser.OrganizationID) require.NoError(t, err) found := false - for _, config := range configs { + for _, config := range configs.Models { if config.ID == disabledConfig.ID { found = true require.False(t, config.Enabled) @@ -4590,136 +4928,232 @@ func TestListChatModelConfigs(t *testing.T) { require.True(t, found) }) - t.Run("NonAdminIncludesAllowedDisabledModelConfigs", func(t *testing.T) { + t.Run("OrganizationMemberSeesReadableModels", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) + adminClient, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - enabledConfig := createChatModelConfig(t, adminClient) + readableConfig := createChatModel(t, adminClient) + privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: readableConfig.AIProviderID, Valid: true}, + Model: "private-" + uuid.NewString(), + OrganizationID: firstUser.OrganizationID, + GroupACL: database.ChatACL{}, + }) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - contextLimit := int64(4096) - enabled := false - _, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ - AIProviderID: &enabledConfig.AIProviderID, - Model: "gpt-4o-disabled", - DisplayName: "GPT-4o Disabled", - Enabled: &enabled, - ContextLimit: &contextLimit, - }) + configs, err := memberClient.ChatModels(ctx, firstUser.OrganizationID) require.NoError(t, err) - - configs, err := memberClient.ChatModels(ctx) - require.NoError(t, err) - require.Len(t, configs, 2) - require.True(t, slices.ContainsFunc(configs, func(config codersdk.ChatModel) bool { - return config.ID == enabledConfig.ID && config.Enabled - })) - require.True(t, slices.ContainsFunc(configs, func(config codersdk.ChatModel) bool { - return config.Model == "gpt-4o-disabled" && !config.Enabled - })) + modelIDs := make([]uuid.UUID, 0, len(configs.Models)) + for _, config := range configs.Models { + modelIDs = append(modelIDs, config.ID) + } + require.Contains(t, modelIDs, readableConfig.ID) + require.NotContains(t, modelIDs, privateConfig.ID) }) - // Provider status does not change management list visibility. - t.Run("ProviderDisabled", func(t *testing.T) { + t.Run("BedrockDescriptorReportsEffectiveCredentials", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) - firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - enabledConfig := createChatModelConfig(t, adminClient) - providerDisabledConfig := createProviderDisabledChatModelConfig( - t, - adminClient, - "openai", - "gpt-4o-provider-disabled-"+uuid.NewString(), - ) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) - memberClient := codersdk.NewExperimentalClient(memberClientRaw) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + provider := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AIProviderTypeBedrock, + }) - adminConfigs, err := adminClient.ChatModels(ctx) + models, err := client.ChatModels(ctx, firstUser.OrganizationID) require.NoError(t, err) - adminIDs := make([]uuid.UUID, 0, len(adminConfigs)) - for _, config := range adminConfigs { - adminIDs = append(adminIDs, config.ID) + for _, descriptor := range models.Providers { + if descriptor.ID == provider.ID { + require.False(t, descriptor.HasAPIKey) + require.False(t, descriptor.HasUserAPIKey) + require.True(t, descriptor.HasEffectiveAPIKey) + return + } } - require.Contains(t, adminIDs, providerDisabledConfig.ID) + t.Fatal("bedrock provider descriptor not found") + }) +} + +func TestGetChatModel(t *testing.T) { + t.Parallel() - memberConfigs, err := memberClient.ChatModels(ctx) + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModel(t, client) + + got, err := client.ChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID) require.NoError(t, err) - memberIDs := make([]uuid.UUID, 0, len(memberConfigs)) - for _, config := range memberConfigs { - memberIDs = append(memberIDs, config.ID) - } - require.Contains(t, memberIDs, enabledConfig.ID) - require.Contains(t, memberIDs, providerDisabledConfig.ID) + require.Equal(t, modelConfig.ID, got.ID) }) - t.Run("ManagementListExcludesOtherOrganizations", func(t *testing.T) { + t.Run("WrongOrganizationConcealedForAllItemActions", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) _ = coderdtest.CreateFirstUser(t, client.Client) - defaultConfig := createChatModelConfig(t, client) - otherOrg := dbgen.Organization(t, db, database.Organization{}) - otherConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - OrganizationID: otherOrg.ID, - AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true}, - }) + modelConfig := createChatModel(t, client) + wrongOrganization := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - configs, err := client.ChatModels(ctx) - require.NoError(t, err) - require.Contains(t, configs, defaultConfig) - for _, config := range configs { - require.NotEqual(t, otherConfig.ID, config.ID) + for _, tc := range []struct { + name string + method string + body any + }{ + {name: "Get", method: http.MethodGet}, + {name: "Patch", method: http.MethodPatch, body: codersdk.UpdateChatModelRequest{DisplayName: "wrong organization"}}, + {name: "Delete", method: http.MethodDelete}, + } { + t.Run(tc.name, func(t *testing.T) { + res, err := client.Request(ctx, tc.method, fmt.Sprintf( + "/api/experimental/organizations/%s/chats/models/%s", + wrongOrganization.ID, + modelConfig.ID, + ), tc.body) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) } + + stored, err := db.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), modelConfig.ID) + require.NoError(t, err) + require.Equal(t, modelConfig.DisplayName, stored.DisplayName) }) - t.Run("SuccessForOrganizationMember", func(t *testing.T) { + t.Run("OrganizationMemberCanReadSharedModel", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - modelConfig := createChatModelConfig(t, adminClient) + modelConfig := createChatModel(t, adminClient) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - // The management list returns all rows allowed by the row ACL. - configs, err := memberClient.ChatModels(ctx) + got, err := memberClient.ChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID) require.NoError(t, err) - require.NotEmpty(t, configs) + require.Equal(t, modelConfig.ID, got.ID) + }) - found := false - for _, config := range configs { - if config.ID == modelConfig.ID { - found = true - require.Equal(t, modelConfig.AIProviderID, config.AIProviderID) - require.Equal(t, modelConfig.Model, config.Model) - } - } - require.True(t, found) + t.Run("OrganizationMemberCannotReadPrivateModel", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + sharedConfig := createChatModel(t, adminClient) + privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: sharedConfig.AIProviderID, Valid: true}, + Model: "private-" + uuid.NewString(), + OrganizationID: firstUser.OrganizationID, + GroupACL: database.ChatACL{}, + }) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + _, err := memberClient.ChatModel(ctx, privateConfig.OrganizationID, privateConfig.ID) + requireSDKError(t, err, http.StatusNotFound) }) } func TestCreateChatModelConfig(t *testing.T) { t.Parallel() + t.Run("UnauthorizedPrincipalCannotProbeProviderState", func(t *testing.T) { + t.Parallel() + + // The write is authorized before any privileged provider lookup, so + // a principal without create-in-org gets an identical denial whether + // the provider ID is missing, disabled, or configured: no provider + // state is disclosed. + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + configuredProvider := createAIProviderForTest(t, client, "openai", "test-api-key") + disabledProvider := createAIProviderForTest(t, client, "google", "test-api-key-2") + enabled := false + _, err := client.UpdateAIProvider(ctx, disabledProvider.ID.String(), codersdk.UpdateAIProviderRequest{ + Enabled: &enabled, + }) + require.NoError(t, err) + + for _, tc := range []struct { + name string + providerID uuid.UUID + }{ + {"missing", uuid.New()}, + {"disabled", disabledProvider.ID}, + {"configured", configuredProvider.ID}, + } { + t.Run(tc.name, func(t *testing.T) { + contextLimit := int64(4096) + _, err := memberClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ + AIProviderID: &tc.providerID, + Model: "probe-" + tc.name, + ContextLimit: &contextLimit, + }) + sdkErr := requireSDKError(t, err, http.StatusForbidden) + require.Equal(t, "Forbidden.", sdkErr.Message) + }) + } + }) + + t.Run("NormalCreateConcealsHiddenAndMissingOrganizations", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + provider := createAIProviderForTest(t, adminClient, "openai", "test-api-key") + hiddenOrganization := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + contextLimit := int64(4096) + + var concealedMessage string + for _, organizationID := range []uuid.UUID{hiddenOrganization.ID, uuid.New()} { + _, err := memberClient.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{ + AIProviderID: &provider.ID, + Model: "concealed-" + uuid.NewString(), + ContextLimit: &contextLimit, + }) + sdkErr := requireSDKError(t, err, http.StatusNotFound) + if concealedMessage == "" { + concealedMessage = sdkErr.Message + } + require.Equal(t, concealedMessage, sdkErr.Message) + } + + configs, err := adminClient.ChatModels(ctx, hiddenOrganization.ID) + require.NoError(t, err) + require.Empty(t, configs.Models) + }) + t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") contextLimit := int64(4096) isDefault := true - modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, @@ -4732,9 +5166,9 @@ func TestCreateChatModelConfig(t *testing.T) { require.EqualValues(t, 4096, modelConfig.ContextLimit) require.True(t, modelConfig.IsDefault) - configs, err := client.ChatModels(ctx) + configs, err := client.ChatModels(ctx, firstUser.OrganizationID) require.NoError(t, err) - require.Len(t, configs, 1) + require.Len(t, configs.Models, 1) }) t.Run("ProviderReferenceLockFailureUsesCreateErrorText", func(t *testing.T) { @@ -4742,13 +5176,13 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, store := newChatClientWithModelConfigHookStore(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") store.armFailProviderReferenceLock(aiProvider.ID) contextLimit := int64(4096) - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, @@ -4764,7 +5198,7 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") @@ -4777,7 +5211,7 @@ func TestCreateChatModelConfig(t *testing.T) { var eg errgroup.Group for i := range creators - 1 { eg.Go(func() error { - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: fmt.Sprintf("gpt-4o-mini-%d", i), ContextLimit: &contextLimit, @@ -4786,7 +5220,7 @@ func TestCreateChatModelConfig(t *testing.T) { }) } eg.Go(func() error { - created, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + created, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o", ContextLimit: &contextLimit, @@ -4794,7 +5228,7 @@ func TestCreateChatModelConfig(t *testing.T) { if err != nil { return xerrors.Errorf("create claimed config: %w", err) } - claimed, err = client.UpdateChatModel(ctx, created.ID, codersdk.UpdateChatModelRequest{ + claimed, err = client.UpdateChatModel(ctx, created.OrganizationID, created.ID, codersdk.UpdateChatModelRequest{ IsDefault: ptr.Ref(true), }) if err != nil { @@ -4804,11 +5238,11 @@ func TestCreateChatModelConfig(t *testing.T) { }) require.NoError(t, eg.Wait()) - configs, err := client.ChatModels(ctx) + configs, err := client.ChatModels(ctx, firstUser.OrganizationID) require.NoError(t, err) - require.Len(t, configs, creators) + require.Len(t, configs.Models, creators) var defaults []uuid.UUID - for _, cfg := range configs { + for _, cfg := range configs.Models { if cfg.IsDefault { defaults = append(defaults, cfg.ID) } @@ -4816,7 +5250,7 @@ func TestCreateChatModelConfig(t *testing.T) { require.Equal(t, []uuid.UUID{claimed.ID}, defaults) }) - t.Run("SuccessCustomDeploymentConfigRole", func(t *testing.T) { + t.Run("SuccessCustomOrgRole", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -4826,42 +5260,68 @@ func TestCreateChatModelConfig(t *testing.T) { opts.Pubsub = pubsub }) _ = coderdtest.CreateFirstUser(t, client.Client) - nonDefaultOrg := dbgen.Organization(t, rawDB, database.Organization{IsDefault: false}) + defaultOrg, err := rawDB.GetDefaultOrganization(ctx) + require.NoError(t, err) - // A restricted system context supplies organization read permission for - // the internal lookup, so the user's role does not require it. - adminClient := newSiteCustomRoleClient( - ctx, - t, - client, - rawDB, - nonDefaultOrg.ID, - database.CustomRolePermission{ - ResourceType: rbac.ResourceDeploymentConfig.Type, - Action: policy.ActionRead, - }, - database.CustomRolePermission{ - ResourceType: rbac.ResourceDeploymentConfig.Type, - Action: policy.ActionUpdate, + // A custom org role holding only chat_model_config permissions + // passes write authorization for configs in its org and holds no + // grant anywhere else; org role permissions are scoped to the + // organization the role belongs to. + role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: testutil.GetRandomName(t), + DisplayName: "Chat Model Config Admin", + OrganizationID: uuid.NullUUID{UUID: defaultOrg.ID, Valid: true}, + OrgPermissions: database.CustomRolePermissions{ + { + ResourceType: rbac.ResourceChatModelConfig.Type, + Action: policy.ActionCreate, + }, + { + ResourceType: rbac.ResourceChatModelConfig.Type, + Action: policy.ActionRead, + }, + { + ResourceType: rbac.ResourceChatModelConfig.Type, + Action: policy.ActionUpdate, + }, }, - ) + }) + require.NoError(t, err) + // An existing org default keeps the create off the self-promotion + // path; the writer below holds no chat_model_config:update grant + // beyond its own org. The owner seeds the provider and the + // default: both operations need site-wide permissions a + // non-owner lacks. aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") + _ = createAdditionalChatModel(t, client, "openai", "gpt-4o-existing") + + adminClientRaw, adminUser := coderdtest.CreateAnotherUser( + t, + client.Client, + defaultOrg.ID, + ) + _, err = client.Client.UpdateOrganizationMemberRoles( + ctx, + defaultOrg.ID, + adminUser.ID.String(), + codersdk.UpdateRoles{Roles: []string{role.Name}}, + ) + require.NoError(t, err) + adminClient := codersdk.NewExperimentalClient(adminClientRaw) contextLimit := int64(4096) - modelConfig, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + modelConfig, err := adminClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, }) require.NoError(t, err) - defaultOrg, err := rawDB.GetDefaultOrganization(ctx) - require.NoError(t, err) row, err := rawDB.GetChatModelConfigByID(ctx, modelConfig.ID) require.NoError(t, err) require.Equal(t, defaultOrg.ID, row.OrganizationID) - require.True(t, row.IsDefault) + require.False(t, row.IsDefault) require.Equal( t, database.ChatACL{ @@ -4871,101 +5331,117 @@ func TestCreateChatModelConfig(t *testing.T) { ) require.Equal(t, database.ChatACL{}, row.UserACL) }) - - // Deployment-config administrators can manage configs and defaults. - t.Run("DeploymentConfigOnlyRoleWritesThroughWindow", func(t *testing.T) { + // An organization role can manage the full model lifecycle. Default + // election must not select another organization's config. + t.Run("OrgRoleWritesThroughLifecycle", func(t *testing.T) { t.Parallel() - setupCtx := testutil.Context(t, testutil.WaitLong) + 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) - nonDefaultOrg := dbgen.Organization(t, rawDB, database.Organization{IsDefault: false}) + defaultOrg, err := rawDB.GetDefaultOrganization(ctx) + require.NoError(t, err) - writerClient := newSiteCustomRoleClient( - setupCtx, - t, - client, - rawDB, - nonDefaultOrg.ID, - database.CustomRolePermission{ - ResourceType: rbac.ResourceDeploymentConfig.Type, - Action: policy.ActionRead, - }, - database.CustomRolePermission{ - ResourceType: rbac.ResourceDeploymentConfig.Type, - Action: policy.ActionUpdate, + // The writer role holds chat_model_config permissions in the + // default org and nothing else: user-scoped member permissions do + // not carry the org-wide create grant the route needs, so the role + // must supply every action the lifecycle touches. + role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: testutil.GetRandomName(t), + DisplayName: "Chat Model Config Admin", + OrganizationID: uuid.NullUUID{UUID: defaultOrg.ID, Valid: true}, + OrgPermissions: database.CustomRolePermissions{ + { + ResourceType: rbac.ResourceChatModelConfig.Type, + Action: policy.ActionCreate, + }, + { + ResourceType: rbac.ResourceChatModelConfig.Type, + Action: policy.ActionRead, + }, + { + ResourceType: rbac.ResourceChatModelConfig.Type, + Action: policy.ActionUpdate, + }, + { + ResourceType: rbac.ResourceChatModelConfig.Type, + Action: policy.ActionDelete, + }, }, - ) + }) + require.NoError(t, err) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") - // The first config self-elects as the deployment default. The existing - // default keeps the writer's own create off the self-promotion path. - defaultConfig := createChatModelConfig(t, client) + + writerClientRaw, writerUser := coderdtest.CreateAnotherUser( + t, + client.Client, + defaultOrg.ID, + ) + _, err = client.Client.UpdateOrganizationMemberRoles( + ctx, + defaultOrg.ID, + writerUser.ID.String(), + codersdk.UpdateRoles{Roles: []string{role.Name}}, + ) + require.NoError(t, err) + writerClient := codersdk.NewExperimentalClient(writerClientRaw) contextLimit := int64(4096) - modelConfig, err := writerClient.CreateChatModel(setupCtx, codersdk.CreateChatModelRequest{ + defaultConfig, err := writerClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, }) require.NoError(t, err) + require.True(t, defaultConfig.IsDefault) - updated, err := writerClient.UpdateChatModel(setupCtx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := writerClient.UpdateChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID, codersdk.UpdateChatModelRequest{ DisplayName: "Window Write", }) require.NoError(t, err) require.Equal(t, "Window Write", updated.DisplayName) - require.NoError(t, writerClient.DeleteChatModel(setupCtx, modelConfig.ID)) + candidate, err := writerClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{ + AIProviderID: &aiProvider.ID, + Model: "gpt-4o-mini-candidate", + ContextLimit: &contextLimit, + }) + require.NoError(t, err) - // TODO(mafredri): remove these default-transition checks after - // CODAGT-709 M3 (org-scoping cutover). - ctx := testutil.Context(t, testutil.WaitLong) notDefault := false - updated, err = writerClient.UpdateChatModel(ctx, defaultConfig.ID, codersdk.UpdateChatModelRequest{ + _, err = writerClient.UpdateChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID, codersdk.UpdateChatModelRequest{ IsDefault: ¬Default, }) require.NoError(t, err) - require.True(t, updated.IsDefault, "the sole config must remain default") - row, err := rawDB.GetChatModelConfigByID(ctx, defaultConfig.ID) + candidateRow, err := rawDB.GetChatModelConfigByID(ctx, candidate.ID) require.NoError(t, err) - require.True(t, row.IsDefault, "the sole config must remain default") + require.True(t, candidateRow.IsDefault) - replacement, err := writerClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ - AIProviderID: &aiProvider.ID, - Model: "gpt-4o-mini-replacement", - ContextLimit: &contextLimit, - }) + err = writerClient.DeleteChatModel(ctx, candidate.OrganizationID, candidate.ID) require.NoError(t, err) - require.NoError(t, writerClient.DeleteChatModel(ctx, defaultConfig.ID)) - _, err = rawDB.GetChatModelConfigByID(ctx, defaultConfig.ID) - require.ErrorIs(t, err, sql.ErrNoRows) - row, err = rawDB.GetChatModelConfigByID(ctx, replacement.ID) + defaultRow, err := rawDB.GetChatModelConfigByID(ctx, defaultConfig.ID) require.NoError(t, err) - require.True(t, row.IsDefault, "replacement must be promoted") + require.True(t, defaultRow.IsDefault) - candidate, err := writerClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + promoted, err := writerClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, - Model: "gpt-4o-mini-promote", + Model: "gpt-4o-mini-promoted", ContextLimit: &contextLimit, }) require.NoError(t, err) isDefault := true - updated, err = writerClient.UpdateChatModel(ctx, candidate.ID, codersdk.UpdateChatModelRequest{ + _, err = writerClient.UpdateChatModel(ctx, promoted.OrganizationID, promoted.ID, codersdk.UpdateChatModelRequest{ IsDefault: &isDefault, }) require.NoError(t, err) - require.True(t, updated.IsDefault) - row, err = rawDB.GetChatModelConfigByID(ctx, candidate.ID) - require.NoError(t, err) - require.True(t, row.IsDefault, "candidate must be promoted") - row, err = rawDB.GetChatModelConfigByID(ctx, replacement.ID) + promotedRow, err := rawDB.GetChatModelConfigByID(ctx, promoted.ID) require.NoError(t, err) - require.False(t, row.IsDefault, "existing default must be demoted") + require.True(t, promotedRow.IsDefault) }) t.Run("ReasoningEffortStored", func(t *testing.T) { @@ -4973,12 +5449,12 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") contextLimit := int64(4096) - modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, @@ -5004,12 +5480,12 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") contextLimit := int64(4096) - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, @@ -5029,12 +5505,12 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") contextLimit := int64(4096) - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, @@ -5059,12 +5535,12 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") contextLimit := int64(4096) - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, @@ -5085,10 +5561,10 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-mini", }) @@ -5101,10 +5577,10 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) contextLimit := int64(4096) - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ Model: "gpt-4o-mini", ContextLimit: &contextLimit, }) @@ -5117,11 +5593,11 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) contextLimit := int64(4096) missingProviderID := uuid.New() - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &missingProviderID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, @@ -5135,7 +5611,7 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "test-model-config-provider-" + uuid.NewString(), @@ -5145,7 +5621,7 @@ func TestCreateChatModelConfig(t *testing.T) { require.NoError(t, err) contextLimit := int64(4096) - modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, @@ -5160,11 +5636,11 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) missingProviderID := uuid.New() contextLimit := int64(4096) - _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &missingProviderID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, @@ -5178,7 +5654,7 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "test-disabled-model-provider-" + uuid.NewString(), @@ -5188,7 +5664,7 @@ func TestCreateChatModelConfig(t *testing.T) { require.NoError(t, err) contextLimit := int64(4096) - _, err = client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err = client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, @@ -5202,7 +5678,7 @@ func TestCreateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, @@ -5214,7 +5690,7 @@ func TestCreateChatModelConfig(t *testing.T) { require.NoError(t, err) contextLimit := int64(4096) - _, err = client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err = client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "anthropic/claude-opus-4.6", ContextLimit: &contextLimit, @@ -5236,16 +5712,20 @@ func TestCreateChatModelConfig(t *testing.T) { aiProvider := createAIProviderForTest(t, adminClient, "openai", "test-api-key") contextLimit := int64(4096) - _, err := memberClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := memberClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, }) - requireSDKError(t, err, http.StatusForbidden) + // A plain org member holds no chat model config permissions in the + // org, so the create is rejected by the write pre-check before any + // privileged provider lookup. + sdkErr := requireSDKError(t, err, http.StatusForbidden) + require.Equal(t, "Forbidden.", sdkErr.Message) }) } -func TestUpdateChatModelConfig(t *testing.T) { +func TestUpdateChatModel(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { @@ -5254,10 +5734,10 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) contextLimit := int64(8192) - updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ DisplayName: "GPT-4o Mini Updated", ContextLimit: &contextLimit, }) @@ -5266,32 +5746,9 @@ func TestUpdateChatModelConfig(t *testing.T) { require.Equal(t, "GPT-4o Mini Updated", updated.DisplayName) require.EqualValues(t, 8192, updated.ContextLimit) - configs, err := client.ChatModels(ctx) + configs, err := client.ChatModels(ctx, modelConfig.OrganizationID) require.NoError(t, err) - require.Len(t, configs, 1) - }) - - t.Run("RejectsOtherOrganizationConfig", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) - _ = coderdtest.CreateFirstUser(t, client.Client) - defaultConfig := createChatModelConfig(t, client) - otherOrg := dbgen.Organization(t, db, database.Organization{}) - otherConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - OrganizationID: otherOrg.ID, - AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true}, - }) - - _, err := client.UpdateChatModel(ctx, otherConfig.ID, codersdk.UpdateChatModelRequest{ - DisplayName: "not allowed", - }) - requireSDKError(t, err, http.StatusNotFound) - - stored, err := db.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), otherConfig.ID) - require.NoError(t, err) - require.NotEqual(t, "not allowed", stored.DisplayName) + require.Len(t, configs.Models, 1) }) t.Run("SoleConfigRemainsDefaultWhenDemoted", func(t *testing.T) { @@ -5300,9 +5757,9 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) - updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ IsDefault: ptr.Ref(false), }) require.NoError(t, err) @@ -5319,8 +5776,8 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - defaultConfig := createChatModelConfig(t, client) - candidateConfig := createAdditionalChatModelConfig( + defaultConfig := createChatModel(t, client) + candidateConfig := createAdditionalChatModel( t, client, coderdtest.TestChatProviderOpenAICompat, @@ -5334,7 +5791,7 @@ func TestUpdateChatModelConfig(t *testing.T) { IsDefault: true, }) - updated, err := client.UpdateChatModel(ctx, candidateConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := client.UpdateChatModel(ctx, candidateConfig.OrganizationID, candidateConfig.ID, codersdk.UpdateChatModelRequest{ IsDefault: ptr.Ref(true), }) require.NoError(t, err) @@ -5354,9 +5811,9 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) - updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ Model: "gpt-4o-mini-updated", }) require.NoError(t, err) @@ -5371,7 +5828,7 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, @@ -5383,14 +5840,14 @@ func TestUpdateChatModelConfig(t *testing.T) { require.NoError(t, err) contextLimit := int64(4096) - modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-mini", ContextLimit: &contextLimit, }) require.NoError(t, err) - _, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + _, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ Model: "anthropic/claude-opus-4.6", }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) @@ -5419,7 +5876,7 @@ func TestUpdateChatModelConfig(t *testing.T) { AIProviderID: uuid.NullUUID{UUID: aiProvider.ID, Valid: true}, }) - updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ DisplayName: "Existing OpenRouter Config", }) require.NoError(t, err) @@ -5432,7 +5889,7 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) validProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenrouter, @@ -5452,14 +5909,14 @@ func TestUpdateChatModelConfig(t *testing.T) { require.NoError(t, err) contextLimit := int64(4096) - modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &validProvider.ID, Model: "anthropic/claude-opus-4.6", ContextLimit: &contextLimit, }) require.NoError(t, err) - _, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + _, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ AIProviderID: &misconfiguredProvider.ID, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) @@ -5467,29 +5924,29 @@ func TestUpdateChatModelConfig(t *testing.T) { require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.") }) - t.Run("DisablePreservesRecordAndStateForNonAdmins", func(t *testing.T) { + t.Run("DisablePreservesRecordAndRejectsUseForMembers", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - modelConfig := createChatModelConfig(t, adminClient) + modelConfig := createChatModel(t, adminClient) enabled := false - updated, err := adminClient.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := adminClient.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ Enabled: &enabled, }) require.NoError(t, err) require.Equal(t, modelConfig.ID, updated.ID) require.False(t, updated.Enabled) - adminConfigs, err := adminClient.ChatModels(ctx) + adminConfigs, err := adminClient.ChatModels(ctx, firstUser.OrganizationID) require.NoError(t, err) foundForAdmin := false - for _, config := range adminConfigs { + for _, config := range adminConfigs.Models { if config.ID == modelConfig.ID { foundForAdmin = true require.False(t, config.Enabled) @@ -5497,20 +5954,27 @@ func TestUpdateChatModelConfig(t *testing.T) { } require.True(t, foundForAdmin) - memberConfigs, err := memberClient.ChatModels(ctx) + availability, err := memberClient.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) - - foundForMember := false - for _, config := range memberConfigs { - if config.ID == modelConfig.ID { - foundForMember = true - require.False(t, config.Enabled) - } + runtimeIDs := make([]uuid.UUID, 0, len(availability.Models)) + for _, config := range availability.Models { + runtimeIDs = append(runtimeIDs, config.ID) } - require.True(t, foundForMember) + require.NotContains(t, runtimeIDs, modelConfig.ID) + + _, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "use a disabled model config", + }}, + ModelConfigID: &modelConfig.ID, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message) }) - t.Run("ReEnableUpdatesStateForNonAdmins", func(t *testing.T) { + t.Run("ReEnableUpdatesRuntimeAvailability", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -5523,7 +5987,7 @@ func TestUpdateChatModelConfig(t *testing.T) { contextLimit := int64(4096) enabled := false - modelConfig, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + modelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "gpt-4o-reenable", DisplayName: "GPT-4o Re-enable", @@ -5533,37 +5997,29 @@ func TestUpdateChatModelConfig(t *testing.T) { require.NoError(t, err) require.False(t, modelConfig.Enabled) - memberConfigs, err := memberClient.ChatModels(ctx) + availability, err := memberClient.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) - - foundForMember := false - for _, config := range memberConfigs { - if config.ID == modelConfig.ID { - foundForMember = true - require.False(t, config.Enabled) - } + runtimeIDs := make([]uuid.UUID, 0, len(availability.Models)) + for _, config := range availability.Models { + runtimeIDs = append(runtimeIDs, config.ID) } - require.True(t, foundForMember) + require.NotContains(t, runtimeIDs, modelConfig.ID) enabled = true - updated, err := adminClient.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := adminClient.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ Enabled: &enabled, }) require.NoError(t, err) require.Equal(t, modelConfig.ID, updated.ID) require.True(t, updated.Enabled) - memberConfigs, err = memberClient.ChatModels(ctx) + availability, err = memberClient.ChatModelAvailability(ctx, firstUser.OrganizationID) require.NoError(t, err) - - foundForMember = false - for _, config := range memberConfigs { - if config.ID == modelConfig.ID { - foundForMember = true - require.True(t, config.Enabled) - } + runtimeIDs = runtimeIDs[:0] + for _, config := range availability.Models { + runtimeIDs = append(runtimeIDs, config.ID) } - require.True(t, foundForMember) + require.Contains(t, runtimeIDs, modelConfig.ID) }) t.Run("UpdateAIProviderID", func(t *testing.T) { @@ -5572,7 +6028,7 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeAnthropic, Name: "test-update-model-provider-" + uuid.NewString(), @@ -5581,7 +6037,7 @@ func TestUpdateChatModelConfig(t *testing.T) { }) require.NoError(t, err) - updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ AIProviderID: &provider.ID, Model: "claude-3-5-sonnet-latest", }) @@ -5596,7 +6052,7 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeAnthropic, Name: "test-preserve-model-provider-" + uuid.NewString(), @@ -5605,14 +6061,14 @@ func TestUpdateChatModelConfig(t *testing.T) { }) require.NoError(t, err) - updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ AIProviderID: &provider.ID, Model: "claude-3-5-sonnet-latest", }) require.NoError(t, err) require.NotEqual(t, uuid.Nil, updated.AIProviderID) - updated, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ Model: "claude-3-5-haiku-latest", }) require.NoError(t, err) @@ -5626,10 +6082,10 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) missingProviderID := uuid.New() - _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ AIProviderID: &missingProviderID, }) sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed) @@ -5642,7 +6098,7 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "test-update-disabled-model-provider-" + uuid.NewString(), @@ -5651,7 +6107,7 @@ func TestUpdateChatModelConfig(t *testing.T) { }) require.NoError(t, err) - _, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + _, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ AIProviderID: &provider.ID, }) sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed) @@ -5664,10 +6120,10 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) missingProviderID := uuid.New() - _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ AIProviderID: &missingProviderID, }) sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed) @@ -5680,11 +6136,11 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, store := newChatClientWithModelConfigHookStore(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) store.armFailNextUpdate(modelConfig.ID) - _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ DisplayName: "missing in tx", }) requireSDKError(t, err, http.StatusNotFound) @@ -5695,14 +6151,14 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, store := newChatClientWithModelConfigHookStore(t) - _ = coderdtest.CreateFirstUser(t, client.Client) - defaultConfig := createChatModelConfig(t, client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + defaultConfig := createChatModel(t, client) aiProvider := createAIProviderForTest(t, client, "anthropic", "candidate-api-key") contextLimit := int64(4096) isDefault := false - candidateConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + candidateConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "claude-3-5-sonnet", ContextLimit: &contextLimit, @@ -5712,7 +6168,7 @@ func TestUpdateChatModelConfig(t *testing.T) { store.armFailNextUpdate(candidateConfig.ID) - _, err = client.UpdateChatModel(ctx, defaultConfig.ID, codersdk.UpdateChatModelRequest{ + _, err = client.UpdateChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID, codersdk.UpdateChatModelRequest{ IsDefault: ptr.Ref(false), }) sdkErr := requireSDKError(t, err, http.StatusInternalServerError) @@ -5724,9 +6180,9 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) - _, err := client.UpdateChatModel(ctx, uuid.New(), codersdk.UpdateChatModelRequest{ + _, err := client.UpdateChatModel(ctx, firstUser.OrganizationID, uuid.New(), codersdk.UpdateChatModelRequest{ DisplayName: "missing", }) requireSDKError(t, err, http.StatusNotFound) @@ -5738,10 +6194,10 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) contextLimit := int64(0) - _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ ContextLimit: &contextLimit, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) @@ -5756,11 +6212,11 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, store := newChatClientWithModelConfigHookStore(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) store.armVanishAtLockedRead(modelConfig.ID) - _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ DisplayName: "vanished before update", }) requireSDKError(t, err, http.StatusNotFound) @@ -5772,12 +6228,12 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, store := newChatClientWithModelConfigHookStore(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) const sentinel = "locked-copy-sentinel-model" store.armMutateAtLockedRead(modelConfig.ID, sentinel) - updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ DisplayName: "only-display-name", }) require.NoError(t, err) @@ -5791,7 +6247,7 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) disabled := false _, err := client.UpdateAIProvider(ctx, modelConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{ @@ -5799,7 +6255,7 @@ func TestUpdateChatModelConfig(t *testing.T) { }) require.NoError(t, err) - _, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + _, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ Model: "gpt-4o-different", }) sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed) @@ -5812,27 +6268,60 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) err := client.DeleteAIProvider(ctx, modelConfig.AIProviderID.String()) require.NoError(t, err) - _, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + _, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ Model: "gpt-4o-different", }) sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed) require.Equal(t, "AI provider is not configured.", sdkErr.Message) }) + t.Run("RejectsACLKeys", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModel(t, client) + + for _, tc := range []struct { + name string + key string + value any + }{ + {name: "group_acl/object", key: "group_acl", value: map[string]any{}}, + {name: "group_acl/null", key: "group_acl", value: nil}, + {name: "user_acl/object", key: "user_acl", value: map[string]any{}}, + {name: "user_acl/null", key: "user_acl", value: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + res, err := client.Request(ctx, http.MethodPatch, fmt.Sprintf( + "/api/experimental/organizations/%s/chats/models/%s", + modelConfig.OrganizationID, + modelConfig.ID, + ), map[string]any{tc.key: tc.value}) + require.NoError(t, err) + defer res.Body.Close() + err = codersdk.ReadBodyAsError(res) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Message, "nested /acl endpoint") + }) + } + }) + t.Run("CompressionThresholdOutOfRange", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) - _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ CompressionThreshold: ptr.Ref(int32(150)), }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) @@ -5845,9 +6334,9 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) - updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ CompressionThreshold: ptr.Ref(int32(55)), }) require.NoError(t, err) @@ -5860,9 +6349,9 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) - updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ ModelConfig: &codersdk.ChatModelCallConfig{ ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ Default: ptr.Ref("high"), @@ -5882,12 +6371,12 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) res, err := client.Request( ctx, http.MethodPatch, - "/api/experimental/chats/model-configs/not-a-uuid", + fmt.Sprintf("/api/experimental/organizations/%s/chats/models/not-a-uuid", firstUser.OrganizationID), codersdk.UpdateChatModelRequest{DisplayName: "ignored"}, ) require.NoError(t, err) @@ -5895,7 +6384,7 @@ func TestUpdateChatModelConfig(t *testing.T) { err = codersdk.ReadBodyAsError(res) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid chat model config ID.", sdkErr.Message) + require.Equal(t, `Invalid UUID "not-a-uuid".`, sdkErr.Message) }) t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { @@ -5907,11 +6396,13 @@ func TestUpdateChatModelConfig(t *testing.T) { memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - modelConfig := createChatModelConfig(t, adminClient) - _, err := memberClient.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + modelConfig := createChatModel(t, adminClient) + _, err := memberClient.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ DisplayName: "member update", }) - requireSDKError(t, err, http.StatusForbidden) + // Action-aware item admission conceals a caller without update access. + sdkErr := requireSDKError(t, err, http.StatusNotFound) + require.Equal(t, "Resource not found or you do not have access to this resource", sdkErr.Message) }) } @@ -5924,45 +6415,26 @@ func TestDeleteChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) - err := client.DeleteChatModel(ctx, modelConfig.ID) + err := client.DeleteChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID) require.NoError(t, err) - configs, err := client.ChatModels(ctx) + configs, err := client.ChatModels(ctx, modelConfig.OrganizationID) require.NoError(t, err) - for _, config := range configs { + for _, config := range configs.Models { require.NotEqual(t, modelConfig.ID, config.ID) } }) - t.Run("RejectsOtherOrganizationConfig", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) - _ = coderdtest.CreateFirstUser(t, client.Client) - defaultConfig := createChatModelConfig(t, client) - otherOrg := dbgen.Organization(t, db, database.Organization{}) - otherConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - OrganizationID: otherOrg.ID, - AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true}, - }) - - err := client.DeleteChatModel(ctx, otherConfig.ID) - requireSDKError(t, err, http.StatusNotFound) - _, err = db.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), otherConfig.ID) - require.NoError(t, err) - }) - t.Run("DeletionLeavesOtherOrganizationDefaultUnchanged", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - defaultConfig := createChatModelConfig(t, client) - candidateConfig := createAdditionalChatModelConfig( + defaultConfig := createChatModel(t, client) + candidateConfig := createAdditionalChatModel( t, client, coderdtest.TestChatProviderOpenAICompat, @@ -5976,7 +6448,7 @@ func TestDeleteChatModelConfig(t *testing.T) { IsDefault: true, }) - err := client.DeleteChatModel(ctx, defaultConfig.ID) + err := client.DeleteChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID) require.NoError(t, err) orgDefault, err := db.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), firstUser.OrganizationID) @@ -5996,30 +6468,30 @@ func TestDeleteChatModelConfig(t *testing.T) { client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - defaultConfig := createChatModelConfig(t, client) + defaultConfig := createChatModel(t, client) // Same provider type as the enabled candidate with an // alphabetically earlier model, so it sorts first in the // reselection order. - createProviderDisabledChatModelConfig( + createProviderDisabledChatModel( t, client, coderdtest.TestChatProviderOpenAICompat, "a-provider-disabled-model", ) - enabledConfig := createAdditionalChatModelConfig( + enabledConfig := createAdditionalChatModel( t, client, coderdtest.TestChatProviderOpenAICompat, "z-enabled-model", ) - err := client.DeleteChatModel(ctx, defaultConfig.ID) + err := client.DeleteChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID) require.NoError(t, err) - configs, err := client.ChatModels(ctx) + configs, err := client.ChatModels(ctx, defaultConfig.OrganizationID) require.NoError(t, err) defaultID := uuid.Nil - for _, config := range configs { + for _, config := range configs.Models { if config.IsDefault { defaultID = config.ID } @@ -6032,9 +6504,9 @@ func TestDeleteChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) - err := client.DeleteChatModel(ctx, uuid.New()) + err := client.DeleteChatModel(ctx, firstUser.OrganizationID, uuid.New()) requireSDKError(t, err, http.StatusNotFound) }) @@ -6044,11 +6516,11 @@ func TestDeleteChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, store := newChatClientWithModelConfigHookStore(t) _ = coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) store.armFailNextDelete(modelConfig.ID) - err := client.DeleteChatModel(ctx, modelConfig.ID) + err := client.DeleteChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID) requireSDKError(t, err, http.StatusNotFound) }) @@ -6057,12 +6529,12 @@ func TestDeleteChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) + firstUser := coderdtest.CreateFirstUser(t, client.Client) res, err := client.Request( ctx, http.MethodDelete, - "/api/experimental/chats/model-configs/not-a-uuid", + fmt.Sprintf("/api/experimental/organizations/%s/chats/models/not-a-uuid", firstUser.OrganizationID), nil, ) require.NoError(t, err) @@ -6070,7 +6542,7 @@ func TestDeleteChatModelConfig(t *testing.T) { err = codersdk.ReadBodyAsError(res) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid chat model config ID.", sdkErr.Message) + require.Equal(t, `Invalid UUID "not-a-uuid".`, sdkErr.Message) }) t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { @@ -6082,9 +6554,11 @@ func TestDeleteChatModelConfig(t *testing.T) { memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - modelConfig := createChatModelConfig(t, adminClient) - err := memberClient.DeleteChatModel(ctx, modelConfig.ID) - requireSDKError(t, err, http.StatusForbidden) + modelConfig := createChatModel(t, adminClient) + err := memberClient.DeleteChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID) + // Action-aware item admission conceals a caller without delete access. + sdkErr := requireSDKError(t, err, http.StatusNotFound) + require.Equal(t, "Resource not found or you do not have access to this resource", sdkErr.Message) }) } @@ -6097,7 +6571,7 @@ func TestGetChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -6144,7 +6618,7 @@ func TestGetChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -6201,7 +6675,7 @@ func TestGetChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Upload a file. pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) @@ -6240,7 +6714,7 @@ func TestGetChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, store := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Create a chat via the API so all metadata is set up. chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -6349,7 +6823,7 @@ func TestGetChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithAPIAndDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, @@ -6454,7 +6928,7 @@ func TestGetChatUserPrompts(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OrganizationID: user.OrganizationID, @@ -6593,7 +7067,7 @@ func TestGetChatUserPrompts(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OrganizationID: user.OrganizationID, @@ -6627,7 +7101,7 @@ func TestGetChatUserPrompts(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OrganizationID: user.OrganizationID, @@ -6652,7 +7126,7 @@ func TestGetChatUserPrompts(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OrganizationID: firstUser.OrganizationID, @@ -6692,7 +7166,7 @@ func TestGetChatUserPrompts(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) emptyChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OrganizationID: user.OrganizationID, @@ -6809,7 +7283,7 @@ func TestPatchChat(t *testing.T) { opts.Auditor = mAudit }) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "set plan mode") err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ @@ -6836,7 +7310,7 @@ func TestPatchChat(t *testing.T) { opts.Auditor = mAudit }) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "clear plan mode") err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ @@ -6868,7 +7342,7 @@ func TestPatchChat(t *testing.T) { opts.Auditor = mAudit }) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "invalid plan mode") err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ @@ -6897,7 +7371,7 @@ func TestPatchChat(t *testing.T) { opts.Auditor = mAudit }) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: firstUser.OrganizationID, @@ -6940,7 +7414,7 @@ func TestPatchChat(t *testing.T) { opts.Auditor = mAudit }) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := createStoredChat( ctx, @@ -6974,7 +7448,7 @@ func TestPatchChat(t *testing.T) { opts.Auditor = mAudit }) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) secondOrg := dbgen.Organization(t, db, database.Organization{}) dbgen.OrganizationMember(t, db, database.OrganizationMember{ @@ -7017,7 +7491,7 @@ func TestPatchChat(t *testing.T) { opts.Auditor = mAudit }) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: firstUser.OrganizationID, @@ -7066,7 +7540,7 @@ func TestPatchChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, api := newChatClientWithAPI(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "original title") @@ -7087,7 +7561,7 @@ func TestPatchChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, api := newChatClientWithAPI(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "before trim") @@ -7108,7 +7582,7 @@ func TestPatchChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "keep original") @@ -7127,7 +7601,7 @@ func TestPatchChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "keep original length") @@ -7187,7 +7661,7 @@ func TestPatchChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, api := newChatClientWithAPI(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "boundary baseline") coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) @@ -7221,7 +7695,7 @@ func TestPatchChat(t *testing.T) { aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) client := codersdk.NewExperimentalClient(clientRaw) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "rename me") coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) @@ -7259,7 +7733,7 @@ func TestPatchChat(t *testing.T) { aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) client := codersdk.NewExperimentalClient(clientRaw) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "steady title") coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) @@ -7288,7 +7762,7 @@ func TestPatchChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, api := newChatClientWithAPI(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "announce me") @@ -7332,7 +7806,7 @@ func TestArchiveChat(t *testing.T) { o.Auditor = mAudit }) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chatToArchive, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -7414,7 +7888,7 @@ func TestArchiveChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithAPIAndDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Create a parent chat via the API. parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -7503,7 +7977,7 @@ func TestArchiveChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithAPIAndDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Create a parent chat via the API. parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -7552,7 +8026,7 @@ func TestUnarchiveChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, api := newChatClientWithAPI(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -7604,7 +8078,7 @@ func TestUnarchiveChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithAPIAndDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, @@ -7697,7 +8171,7 @@ func TestUnarchiveChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, api := newChatClientWithAPI(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -7722,7 +8196,7 @@ func TestUnarchiveChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithAPIAndDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Create a parent chat via the API. parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -7770,7 +8244,7 @@ func TestUnarchiveChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithAPIAndDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, @@ -7854,7 +8328,7 @@ func TestChatPinOrder(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) first := createChat(ctx, t, client, firstUser.OrganizationID, "first pinned chat") second := createChat(ctx, t, client, firstUser.OrganizationID, "second pinned chat") @@ -7901,7 +8375,7 @@ func TestChatPinOrder(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, api := newChatClientWithAPI(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) first := createChat(ctx, t, client, firstUser.OrganizationID, "pinned then archived") second := createChat(ctx, t, client, firstUser.OrganizationID, "stays pinned") @@ -7933,7 +8407,7 @@ func TestChatPinOrder(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "negative pin order") err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(-1))}) @@ -7950,7 +8424,7 @@ func TestChatPinOrder(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) parentChat := createChat(ctx, t, client, firstUser.OrganizationID, "parent chat") @@ -7983,7 +8457,7 @@ func TestPostChatMessages(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -8074,7 +8548,7 @@ func TestPostChatMessages(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -8085,7 +8559,7 @@ func TestPostChatMessages(t *testing.T) { }) require.NoError(t, err) - providerDisabledConfig := createProviderDisabledChatModelConfig( + providerDisabledConfig := createProviderDisabledChatModel( t, client, "openai", @@ -8103,13 +8577,113 @@ func TestPostChatMessages(t *testing.T) { require.Equal(t, "Invalid model_config_id: provider is not enabled for this model.", sdkErr.Message) }) + t.Run("CrossOrgModelConfigRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + defaultConfig := createChatModel(t, client) + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "initial message before cross-org switch", + }}, + }) + require.NoError(t, err) + + otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + otherConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true}, + Model: "cross-org-send-" + uuid.NewString(), + Enabled: true, + OrganizationID: otherOrg.ID, + }) + _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "reject another organization's model", + }}, + ModelConfigID: ptr.Ref(otherConfig.ID), + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message) + }) + + t.Run("HistoricalForeignModelFallsBackToLocalDefault", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + foreignConfig := createChatModel(t, client) + provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key") + chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + localConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, + Model: "local-send-" + uuid.NewString(), + Enabled: true, + IsDefault: true, + OrganizationID: chatOrg.ID, + }) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: chatOrg.ID, + OwnerID: member.ID, + LastModelConfigID: foreignConfig.ID, + Title: "historical foreign model", + }) + + resp, err := memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "use the local fallback", + }}, + }) + require.NoError(t, err) + require.NotNil(t, resp.Message) + require.NotNil(t, resp.Message.ModelConfigID) + require.Equal(t, localConfig.ID, *resp.Message.ModelConfigID) + require.Equal(t, firstUser.OrganizationID, foreignConfig.OrganizationID) + }) + + t.Run("HistoricalForeignModelWithoutLocalDefaultReturnsGuidance", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + foreignConfig := createChatModel(t, client) + chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: chatOrg.ID, + OwnerID: member.ID, + LastModelConfigID: foreignConfig.ID, + Title: "historical foreign model without fallback", + }) + + _, err := memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "require a local fallback", + }}, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) + }) + t.Run("ProviderDisabledDefaultFallbackRejected", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - defaultConfig := createChatModelConfig(t, client) + defaultConfig := createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -8134,7 +8708,8 @@ func TestPostChatMessages(t *testing.T) { }}, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "No default chat model config is configured.", sdkErr.Message) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) }) t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { @@ -8143,7 +8718,7 @@ func TestPostChatMessages(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Create a member without agents-access and insert a // chat owned by them via system context. Without @@ -8176,7 +8751,7 @@ func TestPostChatMessages(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -8208,7 +8783,7 @@ func TestPostChatMessages(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) _, err := client.CreateChatMessage(ctx, uuid.New(), codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{ @@ -8227,7 +8802,7 @@ func TestPostChatMessages(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, api := newChatClientWithAPI(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -8279,8 +8854,8 @@ func TestSendMessageWithModelOverrideUpdatesLastModelConfigID(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfigA := createChatModelConfig(t, client) - modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-override-"+uuid.NewString()) + modelConfigA := createChatModel(t, client) + modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-override-"+uuid.NewString()) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -8322,7 +8897,7 @@ func TestSendMessageWithReasoningEffortUpdatesLastReasoningEffort(t *testing.T) ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -8378,7 +8953,7 @@ func TestSendMessageRejectsInvalidReasoningEffort(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -8406,7 +8981,7 @@ func TestSendMessageQueuesReasoningEffort(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -8453,8 +9028,8 @@ func TestSendMessageQueuesEffectiveModelConfigID(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfigA := createChatModelConfig(t, client) - modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-queued-"+uuid.NewString()) + modelConfigA := createChatModel(t, client) + modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-queued-"+uuid.NewString()) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -8508,8 +9083,8 @@ func TestQueuedMessageWithoutOverrideCapturesEnqueueTimeModel(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfigA := createChatModelConfig(t, client) - modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-later-"+uuid.NewString()) + modelConfigA := createChatModel(t, client) + modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-later-"+uuid.NewString()) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -8560,8 +9135,8 @@ func TestSubsequentSendWithoutOverrideUsesPersistedModel(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) - modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-persisted-"+uuid.NewString()) + _ = createChatModel(t, client) + modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-persisted-"+uuid.NewString()) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -8601,8 +9176,8 @@ func TestWatchChatsStatusChangeCarriesUpdatedLastModelConfigID(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfigA := createChatModelConfig(t, client) - modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-watch-direct-"+uuid.NewString()) + modelConfigA := createChatModel(t, client) + modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-watch-direct-"+uuid.NewString()) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -8634,8 +9209,8 @@ func TestWatchChatsStatusChangeCarriesUpdatedLastModelConfigID(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfigA := createChatModelConfig(t, client) - modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-watch-promote-"+uuid.NewString()) + modelConfigA := createChatModel(t, client) + modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-watch-promote-"+uuid.NewString()) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -8719,7 +9294,7 @@ func TestChatMessageWithFileReferences(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChatForTest(t, client, firstUser.OrganizationID) created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ @@ -8781,7 +9356,7 @@ func TestChatMessageWithFileReferences(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChatForTest(t, client, firstUser.OrganizationID) created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ @@ -8834,7 +9409,7 @@ func TestChatMessageWithFileReferences(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChatForTest(t, client, firstUser.OrganizationID) created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ @@ -8887,7 +9462,7 @@ func TestChatMessageWithFileReferences(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChatForTest(t, client, firstUser.OrganizationID) created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ @@ -8940,7 +9515,7 @@ func TestChatMessageWithFileReferences(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChatForTest(t, client, firstUser.OrganizationID) created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ @@ -9053,7 +9628,7 @@ func TestChatMessageWithFileReferences(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat := createChatForTest(t, client, firstUser.OrganizationID) _, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ @@ -9075,7 +9650,7 @@ func TestChatMessageWithFileReferences(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // File references should also work in the initial CreateChat call. chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -9106,7 +9681,7 @@ func TestChatMessageWithFiles(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Upload a file. pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) @@ -9150,7 +9725,7 @@ func TestChatMessageWithFiles(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Upload a file. pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) @@ -9210,7 +9785,7 @@ func TestChatMessageWithFiles(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Upload a file. pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) @@ -9249,7 +9824,7 @@ func TestChatMessageWithFiles(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Upload a synthetic pasted-text attachment as created by the // chat UI when a large paste is collapsed into a file. @@ -9284,7 +9859,7 @@ func TestChatMessageWithFiles(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Create a chat with text first. chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -9318,7 +9893,7 @@ func TestChatMessageWithFiles(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, store := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -9363,7 +9938,7 @@ func TestChatMessageWithFiles(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Create a text-only chat (no files initially). chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -9402,7 +9977,7 @@ func TestChatMessageWithFiles(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Upload a file. pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) @@ -9440,7 +10015,7 @@ func TestChatMessageWithFiles(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) @@ -9520,7 +10095,7 @@ func TestChatMessageWithFiles(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) @@ -9561,7 +10136,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -9632,6 +10207,131 @@ func TestPatchChatMessage(t *testing.T) { require.False(t, foundOriginalInChat) }) + t.Run("CrossOrgModelConfigRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + defaultConfig := createChatModel(t, client) + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "before cross-org edit", + }}, + }) + require.NoError(t, err) + messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil) + require.NoError(t, err) + userMessageID := messagesResult.Messages[0].ID + + otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + otherConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true}, + Model: "cross-org-edit-" + uuid.NewString(), + Enabled: true, + OrganizationID: otherOrg.ID, + }) + _, err = client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "reject another organization's model", + }}, + ModelConfigID: ptr.Ref(otherConfig.ID), + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message) + + storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + require.Equal(t, defaultConfig.ID, storedChat.LastModelConfigID) + }) + + t.Run("HistoricalForeignModelFallsBackToLocalDefault", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + foreignConfig := createChatModel(t, client) + provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key") + chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + localConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, + Model: "local-edit-" + uuid.NewString(), + Enabled: true, + IsDefault: true, + OrganizationID: chatOrg.ID, + }) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: chatOrg.ID, + OwnerID: member.ID, + LastModelConfigID: foreignConfig.ID, + Title: "historical foreign model edit", + }) + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText("before edit")}) + require.NoError(t, err) + message := dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + CreatedBy: uuid.NullUUID{UUID: member.ID, Valid: true}, + ModelConfigID: uuid.NullUUID{UUID: foreignConfig.ID, Valid: true}, + Role: database.ChatMessageRoleUser, + Visibility: database.ChatMessageVisibilityBoth, + Content: content, + }) + + edited, err := memberClient.EditChatMessage(ctx, chat.ID, message.ID, codersdk.EditChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "after edit", + }}, + }) + require.NoError(t, err) + require.NotNil(t, edited.Message.ModelConfigID) + require.Equal(t, localConfig.ID, *edited.Message.ModelConfigID) + }) + + t.Run("HistoricalForeignModelWithoutLocalDefaultReturnsGuidance", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + foreignConfig := createChatModel(t, client) + chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: chatOrg.ID, + OwnerID: member.ID, + LastModelConfigID: foreignConfig.ID, + Title: "historical foreign model edit without fallback", + }) + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText("before edit")}) + require.NoError(t, err) + message := dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + CreatedBy: uuid.NullUUID{UUID: member.ID, Valid: true}, + ModelConfigID: uuid.NullUUID{UUID: foreignConfig.ID, Valid: true}, + Role: database.ChatMessageRoleUser, + Visibility: database.ChatMessageVisibilityBoth, + Content: content, + }) + + _, err = memberClient.EditChatMessage(ctx, chat.ID, message.ID, codersdk.EditChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "after edit", + }}, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) + }) + t.Run("ReasoningEffort", func(t *testing.T) { t.Parallel() @@ -9649,7 +10349,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -9692,7 +10392,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -9726,7 +10426,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Upload a file. pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) @@ -9823,7 +10523,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -9854,7 +10554,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -9894,7 +10594,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Create a text-only chat. chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -9947,7 +10647,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) // Create a chat with MaxChatFileIDs files already linked. parts := []codersdk.ChatInputPart{ @@ -10012,7 +10712,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, api := newChatClientWithAPI(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -10057,8 +10757,8 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - defaultModel := createChatModelConfig(t, client) - overrideModel := createAdditionalChatModelConfig( + defaultModel := createChatModel(t, client) + overrideModel := createAdditionalChatModel( t, client, coderdtest.TestChatProviderOpenAICompat, @@ -10137,7 +10837,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -10177,7 +10877,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -10199,7 +10899,7 @@ func TestPatchChatMessage(t *testing.T) { } require.NotZero(t, userMessageID) - providerDisabledConfig := createProviderDisabledChatModelConfig( + providerDisabledConfig := createProviderDisabledChatModel( t, client, "openai", @@ -10222,7 +10922,7 @@ func TestPatchChatMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - defaultConfig := createChatModelConfig(t, client) + defaultConfig := createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -10258,7 +10958,8 @@ func TestPatchChatMessage(t *testing.T) { }}, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "No default chat model config is configured.", sdkErr.Message) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) }) } @@ -10271,7 +10972,7 @@ func TestStreamChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) const initialMessage = "stream chat route initial message" chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -10347,7 +11048,7 @@ func TestInterruptChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -10441,7 +11142,7 @@ func TestCompactChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID) // Assert on the response snapshot only: the chat is runnable @@ -10459,7 +11160,7 @@ func TestCompactChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Idle chat with only a user message: no assistant turn to // summarize. @@ -10497,7 +11198,7 @@ func TestCompactChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID) _, err := db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ @@ -10526,7 +11227,7 @@ func TestCompactChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID) _, err := db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ @@ -10550,7 +11251,7 @@ func TestCompactChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID) _, err := db.ArchiveChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) @@ -10597,7 +11298,7 @@ func TestCompactChat(t *testing.T) { db := api.Database client := codersdk.NewExperimentalClient(clientRaw) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID) _, err := client.CompactChat(ctx, chat.ID) @@ -10638,7 +11339,7 @@ func TestRegenerateChatTitle(t *testing.T) { db := api.Database client := codersdk.NewExperimentalClient(clientRaw) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -10657,7 +11358,7 @@ func TestRegenerateChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -10682,7 +11383,7 @@ func TestRegenerateChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -10704,7 +11405,7 @@ func TestRegenerateChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithoutAIBridge(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createTitleGenerationModelConfig(t, client) + modelConfig := createTitleGenerationChatModel(t, client) aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) chat := dbgen.Chat(t, db, database.Chat{ @@ -10729,7 +11430,7 @@ func TestRegenerateChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithoutAIBridge(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createTitleGenerationModelConfig(t, client) + modelConfig := createTitleGenerationChatModel(t, client) // Wire the daemon's reload subscription to a pubsub coderd never // publishes to: gateway routes can then only come from the @@ -10760,7 +11461,7 @@ func TestRegenerateChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithoutAIBridge(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createTitleGenerationModelConfig(t, client) + modelConfig := createTitleGenerationChatModel(t, client) aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) chat := dbgen.Chat(t, db, database.Chat{ @@ -10802,7 +11503,8 @@ func TestRegenerateChatTitle(t *testing.T) { _, err := client.RegenerateChatTitle(ctx, chat.ID) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "No default chat model config is configured.", sdkErr.Message) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) }) t.Run("RegenerationFailure", func(t *testing.T) { @@ -10811,7 +11513,7 @@ func TestRegenerateChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithoutAIBridge(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfigWithTitleFailure(t, client) + _ = createChatModelWithTitleFailure(t, client) aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -10854,7 +11556,7 @@ func TestRegenerateChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithAPIAndDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfigWithTitleQuotaExhausted(t, client) + _ = createChatModelWithTitleQuotaExhausted(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -10920,7 +11622,7 @@ func TestProposeChatTitle(t *testing.T) { db := api.Database client := codersdk.NewExperimentalClient(clientRaw) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -10940,7 +11642,7 @@ func TestProposeChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -10962,7 +11664,7 @@ func TestProposeChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithoutAIBridge(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createTitleGenerationModelConfig(t, client) + modelConfig := createTitleGenerationChatModel(t, client) aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) chat := dbgen.Chat(t, db, database.Chat{ @@ -11002,7 +11704,8 @@ func TestProposeChatTitle(t *testing.T) { _, err := client.ProposeChatTitle(ctx, chat.ID) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "No default chat model config is configured.", sdkErr.Message) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) }) t.Run("StoppedWorkspace", func(t *testing.T) { @@ -11011,7 +11714,7 @@ func TestProposeChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithoutAIBridge(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createTitleGenerationModelConfig(t, client) + modelConfig := createTitleGenerationChatModel(t, client) aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ @@ -11047,7 +11750,7 @@ func TestProposeChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db, api := newChatClientWithoutAIBridge(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfigWithTitleFailure(t, client) + _ = createChatModelWithTitleFailure(t, client) aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -11080,7 +11783,7 @@ func TestProposeChatTitle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, _, api := newChatClientWithAPIAndDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfigWithTitleQuotaExhausted(t, client) + _ = createChatModelWithTitleQuotaExhausted(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -11133,7 +11836,7 @@ func TestManualTitleEndpointsPassOwnerSyntheticAPIKeyToAIGateway(t *testing.T) { opts.DeploymentValues = values }) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createAdditionalChatModelConfig(t, client, "openai", "gpt-4.1") + modelConfig := createAdditionalChatModel(t, client, "openai", "gpt-4.1") wantTitle := "Fallback title" seenAPIKeyID := make(chan string, 1) stub := &stubTransportFactory{ @@ -11208,7 +11911,7 @@ func TestPostChats_AutomaticTitleGeneration(t *testing.T) { client, _, api := newChatClientWithoutAIBridge(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfigWithBaseURL(t, client, baseURL) + _ = createChatModelWithBaseURL(t, client, baseURL) aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -11264,7 +11967,7 @@ func TestPostChats_AutomaticTitleGenerationPasteOnly(t *testing.T) { client, _, api := newChatClientWithoutAIBridge(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfigWithBaseURL(t, client, baseURL) + _ = createChatModelWithBaseURL(t, client, baseURL) aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) uploadResp, err := client.UploadChatFile( @@ -11321,7 +12024,7 @@ func TestGetChatDiffStatus(t *testing.T) { db := api.Database user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) noCachedStatusChat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -11401,7 +12104,7 @@ func TestGetChatDiffStatus(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -11442,7 +12145,7 @@ func TestGetChatDiffContents(t *testing.T) { client := codersdk.NewExperimentalClient(rawClient) db := api.Database user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, OwnerID: user.UserID, @@ -11481,7 +12184,7 @@ func TestGetChatDiffContents(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -11510,7 +12213,7 @@ func TestGetChatDiffContents(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -11539,7 +12242,7 @@ func TestDeleteChatQueuedMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -11584,7 +12287,7 @@ func TestDeleteChatQueuedMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -11619,7 +12322,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -11676,13 +12379,59 @@ func TestPromoteChatQueuedMessage(t *testing.T) { } }) + t.Run("ForeignModelWithoutLocalDefaultReturnsGuidance", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + foreignConfig := createChatModel(t, client) + chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: chatOrg.ID, + OwnerID: member.ID, + LastModelConfigID: foreignConfig.ID, + Title: "promote queued foreign model without fallback", + Status: database.ChatStatusError, + }) + + queuedContent, err := json.Marshal([]codersdk.ChatMessagePart{ + codersdk.ChatMessageText("require a local model for promotion"), + }) + require.NoError(t, err) + queuedMessage := insertTestChatQueuedMessage(ctx, t, db, chat.ID, queuedContent, foreignConfig.ID) + + promoteRes, err := memberClient.Request( + ctx, + http.MethodPost, + fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID), + nil, + ) + require.NoError(t, err) + defer promoteRes.Body.Close() + + err = codersdk.ReadBodyAsError(promoteRes) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) + require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) + + remaining, err := db.GetChatQueuedMessageByID(dbauthz.AsSystemRestricted(ctx), database.GetChatQueuedMessageByIDParams{ + ID: queuedMessage.ID, + ChatID: chat.ID, + }) + require.NoError(t, err) + require.Equal(t, queuedMessage.ID, remaining.ID) + }) + t.Run("InvalidQueuedMessageID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -11712,7 +12461,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Create a member without agents-access. Without // agents-access the member has no ResourceChat @@ -11750,7 +12499,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -11790,7 +12539,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) const dynamicToolName = "my_dynamic_tool" dynamicTools := []mcp.Tool{{ @@ -11915,7 +12664,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OrganizationID: user.OrganizationID, @@ -12591,7 +13340,7 @@ func TestChatFileDownloadURL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, _ := newClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) uploaded, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "shared.png") chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -12680,7 +13429,7 @@ func TestGetChatCost(t *testing.T) { client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true} rootChat := dbgen.Chat(t, db, database.Chat{ @@ -12738,7 +13487,7 @@ func TestGetChatCost(t *testing.T) { client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true} chat := dbgen.Chat(t, db, database.Chat{ @@ -12769,7 +13518,7 @@ func TestGetChatCost(t *testing.T) { client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true} chat := dbgen.Chat(t, db, database.Chat{ @@ -12805,7 +13554,7 @@ func TestGetChatCost(t *testing.T) { client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true} chat := dbgen.Chat(t, db, database.Chat{ @@ -12834,7 +13583,7 @@ func TestGetChatCost(t *testing.T) { client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true} chat := dbgen.Chat(t, db, database.Chat{ @@ -12865,7 +13614,7 @@ func TestGetChatCost(t *testing.T) { client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true} firstRoot := dbgen.Chat(t, db, database.Chat{ @@ -12922,7 +13671,7 @@ func TestGetChatCost(t *testing.T) { client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, @@ -12952,7 +13701,7 @@ func TestGetChatCost(t *testing.T) { client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) _, otherUser := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true} chat := dbgen.Chat(t, db, database.Chat{ @@ -13009,7 +13758,7 @@ func TestGetChatCost(t *testing.T) { // create or read chats at all, so they never reach this endpoint. memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true} chat := dbgen.Chat(t, db, database.Chat{ @@ -13039,7 +13788,7 @@ func TestGetChatCost(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, @@ -13062,7 +13811,7 @@ func TestGetChatCost(t *testing.T) { client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // An ungrouped aggregate always returns a row, so a chat with no // gateway requests reports zeros instead of failing. @@ -13093,7 +13842,7 @@ func TestWatchChatDesktop(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -13140,7 +13889,7 @@ func TestWatchChatGitAuthz(t *testing.T) { // and is demoted after the chat is bound. adminClient, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - _ = createChatModelConfig(t, adminClient) + _ = createChatModel(t, adminClient) // A second owner is needed to run UpdateUserRoles on the first // user, since the server refuses self-demotion. @@ -13301,15 +14050,15 @@ func seedPasteOnlyTitleSourceMessage( }) } -// createTitleGenerationModelConfig provisions a model config on the openai +// createTitleGenerationChatModel provisions a model config on the openai // provider type, which routes structured title generation through the // Responses API. The chattest fake answers it with {"title": "Test Chat"}. -func createTitleGenerationModelConfig( +func createTitleGenerationChatModel( t *testing.T, client *codersdk.ExperimentalClient, ) codersdk.ChatModel { t.Helper() - return createAdditionalChatModelConfig(t, client, "openai", "gpt-4.1") + return createAdditionalChatModel(t, client, "openai", "gpt-4.1") } // seedChatWithDeletedModelConfig creates a chat whose only model config is @@ -13331,43 +14080,35 @@ func seedChatWithDeletedModelConfig( Title: "chat without model config", }) seedManualTitleSourceMessage(t, db, chat, modelConfig.ID) + // The fixture's soft-delete runs under an owner subject authorized to + // delete the config, not a shared internal actor. _, err := db.DeleteChatModelConfigByID( - dbauthz.AsSystemRestricted(ctx), + dbauthz.As(ctx, rbac.Subject{ + ID: user.UserID.String(), + Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, + Scope: rbac.ScopeAll, + }), modelConfig.ID, ) require.NoError(t, err) return chat } -func createChatModelConfig(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel { - t.Helper() - return coderdtest.CreateOpenAICompatChatModel(t, client, "") -} - -// createChatModel bridges the SDK-layer name while the later API layer owns -// the full helper rename. func createChatModel(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel { t.Helper() - return createChatModelConfig(t, client) + return coderdtest.CreateOpenAICompatChatModel(t, client, "") } -// createChatModelWithBaseURL bridges the SDK-layer name while the later API -// layer owns the full helper rename. func createChatModelWithBaseURL(t testing.TB, client *codersdk.ExperimentalClient, baseURL string) codersdk.ChatModel { - t.Helper() - return createChatModelConfigWithBaseURL(t, client, baseURL) -} - -func createChatModelConfigWithBaseURL(t testing.TB, client *codersdk.ExperimentalClient, baseURL string) codersdk.ChatModel { t.Helper() return coderdtest.CreateOpenAICompatChatModel(t, client, baseURL) } -// createChatModelConfigWithTitleFailure provisions a model whose streaming chat +// createChatModelWithTitleFailure provisions a model whose streaming chat // responses succeed, while non-streaming requests fail. The non-streaming path // is how quick title generation requests structured output, so tests can fail // title generation without breaking the main assistant response. -func createChatModelConfigWithTitleFailure(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel { +func createChatModelWithTitleFailure(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel { t.Helper() baseURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { if req.Stream { @@ -13375,13 +14116,13 @@ func createChatModelConfigWithTitleFailure(t testing.TB, client *codersdk.Experi } return chattest.OpenAIErrorResponse(http.StatusUnauthorized, "invalid_api_key", "test title failure") }) - return createChatModelConfigWithBaseURL(t, client, baseURL) + return createChatModelWithBaseURL(t, client, baseURL) } -// createChatModelConfigWithTitleQuotaExhausted provisions a model whose +// createChatModelWithTitleQuotaExhausted provisions a model whose // non-streaming responses return a provider insufficient_quota error, which // classifies as a usage limit like an exhausted AI Gateway budget. -func createChatModelConfigWithTitleQuotaExhausted(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel { +func createChatModelWithTitleQuotaExhausted(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel { t.Helper() baseURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { if req.Stream { @@ -13393,11 +14134,9 @@ func createChatModelConfigWithTitleQuotaExhausted(t testing.TB, client *codersdk "You exceeded your current quota, please check your plan and billing details.", ) }) - return createChatModelConfigWithBaseURL(t, client, baseURL) + return createChatModelWithBaseURL(t, client, baseURL) } -// createAdditionalChatModel bridges the SDK-layer name while the later API -// layer owns the full helper rename. func createAdditionalChatModel( t *testing.T, client *codersdk.ExperimentalClient, @@ -13405,20 +14144,10 @@ func createAdditionalChatModel( model string, ) codersdk.ChatModel { t.Helper() - return createAdditionalChatModelConfig(t, client, provider, model) -} - -func createAdditionalChatModelConfig( - t *testing.T, - client *codersdk.ExperimentalClient, - provider string, - model string, -) codersdk.ChatModel { - t.Helper() - return createAdditionalChatModelConfigWithModelConfig(t, client, provider, model, nil) + return createAdditionalChatModelWithCallConfig(t, client, provider, model, nil) } -func createAdditionalChatModelConfigWithReasoningEffort( +func createAdditionalChatModelWithReasoningEffort( t *testing.T, client *codersdk.ExperimentalClient, provider string, @@ -13427,7 +14156,7 @@ func createAdditionalChatModelConfigWithReasoningEffort( maxEffort string, ) codersdk.ChatModel { t.Helper() - return createAdditionalChatModelConfigWithModelConfig(t, client, provider, model, &codersdk.ChatModelCallConfig{ + return createAdditionalChatModelWithCallConfig(t, client, provider, model, &codersdk.ChatModelCallConfig{ ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ Default: ptr.Ref(defaultEffort), Max: ptr.Ref(maxEffort), @@ -13435,7 +14164,7 @@ func createAdditionalChatModelConfigWithReasoningEffort( }) } -func createAdditionalChatModelConfigWithModelConfig( +func createAdditionalChatModelWithCallConfig( t *testing.T, client *codersdk.ExperimentalClient, provider string, @@ -13448,10 +14177,12 @@ func createAdditionalChatModelConfigWithModelConfig( aiProvider := createAIProviderForTest(t, client, provider, "test-api-key") contextLimit := int64(4096) isDefault := false - modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + defaultOrg, err := client.Client.OrganizationByName(ctx, codersdk.DefaultOrganization) + require.NoError(t, err) + modelConfig, err := client.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, - Model: model, ContextLimit: &contextLimit, + Model: model, IsDefault: &isDefault, ModelConfig: modelCallConfig, }) @@ -13459,7 +14190,7 @@ func createAdditionalChatModelConfigWithModelConfig( return modelConfig } -func createDisabledChatModelConfig( +func createDisabledChatModel( t *testing.T, client *codersdk.ExperimentalClient, provider string, @@ -13467,18 +14198,18 @@ func createDisabledChatModelConfig( ) codersdk.ChatModel { t.Helper() - modelConfig := createAdditionalChatModelConfig(t, client, provider, model) + modelConfig := createAdditionalChatModel(t, client, provider, model) ctx := testutil.Context(t, testutil.WaitLong) - updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{ + updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{ Enabled: ptr.Ref(false), }) require.NoError(t, err) return updated } -// createProviderDisabledChatModelConfig creates an enabled model config, +// createProviderDisabledChatModel creates an enabled model config, // then disables its parent AI provider. -func createProviderDisabledChatModelConfig( +func createProviderDisabledChatModel( t *testing.T, client *codersdk.ExperimentalClient, provider string, @@ -13486,7 +14217,7 @@ func createProviderDisabledChatModelConfig( ) codersdk.ChatModel { t.Helper() - modelConfig := createAdditionalChatModelConfig(t, client, provider, model) + modelConfig := createAdditionalChatModel(t, client, provider, model) ctx := testutil.Context(t, testutil.WaitLong) _, err := client.UpdateAIProvider(ctx, modelConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{ Enabled: ptr.Ref(false), @@ -13529,7 +14260,7 @@ func TestChatSystemPrompt(t *testing.T) { adminClient, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - _ = createChatModelConfig(t, adminClient) + _ = createChatModel(t, adminClient) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -13665,7 +14396,7 @@ If a workspace is needed, use list_templates before create_workspace and follow aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) client := codersdk.NewExperimentalClient(rawClient) _ = coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ SystemPrompt: "", @@ -13693,7 +14424,7 @@ If a workspace is needed, use list_templates before create_workspace and follow legacyClient, legacyDB := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, legacyClient.Client) - _ = createChatModelConfig(t, legacyClient) + _ = createChatModel(t, legacyClient) require.NoError(t, legacyDB.UpsertChatSystemPrompt(dbauthz.AsSystemRestricted(ctx), "Legacy custom instructions")) @@ -13835,7 +14566,7 @@ If a workspace is needed, use list_templates before create_workspace and follow aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) client := codersdk.NewExperimentalClient(rawClient) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ SystemPrompt: "Keep custom instructions", @@ -13884,7 +14615,7 @@ If a workspace is needed, use list_templates before create_workspace and follow aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) client := codersdk.NewExperimentalClient(rawClient) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ SystemPrompt: "Do not use the default prompt", @@ -14414,7 +15145,7 @@ func TestChatPlanModeInstructions(t *testing.T) { adminClient, _ := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - _ = createChatModelConfig(t, adminClient) + _ = createChatModel(t, adminClient) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -14900,14 +15631,14 @@ func TestChatModelOverrides(t *testing.T) { t.Run(setting.name, func(t *testing.T) { adminClient, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - defaultModel := createChatModelConfig(t, adminClient) - openAIModel := createAdditionalChatModelConfig( + defaultModel := createChatModel(t, adminClient) + openAIModel := createAdditionalChatModel( t, adminClient, coderdtest.TestChatProviderOpenAICompat, "gpt-4.1-mini-"+string(setting.context), ) - reasoningModel := createAdditionalChatModelConfigWithReasoningEffort( + reasoningModel := createAdditionalChatModelWithReasoningEffort( t, adminClient, coderdtest.TestChatProviderOpenAICompat, @@ -14915,17 +15646,18 @@ func TestChatModelOverrides(t *testing.T) { "medium", "high", ) - disabledModel := createDisabledChatModelConfig( + disabledModel := createDisabledChatModel( t, adminClient, coderdtest.TestChatProviderOpenAICompat, "gpt-4.1-disabled-"+string(setting.context), ) - localOrg := dbgen.Organization(t, db, database.Organization{}) - localModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - OrganizationID: localOrg.ID, - AIProviderID: uuid.NullUUID{UUID: openAIModel.AIProviderID, Valid: true}, + otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + otherOrgConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: defaultModel.AIProviderID, Valid: true}, + Model: "other-org-override-" + uuid.NewString(), Enabled: true, + OrganizationID: otherOrg.ID, }) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -15075,7 +15807,7 @@ func TestChatModelOverrides(t *testing.T) { t.Run("ProviderDisabledModelReturns400", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - providerDisabledModel := createProviderDisabledChatModelConfig( + providerDisabledModel := createProviderDisabledChatModel( t, adminClient, "openai", @@ -15095,10 +15827,10 @@ func TestChatModelOverrides(t *testing.T) { require.Equal(t, "Invalid model_config_id.", sdkErr.Message) }) - t.Run("ChatOrgModelReturns400", func(t *testing.T) { + t.Run("OtherOrganizationModelReturns400", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - err := putOverride(ctx, adminClient, setting.context, localModel.ID.String()) + err := putOverride(ctx, adminClient, setting.context, otherOrgConfig.ID.String()) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid model_config_id.", sdkErr.Message) }) @@ -15213,7 +15945,7 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { noKeyClientRaw, noKeyUser := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) noKeyClient := codersdk.NewExperimentalClient(noKeyClientRaw) - defaultModelConfig := createChatModelConfig(t, adminClient) + defaultModelConfig := createChatModel(t, adminClient) provider := enableUserChatProviderKey(t, adminClient, memberClient, coderdtest.TestChatProviderOpenAICompat) modelProvider := createAIProviderForTest(t, adminClient, "anthropic", "") _, err := memberClient.UpsertUserAIProviderKey(ctx, "me", modelProvider.ID, codersdk.CreateUserAIProviderKeyRequest{ @@ -15226,7 +15958,7 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { Model: "claude-personal-" + uuid.NewString(), ContextLimit: &contextLimit, } - modelConfig, err := adminClient.CreateChatModel(ctx, modelConfigRequest) + modelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, modelConfigRequest) require.NoError(t, err) modelConfigRequest.Model = "claude-personal-reasoning-" + uuid.NewString() modelConfigRequest.ModelConfig = &codersdk.ChatModelCallConfig{ @@ -15235,7 +15967,7 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { Max: ptr.Ref("high"), }, } - reasoningModelConfig, err := adminClient.CreateChatModel(ctx, modelConfigRequest) + reasoningModelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, modelConfigRequest) require.NoError(t, err) err = adminClient.UpdateChatModelOverride(ctx, codersdk.ChatModelOverrideContextGeneral, codersdk.UpdateChatModelOverrideRequest{ ModelConfigID: modelConfig.ID.String(), @@ -15246,20 +15978,26 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { }) require.NoError(t, err) - disabledModelConfig := createDisabledChatModelConfig( + disabledModelConfig := createDisabledChatModel( t, adminClient, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-personal-disabled-"+uuid.NewString(), ) disabledProvider := createAIProviderForTest(t, adminClient, "google", "test-api-key") - contextLimit = int64(4096) - disabledProviderModelConfig, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + disabledProviderModelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &disabledProvider.ID, Model: "gemini-personal-disabled-provider-" + uuid.NewString(), ContextLimit: &contextLimit, }) require.NoError(t, err) + otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + otherOrgModelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: defaultModelConfig.AIProviderID, Valid: true}, + Model: "personal-other-org-" + uuid.NewString(), + Enabled: true, + OrganizationID: otherOrg.ID, + }) enabled := false disabledProvider, err = adminClient.UpdateAIProvider(ctx, disabledProvider.ID.String(), codersdk.UpdateAIProviderRequest{ Enabled: &enabled, @@ -15268,12 +16006,6 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { require.NotEqual(t, uuid.Nil, provider.ID) require.NotEqual(t, uuid.Nil, disabledProvider.ID) - localOrg := dbgen.Organization(t, db, database.Organization{}) - localModelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - OrganizationID: localOrg.ID, - AIProviderID: uuid.NullUUID{UUID: modelProvider.ID, Valid: true}, - Enabled: true, - }) personalOverride := func( resp codersdk.UserChatPersonalModelOverridesResponse, overrideContext codersdk.ChatPersonalModelOverrideContext, @@ -15616,6 +16348,14 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { wantMessageSubstring: "Invalid model_config_id: model config " + "not found or disabled.", }, + { + name: "OtherOrganization", + client: memberClient, + userID: member.ID, + modelConfigID: otherOrgModelConfig.ID.String(), + wantMessageSubstring: "Invalid model_config_id: model config " + + "not found or disabled.", + }, { name: "ProviderDisabled", client: memberClient, @@ -15647,19 +16387,6 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { } }) - t.Run("PUTModelRejectsChatOrgModel", func(t *testing.T) { - for _, overrideContext := range contexts { - rawBefore := getRaw(overrideContext) - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, overrideContext, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeModel, - ModelConfigID: localModelConfig.ID.String(), - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, "model config not found or disabled") - require.Equal(t, rawBefore, getRaw(overrideContext)) - } - }) - t.Run("GETMalformedStoredValueFallsBackToContextDefault", func(t *testing.T) { upsertRaw(codersdk.ChatPersonalModelOverrideContextRoot, "model:not-a-uuid") @@ -15687,7 +16414,7 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) adminClient, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - defaultModel := createChatModelConfig(t, adminClient) + defaultModel := createChatModel(t, adminClient) _ = enableUserChatProviderKey(t, adminClient, adminClient, coderdtest.TestChatProviderOpenAICompat) overrideProvider := createAIProviderForTest(t, adminClient, "anthropic", "") _, err := adminClient.UpsertUserAIProviderKey(ctx, "me", overrideProvider.ID, codersdk.CreateUserAIProviderKeyRequest{ @@ -15695,13 +16422,13 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { }) require.NoError(t, err) contextLimit := int64(4096) - overrideModel, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + overrideModel, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &overrideProvider.ID, Model: "claude-root-personal-" + uuid.NewString(), ContextLimit: &contextLimit, }) require.NoError(t, err) - disabledModel := createDisabledChatModelConfig( + disabledModel := createDisabledChatModel( t, adminClient, coderdtest.TestChatProviderOpenAICompat, @@ -15802,7 +16529,7 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { }) t.Run("RootModelOverrideUsesSavedReasoningEffort", func(t *testing.T) { - reasoningModel, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + reasoningModel, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &overrideProvider.ID, Model: "claude-root-personal-reasoning-" + uuid.NewString(), ContextLimit: &contextLimit, @@ -15826,6 +16553,35 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { require.Equal(t, ptr.Ref("high"), chat.LastReasoningEffort) }) + t.Run("CrossOrgRootModelFallsBackToOrgDefault", func(t *testing.T) { + org := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + orgModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: defaultModel.AIProviderID, Valid: true}, + Model: "org-root-personal-" + uuid.NewString(), + Enabled: true, + IsDefault: true, + OrganizationID: org.ID, + }) + otherClientRaw, otherUser := coderdtest.CreateAnotherUser( + t, + adminClient.Client, + org.ID, + rbac.ScopedRoleAgentsAccess(org.ID), + ) + otherClient := codersdk.NewExperimentalClient(otherClientRaw) + upsertRootRaw(otherUser.ID, "model:"+overrideModel.ID.String()) + + chat, err := otherClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: org.ID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "cross-org root model falls back", + }}, + }) + require.NoError(t, err) + require.Equal(t, orgModel.ID, chat.LastModelConfigID) + }) + t.Run("UnavailableRootModelFallsBackToDefault", func(t *testing.T) { upsertRootRaw(firstUser.UserID, "model:"+disabledModel.ID.String()) chat := createChat(adminClient, "disabled root model falls back", nil) @@ -16197,7 +16953,7 @@ func TestChatDebugRuns(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -16228,7 +16984,7 @@ func TestChatDebugRuns(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, @@ -16264,7 +17020,7 @@ func TestChatDebugRuns(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, @@ -16299,7 +17055,7 @@ func TestChatDebugRuns(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Chat owned by the first (admin) user. chat := dbgen.Chat(t, db, database.Chat{ @@ -16329,7 +17085,7 @@ func TestChatDebugRun(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, @@ -16362,7 +17118,7 @@ func TestChatDebugRun(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, @@ -16385,7 +17141,7 @@ func TestChatDebugRun(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, @@ -16409,7 +17165,7 @@ func TestChatDebugRun(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, @@ -16429,7 +17185,7 @@ func TestChatDebugRun(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Two chats owned by the same user. A run on chat A must not // be addressable through chat B's URL. @@ -16572,7 +17328,7 @@ func TestChatAdvisorConfig_RoundTripModelConfigID(t *testing.T) { adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - modelConfig := createAdditionalChatModelConfigWithReasoningEffort( + modelConfig := createAdditionalChatModelWithReasoningEffort( t, adminClient, "openai", @@ -16620,7 +17376,7 @@ func TestChatAdvisorConfig_DisabledModelConfigID(t *testing.T) { adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - disabledConfig := createDisabledChatModelConfig( + disabledConfig := createDisabledChatModel( t, adminClient, coderdtest.TestChatProviderOpenAICompat, @@ -16640,7 +17396,7 @@ func TestChatAdvisorConfig_ProviderDisabledModelConfigID(t *testing.T) { adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - providerDisabledConfig := createProviderDisabledChatModelConfig( + providerDisabledConfig := createProviderDisabledChatModel( t, adminClient, "openai", @@ -16653,6 +17409,28 @@ func TestChatAdvisorConfig_ProviderDisabledModelConfigID(t *testing.T) { require.Contains(t, sdkErr.Message, "does not match any enabled model config") } +func TestChatAdvisorConfig_OtherOrganizationModelConfigID(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient, db := newChatClientWithDatabase(t) + coderdtest.CreateFirstUser(t, adminClient.Client) + defaultConfig := createChatModel(t, adminClient) + otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + otherOrgConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true}, + Model: "advisor-other-org-" + uuid.NewString(), + Enabled: true, + OrganizationID: otherOrg.ID, + }) + + err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ + ModelConfigID: otherOrgConfig.ID, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Message, "does not match any enabled model config") +} + func TestChatAdvisorConfig_ReasoningEffortRequiresModelConfig(t *testing.T) { t.Parallel() @@ -16673,7 +17451,7 @@ func TestChatAdvisorConfig_ReasoningEffortMustBeSelectable(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - modelConfig := createAdditionalChatModelConfigWithReasoningEffort( + modelConfig := createAdditionalChatModelWithReasoningEffort( t, adminClient, "openai", @@ -16723,7 +17501,7 @@ func TestChatAdvisorConfig_OverwriteClearsPreviousValues(t *testing.T) { adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - modelConfig := createAdditionalChatModelConfigWithReasoningEffort( + modelConfig := createAdditionalChatModelWithReasoningEffort( t, adminClient, "openai", @@ -17146,7 +17924,7 @@ func TestUserChatCompactionThresholds(t *testing.T) { client, _ := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) t.Run("EmptyByDefault", func(t *testing.T) { //nolint:paralleltest // subtests share parent state ctx := testutil.Context(t, testutil.WaitLong) @@ -17291,7 +18069,7 @@ func TestGetChatsByWorkspace(t *testing.T) { client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Helper to create a workspace owned by the test user. newWorkspace := func() dbfake.WorkspaceBuildBuilder { @@ -17495,7 +18273,7 @@ func TestSubmitToolResults(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) const toolName = "my_dynamic_tool" toolCallIDs := []string{"call_abc", "call_def"} @@ -17538,7 +18316,7 @@ func TestSubmitToolResults(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Create a chat that is NOT in requires_action status. chat := dbgen.Chat(t, db, database.Chat{ @@ -17562,7 +18340,7 @@ func TestSubmitToolResults(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) const toolName = "my_dynamic_tool" toolCallIDs := []string{"call_one", "call_two"} @@ -17584,7 +18362,7 @@ func TestSubmitToolResults(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) const toolName = "my_dynamic_tool" toolCallIDs := []string{"call_real"} @@ -17606,7 +18384,7 @@ func TestSubmitToolResults(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) const toolName = "my_dynamic_tool" toolCallIDs := []string{"call_json"} @@ -17636,7 +18414,7 @@ func TestSubmitToolResults(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) const toolName = "my_dynamic_tool" toolCallIDs := []string{"call_dup1", "call_dup2"} @@ -17659,7 +18437,7 @@ func TestSubmitToolResults(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) const toolName = "my_dynamic_tool" toolCallIDs := []string{"call_empty"} @@ -17678,7 +18456,7 @@ func TestSubmitToolResults(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) const toolName = "my_dynamic_tool" toolCallIDs := []string{"call_other"} @@ -17707,7 +18485,7 @@ func TestSubmitToolResults(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Create a member without agents-access. Without // agents-access the member has no ResourceChat @@ -17735,7 +18513,7 @@ func TestSubmitToolResults(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t, withChatWorkerDisabled) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) const toolName = "my_dynamic_tool" toolCallIDs := []string{"call_archived"} @@ -17765,7 +18543,7 @@ func TestPostChats_DynamicToolValidation(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) user := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) tools := make([]codersdk.DynamicTool, 251) for i := range tools { @@ -17792,7 +18570,7 @@ func TestPostChats_DynamicToolValidation(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) user := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, @@ -17814,7 +18592,7 @@ func TestPostChats_DynamicToolValidation(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) user := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelConfig(t, client) + _ = createChatModel(t, client) _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, @@ -17986,7 +18764,7 @@ func TestGetChatMessages_Pagination(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 5) seedQueuedMessage(ctx, t, db, chat.ID, modelConfig.ID) @@ -18011,7 +18789,7 @@ func TestGetChatMessages_Pagination(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 5) seedQueuedMessage(ctx, t, db, chat.ID, modelConfig.ID) @@ -18037,7 +18815,7 @@ func TestGetChatMessages_Pagination(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 5) seedQueuedMessage(ctx, t, db, chat.ID, modelConfig.ID) @@ -18065,7 +18843,7 @@ func TestGetChatMessages_Pagination(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 5) seedQueuedMessage(ctx, t, db, chat.ID, modelConfig.ID) @@ -18092,7 +18870,7 @@ func TestGetChatMessages_Pagination(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 5) // Seed a queued message so the Empty assertion below verifies @@ -18124,7 +18902,7 @@ func TestGetChatMessages_Pagination(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, _ := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 1) @@ -18155,7 +18933,7 @@ func TestGetChatMessages_Pagination(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, _ := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 1) @@ -18186,7 +18964,7 @@ func TestGetChatMessages_Pagination(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 3) // Seed a queued message to prove the cursor path suppresses @@ -18210,7 +18988,7 @@ func TestGetChatMessages_Pagination(t *testing.T) { client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 3) @@ -18244,7 +19022,7 @@ func TestGetChatMessages_Pagination(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) + modelConfig := createChatModel(t, client) // Simulate a polling client that has already acknowledged the // first message (cursor = ids[0]) when a burst of @@ -18313,7 +19091,7 @@ func TestChatReadOnlySharedWriteHandlers(t *testing.T) { ctx = testutil.Context(t, testutil.WaitLong) ownerClient, db = newChatClientWithDatabase(t) owner := coderdtest.CreateFirstUser(t, ownerClient.Client) - _ = createChatModelConfig(t, ownerClient) + _ = createChatModel(t, ownerClient) sharedRaw, sharedUser := coderdtest.CreateAnotherUser( t, ownerClient.Client, @@ -18540,7 +19318,7 @@ func TestChatOwnerOnlyWriteHandlers(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) ownerClient, db = newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, ownerClient.Client) - _ = createChatModelConfig(t, ownerClient) + _ = createChatModel(t, ownerClient) // Create a chat owned by the first user. var err error diff --git a/coderd/httpmw/chatmodelconfigparam.go b/coderd/httpmw/chatmodelconfigparam.go new file mode 100644 index 0000000000000..2faf81392250c --- /dev/null +++ b/coderd/httpmw/chatmodelconfigparam.go @@ -0,0 +1,60 @@ +package httpmw + +import ( + "context" + "net/http" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +type chatModelConfigParamContextKey struct{} + +// ChatModelConfigParam returns the chat model config from the +// ExtractChatModelConfigParam middleware. +func ChatModelConfigParam(r *http.Request) database.ChatModelConfig { + config, ok := r.Context().Value(chatModelConfigParamContextKey{}).(database.ChatModelConfig) + if !ok { + panic("developer error: chat model config param middleware not provided") + } + return config +} + +// ExtractChatModelConfigParam grabs a chat model config from the "model" URL +// parameter after the organization route parameter has been resolved. It only +// resolves route identity; each handler authorizes its own action. +func ExtractChatModelConfigParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + modelID, parsed := ParseUUIDParam(rw, r, "model") + if !parsed { + return + } + + organization := OrganizationParam(r) + //nolint:gocritic // Each model handler authorizes its concrete action. + config, err := db.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), modelID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching chat model config.", + Detail: err.Error(), + }) + return + } + if config.OrganizationID != organization.ID { + httpapi.ResourceNotFound(rw) + return + } + + ctx = context.WithValue(ctx, chatModelConfigParamContextKey{}, config) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go index 349ffe25e6c93..0973a36eeed71 100644 --- a/coderd/httpmw/organizationparam.go +++ b/coderd/httpmw/organizationparam.go @@ -55,58 +55,92 @@ func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - arg := chi.URLParam(r, "organization") - if arg == "" { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "\"organization\" must be provided.", - }) + organization, ok := resolveOrganizationParam(ctx, ctx, db, rw, r) + if !ok { return } - var organization database.Organization - var dbErr error - - // If the name is exactly "default", then we fetch the default - // organization. This is a special case to make it easier - // for single org deployments. - // - // arg == uuid.Nil.String() should be a temporary workaround for - // legacy provisioners that don't provide an organization ID. - // This prevents a breaking change. - // TODO: This change was added March 2024. Nil uuid returning the - // default org should be removed some number of months after - // that date. - if arg == codersdk.DefaultOrganization || arg == uuid.Nil.String() { - organization, dbErr = db.GetDefaultOrganization(ctx) - } else { - // Try by name or uuid. - id, err := uuid.Parse(arg) - if err == nil { - organization, dbErr = db.GetOrganizationByID(ctx, id) - } else { - organization, dbErr = db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{ - Name: arg, - Deleted: false, - }) - } - } - if httpapi.Is404Error(dbErr) { - httpapi.ResourceNotFound(rw) - return - } - if dbErr != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: fmt.Sprintf("Internal error fetching organization %q.", arg), - Detail: dbErr.Error(), - }) + ctx = context.WithValue(ctx, organizationParamContextKey{}, organization) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} + +// ExtractChatModelOrganizationParam resolves organization identity for chat +// model routes without requiring organization:read. The model handlers and +// Store methods authorize the original caller for the concrete operation. +func ExtractChatModelOrganizationParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + //nolint:gocritic // Route context resolution does not expose organization data. + organization, ok := resolveOrganizationParam(ctx, dbauthz.AsSystemRestricted(ctx), db, rw, r) + if !ok { return } + ctx = context.WithValue(ctx, organizationParamContextKey{}, organization) next.ServeHTTP(rw, r.WithContext(ctx)) }) } } +func resolveOrganizationParam( + responseCtx context.Context, + queryCtx context.Context, + db database.Store, + rw http.ResponseWriter, + r *http.Request, +) (database.Organization, bool) { + arg := chi.URLParam(r, "organization") + if arg == "" { + httpapi.Write(responseCtx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "\"organization\" must be provided.", + }) + return database.Organization{}, false + } + + var organization database.Organization + var dbErr error + + // If the name is exactly "default", then we fetch the default + // organization. This is a special case to make it easier + // for single org deployments. + // + // arg == uuid.Nil.String() should be a temporary workaround for + // legacy provisioners that don't provide an organization ID. + // This prevents a breaking change. + // TODO: This change was added March 2024. Nil uuid returning the + // default org should be removed some number of months after + // that date. + if arg == codersdk.DefaultOrganization || arg == uuid.Nil.String() { + organization, dbErr = db.GetDefaultOrganization(queryCtx) + } else { + // Try by name or uuid. + id, err := uuid.Parse(arg) + if err == nil { + organization, dbErr = db.GetOrganizationByID(queryCtx, id) + } else { + organization, dbErr = db.GetOrganizationByName(queryCtx, database.GetOrganizationByNameParams{ + Name: arg, + Deleted: false, + }) + } + } + if httpapi.Is404Error(dbErr) { + httpapi.ResourceNotFound(rw) + return database.Organization{}, false + } + if dbErr != nil { + httpapi.Write(responseCtx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: fmt.Sprintf("Internal error fetching organization %q.", arg), + Detail: dbErr.Error(), + }) + return database.Organization{}, false + } + return organization, true +} + // OrganizationMember is the database object plus the Username and Avatar URL. Including these // in the middleware is preferable to a join at the SQL layer so that we can keep the // autogenerated database types as they are. diff --git a/coderd/x/chatd/advisor_internal_test.go b/coderd/x/chatd/advisor_internal_test.go index 8b3c166885b0d..a8b7702765f27 100644 --- a/coderd/x/chatd/advisor_internal_test.go +++ b/coderd/x/chatd/advisor_internal_test.go @@ -455,7 +455,7 @@ func TestResolveAdvisorModelOverridePromotesAIBridgeErrors(t *testing.T) { ctx = aibridge.WithDelegatedAPIKeyID(ctx, uuid.NewString()) resolved, ok, err := p.resolveAdvisorModelOverride( ctx, - database.Chat{ID: uuid.New(), OwnerID: uuid.New()}, + database.Chat{ID: uuid.New()}, codersdk.AdvisorConfig{ModelConfigID: configID}, advisorTestMaxOutputTokens, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index ac96eb393da4c..d0214d41554de 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -163,9 +163,10 @@ type Server struct { inflightMu sync.Mutex inflightClosed atomic.Bool - db database.Store - workerID uuid.UUID - logger slog.Logger + db database.Store + workerID uuid.UUID + logger slog.Logger + modelConfigContext func(context.Context, uuid.UUID) (context.Context, error) streamPartsDialer StreamPartsDialer @@ -260,19 +261,24 @@ func (p *Server) resolveAdvisorModelOverride( return resolvedModelCall{}, false, nil } + modelCtx, modelCtxErr := p.callerModelConfigContext(ctx, chat.OwnerID) + if modelCtxErr != nil { + logger.Warn( + ctx, + "failed to load advisor model authorization, continuing with chat model", + slog.F("model_config_id", advisorCfg.ModelConfigID), + slog.Error(modelCtxErr), + ) + return resolvedModelCall{}, false, nil + } // Re-read the override instead of using the cache so disabled models // or providers stop routing advisor prompts immediately. overrideConfig, err := p.db.GetEnabledChatModelConfigByID( - ctx, + modelCtx, advisorCfg.ModelConfigID, ) if err == nil && overrideConfig.OrganizationID != chat.OrganizationID { - logger.Warn( - ctx, - "advisor model config belongs to another organization, continuing with chat model", - slog.F("model_config_id", advisorCfg.ModelConfigID), - ) - return resolvedModelCall{}, false, nil + err = sql.ErrNoRows } if err != nil { if xerrors.Is(err, sql.ErrNoRows) { @@ -1579,10 +1585,31 @@ func (p *Server) SendMessage( return result, nil } -func chatdModelConfigLookupContext(ctx context.Context) context.Context { - //nolint:gocritic // Chat message admission needs daemon-scoped - // deployment-config reads for model config validation. - return dbauthz.AsChatd(ctx) +func (p *Server) callerModelConfigContext(ctx context.Context, ownerID uuid.UUID) (context.Context, error) { + if p.modelConfigContext == nil { + return ctx, nil + } + return p.modelConfigContext(ctx, ownerID) +} + +func callerModelConfigContext( + ctx context.Context, + store database.Store, + ownerID uuid.UUID, +) (context.Context, error) { + if ownerID == uuid.Nil { + return ctx, nil + } + if actor, ok := dbauthz.ActorFromContext(ctx); ok && + actor.Type == rbac.SubjectTypeUser && actor.ID == ownerID.String() { + return ctx, nil + } + actor, _, err := httpmw.UserRBACSubject(ctx, store, ownerID, rbac.ScopeAll) + if err != nil { + return nil, xerrors.Errorf("load model config authorization: %w", err) + } + //nolint:gocritic // Background Chatd work must use the chat owner's model ACLs. + return dbauthz.As(ctx, actor), nil } func resolveSendMessageModelConfigID( @@ -1592,7 +1619,7 @@ func resolveSendMessageModelConfigID( requested uuid.UUID, ) (uuid.UUID, error) { if requested == uuid.Nil { - return resolveFallbackModelConfigID(ctx, store, chat.OrganizationID, chat.LastModelConfigID) + return resolveFallbackModelConfigID(ctx, store, chat, chat.LastModelConfigID) } if err := requireEnabledChatModelConfig(ctx, store, chat.OrganizationID, requested); err != nil { @@ -1609,8 +1636,7 @@ func requireEnabledChatModelConfig( organizationID uuid.UUID, modelConfigID uuid.UUID, ) error { - chatdCtx := chatdModelConfigLookupContext(ctx) - config, err := store.GetEnabledChatModelConfigByID(chatdCtx, modelConfigID) + config, err := store.GetEnabledChatModelConfigByID(ctx, modelConfigID) if err == nil { if config.OrganizationID == organizationID { return nil @@ -1640,8 +1666,7 @@ func validateCreateModelConfigID( if modelConfigID == uuid.Nil { return xerrors.Errorf("%w: %s", ErrInvalidModelConfigID, modelConfigID) } - chatdCtx := chatdModelConfigLookupContext(ctx) - config, err := store.GetChatModelConfigByID(chatdCtx, modelConfigID) + config, err := store.GetChatModelConfigByID(ctx, modelConfigID) if err == nil { if config.OrganizationID == organizationID { return nil @@ -1657,17 +1682,16 @@ func validateCreateModelConfigID( func resolveFallbackModelConfigID( ctx context.Context, store database.Store, - organizationID uuid.UUID, + chat database.Chat, modelConfigID uuid.UUID, ) (uuid.UUID, error) { - chatdCtx := chatdModelConfigLookupContext(ctx) if modelConfigID != uuid.Nil { - config, err := store.GetEnabledChatModelConfigByID(chatdCtx, modelConfigID) + config, err := store.GetEnabledChatModelConfigByID(ctx, modelConfigID) if err == nil { - if config.OrganizationID == organizationID { + if config.OrganizationID == chat.OrganizationID { return modelConfigID, nil } - } else if !errors.Is(err, sql.ErrNoRows) { + } else if !errors.Is(err, sql.ErrNoRows) && !dbauthz.IsNotAuthorizedError(err) { return uuid.Nil, xerrors.Errorf( "get chat model config %s: %w", modelConfigID, @@ -1676,7 +1700,7 @@ func resolveFallbackModelConfigID( } } - defaultConfig, err := effectiveDefaultChatModelConfig(chatdCtx, store, organizationID) + defaultConfig, err := effectiveDefaultChatModelConfig(ctx, store, chat.OrganizationID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return uuid.Nil, ErrNoDefaultChatModelConfig @@ -1718,29 +1742,44 @@ func validateEditTarget(ctx context.Context, store database.Store, chatID uuid.U return nil } +func loadEffectiveChatModelConfigs( + ctx context.Context, + store database.Store, + organizationID uuid.UUID, +) (database.EffectiveChatModelConfigs, error) { + rows, err := store.GetEnabledChatModelConfigsByOrganization(ctx, organizationID) + if err != nil { + return database.EffectiveChatModelConfigs{}, err + } + return database.DeriveEffectiveChatModelConfigs(rows), nil +} + func effectiveDefaultChatModelConfig( ctx context.Context, store database.Store, organizationID uuid.UUID, ) (database.ChatModelConfig, error) { - rows, err := store.GetEnabledChatModelConfigsByOrganization(ctx, organizationID) + effective, err := loadEffectiveChatModelConfigs(ctx, store, organizationID) if err != nil { return database.ChatModelConfig{}, err } - for _, row := range rows { - if row.ChatModelConfig.IsDefault { - return row.ChatModelConfig, nil - } + if effective.DefaultConfig.ID == uuid.Nil { + return database.ChatModelConfig{}, sql.ErrNoRows } - return database.ChatModelConfig{}, sql.ErrNoRows + return effective.DefaultConfig, nil } +// enabledChatModelConfigsForOrganization returns enabled configs from the chat organization. func enabledChatModelConfigsForOrganization( ctx context.Context, store database.Store, organizationID uuid.UUID, ) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { - return store.GetEnabledChatModelConfigsByOrganization(ctx, organizationID) + effective, err := loadEffectiveChatModelConfigs(ctx, store, organizationID) + if err != nil { + return nil, err + } + return effective.Configs, nil } // EditMessage replaces an earlier user message and discards the @@ -1848,7 +1887,7 @@ func (p *Server) EditMessage( if target.ModelConfigID.Valid { preserved = target.ModelConfigID.UUID } - resolved, err := resolveFallbackModelConfigID(ctx, store, lockedChat.OrganizationID, preserved) + resolved, err := resolveFallbackModelConfigID(ctx, store, lockedChat, preserved) if err != nil { return err } @@ -2761,9 +2800,6 @@ func (p *Server) resolveManualTitleModel( modelOpts, ) if overrideErr != nil { - if errors.Is(overrideErr, errModelConfigOutsideOrganization) { - return p.resolveDefaultManualTitleModel(ctx, chat, modelOpts) - } if overrideSet { return resolvedModelCall{}, xerrors.Errorf( "resolve manual title generation model override: %w", @@ -2778,7 +2814,11 @@ func (p *Server) resolveManualTitleModel( return overrideResolved, nil } - configs, err := enabledChatModelConfigsForOrganization(ctx, store, chat.OrganizationID) + modelCtx, err := p.callerModelConfigContext(ctx, chat.OwnerID) + if err != nil { + return resolvedModelCall{}, err + } + configs, err := enabledChatModelConfigsForOrganization(modelCtx, store, chat.OrganizationID) if err != nil { p.logger.Debug(ctx, "failed to list manual title model configs", slog.F("chat_id", chat.ID), @@ -2809,36 +2849,6 @@ func (p *Server) resolveManualTitleModel( return resolved, nil } -func (p *Server) resolveDefaultManualTitleModel( - ctx context.Context, - chat database.Chat, - modelOpts modelBuildOptions, -) (resolvedModelCall, error) { - config, err := p.configCache.DefaultModelConfig(ctx, chat.OrganizationID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return resolvedModelCall{}, ErrNoDefaultChatModelConfig - } - return resolvedModelCall{}, xerrors.Errorf( - "get default manual title model config: %w", - err, - ) - } - resolved, err := p.resolveModelCall(ctx, modelCallSpec{ - purpose: "title", - chat: chat, - explicitConfig: &config, - buildOptions: modelOpts, - }) - if err != nil { - return resolvedModelCall{}, xerrors.Errorf( - "create default manual title model: %w", - err, - ) - } - return resolved, nil -} - func (p *Server) resolveFallbackManualTitleModel( ctx context.Context, chat database.Chat, @@ -3111,10 +3121,13 @@ func New(ps pubsub.Pubsub, cfg Config) *Server { hookDispatcher = nil } p := &Server{ - cancel: cancel, - db: cfg.Database, - workerID: workerID, - logger: cfg.Logger.Named("processor"), + cancel: cancel, + db: cfg.Database, + workerID: workerID, + logger: cfg.Logger.Named("processor"), + modelConfigContext: func(ctx context.Context, ownerID uuid.UUID) (context.Context, error) { + return callerModelConfigContext(ctx, cfg.Database, ownerID) + }, agentConnFn: cfg.AgentConn, agentInactiveDisconnectTimeout: cfg.AgentInactiveDisconnectTimeout, dialTimeout: defaultDialTimeout, @@ -3216,8 +3229,6 @@ func New(ps pubsub.Pubsub, cfg Config) *Server { return } switch ev.Kind { - case coderdpubsub.ChatConfigEventModelConfig: - p.configCache.InvalidateModelConfig(ev.EntityID) case coderdpubsub.ChatConfigEventUserPrompt: p.configCache.InvalidateUserPrompt(ev.EntityID) case coderdpubsub.ChatConfigEventAdvisorConfig: @@ -4263,20 +4274,18 @@ func (p *Server) resolveUserProviderAPIKeys( func (p *Server) resolveModelConfigForOrganization( ctx context.Context, + ownerID uuid.UUID, organizationID uuid.UUID, modelConfigID uuid.UUID, ) (database.ChatModelConfig, string, error) { - if modelConfigID == uuid.Nil { - return database.ChatModelConfig{}, "", sql.ErrNoRows - } - modelConfig, err := p.configCache.ModelConfigByID(ctx, modelConfigID) + modelConfig, providerName, err := p.resolveModelConfigAndNormalizedProvider(ctx, ownerID, modelConfigID) if err != nil { return database.ChatModelConfig{}, "", err } if modelConfig.OrganizationID != organizationID { return database.ChatModelConfig{}, "", errModelConfigOutsideOrganization } - return p.resolveNormalizedProviderForModelConfig(ctx, modelConfig) + return modelConfig, providerName, nil } // resolveModelConfig looks up the chat's enabled model config by its @@ -4287,34 +4296,32 @@ func (p *Server) resolveModelConfig( ctx context.Context, chat database.Chat, ) (database.ChatModelConfig, error) { + modelCtx, err := p.callerModelConfigContext(ctx, chat.OwnerID) + if err != nil { + return database.ChatModelConfig{}, err + } if chat.LastModelConfigID != uuid.Nil { - modelConfig, err := p.configCache.ModelConfigByID(ctx, chat.LastModelConfigID) + modelConfig, err := p.db.GetEnabledChatModelConfigByID( + modelCtx, + chat.LastModelConfigID, + ) if err == nil { - if modelConfig.Enabled && modelConfig.OrganizationID == chat.OrganizationID && modelConfig.AIProviderID.Valid { - provider, providerErr := p.db.GetAIProviderByID( - chatdModelConfigLookupContext(ctx), - modelConfig.AIProviderID.UUID, - ) - switch { - case providerErr == nil && provider.Enabled: - return modelConfig, nil - case providerErr == nil, xerrors.Is(providerErr, sql.ErrNoRows): - default: - return database.ChatModelConfig{}, xerrors.Errorf( - "get AI provider %s: %w", - modelConfig.AIProviderID.UUID, providerErr, - ) - } + if modelConfig.OrganizationID == chat.OrganizationID { + return modelConfig, nil } - } else if !xerrors.Is(err, sql.ErrNoRows) { + err = sql.ErrNoRows + } + if !xerrors.Is(err, sql.ErrNoRows) && !dbauthz.IsNotAuthorizedError(err) { return database.ChatModelConfig{}, xerrors.Errorf( "get chat model config %s: %w", chat.LastModelConfigID, err, ) } + // The model config is unavailable or belongs to another organization. + // Fall through to the local default. } - defaultConfig, err := p.configCache.DefaultModelConfig(ctx, chat.OrganizationID) + defaultConfig, err := effectiveDefaultChatModelConfig(modelCtx, p.db, chat.OrganizationID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { return database.ChatModelConfig{}, ErrNoDefaultChatModelConfig diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index f22988c00e90f..49c09d1fa35af 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -1086,7 +1086,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { aibridgeTransportFactory: aibridgeTestFactoryPointer(factory), } - db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil) + db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{ ID: providerID, Name: "primary-openai", @@ -1242,7 +1242,7 @@ func TestRegenerateChatTitle_SkipsPersistWhenTitleChangedConcurrently(t *testing aibridgeTransportFactory: aibridgeTestFactoryPointer(factory), } - db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil) + db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{ ID: providerID, Name: "primary-openai", @@ -3673,16 +3673,8 @@ func TestResolveModelConfigProviderLookupError(t *testing.T) { db := dbmock.NewMockStore(ctrl) organizationID := uuid.New() modelConfigID := uuid.New() - providerID := uuid.New() - modelConfig := database.ChatModelConfig{ - ID: modelConfigID, - Enabled: true, - OrganizationID: organizationID, - AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}, - } - db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil) - db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{}, sql.ErrConnDone) + db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelConfigID).Return(database.ChatModelConfig{}, sql.ErrConnDone) server := &Server{ db: db, @@ -3693,7 +3685,7 @@ func TestResolveModelConfigProviderLookupError(t *testing.T) { LastModelConfigID: modelConfigID, }) require.ErrorIs(t, err, sql.ErrConnDone) - require.ErrorContains(t, err, "get AI provider") + require.ErrorContains(t, err, "get chat model config") } func TestResolveModelConfigOrganizationScope(t *testing.T) { @@ -3784,7 +3776,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) { provider := newProvider(t, db, true) lastModel := newModelConfig(t, db, orgID, provider.ID, false) - resolved, err := resolveFallbackModelConfigID(ctx, db, orgID, lastModel.ID) + resolved, err := resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, lastModel.ID) require.NoError(t, err) require.Equal(t, lastModel.ID, resolved) }) @@ -3800,7 +3792,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) { enabledProvider := newProvider(t, db, true) defaultModel := newModelConfig(t, db, orgID, enabledProvider.ID, true) - resolved, err := resolveFallbackModelConfigID(ctx, db, orgID, lastModel.ID) + resolved, err := resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, lastModel.ID) require.NoError(t, err) require.Equal(t, defaultModel.ID, resolved) }) @@ -3814,7 +3806,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) { provider := newProvider(t, db, true) defaultModel := newModelConfig(t, db, orgID, provider.ID, true) - resolved, err := resolveFallbackModelConfigID(ctx, db, orgID, uuid.Nil) + resolved, err := resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, uuid.Nil) require.NoError(t, err) require.Equal(t, defaultModel.ID, resolved) }) @@ -3830,7 +3822,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) { provider := newProvider(t, db, true) newModelConfig(t, db, defaultOrg.ID, provider.ID, true) - _, err = resolveFallbackModelConfigID(ctx, db, otherOrgID, uuid.Nil) + _, err = resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: otherOrgID}, uuid.Nil) require.ErrorIs(t, err, ErrNoDefaultChatModelConfig) }) @@ -3855,7 +3847,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) { }) require.NoError(t, err) - _, err = resolveFallbackModelConfigID(ctx, db, orgID, uuid.Nil) + _, err = resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, uuid.Nil) require.ErrorIs(t, err, ErrNoDefaultChatModelConfig) }) @@ -3868,7 +3860,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) { disabledProvider := newProvider(t, db, false) newModelConfig(t, db, orgID, disabledProvider.ID, true) - _, err := resolveFallbackModelConfigID(ctx, db, orgID, uuid.Nil) + _, err := resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, uuid.Nil) require.ErrorIs(t, err, ErrNoDefaultChatModelConfig) }) @@ -3882,7 +3874,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) { lastModel := newModelConfig(t, db, orgID, disabledProvider.ID, false) newModelConfig(t, db, orgID, disabledProvider.ID, true) - _, err := resolveFallbackModelConfigID(ctx, db, orgID, lastModel.ID) + _, err := resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, lastModel.ID) require.ErrorIs(t, err, ErrNoDefaultChatModelConfig) }) diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index 0b80dcc398e5c..76de22ae28906 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -552,6 +552,9 @@ func (*ModelCatalog) ListConfiguredModels( } models := modelsByProvider[provider] + if models == nil { + models = []codersdk.ChatModelCatalogEntry{} + } sortChatModelCatalogEntries(models) result := codersdk.ChatModelProvider{ diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 12751d44fd67b..9bf7d07c4bc91 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -759,6 +759,39 @@ func TestListConfiguredModels_PolicyAwareAvailability(t *testing.T) { }}, }}}, }, + { + name: "ProviderWithoutConfiguredModelUsesEmptyArray", + configuredProviders: []chatprovider.ConfiguredProvider{ + configuredProvider(fantasyanthropic.Name, "sk-anthropic"), + configuredProvider(fantasyopenai.Name, "sk-openai"), + }, + configuredModels: []chatprovider.ConfiguredModel{{ + Provider: fantasyanthropic.Name, + Model: "claude-3-5-sonnet", + }}, + availabilityByProvider: map[string]chatprovider.ProviderAvailability{ + fantasyanthropic.Name: {Available: true}, + fantasyopenai.Name: {Available: true}, + }, + enabledProviders: enabledProviders(fantasyanthropic.Name, fantasyopenai.Name), + want: codersdk.ChatModelAvailabilityResponse{Providers: []codersdk.ChatModelProvider{ + { + Provider: fantasyanthropic.Name, + Available: true, + Models: []codersdk.ChatModelCatalogEntry{{ + ID: fantasyanthropic.Name + ":claude-3-5-sonnet", + Provider: fantasyanthropic.Name, + Model: "claude-3-5-sonnet", + DisplayName: "claude-3-5-sonnet", + }}, + }, + { + Provider: fantasyopenai.Name, + Available: true, + Models: []codersdk.ChatModelCatalogEntry{}, + }, + }}, + }, { name: "MissingAvailabilityDefaultsToMissingAPIKey", configuredProviders: []chatprovider.ConfiguredProvider{ @@ -799,6 +832,42 @@ func TestListConfiguredModels_PolicyAwareAvailability(t *testing.T) { } } +func TestListConfiguredModels_EmptyModelsJSON(t *testing.T) { + t.Parallel() + + catalog := chatprovider.NewModelCatalog() + response, ok := catalog.ListConfiguredModels( + []chatprovider.ConfiguredProvider{ + {ProviderID: uuid.New(), Provider: fantasyanthropic.Name, APIKey: "anthropic-key"}, + {ProviderID: uuid.New(), Provider: fantasyopenai.Name, APIKey: "openai-key"}, + }, + []chatprovider.ConfiguredModel{{Provider: fantasyanthropic.Name, Model: "claude-3-5-sonnet"}}, + map[string]chatprovider.ProviderAvailability{ + fantasyanthropic.Name: {Available: true}, + fantasyopenai.Name: {Available: true}, + }, + map[string]struct{}{fantasyanthropic.Name: {}, fantasyopenai.Name: {}}, + ) + require.True(t, ok) + + encoded, err := json.Marshal(response) + require.NoError(t, err) + var wire struct { + Providers []struct { + Provider string `json:"provider"` + Models json.RawMessage `json:"models"` + } `json:"providers"` + } + require.NoError(t, json.Unmarshal(encoded, &wire)) + for _, provider := range wire.Providers { + if provider.Provider == fantasyopenai.Name { + require.JSONEq(t, `[]`, string(provider.Models)) + return + } + } + t.Fatal("openai provider not found") +} + func TestListConfiguredProviderAvailability_PolicyAwareFiltering(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/compaction_override.go b/coderd/x/chatd/compaction_override.go index 047aa46086ba3..0aa1335c152bf 100644 --- a/coderd/x/chatd/compaction_override.go +++ b/coderd/x/chatd/compaction_override.go @@ -65,7 +65,7 @@ func (p *Server) resolveCompactionOverrideConfig( raw, chat.OwnerID, func(ctx context.Context, modelConfigID uuid.UUID) (database.ChatModelConfig, string, error) { - return p.resolveModelConfigForOrganization(ctx, chat.OrganizationID, modelConfigID) + return p.resolveModelConfigForOrganization(ctx, chat.OwnerID, chat.OrganizationID, modelConfigID) }, func(ctx context.Context, ownerID uuid.UUID, aiProviderID uuid.UUID) (chatprovider.ProviderAPIKeys, error) { return p.resolveUserProviderAPIKeys(ctx, ownerID, aiProviderID) diff --git a/coderd/x/chatd/compaction_override_internal_test.go b/coderd/x/chatd/compaction_override_internal_test.go index 30ad039f92e7f..c01d8973b19cd 100644 --- a/coderd/x/chatd/compaction_override_internal_test.go +++ b/coderd/x/chatd/compaction_override_internal_test.go @@ -28,10 +28,16 @@ func TestResolveCompactionOverrideConfig_ForeignConfigFallsBack(t *testing.T) { chat.OrganizationID = uuid.New() overrideConfig := titleOverrideModelConfig("gpt-4.1", true) overrideConfig.OrganizationID = uuid.New() - overrideConfig.AIProviderID = uuid.NullUUID{UUID: uuid.New(), Valid: true} + overrideProviderID := uuid.New() + overrideConfig.AIProviderID = uuid.NullUUID{UUID: overrideProviderID, Valid: true} db.EXPECT().GetChatCompactionModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) + db.EXPECT().GetAIProviderByID(gomock.Any(), overrideProviderID).Return(database.AIProvider{ + ID: overrideProviderID, + Type: database.AIProviderTypeOpenai, + Enabled: true, + }, nil) server := titleOverrideTestServer(db, logger) override, err := server.resolveCompactionOverrideConfig(ctx, chat) diff --git a/coderd/x/chatd/configcache.go b/coderd/x/chatd/configcache.go index ea5a0138f84f7..017aff1de9ec1 100644 --- a/coderd/x/chatd/configcache.go +++ b/coderd/x/chatd/configcache.go @@ -21,7 +21,6 @@ import ( const ( chatConfigProvidersTTL = 10 * time.Second - chatConfigModelConfigTTL = 10 * time.Second chatConfigUserPromptTTL = 5 * time.Second chatConfigAdvisorConfigTTL = 10 * time.Second // Bound user-prompt cache cardinality so one-shot users do not @@ -39,37 +38,6 @@ type cachedAdvisorConfig struct { expiresAt time.Time } -type cachedModelConfig struct { - config database.ChatModelConfig - expiresAt time.Time -} - -type modelConfigSnapshot struct { - epoch uint64 - generation uint64 -} - -func cloneChatACL(acl database.ChatACL) database.ChatACL { - if acl == nil { - return nil - } - clone := make(database.ChatACL, len(acl)) - for id, entry := range acl { - entry.Permissions = slices.Clone(entry.Permissions) - clone[id] = entry - } - return clone -} - -// cloneModelConfig returns a copy of cfg with mutable fields cloned so the -// cache owns their backing storage. -func cloneModelConfig(cfg database.ChatModelConfig) database.ChatModelConfig { - cfg.Options = slices.Clone(cfg.Options) - cfg.GroupACL = cloneChatACL(cfg.GroupACL) - cfg.UserACL = cloneChatACL(cfg.UserACL) - return cfg -} - type chatConfigCache struct { db database.Store clock quartz.Clock @@ -90,16 +58,6 @@ type chatConfigCache struct { providerGeneration uint64 providerFetches singleflight.Group[string, []database.AIProvider] - // Model configs (keyed by ID). - modelTopologyEpoch uint64 - modelConfigs map[uuid.UUID]cachedModelConfig - modelConfigFetches singleflight.Group[string, database.ChatModelConfig] - - // Default model configs (keyed by organization ID). - defaultModelConfigs map[uuid.UUID]cachedModelConfig - defaultModelConfigGeneration uint64 - defaultModelConfigFetches singleflight.Group[string, database.ChatModelConfig] - // User custom prompts (keyed by user ID). userPromptEpoch uint64 userPrompts *tlru.Cache[uuid.UUID, string] @@ -113,11 +71,9 @@ type chatConfigCache struct { func newChatConfigCache(ctx context.Context, db database.Store, clock quartz.Clock) *chatConfigCache { return &chatConfigCache{ - db: db, - clock: clock, - ctx: ctx, - modelConfigs: make(map[uuid.UUID]cachedModelConfig), - defaultModelConfigs: make(map[uuid.UUID]cachedModelConfig), + db: db, + clock: clock, + ctx: ctx, userPrompts: tlru.New[uuid.UUID]( tlru.ConstantCost[string], chatConfigUserPromptEntryLimit, @@ -221,152 +177,7 @@ func (c *chatConfigCache) InvalidateProviders() { c.mu.Lock() c.providers = nil c.providerGeneration++ - // Provider topology changed — model selections depend on - // provider existence, so flush all model-config state. - clear(c.modelConfigs) - c.modelTopologyEpoch++ - clear(c.defaultModelConfigs) - c.defaultModelConfigGeneration++ - c.mu.Unlock() -} - -func (c *chatConfigCache) ModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) { - if config, ok := c.cachedModelConfig(id); ok { - return config, nil - } - - snap := c.modelConfigSnapshot() - config, err := singleflightDoChan(ctx, &c.modelConfigFetches, fmt.Sprintf("%d:%s", snap.epoch, id), func() (database.ChatModelConfig, error) { - if cached, ok := c.cachedModelConfig(id); ok { - return cached, nil - } - - fetched, err := c.db.GetChatModelConfigByID(c.ctx, id) - if err != nil { - return database.ChatModelConfig{}, err - } - c.storeModelConfig(snap, fetched) - return cloneModelConfig(fetched), nil - }) - if err != nil { - return database.ChatModelConfig{}, err - } - - return config, nil -} - -func (c *chatConfigCache) cachedModelConfig(id uuid.UUID) (database.ChatModelConfig, bool) { - c.mu.RLock() - entry, ok := c.modelConfigs[id] - c.mu.RUnlock() - if !ok { - return database.ChatModelConfig{}, false - } - if c.clock.Now().Before(entry.expiresAt) { - return cloneModelConfig(entry.config), true - } - - c.mu.Lock() - if current, ok := c.modelConfigs[id]; ok && !c.clock.Now().Before(current.expiresAt) { - delete(c.modelConfigs, id) - } - c.mu.Unlock() - - return database.ChatModelConfig{}, false -} - -func (c *chatConfigCache) modelConfigSnapshot() modelConfigSnapshot { - c.mu.RLock() - snap := modelConfigSnapshot{epoch: c.modelTopologyEpoch} - c.mu.RUnlock() - return snap -} - -func (c *chatConfigCache) storeModelConfig(snap modelConfigSnapshot, config database.ChatModelConfig) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.modelTopologyEpoch != snap.epoch { - return - } - - c.modelConfigs[config.ID] = cachedModelConfig{ - config: cloneModelConfig(config), - expiresAt: c.clock.Now().Add(chatConfigModelConfigTTL), - } -} - -// DefaultModelConfig returns the usable local default model config. -func (c *chatConfigCache) DefaultModelConfig(ctx context.Context, orgID uuid.UUID) (database.ChatModelConfig, error) { - if config, ok := c.cachedDefaultModelConfig(orgID); ok { - return config, nil - } - - snap := c.defaultModelConfigSnapshot() - config, err := singleflightDoChan(ctx, &c.defaultModelConfigFetches, fmt.Sprintf("%d:default:%s", snap.epoch, orgID), func() (database.ChatModelConfig, error) { - if cached, ok := c.cachedDefaultModelConfig(orgID); ok { - return cached, nil - } - - fetched, err := effectiveDefaultChatModelConfig(c.ctx, c.db, orgID) - if err != nil { - return database.ChatModelConfig{}, err - } - c.storeDefaultModelConfig(snap, orgID, fetched) - return cloneModelConfig(fetched), nil - }) - if err != nil { - return database.ChatModelConfig{}, err - } - - return config, nil -} - -func (c *chatConfigCache) cachedDefaultModelConfig(orgID uuid.UUID) (database.ChatModelConfig, bool) { - c.mu.RLock() - entry, ok := c.defaultModelConfigs[orgID] - c.mu.RUnlock() - if !ok { - return database.ChatModelConfig{}, false - } - if c.clock.Now().Before(entry.expiresAt) { - return cloneModelConfig(entry.config), true - } - - c.mu.Lock() - if current, ok := c.defaultModelConfigs[orgID]; ok && !c.clock.Now().Before(current.expiresAt) { - delete(c.defaultModelConfigs, orgID) - } c.mu.Unlock() - - return database.ChatModelConfig{}, false -} - -func (c *chatConfigCache) defaultModelConfigSnapshot() modelConfigSnapshot { - c.mu.RLock() - snap := modelConfigSnapshot{ - epoch: c.modelTopologyEpoch, - generation: c.defaultModelConfigGeneration, - } - c.mu.RUnlock() - return snap -} - -func (c *chatConfigCache) storeDefaultModelConfig(snap modelConfigSnapshot, orgID uuid.UUID, config database.ChatModelConfig) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.modelTopologyEpoch != snap.epoch { - return - } - if c.defaultModelConfigGeneration != snap.generation { - return - } - - c.defaultModelConfigs[orgID] = cachedModelConfig{ - config: cloneModelConfig(config), - expiresAt: c.clock.Now().Add(chatConfigModelConfigTTL), - } } func (c *chatConfigCache) UserPrompt(ctx context.Context, userID uuid.UUID) (string, error) { @@ -424,17 +235,6 @@ func (c *chatConfigCache) storeUserPrompt(epoch uint64, userID uuid.UUID, prompt c.userPrompts.Set(userID, prompt, chatConfigUserPromptTTL) } -func (c *chatConfigCache) InvalidateModelConfig(id uuid.UUID) { - c.mu.Lock() - delete(c.modelConfigs, id) - c.modelTopologyEpoch++ - // Coarse invalidation: the event does not identify the changed config's - // organization or default status, so every per-org default is dropped. - clear(c.defaultModelConfigs) - c.defaultModelConfigGeneration++ - c.mu.Unlock() -} - func (c *chatConfigCache) InvalidateUserPrompt(userID uuid.UUID) { c.mu.Lock() c.userPrompts.Delete(userID) diff --git a/coderd/x/chatd/configcache_internal_test.go b/coderd/x/chatd/configcache_internal_test.go index 67ef60c26b509..93eda31be8a65 100644 --- a/coderd/x/chatd/configcache_internal_test.go +++ b/coderd/x/chatd/configcache_internal_test.go @@ -16,8 +16,6 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtestutil" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" - "github.com/coder/coder/v2/coderd/rbac/policy" - "github.com/coder/coder/v2/coderd/util/syncmap" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -27,15 +25,11 @@ import ( type stubChatConfigStore struct { database.Store - getAIProviders func(context.Context) ([]database.AIProvider, error) - getChatModelConfigByID func(context.Context, uuid.UUID) (database.ChatModelConfig, error) - getEnabledChatModelConfigsByOrganization func(context.Context, uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) - getUserChatCustomPrompt func(context.Context, uuid.UUID) (string, error) - getChatAdvisorConfig func(context.Context) (string, error) + getAIProviders func(context.Context) ([]database.AIProvider, error) + getUserChatCustomPrompt func(context.Context, uuid.UUID) (string, error) + getChatAdvisorConfig func(context.Context) (string, error) enabledProvidersCalls atomic.Int32 - modelConfigByIDCalls atomic.Int32 - effectiveConfigCalls syncmap.Map[uuid.UUID, *atomic.Int32] userPromptCalls atomic.Int32 advisorConfigCalls atomic.Int32 } @@ -48,43 +42,6 @@ func (s *stubChatConfigStore) GetAIProviders(ctx context.Context, _ database.Get return s.getAIProviders(ctx) } -func (s *stubChatConfigStore) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) { - s.modelConfigByIDCalls.Add(1) - if s.getChatModelConfigByID == nil { - panic("unexpected GetChatModelConfigByID call") - } - return s.getChatModelConfigByID(ctx, id) -} - -func (s *stubChatConfigStore) GetEnabledChatModelConfigsByOrganization( - ctx context.Context, - organizationID uuid.UUID, -) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { - counter, _ := s.effectiveConfigCalls.LoadOrStore(organizationID, &atomic.Int32{}) - counter.Add(1) - if s.getEnabledChatModelConfigsByOrganization == nil { - panic("unexpected GetEnabledChatModelConfigsByOrganization call") - } - return s.getEnabledChatModelConfigsByOrganization(ctx, organizationID) -} - -func (s *stubChatConfigStore) effectiveConfigCallCount(organizationID uuid.UUID) int32 { - counter, ok := s.effectiveConfigCalls.Load(organizationID) - if !ok { - return 0 - } - return counter.Load() -} - -func (s *stubChatConfigStore) totalEffectiveConfigCalls() int32 { - var total int32 - s.effectiveConfigCalls.Range(func(_ uuid.UUID, counter *atomic.Int32) bool { - total += counter.Load() - return true - }) - return total -} - func (s *stubChatConfigStore) GetUserChatCustomPrompt(ctx context.Context, userID uuid.UUID) (string, error) { s.userPromptCalls.Add(1) if s.getUserChatCustomPrompt == nil { @@ -168,204 +125,6 @@ func TestConfigCache_EnabledProviders_Invalidation(t *testing.T) { require.Equal(t, int32(2), store.enabledProvidersCalls.Load()) } -func TestConfigCache_ModelConfigByID_CacheHit(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - clock := quartz.NewMock(t) - configID := uuid.New() - config := testChatModelConfig(configID, "model-a") - store := &stubChatConfigStore{ - getChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) { - return config, nil - }, - } - cache := newChatConfigCache(ctx, store, clock) - - first, err := cache.ModelConfigByID(ctx, configID) - require.NoError(t, err) - second, err := cache.ModelConfigByID(ctx, configID) - require.NoError(t, err) - - require.Equal(t, config, first) - require.Equal(t, config, second) - require.Equal(t, int32(1), store.modelConfigByIDCalls.Load()) -} - -func TestConfigCache_ModelConfigByID_ClonesMutableFieldsForCache(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - clock := quartz.NewMock(t) - configID := uuid.New() - const options = `{"temperature":0.1}` - config := testChatModelConfig(configID, "model-a") - config.Options = []byte(options) - config.GroupACL = database.ChatACL{ - "group": {Permissions: []policy.Action{policy.ActionRead, policy.ActionUpdate}}, - } - config.UserACL = database.ChatACL{ - "user": {Permissions: []policy.Action{policy.ActionRead, policy.ActionDelete}}, - } - store := &stubChatConfigStore{ - getChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) { - return config, nil - }, - } - cache := newChatConfigCache(ctx, store, clock) - - // First call populates cache via singleflight. - first, err := cache.ModelConfigByID(ctx, configID) - require.NoError(t, err) - first.Options[0] = 'x' // mutate singleflight return - groupEntry := first.GroupACL["group"] - groupEntry.Permissions[0] = policy.ActionDelete - first.GroupACL["group"] = groupEntry - delete(first.GroupACL, "group") - userEntry := first.UserACL["user"] - userEntry.Permissions[0] = policy.ActionUpdate - first.UserACL["user"] = userEntry - first.UserACL["first-mutation"] = database.ChatACLEntry{} - - // Second call is a cache hit. - second, err := cache.ModelConfigByID(ctx, configID) - require.NoError(t, err) - require.Equal(t, options, string(second.Options)) - require.Equal(t, config.GroupACL, second.GroupACL) - require.Equal(t, config.UserACL, second.UserACL) - require.Equal(t, []policy.Action{policy.ActionRead, policy.ActionUpdate}, second.GroupACL["group"].Permissions) - require.Equal(t, []policy.Action{policy.ActionRead, policy.ActionDelete}, second.UserACL["user"].Permissions) - second.Options[0] = 'y' // mutate cache-hit return - groupEntry = second.GroupACL["group"] - groupEntry.Permissions[1] = policy.ActionDelete - second.GroupACL["group"] = groupEntry - second.GroupACL["second-mutation"] = database.ChatACLEntry{} - delete(second.UserACL, "user") - - // Third call is another cache hit. It must be unaffected. - third, err := cache.ModelConfigByID(ctx, configID) - require.NoError(t, err) - require.Equal(t, options, string(third.Options)) - require.Equal(t, config.GroupACL, third.GroupACL) - require.Equal(t, config.UserACL, third.UserACL) - require.Equal(t, []policy.Action{policy.ActionRead, policy.ActionUpdate}, third.GroupACL["group"].Permissions) - require.Equal(t, []policy.Action{policy.ActionRead, policy.ActionDelete}, third.UserACL["user"].Permissions) -} - -func TestConfigCache_ModelConfigByID_NotFound(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - clock := quartz.NewMock(t) - configID := uuid.New() - store := &stubChatConfigStore{ - getChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) { - return database.ChatModelConfig{}, sql.ErrNoRows - }, - } - cache := newChatConfigCache(ctx, store, clock) - - _, err := cache.ModelConfigByID(ctx, configID) - require.ErrorIs(t, err, sql.ErrNoRows) - _, err = cache.ModelConfigByID(ctx, configID) - require.ErrorIs(t, err, sql.ErrNoRows) - - require.Equal(t, int32(2), store.modelConfigByIDCalls.Load()) - _, ok := cache.modelConfigs[configID] - require.False(t, ok) -} - -func TestConfigCache_InvalidateModelConfig_CascadesToDefault(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - clock := quartz.NewMock(t) - configID := uuid.New() - orgID := uuid.New() - config := testChatModelConfig(configID, "model-a") - store := &stubChatConfigStore{} - store.getChatModelConfigByID = func(context.Context, uuid.UUID) (database.ChatModelConfig, error) { - return config, nil - } - store.getEnabledChatModelConfigsByOrganization = func(_ context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { - call := store.effectiveConfigCallCount(organizationID) - config := testChatModelConfig(uuid.New(), fmt.Sprintf("default-model-%d", call)) - config.IsDefault = true - return []database.GetEnabledChatModelConfigsByOrganizationRow{{ - ChatModelConfig: config, - }}, nil - } - cache := newChatConfigCache(ctx, store, clock) - - _, err := cache.ModelConfigByID(ctx, configID) - require.NoError(t, err) - firstDefault, err := cache.DefaultModelConfig(ctx, orgID) - require.NoError(t, err) - - cache.InvalidateModelConfig(configID) - secondDefault, err := cache.DefaultModelConfig(ctx, orgID) - require.NoError(t, err) - - require.NotEqual(t, firstDefault, secondDefault) - require.Equal(t, int32(2), store.effectiveConfigCallCount(orgID)) -} - -func TestConfigCache_DefaultModelConfig_PerOrgKeying(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - clock := quartz.NewMock(t) - orgA := uuid.New() - orgB := uuid.New() - store := &stubChatConfigStore{} - store.getEnabledChatModelConfigsByOrganization = func(_ context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { - config := testChatModelConfig(uuid.New(), "default-"+organizationID.String()) - config.IsDefault = true - return []database.GetEnabledChatModelConfigsByOrganizationRow{{ - ChatModelConfig: config, - }}, nil - } - cache := newChatConfigCache(ctx, store, clock) - - defaultA, err := cache.DefaultModelConfig(ctx, orgA) - require.NoError(t, err) - defaultB, err := cache.DefaultModelConfig(ctx, orgB) - require.NoError(t, err) - require.NotEqual(t, defaultA.ID, defaultB.ID) - - defaultAAgain, err := cache.DefaultModelConfig(ctx, orgA) - require.NoError(t, err) - require.Equal(t, defaultA, defaultAAgain) - require.Equal(t, int32(1), store.effectiveConfigCallCount(orgA)) - require.Equal(t, int32(1), store.effectiveConfigCallCount(orgB)) - - cache.InvalidateModelConfig(uuid.New()) - _, err = cache.DefaultModelConfig(ctx, orgA) - require.NoError(t, err) - _, err = cache.DefaultModelConfig(ctx, orgB) - require.NoError(t, err) - require.Equal(t, int32(2), store.effectiveConfigCallCount(orgA)) - require.Equal(t, int32(2), store.effectiveConfigCallCount(orgB)) -} - -func TestConfigCache_DefaultModelConfig_NoEffectiveDefault(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - clock := quartz.NewMock(t) - store := &stubChatConfigStore{ - getEnabledChatModelConfigsByOrganization: func(context.Context, uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { - return []database.GetEnabledChatModelConfigsByOrganizationRow{{ - ChatModelConfig: testChatModelConfig(uuid.New(), "non-default"), - }}, nil - }, - } - cache := newChatConfigCache(ctx, store, clock) - - _, err := cache.DefaultModelConfig(ctx, uuid.New()) - require.ErrorIs(t, err, sql.ErrNoRows) -} - func TestConfigCache_UserPrompt_NegativeCaching(t *testing.T) { t.Parallel() @@ -666,125 +425,6 @@ func TestConfigCache_InvalidateProviders_BlocksStaleInFlightProviders(t *testing require.Equal(t, int32(2), store.enabledProvidersCalls.Load()) } -func TestConfigCache_InvalidateProviders_CascadesToModelConfigs(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - clock := quartz.NewMock(t) - configID := uuid.New() - store := &stubChatConfigStore{} - store.getChatModelConfigByID = func(context.Context, uuid.UUID) (database.ChatModelConfig, error) { - call := store.modelConfigByIDCalls.Load() - return testChatModelConfig(configID, fmt.Sprintf("model-%d", call)), nil - } - cache := newChatConfigCache(ctx, store, clock) - - first, err := cache.ModelConfigByID(ctx, configID) - require.NoError(t, err) - cache.InvalidateProviders() - second, err := cache.ModelConfigByID(ctx, configID) - require.NoError(t, err) - - require.NotEqual(t, first, second) - require.Equal(t, int32(2), store.modelConfigByIDCalls.Load()) -} - -func TestConfigCache_InvalidateProviders_CascadesToDefaultModelConfig(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - clock := quartz.NewMock(t) - orgID := uuid.New() - store := &stubChatConfigStore{} - store.getEnabledChatModelConfigsByOrganization = func(_ context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { - call := store.effectiveConfigCallCount(organizationID) - config := testChatModelConfig(uuid.New(), fmt.Sprintf("default-model-%d", call)) - config.IsDefault = true - return []database.GetEnabledChatModelConfigsByOrganizationRow{{ - ChatModelConfig: config, - }}, nil - } - cache := newChatConfigCache(ctx, store, clock) - - first, err := cache.DefaultModelConfig(ctx, orgID) - require.NoError(t, err) - cache.InvalidateProviders() - second, err := cache.DefaultModelConfig(ctx, orgID) - require.NoError(t, err) - - require.NotEqual(t, first, second) - require.Equal(t, int32(2), store.effectiveConfigCallCount(orgID)) -} - -func TestConfigCache_InvalidateProviders_BlocksStaleInFlightModelConfig(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitMedium) - clock := quartz.NewMock(t) - configID := uuid.New() - staleConfig := testChatModelConfig(configID, "stale-model") - freshConfig := testChatModelConfig(configID, "fresh-model") - firstStarted := make(chan struct{}) - secondStarted := make(chan struct{}) - releaseFirst := make(chan struct{}) - releaseSecond := make(chan struct{}) - store := &stubChatConfigStore{} - store.getChatModelConfigByID = func(context.Context, uuid.UUID) (database.ChatModelConfig, error) { - switch call := store.modelConfigByIDCalls.Load(); call { - case 1: - close(firstStarted) - <-releaseFirst - return staleConfig, nil - case 2: - close(secondStarted) - <-releaseSecond - return freshConfig, nil - default: - return database.ChatModelConfig{}, xerrors.Errorf("unexpected model config call %d", call) - } - } - cache := newChatConfigCache(ctx, store, clock) - - type result struct { - config database.ChatModelConfig - err error - } - - firstResult := make(chan result, 1) - go func() { - config, err := cache.ModelConfigByID(ctx, configID) - firstResult <- result{config: config, err: err} - }() - - waitForSignal(t, firstStarted) - cache.InvalidateProviders() - - secondResult := make(chan result, 1) - go func() { - config, err := cache.ModelConfigByID(ctx, configID) - secondResult <- result{config: config, err: err} - }() - - waitForSignal(t, secondStarted) - close(releaseFirst) - first := <-firstResult - require.NoError(t, first.err) - require.Equal(t, staleConfig, first.config) - _, ok := cache.modelConfigs[configID] - require.False(t, ok) - - close(releaseSecond) - second := <-secondResult - require.NoError(t, second.err) - require.Equal(t, freshConfig, second.config) - require.Equal(t, int32(2), store.modelConfigByIDCalls.Load()) - - third, err := cache.ModelConfigByID(ctx, configID) - require.NoError(t, err) - require.Equal(t, freshConfig, third) - require.Equal(t, int32(2), store.modelConfigByIDCalls.Load()) -} - func testAIProvider(name string) database.AIProvider { return database.AIProvider{ ID: uuid.New(), @@ -797,19 +437,6 @@ func testAIProvider(name string) database.AIProvider { } } -func testChatModelConfig(id uuid.UUID, model string) database.ChatModelConfig { - return database.ChatModelConfig{ - ID: id, - Model: model, - DisplayName: model, - Enabled: true, - CreatedAt: time.Unix(0, 0).UTC(), - UpdatedAt: time.Unix(0, 0).UTC(), - ContextLimit: 128000, - CompressionThreshold: 64000, - } -} - func waitForSignal(t *testing.T, ch <-chan struct{}) { t.Helper() @@ -820,8 +447,7 @@ func waitForSignal(t *testing.T, ch <-chan struct{}) { } } -// TestConfigCache_CallerCancellation verifies the DoChan-based -// cancellation semantics across all four cache methods: +// TestConfigCache_CallerCancellation verifies the DoChan-based cancellation semantics: // - A canceled caller returns immediately without waiting for the // shared fill to complete. // - One canceled waiter does not poison other coalesced waiters. @@ -844,9 +470,7 @@ func TestConfigCache_CallerCancellation(t *testing.T) { storeCalls func(store *stubChatConfigStore) int32 } - configID := uuid.New() userID := uuid.New() - defaultOrgID := uuid.New() methods := []cacheMethod{ { @@ -879,70 +503,6 @@ func TestConfigCache_CallerCancellation(t *testing.T) { return store.enabledProvidersCalls.Load() }, }, - { - name: "ModelConfigByID", - setupBlocked: func(store *stubChatConfigStore, started, release chan struct{}) { - var once sync.Once - store.getChatModelConfigByID = func(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) { - once.Do(func() { close(started) }) - select { - case <-ctx.Done(): - return database.ChatModelConfig{}, ctx.Err() - case <-release: - return testChatModelConfig(id, "model"), nil - } - } - }, - setupCtxSensitive: func(store *stubChatConfigStore, started chan struct{}) { - var once sync.Once - store.getChatModelConfigByID = func(ctx context.Context, _ uuid.UUID) (database.ChatModelConfig, error) { - once.Do(func() { close(started) }) - <-ctx.Done() - return database.ChatModelConfig{}, ctx.Err() - } - }, - call: func(ctx context.Context, cache *chatConfigCache) error { - _, err := cache.ModelConfigByID(ctx, configID) - return err - }, - storeCalls: func(store *stubChatConfigStore) int32 { - return store.modelConfigByIDCalls.Load() - }, - }, - { - name: "DefaultModelConfig", - setupBlocked: func(store *stubChatConfigStore, started, release chan struct{}) { - var once sync.Once - store.getEnabledChatModelConfigsByOrganization = func(ctx context.Context, _ uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { - once.Do(func() { close(started) }) - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-release: - config := testChatModelConfig(uuid.New(), "default") - config.IsDefault = true - return []database.GetEnabledChatModelConfigsByOrganizationRow{{ - ChatModelConfig: config, - }}, nil - } - } - }, - setupCtxSensitive: func(store *stubChatConfigStore, started chan struct{}) { - var once sync.Once - store.getEnabledChatModelConfigsByOrganization = func(ctx context.Context, _ uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { - once.Do(func() { close(started) }) - <-ctx.Done() - return nil, ctx.Err() - } - }, - call: func(ctx context.Context, cache *chatConfigCache) error { - _, err := cache.DefaultModelConfig(ctx, defaultOrgID) - return err - }, - storeCalls: func(store *stubChatConfigStore) int32 { - return store.totalEffectiveConfigCalls() - }, - }, { name: "UserPrompt", setupBlocked: func(store *stubChatConfigStore, started, release chan struct{}) { diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index f0b06e6fb9344..bb79819a3f56a 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -276,7 +276,7 @@ func (server *Server) prepareGeneration( // (e.g. Bedrock and Anthropic) can still reject the other's // provider-executed blocks, so a mid-chat provider switch must not replay // them. - promptRows = server.sanitizeForeignProviderExecutedToolRows(ctx, logger, promptRows, modelConfig.ID) + promptRows = server.sanitizeForeignProviderExecutedToolRows(ctx, logger, promptRows, chat.OwnerID, modelConfig.ID) if chat.WorkspaceID.Valid { // Resolve the workspace agent so the chat row's AgentID and diff --git a/coderd/x/chatd/integration_test.go b/coderd/x/chatd/integration_test.go index 2ca2538e81aee..ebf89d783b24f 100644 --- a/coderd/x/chatd/integration_test.go +++ b/coderd/x/chatd/integration_test.go @@ -97,7 +97,7 @@ func TestAnthropicWebSearchRoundTrip(t *testing.T) { // Create a model config that enables web_search. contextLimit := int64(200000) isDefault := true - _, err := expClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := expClient.CreateChatModel(ctx, user.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "claude-sonnet-4-20250514", ContextLimit: &contextLimit, @@ -355,7 +355,7 @@ func TestOpenAIReasoningRoundTrip(t *testing.T) { contextLimit := int64(200000) isDefault := true reasoningSummary := "auto" - _, err := expClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := expClient.CreateChatModel(ctx, user.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "o4-mini", ContextLimit: &contextLimit, @@ -504,7 +504,7 @@ func TestOpenAIReasoningRoundTripStoreFalse(t *testing.T) { contextLimit := int64(200000) isDefault := true reasoningSummary := "auto" - _, err := expClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + _, err := expClient.CreateChatModel(ctx, user.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "o4-mini", ContextLimit: &contextLimit, diff --git a/coderd/x/chatd/provider_switch_sanitize.go b/coderd/x/chatd/provider_switch_sanitize.go index 3b300003f1853..f3ce2b8b6a020 100644 --- a/coderd/x/chatd/provider_switch_sanitize.go +++ b/coderd/x/chatd/provider_switch_sanitize.go @@ -105,9 +105,10 @@ func (server *Server) sanitizeForeignProviderExecutedToolRows( ctx context.Context, logger slog.Logger, rows []database.ChatMessage, + ownerID uuid.UUID, modelConfigID uuid.UUID, ) []database.ChatMessage { - targetCfg, targetProvider, err := server.resolveModelConfigAndNormalizedProvider(ctx, modelConfigID) + targetCfg, targetProvider, err := server.resolveModelConfigAndNormalizedProvider(ctx, ownerID, modelConfigID) if err != nil || targetProvider == "" { logger.Debug(ctx, "skipping provider-switch sanitization: target provider unresolved", slog.F("model_config_id", modelConfigID), @@ -125,7 +126,7 @@ func (server *Server) sanitizeForeignProviderExecutedToolRows( if identity, seen := cache[id.UUID]; seen { return identity, identity != "" } - originCfg, provider, rErr := server.resolveModelConfigAndNormalizedProvider(ctx, id.UUID) + originCfg, provider, rErr := server.resolveModelConfigAndNormalizedProvider(ctx, ownerID, id.UUID) if rErr != nil { logger.Debug(ctx, "provider-switch sanitization: origin provider unresolved, treating as foreign", slog.F("model_config_id", id.UUID), diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index 8429fabe88136..960defa4d6c4b 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -382,6 +382,7 @@ func (p *Server) resolvePersonalModelOverride( ) (database.ChatModelConfig, bool, error) { modelConfig, providerName, err := p.resolveModelConfigForOrganization( ctx, + ownerID, organizationID, modelConfigID, ) @@ -484,7 +485,7 @@ func (p *Server) resolveSubagentModelConfigID( raw, ownerID, func(ctx context.Context, modelConfigID uuid.UUID) (database.ChatModelConfig, string, error) { - return p.resolveModelConfigForOrganization(ctx, organizationID, modelConfigID) + return p.resolveModelConfigForOrganization(ctx, ownerID, organizationID, modelConfigID) }, p.resolveUserProviderAPIKeys, modelOverrideFailureModeSoft, @@ -507,12 +508,17 @@ func modelConfigAIProviderID(modelConfig database.ChatModelConfig) uuid.UUID { func (p *Server) resolveModelConfigAndNormalizedProvider( ctx context.Context, + ownerID uuid.UUID, modelConfigID uuid.UUID, ) (database.ChatModelConfig, string, error) { if modelConfigID == uuid.Nil { return database.ChatModelConfig{}, "", sql.ErrNoRows } - modelConfig, err := p.configCache.ModelConfigByID(ctx, modelConfigID) + modelCtx, err := p.callerModelConfigContext(ctx, ownerID) + if err != nil { + return database.ChatModelConfig{}, "", err + } + modelConfig, err := p.db.GetChatModelConfigByID(modelCtx, modelConfigID) if err != nil { return database.ChatModelConfig{}, "", err } @@ -527,7 +533,8 @@ func (p *Server) resolveNormalizedProviderForModelConfig( return database.ChatModelConfig{}, "", sql.ErrNoRows } if modelConfig.AIProviderID.Valid { - provider, err := p.db.GetAIProviderByID(ctx, modelConfig.AIProviderID.UUID) + //nolint:gocritic // Provider configuration remains a privileged Chatd read. + provider, err := p.db.GetAIProviderByID(dbauthz.AsChatd(ctx), modelConfig.AIProviderID.UUID) if err != nil { return database.ChatModelConfig{}, "", err } @@ -562,10 +569,9 @@ func (p *Server) resolveExplicitSpawnOverrides( listSubagentModelsToolName + " to see available models", ) } - //nolint:gocritic // Chatd needs its scoped config and user-data access here. - chatdCtx := dbauthz.AsChatd(ctx) modelConfig, providerName, err := p.resolveModelConfigForOrganization( - chatdCtx, + ctx, + ownerID, organizationID, modelConfigID, ) @@ -589,8 +595,9 @@ func (p *Server) resolveExplicitSpawnOverrides( return nil, nil, xerrors.New("internal error looking up model config") } } + //nolint:gocritic // Provider credentials remain privileged Chatd reads. providerKeys, err := p.resolveUserProviderAPIKeys( - chatdCtx, + dbauthz.AsChatd(ctx), ownerID, modelConfigAIProviderID(modelConfig), ) @@ -630,9 +637,11 @@ func (p *Server) listSpawnableModelConfigs( ownerID uuid.UUID, organizationID uuid.UUID, ) ([]map[string]any, error) { - //nolint:gocritic // Chatd needs its scoped config and user-data access here. - chatdCtx := dbauthz.AsChatd(ctx) - rows, err := enabledChatModelConfigsForOrganization(chatdCtx, p.db, organizationID) + modelCtx, err := p.callerModelConfigContext(ctx, ownerID) + if err != nil { + return nil, err + } + rows, err := enabledChatModelConfigsForOrganization(modelCtx, p.db, organizationID) if err != nil { return nil, xerrors.Errorf("get enabled chat model configs: %w", err) } @@ -652,8 +661,9 @@ func (p *Server) listSpawnableModelConfigs( providerID := modelConfigAIProviderID(row.ChatModelConfig) providerKeys, ok := providerKeysByID[providerID] if !ok { + //nolint:gocritic // Provider credentials remain privileged Chatd reads. providerKeys, err = p.resolveUserProviderAPIKeys( - chatdCtx, + dbauthz.AsChatd(ctx), ownerID, providerID, ) diff --git a/coderd/x/chatd/title_override.go b/coderd/x/chatd/title_override.go index 7b10541cae3fc..ef9c1c469cde3 100644 --- a/coderd/x/chatd/title_override.go +++ b/coderd/x/chatd/title_override.go @@ -55,8 +55,11 @@ func readTitleGenerationModelOverride( // resolveTitleGenerationModelOverride resolves the deployment-wide title // generation model override. overrideSet is true when an override was -// configured; in that case any returned error is a hard failure. When -// overrideSet is false, callers may fall back to the default title model. +// configured and resolved; a configured override that does not resolve +// because it is unknown or disabled is ignored. Model construction failures +// after a successful resolution stay hard failures. +// When overrideSet is false, callers may fall back to the default title +// model. func (p *Server) resolveTitleGenerationModelOverride( ctx context.Context, chat database.Chat, @@ -76,12 +79,12 @@ func (p *Server) resolveTitleGenerationModelOverride( raw, chat.OwnerID, func(ctx context.Context, modelConfigID uuid.UUID) (database.ChatModelConfig, string, error) { - return p.resolveModelConfigForOrganization(ctx, chat.OrganizationID, modelConfigID) + return p.resolveModelConfigForOrganization(ctx, chat.OwnerID, chat.OrganizationID, modelConfigID) }, func(ctx context.Context, ownerID uuid.UUID, aiProviderID uuid.UUID) (chatprovider.ProviderAPIKeys, error) { return p.resolveUserProviderAPIKeys(ctx, ownerID, aiProviderID) }, - modelOverrideFailureModeHard, + modelOverrideFailureModeSoft, ) if err != nil { if errors.Is(err, errModelConfigOutsideOrganization) { diff --git a/coderd/x/chatd/title_override_internal_test.go b/coderd/x/chatd/title_override_internal_test.go index 779a96f974b3b..8c8ee99741df8 100644 --- a/coderd/x/chatd/title_override_internal_test.go +++ b/coderd/x/chatd/title_override_internal_test.go @@ -281,15 +281,21 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUnusableSkips(t *testi logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) chat, messages := titleOverrideTestChatAndMessages(t) overrideConfig := titleOverrideModelConfig("gpt-4.1", false) + wantTitle := "Fallback title" fallbackModel := &chattest.FakeModel{ GenerateObjectFn: func(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { - t.Fatal("fallback model should not be called when override is unusable") - return nil, xerrors.New("unexpected fallback model call") + return &fantasy.ObjectResponse{ + Object: map[string]any{"title": wantTitle}, + }, nil }, } db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) + db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ + ID: chat.ID, + Title: wantTitle, + }).Return(chatWithTitle(chat, wantTitle), nil) generated := &generatedChatTitle{} server := titleOverrideTestServer(db, logger) @@ -308,8 +314,9 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUnusableSkips(t *testi nil, ) - _, ok := generated.Load() - require.False(t, ok) + gotTitle, ok := generated.Load() + require.True(t, ok) + require.Equal(t, wantTitle, gotTitle) } func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback(t *testing.T) { @@ -398,6 +405,7 @@ func TestResolveManualTitleModel_TitleGenerationOverrideIgnoresForeignConfig(t * db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) + db.EXPECT().GetAIProviderByID(gomock.Any(), overrideProviderID).Return(aibridgeTestAIProvider(overrideProviderID, "foreign-openai", database.AIProviderTypeOpenai), nil) db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{ {ChatModelConfig: localPreferred, Provider: preferredTitleModels[0].provider}, {ChatModelConfig: localDefault, Provider: "openai"}, @@ -612,6 +620,9 @@ func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *te db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return(nil, nil).AnyTimes() + // Missing override credentials soft-fail the override. The local + // organization has no usable model, so fallback resolution fails. + db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return(nil, nil).Times(2) server := titleOverrideTestServer(db, logger) resolved, err := server.resolveManualTitleModel( @@ -620,9 +631,9 @@ func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *te chat, modelBuildOptions{}, ) - require.Error(t, err) - require.ErrorContains(t, err, "resolve manual title generation model override") - require.ErrorContains(t, err, "credentials are unavailable") + // The chat has no model config and its org has no default, so + // fallback resolution errors with ErrNoDefaultChatModelConfig. + require.ErrorIs(t, err, ErrNoDefaultChatModelConfig) require.Equal(t, resolvedModelCall{}, resolved) } @@ -717,6 +728,9 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) + // A disabled override config soft-fails the override. The local + // organization has no usable model, so fallback resolution fails. + db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return(nil, nil).Times(2) server := titleOverrideTestServer(db, logger) resolved, err := server.resolveManualTitleModel( @@ -725,9 +739,9 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T chat, modelBuildOptions{}, ) - require.Error(t, err) - require.ErrorContains(t, err, "resolve manual title generation model override") - require.ErrorContains(t, err, "title generation model override is unavailable") + // The chat has no model config and its org has no default, so + // fallback resolution errors with ErrNoDefaultChatModelConfig. + require.ErrorIs(t, err, ErrNoDefaultChatModelConfig) require.Equal(t, resolvedModelCall{}, resolved) } diff --git a/codersdk/chats.go b/codersdk/chats.go index 8fd24e5fef76e..3e80ced483fe6 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -722,9 +722,9 @@ const ( ChatModelProviderUnavailableReasonUserAPIKeyRequired ChatModelProviderUnavailableReason = "user_api_key_required" ) -// ChatModelCatalogEntry is a discovery catalog entry for end users. -// Its ID is a synthetic provider:model value, not the UUID in ChatModel.ID. -// It is not an admin-managed record. See ChatModel. +// ChatModelCatalogEntry is the runtime catalog view of a model. Its ID is the +// synthetic catalog identity `provider:model` built by canonicalModelID, not +// the chat_model_configs row UUID that ChatModel.ID carries. type ChatModelCatalogEntry struct { ID string `json:"id"` Provider string `json:"provider"` @@ -740,9 +740,11 @@ type ChatModelProvider struct { Models []ChatModelCatalogEntry `json:"models"` } -// ChatModelAvailabilityResponse groups the discovery catalog by provider and -// reports provider availability. +// ChatModelAvailabilityResponse is the catalog returned from chat model discovery. type ChatModelAvailabilityResponse struct { + // Models contains the effective runtime model configs for the requested + // organization. Each config belongs to that organization. + Models []ChatModel `json:"models,omitempty"` Providers []ChatModelProvider `json:"providers"` // UnsupportedProviders lists configured providers the Agents harness // cannot use, so the UI can explain the empty state. @@ -1318,10 +1320,10 @@ type CreateUserChatProviderKeyRequest struct { APIKey string `json:"api_key"` } -// ChatModel is an admin-managed model record for an organization. -// It is not a discovery catalog entry. See ChatModelCatalogEntry. +// ChatModel is an org-scoped model configuration. type ChatModel struct { ID uuid.UUID `json:"id" format:"uuid"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` AIProviderID uuid.UUID `json:"ai_provider_id" format:"uuid"` Model string `json:"model"` DisplayName string `json:"display_name"` @@ -1337,6 +1339,21 @@ type ChatModel struct { UpdatedAt time.Time `json:"updated_at" format:"date-time"` } +// ChatModelACL is the access control list for an organization-scoped chat +// model. Each principal is mapped to its effective model role. +type ChatModelACL struct { + UserRoles map[string]ChatRole `json:"user_roles"` + GroupRoles map[string]ChatRole `json:"group_roles"` +} + +// UpdateChatModelACLRequest is a sparse update of a chat model ACL. Only the +// listed principals change. ChatRoleDeleted removes an entry, while an omitted +// map or principal is unchanged. +type UpdateChatModelACLRequest struct { + UserRoles map[string]ChatRole `json:"user_roles,omitempty"` + GroupRoles map[string]ChatRole `json:"group_roles,omitempty"` +} + // ChatModelProviderOptions contains typed provider-specific options. // // Note: Azure models use the `openai` options shape. @@ -2030,9 +2047,10 @@ func (c *ExperimentalClient) ListChats(ctx context.Context, opts *ListChatsOptio return chats, ReadBodyAsJSON(res, &chats) } -// ChatModelAvailability returns the discovery catalog and provider availability. -func (c *ExperimentalClient) ChatModelAvailability(ctx context.Context) (ChatModelAvailabilityResponse, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/models", nil) +// ChatModelAvailability returns the provider-grouped, per-caller +// availability view of one organization's chat models. +func (c *ExperimentalClient) ChatModelAvailability(ctx context.Context, organizationID uuid.UUID) (ChatModelAvailabilityResponse, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/available", organizationID), nil) if err != nil { return ChatModelAvailabilityResponse{}, err } @@ -2189,24 +2207,41 @@ func (c *ExperimentalClient) DeleteUserChatProviderKey(ctx context.Context, prov return nil } -// ChatModels returns admin-managed chat model records. -func (c *ExperimentalClient) ChatModels(ctx context.Context) ([]ChatModel, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/model-configs", nil) +// ChatModels returns the chat model configs the caller can read in one +// organization, plus the redacted provider descriptors the authoring page +// needs, for org-scoped management and picker surfaces. +func (c *ExperimentalClient) ChatModels(ctx context.Context, organizationID uuid.UUID) (OrganizationChatModelsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID), nil) if err != nil { - return nil, err + return OrganizationChatModelsResponse{}, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return nil, ReadBodyAsError(res) + return OrganizationChatModelsResponse{}, ReadBodyAsError(res) + } + + var resp OrganizationChatModelsResponse + return resp, ReadBodyAsJSON(res, &resp) +} + +// ChatModel fetches one chat model config by ID in an organization. +func (c *ExperimentalClient) ChatModel(ctx context.Context, organizationID, modelConfigID uuid.UUID) (ChatModel, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelConfigID), nil) + if err != nil { + return ChatModel{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatModel{}, ReadBodyAsError(res) } - var models []ChatModel - return models, ReadBodyAsJSON(res, &models) + var config ChatModel + return config, ReadBodyAsJSON(res, &config) } -// CreateChatModel creates an admin-managed ChatModel. -func (c *ExperimentalClient) CreateChatModel(ctx context.Context, req CreateChatModelRequest) (ChatModel, error) { - res, err := c.Request(ctx, http.MethodPost, "/api/experimental/chats/model-configs", req) +// CreateChatModel creates a chat model config in the given organization. +func (c *ExperimentalClient) CreateChatModel(ctx context.Context, organizationID uuid.UUID, req CreateChatModelRequest) (ChatModel, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID), req) if err != nil { return ChatModel{}, err } @@ -2219,9 +2254,9 @@ func (c *ExperimentalClient) CreateChatModel(ctx context.Context, req CreateChat return model, ReadBodyAsJSON(res, &model) } -// UpdateChatModel updates an admin-managed ChatModel. -func (c *ExperimentalClient) UpdateChatModel(ctx context.Context, modelID uuid.UUID, req UpdateChatModelRequest) (ChatModel, error) { - res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/chats/model-configs/%s", modelID), req) +// UpdateChatModel updates a ChatModel in an organization. +func (c *ExperimentalClient) UpdateChatModel(ctx context.Context, organizationID, modelID uuid.UUID, req UpdateChatModelRequest) (ChatModel, error) { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelID), req) if err != nil { return ChatModel{}, err } @@ -2234,9 +2269,39 @@ func (c *ExperimentalClient) UpdateChatModel(ctx context.Context, modelID uuid.U return model, ReadBodyAsJSON(res, &model) } -// DeleteChatModel deletes an admin-managed ChatModel. -func (c *ExperimentalClient) DeleteChatModel(ctx context.Context, modelID uuid.UUID) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/model-configs/%s", modelID), nil) +// ChatModelACL returns the access control list for a chat model in an +// organization. +func (c *ExperimentalClient) ChatModelACL(ctx context.Context, organizationID, modelID uuid.UUID) (ChatModelACL, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s/acl", organizationID, modelID), nil) + if err != nil { + return ChatModelACL{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatModelACL{}, ReadBodyAsError(res) + } + + var modelACL ChatModelACL + return modelACL, ReadBodyAsJSON(res, &modelACL) +} + +// UpdateChatModelACL applies a sparse access control list update to a chat +// model in an organization. +func (c *ExperimentalClient) UpdateChatModelACL(ctx context.Context, organizationID, modelID uuid.UUID, req UpdateChatModelACLRequest) error { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s/acl", organizationID, modelID), req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +// DeleteChatModel deletes a ChatModel in an organization. +func (c *ExperimentalClient) DeleteChatModel(ctx context.Context, organizationID, modelID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelID), nil) if err != nil { return err } @@ -2247,6 +2312,31 @@ func (c *ExperimentalClient) DeleteChatModel(ctx context.Context, modelID uuid.U return nil } +// ChatModelProviderDescriptor is the redacted view of an AI provider carried +// on the org model collection response. It carries only the capability +// metadata the Models UI needs; key material, base URLs, and headers are +// never exposed. The fields mirror what /api/experimental/chats/models +// already discloses to any authenticated caller. +type ChatModelProviderDescriptor struct { + ID uuid.UUID `json:"id" format:"uuid"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + Icon string `json:"icon"` + Enabled bool `json:"enabled"` + HasAPIKey bool `json:"has_api_key"` + HasUserAPIKey bool `json:"has_user_api_key"` + HasEffectiveAPIKey bool `json:"has_effective_api_key"` + AllowUserAPIKey bool `json:"allow_user_api_key"` +} + +// OrganizationChatModelsResponse is the org chat model config collection: +// the caller-readable configs plus the redacted provider descriptors the +// authoring page needs. +type OrganizationChatModelsResponse struct { + Models []ChatModel `json:"models"` + Providers []ChatModelProviderDescriptor `json:"providers"` +} + // GetChatCost returns the AI Gateway cost for the whole chat tree that // contains chatID. func (c *ExperimentalClient) GetChatCost(ctx context.Context, chatID uuid.UUID) (ChatCost, error) { diff --git a/codersdk/chats_model_acl_test.go b/codersdk/chats_model_acl_test.go new file mode 100644 index 0000000000000..205226de81865 --- /dev/null +++ b/codersdk/chats_model_acl_test.go @@ -0,0 +1,88 @@ +package codersdk_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +func TestExperimentalClientChatModelACL(t *testing.T) { + t.Parallel() + + organizationID := uuid.New() + modelID := uuid.New() + userID := uuid.New() + groupID := uuid.New() + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/api/experimental/organizations/"+organizationID.String()+"/chats/models/"+modelID.String()+"/acl", r.URL.Path) + http.Error(rw, `{"user_roles":{"`+userID.String()+`":"read"},"group_roles":{"`+groupID.String()+`":"read"}}`, http.StatusOK) + })) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + client := codersdk.NewExperimentalClient(codersdk.New(serverURL)) + + modelACL, err := client.ChatModelACL(context.Background(), organizationID, modelID) + require.NoError(t, err) + require.Equal(t, map[string]codersdk.ChatRole{userID.String(): codersdk.ChatRoleRead}, modelACL.UserRoles) + require.Equal(t, map[string]codersdk.ChatRole{groupID.String(): codersdk.ChatRoleRead}, modelACL.GroupRoles) +} + +func TestExperimentalClientUpdateChatModelACL(t *testing.T) { + t.Parallel() + + organizationID := uuid.New() + modelID := uuid.New() + userID := uuid.New() + groupID := uuid.New() + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method) + require.Equal(t, "/api/experimental/organizations/"+organizationID.String()+"/chats/models/"+modelID.String()+"/acl", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + var payload map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &payload)) + require.JSONEq(t, `{"`+userID.String()+`":"read"}`, string(payload["user_roles"])) + require.JSONEq(t, `{"`+groupID.String()+`":""}`, string(payload["group_roles"])) + rw.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + client := codersdk.NewExperimentalClient(codersdk.New(serverURL)) + + err = client.UpdateChatModelACL(context.Background(), organizationID, modelID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{userID.String(): codersdk.ChatRoleRead}, + GroupRoles: map[string]codersdk.ChatRole{groupID.String(): codersdk.ChatRoleDeleted}, + }) + require.NoError(t, err) +} + +func TestUpdateChatModelACLRequestOmittedMaps(t *testing.T) { + t.Parallel() + + payload, err := json.Marshal(codersdk.UpdateChatModelACLRequest{}) + require.NoError(t, err) + require.JSONEq(t, `{}`, string(payload)) + + payload, err = json.Marshal(codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{}, + GroupRoles: map[string]codersdk.ChatRole{}, + }) + require.NoError(t, err) + require.JSONEq(t, `{}`, string(payload)) +} diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go index 8cfc2b0b1c439..8647e4bed44e4 100644 --- a/codersdk/toolsdk/chats.go +++ b/codersdk/toolsdk/chats.go @@ -2,10 +2,8 @@ package toolsdk import ( "context" - "errors" "fmt" "io" - "net/http" "strings" "time" @@ -18,11 +16,6 @@ import ( const chatIDDescription = "UUID of the chat." -func isForbiddenError(err error) bool { - var sdkErr *codersdk.Error - return errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusForbidden -} - func parseChatID(chatID string) (uuid.UUID, error) { if chatID == "" { return uuid.Nil, xerrors.New("chat_id is required") @@ -108,7 +101,7 @@ The chat runs asynchronously. Poll coder_get_chat for status and read the transc }, "model_config_id": map[string]any{ "type": "string", - "description": "Optional chat model config UUID from coder_list_chat_model_configs. Defaults to the deployment default model.", + "description": "Optional chat model config UUID from coder_list_chat_model_configs. Defaults to the organization's default model.", }, "labels": map[string]any{ "type": "object", @@ -866,58 +859,61 @@ type ChatModelConfigSummary struct { IsDefault bool `json:"is_default"` } +type ListChatModelConfigsArgs struct { + OrganizationID string `json:"organization_id"` +} + type ListChatModelConfigsResponse struct { ModelConfigs []ChatModelConfigSummary `json:"model_configs"` } -var ListChatModelConfigs = Tool[NoArgs, ListChatModelConfigsResponse]{ +var ListChatModelConfigs = Tool[ListChatModelConfigsArgs, ListChatModelConfigsResponse]{ Tool: aisdk.Tool{ Name: ToolNameListChatModelConfigs, Description: `List the enabled chat models available for Coder Agents chats. Use a model config ID with coder_create_chat to pick a model. Per-user provider credentials are validated when creating a chat, so coder_create_chat can still reject a listed model with an explanatory error.`, Schema: aisdk.Schema{ - Properties: map[string]any{}, - Required: []string{}, + Properties: map[string]any{ + "organization_id": map[string]any{ + "type": "string", + "description": "Optional organization UUID. Defaults to the authenticated user's first organization.", + }, + }, + Required: []string{}, }, }, MCPAnnotations: mcpReadOnlyAnnotations, - Handler: func(ctx context.Context, deps Deps, _ NoArgs) (ListChatModelConfigsResponse, error) { - configs, err := codersdk.NewExperimentalClient(deps.coderClient).ChatModels(ctx) + Handler: func(ctx context.Context, deps Deps, args ListChatModelConfigsArgs) (ListChatModelConfigsResponse, error) { + var organizationID uuid.UUID + if args.OrganizationID != "" { + var err error + organizationID, err = uuid.Parse(args.OrganizationID) + if err != nil { + return ListChatModelConfigsResponse{}, xerrors.New("organization_id must be a valid UUID") + } + } else { + me, err := deps.coderClient.User(ctx, codersdk.Me) + if err != nil { + return ListChatModelConfigsResponse{}, xerrors.Errorf("get authenticated user: %w", err) + } + if len(me.OrganizationIDs) == 0 { + return ListChatModelConfigsResponse{}, xerrors.New("authenticated user belongs to no organization; pass organization_id explicitly") + } + organizationID = me.OrganizationIDs[0] + } + + response, err := codersdk.NewExperimentalClient(deps.coderClient).ChatModels(ctx, organizationID) if err != nil { return ListChatModelConfigsResponse{}, xerrors.Errorf("list chat model configs: %w", err) } - // Admin model lists include disabled providers; non-admin lists are - // already filtered server-side. - var providerEnabled map[uuid.UUID]bool - providers, err := deps.coderClient.AIProviders(ctx) - switch { - case err == nil: - providerEnabled = make(map[uuid.UUID]bool, len(providers)) - for _, provider := range providers { - providerEnabled[provider.ID] = provider.Enabled - } - case isForbiddenError(err): - // Deployment-config readers can receive the unfiltered admin list - // without provider access, so fail closed unless both requests return 403. - _, dcErr := deps.coderClient.DeploymentConfig(ctx) - switch { - case dcErr == nil: - return ListChatModelConfigsResponse{}, xerrors.New("cannot verify provider availability for the admin model config list: missing AI provider read permission") - case !isForbiddenError(dcErr): - return ListChatModelConfigsResponse{}, xerrors.Errorf("verify deployment config access: %w", dcErr) - } - default: - return ListChatModelConfigsResponse{}, xerrors.Errorf("list AI providers: %w", err) + providerEnabled := make(map[uuid.UUID]bool, len(response.Providers)) + for _, provider := range response.Providers { + providerEnabled[provider.ID] = provider.Enabled } - summaries := make([]ChatModelConfigSummary, 0, len(configs)) - for _, config := range configs { - if !config.Enabled { - continue - } - // A non-nil map is authoritative because soft-deleted providers are - // absent while their configs remain in the admin response. - if providerEnabled != nil && !providerEnabled[config.AIProviderID] { + summaries := make([]ChatModelConfigSummary, 0, len(response.Models)) + for _, config := range response.Models { + if !config.Enabled || !providerEnabled[config.AIProviderID] { continue } summaries = append(summaries, ChatModelConfigSummary{ diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go index c40f0087e29d9..c6a36158af83b 100644 --- a/codersdk/toolsdk/chats_test.go +++ b/codersdk/toolsdk/chats_test.go @@ -13,7 +13,6 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/aibridgedtest" "github.com/coder/coder/v2/coderd/coderdtest" @@ -29,17 +28,6 @@ import ( "github.com/coder/coder/v2/testutil" ) -type failPathTransport struct { - path string -} - -func (t *failPathTransport) RoundTrip(req *http.Request) (*http.Response, error) { - if req.URL.Path == t.path { - return nil, xerrors.New("transport down") - } - return http.DefaultTransport.RoundTrip(req) -} - // stallTransport hangs every request until its context is canceled. type stallTransport struct{} @@ -90,7 +78,7 @@ func TestChatTools(t *testing.T) { require.NoError(t, err) t.Run("ListChatModelConfigs", func(t *testing.T) { - result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{}) + result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{}) require.NoError(t, err) require.Len(t, result.ModelConfigs, 1) require.Equal(t, defaultModelConfig.ID.String(), result.ModelConfigs[0].ID) @@ -98,6 +86,38 @@ func TestChatTools(t *testing.T) { require.True(t, result.ModelConfigs[0].IsDefault) }) + t.Run("ListChatModelConfigsForOrganization", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + organization := dbgen.Organization(t, api.Database, database.Organization{}) + dbgen.OrganizationMember(t, api.Database, database.OrganizationMember{ + OrganizationID: organization.ID, + UserID: firstUser.UserID, + }) + + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAICompat, + Name: "other-org-" + uuid.NewString(), + BaseURL: chattest.OpenAI(t), + Enabled: true, + APIKeys: []string{"test-api-key"}, + }) + require.NoError(t, err) + contextLimit := int64(4096) + model, err := expClient.CreateChatModel(ctx, organization.ID, codersdk.CreateChatModelRequest{ + AIProviderID: &provider.ID, + Model: "gpt-4o-other-org", + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + + result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{ + OrganizationID: organization.ID.String(), + }) + require.NoError(t, err) + require.Len(t, result.ModelConfigs, 1) + require.Equal(t, model.ID.String(), result.ModelConfigs[0].ID) + }) + t.Run("Lifecycle", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) @@ -671,7 +691,7 @@ func TestChatTools(t *testing.T) { require.NoError(t, err) require.False(t, provider.Enabled) - result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{}) + result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{}) require.NoError(t, err) var ids []string for _, config := range result.ModelConfigs { @@ -688,7 +708,7 @@ func TestChatTools(t *testing.T) { err := client.DeleteAIProvider(ctx, deletedProviderConfig.AIProviderID.String()) require.NoError(t, err) - result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{}) + result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{}) require.NoError(t, err) var ids []string for _, config := range result.ModelConfigs { @@ -754,7 +774,7 @@ func TestChatTools(t *testing.T) { memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) memberDeps, err := toolsdk.NewDeps(memberClient) require.NoError(t, err) - result, err := testTool(t, toolsdk.ListChatModelConfigs, memberDeps, toolsdk.NoArgs{}) + result, err := testTool(t, toolsdk.ListChatModelConfigs, memberDeps, toolsdk.ListChatModelConfigsArgs{}) require.NoError(t, err) var ids []string for _, config := range result.ModelConfigs { @@ -765,19 +785,13 @@ func TestChatTools(t *testing.T) { auditorClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID, rbac.RoleAuditor()) auditorDeps, err := toolsdk.NewDeps(auditorClient) require.NoError(t, err) - _, err = testTool(t, toolsdk.ListChatModelConfigs, auditorDeps, toolsdk.NoArgs{}) - require.ErrorContains(t, err, "missing AI provider read permission") - - brokenProbeClient := codersdk.New(auditorClient.URL) - brokenProbeClient.SetSessionToken(auditorClient.SessionToken()) - brokenProbeClient.HTTPClient = &http.Client{ - Transport: &failPathTransport{path: "/api/v2/deployment/config"}, - } - t.Cleanup(brokenProbeClient.HTTPClient.CloseIdleConnections) - brokenProbeDeps, err := toolsdk.NewDeps(brokenProbeClient) + auditorResult, err := testTool(t, toolsdk.ListChatModelConfigs, auditorDeps, toolsdk.ListChatModelConfigsArgs{}) require.NoError(t, err) - _, err = testTool(t, toolsdk.ListChatModelConfigs, brokenProbeDeps, toolsdk.NoArgs{}) - require.ErrorContains(t, err, "verify deployment config access") + var auditorIDs []string + for _, config := range auditorResult.ModelConfigs { + auditorIDs = append(auditorIDs, config.ID) + } + require.Contains(t, auditorIDs, defaultModelConfig.ID.String()) }) t.Run("CreateChatZeroOrgUser", func(t *testing.T) { @@ -796,6 +810,9 @@ func TestChatTools(t *testing.T) { _, err := testTool(t, toolsdk.CreateChat, tb, toolsdk.CreateChatArgs{}) require.ErrorContains(t, err, "prompt is required") + _, err = testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{OrganizationID: "not-a-uuid"}) + require.ErrorContains(t, err, "organization_id must be a valid UUID") + _, err = testTool(t, toolsdk.GetChat, tb, toolsdk.GetChatArgs{ChatID: "not-a-uuid"}) require.ErrorContains(t, err, "chat_id must be a valid UUID") diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index b08703eb54b8c..d51b4bde77da2 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -27,6 +27,7 @@ We track the following resources: | AuditableUserAIBudgetOverride
write, delete | |
FieldTracked
created_atfalse
group_idtrue
group_nametrue
spend_limittrue
spend_limit_microsfalse
updated_atfalse
user_idfalse
usernamefalse
| | Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
compaction_requested_atfalse
context_aggregate_hashfalse
context_dirty_resourcesfalse
context_dirty_sincefalse
context_errorfalse
created_atfalse
dynamic_toolsfalse
generation_attemptfalse
group_acltrue
heartbeat_atfalse
history_versionfalse
idtrue
labelstrue
last_errorfalse
last_model_config_idfalse
last_read_message_idfalse
last_reasoning_effortfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
queue_versionfalse
requires_action_deadline_atfalse
retry_statefalse
retry_state_versionfalse
root_chat_idfalse
runner_idfalse
snapshot_versionfalse
started_atfalse
statusfalse
summaryfalse
summary_generated_atfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| | ChatInstructionSettings
write | |
FieldTracked
idfalse
include_default_system_prompttrue
include_default_system_prompt_settrue
namefalse
plan_mode_instructionstrue
system_prompttrue
| +| ChatModelConfig
write | |
FieldTracked
ai_provider_idtrue
compression_thresholdtrue
context_limittrue
created_atfalse
created_bytrue
deletedtrue
deleted_atfalse
display_nametrue
enabledtrue
group_acltrue
idfalse
is_defaulttrue
modeltrue
optionstrue
organization_idfalse
updated_atfalse
updated_bytrue
user_acltrue
| | CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| | GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| | GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index a47211652f363..d8fe3468d000a 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -596,59 +596,6 @@ Experimental: this endpoint is subject to change. To perform this operation, you must be authenticated. [Learn more](authentication.md). -## List chat models - -### Code samples - -```sh -# Example request using curl -curl -X GET http://coder-server:8080/api/experimental/chats/models \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /api/experimental/chats/models` - -Experimental: this endpoint is subject to change. - -### Example responses - -> 200 Response - -```json -{ - "providers": [ - { - "available": true, - "models": [ - { - "display_name": "string", - "id": "string", - "model": "string", - "provider": "string" - } - ], - "provider": "string", - "unavailable_reason": "missing_api_key" - } - ], - "unsupported_providers": [ - { - "display_name": "string", - "provider": "string" - } - ] -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ChatModelAvailabilityResponse](schemas.md#codersdkchatmodelavailabilityresponse) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - ## Watch chat events for a user via WebSockets ### Code samples @@ -3268,3 +3215,233 @@ Experimental: this endpoint is subject to change. | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## List available chat models in an organization + +### Code samples + +```sh +# Example request using curl +curl -X GET http://coder-server:8080/api/experimental/organizations/{organization}/chats/models/available \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/experimental/organizations/{organization}/chats/models/available` + +Experimental: this endpoint is subject to change. + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|------|--------|----------|-------------------------| +| `organization` | path | string | true | Organization name or ID | + +### Example responses + +> 200 Response + +```json +{ + "models": [ + { + "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819", + "compression_threshold": 0, + "context_limit": 0, + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "enabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_default": true, + "model": "string", + "model_config": { + "frequency_penalty": 0, + "max_output_tokens": 0, + "openai_config": { + "use_responses_api": true + }, + "presence_penalty": 0, + "provider_options": { + "anthropic": { + "allowed_domains": [ + "string" + ], + "blocked_domains": [ + "string" + ], + "context_1m_enabled": true, + "disable_parallel_tool_use": true, + "send_reasoning": true, + "thinking": { + "budget_tokens": 0 + }, + "thinking_display": "string", + "web_search_enabled": true + }, + "google": { + "cached_content": "string", + "safety_settings": [ + { + "category": "string", + "threshold": "string" + } + ], + "thinking_config": { + "include_thoughts": true, + "thinking_budget": 0, + "thinking_level": "string" + }, + "threshold": "string", + "web_search_enabled": true + }, + "openai": { + "allowed_domains": [ + "string" + ], + "include": [ + "string" + ], + "instructions": "string", + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "max_completion_tokens": 0, + "max_tool_calls": 0, + "metadata": { + "property1": null, + "property2": null + }, + "parallel_tool_calls": true, + "prediction": { + "property1": null, + "property2": null + }, + "prompt_cache_key": "string", + "reasoning_summary": "string", + "safety_identifier": "string", + "search_context_size": "string", + "service_tier": "string", + "store": true, + "strict_json_schema": true, + "structured_outputs": true, + "text_verbosity": "string", + "top_log_probs": 0, + "user": "string", + "web_search_enabled": true + }, + "openaicompat": { + "user": "string" + }, + "openrouter": { + "extra_body": { + "property1": null, + "property2": null + }, + "include_usage": true, + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "parallel_tool_calls": true, + "provider": { + "allow_fallbacks": true, + "data_collection": "string", + "ignore": [ + "string" + ], + "only": [ + "string" + ], + "order": [ + "string" + ], + "quantizations": [ + "string" + ], + "require_parameters": true, + "sort": "string" + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "user": "string" + }, + "vercel": { + "extra_body": { + "property1": null, + "property2": null + }, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "logprobs": true, + "parallel_tool_calls": true, + "providerOptions": { + "models": [ + "string" + ], + "order": [ + "string" + ] + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "top_logprobs": 0, + "user": "string" + } + }, + "reasoning_effort": { + "default": "string", + "max": "string" + }, + "temperature": 0, + "top_k": 0, + "top_p": 0 + }, + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "reasoning_efforts": [ + "string" + ], + "updated_at": "2019-08-24T14:15:22Z" + } + ], + "providers": [ + { + "available": true, + "models": [ + { + "display_name": "string", + "id": "string", + "model": "string", + "provider": "string" + } + ], + "provider": "string", + "unavailable_reason": "missing_api_key" + } + ], + "unsupported_providers": [ + { + "display_name": "string", + "provider": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ChatModelAvailabilityResponse](schemas.md#codersdkchatmodelavailabilityresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 4bd8af6b0709f..4259672f371ce 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3393,60 +3393,926 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `messages` | array of [codersdk.ChatMessage](#codersdkchatmessage) | false | | | | `queued_messages` | array of [codersdk.ChatQueuedMessage](#codersdkchatqueuedmessage) | false | | | +## codersdk.ChatModel + +```json +{ + "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819", + "compression_threshold": 0, + "context_limit": 0, + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "enabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_default": true, + "model": "string", + "model_config": { + "frequency_penalty": 0, + "max_output_tokens": 0, + "openai_config": { + "use_responses_api": true + }, + "presence_penalty": 0, + "provider_options": { + "anthropic": { + "allowed_domains": [ + "string" + ], + "blocked_domains": [ + "string" + ], + "context_1m_enabled": true, + "disable_parallel_tool_use": true, + "send_reasoning": true, + "thinking": { + "budget_tokens": 0 + }, + "thinking_display": "string", + "web_search_enabled": true + }, + "google": { + "cached_content": "string", + "safety_settings": [ + { + "category": "string", + "threshold": "string" + } + ], + "thinking_config": { + "include_thoughts": true, + "thinking_budget": 0, + "thinking_level": "string" + }, + "threshold": "string", + "web_search_enabled": true + }, + "openai": { + "allowed_domains": [ + "string" + ], + "include": [ + "string" + ], + "instructions": "string", + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "max_completion_tokens": 0, + "max_tool_calls": 0, + "metadata": { + "property1": null, + "property2": null + }, + "parallel_tool_calls": true, + "prediction": { + "property1": null, + "property2": null + }, + "prompt_cache_key": "string", + "reasoning_summary": "string", + "safety_identifier": "string", + "search_context_size": "string", + "service_tier": "string", + "store": true, + "strict_json_schema": true, + "structured_outputs": true, + "text_verbosity": "string", + "top_log_probs": 0, + "user": "string", + "web_search_enabled": true + }, + "openaicompat": { + "user": "string" + }, + "openrouter": { + "extra_body": { + "property1": null, + "property2": null + }, + "include_usage": true, + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "parallel_tool_calls": true, + "provider": { + "allow_fallbacks": true, + "data_collection": "string", + "ignore": [ + "string" + ], + "only": [ + "string" + ], + "order": [ + "string" + ], + "quantizations": [ + "string" + ], + "require_parameters": true, + "sort": "string" + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "user": "string" + }, + "vercel": { + "extra_body": { + "property1": null, + "property2": null + }, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "logprobs": true, + "parallel_tool_calls": true, + "providerOptions": { + "models": [ + "string" + ], + "order": [ + "string" + ] + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "top_logprobs": 0, + "user": "string" + } + }, + "reasoning_effort": { + "default": "string", + "max": "string" + }, + "temperature": 0, + "top_k": 0, + "top_p": 0 + }, + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "reasoning_efforts": [ + "string" + ], + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------------|--------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------| +| `ai_provider_id` | string | false | | | +| `compression_threshold` | integer | false | | | +| `context_limit` | integer | false | | | +| `created_at` | string | false | | | +| `display_name` | string | false | | | +| `enabled` | boolean | false | | | +| `id` | string | false | | | +| `is_default` | boolean | false | | | +| `model` | string | false | | | +| `model_config` | [codersdk.ChatModelCallConfig](#codersdkchatmodelcallconfig) | false | | | +| `organization_id` | string | false | | | +| `reasoning_efforts` | array of string | false | | Reasoning efforts lists selectable reasoning effort values through the model's configured maximum. | +| `updated_at` | string | false | | | + +## codersdk.ChatModelACL + +```json +{ + "group_roles": { + "property1": "read", + "property2": "read" + }, + "user_roles": { + "property1": "read", + "property2": "read" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|----------------------------------------|----------|--------------|-------------| +| `group_roles` | object | false | | | +| » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | | +| `user_roles` | object | false | | | +| » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | | + +## codersdk.ChatModelAnthropicProviderOptions + +```json +{ + "allowed_domains": [ + "string" + ], + "blocked_domains": [ + "string" + ], + "context_1m_enabled": true, + "disable_parallel_tool_use": true, + "send_reasoning": true, + "thinking": { + "budget_tokens": 0 + }, + "thinking_display": "string", + "web_search_enabled": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------------------|------------------------------------------------------------------------------------------|----------|--------------|-------------| +| `allowed_domains` | array of string | false | | | +| `blocked_domains` | array of string | false | | | +| `context_1m_enabled` | boolean | false | | | +| `disable_parallel_tool_use` | boolean | false | | | +| `send_reasoning` | boolean | false | | | +| `thinking` | [codersdk.ChatModelAnthropicThinkingOptions](#codersdkchatmodelanthropicthinkingoptions) | false | | | +| `thinking_display` | string | false | | | +| `web_search_enabled` | boolean | false | | | + +## codersdk.ChatModelAnthropicThinkingOptions + +```json +{ + "budget_tokens": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------|---------|----------|--------------|-------------| +| `budget_tokens` | integer | false | | | + ## codersdk.ChatModelAvailabilityResponse ```json { - "providers": [ - { - "available": true, - "models": [ - { - "display_name": "string", - "id": "string", - "model": "string", - "provider": "string" - } - ], - "provider": "string", - "unavailable_reason": "missing_api_key" - } + "models": [ + { + "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819", + "compression_threshold": 0, + "context_limit": 0, + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "enabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_default": true, + "model": "string", + "model_config": { + "frequency_penalty": 0, + "max_output_tokens": 0, + "openai_config": { + "use_responses_api": true + }, + "presence_penalty": 0, + "provider_options": { + "anthropic": { + "allowed_domains": [ + "string" + ], + "blocked_domains": [ + "string" + ], + "context_1m_enabled": true, + "disable_parallel_tool_use": true, + "send_reasoning": true, + "thinking": { + "budget_tokens": 0 + }, + "thinking_display": "string", + "web_search_enabled": true + }, + "google": { + "cached_content": "string", + "safety_settings": [ + { + "category": "string", + "threshold": "string" + } + ], + "thinking_config": { + "include_thoughts": true, + "thinking_budget": 0, + "thinking_level": "string" + }, + "threshold": "string", + "web_search_enabled": true + }, + "openai": { + "allowed_domains": [ + "string" + ], + "include": [ + "string" + ], + "instructions": "string", + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "max_completion_tokens": 0, + "max_tool_calls": 0, + "metadata": { + "property1": null, + "property2": null + }, + "parallel_tool_calls": true, + "prediction": { + "property1": null, + "property2": null + }, + "prompt_cache_key": "string", + "reasoning_summary": "string", + "safety_identifier": "string", + "search_context_size": "string", + "service_tier": "string", + "store": true, + "strict_json_schema": true, + "structured_outputs": true, + "text_verbosity": "string", + "top_log_probs": 0, + "user": "string", + "web_search_enabled": true + }, + "openaicompat": { + "user": "string" + }, + "openrouter": { + "extra_body": { + "property1": null, + "property2": null + }, + "include_usage": true, + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "parallel_tool_calls": true, + "provider": { + "allow_fallbacks": true, + "data_collection": "string", + "ignore": [ + "string" + ], + "only": [ + "string" + ], + "order": [ + "string" + ], + "quantizations": [ + "string" + ], + "require_parameters": true, + "sort": "string" + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "user": "string" + }, + "vercel": { + "extra_body": { + "property1": null, + "property2": null + }, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "logprobs": true, + "parallel_tool_calls": true, + "providerOptions": { + "models": [ + "string" + ], + "order": [ + "string" + ] + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "top_logprobs": 0, + "user": "string" + } + }, + "reasoning_effort": { + "default": "string", + "max": "string" + }, + "temperature": 0, + "top_k": 0, + "top_p": 0 + }, + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "reasoning_efforts": [ + "string" + ], + "updated_at": "2019-08-24T14:15:22Z" + } + ], + "providers": [ + { + "available": true, + "models": [ + { + "display_name": "string", + "id": "string", + "model": "string", + "provider": "string" + } + ], + "provider": "string", + "unavailable_reason": "missing_api_key" + } + ], + "unsupported_providers": [ + { + "display_name": "string", + "provider": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------------|-------------------------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------| +| `models` | array of [codersdk.ChatModel](#codersdkchatmodel) | false | | Models contains the effective runtime model configs for the requested organization. Each config belongs to that organization. | +| `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.ChatModelCallConfig + +```json +{ + "frequency_penalty": 0, + "max_output_tokens": 0, + "openai_config": { + "use_responses_api": true + }, + "presence_penalty": 0, + "provider_options": { + "anthropic": { + "allowed_domains": [ + "string" + ], + "blocked_domains": [ + "string" + ], + "context_1m_enabled": true, + "disable_parallel_tool_use": true, + "send_reasoning": true, + "thinking": { + "budget_tokens": 0 + }, + "thinking_display": "string", + "web_search_enabled": true + }, + "google": { + "cached_content": "string", + "safety_settings": [ + { + "category": "string", + "threshold": "string" + } + ], + "thinking_config": { + "include_thoughts": true, + "thinking_budget": 0, + "thinking_level": "string" + }, + "threshold": "string", + "web_search_enabled": true + }, + "openai": { + "allowed_domains": [ + "string" + ], + "include": [ + "string" + ], + "instructions": "string", + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "max_completion_tokens": 0, + "max_tool_calls": 0, + "metadata": { + "property1": null, + "property2": null + }, + "parallel_tool_calls": true, + "prediction": { + "property1": null, + "property2": null + }, + "prompt_cache_key": "string", + "reasoning_summary": "string", + "safety_identifier": "string", + "search_context_size": "string", + "service_tier": "string", + "store": true, + "strict_json_schema": true, + "structured_outputs": true, + "text_verbosity": "string", + "top_log_probs": 0, + "user": "string", + "web_search_enabled": true + }, + "openaicompat": { + "user": "string" + }, + "openrouter": { + "extra_body": { + "property1": null, + "property2": null + }, + "include_usage": true, + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "parallel_tool_calls": true, + "provider": { + "allow_fallbacks": true, + "data_collection": "string", + "ignore": [ + "string" + ], + "only": [ + "string" + ], + "order": [ + "string" + ], + "quantizations": [ + "string" + ], + "require_parameters": true, + "sort": "string" + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "user": "string" + }, + "vercel": { + "extra_body": { + "property1": null, + "property2": null + }, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "logprobs": true, + "parallel_tool_calls": true, + "providerOptions": { + "models": [ + "string" + ], + "order": [ + "string" + ] + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "top_logprobs": 0, + "user": "string" + } + }, + "reasoning_effort": { + "default": "string", + "max": "string" + }, + "temperature": 0, + "top_k": 0, + "top_p": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------------|------------------------------------------------------------------------------------|----------|--------------|-------------| +| `frequency_penalty` | number | false | | | +| `max_output_tokens` | integer | false | | | +| `openai_config` | [codersdk.ChatModelOpenAIConfig](#codersdkchatmodelopenaiconfig) | false | | | +| `presence_penalty` | number | false | | | +| `provider_options` | [codersdk.ChatModelProviderOptions](#codersdkchatmodelprovideroptions) | false | | | +| `reasoning_effort` | [codersdk.ChatModelReasoningEffortConfig](#codersdkchatmodelreasoningeffortconfig) | false | | | +| `temperature` | number | false | | | +| `top_k` | integer | false | | | +| `top_p` | number | false | | | + +## codersdk.ChatModelCatalogEntry + +```json +{ + "display_name": "string", + "id": "string", + "model": "string", + "provider": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|--------|----------|--------------|-------------| +| `display_name` | string | false | | | +| `id` | string | false | | | +| `model` | string | false | | | +| `provider` | string | false | | | + +## codersdk.ChatModelGoogleProviderOptions + +```json +{ + "cached_content": "string", + "safety_settings": [ + { + "category": "string", + "threshold": "string" + } + ], + "thinking_config": { + "include_thoughts": true, + "thinking_budget": 0, + "thinking_level": "string" + }, + "threshold": "string", + "web_search_enabled": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------|-----------------------------------------------------------------------------------------|----------|--------------|-------------| +| `cached_content` | string | false | | | +| `safety_settings` | array of [codersdk.ChatModelGoogleSafetySetting](#codersdkchatmodelgooglesafetysetting) | false | | | +| `thinking_config` | [codersdk.ChatModelGoogleThinkingConfig](#codersdkchatmodelgooglethinkingconfig) | false | | | +| `threshold` | string | false | | | +| `web_search_enabled` | boolean | false | | | + +## codersdk.ChatModelGoogleSafetySetting + +```json +{ + "category": "string", + "threshold": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------|--------|----------|--------------|-------------| +| `category` | string | false | | | +| `threshold` | string | false | | | + +## codersdk.ChatModelGoogleThinkingConfig + +```json +{ + "include_thoughts": true, + "thinking_budget": 0, + "thinking_level": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|---------|----------|--------------|-------------| +| `include_thoughts` | boolean | false | | | +| `thinking_budget` | integer | false | | | +| `thinking_level` | string | false | | | + +## codersdk.ChatModelOpenAICompatProviderOptions + +```json +{ + "user": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------|--------|----------|--------------|-------------| +| `user` | string | false | | | + +## codersdk.ChatModelOpenAIConfig + +```json +{ + "use_responses_api": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------------|---------|----------|--------------|-------------| +| `use_responses_api` | boolean | false | | | + +## codersdk.ChatModelOpenAIProviderOptions + +```json +{ + "allowed_domains": [ + "string" ], - "unsupported_providers": [ - { - "display_name": "string", - "provider": "string" - } - ] + "include": [ + "string" + ], + "instructions": "string", + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "max_completion_tokens": 0, + "max_tool_calls": 0, + "metadata": { + "property1": null, + "property2": null + }, + "parallel_tool_calls": true, + "prediction": { + "property1": null, + "property2": null + }, + "prompt_cache_key": "string", + "reasoning_summary": "string", + "safety_identifier": "string", + "search_context_size": "string", + "service_tier": "string", + "store": true, + "strict_json_schema": true, + "structured_outputs": true, + "text_verbosity": "string", + "top_log_probs": 0, + "user": "string", + "web_search_enabled": true } ``` ### Properties -| 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. | +| Name | Type | Required | Restrictions | Description | +|-------------------------|-----------------|----------|--------------|-------------| +| `allowed_domains` | array of string | false | | | +| `include` | array of string | false | | | +| `instructions` | string | false | | | +| `log_probs` | boolean | false | | | +| `logit_bias` | object | false | | | +| » `[any property]` | integer | false | | | +| `max_completion_tokens` | integer | false | | | +| `max_tool_calls` | integer | false | | | +| `metadata` | object | false | | | +| » `[any property]` | any | false | | | +| `parallel_tool_calls` | boolean | false | | | +| `prediction` | object | false | | | +| » `[any property]` | any | false | | | +| `prompt_cache_key` | string | false | | | +| `reasoning_summary` | string | false | | | +| `safety_identifier` | string | false | | | +| `search_context_size` | string | false | | | +| `service_tier` | string | false | | | +| `store` | boolean | false | | | +| `strict_json_schema` | boolean | false | | | +| `structured_outputs` | boolean | false | | | +| `text_verbosity` | string | false | | | +| `top_log_probs` | integer | false | | | +| `user` | string | false | | | +| `web_search_enabled` | boolean | false | | | + +## codersdk.ChatModelOpenRouterProvider + +```json +{ + "allow_fallbacks": true, + "data_collection": "string", + "ignore": [ + "string" + ], + "only": [ + "string" + ], + "order": [ + "string" + ], + "quantizations": [ + "string" + ], + "require_parameters": true, + "sort": "string" +} +``` -## codersdk.ChatModelCatalogEntry +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------|-----------------|----------|--------------|-------------| +| `allow_fallbacks` | boolean | false | | | +| `data_collection` | string | false | | | +| `ignore` | array of string | false | | | +| `only` | array of string | false | | | +| `order` | array of string | false | | | +| `quantizations` | array of string | false | | | +| `require_parameters` | boolean | false | | | +| `sort` | string | false | | | + +## codersdk.ChatModelOpenRouterProviderOptions ```json { - "display_name": "string", - "id": "string", - "model": "string", - "provider": "string" + "extra_body": { + "property1": null, + "property2": null + }, + "include_usage": true, + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "parallel_tool_calls": true, + "provider": { + "allow_fallbacks": true, + "data_collection": "string", + "ignore": [ + "string" + ], + "only": [ + "string" + ], + "order": [ + "string" + ], + "quantizations": [ + "string" + ], + "require_parameters": true, + "sort": "string" + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "user": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------|--------|----------|--------------|-------------| -| `display_name` | string | false | | | -| `id` | string | false | | | -| `model` | string | false | | | -| `provider` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------|------------------------------------------------------------------------------|----------|--------------|-------------| +| `extra_body` | object | false | | | +| » `[any property]` | any | false | | | +| `include_usage` | boolean | false | | | +| `log_probs` | boolean | false | | | +| `logit_bias` | object | false | | | +| » `[any property]` | integer | false | | | +| `parallel_tool_calls` | boolean | false | | | +| `provider` | [codersdk.ChatModelOpenRouterProvider](#codersdkchatmodelopenrouterprovider) | false | | | +| `reasoning` | [codersdk.ChatModelReasoningOptions](#codersdkchatmodelreasoningoptions) | false | | | +| `user` | string | false | | | ## codersdk.ChatModelProvider @@ -3475,6 +4341,190 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `provider` | string | false | | | | `unavailable_reason` | [codersdk.ChatModelProviderUnavailableReason](#codersdkchatmodelproviderunavailablereason) | false | | | +## codersdk.ChatModelProviderDescriptor + +```json +{ + "allow_user_api_key": true, + "display_name": "string", + "enabled": true, + "has_api_key": true, + "has_effective_api_key": true, + "has_user_api_key": true, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "type": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------------|---------|----------|--------------|-------------| +| `allow_user_api_key` | boolean | false | | | +| `display_name` | string | false | | | +| `enabled` | boolean | false | | | +| `has_api_key` | boolean | false | | | +| `has_effective_api_key` | boolean | false | | | +| `has_user_api_key` | boolean | false | | | +| `icon` | string | false | | | +| `id` | string | false | | | +| `type` | string | false | | | + +## codersdk.ChatModelProviderOptions + +```json +{ + "anthropic": { + "allowed_domains": [ + "string" + ], + "blocked_domains": [ + "string" + ], + "context_1m_enabled": true, + "disable_parallel_tool_use": true, + "send_reasoning": true, + "thinking": { + "budget_tokens": 0 + }, + "thinking_display": "string", + "web_search_enabled": true + }, + "google": { + "cached_content": "string", + "safety_settings": [ + { + "category": "string", + "threshold": "string" + } + ], + "thinking_config": { + "include_thoughts": true, + "thinking_budget": 0, + "thinking_level": "string" + }, + "threshold": "string", + "web_search_enabled": true + }, + "openai": { + "allowed_domains": [ + "string" + ], + "include": [ + "string" + ], + "instructions": "string", + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "max_completion_tokens": 0, + "max_tool_calls": 0, + "metadata": { + "property1": null, + "property2": null + }, + "parallel_tool_calls": true, + "prediction": { + "property1": null, + "property2": null + }, + "prompt_cache_key": "string", + "reasoning_summary": "string", + "safety_identifier": "string", + "search_context_size": "string", + "service_tier": "string", + "store": true, + "strict_json_schema": true, + "structured_outputs": true, + "text_verbosity": "string", + "top_log_probs": 0, + "user": "string", + "web_search_enabled": true + }, + "openaicompat": { + "user": "string" + }, + "openrouter": { + "extra_body": { + "property1": null, + "property2": null + }, + "include_usage": true, + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "parallel_tool_calls": true, + "provider": { + "allow_fallbacks": true, + "data_collection": "string", + "ignore": [ + "string" + ], + "only": [ + "string" + ], + "order": [ + "string" + ], + "quantizations": [ + "string" + ], + "require_parameters": true, + "sort": "string" + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "user": "string" + }, + "vercel": { + "extra_body": { + "property1": null, + "property2": null + }, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "logprobs": true, + "parallel_tool_calls": true, + "providerOptions": { + "models": [ + "string" + ], + "order": [ + "string" + ] + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "top_logprobs": 0, + "user": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|------------------------------------------------------------------------------------------------|----------|--------------|-------------| +| `anthropic` | [codersdk.ChatModelAnthropicProviderOptions](#codersdkchatmodelanthropicprovideroptions) | false | | | +| `google` | [codersdk.ChatModelGoogleProviderOptions](#codersdkchatmodelgoogleprovideroptions) | false | | | +| `openai` | [codersdk.ChatModelOpenAIProviderOptions](#codersdkchatmodelopenaiprovideroptions) | false | | | +| `openaicompat` | [codersdk.ChatModelOpenAICompatProviderOptions](#codersdkchatmodelopenaicompatprovideroptions) | false | | | +| `openrouter` | [codersdk.ChatModelOpenRouterProviderOptions](#codersdkchatmodelopenrouterprovideroptions) | false | | | +| `vercel` | [codersdk.ChatModelVercelProviderOptions](#codersdkchatmodelvercelprovideroptions) | false | | | + ## codersdk.ChatModelProviderUnavailableReason ```json @@ -3489,6 +4539,107 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in |------------------------------------------------------------| | `fetch_failed`, `missing_api_key`, `user_api_key_required` | +## codersdk.ChatModelReasoningEffortConfig + +```json +{ + "default": "string", + "max": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|--------|----------|--------------|-------------| +| `default` | string | false | | | +| `max` | string | false | | | + +## codersdk.ChatModelReasoningOptions + +```json +{ + "enabled": true, + "exclude": true, + "max_tokens": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|---------|----------|--------------|-------------| +| `enabled` | boolean | false | | | +| `exclude` | boolean | false | | | +| `max_tokens` | integer | false | | | + +## codersdk.ChatModelVercelGatewayProviderOptions + +```json +{ + "models": [ + "string" + ], + "order": [ + "string" + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------|-----------------|----------|--------------|-------------| +| `models` | array of string | false | | | +| `order` | array of string | false | | | + +## codersdk.ChatModelVercelProviderOptions + +```json +{ + "extra_body": { + "property1": null, + "property2": null + }, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "logprobs": true, + "parallel_tool_calls": true, + "providerOptions": { + "models": [ + "string" + ], + "order": [ + "string" + ] + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "top_logprobs": 0, + "user": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------------|--------------------------------------------------------------------------------------------------|----------|--------------|-------------| +| `extra_body` | object | false | | | +| » `[any property]` | any | false | | | +| `logit_bias` | object | false | | | +| » `[any property]` | integer | false | | | +| `logprobs` | boolean | false | | | +| `parallel_tool_calls` | boolean | false | | | +| `providerOptions` | [codersdk.ChatModelVercelGatewayProviderOptions](#codersdkchatmodelvercelgatewayprovideroptions) | false | | | +| `reasoning` | [codersdk.ChatModelReasoningOptions](#codersdkchatmodelreasoningoptions) | false | | | +| `top_logprobs` | integer | false | | | +| `user` | string | false | | | + ## codersdk.ChatPlanMode ```json @@ -4930,6 +6081,186 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `queued_message` | [codersdk.ChatQueuedMessage](#codersdkchatqueuedmessage) | false | | | | `warnings` | array of string | false | | | +## codersdk.CreateChatModelRequest + +```json +{ + "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819", + "compression_threshold": 0, + "context_limit": 0, + "display_name": "string", + "enabled": true, + "is_default": true, + "model": "string", + "model_config": { + "frequency_penalty": 0, + "max_output_tokens": 0, + "openai_config": { + "use_responses_api": true + }, + "presence_penalty": 0, + "provider_options": { + "anthropic": { + "allowed_domains": [ + "string" + ], + "blocked_domains": [ + "string" + ], + "context_1m_enabled": true, + "disable_parallel_tool_use": true, + "send_reasoning": true, + "thinking": { + "budget_tokens": 0 + }, + "thinking_display": "string", + "web_search_enabled": true + }, + "google": { + "cached_content": "string", + "safety_settings": [ + { + "category": "string", + "threshold": "string" + } + ], + "thinking_config": { + "include_thoughts": true, + "thinking_budget": 0, + "thinking_level": "string" + }, + "threshold": "string", + "web_search_enabled": true + }, + "openai": { + "allowed_domains": [ + "string" + ], + "include": [ + "string" + ], + "instructions": "string", + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "max_completion_tokens": 0, + "max_tool_calls": 0, + "metadata": { + "property1": null, + "property2": null + }, + "parallel_tool_calls": true, + "prediction": { + "property1": null, + "property2": null + }, + "prompt_cache_key": "string", + "reasoning_summary": "string", + "safety_identifier": "string", + "search_context_size": "string", + "service_tier": "string", + "store": true, + "strict_json_schema": true, + "structured_outputs": true, + "text_verbosity": "string", + "top_log_probs": 0, + "user": "string", + "web_search_enabled": true + }, + "openaicompat": { + "user": "string" + }, + "openrouter": { + "extra_body": { + "property1": null, + "property2": null + }, + "include_usage": true, + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "parallel_tool_calls": true, + "provider": { + "allow_fallbacks": true, + "data_collection": "string", + "ignore": [ + "string" + ], + "only": [ + "string" + ], + "order": [ + "string" + ], + "quantizations": [ + "string" + ], + "require_parameters": true, + "sort": "string" + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "user": "string" + }, + "vercel": { + "extra_body": { + "property1": null, + "property2": null + }, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "logprobs": true, + "parallel_tool_calls": true, + "providerOptions": { + "models": [ + "string" + ], + "order": [ + "string" + ] + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "top_logprobs": 0, + "user": "string" + } + }, + "reasoning_effort": { + "default": "string", + "max": "string" + }, + "temperature": 0, + "top_k": 0, + "top_p": 0 + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------------|--------------------------------------------------------------|----------|--------------|-------------| +| `ai_provider_id` | string | false | | | +| `compression_threshold` | integer | false | | | +| `context_limit` | integer | false | | | +| `display_name` | string | false | | | +| `enabled` | boolean | false | | | +| `is_default` | boolean | false | | | +| `model` | string | false | | | +| `model_config` | [codersdk.ChatModelCallConfig](#codersdkchatmodelcallconfig) | false | | | + ## codersdk.CreateChatRequest ```json @@ -10051,6 +11382,204 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `name` | string | false | | | | `updated_at` | string | true | | | +## codersdk.OrganizationChatModelsResponse + +```json +{ + "models": [ + { + "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819", + "compression_threshold": 0, + "context_limit": 0, + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "enabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_default": true, + "model": "string", + "model_config": { + "frequency_penalty": 0, + "max_output_tokens": 0, + "openai_config": { + "use_responses_api": true + }, + "presence_penalty": 0, + "provider_options": { + "anthropic": { + "allowed_domains": [ + "string" + ], + "blocked_domains": [ + "string" + ], + "context_1m_enabled": true, + "disable_parallel_tool_use": true, + "send_reasoning": true, + "thinking": { + "budget_tokens": 0 + }, + "thinking_display": "string", + "web_search_enabled": true + }, + "google": { + "cached_content": "string", + "safety_settings": [ + { + "category": "string", + "threshold": "string" + } + ], + "thinking_config": { + "include_thoughts": true, + "thinking_budget": 0, + "thinking_level": "string" + }, + "threshold": "string", + "web_search_enabled": true + }, + "openai": { + "allowed_domains": [ + "string" + ], + "include": [ + "string" + ], + "instructions": "string", + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "max_completion_tokens": 0, + "max_tool_calls": 0, + "metadata": { + "property1": null, + "property2": null + }, + "parallel_tool_calls": true, + "prediction": { + "property1": null, + "property2": null + }, + "prompt_cache_key": "string", + "reasoning_summary": "string", + "safety_identifier": "string", + "search_context_size": "string", + "service_tier": "string", + "store": true, + "strict_json_schema": true, + "structured_outputs": true, + "text_verbosity": "string", + "top_log_probs": 0, + "user": "string", + "web_search_enabled": true + }, + "openaicompat": { + "user": "string" + }, + "openrouter": { + "extra_body": { + "property1": null, + "property2": null + }, + "include_usage": true, + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "parallel_tool_calls": true, + "provider": { + "allow_fallbacks": true, + "data_collection": "string", + "ignore": [ + "string" + ], + "only": [ + "string" + ], + "order": [ + "string" + ], + "quantizations": [ + "string" + ], + "require_parameters": true, + "sort": "string" + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "user": "string" + }, + "vercel": { + "extra_body": { + "property1": null, + "property2": null + }, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "logprobs": true, + "parallel_tool_calls": true, + "providerOptions": { + "models": [ + "string" + ], + "order": [ + "string" + ] + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "top_logprobs": 0, + "user": "string" + } + }, + "reasoning_effort": { + "default": "string", + "max": "string" + }, + "temperature": 0, + "top_k": 0, + "top_p": 0 + }, + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "reasoning_efforts": [ + "string" + ], + "updated_at": "2019-08-24T14:15:22Z" + } + ], + "providers": [ + { + "allow_user_api_key": true, + "display_name": "string", + "enabled": true, + "has_api_key": true, + "has_effective_api_key": true, + "has_user_api_key": true, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "type": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------|---------------------------------------------------------------------------------------|----------|--------------|-------------| +| `models` | array of [codersdk.ChatModel](#codersdkchatmodel) | false | | | +| `providers` | array of [codersdk.ChatModelProviderDescriptor](#codersdkchatmodelproviderdescriptor) | false | | | + ## codersdk.OrganizationGroupAISpend ```json @@ -13670,6 +15199,210 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `user_roles` | object | false | | | | » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | | +## codersdk.UpdateChatModelACLRequest + +```json +{ + "group_roles": { + "property1": "read", + "property2": "read" + }, + "user_roles": { + "property1": "read", + "property2": "read" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|----------------------------------------|----------|--------------|-------------| +| `group_roles` | object | false | | | +| » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | | +| `user_roles` | object | false | | | +| » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | | + +## codersdk.UpdateChatModelRequest + +```json +{ + "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819", + "compression_threshold": 0, + "context_limit": 0, + "display_name": "string", + "enabled": true, + "is_default": true, + "model": "string", + "model_config": { + "frequency_penalty": 0, + "max_output_tokens": 0, + "openai_config": { + "use_responses_api": true + }, + "presence_penalty": 0, + "provider_options": { + "anthropic": { + "allowed_domains": [ + "string" + ], + "blocked_domains": [ + "string" + ], + "context_1m_enabled": true, + "disable_parallel_tool_use": true, + "send_reasoning": true, + "thinking": { + "budget_tokens": 0 + }, + "thinking_display": "string", + "web_search_enabled": true + }, + "google": { + "cached_content": "string", + "safety_settings": [ + { + "category": "string", + "threshold": "string" + } + ], + "thinking_config": { + "include_thoughts": true, + "thinking_budget": 0, + "thinking_level": "string" + }, + "threshold": "string", + "web_search_enabled": true + }, + "openai": { + "allowed_domains": [ + "string" + ], + "include": [ + "string" + ], + "instructions": "string", + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "max_completion_tokens": 0, + "max_tool_calls": 0, + "metadata": { + "property1": null, + "property2": null + }, + "parallel_tool_calls": true, + "prediction": { + "property1": null, + "property2": null + }, + "prompt_cache_key": "string", + "reasoning_summary": "string", + "safety_identifier": "string", + "search_context_size": "string", + "service_tier": "string", + "store": true, + "strict_json_schema": true, + "structured_outputs": true, + "text_verbosity": "string", + "top_log_probs": 0, + "user": "string", + "web_search_enabled": true + }, + "openaicompat": { + "user": "string" + }, + "openrouter": { + "extra_body": { + "property1": null, + "property2": null + }, + "include_usage": true, + "log_probs": true, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "parallel_tool_calls": true, + "provider": { + "allow_fallbacks": true, + "data_collection": "string", + "ignore": [ + "string" + ], + "only": [ + "string" + ], + "order": [ + "string" + ], + "quantizations": [ + "string" + ], + "require_parameters": true, + "sort": "string" + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "user": "string" + }, + "vercel": { + "extra_body": { + "property1": null, + "property2": null + }, + "logit_bias": { + "property1": 0, + "property2": 0 + }, + "logprobs": true, + "parallel_tool_calls": true, + "providerOptions": { + "models": [ + "string" + ], + "order": [ + "string" + ] + }, + "reasoning": { + "enabled": true, + "exclude": true, + "max_tokens": 0 + }, + "top_logprobs": 0, + "user": "string" + } + }, + "reasoning_effort": { + "default": "string", + "max": "string" + }, + "temperature": 0, + "top_k": 0, + "top_p": 0 + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------------|--------------------------------------------------------------|----------|--------------|-------------| +| `ai_provider_id` | string | false | | | +| `compression_threshold` | integer | false | | | +| `context_limit` | integer | false | | | +| `display_name` | string | false | | | +| `enabled` | boolean | false | | | +| `is_default` | boolean | false | | | +| `model` | string | false | | | +| `model_config` | [codersdk.ChatModelCallConfig](#codersdkchatmodelcallconfig) | false | | | + ## codersdk.UpdateChatRequest ```json diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 4303eaba86036..3d881231addf3 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -35,6 +35,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{ "AuditableGroupAIBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "AuditableUserAIBudgetOverride": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted. + "ChatModelConfig": {codersdk.AuditActionWrite}, "MCPServerConfig": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, @@ -502,6 +503,26 @@ var auditableResourcesTypes = map[any]map[string]Action{ "requires_action_deadline_at": ActionIgnore, // Internal pending-action deadline. "compaction_requested_at": ActionIgnore, // Internal one-shot manual compaction signal. }, + &database.ChatModelConfig{}: { + "id": ActionIgnore, // Conveyed by resource_id. + "model": ActionTrack, + "display_name": ActionTrack, + "created_by": ActionTrack, + "updated_by": ActionTrack, + "enabled": ActionTrack, + "is_default": ActionTrack, + "deleted": ActionTrack, + "deleted_at": ActionIgnore, + "created_at": ActionIgnore, + "updated_at": ActionIgnore, + "context_limit": ActionTrack, + "compression_threshold": ActionTrack, + "options": ActionTrack, + "ai_provider_id": ActionTrack, + "organization_id": ActionIgnore, + "group_acl": ActionTrack, + "user_acl": ActionTrack, + }, &database.MCPServerConfig{}: { "id": ActionIgnore, // Conveyed by resource_id, not useful in a diff. "display_name": ActionTrack, diff --git a/enterprise/coderd/exp_chats_test.go b/enterprise/coderd/exp_chats_test.go index e5ea072f272ee..e2633eec887a4 100644 --- a/enterprise/coderd/exp_chats_test.go +++ b/enterprise/coderd/exp_chats_test.go @@ -14,7 +14,6 @@ import ( "github.com/coder/coder/v2/coderd/aibridgedtest" "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/util/ptr" @@ -55,7 +54,9 @@ func createOpenAIChatModelForTest( ) codersdk.ChatModel { t.Helper() provider := createOpenAIProviderForTest(ctx, t, client, apiKey, baseURL) - model, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + defaultOrg, err := client.Client.OrganizationByName(ctx, codersdk.DefaultOrganization) + require.NoError(t, err) + model, err := client.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "gpt-4", DisplayName: "GPT-4", @@ -945,6 +946,8 @@ func TestChatModelDefault(t *testing.T) { expClient := codersdk.NewExperimentalClient(client) provider := createOpenAIProviderForTest(ctx, t, expClient, "test", "https://example.com") + defaultOrg, err := expClient.Client.OrganizationByName(ctx, codersdk.DefaultOrganization) + require.NoError(t, err) contextLimit := int64(1000) compressionThreshold := int32(70) @@ -953,6 +956,7 @@ func TestChatModelDefault(t *testing.T) { firstModel, err := expClient.CreateChatModel( ctx, + defaultOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "gpt-5-a", @@ -967,6 +971,7 @@ func TestChatModelDefault(t *testing.T) { secondModel, err := expClient.CreateChatModel( ctx, + defaultOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "gpt-5-b", @@ -979,15 +984,16 @@ func TestChatModelDefault(t *testing.T) { require.NoError(t, err) require.True(t, secondModel.IsDefault) - models, err := expClient.ChatModels(ctx) + models, err := expClient.ChatModels(ctx, defaultOrg.ID) require.NoError(t, err) - firstStored := findChatModelByID(t, models, firstModel.ID) - secondStored := findChatModelByID(t, models, secondModel.ID) + firstStored := findChatModelByID(t, models.Models, firstModel.ID) + secondStored := findChatModelByID(t, models.Models, secondModel.ID) require.False(t, firstStored.IsDefault) require.True(t, secondStored.IsDefault) updatedFirst, err := expClient.UpdateChatModel( ctx, + defaultOrg.ID, firstModel.ID, codersdk.UpdateChatModelRequest{ IsDefault: &trueValue, @@ -996,15 +1002,16 @@ func TestChatModelDefault(t *testing.T) { require.NoError(t, err) require.True(t, updatedFirst.IsDefault) - models, err = expClient.ChatModels(ctx) + models, err = expClient.ChatModels(ctx, defaultOrg.ID) require.NoError(t, err) - firstStored = findChatModelByID(t, models, firstModel.ID) - secondStored = findChatModelByID(t, models, secondModel.ID) + firstStored = findChatModelByID(t, models.Models, firstModel.ID) + secondStored = findChatModelByID(t, models.Models, secondModel.ID) require.True(t, firstStored.IsDefault) require.False(t, secondStored.IsDefault) updatedFirst, err = expClient.UpdateChatModel( ctx, + defaultOrg.ID, firstModel.ID, codersdk.UpdateChatModelRequest{ IsDefault: &falseValue, @@ -1013,10 +1020,10 @@ func TestChatModelDefault(t *testing.T) { require.NoError(t, err) require.False(t, updatedFirst.IsDefault) - models, err = expClient.ChatModels(ctx) + models, err = expClient.ChatModels(ctx, defaultOrg.ID) require.NoError(t, err) - firstStored = findChatModelByID(t, models, firstModel.ID) - secondStored = findChatModelByID(t, models, secondModel.ID) + firstStored = findChatModelByID(t, models.Models, firstModel.ID) + secondStored = findChatModelByID(t, models.Models, secondModel.ID) require.False(t, firstStored.IsDefault) require.True(t, secondStored.IsDefault) } @@ -1080,7 +1087,7 @@ func TestCreateChatUsesOrganizationLocalModel(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + client, firstUser := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ DeploymentValues: func() *codersdk.DeploymentValues { v := coderdtest.DeploymentValues(t) @@ -1096,36 +1103,31 @@ func TestCreateChatUsesOrganizationLocalModel(t *testing.T) { expClient := codersdk.NewExperimentalClient(client) provider := createOpenAIProviderForTest(ctx, t, expClient, "test-key", "https://example.com") - defaultModel, err := expClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + + // Create a second (non-default) org via the API. + secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) + + // Strict organization scoping requires the chat and its default model in the same organization. + localModel, err := expClient.CreateChatModel(ctx, secondOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, - Model: "gpt-4o-mini", - DisplayName: "Default Organization Model", + Model: "gpt-4o-mini-local", + DisplayName: "Second Organization Model", IsDefault: ptr.Ref(true), ContextLimit: ptr.Ref(int64(1000)), CompressionThreshold: ptr.Ref(int32(70)), }) require.NoError(t, err) - secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) - localModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - Model: "gpt-4o-mini-local", - DisplayName: "Second Organization Model", - AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, - OrganizationID: secondOrg.ID, - IsDefault: true, - }) - require.NotEqual(t, defaultModel.ID, localModel.ID) - + // Create a member with agents-access in both orgs. memberClientRaw, member := coderdtest.CreateAnotherUser( t, client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), rbac.ScopedRoleAgentsAccess(secondOrg.ID), ) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - + // Create a chat in the non-default org. chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: secondOrg.ID, - ModelConfigID: &localModel.ID, Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, @@ -1137,6 +1139,79 @@ func TestCreateChatUsesOrganizationLocalModel(t *testing.T) { require.Equal(t, secondOrg.ID, chat.OrganizationID) require.Equal(t, member.ID, chat.OwnerID) require.Equal(t, localModel.ID, chat.LastModelConfigID) + + // Verify the chat is visible when listing. + chats, err := memberClient.ListChats(ctx, nil) + require.NoError(t, err) + var found bool + for _, c := range chats { + if c.ID == chat.ID { + found = true + require.Equal(t, secondOrg.ID, c.OrganizationID) + break + } + } + require.True(t, found, "chat should be visible in list") +} + +// TestCreateChatCrossOrgModelConfigRejected proves an explicit +// model_config_id naming a config in a DIFFERENT org than the chat is +// rejected as unavailable: post-cutover validation is org-aware, so a +// config outside the chat's org is "not found or disabled" to the creator. +func TestCreateChatCrossOrgModelConfigRejected(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + client, firstUser := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + expClient := codersdk.NewExperimentalClient(client) + + provider := createOpenAIProviderForTest(ctx, t, expClient, "test-key", "https://example.com") + + // The config lives in the default org. + defaultConfig, err := expClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ + AIProviderID: &provider.ID, + Model: "gpt-4o-mini", + DisplayName: "Default Org Model", + IsDefault: ptr.Ref(true), + ContextLimit: ptr.Ref(int64(1000)), + CompressionThreshold: ptr.Ref(int32(70)), + }) + require.NoError(t, err) + + secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) + + // A member with agents-access in both orgs still cannot bind a chat in + // the second org to the default org's config. + memberClientRaw, _ := coderdtest.CreateAnotherUser( + t, client, firstUser.OrganizationID, + rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), + rbac.ScopedRoleAgentsAccess(secondOrg.ID), + ) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + _, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: secondOrg.ID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }}, + ModelConfigID: ptr.Ref(defaultConfig.ID), + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message) } func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) { @@ -1144,7 +1219,7 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + client, firstUser := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ DeploymentValues: func() *codersdk.DeploymentValues { v := coderdtest.DeploymentValues(t) @@ -1160,7 +1235,13 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) { expClient := codersdk.NewExperimentalClient(client) provider := createOpenAIProviderForTest(ctx, t, expClient, "test-key", "https://example.com") - _, err := expClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + + // Create a second (non-default) org. + secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) + + // Strict org scoping: the chats below live in the second org, so + // their default model config must too. + _, err := expClient.CreateChatModel(ctx, secondOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: "gpt-4o-mini", DisplayName: "Test Model", @@ -1170,16 +1251,6 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) { }) require.NoError(t, err) - // Create a second (non-default) org. - secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) - localModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - Model: "gpt-4o-mini-local", - DisplayName: "Second Organization Model", - AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, - OrganizationID: secondOrg.ID, - IsDefault: true, - }) - // Create a member with agents-access in both orgs. memberClientRaw, _ := coderdtest.CreateAnotherUser( t, client, firstUser.OrganizationID, @@ -1190,7 +1261,6 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) { // Member creates a chat in the second org. memberChat, err := memberExp.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: secondOrg.ID, - ModelConfigID: &localModel.ID, Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, @@ -1211,7 +1281,6 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) { // Admin creates a chat in the second org. adminChat, err := adminExp.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: secondOrg.ID, - ModelConfigID: &localModel.ID, Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, diff --git a/scaletest/chat/client.go b/scaletest/chat/client.go index 4649f16b6e675..bb75e93af35e1 100644 --- a/scaletest/chat/client.go +++ b/scaletest/chat/client.go @@ -22,8 +22,8 @@ type chatClient interface { var _ chatClient = (*codersdk.ExperimentalClient)(nil) type chatModelClient interface { - ChatModels(ctx context.Context) ([]codersdk.ChatModel, error) - CreateChatModel(ctx context.Context, req codersdk.CreateChatModelRequest) (codersdk.ChatModel, error) + ChatModels(ctx context.Context, organizationID uuid.UUID) (codersdk.OrganizationChatModelsResponse, error) + CreateChatModel(ctx context.Context, organizationID uuid.UUID, req codersdk.CreateChatModelRequest) (codersdk.ChatModel, error) } var _ chatModelClient = (*codersdk.ExperimentalClient)(nil) diff --git a/scaletest/chat/provider.go b/scaletest/chat/provider.go index c0faf98a9f7c3..929cecd34c145 100644 --- a/scaletest/chat/provider.go +++ b/scaletest/chat/provider.go @@ -38,17 +38,18 @@ const ( scaletestAIProviderActionReused scaletestAIProviderAction = "reused" ) -// EnsureScaletestChatModel bootstraps the shared AI provider and ChatModel -// used by chat scaletests. When the provider was created or updated, it waits -// for propagationWait so every coderd replica's cached provider expires. -func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logger slog.Logger, llmMockURL string, propagationWait time.Duration) (uuid.UUID, error) { +// EnsureScaletestChatModel bootstraps the shared AI provider used by chat +// scaletests. It returns a function that creates or reuses the ChatModel for +// each organization. When the provider changes, it waits for cached provider +// configurations to expire before chats start. +func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logger slog.Logger, llmMockURL string, propagationWait time.Duration) (func(organizationID uuid.UUID) (uuid.UUID, error), error) { expClient := codersdk.NewExperimentalClient(client) logger.Info(ctx, "bootstrapping mock LLM provider", slog.F("llm_mock_url", llmMockURL)) provider, providerAction, err := ensureScaletestAIProvider(ctx, expClient, llmMockURL) if err != nil { - return uuid.Nil, err + return nil, err } switch providerAction { @@ -71,11 +72,6 @@ func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logg ) } - modelID, err := ensureScaletestChatModelRecord(ctx, expClient, logger, provider) - if err != nil { - return uuid.Nil, err - } - if providerAction != scaletestAIProviderActionReused && propagationWait > 0 { logger.Info(ctx, "waiting for mock LLM provider propagation", slog.F("provider_name", provider.Name), @@ -83,38 +79,40 @@ func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logg ) select { case <-ctx.Done(): - return uuid.Nil, ctx.Err() + return nil, ctx.Err() case <-time.After(propagationWait): } } - return modelID, nil + return func(organizationID uuid.UUID) (uuid.UUID, error) { + return ensureScaletestChatModelRecord(ctx, expClient, logger, provider, organizationID) + }, nil } -func ensureScaletestChatModelRecord(ctx context.Context, client chatModelClient, logger slog.Logger, provider codersdk.AIProvider) (uuid.UUID, error) { - models, err := client.ChatModels(ctx) +func ensureScaletestChatModelRecord(ctx context.Context, client chatModelClient, logger slog.Logger, provider codersdk.AIProvider, organizationID uuid.UUID) (uuid.UUID, error) { + resp, err := client.ChatModels(ctx, organizationID) if err != nil { return uuid.Nil, xerrors.Errorf("list chat models: %w", err) } - for i := range models { - matchesProvider := models[i].AIProviderID == provider.ID - matchesModel := models[i].Model == scaletestModelName + for i := range resp.Models { + matchesProvider := resp.Models[i].AIProviderID == provider.ID + matchesModel := resp.Models[i].Model == scaletestModelName if !matchesProvider || !matchesModel { continue } - if !models[i].Enabled { - return uuid.Nil, xerrors.Errorf("existing scaletest ChatModel %s is disabled; re-enable or delete it before running scaletests", models[i].ID) + if !resp.Models[i].Enabled { + return uuid.Nil, xerrors.Errorf("existing scaletest ChatModel %s is disabled; re-enable or delete it before running scaletests", resp.Models[i].ID) } - modelID := models[i].ID - logger.Info(ctx, "reusing scaletest ChatModel", slog.F("model_id", modelID)) + modelID := resp.Models[i].ID + logger.Info(ctx, "reusing scaletest ChatModel", slog.F("model_id", modelID), slog.F("organization_id", organizationID)) return modelID, nil } enabled := true isDefault := false contextLimit := scaletestModelContextLimit - created, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + created, err := client.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, Model: scaletestModelName, DisplayName: scaletestModelDisplayName, @@ -125,7 +123,7 @@ func ensureScaletestChatModelRecord(ctx context.Context, client chatModelClient, if err != nil { return uuid.Nil, xerrors.Errorf("create scaletest ChatModel: %w", err) } - logger.Info(ctx, "created scaletest ChatModel", slog.F("model_id", created.ID)) + logger.Info(ctx, "created scaletest ChatModel", slog.F("model_id", created.ID), slog.F("organization_id", organizationID)) return created.ID, nil } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6e797f677c407..0b426ba5a84cb 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2830,11 +2830,11 @@ export interface ChatMessagesResponse { // From codersdk/chats.go /** - * ChatModel is an admin-managed model record for an organization. - * It is not a discovery catalog entry. See ChatModelCatalogEntry. + * ChatModel is an org-scoped model configuration. */ export interface ChatModel { readonly id: string; + readonly organization_id: string; readonly ai_provider_id: string; readonly model: string; readonly display_name: string; @@ -2852,6 +2852,16 @@ export interface ChatModel { readonly updated_at: string; } +// From codersdk/chats.go +/** + * ChatModelACL is the access control list for an organization-scoped chat + * model. Each principal is mapped to its effective model role. + */ +export interface ChatModelACL { + readonly user_roles: Record; + readonly group_roles: Record; +} + // From codersdk/chats.go /** * ChatModelAnthropicProviderOptions configures Anthropic provider behavior. @@ -2877,10 +2887,14 @@ export interface ChatModelAnthropicThinkingOptions { // From codersdk/chats.go /** - * ChatModelAvailabilityResponse groups the discovery catalog by provider and - * reports provider availability. + * ChatModelAvailabilityResponse is the catalog returned from chat model discovery. */ export interface ChatModelAvailabilityResponse { + /** + * Models contains the effective runtime model configs for the requested + * organization. Each config belongs to that organization. + */ + readonly models?: readonly ChatModel[]; readonly providers: readonly ChatModelProvider[]; /** * UnsupportedProviders lists configured providers the Agents harness @@ -2907,9 +2921,9 @@ export interface ChatModelCallConfig { // From codersdk/chats.go /** - * ChatModelCatalogEntry is a discovery catalog entry for end users. - * Its ID is a synthetic provider:model value, not the UUID in ChatModel.ID. - * It is not an admin-managed record. See ChatModel. + * ChatModelCatalogEntry is the runtime catalog view of a model. Its ID is the + * synthetic catalog identity `provider:model` built by canonicalModelID, not + * the chat_model_configs row UUID that ChatModel.ID carries. */ export interface ChatModelCatalogEntry { readonly id: string; @@ -3065,6 +3079,26 @@ export interface ChatModelProvider { readonly models: readonly ChatModelCatalogEntry[]; } +// From codersdk/chats.go +/** + * ChatModelProviderDescriptor is the redacted view of an AI provider carried + * on the org model collection response. It carries only the capability + * metadata the Models UI needs; key material, base URLs, and headers are + * never exposed. The fields mirror what /api/experimental/chats/models + * already discloses to any authenticated caller. + */ +export interface ChatModelProviderDescriptor { + readonly id: string; + readonly type: string; + readonly display_name: string; + readonly icon: string; + readonly enabled: boolean; + readonly has_api_key: boolean; + readonly has_user_api_key: boolean; + readonly has_effective_api_key: boolean; + readonly allow_user_api_key: boolean; +} + // From codersdk/chats.go /** * ChatModelProviderOptions contains typed provider-specific options. @@ -7019,6 +7053,17 @@ export interface Organization extends MinimalOrganization { readonly default_org_member_roles: readonly string[]; } +// From codersdk/chats.go +/** + * OrganizationChatModelsResponse is the org chat model config collection: + * the caller-readable configs plus the redacted provider descriptors the + * authoring page needs. + */ +export interface OrganizationChatModelsResponse { + readonly models: readonly ChatModel[]; + readonly providers: readonly ChatModelProviderDescriptor[]; +} + // From codersdk/aibridge.go /** * OrganizationGroupAISpend is the current AI spend snapshot for a group @@ -9780,6 +9825,17 @@ export interface UpdateChatDebugRetentionDaysRequest { readonly debug_retention_days: number; } +// From codersdk/chats.go +/** + * UpdateChatModelACLRequest is a sparse update of a chat model ACL. Only the + * listed principals change. ChatRoleDeleted removes an entry, while an omitted + * map or principal is unchanged. + */ +export interface UpdateChatModelACLRequest { + readonly user_roles?: Record; + readonly group_roles?: Record; +} + // From codersdk/chats.go /** * UpdateChatModelOverrideRequest is the request body for updating the chat diff --git a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts index 3c1e1111862ff..37853db3a70bd 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts +++ b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts @@ -27,6 +27,7 @@ const MockAnthropicProviderConfig: ChatProviderConfig = { }; export const mockGPT5: ChatModel = { + organization_id: "00000000-0000-0000-0000-000000000000", id: "model-gpt5", ai_provider_id: "prov-openai", model: "gpt-5", diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 53baea63d6dcc..f1bf39bba26bc 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -61,6 +61,7 @@ const defaultModelID = "model-config-1"; const defaultModels: TypesGen.ChatModel[] = [ { + organization_id: "00000000-0000-0000-0000-000000000000", id: defaultModelID, ai_provider_id: "provider-openai", model: "gpt-4o", @@ -366,6 +367,7 @@ const meta: Meta = { }); spyOn(API.experimental, "getChatModels").mockResolvedValue([ { + organization_id: "00000000-0000-0000-0000-000000000000", id: defaultModelID, ai_provider_id: "provider-openai", model: "gpt-4o", diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx index af2787cde3e6e..8dfa87d3fa519 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx @@ -51,6 +51,7 @@ const defaultModelOptions: ModelSelectorOption[] = [ const defaultModels: TypesGen.ChatModel[] = [ { + organization_id: "00000000-0000-0000-0000-000000000000", id: "config-openai-gpt-4o", ai_provider_id: "prov-1", model: "gpt-4o", diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx index 31c741135b1a4..3562fe58702a2 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx @@ -582,6 +582,7 @@ describe("ChatsSidebar model display names", () => { ]; const models: TypesGen.ChatModel[] = [ { + organization_id: "00000000-0000-0000-0000-000000000000", id: "config-fast", ai_provider_id: "prov-openai", model: "gpt-4o", @@ -594,6 +595,7 @@ describe("ChatsSidebar model display names", () => { updated_at: oneWeekAgo, }, { + organization_id: "00000000-0000-0000-0000-000000000000", id: "config-quality", ai_provider_id: "prov-openai", model: "gpt-4o", diff --git a/site/src/testHelpers/chatModels.ts b/site/src/testHelpers/chatModels.ts index 2aea7ea5c2109..7efde31ca8ac6 100644 --- a/site/src/testHelpers/chatModels.ts +++ b/site/src/testHelpers/chatModels.ts @@ -7,6 +7,7 @@ import type { import { MOCK_TIMESTAMP } from "./chatEntities"; export const MockChatModel: ChatModel = { + organization_id: "00000000-0000-0000-0000-000000000000", id: "model-1", ai_provider_id: "provider-1", model: "gpt-5", From 1ac476e73a3dd490c0206f348a54b29bf83c2a5e Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 21 Aug 2026 19:53:59 +0000 Subject: [PATCH 2/5] test(coderd/x/chatd): use enabled model lookups --- coderd/x/chatd/advisor_internal_test.go | 4 ++-- coderd/x/chatd/modelcall_internal_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/x/chatd/advisor_internal_test.go b/coderd/x/chatd/advisor_internal_test.go index a8b7702765f27..92050bf45a8f0 100644 --- a/coderd/x/chatd/advisor_internal_test.go +++ b/coderd/x/chatd/advisor_internal_test.go @@ -122,7 +122,7 @@ func advisorChatModelFixture(t *testing.T, options json.RawMessage) (database.Ch configID := uuid.New() providerID := uuid.New() store := &advisorOverrideStubStore{ - getChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) { + getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) { return database.ChatModelConfig{ ID: configID, Model: "gpt-5.2", @@ -630,7 +630,7 @@ func TestNewAdvisorRuntime(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) store := &advisorOverrideStubStore{ - getChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) { + getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) { return database.ChatModelConfig{}, xerrors.New("lookup failed") }, } diff --git a/coderd/x/chatd/modelcall_internal_test.go b/coderd/x/chatd/modelcall_internal_test.go index 7da6ae71560c1..42ce58b2917ce 100644 --- a/coderd/x/chatd/modelcall_internal_test.go +++ b/coderd/x/chatd/modelcall_internal_test.go @@ -75,7 +75,7 @@ func TestResolveModelCallDerivesProviderOptions(t *testing.T) { config.Options = modelCallSentinelOptions(t, "summary-options-sentinel") chat.LastModelConfigID = config.ID - db.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil) + db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ ProviderID: providerID, From ab2c97703c17a64fc781cb15a9a1105ce4c142f3 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Sun, 23 Aug 2026 10:35:15 +0000 Subject: [PATCH 3/5] fix(coderd): require organization read for chat model routes --- coderd/coderd.go | 6 +- coderd/exp_chats_model_acl_test.go | 14 +-- coderd/exp_chats_model_config_list_test.go | 7 +- coderd/httpmw/organizationparam.go | 116 ++++++++------------- 4 files changed, 56 insertions(+), 87 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 842b74e7b15cf..898d6304b00c9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1438,12 +1438,12 @@ func New(options *Options) *API { r.Route("/organizations/{organization}/chats/models", func(r chi.Router) { r.Use(apiKeyMiddleware) - r.With(httpmw.ExtractChatModelOrganizationParam(options.Database)).Get("/", api.listChatModelConfigsByOrganization) + r.With(httpmw.ExtractOrganizationParam(options.Database)).Get("/", api.listChatModelConfigsByOrganization) r.With(httpmw.ExtractOrganizationParam(options.Database)).Post("/", api.createChatModelConfig) - r.With(httpmw.ExtractChatModelOrganizationParam(options.Database)).Get("/available", api.listChatModelAvailability) + r.With(httpmw.ExtractOrganizationParam(options.Database)).Get("/available", api.listChatModelAvailability) r.Route("/{model}", func(r chi.Router) { r.Use( - httpmw.ExtractChatModelOrganizationParam(options.Database), + httpmw.ExtractOrganizationParam(options.Database), httpmw.ExtractChatModelConfigParam(options.Database), ) r.Get("/", api.getChatModelConfig) diff --git a/coderd/exp_chats_model_acl_test.go b/coderd/exp_chats_model_acl_test.go index e874cf8e1cb30..93bf3d20ec564 100644 --- a/coderd/exp_chats_model_acl_test.go +++ b/coderd/exp_chats_model_acl_test.go @@ -338,7 +338,7 @@ func TestChatModelACLLockFailure(t *testing.T) { } //nolint:tparallel,paralleltest // Subtests share one model and run sequentially. -func TestChatModelActionOnlyScopes(t *testing.T) { +func TestChatModelActionScopesWithOrgRead(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -346,11 +346,11 @@ func TestChatModelActionOnlyScopes(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) model := createChatModel(t, adminClient) - newScopedClient := func(t *testing.T, scope database.APIKeyScope) *codersdk.ExperimentalClient { + newScopedClient := func(t *testing.T, scopes ...database.APIKeyScope) *codersdk.ExperimentalClient { t.Helper() _, token := dbgen.APIKey(t, db, database.APIKey{ UserID: firstUser.UserID, - Scopes: database.APIKeyScopes{scope}, + Scopes: append(database.APIKeyScopes{"organization:read"}, scopes...), }) client := codersdk.New( adminClient.URL, @@ -361,19 +361,19 @@ func TestChatModelActionOnlyScopes(t *testing.T) { return codersdk.NewExperimentalClient(client) } - t.Run("UpdateWithoutRead", func(t *testing.T) { + t.Run("UpdateWithoutModelRead", func(t *testing.T) { updateClient := newScopedClient(t, database.ApiKeyScopeChatModelConfigUpdate) updated, err := updateClient.UpdateChatModel(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelRequest{ - DisplayName: "Updated with update-only scope", + DisplayName: "Updated without model read scope", }) require.NoError(t, err) - require.Equal(t, "Updated with update-only scope", updated.DisplayName) + require.Equal(t, "Updated without model read scope", updated.DisplayName) _, err = updateClient.ChatModel(ctx, firstUser.OrganizationID, model.ID) requireSDKError(t, err, http.StatusNotFound) }) - t.Run("ShareWithoutRead", func(t *testing.T) { + t.Run("ShareWithoutModelRead", func(t *testing.T) { shareClient := newScopedClient(t, database.ApiKeyScopeChatModelConfigShare) _, err := shareClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) require.NoError(t, err) diff --git a/coderd/exp_chats_model_config_list_test.go b/coderd/exp_chats_model_config_list_test.go index 2f7339cc4df63..bbe7dbf9ff2f4 100644 --- a/coderd/exp_chats_model_config_list_test.go +++ b/coderd/exp_chats_model_config_list_test.go @@ -75,8 +75,11 @@ func TestChatModelConfigListReadContracts(t *testing.T) { wantACLStatus int }{ { - name: "ModelReadOnly", - scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeChatModelConfigRead}, + name: "ModelReadWithOrganizationRead", + scopes: []codersdk.APIKeyScope{ + codersdk.APIKeyScopeOrganizationRead, + codersdk.APIKeyScopeChatModelConfigRead, + }, wantACLStatus: http.StatusNotFound, }, { diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go index 0973a36eeed71..349ffe25e6c93 100644 --- a/coderd/httpmw/organizationparam.go +++ b/coderd/httpmw/organizationparam.go @@ -55,92 +55,58 @@ func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - organization, ok := resolveOrganizationParam(ctx, ctx, db, rw, r) - if !ok { + arg := chi.URLParam(r, "organization") + if arg == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "\"organization\" must be provided.", + }) return } - ctx = context.WithValue(ctx, organizationParamContextKey{}, organization) - next.ServeHTTP(rw, r.WithContext(ctx)) - }) - } -} - -// ExtractChatModelOrganizationParam resolves organization identity for chat -// model routes without requiring organization:read. The model handlers and -// Store methods authorize the original caller for the concrete operation. -func ExtractChatModelOrganizationParam(db database.Store) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - //nolint:gocritic // Route context resolution does not expose organization data. - organization, ok := resolveOrganizationParam(ctx, dbauthz.AsSystemRestricted(ctx), db, rw, r) - if !ok { + var organization database.Organization + var dbErr error + + // If the name is exactly "default", then we fetch the default + // organization. This is a special case to make it easier + // for single org deployments. + // + // arg == uuid.Nil.String() should be a temporary workaround for + // legacy provisioners that don't provide an organization ID. + // This prevents a breaking change. + // TODO: This change was added March 2024. Nil uuid returning the + // default org should be removed some number of months after + // that date. + if arg == codersdk.DefaultOrganization || arg == uuid.Nil.String() { + organization, dbErr = db.GetDefaultOrganization(ctx) + } else { + // Try by name or uuid. + id, err := uuid.Parse(arg) + if err == nil { + organization, dbErr = db.GetOrganizationByID(ctx, id) + } else { + organization, dbErr = db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{ + Name: arg, + Deleted: false, + }) + } + } + if httpapi.Is404Error(dbErr) { + httpapi.ResourceNotFound(rw) + return + } + if dbErr != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: fmt.Sprintf("Internal error fetching organization %q.", arg), + Detail: dbErr.Error(), + }) return } - ctx = context.WithValue(ctx, organizationParamContextKey{}, organization) next.ServeHTTP(rw, r.WithContext(ctx)) }) } } -func resolveOrganizationParam( - responseCtx context.Context, - queryCtx context.Context, - db database.Store, - rw http.ResponseWriter, - r *http.Request, -) (database.Organization, bool) { - arg := chi.URLParam(r, "organization") - if arg == "" { - httpapi.Write(responseCtx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "\"organization\" must be provided.", - }) - return database.Organization{}, false - } - - var organization database.Organization - var dbErr error - - // If the name is exactly "default", then we fetch the default - // organization. This is a special case to make it easier - // for single org deployments. - // - // arg == uuid.Nil.String() should be a temporary workaround for - // legacy provisioners that don't provide an organization ID. - // This prevents a breaking change. - // TODO: This change was added March 2024. Nil uuid returning the - // default org should be removed some number of months after - // that date. - if arg == codersdk.DefaultOrganization || arg == uuid.Nil.String() { - organization, dbErr = db.GetDefaultOrganization(queryCtx) - } else { - // Try by name or uuid. - id, err := uuid.Parse(arg) - if err == nil { - organization, dbErr = db.GetOrganizationByID(queryCtx, id) - } else { - organization, dbErr = db.GetOrganizationByName(queryCtx, database.GetOrganizationByNameParams{ - Name: arg, - Deleted: false, - }) - } - } - if httpapi.Is404Error(dbErr) { - httpapi.ResourceNotFound(rw) - return database.Organization{}, false - } - if dbErr != nil { - httpapi.Write(responseCtx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: fmt.Sprintf("Internal error fetching organization %q.", arg), - Detail: dbErr.Error(), - }) - return database.Organization{}, false - } - return organization, true -} - // OrganizationMember is the database object plus the Username and Avatar URL. Including these // in the middleware is preferable to a join at the SQL layer so that we can keep the // autogenerated database types as they are. From 4278ea68fb135053f058d467577f0261b97b5626 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 24 Aug 2026 08:33:50 +0000 Subject: [PATCH 4/5] fix: include disabled providers in chat model descriptors and cache scaletest chat model lookups --- cli/exp_scaletest_chat.go | 14 ++++++++++++++ coderd/exp_chats.go | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cli/exp_scaletest_chat.go b/cli/exp_scaletest_chat.go index 1a671c4636f41..e85bfa3516d5f 100644 --- a/cli/exp_scaletest_chat.go +++ b/cli/exp_scaletest_chat.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/xerrors" @@ -81,6 +82,19 @@ func (r *RootCmd) scaletestChat() *serpent.Command { if err != nil { return err } + uncachedModelForOrg := modelForOrg + modelIDsByOrganization := make(map[uuid.UUID]uuid.UUID) + modelForOrg = func(organizationID uuid.UUID) (uuid.UUID, error) { + if modelID, ok := modelIDsByOrganization[organizationID]; ok { + return modelID, nil + } + modelID, err := uncachedModelForOrg(organizationID) + if err != nil { + return uuid.Nil, err + } + modelIDsByOrganization[organizationID] = modelID + return modelID, nil + } // Start metrics and tracing before creating runners. reg := prometheus.NewRegistry() diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 7c7b3c0fa8b9d..45c33b5af1d09 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -7069,7 +7069,7 @@ func (api *API) chatModelProviderDescriptors( userID uuid.UUID, ) ([]codersdk.ChatModelProviderDescriptor, error) { //nolint:gocritic // Fixed redacted projection under the model read gate; see function doc. - providers, err := api.Database.GetAIProviders(dbauthz.AsChatd(ctx), database.GetAIProvidersParams{}) + providers, err := api.Database.GetAIProviders(dbauthz.AsChatd(ctx), database.GetAIProvidersParams{IncludeDisabled: true}) if err != nil { return nil, err } From cc1b5b44e455845b3419c489823083038f320376 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 24 Aug 2026 08:33:50 +0000 Subject: [PATCH 5/5] chore: drop redundant embedded Client selectors in chat tests --- coderd/coderdtest/chat.go | 2 +- coderd/exp_chats_test.go | 6 +++--- enterprise/coderd/exp_chats_test.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/coderd/coderdtest/chat.go b/coderd/coderdtest/chat.go index ae18a4cb9ca83..a3b777b72be29 100644 --- a/coderd/coderdtest/chat.go +++ b/coderd/coderdtest/chat.go @@ -70,7 +70,7 @@ func CreateOpenAICompatChatModel( APIKeys: []string{TestChatProviderAPIKey}, }) require.NoError(t, err) - defaultOrg, err := client.Client.OrganizationByName(ctx, codersdk.DefaultOrganization) + defaultOrg, err := client.OrganizationByName(ctx, codersdk.DefaultOrganization) require.NoError(t, err) contextLimit := int64(4096) isDefault := true diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 2b6092a4a0078..34f227a8aca95 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -5301,7 +5301,7 @@ func TestCreateChatModelConfig(t *testing.T) { client.Client, defaultOrg.ID, ) - _, err = client.Client.UpdateOrganizationMemberRoles( + _, err = client.UpdateOrganizationMemberRoles( ctx, defaultOrg.ID, adminUser.ID.String(), @@ -5382,7 +5382,7 @@ func TestCreateChatModelConfig(t *testing.T) { client.Client, defaultOrg.ID, ) - _, err = client.Client.UpdateOrganizationMemberRoles( + _, err = client.UpdateOrganizationMemberRoles( ctx, defaultOrg.ID, writerUser.ID.String(), @@ -14177,7 +14177,7 @@ func createAdditionalChatModelWithCallConfig( aiProvider := createAIProviderForTest(t, client, provider, "test-api-key") contextLimit := int64(4096) isDefault := false - defaultOrg, err := client.Client.OrganizationByName(ctx, codersdk.DefaultOrganization) + defaultOrg, err := client.OrganizationByName(ctx, codersdk.DefaultOrganization) require.NoError(t, err) modelConfig, err := client.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, diff --git a/enterprise/coderd/exp_chats_test.go b/enterprise/coderd/exp_chats_test.go index e2633eec887a4..6e1b301a4cb08 100644 --- a/enterprise/coderd/exp_chats_test.go +++ b/enterprise/coderd/exp_chats_test.go @@ -54,7 +54,7 @@ func createOpenAIChatModelForTest( ) codersdk.ChatModel { t.Helper() provider := createOpenAIProviderForTest(ctx, t, client, apiKey, baseURL) - defaultOrg, err := client.Client.OrganizationByName(ctx, codersdk.DefaultOrganization) + defaultOrg, err := client.OrganizationByName(ctx, codersdk.DefaultOrganization) require.NoError(t, err) model, err := client.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &provider.ID, @@ -946,7 +946,7 @@ func TestChatModelDefault(t *testing.T) { expClient := codersdk.NewExperimentalClient(client) provider := createOpenAIProviderForTest(ctx, t, expClient, "test", "https://example.com") - defaultOrg, err := expClient.Client.OrganizationByName(ctx, codersdk.DefaultOrganization) + defaultOrg, err := expClient.OrganizationByName(ctx, codersdk.DefaultOrganization) require.NoError(t, err) contextLimit := int64(1000)