From aefc761fcf2033f675cef8ae423e23502f9efdad Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:20:25 +0100 Subject: [PATCH] feat!: enforce organization scope for chat models (#27959) ## Summary Move chat model management and runtime availability to organization-scoped API routes. The API now identifies the organization in every model-management operation, applies chat model RBAC and ACLs within that organization, and returns only models the caller can use there. Depends on #27958. ## Problem Chat model configurations now belong to organizations, but the existing experimental management and discovery APIs are deployment-shaped. Their routes do not identify an organization, SDK methods do not accept an organization ID, and item operations address a model configuration by ID alone. That contract cannot safely expose organization-owned model configurations. It also does not provide the organization-specific provider information, authorization behavior, ACL management, or recovery guidance needed by the settings and chat clients later in the stack. ## Fix Add organization-scoped chat model endpoints under: ```text /api/experimental/organizations/{organization}/chats/models ``` The new API provides: - Organization-scoped model listing and creation. - Model get, update, and delete operations scoped by both organization and model ID. - Model ACL read and update operations. - Organization-scoped runtime availability and provider status. - Redacted provider descriptors for model-management clients. - Chat model RBAC and row ACL enforcement for writes, runtime discovery, and Chatd model selection. - Recovery guidance when a chat's organization has no usable local model. Historical chats can retain a model reference owned by another organization, but runtime selection uses an authorized local default when one is available. Foreign, inaccessible, disabled, and otherwise unusable models are not exposed as usable selections for the organization. The existing collection and availability routes remain as default-organization compatibility endpoints in this layer: ```text GET /api/experimental/chats/model-configs POST /api/experimental/chats/model-configs GET /api/experimental/chats/models ``` These compatibility routes do not provide access to models in non-default organizations. The previous unscoped item update and delete routes are removed because item operations must include the owning organization. ## Breaking change This changes the experimental HTTP API and Go SDK contracts for chat model management. Clients must migrate model-management and availability requests to the organization-scoped routes and provide an organization identifier. In the Go SDK, the following methods now require an organization ID: - `ChatModelAvailability` - `ChatModels` - `CreateChatModel` - `UpdateChatModel` - `DeleteChatModel` `ChatModels` also changes its return value from `[]ChatModel` to `OrganizationChatModelsResponse`, which includes the organization models and the redacted provider descriptors required by management clients. New item and ACL methods likewise require both the organization ID and model ID. The default-organization compatibility routes reduce the immediate impact for collection and availability callers, but they do not preserve the previous item API or SDK method signatures. Generated clients and direct consumers of the experimental API must be updated before adopting this change. _This pull request description was generated by Coder Agents._ --- cli/exp_scaletest_chat.go | 21 +- cli/exp_scaletest_chat_test.go | 8 +- coderd/apidoc/docs.go | 973 ++++++- coderd/apidoc/swagger.json | 935 ++++++- 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 | 813 +++--- coderd/exp_chats_model_acl.go | 299 +++ coderd/exp_chats_model_acl_test.go | 436 +++ coderd/exp_chats_model_config_list_test.go | 204 +- coderd/exp_chats_test.go | 2370 +++++++++++------ coderd/httpmw/chatmodelconfigparam.go | 60 + coderd/x/chatd/advisor_internal_test.go | 6 +- 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/modelcall_internal_test.go | 2 +- 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 | 1741 +++++++++++- 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, 7995 insertions(+), 2558 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 8de6809e722..e85bfa3516d 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" @@ -77,10 +78,23 @@ 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 } + 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() @@ -124,6 +138,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 b83c7bfdbac..3294af4e584 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 98905869138..8e63c7ee70c 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,6 +1359,353 @@ const docTemplate = `{ } } }, + "/api/experimental/organizations/{organization}/chats/models": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "List AI models and provider descriptors in an organization", + "operationId": "list-ai-models-by-organization", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationChatModelsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "Create an AI model in an organization", + "operationId": "create-ai-model", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "description": "Model", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateChatModelRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.ChatModel" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, + "/api/experimental/organizations/{organization}/chats/models/available": { + "get": { + "description": "Experimental: this endpoint is subject to change.", + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "List available chat models in an organization", + "operationId": "list-chat-model-availability", + "parameters": [ + { + "type": "string", + "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", + "description": "Model ID", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModel" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + }, + "delete": { + "tags": [ + "Chats" + ], + "summary": "Delete an AI model", + "operationId": "delete-ai-model", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Model ID", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "Update an AI model", + "operationId": "update-ai-model", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Model ID", + "name": "model", + "in": "path", + "required": true + }, + { + "description": "Model updates", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateChatModelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModel" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, + "/api/experimental/organizations/{organization}/chats/models/{model}/acl": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "Get an AI model ACL", + "operationId": "get-ai-model-acl", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Model ID", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModelACL" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "Update an AI model ACL", + "operationId": "update-ai-model-acl", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Model ID", + "name": "model", + "in": "path", + "required": true + }, + { + "description": "Sparse model ACL update", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateChatModelACLRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/organizations/{organization}/mcp-servers": { "get": { "produces": [ @@ -18627,9 +18948,129 @@ const docTemplate = `{ } } }, + "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": { @@ -18645,6 +19086,38 @@ const docTemplate = `{ } } }, + "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": { @@ -18662,6 +19135,228 @@ const docTemplate = `{ } } }, + "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" + }, + "service_tier": { + "type": "string" + }, + "store": { + "type": "boolean" + }, + "strict_json_schema": { + "type": "boolean" + }, + "structured_outputs": { + "type": "boolean" + }, + "text_verbosity": { + "type": "string" + }, + "top_log_probs": { + "type": "integer" + }, + "user": { + "type": "string" + }, + "web_search_enabled": { + "type": "boolean" + } + } + }, + "codersdk.ChatModelOpenRouterProvider": { + "type": "object", + "properties": { + "allow_fallbacks": { + "type": "boolean" + }, + "data_collection": { + "type": "string" + }, + "ignore": { + "type": "array", + "items": { + "type": "string" + } + }, + "only": { + "type": "array", + "items": { + "type": "string" + } + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "quantizations": { + "type": "array", + "items": { + "type": "string" + } + }, + "require_parameters": { + "type": "boolean" + }, + "sort": { + "type": "string" + } + } + }, + "codersdk.ChatModelOpenRouterProviderOptions": { + "type": "object", + "properties": { + "extra_body": { + "type": "object", + "additionalProperties": {} + }, + "include_usage": { + "type": "boolean" + }, + "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" + } + } + }, "codersdk.ChatModelProvider": { "type": "object", "properties": { @@ -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 039748a88c2..77033188ce8 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,6 +1210,319 @@ } } }, + "/api/experimental/organizations/{organization}/chats/models": { + "get": { + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "List AI models and provider descriptors in an organization", + "operationId": "list-ai-models-by-organization", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationChatModelsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + }, + "post": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "Create an AI model in an organization", + "operationId": "create-ai-model", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "description": "Model", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateChatModelRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.ChatModel" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, + "/api/experimental/organizations/{organization}/chats/models/available": { + "get": { + "description": "Experimental: this endpoint is subject to change.", + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "List available chat models in an organization", + "operationId": "list-chat-model-availability", + "parameters": [ + { + "type": "string", + "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", + "description": "Model ID", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModel" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + }, + "delete": { + "tags": ["Chats"], + "summary": "Delete an AI model", + "operationId": "delete-ai-model", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Model ID", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + }, + "patch": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "Update an AI model", + "operationId": "update-ai-model", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Model ID", + "name": "model", + "in": "path", + "required": true + }, + { + "description": "Model updates", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateChatModelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModel" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, + "/api/experimental/organizations/{organization}/chats/models/{model}/acl": { + "get": { + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "Get an AI model ACL", + "operationId": "get-ai-model-acl", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Model ID", + "name": "model", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModelACL" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + }, + "patch": { + "consumes": ["application/json"], + "tags": ["Chats"], + "summary": "Update an AI model ACL", + "operationId": "update-ai-model-acl", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Model ID", + "name": "model", + "in": "path", + "required": true + }, + { + "description": "Sparse model ACL update", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateChatModelACLRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/organizations/{organization}/mcp-servers": { "get": { "produces": ["application/json"], @@ -16809,9 +17100,129 @@ } } }, + "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": { @@ -16827,6 +17238,38 @@ } } }, + "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": { @@ -16844,6 +17287,228 @@ } } }, + "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" + }, + "service_tier": { + "type": "string" + }, + "store": { + "type": "boolean" + }, + "strict_json_schema": { + "type": "boolean" + }, + "structured_outputs": { + "type": "boolean" + }, + "text_verbosity": { + "type": "string" + }, + "top_log_probs": { + "type": "integer" + }, + "user": { + "type": "string" + }, + "web_search_enabled": { + "type": "boolean" + } + } + }, + "codersdk.ChatModelOpenRouterProvider": { + "type": "object", + "properties": { + "allow_fallbacks": { + "type": "boolean" + }, + "data_collection": { + "type": "string" + }, + "ignore": { + "type": "array", + "items": { + "type": "string" + } + }, + "only": { + "type": "array", + "items": { + "type": "string" + } + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "quantizations": { + "type": "array", + "items": { + "type": "string" + } + }, + "require_parameters": { + "type": "boolean" + }, + "sort": { + "type": "string" + } + } + }, + "codersdk.ChatModelOpenRouterProviderOptions": { + "type": "object", + "properties": { + "extra_body": { + "type": "object", + "additionalProperties": {} + }, + "include_usage": { + "type": "boolean" + }, + "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" + } + } + }, "codersdk.ChatModelProvider": { "type": "object", "properties": { @@ -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 8564f33a964..6fa6fc7ff4b 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 fed41ba5df9..e83b5d7fb2c 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 1ce598fcecd..898d6304b00 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.ExtractOrganizationParam(options.Database)).Get("/", api.listChatModelConfigsByOrganization) + r.With(httpmw.ExtractOrganizationParam(options.Database)).Post("/", api.createChatModelConfig) + r.With(httpmw.ExtractOrganizationParam(options.Database)).Get("/available", api.listChatModelAvailability) + r.Route("/{model}", func(r chi.Router) { + r.Use( + httpmw.ExtractOrganizationParam(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 ebb4501c25c..a3b777b72be 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.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 00000000000..241e3edfb67 --- /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 d3b15f8374e..b243230bcfb 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 e5b7766b19c..469a9180772 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() + check.Args(orgID, 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}) - })) - 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) + 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("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}) - })) - 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 33f5560ada7..1f728f1693f 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 856f1ba49a2..b2f091c868c 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 242f9c33324..c369e3f4048 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 663c0a97e92..23956a7ab40 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 00dd4cc0545..167f9210f76 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 6bc0b0eeabd..2af1b17da1c 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 c3759bea71e..bc76ac7179a 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 671440a47be..902661648a5 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 e450ab3157e..45c33b5af1d 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 !model.AIProviderID.Valid { + continue + } + providerID := model.AIProviderID.UUID + providerType, ok := availability.providerTypeByID[providerID] + if !ok { + continue + } + normalizedProvider := chatprovider.NormalizeProvider(providerType) if normalizedProvider == "" { continue } - if model.ChatModelConfig.AIProviderID.Valid { - status, ok := availability.providerStatusByID[model.ChatModelConfig.AIProviderID.UUID] - if ok { - mergeProviderStatus(modelStatusByType, normalizedProvider, status) - } - 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 err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to load the default organization.", - Detail: err.Error(), - }) + if !chatModelConfigReadScope(apiKey.Scopes) { + httpapi.Forbidden(rw) return } - availability, err := api.getUserChatProviderAvailability(ctx, apiKey.UserID, defaultOrganization.ID) + organization := httpmw.OrganizationParam(r) + visible, err := api.canReadChatModelsInOrganization(ctx, r, organization) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + if !visible { + httpapi.ResourceNotFound(rw) + return + } + 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,7 @@ func (api *API) listChatModelConfigs(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 - } - - allConfigs, err := api.Database.GetChatModelConfigs(ctx) + 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 +6969,186 @@ 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)) - } + 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.", + Detail: err.Error(), + }) + return + } + + 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{IncludeDisabled: true}) + 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,18 +7195,34 @@ 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) + organization := httpmw.OrganizationParam(r) + + var req struct { + codersdk.CreateChatModelRequest + GroupACL json.RawMessage `json:"group_acl"` + UserACL json.RawMessage `json:"user_acl"` + } + if !httpapi.Read(ctx, rw, r, &req) { return } - - var req codersdk.CreateChatModelRequest - if !httpapi.Read(ctx, rw, r, &req) { + 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 } @@ -7033,24 +7230,7 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "AI provider ID is required."}) 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(), - }) - return - } - if !aiProvider.Enabled { - writeChatProviderPreconditionError(ctx, rw, errChatProviderDisabled) - 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,14 +7297,37 @@ 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. + 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 + + if insertAsDefault && defaultExists { + if err := tx.UnsetDefaultChatModelConfigs(ctx, insertParams.OrganizationID); err != nil { + return xerrors.Errorf("unset default model configs: %w", err) + } + } + insertParams.IsDefault = insertAsDefault + + 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) { @@ -7149,32 +7342,6 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { return err } - 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 err := tx.UnsetDefaultChatModelConfigs(ctx, insertParams.OrganizationID); err != nil { - return xerrors.Errorf("unset default model configs: %w", err) - } - } - insertParams.IsDefault = insertAsDefault - - config, err := tx.InsertChatModelConfig(ctx, insertParams) - if err != nil { - return err - } - inserted = config - 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) - 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.ActionUpdate, chatModelConfigRBACObject(existing)) { httpapi.ResourceNotFound(rw) return } - var req codersdk.UpdateChatModelRequest + var req struct { + codersdk.UpdateChatModelRequest + GroupACL json.RawMessage `json:"group_acl"` + UserACL json.RawMessage `json:"user_acl"` + } if !httpapi.Read(ctx, rw, r, &req) { return } + 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 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 00000000000..de1922d4179 --- /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 00000000000..93bf3d20ec5 --- /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 TestChatModelActionScopesWithOrgRead(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, scopes ...database.APIKeyScope) *codersdk.ExperimentalClient { + t.Helper() + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: firstUser.UserID, + Scopes: append(database.APIKeyScopes{"organization:read"}, scopes...), + }) + 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("UpdateWithoutModelRead", func(t *testing.T) { + updateClient := newScopedClient(t, database.ApiKeyScopeChatModelConfigUpdate) + updated, err := updateClient.UpdateChatModel(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelRequest{ + DisplayName: "Updated without model read scope", + }) + require.NoError(t, err) + 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("ShareWithoutModelRead", 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 2cf19fcc2f8..bbe7dbf9ff2 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,34 @@ 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: "ModelReadWithOrganizationRead", + scopes: []codersdk.APIKeyScope{ + codersdk.APIKeyScopeOrganizationRead, + codersdk.APIKeyScopeChatModelConfigRead, + }, + wantACLStatus: http.StatusNotFound, + }, + { + name: "WorkspaceRead", + scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeOrganizationRead, codersdk.APIKeyScopeWorkspaceRead}, + wantCollectionStatus: http.StatusForbidden, + wantItemStatus: http.StatusNotFound, + wantACLStatus: http.StatusNotFound, + }, } { t.Run("TokenScope/"+testCase.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ - Scopes: []codersdk.APIKeyScope{testCase.scope}, + Scopes: testCase.scopes, }) require.NoError(t, err) scopedClient := codersdk.New( @@ -90,52 +105,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) + } + if testCase.wantItemStatus != 0 { + requireSDKError(t, itemErr, testCase.wantItemStatus) + } else { + require.NoError(t, itemErr) + require.Equal(t, ownEnabled.ID, model.ID) + } + requireSDKError(t, aclErr, testCase.wantACLStatus) + }) + } + + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + for _, endpoint := range []struct { + name string + call func(context.Context, uuid.UUID) error + }{ + { + name: "Collection", + call: func(ctx context.Context, organizationID uuid.UUID) error { + _, err := memberClient.ChatModels(ctx, organizationID) + return err + }, + }, + { + name: "Availability", + call: func(ctx context.Context, organizationID uuid.UUID) error { + _, err := memberClient.ChatModelAvailability(ctx, organizationID) + return err + }, + }, + { + name: "Item", + call: func(ctx context.Context, organizationID uuid.UUID) error { + _, err := memberClient.ChatModel(ctx, organizationID, otherEnabled.ID) + return err + }, + }, + { + name: "ACL", + call: func(ctx context.Context, organizationID uuid.UUID) error { + _, err := memberClient.ChatModelACL(ctx, organizationID, otherEnabled.ID) + return err + }, + }, + } { + t.Run("ConcealsHiddenOrganization/"+endpoint.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + var concealedMessage string + for _, organizationID := range []uuid.UUID{otherOrg.ID, uuid.New()} { + err := endpoint.call(ctx, organizationID) + sdkErr := requireSDKError(t, err, http.StatusNotFound) + if concealedMessage == "" { + concealedMessage = sdkErr.Message + } + require.Equal(t, concealedMessage, sdkErr.Message) } - require.NoError(t, err) - require.NotEmpty(t, configs) }) } testCases := []struct { - name string - client func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient - visible []uuid.UUID - hidden []uuid.UUID + name string + client func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient + seesDenied bool }{ { - name: "OwnerSeesDefaultOrgConfigs", - client: func(*testing.T, context.Context) *codersdk.ExperimentalClient { - return client - }, - 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) - }, - 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, - }) - }, - visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, - hidden: []uuid.UUID{otherEnabled.ID}, - }, - { - name: "AgentsAccessUsesRowACL", + name: "AgentsAccess", client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { rawClient, _ := coderdtest.CreateAnotherUser( t, @@ -145,53 +195,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", + name: "Owner", + seesDenied: true, + client: func(*testing.T, context.Context) *codersdk.ExperimentalClient { + return client + }, + }, + { + name: "SiteAuditor", + seesDenied: true, + client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { + rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.RoleAuditor()) + return codersdk.NewExperimentalClient(rawClient) + }, + }, + { + name: "CustomSiteReadRole", + seesDenied: true, client: func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient { return newSiteCustomRoleClient(ctx, t, client, rawDB, defaultOrg.ID, database.CustomRolePermission{ - ResourceType: rbac.ResourceDeploymentConfig.Type, + ResourceType: rbac.ResourceChatModelConfig.Type, Action: policy.ActionRead, }) }, - visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID}, - hidden: []uuid.UUID{denied.ID, otherEnabled.ID}, }, { - name: "DefaultOrgAdminKeepsEnabledList", + name: "OrgAdmin", + seesDenied: true, client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAdmin(defaultOrg.ID)) return codersdk.NewExperimentalClient(rawClient) }, - visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, - hidden: []uuid.UUID{otherEnabled.ID}, }, { - name: "DefaultOrgAuditorKeepsEnabledList", + name: "OrgAuditor", + seesDenied: true, client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAuditor(defaultOrg.ID)) return codersdk.NewExperimentalClient(rawClient) }, - visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, - hidden: []uuid.UUID{otherEnabled.ID}, - }, - { - name: "NonDefaultOrgAdminSeesNoDefaultOrgConfigs", - client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { - rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleOrgAdmin(otherOrg.ID)) - return codersdk.NewExperimentalClient(rawClient) - }, - hidden: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID, otherEnabled.ID}, - }, - { - name: "NonDefaultOrgAuditorSeesNoDefaultOrgConfigs", - client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { - rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleOrgAuditor(otherOrg.ID)) - return codersdk.NewExperimentalClient(rawClient) - }, - hidden: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID, otherEnabled.ID}, }, } @@ -200,14 +244,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 f8c9528a9a2..34f227a8aca 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("SuccessNonDefaultOrgUsesOrgDefault", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.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) - _ = createChatModelConfig(t, 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", - }}, - }) - require.NoError(t, err) - require.Equal(t, localDefault.ID, chat.LastModelConfigID) - require.NotEqual(t, deploymentDefault.ID, chat.LastModelConfigID) - }) - - t.Run("RejectsExplicitForeignModel", 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)) - 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, - Content: []codersdk.ChatInputPart{{ - Type: codersdk.ChatInputPartTypeText, - Text: "reject the foreign model", + Text: "do not use the deployment default model", }}, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Model config does not belong to the specified organization.", 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("ForeignPersonalOverrideUsesLocalDefault", 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}) - 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) + 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.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(), - }) - require.NoError(t, err) - chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: org.ID, + _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: otherOrg.ID, Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeText, - Text: "ignore the foreign personal override", + Text: "reject an explicit deployment model", }}, + ModelConfigID: ptr.Ref(defaultConfig.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("ThirdOrgExplicitModelRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + 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) + 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, + }) + + _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: chatOrg.ID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "reject a third organization's model", + }}, + ModelConfigID: ptr.Ref(thirdConfig.ID), + }) + 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) + // 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, 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) + 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,122 +4928,58 @@ 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) - 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, + 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{}, }) - 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 - })) - }) - - // Provider status does not change management list visibility. - t.Run("ProviderDisabled", 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) - adminConfigs, err := adminClient.ChatModels(ctx) + configs, err := memberClient.ChatModels(ctx, firstUser.OrganizationID) require.NoError(t, err) - adminIDs := make([]uuid.UUID, 0, len(adminConfigs)) - for _, config := range adminConfigs { - adminIDs = append(adminIDs, config.ID) + modelIDs := make([]uuid.UUID, 0, len(configs.Models)) + for _, config := range configs.Models { + modelIDs = append(modelIDs, config.ID) } - require.Contains(t, adminIDs, providerDisabledConfig.ID) - - memberConfigs, err := memberClient.ChatModels(ctx) - 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.Contains(t, modelIDs, readableConfig.ID) + require.NotContains(t, modelIDs, privateConfig.ID) }) - t.Run("ManagementListExcludesOtherOrganizations", func(t *testing.T) { + t.Run("BedrockDescriptorReportsEffectiveCredentials", 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}, + firstUser := coderdtest.CreateFirstUser(t, client.Client) + provider := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AIProviderTypeBedrock, }) - configs, err := client.ChatModels(ctx) + models, err := client.ChatModels(ctx, firstUser.OrganizationID) require.NoError(t, err) - require.Contains(t, configs, defaultConfig) - for _, config := range configs { - require.NotEqual(t, otherConfig.ID, config.ID) - } - }) - - t.Run("SuccessForOrganizationMember", 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) - 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) - require.NoError(t, err) - require.NotEmpty(t, configs) - - 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) + 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.True(t, found) + t.Fatal("bedrock provider descriptor not found") }) } -func TestCreateChatModelConfig(t *testing.T) { +func TestGetChatModel(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { @@ -4714,12 +4988,172 @@ func TestCreateChatModelConfig(t *testing.T) { 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) + require.Equal(t, modelConfig.ID, got.ID) + }) + + t.Run("WrongOrganizationConcealedForAllItemActions", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModel(t, client) + wrongOrganization := dbgen.Organization(t, db, database.Organization{IsDefault: false}) + + 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("OrganizationMemberCanReadSharedModel", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + modelConfig := createChatModel(t, adminClient) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + got, err := memberClient.ChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID) + require.NoError(t, err) + require.Equal(t, modelConfig.ID, got.ID) + }) + + 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) + 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, + // 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, + }, }, - database.CustomRolePermission{ - ResourceType: rbac.ResourceDeploymentConfig.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.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, + // 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, + }, }, - database.CustomRolePermission{ - ResourceType: rbac.ResourceDeploymentConfig.Type, - Action: policy.ActionUpdate, - }, - ) + }) + 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.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)) - - // 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{ - IsDefault: ¬Default, - }) - require.NoError(t, err) - require.True(t, updated.IsDefault, "the sole config must remain default") - row, err := rawDB.GetChatModelConfigByID(ctx, defaultConfig.ID) - require.NoError(t, err) - require.True(t, row.IsDefault, "the sole config must remain default") - - replacement, err := writerClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + candidate, err := writerClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, - Model: "gpt-4o-mini-replacement", + Model: "gpt-4o-mini-candidate", ContextLimit: &contextLimit, }) 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) - require.NoError(t, err) - require.True(t, row.IsDefault, "replacement must be promoted") - candidate, err := writerClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{ + notDefault := false + _, err = writerClient.UpdateChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID, codersdk.UpdateChatModelRequest{ + IsDefault: ¬Default, + }) + require.NoError(t, err) + candidateRow, err := rawDB.GetChatModelConfigByID(ctx, candidate.ID) + require.NoError(t, err) + require.True(t, candidateRow.IsDefault) + + err = writerClient.DeleteChatModel(ctx, candidate.OrganizationID, candidate.ID) + require.NoError(t, err) + defaultRow, err := rawDB.GetChatModelConfigByID(ctx, defaultConfig.ID) + require.NoError(t, err) + require.True(t, defaultRow.IsDefault) + + 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) + promotedRow, err := rawDB.GetChatModelConfigByID(ctx, promoted.ID) require.NoError(t, err) - require.True(t, row.IsDefault, "candidate must be promoted") - row, err = rawDB.GetChatModelConfigByID(ctx, replacement.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 { +func createChatModel(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) -} - -// 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) + return createAdditionalChatModelWithCallConfig(t, client, provider, model, nil) } -func createAdditionalChatModelConfig( - t *testing.T, - client *codersdk.ExperimentalClient, - provider string, - model string, -) codersdk.ChatModel { - t.Helper() - return createAdditionalChatModelConfigWithModelConfig(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.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 00000000000..2faf8139225 --- /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/x/chatd/advisor_internal_test.go b/coderd/x/chatd/advisor_internal_test.go index 8b3c166885b..92050bf45a8 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", @@ -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()}, @@ -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/chatd.go b/coderd/x/chatd/chatd.go index ac96eb393da..d0214d41554 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 f22988c00e9..49c09d1fa35 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 0b80dcc398e..76de22ae289 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 12751d44fd6..9bf7d07c4bc 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 047aa46086b..0aa1335c152 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 30ad039f92e..c01d8973b19 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 ea5a0138f84..017aff1de9e 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,154 +177,9 @@ 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) { if prompt, ok := c.cachedUserPrompt(userID); ok { return prompt, nil @@ -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 67ef60c26b5..93eda31be8a 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 f0b06e6fb93..bb79819a3f5 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 2ca2538e81a..ebf89d783b2 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/modelcall_internal_test.go b/coderd/x/chatd/modelcall_internal_test.go index 7da6ae71560..42ce58b2917 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, diff --git a/coderd/x/chatd/provider_switch_sanitize.go b/coderd/x/chatd/provider_switch_sanitize.go index 3b300003f18..f3ce2b8b6a0 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 8429fabe881..960defa4d6c 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 7b10541cae3..ef9c1c469cd 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 779a96f974b..8c8ee99741d 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 8fd24e5fef7..3e80ced483f 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 models []ChatModel - return models, ReadBodyAsJSON(res, &models) + var resp OrganizationChatModelsResponse + return resp, ReadBodyAsJSON(res, &resp) } -// 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) +// 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 config ChatModel + return config, ReadBodyAsJSON(res, &config) +} + +// 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,26 @@ 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 } @@ -2247,6 +2299,44 @@ func (c *ExperimentalClient) DeleteChatModel(ctx context.Context, modelID uuid.U 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 + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + 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 00000000000..205226de818 --- /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 8cfc2b0b1c4..8647e4bed44 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 c40f0087e29..c6a36158af8 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 b08703eb54b..d51b4bde77d 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 a47211652f3..d8fe3468d00 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 4bd8af6b070..4259672f371 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3393,10 +3393,445 @@ 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 { + "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, @@ -3423,10 +3858,183 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### 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 | +|-------------------------|-------------------------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------| +| `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 @@ -3448,6 +4056,264 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `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" + ], + "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 | +|-------------------------|-----------------|----------|--------------|-------------| +| `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" +} +``` + +### 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 +{ + "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 | +|-----------------------|------------------------------------------------------------------------------|----------|--------------|-------------| +| `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 ```json @@ -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 4303eaba860..3d881231add 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 e5ea072f272..6e1b301a4cb 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.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.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 4649f16b6e6..bb75e93af35 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 c0faf98a9f7..929cecd34c1 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 6e797f677c4..0b426ba5a84 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 3c1e1111862..37853db3a70 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 53baea63d6d..f1bf39bba26 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 af2787cde3e..8dfa87d3fa5 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 31c741135b1..3562fe58702 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 2aea7ea5c21..7efde31ca8a 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",