diff --git a/cli/exp_scaletest_chat.go b/cli/exp_scaletest_chat.go
index 8de6809e72204..e85bfa3516d5f 100644
--- a/cli/exp_scaletest_chat.go
+++ b/cli/exp_scaletest_chat.go
@@ -7,6 +7,7 @@ import (
"sync"
"time"
+ "github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/xerrors"
@@ -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 b83c7bfdbacba..3294af4e584fa 100644
--- a/cli/exp_scaletest_chat_test.go
+++ b/cli/exp_scaletest_chat_test.go
@@ -77,9 +77,11 @@ func TestScaleTestChat(t *testing.T) {
require.Equal(t, mockURL, provider.BaseURL)
expClient := codersdk.NewExperimentalClient(client)
- configs, err := expClient.ChatModels(ctx)
+ defaultOrg, err := client.OrganizationByName(ctx, codersdk.DefaultOrganization)
require.NoError(t, err)
- matchingConfigs := scaletestModelsForProvider(configs, provider.ID)
+ configs, err := expClient.ChatModels(ctx, defaultOrg.ID)
+ require.NoError(t, err)
+ matchingConfigs := scaletestModelConfigsForProvider(configs.Models, provider.ID)
require.Len(t, matchingConfigs, 1)
require.True(t, matchingConfigs[0].Enabled)
@@ -125,7 +127,7 @@ func chatMessageText(messages []codersdk.ChatMessage, role codersdk.ChatMessageR
return b.String(), found
}
-func scaletestModelsForProvider(configs []codersdk.ChatModel, providerID uuid.UUID) []codersdk.ChatModel {
+func scaletestModelConfigsForProvider(configs []codersdk.ChatModel, providerID uuid.UUID) []codersdk.ChatModel {
matches := make([]codersdk.ChatModel, 0, 1)
for _, config := range configs {
if config.AIProviderID != providerID {
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 98905869138cc..8e63c7ee70c5b 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -482,32 +482,6 @@ const docTemplate = `{
}
}
},
- "/api/experimental/chats/models": {
- "get": {
- "description": "Experimental: this endpoint is subject to change.",
- "produces": [
- "application/json"
- ],
- "tags": [
- "Chats"
- ],
- "summary": "List chat models",
- "operationId": "list-chat-models",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/codersdk.ChatModelAvailabilityResponse"
- }
- }
- },
- "security": [
- {
- "CoderSessionToken": []
- }
- ]
- }
- },
"/api/experimental/chats/watch": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -1385,21 +1359,20 @@ const docTemplate = `{
}
}
},
- "/api/experimental/organizations/{organization}/mcp-servers": {
+ "/api/experimental/organizations/{organization}/chats/models": {
"get": {
"produces": [
"application/json"
],
"tags": [
- "MCP"
+ "Chats"
],
- "summary": "List MCP server configs",
- "operationId": "list-mcp-server-configs",
+ "summary": "List AI models and provider descriptors in an organization",
+ "operationId": "list-ai-models-by-organization",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
@@ -1409,10 +1382,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/codersdk.MCPServerConfig"
- }
+ "$ref": "#/definitions/codersdk.OrganizationChatModelsResponse"
}
}
},
@@ -1433,26 +1403,25 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "MCP"
+ "Chats"
],
- "summary": "Create MCP server config",
- "operationId": "create-mcp-server-config",
+ "summary": "Create an AI model in an organization",
+ "operationId": "create-ai-model",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
- "description": "Create MCP server config request",
+ "description": "Model",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.CreateMCPServerConfigRequest"
+ "$ref": "#/definitions/codersdk.CreateChatModelRequest"
}
}
],
@@ -1460,7 +1429,7 @@ const docTemplate = `{
"201": {
"description": "Created",
"schema": {
- "$ref": "#/definitions/codersdk.MCPServerConfig"
+ "$ref": "#/definitions/codersdk.ChatModel"
}
}
},
@@ -1474,30 +1443,63 @@ const docTemplate = `{
}
}
},
- "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}": {
+ "/api/experimental/organizations/{organization}/chats/models/available": {
"get": {
+ "description": "Experimental: this endpoint is subject to change.",
"produces": [
"application/json"
],
"tags": [
- "MCP"
+ "Chats"
],
- "summary": "Get MCP server config",
- "operationId": "get-mcp-server-config",
+ "summary": "List available chat models in an organization",
+ "operationId": "list-chat-model-availability",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
+ "name": "organization",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.ChatModelAvailabilityResponse"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/experimental/organizations/{organization}/chats/models/{model}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Chats"
+ ],
+ "summary": "Get an AI model",
+ "operationId": "get-ai-model",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
+ "description": "Model ID",
+ "name": "model",
"in": "path",
"required": true
}
@@ -1506,7 +1508,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.MCPServerConfig"
+ "$ref": "#/definitions/codersdk.ChatModel"
}
}
},
@@ -1521,24 +1523,22 @@ const docTemplate = `{
},
"delete": {
"tags": [
- "MCP"
+ "Chats"
],
- "summary": "Delete MCP server config",
- "operationId": "delete-mcp-server-config",
+ "summary": "Delete an AI model",
+ "operationId": "delete-ai-model",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
+ "description": "Model ID",
+ "name": "model",
"in": "path",
"required": true
}
@@ -1565,34 +1565,32 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "MCP"
+ "Chats"
],
- "summary": "Update MCP server config",
- "operationId": "update-mcp-server-config",
+ "summary": "Update an AI model",
+ "operationId": "update-ai-model",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
+ "description": "Model ID",
+ "name": "model",
"in": "path",
"required": true
},
{
- "description": "Update MCP server config request",
+ "description": "Model updates",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.UpdateMCPServerConfigRequest"
+ "$ref": "#/definitions/codersdk.UpdateChatModelRequest"
}
}
],
@@ -1600,7 +1598,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.MCPServerConfig"
+ "$ref": "#/definitions/codersdk.ChatModel"
}
}
},
@@ -1614,21 +1612,20 @@ const docTemplate = `{
}
}
},
- "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl": {
+ "/api/experimental/organizations/{organization}/chats/models/{model}/acl": {
"get": {
"produces": [
"application/json"
],
"tags": [
- "MCP"
+ "Chats"
],
- "summary": "Get MCP server config ACL",
- "operationId": "get-mcp-server-config-acl",
+ "summary": "Get an AI model ACL",
+ "operationId": "get-ai-model-acl",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
@@ -1636,8 +1633,8 @@ const docTemplate = `{
{
"type": "string",
"format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
+ "description": "Model ID",
+ "name": "model",
"in": "path",
"required": true
}
@@ -1646,7 +1643,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.MCPServerConfigACL"
+ "$ref": "#/definitions/codersdk.ChatModelACL"
}
}
},
@@ -1664,15 +1661,14 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "MCP"
+ "Chats"
],
- "summary": "Update MCP server config ACL",
- "operationId": "update-mcp-server-config-acl",
+ "summary": "Update an AI model ACL",
+ "operationId": "update-ai-model-acl",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
@@ -1680,18 +1676,18 @@ const docTemplate = `{
{
"type": "string",
"format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
+ "description": "Model ID",
+ "name": "model",
"in": "path",
"required": true
},
{
- "description": "Update MCP server config ACL request",
+ "description": "Sparse model ACL update",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.UpdateMCPServerConfigACLRequest"
+ "$ref": "#/definitions/codersdk.UpdateChatModelACLRequest"
}
}
],
@@ -1710,13 +1706,16 @@ const docTemplate = `{
}
}
},
- "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": {
+ "/api/experimental/organizations/{organization}/mcp-servers": {
"get": {
+ "produces": [
+ "application/json"
+ ],
"tags": [
"MCP"
],
- "summary": "Initiate MCP server OAuth2 connect",
- "operationId": "initiate-mcp-server-oauth2-connect",
+ "summary": "List MCP server configs",
+ "operationId": "list-mcp-server-configs",
"parameters": [
{
"type": "string",
@@ -1725,48 +1724,6 @@ const docTemplate = `{
"name": "organization",
"in": "path",
"required": true
- },
- {
- "type": "string",
- "format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "307": {
- "description": "Temporary Redirect"
- }
- },
- "security": [
- {
- "CoderSessionToken": []
- }
- ],
- "x-apidocgen": {
- "skip": true
- }
- }
- },
- "/api/experimental/users/{user}/skills": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "Users"
- ],
- "summary": "List user skills",
- "operationId": "list-user-skills",
- "parameters": [
- {
- "type": "string",
- "description": "User ID, username, or me",
- "name": "user",
- "in": "path",
- "required": true
}
],
"responses": {
@@ -1775,7 +1732,7 @@ const docTemplate = `{
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.UserSkillMetadata"
+ "$ref": "#/definitions/codersdk.MCPServerConfig"
}
}
}
@@ -1797,25 +1754,26 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "Users"
+ "MCP"
],
- "summary": "Create a user skill",
- "operationId": "create-a-user-skill",
+ "summary": "Create MCP server config",
+ "operationId": "create-mcp-server-config",
"parameters": [
{
"type": "string",
- "description": "User ID, username, or me",
- "name": "user",
+ "format": "uuid",
+ "description": "Organization ID",
+ "name": "organization",
"in": "path",
"required": true
},
{
- "description": "Create user skill request",
+ "description": "Create MCP server config request",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.CreateUserSkillRequest"
+ "$ref": "#/definitions/codersdk.CreateMCPServerConfigRequest"
}
}
],
@@ -1823,7 +1781,7 @@ const docTemplate = `{
"201": {
"description": "Created",
"schema": {
- "$ref": "#/definitions/codersdk.UserSkill"
+ "$ref": "#/definitions/codersdk.MCPServerConfig"
}
}
},
@@ -1837,28 +1795,30 @@ const docTemplate = `{
}
}
},
- "/api/experimental/users/{user}/skills/{skillName}": {
+ "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}": {
"get": {
"produces": [
"application/json"
],
"tags": [
- "Users"
+ "MCP"
],
- "summary": "Get a user skill by name",
- "operationId": "get-a-user-skill-by-name",
+ "summary": "Get MCP server config",
+ "operationId": "get-mcp-server-config",
"parameters": [
{
"type": "string",
- "description": "User ID, username, or me",
- "name": "user",
+ "format": "uuid",
+ "description": "Organization ID",
+ "name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "description": "Skill name",
- "name": "skillName",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
"in": "path",
"required": true
}
@@ -1867,7 +1827,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.UserSkill"
+ "$ref": "#/definitions/codersdk.MCPServerConfig"
}
}
},
@@ -1882,22 +1842,24 @@ const docTemplate = `{
},
"delete": {
"tags": [
- "Users"
+ "MCP"
],
- "summary": "Delete a user skill",
- "operationId": "delete-a-user-skill",
+ "summary": "Delete MCP server config",
+ "operationId": "delete-mcp-server-config",
"parameters": [
{
"type": "string",
- "description": "User ID, username, or me",
- "name": "user",
+ "format": "uuid",
+ "description": "Organization ID",
+ "name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "description": "Skill name",
- "name": "skillName",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
"in": "path",
"required": true
}
@@ -1924,32 +1886,34 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "Users"
+ "MCP"
],
- "summary": "Update a user skill",
- "operationId": "update-a-user-skill",
+ "summary": "Update MCP server config",
+ "operationId": "update-mcp-server-config",
"parameters": [
{
"type": "string",
- "description": "User ID, username, or me",
- "name": "user",
+ "format": "uuid",
+ "description": "Organization ID",
+ "name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "description": "Skill name",
- "name": "skillName",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
"in": "path",
"required": true
},
{
- "description": "Update user skill request",
+ "description": "Update MCP server config request",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.UpdateUserSkillRequest"
+ "$ref": "#/definitions/codersdk.UpdateMCPServerConfigRequest"
}
}
],
@@ -1957,7 +1921,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.UserSkill"
+ "$ref": "#/definitions/codersdk.MCPServerConfig"
}
}
},
@@ -1971,19 +1935,40 @@ const docTemplate = `{
}
}
},
- "/api/experimental/watch-all-workspacebuilds": {
+ "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl": {
"get": {
"produces": [
"application/json"
],
"tags": [
- "Workspaces"
+ "MCP"
+ ],
+ "summary": "Get MCP server config ACL",
+ "operationId": "get-mcp-server-config-acl",
+ "parameters": [
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "Organization ID",
+ "name": "organization",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
+ "in": "path",
+ "required": true
+ }
],
- "summary": "Watch all workspace builds",
- "operationId": "watch-all-workspace-builds",
"responses": {
- "101": {
- "description": "Switching Protocols"
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.MCPServerConfigACL"
+ }
}
},
"security": [
@@ -1994,162 +1979,124 @@ const docTemplate = `{
"x-apidocgen": {
"skip": true
}
- }
- },
- "/api/v2/": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "General"
- ],
- "summary": "API root handler",
- "operationId": "api-root-handler",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/codersdk.Response"
- }
- }
- }
- }
- },
- "/api/v2/agent-firewall/sessions/{id}": {
- "get": {
- "produces": [
+ },
+ "patch": {
+ "consumes": [
"application/json"
],
"tags": [
- "Enterprise"
+ "MCP"
],
- "summary": "Get agent firewall session by ID",
- "operationId": "get-agent-firewall-session-by-id",
+ "summary": "Update MCP server config ACL",
+ "operationId": "update-mcp-server-config-acl",
"parameters": [
{
"type": "string",
"format": "uuid",
- "description": "Agent firewall session ID",
- "name": "id",
+ "description": "Organization ID",
+ "name": "organization",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
"in": "path",
"required": true
+ },
+ {
+ "description": "Update MCP server config ACL request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.UpdateMCPServerConfigACLRequest"
+ }
}
],
"responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/codersdk.AgentFirewallSession"
- }
+ "204": {
+ "description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
- ]
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
}
},
- "/api/v2/agent-firewall/sessions/{id}/logs": {
+ "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": {
"get": {
- "produces": [
- "application/json"
- ],
"tags": [
- "Enterprise"
+ "MCP"
],
- "summary": "Get agent firewall session logs",
- "operationId": "get-agent-firewall-session-logs",
+ "summary": "Initiate MCP server OAuth2 connect",
+ "operationId": "initiate-mcp-server-oauth2-connect",
"parameters": [
{
"type": "string",
"format": "uuid",
- "description": "Agent firewall session ID",
- "name": "id",
+ "description": "Organization ID",
+ "name": "organization",
"in": "path",
"required": true
},
{
- "type": "integer",
- "description": "Inclusive lower bound on sequence number",
- "name": "seq_after",
- "in": "query"
- },
- {
- "type": "integer",
- "description": "Exclusive upper bound on sequence number",
- "name": "seq_before",
- "in": "query"
- },
- {
- "type": "integer",
- "description": "Maximum number of logs to return (default 100)",
- "name": "limit",
- "in": "query"
+ "type": "string",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
+ "in": "path",
+ "required": true
}
],
"responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/codersdk.AgentFirewallSessionLogsResponse"
- }
+ "307": {
+ "description": "Temporary Redirect"
}
},
"security": [
{
"CoderSessionToken": []
}
- ]
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
}
},
- "/api/v2/ai-gateway/clients": {
+ "/api/experimental/users/{user}/skills": {
"get": {
- "description": "Alias: also available at /api/v2/aibridge/clients for backward compatibility.",
"produces": [
"application/json"
],
"tags": [
- "AI Gateway"
+ "Users"
],
- "summary": "List AI Gateway clients",
- "operationId": "list-ai-gateway-clients",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- }
- },
- "security": [
+ "summary": "List user skills",
+ "operationId": "list-user-skills",
+ "parameters": [
{
- "CoderSessionToken": []
+ "type": "string",
+ "description": "User ID, username, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
}
- ]
- }
- },
- "/api/v2/ai-gateway/keys": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "Enterprise"
],
- "summary": "List AI Gateway keys",
- "operationId": "list-ai-gateway-keys",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.AIGatewayKey"
+ "$ref": "#/definitions/codersdk.UserSkillMetadata"
}
}
}
@@ -2158,7 +2105,10 @@ const docTemplate = `{
{
"CoderSessionToken": []
}
- ]
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
},
"post": {
"consumes": [
@@ -2168,18 +2118,25 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "Enterprise"
+ "Users"
],
- "summary": "Create AI Gateway key",
- "operationId": "create-ai-gateway-key",
+ "summary": "Create a user skill",
+ "operationId": "create-a-user-skill",
"parameters": [
{
- "description": "Create AI Gateway key request",
+ "type": "string",
+ "description": "User ID, username, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Create user skill request",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest"
+ "$ref": "#/definitions/codersdk.CreateUserSkillRequest"
}
}
],
@@ -2187,7 +2144,7 @@ const docTemplate = `{
"201": {
"description": "Created",
"schema": {
- "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse"
+ "$ref": "#/definitions/codersdk.UserSkill"
}
}
},
@@ -2195,57 +2152,133 @@ const docTemplate = `{
{
"CoderSessionToken": []
}
- ]
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
}
},
- "/api/v2/ai-gateway/keys/{key}": {
- "delete": {
+ "/api/experimental/users/{user}/skills/{skillName}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
"tags": [
- "Enterprise"
+ "Users"
],
- "summary": "Delete AI Gateway key",
- "operationId": "delete-ai-gateway-key",
+ "summary": "Get a user skill by name",
+ "operationId": "get-a-user-skill-by-name",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Key ID",
- "name": "key",
+ "description": "User ID, username, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Skill name",
+ "name": "skillName",
"in": "path",
"required": true
}
],
"responses": {
- "204": {
- "description": "No Content"
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.UserSkill"
+ }
}
},
"security": [
{
"CoderSessionToken": []
}
- ]
- }
- },
- "/api/v2/ai-gateway/models": {
- "get": {
- "description": "Alias: also available at /api/v2/aibridge/models for backward compatibility.",
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
+ },
+ "delete": {
+ "tags": [
+ "Users"
+ ],
+ "summary": "Delete a user skill",
+ "operationId": "delete-a-user-skill",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "User ID, username, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Skill name",
+ "name": "skillName",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content"
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
+ },
+ "patch": {
+ "consumes": [
+ "application/json"
+ ],
"produces": [
"application/json"
],
"tags": [
- "AI Gateway"
+ "Users"
+ ],
+ "summary": "Update a user skill",
+ "operationId": "update-a-user-skill",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "User ID, username, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Skill name",
+ "name": "skillName",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Update user skill request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.UpdateUserSkillRequest"
+ }
+ }
],
- "summary": "List AI Gateway models",
- "operationId": "list-ai-gateway-models",
"responses": {
"200": {
"description": "OK",
"schema": {
- "type": "array",
- "items": {
- "type": "string"
- }
+ "$ref": "#/definitions/codersdk.UserSkill"
}
}
},
@@ -2253,16 +2286,22 @@ const docTemplate = `{
{
"CoderSessionToken": []
}
- ]
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
}
},
- "/api/v2/ai-gateway/serve": {
+ "/api/experimental/watch-all-workspacebuilds": {
"get": {
+ "produces": [
+ "application/json"
+ ],
"tags": [
- "Enterprise"
+ "Workspaces"
],
- "summary": "AI Gateway serve",
- "operationId": "ai-gateway-serve",
+ "summary": "Watch all workspace builds",
+ "operationId": "watch-all-workspace-builds",
"responses": {
"101": {
"description": "Switching Protocols"
@@ -2270,53 +2309,59 @@ const docTemplate = `{
},
"security": [
{
- "AIGatewayKey": []
+ "CoderSessionToken": []
}
- ]
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
}
},
- "/api/v2/ai-gateway/sessions": {
+ "/api/v2/": {
"get": {
- "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.",
"produces": [
"application/json"
],
"tags": [
- "AI Gateway"
+ "General"
],
- "summary": "List AI Gateway sessions",
- "operationId": "list-ai-gateway-sessions",
+ "summary": "API root handler",
+ "operationId": "api-root-handler",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/v2/agent-firewall/sessions/{id}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Enterprise"
+ ],
+ "summary": "Get agent firewall session by ID",
+ "operationId": "get-agent-firewall-session-by-id",
"parameters": [
{
"type": "string",
- "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.",
- "name": "q",
- "in": "query"
- },
- {
- "type": "integer",
- "description": "Page limit",
- "name": "limit",
- "in": "query"
- },
- {
- "type": "string",
- "description": "Cursor pagination after session ID (cannot be used with offset)",
- "name": "after_session_id",
- "in": "query"
- },
- {
- "type": "integer",
- "description": "Offset pagination (cannot be used with after_session_id)",
- "name": "offset",
- "in": "query"
+ "format": "uuid",
+ "description": "Agent firewall session ID",
+ "name": "id",
+ "in": "path",
+ "required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse"
+ "$ref": "#/definitions/codersdk.AgentFirewallSession"
}
}
},
@@ -2327,40 +2372,40 @@ const docTemplate = `{
]
}
},
- "/api/v2/ai-gateway/sessions/{session_id}": {
+ "/api/v2/agent-firewall/sessions/{id}/logs": {
"get": {
- "description": "Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility.",
"produces": [
"application/json"
],
"tags": [
- "AI Gateway"
+ "Enterprise"
],
- "summary": "Get AI Gateway session threads",
- "operationId": "get-ai-gateway-session-threads",
+ "summary": "Get agent firewall session logs",
+ "operationId": "get-agent-firewall-session-logs",
"parameters": [
{
"type": "string",
- "description": "Session ID (client_session_id or interception UUID)",
- "name": "session_id",
+ "format": "uuid",
+ "description": "Agent firewall session ID",
+ "name": "id",
"in": "path",
"required": true
},
{
- "type": "string",
- "description": "Thread pagination cursor (forward/older)",
- "name": "after_id",
+ "type": "integer",
+ "description": "Inclusive lower bound on sequence number",
+ "name": "seq_after",
"in": "query"
},
{
- "type": "string",
- "description": "Thread pagination cursor (backward/newer)",
- "name": "before_id",
+ "type": "integer",
+ "description": "Exclusive upper bound on sequence number",
+ "name": "seq_before",
"in": "query"
},
{
"type": "integer",
- "description": "Number of threads per page (default 50)",
+ "description": "Maximum number of logs to return (default 100)",
"name": "limit",
"in": "query"
}
@@ -2369,7 +2414,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse"
+ "$ref": "#/definitions/codersdk.AgentFirewallSessionLogsResponse"
}
}
},
@@ -2380,23 +2425,52 @@ const docTemplate = `{
]
}
},
- "/api/v2/ai/providers": {
+ "/api/v2/ai-gateway/clients": {
"get": {
+ "description": "Alias: also available at /api/v2/aibridge/clients for backward compatibility.",
"produces": [
"application/json"
],
"tags": [
- "AI Providers"
+ "AI Gateway"
],
- "summary": "List AI providers",
- "operationId": "list-ai-providers",
+ "summary": "List AI Gateway clients",
+ "operationId": "list-ai-gateway-clients",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.AIProvider"
+ "type": "string"
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai-gateway/keys": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Enterprise"
+ ],
+ "summary": "List AI Gateway keys",
+ "operationId": "list-ai-gateway-keys",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.AIGatewayKey"
}
}
}
@@ -2415,18 +2489,18 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "AI Providers"
+ "Enterprise"
],
- "summary": "Create an AI provider",
- "operationId": "create-an-ai-provider",
+ "summary": "Create AI Gateway key",
+ "operationId": "create-ai-gateway-key",
"parameters": [
{
- "description": "Create AI provider request",
+ "description": "Create AI Gateway key request",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.CreateAIProviderRequest"
+ "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest"
}
}
],
@@ -2434,7 +2508,7 @@ const docTemplate = `{
"201": {
"description": "Created",
"schema": {
- "$ref": "#/definitions/codersdk.AIProvider"
+ "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse"
}
}
},
@@ -2445,13 +2519,260 @@ const docTemplate = `{
]
}
},
- "/api/v2/ai/providers/{idOrName}": {
- "get": {
- "produces": [
- "application/json"
- ],
+ "/api/v2/ai-gateway/keys/{key}": {
+ "delete": {
"tags": [
- "AI Providers"
+ "Enterprise"
+ ],
+ "summary": "Delete AI Gateway key",
+ "operationId": "delete-ai-gateway-key",
+ "parameters": [
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "Key ID",
+ "name": "key",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content"
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai-gateway/models": {
+ "get": {
+ "description": "Alias: also available at /api/v2/aibridge/models for backward compatibility.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "AI Gateway"
+ ],
+ "summary": "List AI Gateway models",
+ "operationId": "list-ai-gateway-models",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai-gateway/serve": {
+ "get": {
+ "tags": [
+ "Enterprise"
+ ],
+ "summary": "AI Gateway serve",
+ "operationId": "ai-gateway-serve",
+ "responses": {
+ "101": {
+ "description": "Switching Protocols"
+ }
+ },
+ "security": [
+ {
+ "AIGatewayKey": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai-gateway/sessions": {
+ "get": {
+ "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "AI Gateway"
+ ],
+ "summary": "List AI Gateway sessions",
+ "operationId": "list-ai-gateway-sessions",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.",
+ "name": "q",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Page limit",
+ "name": "limit",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Cursor pagination after session ID (cannot be used with offset)",
+ "name": "after_session_id",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Offset pagination (cannot be used with after_session_id)",
+ "name": "offset",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai-gateway/sessions/{session_id}": {
+ "get": {
+ "description": "Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "AI Gateway"
+ ],
+ "summary": "Get AI Gateway session threads",
+ "operationId": "get-ai-gateway-session-threads",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Session ID (client_session_id or interception UUID)",
+ "name": "session_id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Thread pagination cursor (forward/older)",
+ "name": "after_id",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Thread pagination cursor (backward/newer)",
+ "name": "before_id",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Number of threads per page (default 50)",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai/providers": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "AI Providers"
+ ],
+ "summary": "List AI providers",
+ "operationId": "list-ai-providers",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.AIProvider"
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "AI Providers"
+ ],
+ "summary": "Create an AI provider",
+ "operationId": "create-an-ai-provider",
+ "parameters": [
+ {
+ "description": "Create AI provider request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.CreateAIProviderRequest"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/codersdk.AIProvider"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai/providers/{idOrName}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "AI Providers"
],
"summary": "Get an AI provider",
"operationId": "get-an-ai-provider",
@@ -18587,77 +18908,451 @@ const docTemplate = `{
"cache_creation_tokens": {
"type": "integer"
},
- "cache_read_tokens": {
- "type": "integer"
+ "cache_read_tokens": {
+ "type": "integer"
+ },
+ "context_limit": {
+ "type": "integer"
+ },
+ "input_tokens": {
+ "type": "integer"
+ },
+ "output_tokens": {
+ "type": "integer"
+ },
+ "reasoning_tokens": {
+ "type": "integer"
+ },
+ "total_tokens": {
+ "type": "integer"
+ }
+ }
+ },
+ "codersdk.ChatMessagesResponse": {
+ "type": "object",
+ "properties": {
+ "has_more": {
+ "type": "boolean"
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatMessage"
+ }
+ },
+ "queued_messages": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatQueuedMessage"
+ }
+ }
+ }
+ },
+ "codersdk.ChatModel": {
+ "type": "object",
+ "properties": {
+ "ai_provider_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "compression_threshold": {
+ "type": "integer"
+ },
+ "context_limit": {
+ "type": "integer"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "display_name": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "is_default": {
+ "type": "boolean"
+ },
+ "model": {
+ "type": "string"
+ },
+ "model_config": {
+ "$ref": "#/definitions/codersdk.ChatModelCallConfig"
+ },
+ "organization_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "reasoning_efforts": {
+ "description": "ReasoningEfforts lists selectable reasoning effort values through\nthe model's configured maximum.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "codersdk.ChatModelACL": {
+ "type": "object",
+ "properties": {
+ "group_roles": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/codersdk.ChatRole"
+ }
+ },
+ "user_roles": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/codersdk.ChatRole"
+ }
+ }
+ }
+ },
+ "codersdk.ChatModelAnthropicProviderOptions": {
+ "type": "object",
+ "properties": {
+ "allowed_domains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "blocked_domains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "context_1m_enabled": {
+ "type": "boolean"
+ },
+ "disable_parallel_tool_use": {
+ "type": "boolean"
+ },
+ "send_reasoning": {
+ "type": "boolean"
+ },
+ "thinking": {
+ "$ref": "#/definitions/codersdk.ChatModelAnthropicThinkingOptions"
+ },
+ "thinking_display": {
+ "type": "string"
+ },
+ "web_search_enabled": {
+ "type": "boolean"
+ }
+ }
+ },
+ "codersdk.ChatModelAnthropicThinkingOptions": {
+ "type": "object",
+ "properties": {
+ "budget_tokens": {
+ "type": "integer"
+ }
+ }
+ },
+ "codersdk.ChatModelAvailabilityResponse": {
+ "type": "object",
+ "properties": {
+ "models": {
+ "description": "Models contains the effective runtime model configs for the requested\norganization. Each config belongs to that organization.",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatModel"
+ }
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatModelProvider"
+ }
+ },
+ "unsupported_providers": {
+ "description": "UnsupportedProviders lists configured providers the Agents harness\ncannot use, so the UI can explain the empty state.",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatUnsupportedProvider"
+ }
+ }
+ }
+ },
+ "codersdk.ChatModelCallConfig": {
+ "type": "object",
+ "properties": {
+ "frequency_penalty": {
+ "type": "number"
+ },
+ "max_output_tokens": {
+ "type": "integer"
+ },
+ "openai_config": {
+ "$ref": "#/definitions/codersdk.ChatModelOpenAIConfig"
+ },
+ "presence_penalty": {
+ "type": "number"
+ },
+ "provider_options": {
+ "$ref": "#/definitions/codersdk.ChatModelProviderOptions"
+ },
+ "reasoning_effort": {
+ "$ref": "#/definitions/codersdk.ChatModelReasoningEffortConfig"
+ },
+ "temperature": {
+ "type": "number"
+ },
+ "top_k": {
+ "type": "integer"
+ },
+ "top_p": {
+ "type": "number"
+ }
+ }
+ },
+ "codersdk.ChatModelCatalogEntry": {
+ "type": "object",
+ "properties": {
+ "display_name": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "model": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelGoogleProviderOptions": {
+ "type": "object",
+ "properties": {
+ "cached_content": {
+ "type": "string"
+ },
+ "safety_settings": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatModelGoogleSafetySetting"
+ }
+ },
+ "thinking_config": {
+ "$ref": "#/definitions/codersdk.ChatModelGoogleThinkingConfig"
+ },
+ "threshold": {
+ "type": "string"
+ },
+ "web_search_enabled": {
+ "type": "boolean"
+ }
+ }
+ },
+ "codersdk.ChatModelGoogleSafetySetting": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string"
+ },
+ "threshold": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelGoogleThinkingConfig": {
+ "type": "object",
+ "properties": {
+ "include_thoughts": {
+ "type": "boolean"
+ },
+ "thinking_budget": {
+ "type": "integer"
+ },
+ "thinking_level": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelOpenAICompatProviderOptions": {
+ "type": "object",
+ "properties": {
+ "user": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelOpenAIConfig": {
+ "type": "object",
+ "properties": {
+ "use_responses_api": {
+ "type": "boolean"
+ }
+ }
+ },
+ "codersdk.ChatModelOpenAIProviderOptions": {
+ "type": "object",
+ "properties": {
+ "allowed_domains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "include": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "instructions": {
+ "type": "string"
+ },
+ "log_probs": {
+ "type": "boolean"
+ },
+ "logit_bias": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ "max_completion_tokens": {
+ "type": "integer"
+ },
+ "max_tool_calls": {
+ "type": "integer"
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "parallel_tool_calls": {
+ "type": "boolean"
+ },
+ "prediction": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "prompt_cache_key": {
+ "type": "string"
+ },
+ "reasoning_summary": {
+ "type": "string"
+ },
+ "safety_identifier": {
+ "type": "string"
+ },
+ "search_context_size": {
+ "type": "string"
},
- "context_limit": {
- "type": "integer"
+ "service_tier": {
+ "type": "string"
},
- "input_tokens": {
- "type": "integer"
+ "store": {
+ "type": "boolean"
},
- "output_tokens": {
- "type": "integer"
+ "strict_json_schema": {
+ "type": "boolean"
},
- "reasoning_tokens": {
- "type": "integer"
+ "structured_outputs": {
+ "type": "boolean"
},
- "total_tokens": {
+ "text_verbosity": {
+ "type": "string"
+ },
+ "top_log_probs": {
"type": "integer"
+ },
+ "user": {
+ "type": "string"
+ },
+ "web_search_enabled": {
+ "type": "boolean"
}
}
},
- "codersdk.ChatMessagesResponse": {
+ "codersdk.ChatModelOpenRouterProvider": {
"type": "object",
"properties": {
- "has_more": {
+ "allow_fallbacks": {
"type": "boolean"
},
- "messages": {
+ "data_collection": {
+ "type": "string"
+ },
+ "ignore": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.ChatMessage"
+ "type": "string"
}
},
- "queued_messages": {
+ "only": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.ChatQueuedMessage"
+ "type": "string"
}
- }
- }
- },
- "codersdk.ChatModelAvailabilityResponse": {
- "type": "object",
- "properties": {
- "providers": {
+ },
+ "order": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.ChatModelProvider"
+ "type": "string"
}
},
- "unsupported_providers": {
- "description": "UnsupportedProviders lists configured providers the Agents harness\ncannot use, so the UI can explain the empty state.",
+ "quantizations": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.ChatUnsupportedProvider"
+ "type": "string"
}
+ },
+ "require_parameters": {
+ "type": "boolean"
+ },
+ "sort": {
+ "type": "string"
}
}
},
- "codersdk.ChatModelCatalogEntry": {
+ "codersdk.ChatModelOpenRouterProviderOptions": {
"type": "object",
"properties": {
- "display_name": {
- "type": "string"
+ "extra_body": {
+ "type": "object",
+ "additionalProperties": {}
},
- "id": {
- "type": "string"
+ "include_usage": {
+ "type": "boolean"
},
- "model": {
- "type": "string"
+ "log_probs": {
+ "type": "boolean"
+ },
+ "logit_bias": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ "parallel_tool_calls": {
+ "type": "boolean"
},
"provider": {
+ "$ref": "#/definitions/codersdk.ChatModelOpenRouterProvider"
+ },
+ "reasoning": {
+ "$ref": "#/definitions/codersdk.ChatModelReasoningOptions"
+ },
+ "user": {
"type": "string"
}
}
@@ -18682,6 +19377,62 @@ const docTemplate = `{
}
}
},
+ "codersdk.ChatModelProviderDescriptor": {
+ "type": "object",
+ "properties": {
+ "allow_user_api_key": {
+ "type": "boolean"
+ },
+ "display_name": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "has_api_key": {
+ "type": "boolean"
+ },
+ "has_effective_api_key": {
+ "type": "boolean"
+ },
+ "has_user_api_key": {
+ "type": "boolean"
+ },
+ "icon": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelProviderOptions": {
+ "type": "object",
+ "properties": {
+ "anthropic": {
+ "$ref": "#/definitions/codersdk.ChatModelAnthropicProviderOptions"
+ },
+ "google": {
+ "$ref": "#/definitions/codersdk.ChatModelGoogleProviderOptions"
+ },
+ "openai": {
+ "$ref": "#/definitions/codersdk.ChatModelOpenAIProviderOptions"
+ },
+ "openaicompat": {
+ "$ref": "#/definitions/codersdk.ChatModelOpenAICompatProviderOptions"
+ },
+ "openrouter": {
+ "$ref": "#/definitions/codersdk.ChatModelOpenRouterProviderOptions"
+ },
+ "vercel": {
+ "$ref": "#/definitions/codersdk.ChatModelVercelProviderOptions"
+ }
+ }
+ },
"codersdk.ChatModelProviderUnavailableReason": {
"type": "string",
"enum": [
@@ -18695,6 +19446,82 @@ const docTemplate = `{
"ChatModelProviderUnavailableReasonUserAPIKeyRequired"
]
},
+ "codersdk.ChatModelReasoningEffortConfig": {
+ "type": "object",
+ "properties": {
+ "default": {
+ "type": "string"
+ },
+ "max": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelReasoningOptions": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "exclude": {
+ "type": "boolean"
+ },
+ "max_tokens": {
+ "type": "integer"
+ }
+ }
+ },
+ "codersdk.ChatModelVercelGatewayProviderOptions": {
+ "type": "object",
+ "properties": {
+ "models": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "order": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "codersdk.ChatModelVercelProviderOptions": {
+ "type": "object",
+ "properties": {
+ "extra_body": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "logit_bias": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ "logprobs": {
+ "type": "boolean"
+ },
+ "parallel_tool_calls": {
+ "type": "boolean"
+ },
+ "providerOptions": {
+ "$ref": "#/definitions/codersdk.ChatModelVercelGatewayProviderOptions"
+ },
+ "reasoning": {
+ "$ref": "#/definitions/codersdk.ChatModelReasoningOptions"
+ },
+ "top_logprobs": {
+ "type": "integer"
+ },
+ "user": {
+ "type": "string"
+ }
+ }
+ },
"codersdk.ChatPlanMode": {
"type": "string",
"enum": [
@@ -19342,6 +20169,36 @@ const docTemplate = `{
}
}
},
+ "codersdk.CreateChatModelRequest": {
+ "type": "object",
+ "properties": {
+ "ai_provider_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "compression_threshold": {
+ "type": "integer"
+ },
+ "context_limit": {
+ "type": "integer"
+ },
+ "display_name": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "is_default": {
+ "type": "boolean"
+ },
+ "model": {
+ "type": "string"
+ },
+ "model_config": {
+ "$ref": "#/definitions/codersdk.ChatModelCallConfig"
+ }
+ }
+ },
"codersdk.CreateChatRequest": {
"type": "object",
"properties": {
@@ -23246,6 +24103,23 @@ const docTemplate = `{
}
}
},
+ "codersdk.OrganizationChatModelsResponse": {
+ "type": "object",
+ "properties": {
+ "models": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatModel"
+ }
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatModelProviderDescriptor"
+ }
+ }
+ }
+ },
"codersdk.OrganizationGroupAISpend": {
"type": "object",
"properties": {
@@ -26652,6 +27526,53 @@ const docTemplate = `{
}
}
},
+ "codersdk.UpdateChatModelACLRequest": {
+ "type": "object",
+ "properties": {
+ "group_roles": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/codersdk.ChatRole"
+ }
+ },
+ "user_roles": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/codersdk.ChatRole"
+ }
+ }
+ }
+ },
+ "codersdk.UpdateChatModelRequest": {
+ "type": "object",
+ "properties": {
+ "ai_provider_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "compression_threshold": {
+ "type": "integer"
+ },
+ "context_limit": {
+ "type": "integer"
+ },
+ "display_name": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "is_default": {
+ "type": "boolean"
+ },
+ "model": {
+ "type": "string"
+ },
+ "model_config": {
+ "$ref": "#/definitions/codersdk.ChatModelCallConfig"
+ }
+ }
+ },
"codersdk.UpdateChatRequest": {
"type": "object",
"properties": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 039748a88c258..77033188ce838 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -425,28 +425,6 @@
}
}
},
- "/api/experimental/chats/models": {
- "get": {
- "description": "Experimental: this endpoint is subject to change.",
- "produces": ["application/json"],
- "tags": ["Chats"],
- "summary": "List chat models",
- "operationId": "list-chat-models",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/codersdk.ChatModelAvailabilityResponse"
- }
- }
- },
- "security": [
- {
- "CoderSessionToken": []
- }
- ]
- }
- },
"/api/experimental/chats/watch": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -1232,17 +1210,16 @@
}
}
},
- "/api/experimental/organizations/{organization}/mcp-servers": {
+ "/api/experimental/organizations/{organization}/chats/models": {
"get": {
"produces": ["application/json"],
- "tags": ["MCP"],
- "summary": "List MCP server configs",
- "operationId": "list-mcp-server-configs",
+ "tags": ["Chats"],
+ "summary": "List AI models and provider descriptors in an organization",
+ "operationId": "list-ai-models-by-organization",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
@@ -1252,10 +1229,7 @@
"200": {
"description": "OK",
"schema": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/codersdk.MCPServerConfig"
- }
+ "$ref": "#/definitions/codersdk.OrganizationChatModelsResponse"
}
}
},
@@ -1271,25 +1245,24 @@
"post": {
"consumes": ["application/json"],
"produces": ["application/json"],
- "tags": ["MCP"],
- "summary": "Create MCP server config",
- "operationId": "create-mcp-server-config",
+ "tags": ["Chats"],
+ "summary": "Create an AI model in an organization",
+ "operationId": "create-ai-model",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
- "description": "Create MCP server config request",
+ "description": "Model",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.CreateMCPServerConfigRequest"
+ "$ref": "#/definitions/codersdk.CreateChatModelRequest"
}
}
],
@@ -1297,7 +1270,7 @@
"201": {
"description": "Created",
"schema": {
- "$ref": "#/definitions/codersdk.MCPServerConfig"
+ "$ref": "#/definitions/codersdk.ChatModel"
}
}
},
@@ -1311,26 +1284,55 @@
}
}
},
- "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}": {
+ "/api/experimental/organizations/{organization}/chats/models/available": {
"get": {
+ "description": "Experimental: this endpoint is subject to change.",
"produces": ["application/json"],
- "tags": ["MCP"],
- "summary": "Get MCP server config",
- "operationId": "get-mcp-server-config",
+ "tags": ["Chats"],
+ "summary": "List available chat models in an organization",
+ "operationId": "list-chat-model-availability",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
+ "name": "organization",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.ChatModelAvailabilityResponse"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/experimental/organizations/{organization}/chats/models/{model}": {
+ "get": {
+ "produces": ["application/json"],
+ "tags": ["Chats"],
+ "summary": "Get an AI model",
+ "operationId": "get-ai-model",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
+ "description": "Model ID",
+ "name": "model",
"in": "path",
"required": true
}
@@ -1339,7 +1341,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.MCPServerConfig"
+ "$ref": "#/definitions/codersdk.ChatModel"
}
}
},
@@ -1353,23 +1355,21 @@
}
},
"delete": {
- "tags": ["MCP"],
- "summary": "Delete MCP server config",
- "operationId": "delete-mcp-server-config",
+ "tags": ["Chats"],
+ "summary": "Delete an AI model",
+ "operationId": "delete-ai-model",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
+ "description": "Model ID",
+ "name": "model",
"in": "path",
"required": true
}
@@ -1391,33 +1391,31 @@
"patch": {
"consumes": ["application/json"],
"produces": ["application/json"],
- "tags": ["MCP"],
- "summary": "Update MCP server config",
- "operationId": "update-mcp-server-config",
+ "tags": ["Chats"],
+ "summary": "Update an AI model",
+ "operationId": "update-ai-model",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
+ "description": "Model ID",
+ "name": "model",
"in": "path",
"required": true
},
{
- "description": "Update MCP server config request",
+ "description": "Model updates",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.UpdateMCPServerConfigRequest"
+ "$ref": "#/definitions/codersdk.UpdateChatModelRequest"
}
}
],
@@ -1425,7 +1423,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.MCPServerConfig"
+ "$ref": "#/definitions/codersdk.ChatModel"
}
}
},
@@ -1439,17 +1437,16 @@
}
}
},
- "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl": {
+ "/api/experimental/organizations/{organization}/chats/models/{model}/acl": {
"get": {
"produces": ["application/json"],
- "tags": ["MCP"],
- "summary": "Get MCP server config ACL",
- "operationId": "get-mcp-server-config-acl",
+ "tags": ["Chats"],
+ "summary": "Get an AI model ACL",
+ "operationId": "get-ai-model-acl",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
@@ -1457,8 +1454,8 @@
{
"type": "string",
"format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
+ "description": "Model ID",
+ "name": "model",
"in": "path",
"required": true
}
@@ -1467,7 +1464,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.MCPServerConfigACL"
+ "$ref": "#/definitions/codersdk.ChatModelACL"
}
}
},
@@ -1482,14 +1479,13 @@
},
"patch": {
"consumes": ["application/json"],
- "tags": ["MCP"],
- "summary": "Update MCP server config ACL",
- "operationId": "update-mcp-server-config-acl",
+ "tags": ["Chats"],
+ "summary": "Update an AI model ACL",
+ "operationId": "update-ai-model-acl",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Organization ID",
+ "description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
@@ -1497,18 +1493,18 @@
{
"type": "string",
"format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
+ "description": "Model ID",
+ "name": "model",
"in": "path",
"required": true
},
{
- "description": "Update MCP server config ACL request",
+ "description": "Sparse model ACL update",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.UpdateMCPServerConfigACLRequest"
+ "$ref": "#/definitions/codersdk.UpdateChatModelACLRequest"
}
}
],
@@ -1527,11 +1523,12 @@
}
}
},
- "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": {
+ "/api/experimental/organizations/{organization}/mcp-servers": {
"get": {
+ "produces": ["application/json"],
"tags": ["MCP"],
- "summary": "Initiate MCP server OAuth2 connect",
- "operationId": "initiate-mcp-server-oauth2-connect",
+ "summary": "List MCP server configs",
+ "operationId": "list-mcp-server-configs",
"parameters": [
{
"type": "string",
@@ -1540,44 +1537,6 @@
"name": "organization",
"in": "path",
"required": true
- },
- {
- "type": "string",
- "format": "uuid",
- "description": "MCP server config ID",
- "name": "mcpserverconfig",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "307": {
- "description": "Temporary Redirect"
- }
- },
- "security": [
- {
- "CoderSessionToken": []
- }
- ],
- "x-apidocgen": {
- "skip": true
- }
- }
- },
- "/api/experimental/users/{user}/skills": {
- "get": {
- "produces": ["application/json"],
- "tags": ["Users"],
- "summary": "List user skills",
- "operationId": "list-user-skills",
- "parameters": [
- {
- "type": "string",
- "description": "User ID, username, or me",
- "name": "user",
- "in": "path",
- "required": true
}
],
"responses": {
@@ -1586,7 +1545,7 @@
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.UserSkillMetadata"
+ "$ref": "#/definitions/codersdk.MCPServerConfig"
}
}
}
@@ -1603,24 +1562,25 @@
"post": {
"consumes": ["application/json"],
"produces": ["application/json"],
- "tags": ["Users"],
- "summary": "Create a user skill",
- "operationId": "create-a-user-skill",
+ "tags": ["MCP"],
+ "summary": "Create MCP server config",
+ "operationId": "create-mcp-server-config",
"parameters": [
{
"type": "string",
- "description": "User ID, username, or me",
- "name": "user",
+ "format": "uuid",
+ "description": "Organization ID",
+ "name": "organization",
"in": "path",
"required": true
},
{
- "description": "Create user skill request",
+ "description": "Create MCP server config request",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.CreateUserSkillRequest"
+ "$ref": "#/definitions/codersdk.CreateMCPServerConfigRequest"
}
}
],
@@ -1628,7 +1588,7 @@
"201": {
"description": "Created",
"schema": {
- "$ref": "#/definitions/codersdk.UserSkill"
+ "$ref": "#/definitions/codersdk.MCPServerConfig"
}
}
},
@@ -1642,24 +1602,26 @@
}
}
},
- "/api/experimental/users/{user}/skills/{skillName}": {
+ "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}": {
"get": {
"produces": ["application/json"],
- "tags": ["Users"],
- "summary": "Get a user skill by name",
- "operationId": "get-a-user-skill-by-name",
+ "tags": ["MCP"],
+ "summary": "Get MCP server config",
+ "operationId": "get-mcp-server-config",
"parameters": [
{
"type": "string",
- "description": "User ID, username, or me",
- "name": "user",
+ "format": "uuid",
+ "description": "Organization ID",
+ "name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "description": "Skill name",
- "name": "skillName",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
"in": "path",
"required": true
}
@@ -1668,7 +1630,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.UserSkill"
+ "$ref": "#/definitions/codersdk.MCPServerConfig"
}
}
},
@@ -1682,21 +1644,23 @@
}
},
"delete": {
- "tags": ["Users"],
- "summary": "Delete a user skill",
- "operationId": "delete-a-user-skill",
+ "tags": ["MCP"],
+ "summary": "Delete MCP server config",
+ "operationId": "delete-mcp-server-config",
"parameters": [
{
"type": "string",
- "description": "User ID, username, or me",
- "name": "user",
+ "format": "uuid",
+ "description": "Organization ID",
+ "name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "description": "Skill name",
- "name": "skillName",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
"in": "path",
"required": true
}
@@ -1718,31 +1682,33 @@
"patch": {
"consumes": ["application/json"],
"produces": ["application/json"],
- "tags": ["Users"],
- "summary": "Update a user skill",
- "operationId": "update-a-user-skill",
+ "tags": ["MCP"],
+ "summary": "Update MCP server config",
+ "operationId": "update-mcp-server-config",
"parameters": [
{
"type": "string",
- "description": "User ID, username, or me",
- "name": "user",
+ "format": "uuid",
+ "description": "Organization ID",
+ "name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
- "description": "Skill name",
- "name": "skillName",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
"in": "path",
"required": true
},
{
- "description": "Update user skill request",
+ "description": "Update MCP server config request",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.UpdateUserSkillRequest"
+ "$ref": "#/definitions/codersdk.UpdateMCPServerConfigRequest"
}
}
],
@@ -1750,7 +1716,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.UserSkill"
+ "$ref": "#/definitions/codersdk.MCPServerConfig"
}
}
},
@@ -1764,55 +1730,26 @@
}
}
},
- "/api/experimental/watch-all-workspacebuilds": {
+ "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl": {
"get": {
"produces": ["application/json"],
- "tags": ["Workspaces"],
- "summary": "Watch all workspace builds",
- "operationId": "watch-all-workspace-builds",
- "responses": {
- "101": {
- "description": "Switching Protocols"
- }
- },
- "security": [
- {
- "CoderSessionToken": []
- }
- ],
- "x-apidocgen": {
- "skip": true
- }
- }
- },
- "/api/v2/": {
- "get": {
- "produces": ["application/json"],
- "tags": ["General"],
- "summary": "API root handler",
- "operationId": "api-root-handler",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/codersdk.Response"
- }
- }
- }
- }
- },
- "/api/v2/agent-firewall/sessions/{id}": {
- "get": {
- "produces": ["application/json"],
- "tags": ["Enterprise"],
- "summary": "Get agent firewall session by ID",
- "operationId": "get-agent-firewall-session-by-id",
+ "tags": ["MCP"],
+ "summary": "Get MCP server config ACL",
+ "operationId": "get-mcp-server-config-acl",
"parameters": [
{
"type": "string",
"format": "uuid",
- "description": "Agent firewall session ID",
- "name": "id",
+ "description": "Organization ID",
+ "name": "organization",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
"in": "path",
"required": true
}
@@ -1821,7 +1758,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.AgentFirewallSession"
+ "$ref": "#/definitions/codersdk.MCPServerConfigACL"
}
}
},
@@ -1829,96 +1766,118 @@
{
"CoderSessionToken": []
}
- ]
- }
- },
- "/api/v2/agent-firewall/sessions/{id}/logs": {
- "get": {
- "produces": ["application/json"],
- "tags": ["Enterprise"],
- "summary": "Get agent firewall session logs",
- "operationId": "get-agent-firewall-session-logs",
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
+ },
+ "patch": {
+ "consumes": ["application/json"],
+ "tags": ["MCP"],
+ "summary": "Update MCP server config ACL",
+ "operationId": "update-mcp-server-config-acl",
"parameters": [
{
"type": "string",
"format": "uuid",
- "description": "Agent firewall session ID",
- "name": "id",
+ "description": "Organization ID",
+ "name": "organization",
"in": "path",
"required": true
},
{
- "type": "integer",
- "description": "Inclusive lower bound on sequence number",
- "name": "seq_after",
- "in": "query"
- },
- {
- "type": "integer",
- "description": "Exclusive upper bound on sequence number",
- "name": "seq_before",
- "in": "query"
+ "type": "string",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
+ "in": "path",
+ "required": true
},
{
- "type": "integer",
- "description": "Maximum number of logs to return (default 100)",
- "name": "limit",
- "in": "query"
+ "description": "Update MCP server config ACL request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.UpdateMCPServerConfigACLRequest"
+ }
}
],
"responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/codersdk.AgentFirewallSessionLogsResponse"
- }
+ "204": {
+ "description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
- ]
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
}
},
- "/api/v2/ai-gateway/clients": {
+ "/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": {
"get": {
- "description": "Alias: also available at /api/v2/aibridge/clients for backward compatibility.",
- "produces": ["application/json"],
- "tags": ["AI Gateway"],
- "summary": "List AI Gateway clients",
- "operationId": "list-ai-gateway-clients",
+ "tags": ["MCP"],
+ "summary": "Initiate MCP server OAuth2 connect",
+ "operationId": "initiate-mcp-server-oauth2-connect",
+ "parameters": [
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "Organization ID",
+ "name": "organization",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "MCP server config ID",
+ "name": "mcpserverconfig",
+ "in": "path",
+ "required": true
+ }
+ ],
"responses": {
- "200": {
- "description": "OK",
- "schema": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
+ "307": {
+ "description": "Temporary Redirect"
}
},
"security": [
{
"CoderSessionToken": []
}
- ]
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
}
},
- "/api/v2/ai-gateway/keys": {
+ "/api/experimental/users/{user}/skills": {
"get": {
"produces": ["application/json"],
- "tags": ["Enterprise"],
- "summary": "List AI Gateway keys",
- "operationId": "list-ai-gateway-keys",
+ "tags": ["Users"],
+ "summary": "List user skills",
+ "operationId": "list-user-skills",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "User ID, username, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
+ }
+ ],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.AIGatewayKey"
+ "$ref": "#/definitions/codersdk.UserSkillMetadata"
}
}
}
@@ -1927,22 +1886,32 @@
{
"CoderSessionToken": []
}
- ]
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
},
"post": {
"consumes": ["application/json"],
"produces": ["application/json"],
- "tags": ["Enterprise"],
- "summary": "Create AI Gateway key",
- "operationId": "create-ai-gateway-key",
+ "tags": ["Users"],
+ "summary": "Create a user skill",
+ "operationId": "create-a-user-skill",
"parameters": [
{
- "description": "Create AI Gateway key request",
+ "type": "string",
+ "description": "User ID, username, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Create user skill request",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest"
+ "$ref": "#/definitions/codersdk.CreateUserSkillRequest"
}
}
],
@@ -1950,7 +1919,7 @@
"201": {
"description": "Created",
"schema": {
- "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse"
+ "$ref": "#/definitions/codersdk.UserSkill"
}
}
},
@@ -1958,51 +1927,39 @@
{
"CoderSessionToken": []
}
- ]
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
}
},
- "/api/v2/ai-gateway/keys/{key}": {
- "delete": {
- "tags": ["Enterprise"],
- "summary": "Delete AI Gateway key",
- "operationId": "delete-ai-gateway-key",
+ "/api/experimental/users/{user}/skills/{skillName}": {
+ "get": {
+ "produces": ["application/json"],
+ "tags": ["Users"],
+ "summary": "Get a user skill by name",
+ "operationId": "get-a-user-skill-by-name",
"parameters": [
{
"type": "string",
- "format": "uuid",
- "description": "Key ID",
- "name": "key",
+ "description": "User ID, username, or me",
+ "name": "user",
"in": "path",
"required": true
- }
- ],
- "responses": {
- "204": {
- "description": "No Content"
- }
- },
- "security": [
+ },
{
- "CoderSessionToken": []
+ "type": "string",
+ "description": "Skill name",
+ "name": "skillName",
+ "in": "path",
+ "required": true
}
- ]
- }
- },
- "/api/v2/ai-gateway/models": {
- "get": {
- "description": "Alias: also available at /api/v2/aibridge/models for backward compatibility.",
- "produces": ["application/json"],
- "tags": ["AI Gateway"],
- "summary": "List AI Gateway models",
- "operationId": "list-ai-gateway-models",
+ ],
"responses": {
"200": {
"description": "OK",
"schema": {
- "type": "array",
- "items": {
- "type": "string"
- }
+ "$ref": "#/definitions/codersdk.UserSkill"
}
}
},
@@ -2010,64 +1967,152 @@
{
"CoderSessionToken": []
}
- ]
- }
- },
- "/api/v2/ai-gateway/serve": {
- "get": {
- "tags": ["Enterprise"],
- "summary": "AI Gateway serve",
- "operationId": "ai-gateway-serve",
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
+ },
+ "delete": {
+ "tags": ["Users"],
+ "summary": "Delete a user skill",
+ "operationId": "delete-a-user-skill",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "User ID, username, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Skill name",
+ "name": "skillName",
+ "in": "path",
+ "required": true
+ }
+ ],
"responses": {
- "101": {
- "description": "Switching Protocols"
+ "204": {
+ "description": "No Content"
}
},
"security": [
{
- "AIGatewayKey": []
+ "CoderSessionToken": []
}
- ]
- }
- },
- "/api/v2/ai-gateway/sessions": {
- "get": {
- "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.",
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
+ },
+ "patch": {
+ "consumes": ["application/json"],
"produces": ["application/json"],
- "tags": ["AI Gateway"],
- "summary": "List AI Gateway sessions",
- "operationId": "list-ai-gateway-sessions",
+ "tags": ["Users"],
+ "summary": "Update a user skill",
+ "operationId": "update-a-user-skill",
"parameters": [
{
"type": "string",
- "description": "Search query in the format `key:value`. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.",
- "name": "q",
- "in": "query"
- },
- {
- "type": "integer",
- "description": "Page limit",
- "name": "limit",
- "in": "query"
+ "description": "User ID, username, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
},
{
"type": "string",
- "description": "Cursor pagination after session ID (cannot be used with offset)",
- "name": "after_session_id",
- "in": "query"
+ "description": "Skill name",
+ "name": "skillName",
+ "in": "path",
+ "required": true
},
{
- "type": "integer",
- "description": "Offset pagination (cannot be used with after_session_id)",
- "name": "offset",
- "in": "query"
+ "description": "Update user skill request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.UpdateUserSkillRequest"
+ }
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse"
+ "$ref": "#/definitions/codersdk.UserSkill"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
+ }
+ },
+ "/api/experimental/watch-all-workspacebuilds": {
+ "get": {
+ "produces": ["application/json"],
+ "tags": ["Workspaces"],
+ "summary": "Watch all workspace builds",
+ "operationId": "watch-all-workspace-builds",
+ "responses": {
+ "101": {
+ "description": "Switching Protocols"
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "x-apidocgen": {
+ "skip": true
+ }
+ }
+ },
+ "/api/v2/": {
+ "get": {
+ "produces": ["application/json"],
+ "tags": ["General"],
+ "summary": "API root handler",
+ "operationId": "api-root-handler",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/v2/agent-firewall/sessions/{id}": {
+ "get": {
+ "produces": ["application/json"],
+ "tags": ["Enterprise"],
+ "summary": "Get agent firewall session by ID",
+ "operationId": "get-agent-firewall-session-by-id",
+ "parameters": [
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "Agent firewall session ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.AgentFirewallSession"
}
}
},
@@ -2078,36 +2123,36 @@
]
}
},
- "/api/v2/ai-gateway/sessions/{session_id}": {
+ "/api/v2/agent-firewall/sessions/{id}/logs": {
"get": {
- "description": "Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility.",
"produces": ["application/json"],
- "tags": ["AI Gateway"],
- "summary": "Get AI Gateway session threads",
- "operationId": "get-ai-gateway-session-threads",
+ "tags": ["Enterprise"],
+ "summary": "Get agent firewall session logs",
+ "operationId": "get-agent-firewall-session-logs",
"parameters": [
{
"type": "string",
- "description": "Session ID (client_session_id or interception UUID)",
- "name": "session_id",
+ "format": "uuid",
+ "description": "Agent firewall session ID",
+ "name": "id",
"in": "path",
"required": true
},
{
- "type": "string",
- "description": "Thread pagination cursor (forward/older)",
- "name": "after_id",
+ "type": "integer",
+ "description": "Inclusive lower bound on sequence number",
+ "name": "seq_after",
"in": "query"
},
{
- "type": "string",
- "description": "Thread pagination cursor (backward/newer)",
- "name": "before_id",
+ "type": "integer",
+ "description": "Exclusive upper bound on sequence number",
+ "name": "seq_before",
"in": "query"
},
{
"type": "integer",
- "description": "Number of threads per page (default 50)",
+ "description": "Maximum number of logs to return (default 100)",
"name": "limit",
"in": "query"
}
@@ -2116,7 +2161,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse"
+ "$ref": "#/definitions/codersdk.AgentFirewallSessionLogsResponse"
}
}
},
@@ -2127,19 +2172,44 @@
]
}
},
- "/api/v2/ai/providers": {
+ "/api/v2/ai-gateway/clients": {
"get": {
+ "description": "Alias: also available at /api/v2/aibridge/clients for backward compatibility.",
"produces": ["application/json"],
- "tags": ["AI Providers"],
- "summary": "List AI providers",
- "operationId": "list-ai-providers",
+ "tags": ["AI Gateway"],
+ "summary": "List AI Gateway clients",
+ "operationId": "list-ai-gateway-clients",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.AIProvider"
+ "type": "string"
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai-gateway/keys": {
+ "get": {
+ "produces": ["application/json"],
+ "tags": ["Enterprise"],
+ "summary": "List AI Gateway keys",
+ "operationId": "list-ai-gateway-keys",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.AIGatewayKey"
}
}
}
@@ -2153,17 +2223,17 @@
"post": {
"consumes": ["application/json"],
"produces": ["application/json"],
- "tags": ["AI Providers"],
- "summary": "Create an AI provider",
- "operationId": "create-an-ai-provider",
+ "tags": ["Enterprise"],
+ "summary": "Create AI Gateway key",
+ "operationId": "create-ai-gateway-key",
"parameters": [
{
- "description": "Create AI provider request",
+ "description": "Create AI Gateway key request",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/codersdk.CreateAIProviderRequest"
+ "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest"
}
}
],
@@ -2171,7 +2241,7 @@
"201": {
"description": "Created",
"schema": {
- "$ref": "#/definitions/codersdk.AIProvider"
+ "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse"
}
}
},
@@ -2182,27 +2252,24 @@
]
}
},
- "/api/v2/ai/providers/{idOrName}": {
- "get": {
- "produces": ["application/json"],
- "tags": ["AI Providers"],
- "summary": "Get an AI provider",
- "operationId": "get-an-ai-provider",
+ "/api/v2/ai-gateway/keys/{key}": {
+ "delete": {
+ "tags": ["Enterprise"],
+ "summary": "Delete AI Gateway key",
+ "operationId": "delete-ai-gateway-key",
"parameters": [
{
"type": "string",
- "description": "Provider ID or name",
- "name": "idOrName",
+ "format": "uuid",
+ "description": "Key ID",
+ "name": "key",
"in": "path",
"required": true
}
],
"responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/codersdk.AIProvider"
- }
+ "204": {
+ "description": "No Content"
}
},
"security": [
@@ -2210,8 +2277,232 @@
"CoderSessionToken": []
}
]
- },
- "delete": {
+ }
+ },
+ "/api/v2/ai-gateway/models": {
+ "get": {
+ "description": "Alias: also available at /api/v2/aibridge/models for backward compatibility.",
+ "produces": ["application/json"],
+ "tags": ["AI Gateway"],
+ "summary": "List AI Gateway models",
+ "operationId": "list-ai-gateway-models",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai-gateway/serve": {
+ "get": {
+ "tags": ["Enterprise"],
+ "summary": "AI Gateway serve",
+ "operationId": "ai-gateway-serve",
+ "responses": {
+ "101": {
+ "description": "Switching Protocols"
+ }
+ },
+ "security": [
+ {
+ "AIGatewayKey": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai-gateway/sessions": {
+ "get": {
+ "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.",
+ "produces": ["application/json"],
+ "tags": ["AI Gateway"],
+ "summary": "List AI Gateway sessions",
+ "operationId": "list-ai-gateway-sessions",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Search query in the format `key:value`. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.",
+ "name": "q",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Page limit",
+ "name": "limit",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Cursor pagination after session ID (cannot be used with offset)",
+ "name": "after_session_id",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Offset pagination (cannot be used with after_session_id)",
+ "name": "offset",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai-gateway/sessions/{session_id}": {
+ "get": {
+ "description": "Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility.",
+ "produces": ["application/json"],
+ "tags": ["AI Gateway"],
+ "summary": "Get AI Gateway session threads",
+ "operationId": "get-ai-gateway-session-threads",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Session ID (client_session_id or interception UUID)",
+ "name": "session_id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Thread pagination cursor (forward/older)",
+ "name": "after_id",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Thread pagination cursor (backward/newer)",
+ "name": "before_id",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Number of threads per page (default 50)",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai/providers": {
+ "get": {
+ "produces": ["application/json"],
+ "tags": ["AI Providers"],
+ "summary": "List AI providers",
+ "operationId": "list-ai-providers",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.AIProvider"
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ },
+ "post": {
+ "consumes": ["application/json"],
+ "produces": ["application/json"],
+ "tags": ["AI Providers"],
+ "summary": "Create an AI provider",
+ "operationId": "create-an-ai-provider",
+ "parameters": [
+ {
+ "description": "Create AI provider request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.CreateAIProviderRequest"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/codersdk.AIProvider"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/api/v2/ai/providers/{idOrName}": {
+ "get": {
+ "produces": ["application/json"],
+ "tags": ["AI Providers"],
+ "summary": "Get an AI provider",
+ "operationId": "get-an-ai-provider",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Provider ID or name",
+ "name": "idOrName",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.AIProvider"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ },
+ "delete": {
"tags": ["AI Providers"],
"summary": "Delete an AI provider",
"operationId": "delete-an-ai-provider",
@@ -16769,77 +17060,451 @@
"cache_creation_tokens": {
"type": "integer"
},
- "cache_read_tokens": {
- "type": "integer"
+ "cache_read_tokens": {
+ "type": "integer"
+ },
+ "context_limit": {
+ "type": "integer"
+ },
+ "input_tokens": {
+ "type": "integer"
+ },
+ "output_tokens": {
+ "type": "integer"
+ },
+ "reasoning_tokens": {
+ "type": "integer"
+ },
+ "total_tokens": {
+ "type": "integer"
+ }
+ }
+ },
+ "codersdk.ChatMessagesResponse": {
+ "type": "object",
+ "properties": {
+ "has_more": {
+ "type": "boolean"
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatMessage"
+ }
+ },
+ "queued_messages": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatQueuedMessage"
+ }
+ }
+ }
+ },
+ "codersdk.ChatModel": {
+ "type": "object",
+ "properties": {
+ "ai_provider_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "compression_threshold": {
+ "type": "integer"
+ },
+ "context_limit": {
+ "type": "integer"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "display_name": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "is_default": {
+ "type": "boolean"
+ },
+ "model": {
+ "type": "string"
+ },
+ "model_config": {
+ "$ref": "#/definitions/codersdk.ChatModelCallConfig"
+ },
+ "organization_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "reasoning_efforts": {
+ "description": "ReasoningEfforts lists selectable reasoning effort values through\nthe model's configured maximum.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "codersdk.ChatModelACL": {
+ "type": "object",
+ "properties": {
+ "group_roles": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/codersdk.ChatRole"
+ }
+ },
+ "user_roles": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/codersdk.ChatRole"
+ }
+ }
+ }
+ },
+ "codersdk.ChatModelAnthropicProviderOptions": {
+ "type": "object",
+ "properties": {
+ "allowed_domains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "blocked_domains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "context_1m_enabled": {
+ "type": "boolean"
+ },
+ "disable_parallel_tool_use": {
+ "type": "boolean"
+ },
+ "send_reasoning": {
+ "type": "boolean"
+ },
+ "thinking": {
+ "$ref": "#/definitions/codersdk.ChatModelAnthropicThinkingOptions"
+ },
+ "thinking_display": {
+ "type": "string"
+ },
+ "web_search_enabled": {
+ "type": "boolean"
+ }
+ }
+ },
+ "codersdk.ChatModelAnthropicThinkingOptions": {
+ "type": "object",
+ "properties": {
+ "budget_tokens": {
+ "type": "integer"
+ }
+ }
+ },
+ "codersdk.ChatModelAvailabilityResponse": {
+ "type": "object",
+ "properties": {
+ "models": {
+ "description": "Models contains the effective runtime model configs for the requested\norganization. Each config belongs to that organization.",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatModel"
+ }
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatModelProvider"
+ }
+ },
+ "unsupported_providers": {
+ "description": "UnsupportedProviders lists configured providers the Agents harness\ncannot use, so the UI can explain the empty state.",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatUnsupportedProvider"
+ }
+ }
+ }
+ },
+ "codersdk.ChatModelCallConfig": {
+ "type": "object",
+ "properties": {
+ "frequency_penalty": {
+ "type": "number"
+ },
+ "max_output_tokens": {
+ "type": "integer"
+ },
+ "openai_config": {
+ "$ref": "#/definitions/codersdk.ChatModelOpenAIConfig"
+ },
+ "presence_penalty": {
+ "type": "number"
+ },
+ "provider_options": {
+ "$ref": "#/definitions/codersdk.ChatModelProviderOptions"
+ },
+ "reasoning_effort": {
+ "$ref": "#/definitions/codersdk.ChatModelReasoningEffortConfig"
+ },
+ "temperature": {
+ "type": "number"
+ },
+ "top_k": {
+ "type": "integer"
+ },
+ "top_p": {
+ "type": "number"
+ }
+ }
+ },
+ "codersdk.ChatModelCatalogEntry": {
+ "type": "object",
+ "properties": {
+ "display_name": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "model": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelGoogleProviderOptions": {
+ "type": "object",
+ "properties": {
+ "cached_content": {
+ "type": "string"
+ },
+ "safety_settings": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatModelGoogleSafetySetting"
+ }
+ },
+ "thinking_config": {
+ "$ref": "#/definitions/codersdk.ChatModelGoogleThinkingConfig"
+ },
+ "threshold": {
+ "type": "string"
+ },
+ "web_search_enabled": {
+ "type": "boolean"
+ }
+ }
+ },
+ "codersdk.ChatModelGoogleSafetySetting": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string"
+ },
+ "threshold": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelGoogleThinkingConfig": {
+ "type": "object",
+ "properties": {
+ "include_thoughts": {
+ "type": "boolean"
+ },
+ "thinking_budget": {
+ "type": "integer"
+ },
+ "thinking_level": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelOpenAICompatProviderOptions": {
+ "type": "object",
+ "properties": {
+ "user": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelOpenAIConfig": {
+ "type": "object",
+ "properties": {
+ "use_responses_api": {
+ "type": "boolean"
+ }
+ }
+ },
+ "codersdk.ChatModelOpenAIProviderOptions": {
+ "type": "object",
+ "properties": {
+ "allowed_domains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "include": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "instructions": {
+ "type": "string"
+ },
+ "log_probs": {
+ "type": "boolean"
+ },
+ "logit_bias": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ "max_completion_tokens": {
+ "type": "integer"
+ },
+ "max_tool_calls": {
+ "type": "integer"
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "parallel_tool_calls": {
+ "type": "boolean"
+ },
+ "prediction": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "prompt_cache_key": {
+ "type": "string"
+ },
+ "reasoning_summary": {
+ "type": "string"
+ },
+ "safety_identifier": {
+ "type": "string"
+ },
+ "search_context_size": {
+ "type": "string"
},
- "context_limit": {
- "type": "integer"
+ "service_tier": {
+ "type": "string"
},
- "input_tokens": {
- "type": "integer"
+ "store": {
+ "type": "boolean"
},
- "output_tokens": {
- "type": "integer"
+ "strict_json_schema": {
+ "type": "boolean"
},
- "reasoning_tokens": {
- "type": "integer"
+ "structured_outputs": {
+ "type": "boolean"
},
- "total_tokens": {
+ "text_verbosity": {
+ "type": "string"
+ },
+ "top_log_probs": {
"type": "integer"
+ },
+ "user": {
+ "type": "string"
+ },
+ "web_search_enabled": {
+ "type": "boolean"
}
}
},
- "codersdk.ChatMessagesResponse": {
+ "codersdk.ChatModelOpenRouterProvider": {
"type": "object",
"properties": {
- "has_more": {
+ "allow_fallbacks": {
"type": "boolean"
},
- "messages": {
+ "data_collection": {
+ "type": "string"
+ },
+ "ignore": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.ChatMessage"
+ "type": "string"
}
},
- "queued_messages": {
+ "only": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.ChatQueuedMessage"
+ "type": "string"
}
- }
- }
- },
- "codersdk.ChatModelAvailabilityResponse": {
- "type": "object",
- "properties": {
- "providers": {
+ },
+ "order": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.ChatModelProvider"
+ "type": "string"
}
},
- "unsupported_providers": {
- "description": "UnsupportedProviders lists configured providers the Agents harness\ncannot use, so the UI can explain the empty state.",
+ "quantizations": {
"type": "array",
"items": {
- "$ref": "#/definitions/codersdk.ChatUnsupportedProvider"
+ "type": "string"
}
+ },
+ "require_parameters": {
+ "type": "boolean"
+ },
+ "sort": {
+ "type": "string"
}
}
},
- "codersdk.ChatModelCatalogEntry": {
+ "codersdk.ChatModelOpenRouterProviderOptions": {
"type": "object",
"properties": {
- "display_name": {
- "type": "string"
+ "extra_body": {
+ "type": "object",
+ "additionalProperties": {}
},
- "id": {
- "type": "string"
+ "include_usage": {
+ "type": "boolean"
},
- "model": {
- "type": "string"
+ "log_probs": {
+ "type": "boolean"
+ },
+ "logit_bias": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ "parallel_tool_calls": {
+ "type": "boolean"
},
"provider": {
+ "$ref": "#/definitions/codersdk.ChatModelOpenRouterProvider"
+ },
+ "reasoning": {
+ "$ref": "#/definitions/codersdk.ChatModelReasoningOptions"
+ },
+ "user": {
"type": "string"
}
}
@@ -16864,6 +17529,62 @@
}
}
},
+ "codersdk.ChatModelProviderDescriptor": {
+ "type": "object",
+ "properties": {
+ "allow_user_api_key": {
+ "type": "boolean"
+ },
+ "display_name": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "has_api_key": {
+ "type": "boolean"
+ },
+ "has_effective_api_key": {
+ "type": "boolean"
+ },
+ "has_user_api_key": {
+ "type": "boolean"
+ },
+ "icon": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelProviderOptions": {
+ "type": "object",
+ "properties": {
+ "anthropic": {
+ "$ref": "#/definitions/codersdk.ChatModelAnthropicProviderOptions"
+ },
+ "google": {
+ "$ref": "#/definitions/codersdk.ChatModelGoogleProviderOptions"
+ },
+ "openai": {
+ "$ref": "#/definitions/codersdk.ChatModelOpenAIProviderOptions"
+ },
+ "openaicompat": {
+ "$ref": "#/definitions/codersdk.ChatModelOpenAICompatProviderOptions"
+ },
+ "openrouter": {
+ "$ref": "#/definitions/codersdk.ChatModelOpenRouterProviderOptions"
+ },
+ "vercel": {
+ "$ref": "#/definitions/codersdk.ChatModelVercelProviderOptions"
+ }
+ }
+ },
"codersdk.ChatModelProviderUnavailableReason": {
"type": "string",
"enum": ["missing_api_key", "fetch_failed", "user_api_key_required"],
@@ -16873,6 +17594,82 @@
"ChatModelProviderUnavailableReasonUserAPIKeyRequired"
]
},
+ "codersdk.ChatModelReasoningEffortConfig": {
+ "type": "object",
+ "properties": {
+ "default": {
+ "type": "string"
+ },
+ "max": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.ChatModelReasoningOptions": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "exclude": {
+ "type": "boolean"
+ },
+ "max_tokens": {
+ "type": "integer"
+ }
+ }
+ },
+ "codersdk.ChatModelVercelGatewayProviderOptions": {
+ "type": "object",
+ "properties": {
+ "models": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "order": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "codersdk.ChatModelVercelProviderOptions": {
+ "type": "object",
+ "properties": {
+ "extra_body": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "logit_bias": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ "logprobs": {
+ "type": "boolean"
+ },
+ "parallel_tool_calls": {
+ "type": "boolean"
+ },
+ "providerOptions": {
+ "$ref": "#/definitions/codersdk.ChatModelVercelGatewayProviderOptions"
+ },
+ "reasoning": {
+ "$ref": "#/definitions/codersdk.ChatModelReasoningOptions"
+ },
+ "top_logprobs": {
+ "type": "integer"
+ },
+ "user": {
+ "type": "string"
+ }
+ }
+ },
"codersdk.ChatPlanMode": {
"type": "string",
"enum": ["plan"],
@@ -17497,6 +18294,36 @@
}
}
},
+ "codersdk.CreateChatModelRequest": {
+ "type": "object",
+ "properties": {
+ "ai_provider_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "compression_threshold": {
+ "type": "integer"
+ },
+ "context_limit": {
+ "type": "integer"
+ },
+ "display_name": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "is_default": {
+ "type": "boolean"
+ },
+ "model": {
+ "type": "string"
+ },
+ "model_config": {
+ "$ref": "#/definitions/codersdk.ChatModelCallConfig"
+ }
+ }
+ },
"codersdk.CreateChatRequest": {
"type": "object",
"properties": {
@@ -21246,6 +22073,23 @@
}
}
},
+ "codersdk.OrganizationChatModelsResponse": {
+ "type": "object",
+ "properties": {
+ "models": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatModel"
+ }
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.ChatModelProviderDescriptor"
+ }
+ }
+ }
+ },
"codersdk.OrganizationGroupAISpend": {
"type": "object",
"properties": {
@@ -24495,6 +25339,53 @@
}
}
},
+ "codersdk.UpdateChatModelACLRequest": {
+ "type": "object",
+ "properties": {
+ "group_roles": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/codersdk.ChatRole"
+ }
+ },
+ "user_roles": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/codersdk.ChatRole"
+ }
+ }
+ }
+ },
+ "codersdk.UpdateChatModelRequest": {
+ "type": "object",
+ "properties": {
+ "ai_provider_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "compression_threshold": {
+ "type": "integer"
+ },
+ "context_limit": {
+ "type": "integer"
+ },
+ "display_name": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "is_default": {
+ "type": "boolean"
+ },
+ "model": {
+ "type": "string"
+ },
+ "model_config": {
+ "$ref": "#/definitions/codersdk.ChatModelCallConfig"
+ }
+ }
+ },
"codersdk.UpdateChatRequest": {
"type": "object",
"properties": {
diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go
index 8564f33a964f9..6fa6fc7ff4ba3 100644
--- a/coderd/audit/diff.go
+++ b/coderd/audit/diff.go
@@ -39,6 +39,7 @@ type Auditable interface {
database.AIProviderKey |
database.AIGatewayKey |
database.Chat |
+ database.ChatModelConfig |
database.MCPServerConfig |
database.AuditableGroupAIBudget |
database.AuditableUserAIBudgetOverride |
diff --git a/coderd/audit/request.go b/coderd/audit/request.go
index fed41ba5df931..e83b5d7fb2c4d 100644
--- a/coderd/audit/request.go
+++ b/coderd/audit/request.go
@@ -155,6 +155,8 @@ func ResourceTarget[T Auditable](tgt T) string {
// for display; collisions affect the display label and search
// filter but not the primary resource identifier.
return typed.ID.String()[:8]
+ case database.ChatModelConfig:
+ return cmp.Or(typed.DisplayName, typed.Model, typed.ID.String())
case database.MCPServerConfig:
// Updates can persist an empty display name; fall back to the slug, or
// the ID if both are empty, so the audit entry stays identifiable.
@@ -262,6 +264,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.UserID
case database.Chat:
return typed.ID
+ case database.ChatModelConfig:
+ return typed.ID
case database.MCPServerConfig:
return typed.ID
case database.UserSecret:
@@ -342,6 +346,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeUserAIBudgetOverride
case database.Chat:
return database.ResourceTypeChat
+ case database.ChatModelConfig:
+ return database.ResourceTypeChatModelConfig
case database.MCPServerConfig:
return database.ResourceTypeMCPServerConfig
case database.UserSecret:
@@ -434,6 +440,8 @@ func ResourceRequiresOrgID[T Auditable]() bool {
// Chats always have a non-null organization_id (since
// migration 000467).
return true
+ case database.ChatModelConfig:
+ return true
case database.MCPServerConfig:
// MCP server configs always carry a non-null organization_id.
return true
diff --git a/coderd/coderd.go b/coderd/coderd.go
index 1ce598fcecda9..898d6304b00c9 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 ebb4501c25c7b..a3b777b72be29 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 0000000000000..241e3edfb674e
--- /dev/null
+++ b/coderd/database/chatmodelconfigs.go
@@ -0,0 +1,21 @@
+package database
+
+// EffectiveChatModelConfigs contains enabled configs for one organization.
+type EffectiveChatModelConfigs struct {
+ Configs []GetEnabledChatModelConfigsByOrganizationRow
+ DefaultConfig ChatModelConfig
+}
+
+// DeriveEffectiveChatModelConfigs selects the stored default from enabled
+// configs in one organization.
+func DeriveEffectiveChatModelConfigs(
+ rows []GetEnabledChatModelConfigsByOrganizationRow,
+) EffectiveChatModelConfigs {
+ result := EffectiveChatModelConfigs{Configs: rows}
+ for _, row := range rows {
+ if row.ChatModelConfig.IsDefault {
+ result.DefaultConfig = row.ChatModelConfig
+ }
+ }
+ return result
+}
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index d3b15f8374eb8..b243230bcfb4b 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -34,9 +34,11 @@ const wrapname = "dbauthz.querier"
// ErrNoActor is returned if no actor is present in the context.
var ErrNoActor = xerrors.Errorf("no authorization actor in context")
-// NotAuthorizedError is a sentinel error that unwraps to sql.ErrNoRows.
-// This allows the internal error to be read by the caller if needed. Otherwise
-// it will be handled as a 404.
+// NotAuthorizedError is a sentinel error wrapping the underlying RBAC denial.
+// HTTP layers map it to a 404 (via IsUnauthorized) so denials are
+// indistinguishable from missing resources, but it does NOT unwrap to
+// sql.ErrNoRows: 'errors.Is(err, sql.ErrNoRows)' is false for it, so callers
+// distinguishing "not found" from "denied" must use httpapi.Is404Error.
type NotAuthorizedError struct {
Err error
}
@@ -57,8 +59,7 @@ func (NotAuthorizedError) IsUnauthorized() bool {
return true
}
-// Unwrap will always unwrap to a sql.ErrNoRows so the API returns a 404.
-// So 'errors.Is(err, sql.ErrNoRows)' will always be true.
+// Unwrap returns the wrapped RBAC denial, never sql.ErrNoRows.
func (e NotAuthorizedError) Unwrap() error {
return e.Err
}
@@ -824,8 +825,8 @@ var (
// user's personal data; token writes use the per-user
// AsChatdTokenOwner subject instead.
rbac.ResourceUser.Type: {policy.ActionReadPersonal},
- // TODO(mafredri): remove this organization read after CODAGT-709 M3.
- // exp_chats and chatd use it for the pre-cutover default-org fallback.
+ // Organization reads support organization-scoped model
+ // resolution and user-global settings in the default org.
rbac.ResourceOrganization.Type: {policy.ActionRead},
}),
User: []rbac.Permission{},
@@ -2222,9 +2223,16 @@ func (q *querier) DeleteChatDebugDataByChatID(ctx context.Context, arg database.
}
func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
- // Interim write gate: see InsertChatModelConfig.
- // TODO(mafredri): swap to ResourceChatModelConfig object delete after CODAGT-709 M3 (org-scoping cutover)
- if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
+ config, err := q.db.GetChatModelConfigByID(ctx, id)
+ if err != nil {
+ return uuid.Nil, err
+ }
+ object := rbac.ResourceChatModelConfig.
+ WithID(config.ID).
+ InOrg(config.OrganizationID).
+ WithACLUserList(config.UserACL.RBACACL()).
+ WithGroupACL(config.GroupACL.RBACACL())
+ if err := q.authorizeContext(ctx, policy.ActionDelete, object); err != nil {
return uuid.Nil, err
}
return q.db.DeleteChatModelConfigByID(ctx, id)
@@ -3512,20 +3520,41 @@ func (q *querier) GetChatMessagesForPromptByChatID(ctx context.Context, chatID u
}
func (q *querier) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) {
- // Interim read gate: see InsertChatModelConfig.
- // TODO(mafredri): swap to ResourceChatModelConfig object read after CODAGT-709 M3 (org-scoping cutover)
- if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
+ config, err := q.db.GetChatModelConfigByID(ctx, id)
+ if err != nil {
return database.ChatModelConfig{}, err
}
- return q.db.GetChatModelConfigByID(ctx, id)
+ object := rbac.ResourceChatModelConfig.
+ WithID(config.ID).
+ InOrg(config.OrganizationID).
+ WithACLUserList(config.UserACL.RBACACL()).
+ WithGroupACL(config.GroupACL.RBACACL())
+ if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil {
+ return database.ChatModelConfig{}, err
+ }
+ return config, nil
}
-func (q *querier) GetChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) {
+func (q *querier) GetChatModelConfigs(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceChatModelConfig.Type)
if err != nil {
return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
}
- return q.db.GetAuthorizedChatModelConfigs(ctx, prep)
+ return q.db.GetAuthorizedChatModelConfigs(ctx, organizationID, prep)
+}
+
+func (q *querier) GetChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) {
+ // Consumed only by ensureDefaultChatModelConfig's default-election read
+ // inside the write transaction. Every path that reaches the election
+ // already requires update-in-org (insert/update/delete and the election's
+ // own UnsetDefaultChatModelConfigs/UpdateChatModelConfig), so the read
+ // authorizes update-in-org rather than read; a create-only principal never
+ // reaches it because create short-circuits when the inserted config is the
+ // org's first.
+ if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChatModelConfig.InOrg(organizationID)); err != nil {
+ return nil, err
+ }
+ return q.db.GetChatModelConfigsByOrganization(ctx, organizationID)
}
func (q *querier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]database.GetChatModelConfigsForTelemetryRow, error) {
@@ -3761,12 +3790,19 @@ func (q *querier) GetDatabaseNow(ctx context.Context) (time.Time, error) {
}
func (q *querier) GetDefaultChatModelConfig(ctx context.Context, organizationID uuid.UUID) (database.ChatModelConfig, error) {
- // Chat creation authorizes the organization-scoped chat before this lookup.
- // TODO(mafredri): remove after CODAGT-709 M3.
- if _, ok := ActorFromContext(ctx); !ok {
- return database.ChatModelConfig{}, ErrNoActor
+ config, err := q.db.GetDefaultChatModelConfig(ctx, organizationID)
+ if err != nil {
+ return database.ChatModelConfig{}, err
}
- return q.db.GetDefaultChatModelConfig(ctx, organizationID)
+ object := rbac.ResourceChatModelConfig.
+ WithID(config.ID).
+ InOrg(config.OrganizationID).
+ WithACLUserList(config.UserACL.RBACACL()).
+ WithGroupACL(config.GroupACL.RBACACL())
+ if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil {
+ return database.ChatModelConfig{}, err
+ }
+ return config, nil
}
func (q *querier) GetDefaultOrganization(ctx context.Context) (database.Organization, error) {
@@ -3802,28 +3838,49 @@ func (q *querier) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.C
}
func (q *querier) GetEnabledChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) {
- // TODO(mafredri): authorize the fetched object after CODAGT-709 M3
- // (org-scoping cutover).
- if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatModelConfig); err != nil {
+ config, err := q.db.GetEnabledChatModelConfigByID(ctx, id)
+ if err != nil {
return database.ChatModelConfig{}, err
}
- return q.db.GetEnabledChatModelConfigByID(ctx, id)
-}
-
-func (q *querier) GetEnabledChatModelConfigs(ctx context.Context) ([]database.GetEnabledChatModelConfigsRow, error) {
- // TODO(mafredri): replace this deployment-wide query after CODAGT-709 M3
- // (org-scoping cutover).
- if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatModelConfig); err != nil {
- return nil, err
+ object := rbac.ResourceChatModelConfig.
+ WithID(config.ID).
+ InOrg(config.OrganizationID).
+ WithACLUserList(config.UserACL.RBACACL()).
+ WithGroupACL(config.GroupACL.RBACACL())
+ if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil {
+ return database.ChatModelConfig{}, err
}
- return q.db.GetEnabledChatModelConfigs(ctx)
+ return config, nil
}
func (q *querier) GetEnabledChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) {
- if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatModelConfig.InOrg(organizationID)); err != nil {
+ rows, err := q.db.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
+ if err != nil {
return nil, err
}
- return q.db.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
+ act, ok := ActorFromContext(ctx)
+ if !ok {
+ return nil, ErrNoActor
+ }
+ out := make([]database.GetEnabledChatModelConfigsByOrganizationRow, 0, len(rows))
+ for _, row := range rows {
+ config := row.ChatModelConfig
+ object := rbac.ResourceChatModelConfig.
+ WithID(config.ID).
+ InOrg(config.OrganizationID).
+ WithACLUserList(config.UserACL.RBACACL()).
+ WithGroupACL(config.GroupACL.RBACACL())
+ err := q.auth.Authorize(ctx, act, policy.ActionRead, object)
+ switch {
+ case err == nil:
+ out = append(out, row)
+ case rbac.IsUnauthorizedError(err):
+ continue
+ default:
+ return nil, err
+ }
+ }
+ return out, nil
}
func (q *querier) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) {
@@ -6116,13 +6173,7 @@ func (q *querier) InsertChatMessages(ctx context.Context, arg database.InsertCha
}
func (q *querier) InsertChatModelConfig(ctx context.Context, arg database.InsertChatModelConfigParams) (database.ChatModelConfig, error) {
- // The routes create configs in the default organization. Organization
- // administrators must not manage configs that other organizations can read.
- // TODO(mafredri): swap to ResourceChatModelConfig create-in-org after CODAGT-709 M3 (org-scoping cutover)
- if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
- return database.ChatModelConfig{}, err
- }
- return q.db.InsertChatModelConfig(ctx, arg)
+ return insert(q.log, q.auth, rbac.ResourceChatModelConfig.InOrg(arg.OrganizationID), q.db.InsertChatModelConfig)(ctx, arg)
}
func (q *querier) InsertChatQueuedMessage(ctx context.Context, arg database.InsertChatQueuedMessageParams) (database.ChatQueuedMessage, error) {
@@ -7322,9 +7373,7 @@ func (q *querier) UnpinChatByID(ctx context.Context, id uuid.UUID) error {
}
func (q *querier) UnsetDefaultChatModelConfigs(ctx context.Context, organizationID uuid.UUID) error {
- // Interim write gate: see InsertChatModelConfig.
- // TODO(mafredri): swap to ResourceChatModelConfig update-in-org after CODAGT-709 M3 (org-scoping cutover)
- if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
+ if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChatModelConfig.InOrg(organizationID)); err != nil {
return err
}
return q.db.UnsetDefaultChatModelConfigs(ctx, organizationID)
@@ -7503,9 +7552,16 @@ func (q *querier) UpdateChatMCPServerIDs(ctx context.Context, arg database.Updat
}
func (q *querier) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) {
- // Interim write gate: see InsertChatModelConfig.
- // TODO(mafredri): swap to ResourceChatModelConfig object update after CODAGT-709 M3 (org-scoping cutover)
- if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
+ existing, err := q.db.GetChatModelConfigByID(ctx, arg.ID)
+ if err != nil {
+ return database.ChatModelConfig{}, err
+ }
+ object := rbac.ResourceChatModelConfig.
+ WithID(existing.ID).
+ InOrg(existing.OrganizationID).
+ WithACLUserList(existing.UserACL.RBACACL()).
+ WithGroupACL(existing.GroupACL.RBACACL())
+ if err := q.authorizeContext(ctx, policy.ActionUpdate, object); err != nil {
return database.ChatModelConfig{}, err
}
return q.db.UpdateChatModelConfig(ctx, arg)
@@ -9478,18 +9534,8 @@ func (q *querier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uui
return q.db.GetAuthorizedChatsByChatFileID(ctx, fileID, prepared)
}
-func (q *querier) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) {
- return q.db.GetAuthorizedChatModelConfigs(ctx, prepared)
-}
-
-// GetDefaultChatModelConfigCandidates returns all non-deleted configs.
-// A filtered result can leave an organization without a default.
-// TODO(mafredri): remove after CODAGT-709 M3.
-func (q *querier) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]database.ChatModelConfig, error) {
- if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
- return nil, err
- }
- return q.db.GetDefaultChatModelConfigCandidates(ctx)
+func (q *querier) GetAuthorizedChatModelConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) {
+ return q.db.GetAuthorizedChatModelConfigs(ctx, organizationID, prepared)
}
func (q *querier) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) {
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index e5b7766b19c4f..469a9180772ec 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -802,10 +802,16 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), msg.ID).Return(nil).AnyTimes()
check.Args(msg.ID).Asserts(chat, policy.ActionUpdate).Returns()
}))
- s.Run("DeleteChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
- configID := uuid.New()
- dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), configID).Return(configID, nil).AnyTimes()
- check.Args(configID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(configID)
+ s.Run("DeleteChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
+ config := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
+ dbm.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes()
+ dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), config.ID).Return(config.ID, nil).AnyTimes()
+ object := rbac.ResourceChatModelConfig.
+ WithID(config.ID).
+ InOrg(config.OrganizationID).
+ WithACLUserList(config.UserACL.RBACACL()).
+ WithGroupACL(config.GroupACL.RBACACL())
+ check.Args(config.ID).Asserts(object, policy.ActionDelete).Returns(config.ID)
}))
s.Run("DeleteChatQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
@@ -1175,30 +1181,40 @@ func (s *MethodTestSuite) TestChats() {
s.Run("GetChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
config := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
dbm.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes()
- check.Args(config.ID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config)
+ object := rbac.ResourceChatModelConfig.
+ WithID(config.ID).
+ InOrg(config.OrganizationID).
+ WithACLUserList(config.UserACL.RBACACL()).
+ WithGroupACL(config.GroupACL.RBACACL())
+ check.Args(config.ID).Asserts(object, policy.ActionRead).Returns(config)
}))
s.Run("GetDefaultChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
config := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
dbm.EXPECT().GetDefaultChatModelConfig(gomock.Any(), config.OrganizationID).Return(config, nil).AnyTimes()
- check.Args(config.OrganizationID).Asserts().Returns(config)
+ object := rbac.ResourceChatModelConfig.
+ WithID(config.ID).
+ InOrg(config.OrganizationID).
+ WithACLUserList(config.UserACL.RBACACL()).
+ WithGroupACL(config.GroupACL.RBACACL())
+ check.Args(config.OrganizationID).Asserts(object, policy.ActionRead).Returns(config)
}))
s.Run("GetChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
- dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes()
+ orgID := uuid.New()
+ dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), orgID, gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes()
// No asserts here because SQLFilter.
- check.Args().Asserts()
+ check.Args(orgID).Asserts()
+ }))
+ s.Run("GetChatModelConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
+ orgID := uuid.New()
+ dbm.EXPECT().GetChatModelConfigsByOrganization(gomock.Any(), orgID).Return([]database.ChatModelConfig{}, nil).AnyTimes()
+ check.Args(orgID).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), policy.ActionUpdate).Returns([]database.ChatModelConfig{})
}))
s.Run("GetAuthorizedChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
- dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes()
+ orgID := uuid.New()
+ dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), orgID, gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes()
// No asserts here because callers provide the SQL filter.
- check.Args(emptyPreparedAuthorized{}).Asserts()
- }))
- s.Run("GetDefaultChatModelConfigCandidates", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
- configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
- configB := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
- dbm.EXPECT().GetDefaultChatModelConfigCandidates(gomock.Any()).Return([]database.ChatModelConfig{configA, configB}, nil).AnyTimes()
- check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.ChatModelConfig{configA, configB})
+ check.Args(orgID, emptyPreparedAuthorized{}).Asserts()
}))
-
s.Run("GetChats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
params := database.GetChatsParams{}
dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.GetChatsRow{}, nil).AnyTimes()
@@ -1303,21 +1319,33 @@ func (s *MethodTestSuite) TestChats() {
s.Run("GetEnabledChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
config := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
dbm.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes()
- check.Args(config.ID).Asserts(rbac.ResourceChatModelConfig, policy.ActionRead).Returns(config)
- }))
- s.Run("GetEnabledChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
- rowA := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsRow{})
- rowB := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsRow{})
- dbm.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return([]database.GetEnabledChatModelConfigsRow{rowA, rowB}, nil).AnyTimes()
- check.Args().Asserts(rbac.ResourceChatModelConfig, policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsRow{rowA, rowB})
+ object := rbac.ResourceChatModelConfig.
+ WithID(config.ID).
+ InOrg(config.OrganizationID).
+ WithACLUserList(config.UserACL.RBACACL()).
+ WithGroupACL(config.GroupACL.RBACACL())
+ check.Args(config.ID).Asserts(object, policy.ActionRead).Returns(config)
}))
-
s.Run("GetEnabledChatModelConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
orgID := uuid.New()
- rowA := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{})
- rowB := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{})
+ rowA := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{
+ ChatModelConfig: database.ChatModelConfig{OrganizationID: orgID},
+ })
+ rowB := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{
+ ChatModelConfig: database.ChatModelConfig{OrganizationID: orgID},
+ })
dbm.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), orgID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB}, nil).AnyTimes()
- check.Args(orgID).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB})
+ objectA := rbac.ResourceChatModelConfig.
+ WithID(rowA.ChatModelConfig.ID).
+ InOrg(rowA.ChatModelConfig.OrganizationID).
+ WithACLUserList(rowA.ChatModelConfig.UserACL.RBACACL()).
+ WithGroupACL(rowA.ChatModelConfig.GroupACL.RBACACL())
+ objectB := rbac.ResourceChatModelConfig.
+ WithID(rowB.ChatModelConfig.ID).
+ InOrg(rowB.ChatModelConfig.OrganizationID).
+ WithACLUserList(rowB.ChatModelConfig.UserACL.RBACACL()).
+ WithGroupACL(rowB.ChatModelConfig.GroupACL.RBACACL())
+ check.Args(orgID).Asserts(objectA, policy.ActionRead, objectB, policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB})
}))
s.Run("GetStaleChats", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
@@ -1360,13 +1388,14 @@ func (s *MethodTestSuite) TestChats() {
}))
s.Run("InsertChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := database.InsertChatModelConfigParams{
- Model: "test-model",
- DisplayName: "Test Model",
- Enabled: true,
+ Model: "test-model",
+ DisplayName: "Test Model",
+ Enabled: true,
+ OrganizationID: uuid.New(),
}
config := testutil.Fake(s.T(), faker, database.ChatModelConfig{Model: arg.Model, DisplayName: arg.DisplayName, Enabled: arg.Enabled})
dbm.EXPECT().InsertChatModelConfig(gomock.Any(), arg).Return(config, nil).AnyTimes()
- check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config)
+ check.Args(arg).Asserts(rbac.ResourceChatModelConfig.InOrg(arg.OrganizationID), policy.ActionCreate).Returns(config)
}))
s.Run("PopNextQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
@@ -1594,8 +1623,14 @@ func (s *MethodTestSuite) TestChats() {
DisplayName: "Updated Model",
Enabled: true,
}
+ dbm.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes()
dbm.EXPECT().UpdateChatModelConfig(gomock.Any(), arg).Return(config, nil).AnyTimes()
- check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config)
+ object := rbac.ResourceChatModelConfig.
+ WithID(config.ID).
+ InOrg(config.OrganizationID).
+ WithACLUserList(config.UserACL.RBACACL()).
+ WithGroupACL(config.GroupACL.RBACACL())
+ check.Args(arg).Asserts(object, policy.ActionUpdate).Returns(config)
}))
s.Run("UpdateChatModelConfigACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
@@ -1668,7 +1703,7 @@ func (s *MethodTestSuite) TestChats() {
s.Run("UnsetDefaultChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
orgID := uuid.New()
dbm.EXPECT().UnsetDefaultChatModelConfigs(gomock.Any(), orgID).Return(nil).AnyTimes()
- check.Args(orgID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
+ check.Args(orgID).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), policy.ActionUpdate)
}))
s.Run("UpsertChatDiffStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
@@ -7760,6 +7795,10 @@ func TestAsChatd(t *testing.T) {
err = auth.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig)
require.Error(t, err, "deployment config update should not be allowed")
+ // Chatd resolves organization model configs during background work.
+ err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceChatModelConfig.InOrg(uuid.New()))
+ require.NoError(t, err, "chat model config read should be allowed")
+
// Pin the complete ResourceUser action set: read_personal only.
// Token refresh persistence uses the per-user AsChatdTokenOwner subject.
for _, action := range rbac.ResourceUser.AvailableActions() {
@@ -7771,7 +7810,7 @@ func TestAsChatd(t *testing.T) {
}
}
- // Organization read is temporarily needed for the pre-cutover default-org fallback.
+ // Organization reads support the pre-cutover default-org fallback.
err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceOrganization)
require.NoError(t, err, "organization read should be allowed")
})
diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go
index 33f5560ada77e..1f728f1693f87 100644
--- a/coderd/database/dbmetrics/querymetrics.go
+++ b/coderd/database/dbmetrics/querymetrics.go
@@ -1696,14 +1696,22 @@ func (m queryMetricsStore) GetChatModelConfigByID(ctx context.Context, id uuid.U
return r0, r1
}
-func (m queryMetricsStore) GetChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) {
+func (m queryMetricsStore) GetChatModelConfigs(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) {
start := time.Now()
- r0, r1 := m.s.GetChatModelConfigs(ctx)
+ r0, r1 := m.s.GetChatModelConfigs(ctx, organizationID)
m.queryLatencies.WithLabelValues("GetChatModelConfigs").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatModelConfigs").Inc()
return r0, r1
}
+func (m queryMetricsStore) GetChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetChatModelConfigsByOrganization(ctx, organizationID)
+ m.queryLatencies.WithLabelValues("GetChatModelConfigsByOrganization").Observe(time.Since(start).Seconds())
+ m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatModelConfigsByOrganization").Inc()
+ return r0, r1
+}
+
func (m queryMetricsStore) GetChatModelConfigsForTelemetry(ctx context.Context) ([]database.GetChatModelConfigsForTelemetryRow, error) {
start := time.Now()
r0, r1 := m.s.GetChatModelConfigsForTelemetry(ctx)
@@ -2008,14 +2016,6 @@ func (m queryMetricsStore) GetEnabledChatModelConfigByID(ctx context.Context, id
return r0, r1
}
-func (m queryMetricsStore) GetEnabledChatModelConfigs(ctx context.Context) ([]database.GetEnabledChatModelConfigsRow, error) {
- start := time.Now()
- r0, r1 := m.s.GetEnabledChatModelConfigs(ctx)
- m.queryLatencies.WithLabelValues("GetEnabledChatModelConfigs").Observe(time.Since(start).Seconds())
- m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetEnabledChatModelConfigs").Inc()
- return r0, r1
-}
-
func (m queryMetricsStore) GetEnabledChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) {
start := time.Now()
r0, r1 := m.s.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
@@ -6840,22 +6840,14 @@ func (m queryMetricsStore) GetAuthorizedChatsByChatFileID(ctx context.Context, f
return r0, r1
}
-func (m queryMetricsStore) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) {
+func (m queryMetricsStore) GetAuthorizedChatModelConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) {
start := time.Now()
- r0, r1 := m.s.GetAuthorizedChatModelConfigs(ctx, prepared)
+ r0, r1 := m.s.GetAuthorizedChatModelConfigs(ctx, organizationID, prepared)
m.queryLatencies.WithLabelValues("GetAuthorizedChatModelConfigs").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedChatModelConfigs").Inc()
return r0, r1
}
-func (m queryMetricsStore) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]database.ChatModelConfig, error) {
- start := time.Now()
- r0, r1 := m.s.GetDefaultChatModelConfigCandidates(ctx)
- m.queryLatencies.WithLabelValues("GetDefaultChatModelConfigCandidates").Observe(time.Since(start).Seconds())
- m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetDefaultChatModelConfigCandidates").Inc()
- return r0, r1
-}
-
func (m queryMetricsStore) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) {
start := time.Now()
r0, r1 := m.s.GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared)
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index 856f1ba49a20d..b2f091c868cd8 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -2476,18 +2476,18 @@ func (mr *MockStoreMockRecorder) GetAuthorizedAuditLogsOffset(ctx, arg, prepared
}
// GetAuthorizedChatModelConfigs mocks base method.
-func (m *MockStore) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) {
+func (m *MockStore) GetAuthorizedChatModelConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "GetAuthorizedChatModelConfigs", ctx, prepared)
+ ret := m.ctrl.Call(m, "GetAuthorizedChatModelConfigs", ctx, organizationID, prepared)
ret0, _ := ret[0].([]database.ChatModelConfig)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAuthorizedChatModelConfigs indicates an expected call of GetAuthorizedChatModelConfigs.
-func (mr *MockStoreMockRecorder) GetAuthorizedChatModelConfigs(ctx, prepared any) *gomock.Call {
+func (mr *MockStoreMockRecorder) GetAuthorizedChatModelConfigs(ctx, organizationID, prepared any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedChatModelConfigs), ctx, prepared)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedChatModelConfigs), ctx, organizationID, prepared)
}
// GetAuthorizedChats mocks base method.
@@ -3181,18 +3181,33 @@ func (mr *MockStoreMockRecorder) GetChatModelConfigByID(ctx, id any) *gomock.Cal
}
// GetChatModelConfigs mocks base method.
-func (m *MockStore) GetChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) {
+func (m *MockStore) GetChatModelConfigs(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "GetChatModelConfigs", ctx)
+ ret := m.ctrl.Call(m, "GetChatModelConfigs", ctx, organizationID)
ret0, _ := ret[0].([]database.ChatModelConfig)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatModelConfigs indicates an expected call of GetChatModelConfigs.
-func (mr *MockStoreMockRecorder) GetChatModelConfigs(ctx any) *gomock.Call {
+func (mr *MockStoreMockRecorder) GetChatModelConfigs(ctx, organizationID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigs), ctx)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigs), ctx, organizationID)
+}
+
+// GetChatModelConfigsByOrganization mocks base method.
+func (m *MockStore) GetChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ChatModelConfig, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetChatModelConfigsByOrganization", ctx, organizationID)
+ ret0, _ := ret[0].([]database.ChatModelConfig)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetChatModelConfigsByOrganization indicates an expected call of GetChatModelConfigsByOrganization.
+func (mr *MockStoreMockRecorder) GetChatModelConfigsByOrganization(ctx, organizationID any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigsByOrganization", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigsByOrganization), ctx, organizationID)
}
// GetChatModelConfigsForTelemetry mocks base method.
@@ -3645,21 +3660,6 @@ func (mr *MockStoreMockRecorder) GetDefaultChatModelConfig(ctx, organizationID a
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultChatModelConfig", reflect.TypeOf((*MockStore)(nil).GetDefaultChatModelConfig), ctx, organizationID)
}
-// GetDefaultChatModelConfigCandidates mocks base method.
-func (m *MockStore) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]database.ChatModelConfig, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "GetDefaultChatModelConfigCandidates", ctx)
- ret0, _ := ret[0].([]database.ChatModelConfig)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// GetDefaultChatModelConfigCandidates indicates an expected call of GetDefaultChatModelConfigCandidates.
-func (mr *MockStoreMockRecorder) GetDefaultChatModelConfigCandidates(ctx any) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultChatModelConfigCandidates", reflect.TypeOf((*MockStore)(nil).GetDefaultChatModelConfigCandidates), ctx)
-}
-
// GetDefaultOrganization mocks base method.
func (m *MockStore) GetDefaultOrganization(ctx context.Context) (database.Organization, error) {
m.ctrl.T.Helper()
@@ -3780,21 +3780,6 @@ func (mr *MockStoreMockRecorder) GetEnabledChatModelConfigByID(ctx, id any) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledChatModelConfigByID", reflect.TypeOf((*MockStore)(nil).GetEnabledChatModelConfigByID), ctx, id)
}
-// GetEnabledChatModelConfigs mocks base method.
-func (m *MockStore) GetEnabledChatModelConfigs(ctx context.Context) ([]database.GetEnabledChatModelConfigsRow, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "GetEnabledChatModelConfigs", ctx)
- ret0, _ := ret[0].([]database.GetEnabledChatModelConfigsRow)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// GetEnabledChatModelConfigs indicates an expected call of GetEnabledChatModelConfigs.
-func (mr *MockStoreMockRecorder) GetEnabledChatModelConfigs(ctx any) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetEnabledChatModelConfigs), ctx)
-}
-
// GetEnabledChatModelConfigsByOrganization mocks base method.
func (m *MockStore) GetEnabledChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) {
m.ctrl.T.Helper()
diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go
index 242f9c33324ee..c369e3f4048a8 100644
--- a/coderd/database/dbpurge/dbpurge_test.go
+++ b/coderd/database/dbpurge/dbpurge_test.go
@@ -2234,8 +2234,9 @@ func TestPurgeChatDebugRuns(t *testing.T) {
DisplayName: "OpenAI",
})
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
- Model: "test-model",
- ContextLimit: 8192,
+ Model: "test-model",
+ ContextLimit: 8192,
+ OrganizationID: org.ID,
})
return chatDebugDeps{user: user, org: org, modelConfig: modelConfig}
}
@@ -2460,8 +2461,9 @@ func TestDeleteOldChatFiles(t *testing.T) {
DisplayName: "OpenAI",
})
mc := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
- Model: "test-model",
- ContextLimit: 8192,
+ Model: "test-model",
+ ContextLimit: 8192,
+ OrganizationID: org.ID,
})
return chatDeps{user: user, org: org, modelConfig: mc}
}
@@ -3051,8 +3053,9 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) {
DisplayName: "OpenAI",
})
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
- Model: "test-model",
- ContextLimit: 8192,
+ Model: "test-model",
+ ContextLimit: 8192,
+ OrganizationID: org.ID,
})
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go
index 663c0a97e923a..23956a7ab4029 100644
--- a/coderd/database/modelqueries.go
+++ b/coderd/database/modelqueries.go
@@ -58,18 +58,10 @@ type customQuerier interface {
}
type chatModelConfigQuerier interface {
- GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]ChatModelConfig, error)
- // GetDefaultChatModelConfigCandidates returns every non-deleted config in
- // every organization without an authorization filter. The caller selects
- // one organization's promotion candidate from this deployment-wide list.
- GetDefaultChatModelConfigCandidates(ctx context.Context) ([]ChatModelConfig, error)
+ GetAuthorizedChatModelConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]ChatModelConfig, error)
}
-func (q *sqlQuerier) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]ChatModelConfig, error) {
- return q.GetChatModelConfigs(ctx)
-}
-
-func (q *sqlQuerier) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]ChatModelConfig, error) {
+func (q *sqlQuerier) GetAuthorizedChatModelConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]ChatModelConfig, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigChatModelConfigs())
if err != nil {
return nil, xerrors.Errorf("compile authorized filter: %w", err)
@@ -82,7 +74,7 @@ func (q *sqlQuerier) GetAuthorizedChatModelConfigs(ctx context.Context, prepared
// The name comment is for metric tracking
query := fmt.Sprintf("-- name: GetAuthorizedChatModelConfigs :many\n%s", filtered)
- rows, err := q.db.QueryContext(ctx, query)
+ rows, err := q.db.QueryContext(ctx, query, organizationID)
if err != nil {
return nil, err
}
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 00dd4cc0545e2..167f9210f76d3 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -482,7 +482,12 @@ type sqlcQuerier interface {
// results remain after their assistant calls.
GetChatMessagesForPromptByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error)
GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error)
- GetChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error)
+ GetChatModelConfigs(ctx context.Context, organizationID uuid.UUID) ([]ChatModelConfig, error)
+ // All live configs in one organization, unfiltered. Consumed ONLY by
+ // ensureDefaultChatModelConfig's default-election read inside the write
+ // transaction; authorization is the caller's update-in-org check that every
+ // path reaching the election already requires. No @authorize_filter.
+ GetChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ChatModelConfig, error)
// deleted = false guarantees ai_provider_id is non-null, so INNER JOIN is safe.
GetChatModelConfigsForTelemetry(ctx context.Context) ([]GetChatModelConfigsForTelemetryRow, error)
// GetChatPersonalModelOverridesEnabled returns whether users may configure
@@ -562,7 +567,6 @@ type sqlcQuerier interface {
// Providers can be disabled independently of their model configs.
// Check both to ensure the selected config is actually usable.
GetEnabledChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error)
- GetEnabledChatModelConfigs(ctx context.Context) ([]GetEnabledChatModelConfigsRow, error)
GetEnabledChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]GetEnabledChatModelConfigsByOrganizationRow, error)
GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error)
GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg GetEnabledMCPServerConfigsByOrganizationAndIDsParams) ([]MCPServerConfig, error)
diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go
index 6bc0b0eeabd71..2af1b17da1c00 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -12483,15 +12483,15 @@ func TestGetEnabledChatModelConfigsUsesAIProviders(t *testing.T) {
params.Enabled = false
})
- configs, err := store.GetEnabledChatModelConfigs(ctx)
+ configs, err := store.GetEnabledChatModelConfigsByOrganization(ctx, enabledConfig.OrganizationID)
require.NoError(t, err)
- require.True(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsRow) bool {
+ require.True(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsByOrganizationRow) bool {
return row.ChatModelConfig.ID == enabledConfig.ID
}))
- require.False(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsRow) bool {
+ require.False(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsByOrganizationRow) bool {
return row.ChatModelConfig.ID == disabledProviderConfig.ID
}))
- require.False(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsRow) bool {
+ require.False(t, slices.ContainsFunc(configs, func(row database.GetEnabledChatModelConfigsByOrganizationRow) bool {
return row.ChatModelConfig.ID == disabledModelConfig.ID
}))
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index c3759bea71e3d..bc76ac7179a71 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -6200,6 +6200,7 @@ LEFT JOIN
ai_providers ap ON ap.id = cmc.ai_provider_id
WHERE
cmc.deleted = FALSE
+ AND cmc.organization_id = $1::uuid
-- Authorize Filter clause will be injected below in GetAuthorizedChatModelConfigs
-- @authorize_filter
ORDER BY
@@ -6209,8 +6210,68 @@ ORDER BY
cmc.id DESC
`
-func (q *sqlQuerier) GetChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error) {
- rows, err := q.db.QueryContext(ctx, getChatModelConfigs)
+func (q *sqlQuerier) GetChatModelConfigs(ctx context.Context, organizationID uuid.UUID) ([]ChatModelConfig, error) {
+ rows, err := q.db.QueryContext(ctx, getChatModelConfigs, organizationID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ChatModelConfig
+ for rows.Next() {
+ var i ChatModelConfig
+ if err := rows.Scan(
+ &i.ID,
+ &i.Model,
+ &i.DisplayName,
+ &i.CreatedBy,
+ &i.UpdatedBy,
+ &i.Enabled,
+ &i.IsDefault,
+ &i.Deleted,
+ &i.DeletedAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.ContextLimit,
+ &i.CompressionThreshold,
+ &i.Options,
+ &i.AIProviderID,
+ &i.OrganizationID,
+ &i.GroupACL,
+ &i.UserACL,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getChatModelConfigsByOrganization = `-- name: GetChatModelConfigsByOrganization :many
+SELECT
+ id, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options, ai_provider_id, organization_id, group_acl, user_acl
+FROM
+ chat_model_configs
+WHERE
+ organization_id = $1::uuid
+ AND deleted = FALSE
+ORDER BY
+ model ASC,
+ updated_at DESC,
+ id DESC
+`
+
+// All live configs in one organization, unfiltered. Consumed ONLY by
+// ensureDefaultChatModelConfig's default-election read inside the write
+// transaction; authorization is the caller's update-in-org check that every
+// path reaching the election already requires. No @authorize_filter.
+func (q *sqlQuerier) GetChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ChatModelConfig, error) {
+ rows, err := q.db.QueryContext(ctx, getChatModelConfigsByOrganization, organizationID)
if err != nil {
return nil, err
}
@@ -6331,74 +6392,6 @@ func (q *sqlQuerier) GetEnabledChatModelConfigByID(ctx context.Context, id uuid.
return i, err
}
-const getEnabledChatModelConfigs = `-- name: GetEnabledChatModelConfigs :many
-SELECT
- cmc.id, cmc.model, cmc.display_name, cmc.created_by, cmc.updated_by, cmc.enabled, cmc.is_default, cmc.deleted, cmc.deleted_at, cmc.created_at, cmc.updated_at, cmc.context_limit, cmc.compression_threshold, cmc.options, cmc.ai_provider_id, cmc.organization_id, cmc.group_acl, cmc.user_acl,
- ap.type::text AS provider
-FROM
- chat_model_configs cmc
-JOIN
- ai_providers ap ON ap.id = cmc.ai_provider_id
-WHERE
- cmc.enabled = TRUE
- AND cmc.deleted = FALSE
- AND ap.enabled = TRUE
- AND ap.deleted = FALSE
-ORDER BY
- ap.type::text ASC,
- cmc.model ASC,
- cmc.updated_at DESC,
- cmc.id DESC
-`
-
-type GetEnabledChatModelConfigsRow struct {
- ChatModelConfig ChatModelConfig `db:"chat_model_config" json:"chat_model_config"`
- Provider string `db:"provider" json:"provider"`
-}
-
-func (q *sqlQuerier) GetEnabledChatModelConfigs(ctx context.Context) ([]GetEnabledChatModelConfigsRow, error) {
- rows, err := q.db.QueryContext(ctx, getEnabledChatModelConfigs)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- var items []GetEnabledChatModelConfigsRow
- for rows.Next() {
- var i GetEnabledChatModelConfigsRow
- if err := rows.Scan(
- &i.ChatModelConfig.ID,
- &i.ChatModelConfig.Model,
- &i.ChatModelConfig.DisplayName,
- &i.ChatModelConfig.CreatedBy,
- &i.ChatModelConfig.UpdatedBy,
- &i.ChatModelConfig.Enabled,
- &i.ChatModelConfig.IsDefault,
- &i.ChatModelConfig.Deleted,
- &i.ChatModelConfig.DeletedAt,
- &i.ChatModelConfig.CreatedAt,
- &i.ChatModelConfig.UpdatedAt,
- &i.ChatModelConfig.ContextLimit,
- &i.ChatModelConfig.CompressionThreshold,
- &i.ChatModelConfig.Options,
- &i.ChatModelConfig.AIProviderID,
- &i.ChatModelConfig.OrganizationID,
- &i.ChatModelConfig.GroupACL,
- &i.ChatModelConfig.UserACL,
- &i.Provider,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Close(); err != nil {
- return nil, err
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
const getEnabledChatModelConfigsByOrganization = `-- name: GetEnabledChatModelConfigsByOrganization :many
SELECT
cmc.id, cmc.model, cmc.display_name, cmc.created_by, cmc.updated_by, cmc.enabled, cmc.is_default, cmc.deleted, cmc.deleted_at, cmc.created_at, cmc.updated_at, cmc.context_limit, cmc.compression_threshold, cmc.options, cmc.ai_provider_id, cmc.organization_id, cmc.group_acl, cmc.user_acl,
diff --git a/coderd/database/queries/chatmodelconfigs.sql b/coderd/database/queries/chatmodelconfigs.sql
index 671440a47be5d..902661648a551 100644
--- a/coderd/database/queries/chatmodelconfigs.sql
+++ b/coderd/database/queries/chatmodelconfigs.sql
@@ -26,6 +26,7 @@ LEFT JOIN
ai_providers ap ON ap.id = cmc.ai_provider_id
WHERE
cmc.deleted = FALSE
+ AND cmc.organization_id = @organization_id::uuid
-- Authorize Filter clause will be injected below in GetAuthorizedChatModelConfigs
-- @authorize_filter
ORDER BY
@@ -34,24 +35,22 @@ ORDER BY
cmc.updated_at DESC,
cmc.id DESC;
--- name: GetEnabledChatModelConfigs :many
+-- name: GetChatModelConfigsByOrganization :many
+-- All live configs in one organization, unfiltered. Consumed ONLY by
+-- ensureDefaultChatModelConfig's default-election read inside the write
+-- transaction; authorization is the caller's update-in-org check that every
+-- path reaching the election already requires. No @authorize_filter.
SELECT
- sqlc.embed(cmc),
- ap.type::text AS provider
+ *
FROM
- chat_model_configs cmc
-JOIN
- ai_providers ap ON ap.id = cmc.ai_provider_id
+ chat_model_configs
WHERE
- cmc.enabled = TRUE
- AND cmc.deleted = FALSE
- AND ap.enabled = TRUE
- AND ap.deleted = FALSE
+ organization_id = @organization_id::uuid
+ AND deleted = FALSE
ORDER BY
- ap.type::text ASC,
- cmc.model ASC,
- cmc.updated_at DESC,
- cmc.id DESC;
+ model ASC,
+ updated_at DESC,
+ id DESC;
-- name: GetEnabledChatModelConfigsByOrganization :many
SELECT
diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go
index e450ab3157e30..45c33b5af1d09 100644
--- a/coderd/exp_chats.go
+++ b/coderd/exp_chats.go
@@ -675,28 +675,7 @@ func lookupEnabledChatModelConfigByID(
db database.Store,
id uuid.UUID,
) (database.ChatModelConfig, error) {
- //nolint:gocritic // Validation lookup uses AsChatd to check model
- // availability independently of the caller's read permissions.
- return db.GetEnabledChatModelConfigByID(dbauthz.AsChatd(ctx), id)
-}
-
-func lookupEnabledDefaultOrgChatModelConfigByID(
- ctx context.Context,
- db database.Store,
- id uuid.UUID,
-) (database.ChatModelConfig, error) {
- modelConfig, err := lookupEnabledChatModelConfigByID(ctx, db, id)
- if err != nil {
- return database.ChatModelConfig{}, err
- }
- defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, db)
- if err != nil {
- return database.ChatModelConfig{}, err
- }
- if modelConfig.OrganizationID != defaultOrg.ID {
- return database.ChatModelConfig{}, sql.ErrNoRows
- }
- return modelConfig, nil
+ return db.GetEnabledChatModelConfigByID(ctx, id)
}
func parseChatModelCallConfig(options json.RawMessage) (*codersdk.ChatModelCallConfig, error) {
@@ -749,6 +728,7 @@ func validateChatModelOverrideEffort(
func validateChatModelOverride(
ctx context.Context,
db database.Store,
+ organizationID uuid.UUID,
id *uuid.UUID,
effort *string,
) (int, *codersdk.Response) {
@@ -765,7 +745,10 @@ func validateChatModelOverride(
Message: "Invalid model_config_id.",
}
}
- modelConfig, err := lookupEnabledDefaultOrgChatModelConfigByID(ctx, db, *id)
+ modelConfig, err := lookupEnabledChatModelConfigByID(ctx, db, *id)
+ if err == nil && modelConfig.OrganizationID != organizationID {
+ err = sql.ErrNoRows
+ }
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return http.StatusBadRequest, &codersdk.Response{
@@ -1010,7 +993,8 @@ func (api *API) chatPersonalModelOverrideDeploymentDefaults(
type userChatModelAvailability struct {
configuredProviders []chatprovider.ConfiguredProvider
configuredModels []chatprovider.ConfiguredModel
- enabledModels []database.GetEnabledChatModelConfigsByOrganizationRow
+ enabledModels []database.ChatModelConfig
+ providerTypeByID map[uuid.UUID]string
providerStatus map[string]chatprovider.ProviderAvailability
providerStatusByID map[uuid.UUID]chatprovider.ProviderAvailability
enabledProviderNames map[string]struct{}
@@ -1030,24 +1014,41 @@ const (
chatModelConfigUnavailableOutsideOrganization chatModelConfigUnavailableReason = "outside_organization"
)
+func defaultOrganizationID(
+ ctx context.Context,
+ db database.Store,
+) (uuid.UUID, error) {
+ //nolint:gocritic // Runtime configuration must be independent of caller permissions.
+ organization, err := db.GetDefaultOrganization(dbauthz.AsChatd(ctx))
+ if err != nil {
+ return uuid.Nil, err
+ }
+ return organization.ID, nil
+}
+
// getUserChatProviderAvailability returns the enabled chat providers and models
-// the user can access. Deployment-level configuration is read as chatd, while
-// user key lookups still use the caller's authorization context.
+// the user can access in one organization. Provider configuration uses Chatd
+// access. Model configs and user keys use the caller's authorization context.
func (api *API) getUserChatProviderAvailability(
ctx context.Context,
userID uuid.UUID,
organizationID uuid.UUID,
) (userChatModelAvailability, error) {
- //nolint:gocritic // Chatd context is required to read enabled chat config.
+ //nolint:gocritic // Chatd context is required to read enabled chat providers.
chatdCtx := dbauthz.AsChatd(ctx)
enabledProviders, err := api.Database.GetAIProviders(chatdCtx, database.GetAIProvidersParams{})
if err != nil {
return userChatModelAvailability{}, err
}
- enabledModels, err := api.Database.GetEnabledChatModelConfigsByOrganization(chatdCtx, organizationID)
+ modelRows, err := api.Database.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
if err != nil {
return userChatModelAvailability{}, err
}
+ effectiveModels := database.DeriveEffectiveChatModelConfigs(modelRows)
+ enabledModels := make([]database.ChatModelConfig, 0, len(effectiveModels.Configs))
+ for _, row := range effectiveModels.Configs {
+ enabledModels = append(enabledModels, row.ChatModelConfig)
+ }
configuredProviders, err := api.configuredProvidersFromAIProviders(chatdCtx, enabledProviders)
if err != nil {
@@ -1057,10 +1058,18 @@ func (api *API) getUserChatProviderAvailability(
configuredProviders: configuredProviders,
configuredModels: make([]chatprovider.ConfiguredModel, 0, len(enabledModels)),
enabledModels: enabledModels,
+ providerTypeByID: make(map[uuid.UUID]string, len(enabledProviders)),
enabledProviderNames: make(map[string]struct{}, len(enabledProviders)),
enabledProviderIDs: make(map[uuid.UUID]struct{}, len(enabledProviders)),
providerStatusByID: make(map[uuid.UUID]chatprovider.ProviderAvailability, len(enabledProviders)),
}
+ // Model configs carry no provider type; resolve it from the enabled
+ // providers. A config under a disabled or deleted provider is skipped
+ // for status purposes, mirroring the provider join on the enabled
+ // models query.
+ for _, provider := range enabledProviders {
+ availability.providerTypeByID[provider.ID] = string(provider.Type)
+ }
for _, configuredProvider := range configuredProviders {
normalizedProvider := chatprovider.NormalizeProvider(configuredProvider.Provider)
if normalizedProvider != "" {
@@ -1072,15 +1081,18 @@ func (api *API) getUserChatProviderAvailability(
}
userKeys := []chatprovider.UserProviderKey{}
if api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value() {
- userKeyRows, err := api.Database.GetUserAIProviderKeysByUserID(ctx, userID)
+ userKeyStatus, err := api.userAIProviderKeyStatusByProviderID(ctx, userID)
if err != nil {
return userChatModelAvailability{}, err
}
- userKeys = make([]chatprovider.UserProviderKey, 0, len(userKeyRows))
- for _, userKey := range userKeyRows {
+ userKeys = make([]chatprovider.UserProviderKey, 0, len(userKeyStatus))
+ for providerID, configured := range userKeyStatus {
+ if !configured {
+ continue
+ }
userKeys = append(userKeys, chatprovider.UserProviderKey{
- ChatProviderID: userKey.AIProviderID,
- APIKey: userKey.APIKey,
+ ChatProviderID: providerID,
+ APIKey: "configured",
})
}
}
@@ -1120,18 +1132,20 @@ func (api *API) getUserChatProviderAvailability(
modelStatusByType := make(map[string]chatprovider.ProviderAvailability, len(enabledModels))
for _, model := range enabledModels {
- normalizedProvider := chatprovider.NormalizeProvider(model.Provider)
- if normalizedProvider == "" {
+ if !model.AIProviderID.Valid {
continue
}
- if model.ChatModelConfig.AIProviderID.Valid {
- status, ok := availability.providerStatusByID[model.ChatModelConfig.AIProviderID.UUID]
- if ok {
- mergeProviderStatus(modelStatusByType, normalizedProvider, status)
- }
+ providerID := model.AIProviderID.UUID
+ providerType, ok := availability.providerTypeByID[providerID]
+ if !ok {
+ continue
+ }
+ normalizedProvider := chatprovider.NormalizeProvider(providerType)
+ if normalizedProvider == "" {
continue
}
- if status, ok := providerStatusByType[normalizedProvider]; ok {
+ status, ok := availability.providerStatusByID[providerID]
+ if ok {
mergeProviderStatus(modelStatusByType, normalizedProvider, status)
}
}
@@ -1141,20 +1155,21 @@ func (api *API) getUserChatProviderAvailability(
}
for _, model := range enabledModels {
- normalizedProvider := chatprovider.NormalizeProvider(model.Provider)
- if model.ChatModelConfig.AIProviderID.Valid {
- status, ok := availability.providerStatusByID[model.ChatModelConfig.AIProviderID.UUID]
- if !ok {
- continue
- }
- if aggregateStatus, ok := availability.providerStatus[normalizedProvider]; ok && aggregateStatus.Available && !status.Available {
- continue
- }
+ if !model.AIProviderID.Valid {
+ continue
+ }
+ providerType, ok := availability.providerTypeByID[model.AIProviderID.UUID]
+ if !ok {
+ continue
+ }
+ status, ok := availability.providerStatusByID[model.AIProviderID.UUID]
+ if !ok || !status.Available {
+ continue
}
availability.configuredModels = append(availability.configuredModels, chatprovider.ConfiguredModel{
- Provider: model.Provider,
- Model: model.ChatModelConfig.Model,
- DisplayName: model.ChatModelConfig.DisplayName,
+ Provider: providerType,
+ Model: model.Model,
+ DisplayName: model.DisplayName,
})
}
return availability, nil
@@ -1172,11 +1187,7 @@ func (api *API) userCanUseChatModelConfig(
if modelConfigID == uuid.Nil {
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
- //nolint:gocritic // Non-admin users need deployment config validation.
- model, err := api.Database.GetChatModelConfigByID(
- dbauthz.AsSystemRestricted(ctx),
- modelConfigID,
- )
+ model, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) {
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
@@ -1214,19 +1225,10 @@ func (api *API) userCanUseChatModelConfig(
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
-func (api *API) validateUserChatModelConfigAvailable(
- ctx context.Context,
- userID uuid.UUID,
- organizationID uuid.UUID,
- modelConfigID uuid.UUID,
+func validateUserChatModelConfigAvailability(
+ modelConfig database.ChatModelConfig,
+ reason chatModelConfigUnavailableReason,
) (database.ChatModelConfig, int, *codersdk.Response) {
- modelConfig, reason, err := api.userCanUseChatModelConfig(ctx, userID, organizationID, modelConfigID)
- if err != nil {
- return database.ChatModelConfig{}, http.StatusInternalServerError, &codersdk.Response{
- Message: "Internal error validating model config override.",
- Detail: err.Error(),
- }
- }
switch reason {
case chatModelConfigAvailable:
return modelConfig, 0, nil
@@ -1244,16 +1246,38 @@ func (api *API) validateUserChatModelConfigAvailable(
Message: "Invalid model_config_id: provider is not enabled for this model.",
}
default:
+ return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{
+ Message: "Invalid model_config_id.",
+ }
+ }
+}
+
+func (api *API) validateUserChatModelConfigAvailable(
+ ctx context.Context,
+ userID uuid.UUID,
+ organizationID uuid.UUID,
+ modelConfigID uuid.UUID,
+) (database.ChatModelConfig, int, *codersdk.Response) {
+ modelConfig, reason, err := api.userCanUseChatModelConfig(ctx, userID, organizationID, modelConfigID)
+ if err != nil {
+ return database.ChatModelConfig{}, http.StatusInternalServerError, &codersdk.Response{
+ Message: "Internal error validating model config override.",
+ Detail: err.Error(),
+ }
+ }
+ if reason != chatModelConfigAvailable &&
+ reason != chatModelConfigUnavailableModelNotFoundOrDisabled &&
+ reason != chatModelConfigUnavailableCredentialsMissing &&
+ reason != chatModelConfigUnavailableProviderDisabled &&
+ reason != chatModelConfigUnavailableOutsideOrganization {
api.Logger.Warn(ctx,
"unknown chat model config availability reason",
slog.F("user_id", userID),
slog.F("model_config_id", modelConfigID),
slog.F("reason", reason),
)
- return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{
- Message: "Invalid model_config_id.",
- }
}
+ return validateUserChatModelConfigAvailability(modelConfig, reason)
}
// validateExplicitChatModelConfigAvailable validates a caller-supplied
@@ -1599,26 +1623,33 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
-// @Summary List chat models
-// @ID list-chat-models
+// @Summary List available chat models in an organization
+// @ID list-chat-model-availability
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
+// @Param organization path string true "Organization name or ID"
// @Success 200 {object} codersdk.ChatModelAvailabilityResponse
-// @Router /api/experimental/chats/models [get]
+// @Router /api/experimental/organizations/{organization}/chats/models/available [get]
// @Description Experimental: this endpoint is subject to change.
-func (api *API) listChatModels(rw http.ResponseWriter, r *http.Request) {
+func (api *API) listChatModelAvailability(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
- defaultOrganization, err := defaultOrganizationForChatModelConfigs(ctx, api.Database)
+ if !chatModelConfigReadScope(apiKey.Scopes) {
+ httpapi.Forbidden(rw)
+ return
+ }
+ organization := httpmw.OrganizationParam(r)
+ visible, err := api.canReadChatModelsInOrganization(ctx, r, organization)
if err != nil {
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Failed to load the default organization.",
- Detail: err.Error(),
- })
+ httpapi.InternalServerError(rw, err)
+ return
+ }
+ if !visible {
+ httpapi.ResourceNotFound(rw)
return
}
- availability, err := api.getUserChatProviderAvailability(ctx, apiKey.UserID, defaultOrganization.ID)
+ availability, err := api.getUserChatProviderAvailability(ctx, apiKey.UserID, organization.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to load chat model configuration.",
@@ -1642,6 +1673,11 @@ func (api *API) listChatModels(rw http.ResponseWriter, r *http.Request) {
)
}
+ response.Models = make([]codersdk.ChatModel, 0, len(availability.enabledModels))
+ for _, model := range availability.enabledModels {
+ response.Models = append(response.Models, convertChatModelConfig(model))
+ }
+
// Both catalog branches drop providers the harness cannot use, so
// attach them here for the empty state.
response.UnsupportedProviders = chatprovider.UnsupportedProviders(availability.configuredProviders)
@@ -2721,6 +2757,17 @@ func writeChatInvalidState(ctx context.Context, rw http.ResponseWriter, err erro
return true
}
+func noLocalChatModelResponse() *codersdk.Response {
+ return &codersdk.Response{
+ Message: "No chat model is available in this organization.",
+ Detail: "Ask an organization administrator to configure and enable a chat model.",
+ }
+}
+
+func writeNoLocalChatModelResponse(ctx context.Context, rw http.ResponseWriter) {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, *noLocalChatModelResponse())
+}
+
// writeCommonChatMutationError writes responses shared by chat
// mutation endpoints. Returns true when a response has been written.
func writeCommonChatMutationError(ctx context.Context, rw http.ResponseWriter, err error, archivedMessage string) bool {
@@ -2926,9 +2973,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
return
}
if xerrors.Is(sendErr, chatd.ErrNoDefaultChatModelConfig) {
- httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
- Message: "No default chat model config is configured.",
- })
+ writeNoLocalChatModelResponse(ctx, rw)
return
}
if errors.Is(sendErr, chatstate.ErrChatNotFound) {
@@ -3091,9 +3136,7 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
Message: "Invalid model config ID.",
})
case xerrors.Is(editErr, chatd.ErrNoDefaultChatModelConfig):
- httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
- Message: "No default chat model config is configured.",
- })
+ writeNoLocalChatModelResponse(ctx, rw)
case errors.Is(editErr, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, editErr):
@@ -3240,6 +3283,8 @@ func (api *API) promoteChatQueuedMessage(rw http.ResponseWriter, r *http.Request
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Queued message not found.",
})
+ case xerrors.Is(txErr, chatd.ErrNoDefaultChatModelConfig):
+ writeNoLocalChatModelResponse(ctx, rw)
case errors.Is(txErr, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, txErr):
@@ -3652,9 +3697,7 @@ func (api *API) regenerateChatTitle(rw http.ResponseWriter, r *http.Request) {
updatedChat, err := api.chatDaemon.RegenerateChatTitle(ctx, chat)
if err != nil {
if errors.Is(err, chatd.ErrNoDefaultChatModelConfig) {
- httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
- Message: "No default chat model config is configured.",
- })
+ writeNoLocalChatModelResponse(ctx, rw)
return
}
if httpapi.Is404Error(err) {
@@ -3701,9 +3744,7 @@ func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) {
title, err := api.chatDaemon.ProposeChatTitle(ctx, chat)
if err != nil {
if errors.Is(err, chatd.ErrNoDefaultChatModelConfig) {
- httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
- Message: "No default chat model config is configured.",
- })
+ writeNoLocalChatModelResponse(ctx, rw)
return
}
if httpapi.Is404Error(err) {
@@ -4439,20 +4480,7 @@ func (api *API) resolveCreateChatModelConfigID(
Message: "Invalid model config ID.",
}
}
- _, reason, err := api.userCanUseChatModelConfig(ctx, userID, req.OrganizationID, *req.ModelConfigID)
- if err != nil {
- return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{
- Message: "Failed to resolve chat model config.",
- Detail: err.Error(),
- }
- }
- if reason == chatModelConfigUnavailableOutsideOrganization {
- return uuid.Nil, nil, http.StatusBadRequest, &codersdk.Response{
- Message: "Model config does not belong to the specified organization.",
- }
- }
- if reason != chatModelConfigAvailable {
- _, status, resp := api.validateUserChatModelConfigAvailable(ctx, userID, req.OrganizationID, *req.ModelConfigID)
+ if _, status, resp := api.validateUserChatModelConfigAvailable(ctx, userID, req.OrganizationID, *req.ModelConfigID); resp != nil {
return uuid.Nil, nil, status, resp
}
return *req.ModelConfigID, nil, 0, nil
@@ -4533,60 +4561,23 @@ func (api *API) resolveCreateChatModelConfigID(
return id, nil, status, resp
}
-// defaultOrganizationForChatModelConfigs resolves the organization used by
-// deployment-wide model configuration routes and overrides.
-func defaultOrganizationForChatModelConfigs(
- ctx context.Context,
- db database.Store,
-) (database.Organization, error) {
- //nolint:gocritic // Deployment-wide routes need internal default organization metadata.
- defaultOrg, err := db.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx))
- if err != nil {
- return database.Organization{}, xerrors.Errorf("get default organization: %w", err)
- }
- return defaultOrg, nil
-}
-
func (api *API) defaultCreateChatModelConfigID(
ctx context.Context,
organizationID uuid.UUID,
) (uuid.UUID, int, *codersdk.Response) {
- defaultModelConfig, err := api.Database.GetDefaultChatModelConfig(ctx, organizationID)
+ rows, err := api.Database.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
if err != nil {
- if xerrors.Is(err, sql.ErrNoRows) {
- return uuid.Nil, http.StatusBadRequest, &codersdk.Response{
- Message: "No default chat model config is configured.",
- }
- }
return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
}
}
-
- // The resolved default may itself be disabled or under a disabled
- // provider.
- enabledDefault, err := lookupEnabledChatModelConfigByID(ctx, api.Database, defaultModelConfig.ID)
- if err != nil {
- if xerrors.Is(err, sql.ErrNoRows) {
- return uuid.Nil, http.StatusBadRequest, &codersdk.Response{
- Message: "No default chat model config is configured.",
- Detail: "The default chat model or its provider is disabled.",
- }
- }
- return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{
- Message: "Failed to resolve chat model config.",
- Detail: err.Error(),
- }
+ effective := database.DeriveEffectiveChatModelConfigs(rows)
+ if effective.DefaultConfig.ID != uuid.Nil {
+ return effective.DefaultConfig.ID, 0, nil
}
- if enabledDefault.OrganizationID != organizationID {
- return uuid.Nil, http.StatusBadRequest, &codersdk.Response{
- Message: "No default chat model config is configured.",
- }
- }
-
- return defaultModelConfig.ID, 0, nil
+ return uuid.Nil, http.StatusBadRequest, noLocalChatModelResponse()
}
// validateChatCompressionThreshold enforces the chat_model_configs CHECK
@@ -5084,7 +5075,15 @@ func (api *API) putChatModelOverride(rw http.ResponseWriter, r *http.Request) {
modelConfigID = &parsedModelConfigID
}
- status, resp := validateChatModelOverride(ctx, api.Database, modelConfigID, req.ReasoningEffort)
+ defaultOrgID, err := defaultOrganizationID(ctx, api.Database)
+ if err != nil {
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error validating model config override.",
+ Detail: err.Error(),
+ })
+ return
+ }
+ status, resp := validateChatModelOverride(ctx, api.Database, defaultOrgID, modelConfigID, req.ReasoningEffort)
if resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
@@ -5324,7 +5323,7 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http
})
return
}
- defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, api.Database)
+ defaultOrgID, err := defaultOrganizationID(ctx, api.Database)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to resolve default organization.",
@@ -5335,7 +5334,7 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http
modelConfig, status, resp := api.validateUserChatModelConfigAvailable(
ctx,
apiKey.UserID,
- defaultOrg.ID,
+ defaultOrgID,
parsedModelConfigID,
)
if resp != nil {
@@ -5619,7 +5618,18 @@ func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) {
return
}
} else {
+ defaultOrgID, err := defaultOrganizationID(ctx, api.Database)
+ if err != nil {
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error validating advisor model config.",
+ Detail: err.Error(),
+ })
+ return
+ }
modelConfig, err := lookupEnabledChatModelConfigByID(ctx, api.Database, req.ModelConfigID)
+ if err == nil && modelConfig.OrganizationID != defaultOrgID {
+ err = sql.ErrNoRows
+ }
if err != nil {
if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@@ -6074,10 +6084,8 @@ func (api *API) putUserChatCompactionThreshold(rw http.ResponseWriter, r *http.R
return
}
- // Use system context because GetChatModelConfigByID requires
- // deployment-config read access, which non-admin users lack.
- // The user is only checking if the model exists and is enabled
- // before writing their own personal preference.
+ // The preference is personal, so model existence checks must not depend on
+ // whether the user can read the organization model configuration.
//nolint:gocritic // Non-admin users need this lookup to save their own setting.
modelConfig, err := api.Database.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), modelConfigID)
if err != nil {
@@ -6849,6 +6857,22 @@ func (api *API) deleteUserAIProviderKey(rw http.ResponseWriter, r *http.Request)
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}
+// userAIProviderKeyStatusByProviderID returns only credential presence for the
+// requesting user. The privileged lookup is contained here so callers never
+// receive key material or require user:read_personal.
+func (api *API) userAIProviderKeyStatusByProviderID(ctx context.Context, userID uuid.UUID) (map[uuid.UUID]bool, error) {
+ //nolint:gocritic // The result is reduced to credential presence for exactly the requesting user.
+ rows, err := api.Database.GetUserAIProviderKeysByUserID(dbauthz.AsChatd(ctx), userID)
+ if err != nil {
+ return nil, err
+ }
+ statusByProviderID := make(map[uuid.UUID]bool, len(rows))
+ for _, row := range rows {
+ statusByProviderID[row.AIProviderID] = strings.TrimSpace(row.APIKey) != ""
+ }
+ return statusByProviderID, nil
+}
+
func (api *API) configuredProvidersFromAIProviders(ctx context.Context, providers []database.AIProvider) ([]chatprovider.ConfiguredProvider, error) {
if len(providers) == 0 {
return nil, nil
@@ -6926,8 +6950,9 @@ func (*API) deleteUserChatProviderKey(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
-func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) {
+func (api *API) listDefaultOrganizationChatModelConfigs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
+ organization := httpmw.OrganizationParam(r)
apiKey := httpmw.APIKey(r)
if !chatModelConfigReadScope(apiKey.Scopes) {
@@ -6935,16 +6960,53 @@ func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) {
return
}
- defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, api.Database)
+ configs, err := api.Database.GetChatModelConfigs(ctx, organization.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Failed to resolve default organization.",
+ Message: "Failed to list chat model configs.",
Detail: err.Error(),
})
return
}
- allConfigs, err := api.Database.GetChatModelConfigs(ctx)
+ resp := make([]codersdk.ChatModel, 0, len(configs))
+ for _, config := range configs {
+ resp = append(resp, convertChatModelConfig(config))
+ }
+ httpapi.Write(ctx, rw, http.StatusOK, resp)
+}
+
+// @Summary List AI models and provider descriptors in an organization
+// @ID list-ai-models-by-organization
+// @Security CoderSessionToken
+// @Tags Chats
+// @Produce json
+// @Param organization path string true "Organization name or ID"
+// @Success 200 {object} codersdk.OrganizationChatModelsResponse
+// @Router /api/experimental/organizations/{organization}/chats/models [get]
+// @x-apidocgen {"skip": true}
+func (api *API) listChatModelConfigsByOrganization(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ organization := httpmw.OrganizationParam(r)
+ apiKey := httpmw.APIKey(r)
+
+ // Keep the token scope gate separate from the authorized database filter.
+ // The filter applies each config ACL and the organization predicate.
+ if !chatModelConfigReadScope(apiKey.Scopes) {
+ httpapi.Forbidden(rw)
+ return
+ }
+ visible, err := api.canReadChatModelsInOrganization(ctx, r, organization)
+ if err != nil {
+ httpapi.InternalServerError(rw, err)
+ return
+ }
+ if !visible {
+ httpapi.ResourceNotFound(rw)
+ return
+ }
+
+ configs, err := api.Database.GetChatModelConfigs(ctx, organization.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list chat model configs.",
@@ -6953,21 +7015,140 @@ func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) {
return
}
- resp := make([]codersdk.ChatModel, 0, len(allConfigs))
- for _, config := range allConfigs {
- if config.OrganizationID == defaultOrg.ID {
- resp = append(resp, convertChatModelConfig(config))
- }
+ providers, err := api.chatModelProviderDescriptors(ctx, apiKey.UserID)
+ if err != nil {
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Failed to list AI providers.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ resp := codersdk.OrganizationChatModelsResponse{
+ Models: make([]codersdk.ChatModel, 0, len(configs)),
+ Providers: providers,
+ }
+ for _, config := range configs {
+ resp.Models = append(resp.Models, convertChatModelConfig(config))
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
+func (api *API) canReadChatModelsInOrganization(
+ ctx context.Context,
+ r *http.Request,
+ organization database.Organization,
+) (bool, error) {
+ configs, err := api.Database.GetChatModelConfigs(ctx, organization.ID)
+ if err != nil {
+ return false, err
+ }
+ for _, config := range configs {
+ if api.Authorize(r, policy.ActionRead, chatModelConfigRBACObject(config)) {
+ return true, nil
+ }
+ }
+ return api.Authorize(r, policy.ActionRead, organization.RBACObject()), nil
+}
+
func chatModelConfigReadScope(scopes database.APIKeyScopes) bool {
return scopes.Has(database.ApiKeyScopeCoderAll) ||
scopes.Has(database.ApiKeyScopeChatModelConfigRead)
}
+// chatModelProviderDescriptors assembles the redacted provider descriptors
+// for the org model collection. The caller already passed the org's model
+// read gate; providers are deployment-scoped and an org admin cannot read
+// them directly, so the fetch runs under a narrow AsChatd context scoped to
+// exactly these two reads and the result is projected to the fixed redacted
+// fields (no key material, base URLs, or headers). Disclosure matches what
+// /api/experimental/chats/models already shows any authenticated caller.
+func (api *API) chatModelProviderDescriptors(
+ ctx context.Context,
+ userID uuid.UUID,
+) ([]codersdk.ChatModelProviderDescriptor, error) {
+ //nolint:gocritic // Fixed redacted projection under the model read gate; see function doc.
+ providers, err := api.Database.GetAIProviders(dbauthz.AsChatd(ctx), database.GetAIProvidersParams{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,43 +7195,42 @@ func (api *API) inChatModelConfigWriteTx(
}, &database.TxOptions{Isolation: sql.LevelReadCommitted})
}
+// @Summary Create an AI model in an organization
+// @ID create-ai-model
+// @Security CoderSessionToken
+// @Tags Chats
+// @Accept json
+// @Produce json
+// @Param organization path string true "Organization name or ID"
+// @Param request body codersdk.CreateChatModelRequest true "Model"
+// @Success 201 {object} codersdk.ChatModel
+// @Router /api/experimental/organizations/{organization}/chats/models [post]
+// @x-apidocgen {"skip": true}
func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
- // Interim write gate: see the dbauthz InsertChatModelConfig check.
- // TODO(mafredri): remove after CODAGT-709 M3 (org-scoping cutover)
- if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
- httpapi.Forbidden(rw)
- return
- }
+ organization := httpmw.OrganizationParam(r)
- var req codersdk.CreateChatModelRequest
- if !httpapi.Read(ctx, rw, r, &req) {
- return
+ var req struct {
+ codersdk.CreateChatModelRequest
+ GroupACL json.RawMessage `json:"group_acl"`
+ UserACL json.RawMessage `json:"user_acl"`
}
-
- if req.AIProviderID == nil {
- httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "AI provider ID is required."})
+ if !httpapi.Read(ctx, rw, r, &req) {
return
}
- //nolint:gocritic // The route already authorized chat model config updates.
- aiProvider, err := api.Database.GetAIProviderByID(dbauthz.AsChatd(ctx), *req.AIProviderID)
- if err != nil {
- if httpapi.Is404Error(err) {
- writeChatProviderPreconditionError(ctx, rw, errChatProviderMissing)
- return
- }
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Failed to get AI provider.",
- Detail: err.Error(),
+ if req.GroupACL != nil || req.UserACL != nil {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Model ACLs cannot be set here. Use the nested /acl endpoint after creating the model.",
})
return
}
- if !aiProvider.Enabled {
- writeChatProviderPreconditionError(ctx, rw, errChatProviderDisabled)
+
+ if req.AIProviderID == nil {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "AI provider ID is required."})
return
}
- aiProviderID := uuid.NullUUID{UUID: aiProvider.ID, Valid: true}
+ aiProviderID := uuid.NullUUID{UUID: *req.AIProviderID, Valid: true}
model := strings.TrimSpace(req.Model)
if model == "" {
@@ -7060,11 +7240,6 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
return
}
- if validationErr := validateChatModelConfigProviderModel(aiProvider, model); validationErr != nil {
- httpapi.Write(ctx, rw, http.StatusBadRequest, validationErr.Response)
- return
- }
-
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
@@ -7104,16 +7279,11 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
return
}
- defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, api.Database)
- if err != nil {
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Failed to resolve default organization.",
- Detail: err.Error(),
- })
- return
- }
+ // Seed the everyone-in-org read entry so the config stays visible to
+ // members once ACLs are enforced; the Everyone group shares the
+ // organization's ID.
everyoneReadACL := database.ChatACL{
- defaultOrg.ID.String(): {Permissions: []policy.Action{policy.ActionRead}},
+ organization.ID.String(): {Permissions: []policy.Action{policy.ActionRead}},
}
insertParams := database.InsertChatModelConfigParams{
@@ -7127,42 +7297,21 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
AIProviderID: aiProviderID,
CreatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil},
UpdatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil},
- OrganizationID: defaultOrg.ID,
+ OrganizationID: organization.ID,
GroupACL: everyoneReadACL,
UserACL: database.ChatACL{},
}
var inserted database.ChatModelConfig
- err = api.inChatModelConfigWriteTx(ctx, insertParams.OrganizationID, func(tx database.Store) error {
- //nolint:gocritic // The route already authorized chat model config updates.
- lockedAIProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), insertParams.AIProviderID.UUID)
- if err != nil {
- if xerrors.Is(err, sql.ErrNoRows) {
- return errChatProviderMissing
- }
- return xerrors.Errorf("get AI provider for create: %w", err)
- }
- if !lockedAIProvider.Enabled {
- return errChatProviderDisabled
- }
- if err := validateChatModelConfigProviderModel(lockedAIProvider, insertParams.Model); err != nil {
- return err
+ err := api.inChatModelConfigWriteTx(ctx, insertParams.OrganizationID, func(tx database.Store) error {
+ _, err := tx.GetDefaultChatModelConfig(ctx, insertParams.OrganizationID)
+ defaultExists := err == nil
+ if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
+ return xerrors.Errorf("get default model config: %w", err)
}
+ insertAsDefault := isDefault || !defaultExists
- insertAsDefault := isDefault
- if !insertAsDefault {
- _, err := tx.GetDefaultChatModelConfig(ctx, insertParams.OrganizationID)
- switch {
- case err == nil:
- // A default already exists.
- case xerrors.Is(err, sql.ErrNoRows):
- insertAsDefault = true
- default:
- return xerrors.Errorf("get default model config: %w", err)
- }
- }
-
- if insertAsDefault {
+ if insertAsDefault && defaultExists {
if err := tx.UnsetDefaultChatModelConfigs(ctx, insertParams.OrganizationID); err != nil {
return xerrors.Errorf("unset default model configs: %w", err)
}
@@ -7171,10 +7320,28 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
config, err := tx.InsertChatModelConfig(ctx, insertParams)
if err != nil {
+ if database.IsForeignKeyViolation(err, database.ForeignKeyChatModelConfigsAIProviderID) {
+ return errChatProviderMissing
+ }
return err
}
inserted = config
+ //nolint:gocritic // The provider fetch only reads the redacted descriptor fields.
+ lockedAIProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), insertParams.AIProviderID.UUID)
+ if err != nil {
+ if xerrors.Is(err, sql.ErrNoRows) {
+ return errChatProviderMissing
+ }
+ return xerrors.Errorf("get AI provider for create: %w", err)
+ }
+ if !lockedAIProvider.Enabled {
+ return errChatProviderDisabled
+ }
+ if err := validateChatModelConfigProviderModel(lockedAIProvider, insertParams.Model); err != nil {
+ return err
+ }
+
if err := ensureDefaultChatModelConfig(ctx, tx, insertParams.OrganizationID); err != nil {
return err
}
@@ -7201,6 +7368,11 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
Detail: err.Error(),
})
return
+ case dbauthz.IsNotAuthorizedError(err):
+ // The dbauthz create-in-org check is the write access boundary;
+ // surface its denial as 403, not a concealed 404 or a 500.
+ httpapi.Forbidden(rw)
+ return
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat model config.",
@@ -7215,50 +7387,41 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusCreated, convertChatModelConfig(inserted))
}
+// @Summary Update an AI model
+// @ID update-ai-model
+// @Security CoderSessionToken
+// @Tags Chats
+// @Accept json
+// @Produce json
+// @Param organization path string true "Organization name or ID"
+// @Param model path string true "Model ID"
+// @Param request body codersdk.UpdateChatModelRequest true "Model updates"
+// @Success 200 {object} codersdk.ChatModel
+// @Router /api/experimental/organizations/{organization}/chats/models/{model} [patch]
+// @x-apidocgen {"skip": true}
func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
- // Interim write gate: see createChatModelConfig.
- // TODO(mafredri): remove after CODAGT-709 M3 (org-scoping cutover)
- if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
- httpapi.Forbidden(rw)
+ existing := httpmw.ChatModelConfigParam(r)
+ if !api.Authorize(r, policy.ActionUpdate, chatModelConfigRBACObject(existing)) {
+ httpapi.ResourceNotFound(rw)
return
}
- modelConfigID, ok := parseChatModelConfigID(rw, r)
- if !ok {
- return
+ var req struct {
+ codersdk.UpdateChatModelRequest
+ GroupACL json.RawMessage `json:"group_acl"`
+ UserACL json.RawMessage `json:"user_acl"`
}
-
- existing, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID)
- if err != nil {
- if httpapi.Is404Error(err) {
- httpapi.ResourceNotFound(rw)
- return
- }
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Failed to get chat model config.",
- Detail: err.Error(),
- })
+ if !httpapi.Read(ctx, rw, r, &req) {
return
}
- defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, api.Database)
- if err != nil {
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Failed to resolve default organization.",
- Detail: err.Error(),
+ if req.GroupACL != nil || req.UserACL != nil {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Model ACLs cannot be updated here. Use the nested /acl endpoint.",
})
return
}
- if existing.OrganizationID != defaultOrg.ID {
- httpapi.ResourceNotFound(rw)
- return
- }
-
- var req codersdk.UpdateChatModelRequest
- if !httpapi.Read(ctx, rw, r, &req) {
- return
- }
if req.ContextLimit != nil && *req.ContextLimit <= 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@@ -7295,11 +7458,12 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
}
var updated database.ChatModelConfig
- err = api.inChatModelConfigWriteTx(ctx, existing.OrganizationID, func(tx database.Store) error {
- // The unlocked read above only rejects unknown IDs; a concurrent writer
- // can change or delete the row between the two reads, so merge against
- // this copy.
- lockedExisting, err := tx.GetChatModelConfigByID(ctx, modelConfigID)
+ err := api.inChatModelConfigWriteTx(ctx, existing.OrganizationID, func(tx database.Store) error {
+ // The middleware lookup above only rejects unknown IDs; a concurrent
+ // writer can change or delete the row between the two reads, so merge
+ // against this copy.
+ //nolint:gocritic // The model update below reauthorizes the locked row for update.
+ lockedExisting, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), existing.ID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return errChatModelConfigNotFound
@@ -7357,7 +7521,7 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
// invalidate the stored provider/model pair.
revalidateProviderModel := updateParams.AIProviderID.Valid && (req.AIProviderID != nil || strings.TrimSpace(req.Model) != "")
if revalidateProviderModel {
- //nolint:gocritic // The route already authorized chat model config updates.
+ //nolint:gocritic // The provider fetch only reads the redacted descriptor fields.
aiProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), updateParams.AIProviderID.UUID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
@@ -7402,7 +7566,8 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
return err
}
- refreshedConfig, err := tx.GetChatModelConfigByID(ctx, lockedExisting.ID)
+ //nolint:gocritic // The update above reauthorized the locked row.
+ refreshedConfig, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), lockedExisting.ID)
if err != nil {
return xerrors.Errorf("refresh updated chat model config: %w", err)
}
@@ -7424,7 +7589,12 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
Detail: err.Error(),
})
return
- case xerrors.Is(err, errChatModelConfigNotFound):
+ case dbauthz.IsNotAuthorizedError(err):
+ // The dbauthz object update check is the write access boundary;
+ // surface its denial as 403, not a concealed 404 or a 500.
+ httpapi.Forbidden(rw)
+ return
+ case xerrors.Is(err, errChatModelConfigNotFound), httpapi.Is404Error(err):
httpapi.ResourceNotFound(rw)
return
default:
@@ -7441,47 +7611,25 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, convertChatModelConfig(updated))
}
+// @Summary Delete an AI model
+// @ID delete-ai-model
+// @Security CoderSessionToken
+// @Tags Chats
+// @Param organization path string true "Organization name or ID"
+// @Param model path string true "Model ID"
+// @Success 204
+// @Router /api/experimental/organizations/{organization}/chats/models/{model} [delete]
+// @x-apidocgen {"skip": true}
func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- // Interim write gate: see createChatModelConfig.
- // TODO(mafredri): remove after CODAGT-709 M3 (org-scoping cutover)
- if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
- httpapi.Forbidden(rw)
- return
- }
-
- modelConfigID, ok := parseChatModelConfigID(rw, r)
- if !ok {
- return
- }
-
- existing, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID)
- if err != nil {
- if httpapi.Is404Error(err) {
- httpapi.ResourceNotFound(rw)
- return
- }
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Failed to get chat model config.",
- Detail: err.Error(),
- })
- return
- }
- defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, api.Database)
- if err != nil {
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Failed to resolve default organization.",
- Detail: err.Error(),
- })
- return
- }
- if existing.OrganizationID != defaultOrg.ID {
+ existing := httpmw.ChatModelConfigParam(r)
+ if !api.Authorize(r, policy.ActionDelete, chatModelConfigRBACObject(existing)) {
httpapi.ResourceNotFound(rw)
return
}
if err := api.inChatModelConfigWriteTx(ctx, existing.OrganizationID, func(tx database.Store) error {
- if _, err := tx.DeleteChatModelConfigByID(ctx, modelConfigID); err != nil {
+ if _, err := tx.DeleteChatModelConfigByID(ctx, existing.ID); err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return errChatModelConfigNotFound
}
@@ -7489,7 +7637,13 @@ func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) {
}
return ensureDefaultChatModelConfig(ctx, tx, existing.OrganizationID)
}); err != nil {
- if xerrors.Is(err, errChatModelConfigNotFound) {
+ if dbauthz.IsNotAuthorizedError(err) {
+ // The dbauthz object delete check is the write access boundary;
+ // surface its denial as 403, not a concealed 404 or a 500.
+ httpapi.Forbidden(rw)
+ return
+ }
+ if xerrors.Is(err, errChatModelConfigNotFound) || httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
@@ -7500,7 +7654,7 @@ func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) {
return
}
- publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, modelConfigID)
+ publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, existing.ID)
rw.WriteHeader(http.StatusNoContent)
}
@@ -7513,7 +7667,8 @@ func ensureDefaultChatModelConfig(
organizationID uuid.UUID,
excludedConfigIDs ...uuid.UUID,
) error {
- _, err := tx.GetDefaultChatModelConfig(ctx, organizationID)
+ //nolint:gocritic // Default election is contained by action-authorized writes.
+ _, err := tx.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), organizationID)
switch {
case err == nil:
return nil
@@ -7521,16 +7676,10 @@ func ensureDefaultChatModelConfig(
return xerrors.Errorf("get default model config: %w", err)
}
- modelConfigs, err := tx.GetDefaultChatModelConfigCandidates(ctx)
+ orgModelConfigs, err := tx.GetChatModelConfigsByOrganization(ctx, organizationID)
if err != nil {
return xerrors.Errorf("list default chat model config candidates: %w", err)
}
- orgModelConfigs := make([]database.ChatModelConfig, 0, len(modelConfigs))
- for _, config := range modelConfigs {
- if config.OrganizationID == organizationID {
- orgModelConfigs = append(orgModelConfigs, config)
- }
- }
if len(orgModelConfigs) == 0 {
return nil
}
@@ -7539,8 +7688,7 @@ func ensureDefaultChatModelConfig(
// enabled provider) so the promoted default does not reject
// omitted-model chat creation. Fall back to any non-excluded config
// when no usable candidate exists.
- //nolint:gocritic // Candidate usability depends on deployment-wide provider state, not the caller's permissions.
- enabledRows, err := tx.GetEnabledChatModelConfigsByOrganization(dbauthz.AsChatd(ctx), organizationID)
+ enabledRows, err := tx.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
if err != nil {
return xerrors.Errorf("list enabled chat model configs: %w", err)
}
@@ -7634,6 +7782,7 @@ func convertChatModelConfig(config database.ChatModelConfig) codersdk.ChatModel
// chat_model_configs_ai_provider_required_when_active).
return codersdk.ChatModel{
ID: config.ID,
+ OrganizationID: config.OrganizationID,
AIProviderID: config.AIProviderID.UUID,
Model: config.Model,
DisplayName: config.DisplayName,
diff --git a/coderd/exp_chats_model_acl.go b/coderd/exp_chats_model_acl.go
new file mode 100644
index 0000000000000..de1922d41799b
--- /dev/null
+++ b/coderd/exp_chats_model_acl.go
@@ -0,0 +1,299 @@
+package coderd
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "maps"
+ "net/http"
+ "slices"
+
+ "github.com/google/uuid"
+ "golang.org/x/xerrors"
+
+ "github.com/coder/coder/v2/coderd/audit"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/db2sdk"
+ "github.com/coder/coder/v2/coderd/database/dbauthz"
+ "github.com/coder/coder/v2/coderd/httpapi"
+ "github.com/coder/coder/v2/coderd/httpmw"
+ "github.com/coder/coder/v2/coderd/pubsub"
+ "github.com/coder/coder/v2/coderd/rbac/acl"
+ "github.com/coder/coder/v2/coderd/rbac/policy"
+ "github.com/coder/coder/v2/codersdk"
+)
+
+// EXPERIMENTAL: this endpoint is experimental and is subject to change.
+//
+// @Summary Get an AI model ACL
+// @ID get-ai-model-acl
+// @Security CoderSessionToken
+// @Tags Chats
+// @Produce json
+// @Param organization path string true "Organization name or ID"
+// @Param model path string true "Model ID" format(uuid)
+// @Success 200 {object} codersdk.ChatModelACL
+// @Router /api/experimental/organizations/{organization}/chats/models/{model}/acl [get]
+// @x-apidocgen {"skip": true}
+func (api *API) chatModelConfigACLHandler(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ config := httpmw.ChatModelConfigParam(r)
+ if !api.Authorize(r, policy.ActionShare, chatModelConfigRBACObject(config)) {
+ httpapi.ResourceNotFound(rw)
+ return
+ }
+
+ httpapi.Write(ctx, rw, http.StatusOK, chatModelConfigACL(config))
+}
+
+type chatModelACLValidationError struct {
+ Validations []codersdk.ValidationError
+}
+
+func (*chatModelACLValidationError) Error() string {
+ return "invalid chat model ACL"
+}
+
+// EXPERIMENTAL: this endpoint is experimental and is subject to change.
+//
+// @Summary Update an AI model ACL
+// @ID update-ai-model-acl
+// @Security CoderSessionToken
+// @Tags Chats
+// @Accept json
+// @Param organization path string true "Organization name or ID"
+// @Param model path string true "Model ID" format(uuid)
+// @Param request body codersdk.UpdateChatModelACLRequest true "Sparse model ACL update"
+// @Success 204
+// @Router /api/experimental/organizations/{organization}/chats/models/{model}/acl [patch]
+// @x-apidocgen {"skip": true}
+func (api *API) updateChatModelConfigACL(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ apiKey := httpmw.APIKey(r)
+ config := httpmw.ChatModelConfigParam(r)
+ if !api.Authorize(r, policy.ActionShare, chatModelConfigRBACObject(config)) {
+ httpapi.ResourceNotFound(rw)
+ return
+ }
+
+ auditor := api.Auditor.Load()
+ aReq, commitAudit := audit.InitRequest[database.ChatModelConfig](rw, &audit.RequestParams{
+ Audit: *auditor,
+ Log: api.Logger,
+ Request: r,
+ Action: database.AuditActionWrite,
+ OrganizationID: config.OrganizationID,
+ })
+ defer commitAudit()
+ aReq.Old = config
+
+ var req codersdk.UpdateChatModelACLRequest
+ if !httpapi.Read(ctx, rw, r, &req) {
+ return
+ }
+
+ userRoles, validations := canonicalChatModelACLRoles("user_roles", req.UserRoles)
+ groupRoles, duplicateErrors := canonicalChatModelACLRoles("group_roles", req.GroupRoles)
+ validations = append(validations, duplicateErrors...)
+ if len(validations) > 0 {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Invalid request to update chat model ACL.",
+ Validations: validations,
+ })
+ return
+ }
+
+ var updated database.ChatModelConfig
+ err := api.inChatModelConfigWriteTx(ctx, config.OrganizationID, func(tx database.Store) error {
+ //nolint:gocritic // The ACL write below reauthorizes the locked row for share.
+ current, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), config.ID)
+ if err != nil {
+ return xerrors.Errorf("get chat model config for ACL update: %w", err)
+ }
+ aReq.Old = current
+
+ validations := acl.Validate(ctx, tx, ChatModelACLUpdateValidator(req))
+ validations = append(validations, validateChatModelACLOrganization(
+ ctx,
+ tx,
+ config.OrganizationID,
+ userRoles,
+ groupRoles,
+ )...)
+ if len(validations) > 0 {
+ return &chatModelACLValidationError{Validations: validations}
+ }
+
+ userACL := maps.Clone(current.UserACL)
+ if userACL == nil {
+ userACL = database.ChatACL{}
+ }
+ groupACL := maps.Clone(current.GroupACL)
+ if groupACL == nil {
+ groupACL = database.ChatACL{}
+ }
+ applyChatModelACLRoles(userACL, userRoles)
+ applyChatModelACLRoles(groupACL, groupRoles)
+
+ updated, err = tx.UpdateChatModelConfigACLByID(ctx, database.UpdateChatModelConfigACLByIDParams{
+ ID: config.ID,
+ UserACL: userACL,
+ GroupACL: groupACL,
+ UpdatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil},
+ })
+ return err
+ })
+ if err != nil {
+ var validationErr *chatModelACLValidationError
+ switch {
+ case errors.As(err, &validationErr):
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Invalid request to update chat model ACL.",
+ Validations: validationErr.Validations,
+ })
+ case dbauthz.IsNotAuthorizedError(err):
+ httpapi.Forbidden(rw)
+ case xerrors.Is(err, sql.ErrNoRows), xerrors.Is(err, errChatModelConfigNotFound), httpapi.Is404Error(err):
+ httpapi.ResourceNotFound(rw)
+ default:
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Failed to update chat model ACL.",
+ Detail: err.Error(),
+ })
+ }
+ return
+ }
+
+ aReq.New = updated
+ publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, updated.ID)
+ rw.WriteHeader(http.StatusNoContent)
+}
+
+func chatModelConfigACL(config database.ChatModelConfig) codersdk.ChatModelACL {
+ return codersdk.ChatModelACL{
+ UserRoles: chatModelACLRoles(config.UserACL),
+ GroupRoles: chatModelACLRoles(config.GroupACL),
+ }
+}
+
+func chatModelACLRoles(entries database.ChatACL) map[string]codersdk.ChatRole {
+ roles := make(map[string]codersdk.ChatRole, len(entries))
+ for id, entry := range entries {
+ roles[id] = convertToChatRole(entry.Permissions)
+ }
+ return roles
+}
+
+func applyChatModelACLRoles(entries database.ChatACL, roles map[string]codersdk.ChatRole) {
+ for id, role := range roles {
+ if role == codersdk.ChatRoleDeleted {
+ delete(entries, id)
+ continue
+ }
+ entries[id] = database.ChatACLEntry{Permissions: db2sdk.ChatRoleActions(role)}
+ }
+}
+
+func canonicalChatModelACLRoles(field string, roles map[string]codersdk.ChatRole) (map[string]codersdk.ChatRole, []codersdk.ValidationError) {
+ canonical := make(map[string]codersdk.ChatRole, len(roles))
+ var validations []codersdk.ValidationError
+ for rawID, role := range roles {
+ parsed, err := uuid.Parse(rawID)
+ if err != nil {
+ continue
+ }
+ id := parsed.String()
+ if _, ok := canonical[id]; ok {
+ validations = append(validations, codersdk.ValidationError{
+ Field: field,
+ Detail: fmt.Sprintf("duplicate entries for ID %s", id),
+ })
+ continue
+ }
+ canonical[id] = role
+ }
+ return canonical, validations
+}
+
+func validateChatModelACLOrganization(
+ ctx context.Context,
+ db database.Store,
+ organizationID uuid.UUID,
+ userRoles map[string]codersdk.ChatRole,
+ groupRoles map[string]codersdk.ChatRole,
+) []codersdk.ValidationError {
+ var validations []codersdk.ValidationError
+ userIDs := chatModelACLRoleIDs(userRoles)
+ if len(userIDs) > 0 {
+ //nolint:gocritic // Principal validation requires organization membership visibility.
+ memberships, err := db.GetOrganizationIDsByMemberIDs(dbauthz.AsSystemRestricted(ctx), userIDs)
+ if err != nil {
+ validations = append(validations, codersdk.ValidationError{Field: "user_roles", Detail: err.Error()})
+ } else {
+ byUser := make(map[uuid.UUID][]uuid.UUID, len(memberships))
+ for _, membership := range memberships {
+ byUser[membership.UserID] = membership.OrganizationIDs
+ }
+ for _, id := range userIDs {
+ if !slices.Contains(byUser[id], organizationID) {
+ validations = append(validations, codersdk.ValidationError{
+ Field: "user_roles",
+ Detail: "user " + id.String() + " does not belong to organization " + organizationID.String(),
+ })
+ }
+ }
+ }
+ }
+
+ groupIDs := chatModelACLRoleIDs(groupRoles)
+ if len(groupIDs) > 0 {
+ //nolint:gocritic // Principal validation requires group organization visibility.
+ groups, err := db.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs})
+ if err != nil {
+ validations = append(validations, codersdk.ValidationError{Field: "group_roles", Detail: err.Error()})
+ } else {
+ for _, group := range groups {
+ if group.Group.OrganizationID != organizationID {
+ validations = append(validations, codersdk.ValidationError{
+ Field: "group_roles",
+ Detail: "group " + group.Group.ID.String() + " does not belong to organization " + organizationID.String(),
+ })
+ }
+ }
+ }
+ }
+ return validations
+}
+
+func chatModelACLRoleIDs(roles map[string]codersdk.ChatRole) []uuid.UUID {
+ ids := make([]uuid.UUID, 0, len(roles))
+ for rawID, role := range roles {
+ if role == codersdk.ChatRoleDeleted {
+ continue
+ }
+ if id, err := uuid.Parse(rawID); err == nil {
+ ids = append(ids, id)
+ }
+ }
+ return ids
+}
+
+type ChatModelACLUpdateValidator codersdk.UpdateChatModelACLRequest
+
+var _ acl.UpdateValidator[codersdk.ChatRole] = ChatModelACLUpdateValidator{}
+
+func (c ChatModelACLUpdateValidator) Users() (map[string]codersdk.ChatRole, string) {
+ return c.UserRoles, "user_roles"
+}
+
+func (c ChatModelACLUpdateValidator) Groups() (map[string]codersdk.ChatRole, string) {
+ return c.GroupRoles, "group_roles"
+}
+
+func (ChatModelACLUpdateValidator) ValidateRole(role codersdk.ChatRole) error {
+ if role == codersdk.ChatRoleRead || role == codersdk.ChatRoleDeleted {
+ return nil
+ }
+ return xerrors.Errorf("role %q is not a valid chat model role", role)
+}
diff --git a/coderd/exp_chats_model_acl_test.go b/coderd/exp_chats_model_acl_test.go
new file mode 100644
index 0000000000000..93bf3d20ec564
--- /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 2cf19fcc2f8a3..bbe7dbf9ff2f4 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)
}
- require.NoError(t, err)
- require.NotEmpty(t, configs)
+ if testCase.wantItemStatus != 0 {
+ requireSDKError(t, itemErr, testCase.wantItemStatus)
+ } else {
+ require.NoError(t, itemErr)
+ require.Equal(t, ownEnabled.ID, model.ID)
+ }
+ requireSDKError(t, aclErr, testCase.wantACLStatus)
})
}
- testCases := []struct {
- name string
- client func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient
- visible []uuid.UUID
- hidden []uuid.UUID
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID)
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ for _, endpoint := range []struct {
+ name string
+ call func(context.Context, uuid.UUID) error
}{
{
- name: "OwnerSeesDefaultOrgConfigs",
- client: func(*testing.T, context.Context) *codersdk.ExperimentalClient {
- return client
+ name: "Collection",
+ call: func(ctx context.Context, organizationID uuid.UUID) error {
+ _, err := memberClient.ChatModels(ctx, organizationID)
+ return err
},
- visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID},
- hidden: []uuid.UUID{otherEnabled.ID},
},
{
- name: "SiteAuditorSeesDefaultOrgConfigs",
- client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
- rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.RoleAuditor())
- return codersdk.NewExperimentalClient(rawClient)
+ name: "Availability",
+ call: func(ctx context.Context, organizationID uuid.UUID) error {
+ _, err := memberClient.ChatModelAvailability(ctx, organizationID)
+ return err
},
- visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID},
- hidden: []uuid.UUID{otherEnabled.ID},
},
{
- name: "CustomSiteReadRoleSeesDefaultOrgConfigs",
- client: func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient {
- return newSiteCustomRoleClient(ctx, t, client, rawDB, defaultOrg.ID, database.CustomRolePermission{
- ResourceType: rbac.ResourceChatModelConfig.Type,
- Action: policy.ActionRead,
- })
+ name: "Item",
+ call: func(ctx context.Context, organizationID uuid.UUID) error {
+ _, err := memberClient.ChatModel(ctx, organizationID, otherEnabled.ID)
+ return err
},
- visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID},
- hidden: []uuid.UUID{otherEnabled.ID},
},
{
- name: "AgentsAccessUsesRowACL",
+ name: "ACL",
+ call: func(ctx context.Context, organizationID uuid.UUID) error {
+ _, err := memberClient.ChatModelACL(ctx, organizationID, otherEnabled.ID)
+ return err
+ },
+ },
+ } {
+ t.Run("ConcealsHiddenOrganization/"+endpoint.name, func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ var concealedMessage string
+ for _, organizationID := range []uuid.UUID{otherOrg.ID, uuid.New()} {
+ err := endpoint.call(ctx, organizationID)
+ sdkErr := requireSDKError(t, err, http.StatusNotFound)
+ if concealedMessage == "" {
+ concealedMessage = sdkErr.Message
+ }
+ require.Equal(t, concealedMessage, sdkErr.Message)
+ }
+ })
+ }
+
+ testCases := []struct {
+ name string
+ client func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient
+ seesDenied bool
+ }{
+ {
+ name: "AgentsAccess",
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(
t,
@@ -145,53 +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",
- client: func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient {
- return newSiteCustomRoleClient(ctx, t, client, rawDB, defaultOrg.ID, database.CustomRolePermission{
- ResourceType: rbac.ResourceDeploymentConfig.Type,
- Action: policy.ActionRead,
- })
+ name: "Owner",
+ seesDenied: true,
+ client: func(*testing.T, context.Context) *codersdk.ExperimentalClient {
+ return client
},
- visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID},
- hidden: []uuid.UUID{denied.ID, otherEnabled.ID},
},
{
- name: "DefaultOrgAdminKeepsEnabledList",
+ name: "SiteAuditor",
+ seesDenied: true,
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
- rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAdmin(defaultOrg.ID))
+ rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.RoleAuditor())
return codersdk.NewExperimentalClient(rawClient)
},
- visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID},
- hidden: []uuid.UUID{otherEnabled.ID},
},
{
- name: "DefaultOrgAuditorKeepsEnabledList",
- client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
- rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAuditor(defaultOrg.ID))
- return codersdk.NewExperimentalClient(rawClient)
+ name: "CustomSiteReadRole",
+ seesDenied: true,
+ client: func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient {
+ return newSiteCustomRoleClient(ctx, t, client, rawDB, defaultOrg.ID, database.CustomRolePermission{
+ ResourceType: rbac.ResourceChatModelConfig.Type,
+ Action: policy.ActionRead,
+ })
},
- visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID},
- hidden: []uuid.UUID{otherEnabled.ID},
},
{
- name: "NonDefaultOrgAdminSeesNoDefaultOrgConfigs",
+ name: "OrgAdmin",
+ seesDenied: true,
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
- rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleOrgAdmin(otherOrg.ID))
+ rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAdmin(defaultOrg.ID))
return codersdk.NewExperimentalClient(rawClient)
},
- hidden: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID, otherEnabled.ID},
},
{
- name: "NonDefaultOrgAuditorSeesNoDefaultOrgConfigs",
+ name: "OrgAuditor",
+ seesDenied: true,
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
- rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleOrgAuditor(otherOrg.ID))
+ rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAuditor(defaultOrg.ID))
return codersdk.NewExperimentalClient(rawClient)
},
- hidden: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID, otherEnabled.ID},
},
}
@@ -200,14 +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 f8c9528a9a296..34f227a8aca95 100644
--- a/coderd/exp_chats_test.go
+++ b/coderd/exp_chats_test.go
@@ -604,122 +604,150 @@ func insertAssistantMessage(
func TestPostChats(t *testing.T) {
t.Parallel()
- t.Run("NonDefaultOrgDoesNotUseDeploymentDefault", func(t *testing.T) {
+ t.Run("SuccessNonDefaultOrgUsesOrgDefault", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ aiProvider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
+
+ org := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ // The org's Everyone group shares the org ID (migration 000058).
+ dbgen.Group(t, db, database.Group{
+ ID: org.ID,
+ Name: database.EveryoneGroup,
+ OrganizationID: org.ID,
+ })
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID))
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+
+ contextLimit := int64(4096)
+ modelConfig, err := client.CreateChatModel(ctx, org.ID, codersdk.CreateChatModelRequest{
+ AIProviderID: &aiProvider.ID,
+ Model: "gpt-4o-mini",
+ ContextLimit: &contextLimit,
+ })
+ require.NoError(t, err)
+ require.True(t, modelConfig.IsDefault, "first config in the org must self-elect as default")
+
+ chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
+ OrganizationID: org.ID,
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "hello from a non-default org",
+ }},
+ })
+ require.NoError(t, err)
+ require.Equal(t, modelConfig.ID, chat.LastModelConfigID)
+ })
+ t.Run("NonDefaultOrgDoesNotUseDeploymentDefault", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ _ = createChatModel(t, client)
org := dbgen.Organization(t, db, database.Organization{IsDefault: false})
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID))
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: org.ID,
- Content: []codersdk.ChatInputPart{
- {
- Type: codersdk.ChatInputPartTypeText,
- Text: "hello from a non-default org",
- },
- },
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "require a local model",
+ }},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
- require.Equal(t, "No default chat model config is configured.", sdkErr.Message)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
})
- t.Run("NonDefaultOrgUsesLocalDefault", func(t *testing.T) {
+ t.Run("DisabledLocalDefaultDoesNotUseDefaultOrgDefault", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- deploymentDefault := createChatModelConfig(t, client)
+ defaultConfig := createChatModel(t, client)
org := dbgen.Organization(t, db, database.Organization{IsDefault: false})
- localDefault := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
- AIProviderID: uuid.NullUUID{UUID: deploymentDefault.AIProviderID, Valid: true},
+ dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
+ Model: "disabled-local-default-" + uuid.NewString(),
OrganizationID: org.ID,
IsDefault: true,
+ }, func(params *database.InsertChatModelConfigParams) {
+ params.Enabled = false
})
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID))
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
- chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
+ _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: org.ID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
- Text: "hello with a local default",
+ Text: "do not use the deployment default model",
}},
})
- require.NoError(t, err)
- require.Equal(t, localDefault.ID, chat.LastModelConfigID)
- require.NotEqual(t, deploymentDefault.ID, chat.LastModelConfigID)
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
})
- t.Run("RejectsExplicitForeignModel", func(t *testing.T) {
+ t.Run("DefaultOrgExplicitModelRejectedForNonDefaultOrg", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- foreignModel := createChatModelConfig(t, client)
- org := dbgen.Organization(t, db, database.Organization{IsDefault: false})
- memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID))
+ defaultConfig := createChatModel(t, client)
+ otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleAgentsAccess(otherOrg.ID))
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
- _, err := client.UpdateAIProvider(ctx, foreignModel.AIProviderID.String(), codersdk.UpdateAIProviderRequest{
- Enabled: ptr.Ref(false),
- })
- require.NoError(t, err)
- _, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
- OrganizationID: org.ID,
- ModelConfigID: &foreignModel.ID,
+ _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
+ OrganizationID: otherOrg.ID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
- Text: "reject the foreign model",
+ Text: "reject an explicit deployment model",
}},
+ ModelConfigID: ptr.Ref(defaultConfig.ID),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
- require.Equal(t, "Model config does not belong to the specified organization.", sdkErr.Message)
+ require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message)
})
- t.Run("ForeignPersonalOverrideUsesLocalDefault", func(t *testing.T) {
+ t.Run("ThirdOrgExplicitModelRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- foreignModel := createChatModelConfig(t, client)
- org := dbgen.Organization(t, db, database.Organization{IsDefault: false})
- localDefault := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
- AIProviderID: uuid.NullUUID{UUID: foreignModel.AIProviderID, Valid: true},
- OrganizationID: org.ID,
- IsDefault: true,
- })
- memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID))
+ defaultConfig := createChatModel(t, client)
+ chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ thirdOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID))
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
- err := client.UpdateChatPersonalModelOverridesAdminSettings(ctx, codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest{
- AllowUsers: true,
- })
- require.NoError(t, err)
- err = db.UpsertUserChatPersonalModelOverride(dbauthz.AsSystemRestricted(ctx), database.UpsertUserChatPersonalModelOverrideParams{
- UserID: member.ID,
- Key: chatd.ChatPersonalModelOverrideKey(codersdk.ChatPersonalModelOverrideContextRoot),
- Value: "model:" + foreignModel.ID.String(),
+ thirdConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
+ Model: "third-org-" + uuid.NewString(),
+ Enabled: true,
+ IsDefault: true,
+ OrganizationID: thirdOrg.ID,
})
- require.NoError(t, err)
- chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
- OrganizationID: org.ID,
+ _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
+ OrganizationID: chatOrg.ID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
- Text: "ignore the foreign personal override",
+ Text: "reject a third organization's model",
}},
+ ModelConfigID: ptr.Ref(thirdConfig.ID),
})
- require.NoError(t, err)
- require.Equal(t, localDefault.ID, chat.LastModelConfigID)
- require.NotEqual(t, foreignModel.ID, chat.LastModelConfigID)
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message)
})
t.Run("Success", func(t *testing.T) {
@@ -731,7 +759,7 @@ func TestPostChats(t *testing.T) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Use a member with agents-access instead of the owner to
// verify least-privilege access.
@@ -793,7 +821,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- deploymentDefault := createChatModelConfig(t, client)
+ deploymentDefault := createChatModel(t, client)
defaultOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
OrganizationID: firstUser.OrganizationID,
@@ -830,7 +858,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
coderdtest.CreateFirstUser(t, client.Client)
- deploymentDefault := createChatModelConfig(t, client)
+ deploymentDefault := createChatModel(t, client)
secondOrg := dbgen.Organization(t, db, database.Organization{})
_ = dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
@@ -880,7 +908,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
enabledCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
OrganizationID: firstUser.OrganizationID,
@@ -939,7 +967,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
cfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
OrganizationID: firstUser.OrganizationID,
@@ -1013,7 +1041,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
coderdtest.CreateFirstUser(t, client.Client)
- deploymentDefault := createChatModelConfig(t, client)
+ deploymentDefault := createChatModel(t, client)
thirdOrg := dbgen.Organization(t, db, database.Organization{})
thirdOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
@@ -1051,7 +1079,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Member without agents-access should be denied.
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
@@ -1075,7 +1103,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
@@ -1110,7 +1138,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
@@ -1134,7 +1162,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -1160,8 +1188,8 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
- disabledConfig := createDisabledChatModelConfig(
+ _ = createChatModel(t, client)
+ disabledConfig := createDisabledChatModel(
t,
client,
coderdtest.TestChatProviderOpenAICompat,
@@ -1186,8 +1214,8 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
- providerDisabledConfig := createProviderDisabledChatModelConfig(
+ _ = createChatModel(t, client)
+ providerDisabledConfig := createProviderDisabledChatModel(
t,
client,
"openai",
@@ -1212,7 +1240,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- defaultConfig := createChatModelConfig(t, client)
+ defaultConfig := createChatModel(t, client)
_, err := client.UpdateAIProvider(ctx, defaultConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{
Enabled: ptr.Ref(false),
})
@@ -1228,8 +1256,76 @@ func TestPostChats(t *testing.T) {
}},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
- require.Equal(t, "No default chat model config is configured.", sdkErr.Message)
- require.Equal(t, "The default chat model or its provider is disabled.", sdkErr.Detail)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
+ })
+
+ t.Run("DeniedSameOrganizationDefaultIsRejected", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
+ _ = dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
+ Model: "private-default-" + uuid.NewString(),
+ Enabled: true,
+ IsDefault: true,
+ OrganizationID: firstUser.OrganizationID,
+ GroupACL: database.ChatACL{},
+ })
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(
+ t,
+ client.Client,
+ firstUser.OrganizationID,
+ rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID),
+ )
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+
+ _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
+ OrganizationID: firstUser.OrganizationID,
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "do not select the private default",
+ }},
+ })
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ })
+
+ t.Run("DeniedSameOrganizationExplicitModelIsRejected", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
+ privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
+ Model: "private-explicit-" + uuid.NewString(),
+ Enabled: true,
+ OrganizationID: firstUser.OrganizationID,
+ GroupACL: database.ChatACL{},
+ })
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(
+ t,
+ client.Client,
+ firstUser.OrganizationID,
+ rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID),
+ )
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+
+ _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
+ OrganizationID: firstUser.OrganizationID,
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "do not admit the private model",
+ }},
+ ModelConfigID: &privateConfig.ID,
+ })
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message)
})
t.Run("WithPerChatSystemPrompt", func(t *testing.T) {
@@ -1238,7 +1334,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
@@ -1291,7 +1387,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
@@ -1420,7 +1516,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
@@ -1460,7 +1556,8 @@ func TestPostChats(t *testing.T) {
},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
- require.Equal(t, "No default chat model config is configured.", sdkErr.Message)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
})
t.Run("EmptyContent", func(t *testing.T) {
@@ -1527,7 +1624,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
@@ -1549,7 +1646,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
@@ -1575,7 +1672,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
@@ -1614,7 +1711,7 @@ func TestChats_ForceOnMCPServerEnforced(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// An admin marks an MCP server as Force On.
forced, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
@@ -1669,7 +1766,7 @@ func TestPostChats_ClientType(t *testing.T) {
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
@@ -1752,7 +1849,7 @@ func TestListChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
firstChatA, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -1847,7 +1944,7 @@ func TestListChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
ownerClientRaw, owner := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleOwner())
ownerClient := codersdk.NewExperimentalClient(ownerClientRaw)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
@@ -1931,7 +2028,7 @@ func TestListChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Create a member without agents-access and insert a chat
// owned by them via system context. Without agents-access,
@@ -1978,7 +2075,7 @@ func TestListChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Insert chats with a terminal status so the chatd
// processor never acquires them and never bumps
@@ -2065,7 +2162,7 @@ func TestListChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Insert chats directly with a terminal status: see
// the Pagination subtest for the cursor-race rationale.
@@ -2124,7 +2221,7 @@ func TestListChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Insert chats directly with a terminal status: see
// the Pagination subtest for the cursor-race rationale.
@@ -2211,7 +2308,7 @@ func TestListChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Create a parent chat via the API.
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -2309,7 +2406,7 @@ func TestListChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Create 3 root chats, each with 2 children.
for i := range 3 {
@@ -2354,7 +2451,7 @@ func TestListChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Helper that creates a chat (root or child) with a diff status URL.
create := func(title, url string, parentID uuid.NullUUID) database.Chat {
@@ -2487,10 +2584,10 @@ func TestListChatModels(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ modelConfig := createChatModel(t, client)
- models, err := client.ChatModelAvailability(ctx)
+ models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
var openAIProvider *codersdk.ChatModelProvider
@@ -2513,15 +2610,102 @@ func TestListChatModels(t *testing.T) {
require.True(t, foundModel)
})
+ t.Run("DeniedSameOrganizationEnabledModelIsHidden", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
+ privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
+ Model: "private-discovery-" + uuid.NewString(),
+ Enabled: true,
+ OrganizationID: firstUser.OrganizationID,
+ GroupACL: database.ChatACL{},
+ })
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(
+ t,
+ client.Client,
+ firstUser.OrganizationID,
+ rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID),
+ )
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+
+ models, err := memberClient.ChatModelAvailability(ctx, firstUser.OrganizationID)
+ require.NoError(t, err)
+ for _, model := range models.Models {
+ require.NotEqual(t, privateConfig.ID, model.ID)
+ }
+ for _, availableProvider := range models.Providers {
+ for _, model := range availableProvider.Models {
+ require.NotEqual(t, privateConfig.Model, model.Model)
+ }
+ }
+ })
+
+ t.Run("NonDefaultOrgIncludesOnlyLocalModels", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
+ defaultConfig := createChatModel(t, client)
+ chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ thirdOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ contextLimit := int64(4096)
+ localConfig, err := client.CreateChatModel(ctx, chatOrg.ID, codersdk.CreateChatModelRequest{
+ AIProviderID: &provider.ID,
+ Model: "local-" + uuid.NewString(),
+ ContextLimit: &contextLimit,
+ })
+ require.NoError(t, err)
+ localConfig, err = client.UpdateChatModel(ctx, localConfig.OrganizationID, localConfig.ID, codersdk.UpdateChatModelRequest{
+ IsDefault: ptr.Ref(true),
+ })
+ require.NoError(t, err)
+ thirdConfig, err := client.CreateChatModel(ctx, thirdOrg.ID, codersdk.CreateChatModelRequest{
+ AIProviderID: &provider.ID,
+ Model: "third-" + uuid.NewString(),
+ ContextLimit: &contextLimit,
+ })
+ require.NoError(t, err)
+
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID))
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ models, err := memberClient.ChatModelAvailability(ctx, chatOrg.ID)
+ require.NoError(t, err)
+ var modelNames []string
+ for _, availableProvider := range models.Providers {
+ for _, model := range availableProvider.Models {
+ modelNames = append(modelNames, model.Model)
+ }
+ }
+ require.Contains(t, modelNames, localConfig.Model)
+ require.NotContains(t, modelNames, defaultConfig.Model)
+ require.NotContains(t, modelNames, thirdConfig.Model)
+
+ modelConfigIDs := make([]uuid.UUID, 0, len(models.Models))
+ for _, model := range models.Models {
+ modelConfigIDs = append(modelConfigIDs, model.ID)
+ }
+ require.Contains(t, modelConfigIDs, localConfig.ID)
+ require.NotContains(t, modelConfigIDs, defaultConfig.ID)
+ require.NotContains(t, modelConfigIDs, thirdConfig.ID)
+ require.Len(t, models.Models, 1)
+ require.True(t, models.Models[0].IsDefault)
+ })
+
t.Run("Unauthenticated", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
unauthenticatedClient := codersdk.NewExperimentalClient(codersdk.New(client.URL))
- _, err := unauthenticatedClient.ChatModelAvailability(ctx)
+ _, err := unauthenticatedClient.ChatModelAvailability(ctx, firstUser.OrganizationID)
requireSDKError(t, err, http.StatusUnauthorized)
})
@@ -2530,14 +2714,14 @@ func TestListChatModels(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
// Copilot is a valid AI Gateway provider but the Agents harness
// cannot use it. It must surface as an unsupported provider rather
// than vanish, so the empty state can explain why.
_ = createAIProviderForTest(t, client, string(codersdk.AIProviderTypeCopilot), "")
- models, err := client.ChatModelAvailability(ctx)
+ models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.False(t, slices.ContainsFunc(models.Providers, func(p codersdk.ChatModelProvider) bool {
@@ -2557,10 +2741,10 @@ func TestListChatModels(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ _ = createChatModel(t, client)
- models, err := client.ChatModelAvailability(ctx)
+ models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Empty(t, models.UnsupportedProviders)
})
@@ -2570,10 +2754,10 @@ func TestListChatModels(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ _ = createChatModel(t, client)
- models, err := client.ChatModelAvailability(ctx)
+ models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
var openAIProvider *codersdk.ChatModelProvider
@@ -2592,20 +2776,20 @@ func TestListChatModels(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
providerType := database.AIProviderTypeAnthropic
provider := createAIProviderForTest(t, client, string(providerType), "")
contextLimit := int64(4096)
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "claude-sonnet",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
- models, err := client.ChatModelAvailability(ctx)
+ models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
var anthropicProvider *codersdk.ChatModelProvider
@@ -2624,7 +2808,7 @@ func TestListChatModels(t *testing.T) {
})
require.NoError(t, err)
- models, err = client.ChatModelAvailability(ctx)
+ models, err = client.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
anthropicProvider = nil
@@ -2643,19 +2827,19 @@ func TestListChatModels(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, "google", "provider-api-key")
contextLimit := int64(4096)
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gemini-1.5-pro",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
- models, err := client.ChatModelAvailability(ctx)
+ models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
var googleProvider *codersdk.ChatModelProvider
@@ -2673,7 +2857,7 @@ func TestListChatModels(t *testing.T) {
})
require.NoError(t, err)
- models, err = client.ChatModelAvailability(ctx)
+ models, err = client.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
googleProvider = nil
@@ -2687,15 +2871,107 @@ func TestListChatModels(t *testing.T) {
require.True(t, googleProvider.Available)
})
+ t.Run("SameTypeProvidersUseExactCredentialStatus", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client := newChatClient(t)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ availableProvider := createAIProviderForTest(t, client, "openai", "available-key")
+ unavailableProvider := createAIProviderForTest(t, client, "openai", "")
+ contextLimit := int64(4096)
+
+ availableModel, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
+ AIProviderID: &availableProvider.ID,
+ Model: "available-" + uuid.NewString(),
+ ContextLimit: &contextLimit,
+ })
+ require.NoError(t, err)
+ unavailableModel, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
+ AIProviderID: &unavailableProvider.ID,
+ Model: "unavailable-" + uuid.NewString(),
+ ContextLimit: &contextLimit,
+ })
+ require.NoError(t, err)
+
+ models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID)
+ require.NoError(t, err)
+ var catalogModels []string
+ for _, provider := range models.Providers {
+ for _, model := range provider.Models {
+ catalogModels = append(catalogModels, model.Model)
+ }
+ }
+ require.Contains(t, catalogModels, availableModel.Model)
+ require.NotContains(t, catalogModels, unavailableModel.Model)
+ })
+
+ t.Run("UserCredentialsAreCallerIsolated", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client := newChatClient(t)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ provider := createAIProviderForTest(t, client, "anthropic", "")
+ contextLimit := int64(4096)
+ modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
+ AIProviderID: &provider.ID,
+ Model: "caller-isolated-" + uuid.NewString(),
+ ContextLimit: &contextLimit,
+ })
+ require.NoError(t, err)
+ otherClientRaw, _ := coderdtest.CreateAnotherUser(
+ t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID),
+ )
+ otherClient := codersdk.NewExperimentalClient(otherClientRaw)
+
+ _, err = client.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "owner-key"})
+ require.NoError(t, err)
+
+ ownerModels, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID)
+ require.NoError(t, err)
+ otherModels, err := otherClient.ChatModelAvailability(ctx, firstUser.OrganizationID)
+ require.NoError(t, err)
+ ownerConfigs, err := client.ChatModels(ctx, firstUser.OrganizationID)
+ require.NoError(t, err)
+ otherConfigs, err := otherClient.ChatModels(ctx, firstUser.OrganizationID)
+ require.NoError(t, err)
+
+ hasUserAPIKey := func(response codersdk.OrganizationChatModelsResponse) bool {
+ for _, descriptor := range response.Providers {
+ if descriptor.ID == provider.ID {
+ return descriptor.HasUserAPIKey
+ }
+ }
+ t.Fatal("provider descriptor not found")
+ return false
+ }
+ require.True(t, hasUserAPIKey(ownerConfigs))
+ require.False(t, hasUserAPIKey(otherConfigs))
+
+ containsModel := func(response codersdk.ChatModelAvailabilityResponse) bool {
+ for _, availableProvider := range response.Providers {
+ for _, model := range availableProvider.Models {
+ if model.Model == modelConfig.Model {
+ return true
+ }
+ }
+ }
+ return false
+ }
+ require.True(t, containsModel(ownerModels))
+ require.False(t, containsModel(otherModels))
+ })
+
t.Run("IncludesOnlyDefaultOrganizationModels", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
contextLimit := int64(4096)
- defaultConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ defaultConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "default-" + uuid.NewString(),
ContextLimit: &contextLimit,
@@ -2708,7 +2984,7 @@ func TestListChatModels(t *testing.T) {
OrganizationID: otherOrganization.ID,
})
- models, err := client.ChatModelAvailability(ctx)
+ models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
var catalogModels []string
@@ -2728,32 +3004,51 @@ func TestListChatModels(t *testing.T) {
values := coderdtest.DeploymentValues(t)
values.AI.BridgeConfig.LegacyOpenAI.Key = serpent.String("deployment-openai-key")
client := newChatClientWithDeploymentValues(t, values)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, "openai", "test-key")
contextLimit := int64(4096)
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
- models, err := client.ChatModelAvailability(ctx)
+ models, err := client.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, models.Providers, 1)
require.Equal(t, "openai", models.Providers[0].Provider)
require.Len(t, models.Providers[0].Models, 1)
require.Equal(t, "gpt-4o-mini", models.Providers[0].Models[0].Model)
- enabled := false
- _, err = client.UpdateAIProvider(ctx, provider.ID.String(), codersdk.UpdateAIProviderRequest{
- Enabled: &enabled,
+ // A disabled model under an enabled provider must not appear in
+ // availability, even though the management list still returns it.
+ disabledModel, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
+ AIProviderID: &provider.ID,
+ Model: "gpt-4o",
+ ContextLimit: &contextLimit,
+ })
+ require.NoError(t, err)
+ enabled := false
+ _, err = client.UpdateChatModel(ctx, disabledModel.OrganizationID, disabledModel.ID, codersdk.UpdateChatModelRequest{
+ Enabled: &enabled,
})
require.NoError(t, err)
- models, err = client.ChatModelAvailability(ctx)
+ models, err = client.ChatModelAvailability(ctx, firstUser.OrganizationID)
+ require.NoError(t, err)
+ require.Len(t, models.Providers, 1)
+ require.Len(t, models.Providers[0].Models, 1)
+ require.Equal(t, "gpt-4o-mini", models.Providers[0].Models[0].Model)
+
+ _, err = client.UpdateAIProvider(ctx, provider.ID.String(), codersdk.UpdateAIProviderRequest{
+ Enabled: &enabled,
+ })
+ require.NoError(t, err)
+
+ models, err = client.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Empty(t, models.Providers)
})
@@ -2767,7 +3062,7 @@ func TestListChats_Search(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
return ctx, client, db, firstUser, modelConfig
}
@@ -2943,7 +3238,7 @@ func TestWatchChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
require.NoError(t, err)
@@ -2977,7 +3272,7 @@ func TestWatchChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
require.NoError(t, err)
@@ -3033,7 +3328,7 @@ func TestWatchChats(t *testing.T) {
db := api.Database
chatDaemon := api.ChatDaemonForTest()
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Insert a chat and a diff status row.
chat := dbgen.Chat(t, db, database.Chat{
@@ -3107,7 +3402,7 @@ func TestWatchChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
@@ -3402,7 +3697,7 @@ func TestListChatProviders(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
providers, err := client.ListChatProviders(ctx)
require.NoError(t, err)
@@ -4537,14 +4832,14 @@ func TestListChatModelConfigs(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
- configs, err := client.ChatModels(ctx)
+ configs, err := client.ChatModels(ctx, modelConfig.OrganizationID)
require.NoError(t, err)
- require.NotEmpty(t, configs)
+ require.NotEmpty(t, configs.Models)
found := false
- for _, config := range configs {
+ for _, config := range configs.Models {
if config.ID == modelConfig.ID {
found = true
require.Equal(t, modelConfig.AIProviderID, config.AIProviderID)
@@ -4555,18 +4850,61 @@ func TestListChatModelConfigs(t *testing.T) {
require.True(t, found)
})
- t.Run("AdminIncludesDisabledModelConfigs", func(t *testing.T) {
+ t.Run("CompatibilityCollectionRoutesUseDefaultOrganization", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
+ modelConfig := createChatModel(t, client)
+
+ res, err := client.Request(ctx, http.MethodGet, "/api/experimental/chats/model-configs", nil)
+ require.NoError(t, err)
+ defer res.Body.Close()
+ require.Equal(t, http.StatusOK, res.StatusCode)
+ var configs []codersdk.ChatModel
+ require.NoError(t, codersdk.ReadBodyAsJSON(res, &configs))
+ require.Contains(t, configs, modelConfig)
+
+ availabilityRes, err := client.Request(ctx, http.MethodGet, "/api/experimental/chats/models", nil)
+ require.NoError(t, err)
+ defer availabilityRes.Body.Close()
+ require.Equal(t, http.StatusOK, availabilityRes.StatusCode)
+ var availability codersdk.ChatModelAvailabilityResponse
+ require.NoError(t, codersdk.ReadBodyAsJSON(availabilityRes, &availability))
+ require.Contains(t, availability.Models, modelConfig)
+
+ contextLimit := int64(8192)
+ createdRes, err := client.Request(ctx, http.MethodPost, "/api/experimental/chats/model-configs", codersdk.CreateChatModelRequest{
+ AIProviderID: &modelConfig.AIProviderID,
+ Model: "compatibility-model",
+ ContextLimit: &contextLimit,
+ })
+ require.NoError(t, err)
+ defer createdRes.Body.Close()
+ require.Equal(t, http.StatusCreated, createdRes.StatusCode)
+ var created codersdk.ChatModel
+ require.NoError(t, codersdk.ReadBodyAsJSON(createdRes, &created))
+
+ got, err := client.ChatModel(ctx, created.OrganizationID, created.ID)
+ require.NoError(t, err)
+ require.Equal(t, created.ID, got.ID)
+
+ require.NoError(t, client.DeleteChatModel(ctx, created.OrganizationID, created.ID))
+ })
+
+ t.Run("AdminIncludesDisabledModelConfigs", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client := newChatClient(t)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
enabled := false
- disabledConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ disabledConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-disabled",
DisplayName: "GPT-4o Disabled",
@@ -4576,11 +4914,11 @@ func TestListChatModelConfigs(t *testing.T) {
require.NoError(t, err)
require.False(t, disabledConfig.Enabled)
- configs, err := client.ChatModels(ctx)
+ configs, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
found := false
- for _, config := range configs {
+ for _, config := range configs.Models {
if config.ID == disabledConfig.ID {
found = true
require.False(t, config.Enabled)
@@ -4590,136 +4928,232 @@ func TestListChatModelConfigs(t *testing.T) {
require.True(t, found)
})
- t.Run("NonAdminIncludesAllowedDisabledModelConfigs", func(t *testing.T) {
+ t.Run("OrganizationMemberSeesReadableModels", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
- adminClient := newChatClient(t)
+ adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
- enabledConfig := createChatModelConfig(t, adminClient)
+ readableConfig := createChatModel(t, adminClient)
+ privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: readableConfig.AIProviderID, Valid: true},
+ Model: "private-" + uuid.NewString(),
+ OrganizationID: firstUser.OrganizationID,
+ GroupACL: database.ChatACL{},
+ })
+
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
- contextLimit := int64(4096)
- enabled := false
- _, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
- AIProviderID: &enabledConfig.AIProviderID,
- Model: "gpt-4o-disabled",
- DisplayName: "GPT-4o Disabled",
- Enabled: &enabled,
- ContextLimit: &contextLimit,
- })
+ configs, err := memberClient.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
-
- configs, err := memberClient.ChatModels(ctx)
- require.NoError(t, err)
- require.Len(t, configs, 2)
- require.True(t, slices.ContainsFunc(configs, func(config codersdk.ChatModel) bool {
- return config.ID == enabledConfig.ID && config.Enabled
- }))
- require.True(t, slices.ContainsFunc(configs, func(config codersdk.ChatModel) bool {
- return config.Model == "gpt-4o-disabled" && !config.Enabled
- }))
+ modelIDs := make([]uuid.UUID, 0, len(configs.Models))
+ for _, config := range configs.Models {
+ modelIDs = append(modelIDs, config.ID)
+ }
+ require.Contains(t, modelIDs, readableConfig.ID)
+ require.NotContains(t, modelIDs, privateConfig.ID)
})
- // Provider status does not change management list visibility.
- t.Run("ProviderDisabled", func(t *testing.T) {
+ t.Run("BedrockDescriptorReportsEffectiveCredentials", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
- adminClient := newChatClient(t)
- firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
- enabledConfig := createChatModelConfig(t, adminClient)
- providerDisabledConfig := createProviderDisabledChatModelConfig(
- t,
- adminClient,
- "openai",
- "gpt-4o-provider-disabled-"+uuid.NewString(),
- )
- memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
- memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ client, db := newChatClientWithDatabase(t)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ provider := dbgen.AIProvider(t, db, database.AIProvider{
+ Type: database.AIProviderTypeBedrock,
+ })
- adminConfigs, err := adminClient.ChatModels(ctx)
+ models, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
- adminIDs := make([]uuid.UUID, 0, len(adminConfigs))
- for _, config := range adminConfigs {
- adminIDs = append(adminIDs, config.ID)
+ for _, descriptor := range models.Providers {
+ if descriptor.ID == provider.ID {
+ require.False(t, descriptor.HasAPIKey)
+ require.False(t, descriptor.HasUserAPIKey)
+ require.True(t, descriptor.HasEffectiveAPIKey)
+ return
+ }
}
- require.Contains(t, adminIDs, providerDisabledConfig.ID)
+ t.Fatal("bedrock provider descriptor not found")
+ })
+}
+
+func TestGetChatModel(t *testing.T) {
+ t.Parallel()
- memberConfigs, err := memberClient.ChatModels(ctx)
+ t.Run("Success", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client := newChatClient(t)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ modelConfig := createChatModel(t, client)
+
+ got, err := client.ChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID)
require.NoError(t, err)
- memberIDs := make([]uuid.UUID, 0, len(memberConfigs))
- for _, config := range memberConfigs {
- memberIDs = append(memberIDs, config.ID)
- }
- require.Contains(t, memberIDs, enabledConfig.ID)
- require.Contains(t, memberIDs, providerDisabledConfig.ID)
+ require.Equal(t, modelConfig.ID, got.ID)
})
- t.Run("ManagementListExcludesOtherOrganizations", func(t *testing.T) {
+ t.Run("WrongOrganizationConcealedForAllItemActions", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- defaultConfig := createChatModelConfig(t, client)
- otherOrg := dbgen.Organization(t, db, database.Organization{})
- otherConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
- OrganizationID: otherOrg.ID,
- AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
- })
+ modelConfig := createChatModel(t, client)
+ wrongOrganization := dbgen.Organization(t, db, database.Organization{IsDefault: false})
- configs, err := client.ChatModels(ctx)
- require.NoError(t, err)
- require.Contains(t, configs, defaultConfig)
- for _, config := range configs {
- require.NotEqual(t, otherConfig.ID, config.ID)
+ for _, tc := range []struct {
+ name string
+ method string
+ body any
+ }{
+ {name: "Get", method: http.MethodGet},
+ {name: "Patch", method: http.MethodPatch, body: codersdk.UpdateChatModelRequest{DisplayName: "wrong organization"}},
+ {name: "Delete", method: http.MethodDelete},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ res, err := client.Request(ctx, tc.method, fmt.Sprintf(
+ "/api/experimental/organizations/%s/chats/models/%s",
+ wrongOrganization.ID,
+ modelConfig.ID,
+ ), tc.body)
+ require.NoError(t, err)
+ defer res.Body.Close()
+ require.Equal(t, http.StatusNotFound, res.StatusCode)
+ })
}
+
+ stored, err := db.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), modelConfig.ID)
+ require.NoError(t, err)
+ require.Equal(t, modelConfig.DisplayName, stored.DisplayName)
})
- t.Run("SuccessForOrganizationMember", func(t *testing.T) {
+ t.Run("OrganizationMemberCanReadSharedModel", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
- modelConfig := createChatModelConfig(t, adminClient)
+ modelConfig := createChatModel(t, adminClient)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
- // The management list returns all rows allowed by the row ACL.
- configs, err := memberClient.ChatModels(ctx)
+ got, err := memberClient.ChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID)
require.NoError(t, err)
- require.NotEmpty(t, configs)
+ require.Equal(t, modelConfig.ID, got.ID)
+ })
- found := false
- for _, config := range configs {
- if config.ID == modelConfig.ID {
- found = true
- require.Equal(t, modelConfig.AIProviderID, config.AIProviderID)
- require.Equal(t, modelConfig.Model, config.Model)
- }
- }
- require.True(t, found)
+ t.Run("OrganizationMemberCannotReadPrivateModel", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ adminClient, db := newChatClientWithDatabase(t)
+ firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
+ sharedConfig := createChatModel(t, adminClient)
+ privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: sharedConfig.AIProviderID, Valid: true},
+ Model: "private-" + uuid.NewString(),
+ OrganizationID: firstUser.OrganizationID,
+ GroupACL: database.ChatACL{},
+ })
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+
+ _, err := memberClient.ChatModel(ctx, privateConfig.OrganizationID, privateConfig.ID)
+ requireSDKError(t, err, http.StatusNotFound)
})
}
func TestCreateChatModelConfig(t *testing.T) {
t.Parallel()
+ t.Run("UnauthorizedPrincipalCannotProbeProviderState", func(t *testing.T) {
+ t.Parallel()
+
+ // The write is authorized before any privileged provider lookup, so
+ // a principal without create-in-org gets an identical denial whether
+ // the provider ID is missing, disabled, or configured: no provider
+ // state is disclosed.
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client := newChatClient(t)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+
+ configuredProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
+ disabledProvider := createAIProviderForTest(t, client, "google", "test-api-key-2")
+ enabled := false
+ _, err := client.UpdateAIProvider(ctx, disabledProvider.ID.String(), codersdk.UpdateAIProviderRequest{
+ Enabled: &enabled,
+ })
+ require.NoError(t, err)
+
+ for _, tc := range []struct {
+ name string
+ providerID uuid.UUID
+ }{
+ {"missing", uuid.New()},
+ {"disabled", disabledProvider.ID},
+ {"configured", configuredProvider.ID},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ contextLimit := int64(4096)
+ _, err := memberClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
+ AIProviderID: &tc.providerID,
+ Model: "probe-" + tc.name,
+ ContextLimit: &contextLimit,
+ })
+ sdkErr := requireSDKError(t, err, http.StatusForbidden)
+ require.Equal(t, "Forbidden.", sdkErr.Message)
+ })
+ }
+ })
+
+ t.Run("NormalCreateConcealsHiddenAndMissingOrganizations", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ adminClient, db := newChatClientWithDatabase(t)
+ firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
+ provider := createAIProviderForTest(t, adminClient, "openai", "test-api-key")
+ hiddenOrganization := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ contextLimit := int64(4096)
+
+ var concealedMessage string
+ for _, organizationID := range []uuid.UUID{hiddenOrganization.ID, uuid.New()} {
+ _, err := memberClient.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{
+ AIProviderID: &provider.ID,
+ Model: "concealed-" + uuid.NewString(),
+ ContextLimit: &contextLimit,
+ })
+ sdkErr := requireSDKError(t, err, http.StatusNotFound)
+ if concealedMessage == "" {
+ concealedMessage = sdkErr.Message
+ }
+ require.Equal(t, concealedMessage, sdkErr.Message)
+ }
+
+ configs, err := adminClient.ChatModels(ctx, hiddenOrganization.ID)
+ require.NoError(t, err)
+ require.Empty(t, configs.Models)
+ })
+
t.Run("Success", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
isDefault := true
- modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
@@ -4732,9 +5166,9 @@ func TestCreateChatModelConfig(t *testing.T) {
require.EqualValues(t, 4096, modelConfig.ContextLimit)
require.True(t, modelConfig.IsDefault)
- configs, err := client.ChatModels(ctx)
+ configs, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
- require.Len(t, configs, 1)
+ require.Len(t, configs.Models, 1)
})
t.Run("ProviderReferenceLockFailureUsesCreateErrorText", func(t *testing.T) {
@@ -4742,13 +5176,13 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
store.armFailProviderReferenceLock(aiProvider.ID)
contextLimit := int64(4096)
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
@@ -4764,7 +5198,7 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
@@ -4777,7 +5211,7 @@ func TestCreateChatModelConfig(t *testing.T) {
var eg errgroup.Group
for i := range creators - 1 {
eg.Go(func() error {
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: fmt.Sprintf("gpt-4o-mini-%d", i),
ContextLimit: &contextLimit,
@@ -4786,7 +5220,7 @@ func TestCreateChatModelConfig(t *testing.T) {
})
}
eg.Go(func() error {
- created, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ created, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o",
ContextLimit: &contextLimit,
@@ -4794,7 +5228,7 @@ func TestCreateChatModelConfig(t *testing.T) {
if err != nil {
return xerrors.Errorf("create claimed config: %w", err)
}
- claimed, err = client.UpdateChatModel(ctx, created.ID, codersdk.UpdateChatModelRequest{
+ claimed, err = client.UpdateChatModel(ctx, created.OrganizationID, created.ID, codersdk.UpdateChatModelRequest{
IsDefault: ptr.Ref(true),
})
if err != nil {
@@ -4804,11 +5238,11 @@ func TestCreateChatModelConfig(t *testing.T) {
})
require.NoError(t, eg.Wait())
- configs, err := client.ChatModels(ctx)
+ configs, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
- require.Len(t, configs, creators)
+ require.Len(t, configs.Models, creators)
var defaults []uuid.UUID
- for _, cfg := range configs {
+ for _, cfg := range configs.Models {
if cfg.IsDefault {
defaults = append(defaults, cfg.ID)
}
@@ -4816,7 +5250,7 @@ func TestCreateChatModelConfig(t *testing.T) {
require.Equal(t, []uuid.UUID{claimed.ID}, defaults)
})
- t.Run("SuccessCustomDeploymentConfigRole", func(t *testing.T) {
+ t.Run("SuccessCustomOrgRole", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -4826,42 +5260,68 @@ func TestCreateChatModelConfig(t *testing.T) {
opts.Pubsub = pubsub
})
_ = coderdtest.CreateFirstUser(t, client.Client)
- nonDefaultOrg := dbgen.Organization(t, rawDB, database.Organization{IsDefault: false})
+ defaultOrg, err := rawDB.GetDefaultOrganization(ctx)
+ require.NoError(t, err)
- // A restricted system context supplies organization read permission for
- // the internal lookup, so the user's role does not require it.
- adminClient := newSiteCustomRoleClient(
- ctx,
- t,
- client,
- rawDB,
- nonDefaultOrg.ID,
- database.CustomRolePermission{
- ResourceType: rbac.ResourceDeploymentConfig.Type,
- Action: policy.ActionRead,
- },
- database.CustomRolePermission{
- ResourceType: rbac.ResourceDeploymentConfig.Type,
- Action: policy.ActionUpdate,
+ // A custom org role holding only chat_model_config permissions
+ // passes write authorization for configs in its org and holds no
+ // grant anywhere else; org role permissions are scoped to the
+ // organization the role belongs to.
+ role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{
+ Name: testutil.GetRandomName(t),
+ DisplayName: "Chat Model Config Admin",
+ OrganizationID: uuid.NullUUID{UUID: defaultOrg.ID, Valid: true},
+ OrgPermissions: database.CustomRolePermissions{
+ {
+ ResourceType: rbac.ResourceChatModelConfig.Type,
+ Action: policy.ActionCreate,
+ },
+ {
+ ResourceType: rbac.ResourceChatModelConfig.Type,
+ Action: policy.ActionRead,
+ },
+ {
+ ResourceType: rbac.ResourceChatModelConfig.Type,
+ Action: policy.ActionUpdate,
+ },
},
- )
+ })
+ require.NoError(t, err)
+ // An existing org default keeps the create off the self-promotion
+ // path; the writer below holds no chat_model_config:update grant
+ // beyond its own org. The owner seeds the provider and the
+ // default: both operations need site-wide permissions a
+ // non-owner lacks.
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
+ _ = createAdditionalChatModel(t, client, "openai", "gpt-4o-existing")
+
+ adminClientRaw, adminUser := coderdtest.CreateAnotherUser(
+ t,
+ client.Client,
+ defaultOrg.ID,
+ )
+ _, err = client.UpdateOrganizationMemberRoles(
+ ctx,
+ defaultOrg.ID,
+ adminUser.ID.String(),
+ codersdk.UpdateRoles{Roles: []string{role.Name}},
+ )
+ require.NoError(t, err)
+ adminClient := codersdk.NewExperimentalClient(adminClientRaw)
contextLimit := int64(4096)
- modelConfig, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ modelConfig, err := adminClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
- defaultOrg, err := rawDB.GetDefaultOrganization(ctx)
- require.NoError(t, err)
row, err := rawDB.GetChatModelConfigByID(ctx, modelConfig.ID)
require.NoError(t, err)
require.Equal(t, defaultOrg.ID, row.OrganizationID)
- require.True(t, row.IsDefault)
+ require.False(t, row.IsDefault)
require.Equal(
t,
database.ChatACL{
@@ -4871,101 +5331,117 @@ func TestCreateChatModelConfig(t *testing.T) {
)
require.Equal(t, database.ChatACL{}, row.UserACL)
})
-
- // Deployment-config administrators can manage configs and defaults.
- t.Run("DeploymentConfigOnlyRoleWritesThroughWindow", func(t *testing.T) {
+ // An organization role can manage the full model lifecycle. Default
+ // election must not select another organization's config.
+ t.Run("OrgRoleWritesThroughLifecycle", func(t *testing.T) {
t.Parallel()
- setupCtx := testutil.Context(t, testutil.WaitLong)
+ ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.Database = rawDB
opts.Pubsub = pubsub
})
_ = coderdtest.CreateFirstUser(t, client.Client)
- nonDefaultOrg := dbgen.Organization(t, rawDB, database.Organization{IsDefault: false})
+ defaultOrg, err := rawDB.GetDefaultOrganization(ctx)
+ require.NoError(t, err)
- writerClient := newSiteCustomRoleClient(
- setupCtx,
- t,
- client,
- rawDB,
- nonDefaultOrg.ID,
- database.CustomRolePermission{
- ResourceType: rbac.ResourceDeploymentConfig.Type,
- Action: policy.ActionRead,
- },
- database.CustomRolePermission{
- ResourceType: rbac.ResourceDeploymentConfig.Type,
- Action: policy.ActionUpdate,
+ // The writer role holds chat_model_config permissions in the
+ // default org and nothing else: user-scoped member permissions do
+ // not carry the org-wide create grant the route needs, so the role
+ // must supply every action the lifecycle touches.
+ role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{
+ Name: testutil.GetRandomName(t),
+ DisplayName: "Chat Model Config Admin",
+ OrganizationID: uuid.NullUUID{UUID: defaultOrg.ID, Valid: true},
+ OrgPermissions: database.CustomRolePermissions{
+ {
+ ResourceType: rbac.ResourceChatModelConfig.Type,
+ Action: policy.ActionCreate,
+ },
+ {
+ ResourceType: rbac.ResourceChatModelConfig.Type,
+ Action: policy.ActionRead,
+ },
+ {
+ ResourceType: rbac.ResourceChatModelConfig.Type,
+ Action: policy.ActionUpdate,
+ },
+ {
+ ResourceType: rbac.ResourceChatModelConfig.Type,
+ Action: policy.ActionDelete,
+ },
},
- )
+ })
+ require.NoError(t, err)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
- // The first config self-elects as the deployment default. The existing
- // default keeps the writer's own create off the self-promotion path.
- defaultConfig := createChatModelConfig(t, client)
+
+ writerClientRaw, writerUser := coderdtest.CreateAnotherUser(
+ t,
+ client.Client,
+ defaultOrg.ID,
+ )
+ _, err = client.UpdateOrganizationMemberRoles(
+ ctx,
+ defaultOrg.ID,
+ writerUser.ID.String(),
+ codersdk.UpdateRoles{Roles: []string{role.Name}},
+ )
+ require.NoError(t, err)
+ writerClient := codersdk.NewExperimentalClient(writerClientRaw)
contextLimit := int64(4096)
- modelConfig, err := writerClient.CreateChatModel(setupCtx, codersdk.CreateChatModelRequest{
+ defaultConfig, err := writerClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
+ require.True(t, defaultConfig.IsDefault)
- updated, err := writerClient.UpdateChatModel(setupCtx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := writerClient.UpdateChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "Window Write",
})
require.NoError(t, err)
require.Equal(t, "Window Write", updated.DisplayName)
- require.NoError(t, writerClient.DeleteChatModel(setupCtx, modelConfig.ID))
+ candidate, err := writerClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{
+ AIProviderID: &aiProvider.ID,
+ Model: "gpt-4o-mini-candidate",
+ ContextLimit: &contextLimit,
+ })
+ require.NoError(t, err)
- // TODO(mafredri): remove these default-transition checks after
- // CODAGT-709 M3 (org-scoping cutover).
- ctx := testutil.Context(t, testutil.WaitLong)
notDefault := false
- updated, err = writerClient.UpdateChatModel(ctx, defaultConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err = writerClient.UpdateChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID, codersdk.UpdateChatModelRequest{
IsDefault: ¬Default,
})
require.NoError(t, err)
- require.True(t, updated.IsDefault, "the sole config must remain default")
- row, err := rawDB.GetChatModelConfigByID(ctx, defaultConfig.ID)
+ candidateRow, err := rawDB.GetChatModelConfigByID(ctx, candidate.ID)
require.NoError(t, err)
- require.True(t, row.IsDefault, "the sole config must remain default")
+ require.True(t, candidateRow.IsDefault)
- replacement, err := writerClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
- AIProviderID: &aiProvider.ID,
- Model: "gpt-4o-mini-replacement",
- ContextLimit: &contextLimit,
- })
+ err = writerClient.DeleteChatModel(ctx, candidate.OrganizationID, candidate.ID)
require.NoError(t, err)
- require.NoError(t, writerClient.DeleteChatModel(ctx, defaultConfig.ID))
- _, err = rawDB.GetChatModelConfigByID(ctx, defaultConfig.ID)
- require.ErrorIs(t, err, sql.ErrNoRows)
- row, err = rawDB.GetChatModelConfigByID(ctx, replacement.ID)
+ defaultRow, err := rawDB.GetChatModelConfigByID(ctx, defaultConfig.ID)
require.NoError(t, err)
- require.True(t, row.IsDefault, "replacement must be promoted")
+ require.True(t, defaultRow.IsDefault)
- candidate, err := writerClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ promoted, err := writerClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
- Model: "gpt-4o-mini-promote",
+ Model: "gpt-4o-mini-promoted",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
isDefault := true
- updated, err = writerClient.UpdateChatModel(ctx, candidate.ID, codersdk.UpdateChatModelRequest{
+ _, err = writerClient.UpdateChatModel(ctx, promoted.OrganizationID, promoted.ID, codersdk.UpdateChatModelRequest{
IsDefault: &isDefault,
})
require.NoError(t, err)
- require.True(t, updated.IsDefault)
- row, err = rawDB.GetChatModelConfigByID(ctx, candidate.ID)
- require.NoError(t, err)
- require.True(t, row.IsDefault, "candidate must be promoted")
- row, err = rawDB.GetChatModelConfigByID(ctx, replacement.ID)
+ promotedRow, err := rawDB.GetChatModelConfigByID(ctx, promoted.ID)
require.NoError(t, err)
- require.False(t, row.IsDefault, "existing default must be demoted")
+ require.True(t, promotedRow.IsDefault)
})
t.Run("ReasoningEffortStored", func(t *testing.T) {
@@ -4973,12 +5449,12 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
- modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
@@ -5004,12 +5480,12 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
@@ -5029,12 +5505,12 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
@@ -5059,12 +5535,12 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
@@ -5085,10 +5561,10 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
})
@@ -5101,10 +5577,10 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
contextLimit := int64(4096)
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
@@ -5117,11 +5593,11 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
contextLimit := int64(4096)
missingProviderID := uuid.New()
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &missingProviderID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
@@ -5135,7 +5611,7 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "test-model-config-provider-" + uuid.NewString(),
@@ -5145,7 +5621,7 @@ func TestCreateChatModelConfig(t *testing.T) {
require.NoError(t, err)
contextLimit := int64(4096)
- modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
@@ -5160,11 +5636,11 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
missingProviderID := uuid.New()
contextLimit := int64(4096)
- _, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &missingProviderID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
@@ -5178,7 +5654,7 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "test-disabled-model-provider-" + uuid.NewString(),
@@ -5188,7 +5664,7 @@ func TestCreateChatModelConfig(t *testing.T) {
require.NoError(t, err)
contextLimit := int64(4096)
- _, err = client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err = client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
@@ -5202,7 +5678,7 @@ func TestCreateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
@@ -5214,7 +5690,7 @@ func TestCreateChatModelConfig(t *testing.T) {
require.NoError(t, err)
contextLimit := int64(4096)
- _, err = client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err = client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "anthropic/claude-opus-4.6",
ContextLimit: &contextLimit,
@@ -5236,16 +5712,20 @@ func TestCreateChatModelConfig(t *testing.T) {
aiProvider := createAIProviderForTest(t, adminClient, "openai", "test-api-key")
contextLimit := int64(4096)
- _, err := memberClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := memberClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
- requireSDKError(t, err, http.StatusForbidden)
+ // A plain org member holds no chat model config permissions in the
+ // org, so the create is rejected by the write pre-check before any
+ // privileged provider lookup.
+ sdkErr := requireSDKError(t, err, http.StatusForbidden)
+ require.Equal(t, "Forbidden.", sdkErr.Message)
})
}
-func TestUpdateChatModelConfig(t *testing.T) {
+func TestUpdateChatModel(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
@@ -5254,10 +5734,10 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
contextLimit := int64(8192)
- updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "GPT-4o Mini Updated",
ContextLimit: &contextLimit,
})
@@ -5266,32 +5746,9 @@ func TestUpdateChatModelConfig(t *testing.T) {
require.Equal(t, "GPT-4o Mini Updated", updated.DisplayName)
require.EqualValues(t, 8192, updated.ContextLimit)
- configs, err := client.ChatModels(ctx)
+ configs, err := client.ChatModels(ctx, modelConfig.OrganizationID)
require.NoError(t, err)
- require.Len(t, configs, 1)
- })
-
- t.Run("RejectsOtherOrganizationConfig", func(t *testing.T) {
- t.Parallel()
-
- ctx := testutil.Context(t, testutil.WaitLong)
- client, db := newChatClientWithDatabase(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
- defaultConfig := createChatModelConfig(t, client)
- otherOrg := dbgen.Organization(t, db, database.Organization{})
- otherConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
- OrganizationID: otherOrg.ID,
- AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
- })
-
- _, err := client.UpdateChatModel(ctx, otherConfig.ID, codersdk.UpdateChatModelRequest{
- DisplayName: "not allowed",
- })
- requireSDKError(t, err, http.StatusNotFound)
-
- stored, err := db.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), otherConfig.ID)
- require.NoError(t, err)
- require.NotEqual(t, "not allowed", stored.DisplayName)
+ require.Len(t, configs.Models, 1)
})
t.Run("SoleConfigRemainsDefaultWhenDemoted", func(t *testing.T) {
@@ -5300,9 +5757,9 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
- updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
IsDefault: ptr.Ref(false),
})
require.NoError(t, err)
@@ -5319,8 +5776,8 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- defaultConfig := createChatModelConfig(t, client)
- candidateConfig := createAdditionalChatModelConfig(
+ defaultConfig := createChatModel(t, client)
+ candidateConfig := createAdditionalChatModel(
t,
client,
coderdtest.TestChatProviderOpenAICompat,
@@ -5334,7 +5791,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
IsDefault: true,
})
- updated, err := client.UpdateChatModel(ctx, candidateConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := client.UpdateChatModel(ctx, candidateConfig.OrganizationID, candidateConfig.ID, codersdk.UpdateChatModelRequest{
IsDefault: ptr.Ref(true),
})
require.NoError(t, err)
@@ -5354,9 +5811,9 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
- updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Model: "gpt-4o-mini-updated",
})
require.NoError(t, err)
@@ -5371,7 +5828,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
@@ -5383,14 +5840,14 @@ func TestUpdateChatModelConfig(t *testing.T) {
require.NoError(t, err)
contextLimit := int64(4096)
- modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
- _, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Model: "anthropic/claude-opus-4.6",
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
@@ -5419,7 +5876,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
AIProviderID: uuid.NullUUID{UUID: aiProvider.ID, Valid: true},
})
- updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "Existing OpenRouter Config",
})
require.NoError(t, err)
@@ -5432,7 +5889,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
validProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenrouter,
@@ -5452,14 +5909,14 @@ func TestUpdateChatModelConfig(t *testing.T) {
require.NoError(t, err)
contextLimit := int64(4096)
- modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &validProvider.ID,
Model: "anthropic/claude-opus-4.6",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
- _, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &misconfiguredProvider.ID,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
@@ -5467,29 +5924,29 @@ func TestUpdateChatModelConfig(t *testing.T) {
require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.")
})
- t.Run("DisablePreservesRecordAndStateForNonAdmins", func(t *testing.T) {
+ t.Run("DisablePreservesRecordAndRejectsUseForMembers", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
- memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
- modelConfig := createChatModelConfig(t, adminClient)
+ modelConfig := createChatModel(t, adminClient)
enabled := false
- updated, err := adminClient.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := adminClient.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Enabled: &enabled,
})
require.NoError(t, err)
require.Equal(t, modelConfig.ID, updated.ID)
require.False(t, updated.Enabled)
- adminConfigs, err := adminClient.ChatModels(ctx)
+ adminConfigs, err := adminClient.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
foundForAdmin := false
- for _, config := range adminConfigs {
+ for _, config := range adminConfigs.Models {
if config.ID == modelConfig.ID {
foundForAdmin = true
require.False(t, config.Enabled)
@@ -5497,20 +5954,27 @@ func TestUpdateChatModelConfig(t *testing.T) {
}
require.True(t, foundForAdmin)
- memberConfigs, err := memberClient.ChatModels(ctx)
+ availability, err := memberClient.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
-
- foundForMember := false
- for _, config := range memberConfigs {
- if config.ID == modelConfig.ID {
- foundForMember = true
- require.False(t, config.Enabled)
- }
+ runtimeIDs := make([]uuid.UUID, 0, len(availability.Models))
+ for _, config := range availability.Models {
+ runtimeIDs = append(runtimeIDs, config.ID)
}
- require.True(t, foundForMember)
+ require.NotContains(t, runtimeIDs, modelConfig.ID)
+
+ _, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
+ OrganizationID: firstUser.OrganizationID,
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "use a disabled model config",
+ }},
+ ModelConfigID: &modelConfig.ID,
+ })
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message)
})
- t.Run("ReEnableUpdatesStateForNonAdmins", func(t *testing.T) {
+ t.Run("ReEnableUpdatesRuntimeAvailability", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -5523,7 +5987,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
contextLimit := int64(4096)
enabled := false
- modelConfig, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ modelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-reenable",
DisplayName: "GPT-4o Re-enable",
@@ -5533,37 +5997,29 @@ func TestUpdateChatModelConfig(t *testing.T) {
require.NoError(t, err)
require.False(t, modelConfig.Enabled)
- memberConfigs, err := memberClient.ChatModels(ctx)
+ availability, err := memberClient.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
-
- foundForMember := false
- for _, config := range memberConfigs {
- if config.ID == modelConfig.ID {
- foundForMember = true
- require.False(t, config.Enabled)
- }
+ runtimeIDs := make([]uuid.UUID, 0, len(availability.Models))
+ for _, config := range availability.Models {
+ runtimeIDs = append(runtimeIDs, config.ID)
}
- require.True(t, foundForMember)
+ require.NotContains(t, runtimeIDs, modelConfig.ID)
enabled = true
- updated, err := adminClient.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := adminClient.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Enabled: &enabled,
})
require.NoError(t, err)
require.Equal(t, modelConfig.ID, updated.ID)
require.True(t, updated.Enabled)
- memberConfigs, err = memberClient.ChatModels(ctx)
+ availability, err = memberClient.ChatModelAvailability(ctx, firstUser.OrganizationID)
require.NoError(t, err)
-
- foundForMember = false
- for _, config := range memberConfigs {
- if config.ID == modelConfig.ID {
- foundForMember = true
- require.True(t, config.Enabled)
- }
+ runtimeIDs = runtimeIDs[:0]
+ for _, config := range availability.Models {
+ runtimeIDs = append(runtimeIDs, config.ID)
}
- require.True(t, foundForMember)
+ require.Contains(t, runtimeIDs, modelConfig.ID)
})
t.Run("UpdateAIProviderID", func(t *testing.T) {
@@ -5572,7 +6028,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeAnthropic,
Name: "test-update-model-provider-" + uuid.NewString(),
@@ -5581,7 +6037,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
})
require.NoError(t, err)
- updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &provider.ID,
Model: "claude-3-5-sonnet-latest",
})
@@ -5596,7 +6052,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeAnthropic,
Name: "test-preserve-model-provider-" + uuid.NewString(),
@@ -5605,14 +6061,14 @@ func TestUpdateChatModelConfig(t *testing.T) {
})
require.NoError(t, err)
- updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &provider.ID,
Model: "claude-3-5-sonnet-latest",
})
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, updated.AIProviderID)
- updated, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Model: "claude-3-5-haiku-latest",
})
require.NoError(t, err)
@@ -5626,10 +6082,10 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
missingProviderID := uuid.New()
- _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &missingProviderID,
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
@@ -5642,7 +6098,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "test-update-disabled-model-provider-" + uuid.NewString(),
@@ -5651,7 +6107,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
})
require.NoError(t, err)
- _, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &provider.ID,
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
@@ -5664,10 +6120,10 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
missingProviderID := uuid.New()
- _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &missingProviderID,
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
@@ -5680,11 +6136,11 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
store.armFailNextUpdate(modelConfig.ID)
- _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "missing in tx",
})
requireSDKError(t, err, http.StatusNotFound)
@@ -5695,14 +6151,14 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
- defaultConfig := createChatModelConfig(t, client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ defaultConfig := createChatModel(t, client)
aiProvider := createAIProviderForTest(t, client, "anthropic", "candidate-api-key")
contextLimit := int64(4096)
isDefault := false
- candidateConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ candidateConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "claude-3-5-sonnet",
ContextLimit: &contextLimit,
@@ -5712,7 +6168,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
store.armFailNextUpdate(candidateConfig.ID)
- _, err = client.UpdateChatModel(ctx, defaultConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err = client.UpdateChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID, codersdk.UpdateChatModelRequest{
IsDefault: ptr.Ref(false),
})
sdkErr := requireSDKError(t, err, http.StatusInternalServerError)
@@ -5724,9 +6180,9 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _, err := client.UpdateChatModel(ctx, uuid.New(), codersdk.UpdateChatModelRequest{
+ _, err := client.UpdateChatModel(ctx, firstUser.OrganizationID, uuid.New(), codersdk.UpdateChatModelRequest{
DisplayName: "missing",
})
requireSDKError(t, err, http.StatusNotFound)
@@ -5738,10 +6194,10 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
contextLimit := int64(0)
- _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
ContextLimit: &contextLimit,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
@@ -5756,11 +6212,11 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
store.armVanishAtLockedRead(modelConfig.ID)
- _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "vanished before update",
})
requireSDKError(t, err, http.StatusNotFound)
@@ -5772,12 +6228,12 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
const sentinel = "locked-copy-sentinel-model"
store.armMutateAtLockedRead(modelConfig.ID, sentinel)
- updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "only-display-name",
})
require.NoError(t, err)
@@ -5791,7 +6247,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
disabled := false
_, err := client.UpdateAIProvider(ctx, modelConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{
@@ -5799,7 +6255,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
})
require.NoError(t, err)
- _, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Model: "gpt-4o-different",
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
@@ -5812,27 +6268,60 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
err := client.DeleteAIProvider(ctx, modelConfig.AIProviderID.String())
require.NoError(t, err)
- _, err = client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Model: "gpt-4o-different",
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
require.Equal(t, "AI provider is not configured.", sdkErr.Message)
})
+ t.Run("RejectsACLKeys", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client := newChatClient(t)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ modelConfig := createChatModel(t, client)
+
+ for _, tc := range []struct {
+ name string
+ key string
+ value any
+ }{
+ {name: "group_acl/object", key: "group_acl", value: map[string]any{}},
+ {name: "group_acl/null", key: "group_acl", value: nil},
+ {name: "user_acl/object", key: "user_acl", value: map[string]any{}},
+ {name: "user_acl/null", key: "user_acl", value: nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ res, err := client.Request(ctx, http.MethodPatch, fmt.Sprintf(
+ "/api/experimental/organizations/%s/chats/models/%s",
+ modelConfig.OrganizationID,
+ modelConfig.ID,
+ ), map[string]any{tc.key: tc.value})
+ require.NoError(t, err)
+ defer res.Body.Close()
+ err = codersdk.ReadBodyAsError(res)
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Contains(t, sdkErr.Message, "nested /acl endpoint")
+ })
+ }
+ })
+
t.Run("CompressionThresholdOutOfRange", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
- _, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ _, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
CompressionThreshold: ptr.Ref(int32(150)),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
@@ -5845,9 +6334,9 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
- updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
CompressionThreshold: ptr.Ref(int32(55)),
})
require.NoError(t, err)
@@ -5860,9 +6349,9 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
- updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
ModelConfig: &codersdk.ChatModelCallConfig{
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref("high"),
@@ -5882,12 +6371,12 @@ func TestUpdateChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
res, err := client.Request(
ctx,
http.MethodPatch,
- "/api/experimental/chats/model-configs/not-a-uuid",
+ fmt.Sprintf("/api/experimental/organizations/%s/chats/models/not-a-uuid", firstUser.OrganizationID),
codersdk.UpdateChatModelRequest{DisplayName: "ignored"},
)
require.NoError(t, err)
@@ -5895,7 +6384,7 @@ func TestUpdateChatModelConfig(t *testing.T) {
err = codersdk.ReadBodyAsError(res)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
- require.Equal(t, "Invalid chat model config ID.", sdkErr.Message)
+ require.Equal(t, `Invalid UUID "not-a-uuid".`, sdkErr.Message)
})
t.Run("ForbiddenForOrganizationMember", func(t *testing.T) {
@@ -5907,11 +6396,13 @@ func TestUpdateChatModelConfig(t *testing.T) {
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
- modelConfig := createChatModelConfig(t, adminClient)
- _, err := memberClient.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ modelConfig := createChatModel(t, adminClient)
+ _, err := memberClient.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "member update",
})
- requireSDKError(t, err, http.StatusForbidden)
+ // Action-aware item admission conceals a caller without update access.
+ sdkErr := requireSDKError(t, err, http.StatusNotFound)
+ require.Equal(t, "Resource not found or you do not have access to this resource", sdkErr.Message)
})
}
@@ -5924,45 +6415,26 @@ func TestDeleteChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
- err := client.DeleteChatModel(ctx, modelConfig.ID)
+ err := client.DeleteChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID)
require.NoError(t, err)
- configs, err := client.ChatModels(ctx)
+ configs, err := client.ChatModels(ctx, modelConfig.OrganizationID)
require.NoError(t, err)
- for _, config := range configs {
+ for _, config := range configs.Models {
require.NotEqual(t, modelConfig.ID, config.ID)
}
})
- t.Run("RejectsOtherOrganizationConfig", func(t *testing.T) {
- t.Parallel()
-
- ctx := testutil.Context(t, testutil.WaitLong)
- client, db := newChatClientWithDatabase(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
- defaultConfig := createChatModelConfig(t, client)
- otherOrg := dbgen.Organization(t, db, database.Organization{})
- otherConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
- OrganizationID: otherOrg.ID,
- AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
- })
-
- err := client.DeleteChatModel(ctx, otherConfig.ID)
- requireSDKError(t, err, http.StatusNotFound)
- _, err = db.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), otherConfig.ID)
- require.NoError(t, err)
- })
-
t.Run("DeletionLeavesOtherOrganizationDefaultUnchanged", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- defaultConfig := createChatModelConfig(t, client)
- candidateConfig := createAdditionalChatModelConfig(
+ defaultConfig := createChatModel(t, client)
+ candidateConfig := createAdditionalChatModel(
t,
client,
coderdtest.TestChatProviderOpenAICompat,
@@ -5976,7 +6448,7 @@ func TestDeleteChatModelConfig(t *testing.T) {
IsDefault: true,
})
- err := client.DeleteChatModel(ctx, defaultConfig.ID)
+ err := client.DeleteChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID)
require.NoError(t, err)
orgDefault, err := db.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), firstUser.OrganizationID)
@@ -5996,30 +6468,30 @@ func TestDeleteChatModelConfig(t *testing.T) {
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- defaultConfig := createChatModelConfig(t, client)
+ defaultConfig := createChatModel(t, client)
// Same provider type as the enabled candidate with an
// alphabetically earlier model, so it sorts first in the
// reselection order.
- createProviderDisabledChatModelConfig(
+ createProviderDisabledChatModel(
t,
client,
coderdtest.TestChatProviderOpenAICompat,
"a-provider-disabled-model",
)
- enabledConfig := createAdditionalChatModelConfig(
+ enabledConfig := createAdditionalChatModel(
t,
client,
coderdtest.TestChatProviderOpenAICompat,
"z-enabled-model",
)
- err := client.DeleteChatModel(ctx, defaultConfig.ID)
+ err := client.DeleteChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID)
require.NoError(t, err)
- configs, err := client.ChatModels(ctx)
+ configs, err := client.ChatModels(ctx, defaultConfig.OrganizationID)
require.NoError(t, err)
defaultID := uuid.Nil
- for _, config := range configs {
+ for _, config := range configs.Models {
if config.IsDefault {
defaultID = config.ID
}
@@ -6032,9 +6504,9 @@ func TestDeleteChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
- err := client.DeleteChatModel(ctx, uuid.New())
+ err := client.DeleteChatModel(ctx, firstUser.OrganizationID, uuid.New())
requireSDKError(t, err, http.StatusNotFound)
})
@@ -6044,11 +6516,11 @@ func TestDeleteChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
store.armFailNextDelete(modelConfig.ID)
- err := client.DeleteChatModel(ctx, modelConfig.ID)
+ err := client.DeleteChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID)
requireSDKError(t, err, http.StatusNotFound)
})
@@ -6057,12 +6529,12 @@ func TestDeleteChatModelConfig(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
- _ = coderdtest.CreateFirstUser(t, client.Client)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
res, err := client.Request(
ctx,
http.MethodDelete,
- "/api/experimental/chats/model-configs/not-a-uuid",
+ fmt.Sprintf("/api/experimental/organizations/%s/chats/models/not-a-uuid", firstUser.OrganizationID),
nil,
)
require.NoError(t, err)
@@ -6070,7 +6542,7 @@ func TestDeleteChatModelConfig(t *testing.T) {
err = codersdk.ReadBodyAsError(res)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
- require.Equal(t, "Invalid chat model config ID.", sdkErr.Message)
+ require.Equal(t, `Invalid UUID "not-a-uuid".`, sdkErr.Message)
})
t.Run("ForbiddenForOrganizationMember", func(t *testing.T) {
@@ -6082,9 +6554,11 @@ func TestDeleteChatModelConfig(t *testing.T) {
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
- modelConfig := createChatModelConfig(t, adminClient)
- err := memberClient.DeleteChatModel(ctx, modelConfig.ID)
- requireSDKError(t, err, http.StatusForbidden)
+ modelConfig := createChatModel(t, adminClient)
+ err := memberClient.DeleteChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID)
+ // Action-aware item admission conceals a caller without delete access.
+ sdkErr := requireSDKError(t, err, http.StatusNotFound)
+ require.Equal(t, "Resource not found or you do not have access to this resource", sdkErr.Message)
})
}
@@ -6097,7 +6571,7 @@ func TestGetChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -6144,7 +6618,7 @@ func TestGetChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -6201,7 +6675,7 @@ func TestGetChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Upload a file.
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
@@ -6240,7 +6714,7 @@ func TestGetChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Create a chat via the API so all metadata is set up.
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -6349,7 +6823,7 @@ func TestGetChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
@@ -6454,7 +6928,7 @@ func TestGetChatUserPrompts(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
@@ -6593,7 +7067,7 @@ func TestGetChatUserPrompts(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
@@ -6627,7 +7101,7 @@ func TestGetChatUserPrompts(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
@@ -6652,7 +7126,7 @@ func TestGetChatUserPrompts(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: firstUser.OrganizationID,
@@ -6692,7 +7166,7 @@ func TestGetChatUserPrompts(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
emptyChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
@@ -6809,7 +7283,7 @@ func TestPatchChat(t *testing.T) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "set plan mode")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
@@ -6836,7 +7310,7 @@ func TestPatchChat(t *testing.T) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "clear plan mode")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
@@ -6868,7 +7342,7 @@ func TestPatchChat(t *testing.T) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "invalid plan mode")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
@@ -6897,7 +7371,7 @@ func TestPatchChat(t *testing.T) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
@@ -6940,7 +7414,7 @@ func TestPatchChat(t *testing.T) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := createStoredChat(
ctx,
@@ -6974,7 +7448,7 @@ func TestPatchChat(t *testing.T) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
secondOrg := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
@@ -7017,7 +7491,7 @@ func TestPatchChat(t *testing.T) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
@@ -7066,7 +7540,7 @@ func TestPatchChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "original title")
@@ -7087,7 +7561,7 @@ func TestPatchChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "before trim")
@@ -7108,7 +7582,7 @@ func TestPatchChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "keep original")
@@ -7127,7 +7601,7 @@ func TestPatchChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "keep original length")
@@ -7187,7 +7661,7 @@ func TestPatchChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "boundary baseline")
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
@@ -7221,7 +7695,7 @@ func TestPatchChat(t *testing.T) {
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
client := codersdk.NewExperimentalClient(clientRaw)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "rename me")
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
@@ -7259,7 +7733,7 @@ func TestPatchChat(t *testing.T) {
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
client := codersdk.NewExperimentalClient(clientRaw)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "steady title")
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
@@ -7288,7 +7762,7 @@ func TestPatchChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "announce me")
@@ -7332,7 +7806,7 @@ func TestArchiveChat(t *testing.T) {
o.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chatToArchive, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -7414,7 +7888,7 @@ func TestArchiveChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Create a parent chat via the API.
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -7503,7 +7977,7 @@ func TestArchiveChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Create a parent chat via the API.
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -7552,7 +8026,7 @@ func TestUnarchiveChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -7604,7 +8078,7 @@ func TestUnarchiveChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
@@ -7697,7 +8171,7 @@ func TestUnarchiveChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -7722,7 +8196,7 @@ func TestUnarchiveChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Create a parent chat via the API.
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -7770,7 +8244,7 @@ func TestUnarchiveChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
@@ -7854,7 +8328,7 @@ func TestChatPinOrder(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
first := createChat(ctx, t, client, firstUser.OrganizationID, "first pinned chat")
second := createChat(ctx, t, client, firstUser.OrganizationID, "second pinned chat")
@@ -7901,7 +8375,7 @@ func TestChatPinOrder(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
first := createChat(ctx, t, client, firstUser.OrganizationID, "pinned then archived")
second := createChat(ctx, t, client, firstUser.OrganizationID, "stays pinned")
@@ -7933,7 +8407,7 @@ func TestChatPinOrder(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "negative pin order")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(-1))})
@@ -7950,7 +8424,7 @@ func TestChatPinOrder(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
parentChat := createChat(ctx, t, client, firstUser.OrganizationID, "parent chat")
@@ -7983,7 +8457,7 @@ func TestPostChatMessages(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -8074,7 +8548,7 @@ func TestPostChatMessages(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -8085,7 +8559,7 @@ func TestPostChatMessages(t *testing.T) {
})
require.NoError(t, err)
- providerDisabledConfig := createProviderDisabledChatModelConfig(
+ providerDisabledConfig := createProviderDisabledChatModel(
t,
client,
"openai",
@@ -8103,13 +8577,113 @@ func TestPostChatMessages(t *testing.T) {
require.Equal(t, "Invalid model_config_id: provider is not enabled for this model.", sdkErr.Message)
})
+ t.Run("CrossOrgModelConfigRejected", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ defaultConfig := createChatModel(t, client)
+ chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
+ OrganizationID: firstUser.OrganizationID,
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "initial message before cross-org switch",
+ }},
+ })
+ require.NoError(t, err)
+
+ otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ otherConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
+ Model: "cross-org-send-" + uuid.NewString(),
+ Enabled: true,
+ OrganizationID: otherOrg.ID,
+ })
+ _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "reject another organization's model",
+ }},
+ ModelConfigID: ptr.Ref(otherConfig.ID),
+ })
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message)
+ })
+
+ t.Run("HistoricalForeignModelFallsBackToLocalDefault", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ foreignConfig := createChatModel(t, client)
+ provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
+ chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ localConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
+ Model: "local-send-" + uuid.NewString(),
+ Enabled: true,
+ IsDefault: true,
+ OrganizationID: chatOrg.ID,
+ })
+ memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID))
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ chat := dbgen.Chat(t, db, database.Chat{
+ OrganizationID: chatOrg.ID,
+ OwnerID: member.ID,
+ LastModelConfigID: foreignConfig.ID,
+ Title: "historical foreign model",
+ })
+
+ resp, err := memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "use the local fallback",
+ }},
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp.Message)
+ require.NotNil(t, resp.Message.ModelConfigID)
+ require.Equal(t, localConfig.ID, *resp.Message.ModelConfigID)
+ require.Equal(t, firstUser.OrganizationID, foreignConfig.OrganizationID)
+ })
+
+ t.Run("HistoricalForeignModelWithoutLocalDefaultReturnsGuidance", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ foreignConfig := createChatModel(t, client)
+ chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID))
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ chat := dbgen.Chat(t, db, database.Chat{
+ OrganizationID: chatOrg.ID,
+ OwnerID: member.ID,
+ LastModelConfigID: foreignConfig.ID,
+ Title: "historical foreign model without fallback",
+ })
+
+ _, err := memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "require a local fallback",
+ }},
+ })
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
+ })
+
t.Run("ProviderDisabledDefaultFallbackRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- defaultConfig := createChatModelConfig(t, client)
+ defaultConfig := createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -8134,7 +8708,8 @@ func TestPostChatMessages(t *testing.T) {
}},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
- require.Equal(t, "No default chat model config is configured.", sdkErr.Message)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
})
t.Run("MemberWithoutAgentsAccess", func(t *testing.T) {
@@ -8143,7 +8718,7 @@ func TestPostChatMessages(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Create a member without agents-access and insert a
// chat owned by them via system context. Without
@@ -8176,7 +8751,7 @@ func TestPostChatMessages(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -8208,7 +8783,7 @@ func TestPostChatMessages(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
_, err := client.CreateChatMessage(ctx, uuid.New(), codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
@@ -8227,7 +8802,7 @@ func TestPostChatMessages(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -8279,8 +8854,8 @@ func TestSendMessageWithModelOverrideUpdatesLastModelConfigID(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfigA := createChatModelConfig(t, client)
- modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-override-"+uuid.NewString())
+ modelConfigA := createChatModel(t, client)
+ modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-override-"+uuid.NewString())
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -8322,7 +8897,7 @@ func TestSendMessageWithReasoningEffortUpdatesLastReasoningEffort(t *testing.T)
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -8378,7 +8953,7 @@ func TestSendMessageRejectsInvalidReasoningEffort(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -8406,7 +8981,7 @@ func TestSendMessageQueuesReasoningEffort(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -8453,8 +9028,8 @@ func TestSendMessageQueuesEffectiveModelConfigID(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfigA := createChatModelConfig(t, client)
- modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-queued-"+uuid.NewString())
+ modelConfigA := createChatModel(t, client)
+ modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-queued-"+uuid.NewString())
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -8508,8 +9083,8 @@ func TestQueuedMessageWithoutOverrideCapturesEnqueueTimeModel(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfigA := createChatModelConfig(t, client)
- modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-later-"+uuid.NewString())
+ modelConfigA := createChatModel(t, client)
+ modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-later-"+uuid.NewString())
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -8560,8 +9135,8 @@ func TestSubsequentSendWithoutOverrideUsesPersistedModel(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
- modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-persisted-"+uuid.NewString())
+ _ = createChatModel(t, client)
+ modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-persisted-"+uuid.NewString())
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -8601,8 +9176,8 @@ func TestWatchChatsStatusChangeCarriesUpdatedLastModelConfigID(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfigA := createChatModelConfig(t, client)
- modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-watch-direct-"+uuid.NewString())
+ modelConfigA := createChatModel(t, client)
+ modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-watch-direct-"+uuid.NewString())
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -8634,8 +9209,8 @@ func TestWatchChatsStatusChangeCarriesUpdatedLastModelConfigID(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfigA := createChatModelConfig(t, client)
- modelConfigB := createAdditionalChatModelConfig(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-watch-promote-"+uuid.NewString())
+ modelConfigA := createChatModel(t, client)
+ modelConfigB := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-watch-promote-"+uuid.NewString())
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -8719,7 +9294,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChatForTest(t, client, firstUser.OrganizationID)
created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
@@ -8781,7 +9356,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChatForTest(t, client, firstUser.OrganizationID)
created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
@@ -8834,7 +9409,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChatForTest(t, client, firstUser.OrganizationID)
created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
@@ -8887,7 +9462,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChatForTest(t, client, firstUser.OrganizationID)
created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
@@ -8940,7 +9515,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChatForTest(t, client, firstUser.OrganizationID)
created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
@@ -9053,7 +9628,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat := createChatForTest(t, client, firstUser.OrganizationID)
_, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
@@ -9075,7 +9650,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// File references should also work in the initial CreateChat call.
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -9106,7 +9681,7 @@ func TestChatMessageWithFiles(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Upload a file.
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
@@ -9150,7 +9725,7 @@ func TestChatMessageWithFiles(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Upload a file.
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
@@ -9210,7 +9785,7 @@ func TestChatMessageWithFiles(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Upload a file.
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
@@ -9249,7 +9824,7 @@ func TestChatMessageWithFiles(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Upload a synthetic pasted-text attachment as created by the
// chat UI when a large paste is collapsed into a file.
@@ -9284,7 +9859,7 @@ func TestChatMessageWithFiles(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Create a chat with text first.
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -9318,7 +9893,7 @@ func TestChatMessageWithFiles(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -9363,7 +9938,7 @@ func TestChatMessageWithFiles(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Create a text-only chat (no files initially).
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -9402,7 +9977,7 @@ func TestChatMessageWithFiles(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Upload a file.
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
@@ -9440,7 +10015,7 @@ func TestChatMessageWithFiles(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
@@ -9520,7 +10095,7 @@ func TestChatMessageWithFiles(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
@@ -9561,7 +10136,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -9632,6 +10207,131 @@ func TestPatchChatMessage(t *testing.T) {
require.False(t, foundOriginalInChat)
})
+ t.Run("CrossOrgModelConfigRejected", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ firstUser := coderdtest.CreateFirstUser(t, client.Client)
+ defaultConfig := createChatModel(t, client)
+ chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
+ OrganizationID: firstUser.OrganizationID,
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "before cross-org edit",
+ }},
+ })
+ require.NoError(t, err)
+ messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil)
+ require.NoError(t, err)
+ userMessageID := messagesResult.Messages[0].ID
+
+ otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ otherConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
+ Model: "cross-org-edit-" + uuid.NewString(),
+ Enabled: true,
+ OrganizationID: otherOrg.ID,
+ })
+ _, err = client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "reject another organization's model",
+ }},
+ ModelConfigID: ptr.Ref(otherConfig.ID),
+ })
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message)
+
+ storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
+ require.NoError(t, err)
+ require.Equal(t, defaultConfig.ID, storedChat.LastModelConfigID)
+ })
+
+ t.Run("HistoricalForeignModelFallsBackToLocalDefault", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ foreignConfig := createChatModel(t, client)
+ provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
+ chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ localConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
+ Model: "local-edit-" + uuid.NewString(),
+ Enabled: true,
+ IsDefault: true,
+ OrganizationID: chatOrg.ID,
+ })
+ memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID))
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ chat := dbgen.Chat(t, db, database.Chat{
+ OrganizationID: chatOrg.ID,
+ OwnerID: member.ID,
+ LastModelConfigID: foreignConfig.ID,
+ Title: "historical foreign model edit",
+ })
+ content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText("before edit")})
+ require.NoError(t, err)
+ message := dbgen.ChatMessage(t, db, database.ChatMessage{
+ ChatID: chat.ID,
+ CreatedBy: uuid.NullUUID{UUID: member.ID, Valid: true},
+ ModelConfigID: uuid.NullUUID{UUID: foreignConfig.ID, Valid: true},
+ Role: database.ChatMessageRoleUser,
+ Visibility: database.ChatMessageVisibilityBoth,
+ Content: content,
+ })
+
+ edited, err := memberClient.EditChatMessage(ctx, chat.ID, message.ID, codersdk.EditChatMessageRequest{
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "after edit",
+ }},
+ })
+ require.NoError(t, err)
+ require.NotNil(t, edited.Message.ModelConfigID)
+ require.Equal(t, localConfig.ID, *edited.Message.ModelConfigID)
+ })
+
+ t.Run("HistoricalForeignModelWithoutLocalDefaultReturnsGuidance", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ foreignConfig := createChatModel(t, client)
+ chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID))
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ chat := dbgen.Chat(t, db, database.Chat{
+ OrganizationID: chatOrg.ID,
+ OwnerID: member.ID,
+ LastModelConfigID: foreignConfig.ID,
+ Title: "historical foreign model edit without fallback",
+ })
+ content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText("before edit")})
+ require.NoError(t, err)
+ message := dbgen.ChatMessage(t, db, database.ChatMessage{
+ ChatID: chat.ID,
+ CreatedBy: uuid.NullUUID{UUID: member.ID, Valid: true},
+ ModelConfigID: uuid.NullUUID{UUID: foreignConfig.ID, Valid: true},
+ Role: database.ChatMessageRoleUser,
+ Visibility: database.ChatMessageVisibilityBoth,
+ Content: content,
+ })
+
+ _, err = memberClient.EditChatMessage(ctx, chat.ID, message.ID, codersdk.EditChatMessageRequest{
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "after edit",
+ }},
+ })
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
+ })
+
t.Run("ReasoningEffort", func(t *testing.T) {
t.Parallel()
@@ -9649,7 +10349,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -9692,7 +10392,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -9726,7 +10426,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Upload a file.
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
@@ -9823,7 +10523,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -9854,7 +10554,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -9894,7 +10594,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Create a text-only chat.
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -9947,7 +10647,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
// Create a chat with MaxChatFileIDs files already linked.
parts := []codersdk.ChatInputPart{
@@ -10012,7 +10712,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -10057,8 +10757,8 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- defaultModel := createChatModelConfig(t, client)
- overrideModel := createAdditionalChatModelConfig(
+ defaultModel := createChatModel(t, client)
+ overrideModel := createAdditionalChatModel(
t,
client,
coderdtest.TestChatProviderOpenAICompat,
@@ -10137,7 +10837,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -10177,7 +10877,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -10199,7 +10899,7 @@ func TestPatchChatMessage(t *testing.T) {
}
require.NotZero(t, userMessageID)
- providerDisabledConfig := createProviderDisabledChatModelConfig(
+ providerDisabledConfig := createProviderDisabledChatModel(
t,
client,
"openai",
@@ -10222,7 +10922,7 @@ func TestPatchChatMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- defaultConfig := createChatModelConfig(t, client)
+ defaultConfig := createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -10258,7 +10958,8 @@ func TestPatchChatMessage(t *testing.T) {
}},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
- require.Equal(t, "No default chat model config is configured.", sdkErr.Message)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
})
}
@@ -10271,7 +10972,7 @@ func TestStreamChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
const initialMessage = "stream chat route initial message"
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -10347,7 +11048,7 @@ func TestInterruptChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -10441,7 +11142,7 @@ func TestCompactChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID)
// Assert on the response snapshot only: the chat is runnable
@@ -10459,7 +11160,7 @@ func TestCompactChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Idle chat with only a user message: no assistant turn to
// summarize.
@@ -10497,7 +11198,7 @@ func TestCompactChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID)
_, err := db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{
@@ -10526,7 +11227,7 @@ func TestCompactChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID)
_, err := db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{
@@ -10550,7 +11251,7 @@ func TestCompactChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID)
_, err := db.ArchiveChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
@@ -10597,7 +11298,7 @@ func TestCompactChat(t *testing.T) {
db := api.Database
client := codersdk.NewExperimentalClient(clientRaw)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID)
_, err := client.CompactChat(ctx, chat.ID)
@@ -10638,7 +11339,7 @@ func TestRegenerateChatTitle(t *testing.T) {
db := api.Database
client := codersdk.NewExperimentalClient(clientRaw)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -10657,7 +11358,7 @@ func TestRegenerateChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -10682,7 +11383,7 @@ func TestRegenerateChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -10704,7 +11405,7 @@ func TestRegenerateChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithoutAIBridge(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createTitleGenerationModelConfig(t, client)
+ modelConfig := createTitleGenerationChatModel(t, client)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
chat := dbgen.Chat(t, db, database.Chat{
@@ -10729,7 +11430,7 @@ func TestRegenerateChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithoutAIBridge(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createTitleGenerationModelConfig(t, client)
+ modelConfig := createTitleGenerationChatModel(t, client)
// Wire the daemon's reload subscription to a pubsub coderd never
// publishes to: gateway routes can then only come from the
@@ -10760,7 +11461,7 @@ func TestRegenerateChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithoutAIBridge(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createTitleGenerationModelConfig(t, client)
+ modelConfig := createTitleGenerationChatModel(t, client)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
chat := dbgen.Chat(t, db, database.Chat{
@@ -10802,7 +11503,8 @@ func TestRegenerateChatTitle(t *testing.T) {
_, err := client.RegenerateChatTitle(ctx, chat.ID)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
- require.Equal(t, "No default chat model config is configured.", sdkErr.Message)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
})
t.Run("RegenerationFailure", func(t *testing.T) {
@@ -10811,7 +11513,7 @@ func TestRegenerateChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithoutAIBridge(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfigWithTitleFailure(t, client)
+ _ = createChatModelWithTitleFailure(t, client)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -10854,7 +11556,7 @@ func TestRegenerateChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfigWithTitleQuotaExhausted(t, client)
+ _ = createChatModelWithTitleQuotaExhausted(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -10920,7 +11622,7 @@ func TestProposeChatTitle(t *testing.T) {
db := api.Database
client := codersdk.NewExperimentalClient(clientRaw)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -10940,7 +11642,7 @@ func TestProposeChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -10962,7 +11664,7 @@ func TestProposeChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithoutAIBridge(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createTitleGenerationModelConfig(t, client)
+ modelConfig := createTitleGenerationChatModel(t, client)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
chat := dbgen.Chat(t, db, database.Chat{
@@ -11002,7 +11704,8 @@ func TestProposeChatTitle(t *testing.T) {
_, err := client.ProposeChatTitle(ctx, chat.ID)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
- require.Equal(t, "No default chat model config is configured.", sdkErr.Message)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
})
t.Run("StoppedWorkspace", func(t *testing.T) {
@@ -11011,7 +11714,7 @@ func TestProposeChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithoutAIBridge(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createTitleGenerationModelConfig(t, client)
+ modelConfig := createTitleGenerationChatModel(t, client)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
@@ -11047,7 +11750,7 @@ func TestProposeChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithoutAIBridge(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfigWithTitleFailure(t, client)
+ _ = createChatModelWithTitleFailure(t, client)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -11080,7 +11783,7 @@ func TestProposeChatTitle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, _, api := newChatClientWithAPIAndDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfigWithTitleQuotaExhausted(t, client)
+ _ = createChatModelWithTitleQuotaExhausted(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -11133,7 +11836,7 @@ func TestManualTitleEndpointsPassOwnerSyntheticAPIKeyToAIGateway(t *testing.T) {
opts.DeploymentValues = values
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createAdditionalChatModelConfig(t, client, "openai", "gpt-4.1")
+ modelConfig := createAdditionalChatModel(t, client, "openai", "gpt-4.1")
wantTitle := "Fallback title"
seenAPIKeyID := make(chan string, 1)
stub := &stubTransportFactory{
@@ -11208,7 +11911,7 @@ func TestPostChats_AutomaticTitleGeneration(t *testing.T) {
client, _, api := newChatClientWithoutAIBridge(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfigWithBaseURL(t, client, baseURL)
+ _ = createChatModelWithBaseURL(t, client, baseURL)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
@@ -11264,7 +11967,7 @@ func TestPostChats_AutomaticTitleGenerationPasteOnly(t *testing.T) {
client, _, api := newChatClientWithoutAIBridge(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfigWithBaseURL(t, client, baseURL)
+ _ = createChatModelWithBaseURL(t, client, baseURL)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
uploadResp, err := client.UploadChatFile(
@@ -11321,7 +12024,7 @@ func TestGetChatDiffStatus(t *testing.T) {
db := api.Database
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
noCachedStatusChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -11401,7 +12104,7 @@ func TestGetChatDiffStatus(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -11442,7 +12145,7 @@ func TestGetChatDiffContents(t *testing.T) {
client := codersdk.NewExperimentalClient(rawClient)
db := api.Database
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
@@ -11481,7 +12184,7 @@ func TestGetChatDiffContents(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -11510,7 +12213,7 @@ func TestGetChatDiffContents(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -11539,7 +12242,7 @@ func TestDeleteChatQueuedMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -11584,7 +12287,7 @@ func TestDeleteChatQueuedMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -11619,7 +12322,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -11676,13 +12379,59 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
}
})
+ t.Run("ForeignModelWithoutLocalDefaultReturnsGuidance", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, db := newChatClientWithDatabase(t)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ foreignConfig := createChatModel(t, client)
+ chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID))
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ chat := dbgen.Chat(t, db, database.Chat{
+ OrganizationID: chatOrg.ID,
+ OwnerID: member.ID,
+ LastModelConfigID: foreignConfig.ID,
+ Title: "promote queued foreign model without fallback",
+ Status: database.ChatStatusError,
+ })
+
+ queuedContent, err := json.Marshal([]codersdk.ChatMessagePart{
+ codersdk.ChatMessageText("require a local model for promotion"),
+ })
+ require.NoError(t, err)
+ queuedMessage := insertTestChatQueuedMessage(ctx, t, db, chat.ID, queuedContent, foreignConfig.ID)
+
+ promoteRes, err := memberClient.Request(
+ ctx,
+ http.MethodPost,
+ fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
+ nil,
+ )
+ require.NoError(t, err)
+ defer promoteRes.Body.Close()
+
+ err = codersdk.ReadBodyAsError(promoteRes)
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
+ require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
+
+ remaining, err := db.GetChatQueuedMessageByID(dbauthz.AsSystemRestricted(ctx), database.GetChatQueuedMessageByIDParams{
+ ID: queuedMessage.ID,
+ ChatID: chat.ID,
+ })
+ require.NoError(t, err)
+ require.Equal(t, queuedMessage.ID, remaining.ID)
+ })
+
t.Run("InvalidQueuedMessageID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -11712,7 +12461,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Create a member without agents-access. Without
// agents-access the member has no ResourceChat
@@ -11750,7 +12499,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -11790,7 +12539,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
const dynamicToolName = "my_dynamic_tool"
dynamicTools := []mcp.Tool{{
@@ -11915,7 +12664,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
@@ -12591,7 +13340,7 @@ func TestChatFileDownloadURL(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, _ := newClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
uploaded, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "shared.png")
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -12680,7 +13429,7 @@ func TestGetChatCost(t *testing.T) {
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}
rootChat := dbgen.Chat(t, db, database.Chat{
@@ -12738,7 +13487,7 @@ func TestGetChatCost(t *testing.T) {
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}
chat := dbgen.Chat(t, db, database.Chat{
@@ -12769,7 +13518,7 @@ func TestGetChatCost(t *testing.T) {
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}
chat := dbgen.Chat(t, db, database.Chat{
@@ -12805,7 +13554,7 @@ func TestGetChatCost(t *testing.T) {
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}
chat := dbgen.Chat(t, db, database.Chat{
@@ -12834,7 +13583,7 @@ func TestGetChatCost(t *testing.T) {
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}
chat := dbgen.Chat(t, db, database.Chat{
@@ -12865,7 +13614,7 @@ func TestGetChatCost(t *testing.T) {
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}
firstRoot := dbgen.Chat(t, db, database.Chat{
@@ -12922,7 +13671,7 @@ func TestGetChatCost(t *testing.T) {
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
@@ -12952,7 +13701,7 @@ func TestGetChatCost(t *testing.T) {
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_, otherUser := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}
chat := dbgen.Chat(t, db, database.Chat{
@@ -13009,7 +13758,7 @@ func TestGetChatCost(t *testing.T) {
// create or read chats at all, so they never reach this endpoint.
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}
chat := dbgen.Chat(t, db, database.Chat{
@@ -13039,7 +13788,7 @@ func TestGetChatCost(t *testing.T) {
firstUser := coderdtest.CreateFirstUser(t, client.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
@@ -13062,7 +13811,7 @@ func TestGetChatCost(t *testing.T) {
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// An ungrouped aggregate always returns a row, so a chat with no
// gateway requests reports zeros instead of failing.
@@ -13093,7 +13842,7 @@ func TestWatchChatDesktop(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
@@ -13140,7 +13889,7 @@ func TestWatchChatGitAuthz(t *testing.T) {
// and is demoted after the chat is bound.
adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
- _ = createChatModelConfig(t, adminClient)
+ _ = createChatModel(t, adminClient)
// A second owner is needed to run UpdateUserRoles on the first
// user, since the server refuses self-demotion.
@@ -13301,15 +14050,15 @@ func seedPasteOnlyTitleSourceMessage(
})
}
-// createTitleGenerationModelConfig provisions a model config on the openai
+// createTitleGenerationChatModel provisions a model config on the openai
// provider type, which routes structured title generation through the
// Responses API. The chattest fake answers it with {"title": "Test Chat"}.
-func createTitleGenerationModelConfig(
+func createTitleGenerationChatModel(
t *testing.T,
client *codersdk.ExperimentalClient,
) codersdk.ChatModel {
t.Helper()
- return createAdditionalChatModelConfig(t, client, "openai", "gpt-4.1")
+ return createAdditionalChatModel(t, client, "openai", "gpt-4.1")
}
// seedChatWithDeletedModelConfig creates a chat whose only model config is
@@ -13331,43 +14080,35 @@ func seedChatWithDeletedModelConfig(
Title: "chat without model config",
})
seedManualTitleSourceMessage(t, db, chat, modelConfig.ID)
+ // The fixture's soft-delete runs under an owner subject authorized to
+ // delete the config, not a shared internal actor.
_, err := db.DeleteChatModelConfigByID(
- dbauthz.AsSystemRestricted(ctx),
+ dbauthz.As(ctx, rbac.Subject{
+ ID: user.UserID.String(),
+ Roles: rbac.RoleIdentifiers{rbac.RoleOwner()},
+ Scope: rbac.ScopeAll,
+ }),
modelConfig.ID,
)
require.NoError(t, err)
return chat
}
-func createChatModelConfig(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel {
- t.Helper()
- return coderdtest.CreateOpenAICompatChatModel(t, client, "")
-}
-
-// createChatModel bridges the SDK-layer name while the later API layer owns
-// the full helper rename.
func createChatModel(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel {
t.Helper()
- return createChatModelConfig(t, client)
+ return coderdtest.CreateOpenAICompatChatModel(t, client, "")
}
-// createChatModelWithBaseURL bridges the SDK-layer name while the later API
-// layer owns the full helper rename.
func createChatModelWithBaseURL(t testing.TB, client *codersdk.ExperimentalClient, baseURL string) codersdk.ChatModel {
- t.Helper()
- return createChatModelConfigWithBaseURL(t, client, baseURL)
-}
-
-func createChatModelConfigWithBaseURL(t testing.TB, client *codersdk.ExperimentalClient, baseURL string) codersdk.ChatModel {
t.Helper()
return coderdtest.CreateOpenAICompatChatModel(t, client, baseURL)
}
-// createChatModelConfigWithTitleFailure provisions a model whose streaming chat
+// createChatModelWithTitleFailure provisions a model whose streaming chat
// responses succeed, while non-streaming requests fail. The non-streaming path
// is how quick title generation requests structured output, so tests can fail
// title generation without breaking the main assistant response.
-func createChatModelConfigWithTitleFailure(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel {
+func createChatModelWithTitleFailure(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel {
t.Helper()
baseURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if req.Stream {
@@ -13375,13 +14116,13 @@ func createChatModelConfigWithTitleFailure(t testing.TB, client *codersdk.Experi
}
return chattest.OpenAIErrorResponse(http.StatusUnauthorized, "invalid_api_key", "test title failure")
})
- return createChatModelConfigWithBaseURL(t, client, baseURL)
+ return createChatModelWithBaseURL(t, client, baseURL)
}
-// createChatModelConfigWithTitleQuotaExhausted provisions a model whose
+// createChatModelWithTitleQuotaExhausted provisions a model whose
// non-streaming responses return a provider insufficient_quota error, which
// classifies as a usage limit like an exhausted AI Gateway budget.
-func createChatModelConfigWithTitleQuotaExhausted(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel {
+func createChatModelWithTitleQuotaExhausted(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel {
t.Helper()
baseURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if req.Stream {
@@ -13393,11 +14134,9 @@ func createChatModelConfigWithTitleQuotaExhausted(t testing.TB, client *codersdk
"You exceeded your current quota, please check your plan and billing details.",
)
})
- return createChatModelConfigWithBaseURL(t, client, baseURL)
+ return createChatModelWithBaseURL(t, client, baseURL)
}
-// createAdditionalChatModel bridges the SDK-layer name while the later API
-// layer owns the full helper rename.
func createAdditionalChatModel(
t *testing.T,
client *codersdk.ExperimentalClient,
@@ -13405,20 +14144,10 @@ func createAdditionalChatModel(
model string,
) codersdk.ChatModel {
t.Helper()
- return createAdditionalChatModelConfig(t, client, provider, model)
-}
-
-func createAdditionalChatModelConfig(
- t *testing.T,
- client *codersdk.ExperimentalClient,
- provider string,
- model string,
-) codersdk.ChatModel {
- t.Helper()
- return createAdditionalChatModelConfigWithModelConfig(t, client, provider, model, nil)
+ return createAdditionalChatModelWithCallConfig(t, client, provider, model, nil)
}
-func createAdditionalChatModelConfigWithReasoningEffort(
+func createAdditionalChatModelWithReasoningEffort(
t *testing.T,
client *codersdk.ExperimentalClient,
provider string,
@@ -13427,7 +14156,7 @@ func createAdditionalChatModelConfigWithReasoningEffort(
maxEffort string,
) codersdk.ChatModel {
t.Helper()
- return createAdditionalChatModelConfigWithModelConfig(t, client, provider, model, &codersdk.ChatModelCallConfig{
+ return createAdditionalChatModelWithCallConfig(t, client, provider, model, &codersdk.ChatModelCallConfig{
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref(defaultEffort),
Max: ptr.Ref(maxEffort),
@@ -13435,7 +14164,7 @@ func createAdditionalChatModelConfigWithReasoningEffort(
})
}
-func createAdditionalChatModelConfigWithModelConfig(
+func createAdditionalChatModelWithCallConfig(
t *testing.T,
client *codersdk.ExperimentalClient,
provider string,
@@ -13448,10 +14177,12 @@ func createAdditionalChatModelConfigWithModelConfig(
aiProvider := createAIProviderForTest(t, client, provider, "test-api-key")
contextLimit := int64(4096)
isDefault := false
- modelConfig, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ defaultOrg, err := client.OrganizationByName(ctx, codersdk.DefaultOrganization)
+ require.NoError(t, err)
+ modelConfig, err := client.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
- Model: model,
ContextLimit: &contextLimit,
+ Model: model,
IsDefault: &isDefault,
ModelConfig: modelCallConfig,
})
@@ -13459,7 +14190,7 @@ func createAdditionalChatModelConfigWithModelConfig(
return modelConfig
}
-func createDisabledChatModelConfig(
+func createDisabledChatModel(
t *testing.T,
client *codersdk.ExperimentalClient,
provider string,
@@ -13467,18 +14198,18 @@ func createDisabledChatModelConfig(
) codersdk.ChatModel {
t.Helper()
- modelConfig := createAdditionalChatModelConfig(t, client, provider, model)
+ modelConfig := createAdditionalChatModel(t, client, provider, model)
ctx := testutil.Context(t, testutil.WaitLong)
- updated, err := client.UpdateChatModel(ctx, modelConfig.ID, codersdk.UpdateChatModelRequest{
+ updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Enabled: ptr.Ref(false),
})
require.NoError(t, err)
return updated
}
-// createProviderDisabledChatModelConfig creates an enabled model config,
+// createProviderDisabledChatModel creates an enabled model config,
// then disables its parent AI provider.
-func createProviderDisabledChatModelConfig(
+func createProviderDisabledChatModel(
t *testing.T,
client *codersdk.ExperimentalClient,
provider string,
@@ -13486,7 +14217,7 @@ func createProviderDisabledChatModelConfig(
) codersdk.ChatModel {
t.Helper()
- modelConfig := createAdditionalChatModelConfig(t, client, provider, model)
+ modelConfig := createAdditionalChatModel(t, client, provider, model)
ctx := testutil.Context(t, testutil.WaitLong)
_, err := client.UpdateAIProvider(ctx, modelConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{
Enabled: ptr.Ref(false),
@@ -13529,7 +14260,7 @@ func TestChatSystemPrompt(t *testing.T) {
adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
- _ = createChatModelConfig(t, adminClient)
+ _ = createChatModel(t, adminClient)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
@@ -13665,7 +14396,7 @@ If a workspace is needed, use list_templates before create_workspace and follow
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
client := codersdk.NewExperimentalClient(rawClient)
_ = coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "",
@@ -13693,7 +14424,7 @@ If a workspace is needed, use list_templates before create_workspace and follow
legacyClient, legacyDB := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, legacyClient.Client)
- _ = createChatModelConfig(t, legacyClient)
+ _ = createChatModel(t, legacyClient)
require.NoError(t, legacyDB.UpsertChatSystemPrompt(dbauthz.AsSystemRestricted(ctx), "Legacy custom instructions"))
@@ -13835,7 +14566,7 @@ If a workspace is needed, use list_templates before create_workspace and follow
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
client := codersdk.NewExperimentalClient(rawClient)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "Keep custom instructions",
@@ -13884,7 +14615,7 @@ If a workspace is needed, use list_templates before create_workspace and follow
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
client := codersdk.NewExperimentalClient(rawClient)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "Do not use the default prompt",
@@ -14414,7 +15145,7 @@ func TestChatPlanModeInstructions(t *testing.T) {
adminClient, _ := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
- _ = createChatModelConfig(t, adminClient)
+ _ = createChatModel(t, adminClient)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
@@ -14900,14 +15631,14 @@ func TestChatModelOverrides(t *testing.T) {
t.Run(setting.name, func(t *testing.T) {
adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
- defaultModel := createChatModelConfig(t, adminClient)
- openAIModel := createAdditionalChatModelConfig(
+ defaultModel := createChatModel(t, adminClient)
+ openAIModel := createAdditionalChatModel(
t,
adminClient,
coderdtest.TestChatProviderOpenAICompat,
"gpt-4.1-mini-"+string(setting.context),
)
- reasoningModel := createAdditionalChatModelConfigWithReasoningEffort(
+ reasoningModel := createAdditionalChatModelWithReasoningEffort(
t,
adminClient,
coderdtest.TestChatProviderOpenAICompat,
@@ -14915,17 +15646,18 @@ func TestChatModelOverrides(t *testing.T) {
"medium",
"high",
)
- disabledModel := createDisabledChatModelConfig(
+ disabledModel := createDisabledChatModel(
t,
adminClient,
coderdtest.TestChatProviderOpenAICompat,
"gpt-4.1-disabled-"+string(setting.context),
)
- localOrg := dbgen.Organization(t, db, database.Organization{})
- localModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
- OrganizationID: localOrg.ID,
- AIProviderID: uuid.NullUUID{UUID: openAIModel.AIProviderID, Valid: true},
+ otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ otherOrgConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: defaultModel.AIProviderID, Valid: true},
+ Model: "other-org-override-" + uuid.NewString(),
Enabled: true,
+ OrganizationID: otherOrg.ID,
})
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
@@ -15075,7 +15807,7 @@ func TestChatModelOverrides(t *testing.T) {
t.Run("ProviderDisabledModelReturns400", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
- providerDisabledModel := createProviderDisabledChatModelConfig(
+ providerDisabledModel := createProviderDisabledChatModel(
t,
adminClient,
"openai",
@@ -15095,10 +15827,10 @@ func TestChatModelOverrides(t *testing.T) {
require.Equal(t, "Invalid model_config_id.", sdkErr.Message)
})
- t.Run("ChatOrgModelReturns400", func(t *testing.T) {
+ t.Run("OtherOrganizationModelReturns400", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
- err := putOverride(ctx, adminClient, setting.context, localModel.ID.String())
+ err := putOverride(ctx, adminClient, setting.context, otherOrgConfig.ID.String())
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model_config_id.", sdkErr.Message)
})
@@ -15213,7 +15945,7 @@ func TestUserChatPersonalModelOverrides(t *testing.T) {
noKeyClientRaw, noKeyUser := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
noKeyClient := codersdk.NewExperimentalClient(noKeyClientRaw)
- defaultModelConfig := createChatModelConfig(t, adminClient)
+ defaultModelConfig := createChatModel(t, adminClient)
provider := enableUserChatProviderKey(t, adminClient, memberClient, coderdtest.TestChatProviderOpenAICompat)
modelProvider := createAIProviderForTest(t, adminClient, "anthropic", "")
_, err := memberClient.UpsertUserAIProviderKey(ctx, "me", modelProvider.ID, codersdk.CreateUserAIProviderKeyRequest{
@@ -15226,7 +15958,7 @@ func TestUserChatPersonalModelOverrides(t *testing.T) {
Model: "claude-personal-" + uuid.NewString(),
ContextLimit: &contextLimit,
}
- modelConfig, err := adminClient.CreateChatModel(ctx, modelConfigRequest)
+ modelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, modelConfigRequest)
require.NoError(t, err)
modelConfigRequest.Model = "claude-personal-reasoning-" + uuid.NewString()
modelConfigRequest.ModelConfig = &codersdk.ChatModelCallConfig{
@@ -15235,7 +15967,7 @@ func TestUserChatPersonalModelOverrides(t *testing.T) {
Max: ptr.Ref("high"),
},
}
- reasoningModelConfig, err := adminClient.CreateChatModel(ctx, modelConfigRequest)
+ reasoningModelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, modelConfigRequest)
require.NoError(t, err)
err = adminClient.UpdateChatModelOverride(ctx, codersdk.ChatModelOverrideContextGeneral, codersdk.UpdateChatModelOverrideRequest{
ModelConfigID: modelConfig.ID.String(),
@@ -15246,20 +15978,26 @@ func TestUserChatPersonalModelOverrides(t *testing.T) {
})
require.NoError(t, err)
- disabledModelConfig := createDisabledChatModelConfig(
+ disabledModelConfig := createDisabledChatModel(
t,
adminClient,
coderdtest.TestChatProviderOpenAICompat,
"gpt-4o-personal-disabled-"+uuid.NewString(),
)
disabledProvider := createAIProviderForTest(t, adminClient, "google", "test-api-key")
- contextLimit = int64(4096)
- disabledProviderModelConfig, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ disabledProviderModelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &disabledProvider.ID,
Model: "gemini-personal-disabled-provider-" + uuid.NewString(),
ContextLimit: &contextLimit,
})
require.NoError(t, err)
+ otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ otherOrgModelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: defaultModelConfig.AIProviderID, Valid: true},
+ Model: "personal-other-org-" + uuid.NewString(),
+ Enabled: true,
+ OrganizationID: otherOrg.ID,
+ })
enabled := false
disabledProvider, err = adminClient.UpdateAIProvider(ctx, disabledProvider.ID.String(), codersdk.UpdateAIProviderRequest{
Enabled: &enabled,
@@ -15268,12 +16006,6 @@ func TestUserChatPersonalModelOverrides(t *testing.T) {
require.NotEqual(t, uuid.Nil, provider.ID)
require.NotEqual(t, uuid.Nil, disabledProvider.ID)
- localOrg := dbgen.Organization(t, db, database.Organization{})
- localModelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
- OrganizationID: localOrg.ID,
- AIProviderID: uuid.NullUUID{UUID: modelProvider.ID, Valid: true},
- Enabled: true,
- })
personalOverride := func(
resp codersdk.UserChatPersonalModelOverridesResponse,
overrideContext codersdk.ChatPersonalModelOverrideContext,
@@ -15616,6 +16348,14 @@ func TestUserChatPersonalModelOverrides(t *testing.T) {
wantMessageSubstring: "Invalid model_config_id: model config " +
"not found or disabled.",
},
+ {
+ name: "OtherOrganization",
+ client: memberClient,
+ userID: member.ID,
+ modelConfigID: otherOrgModelConfig.ID.String(),
+ wantMessageSubstring: "Invalid model_config_id: model config " +
+ "not found or disabled.",
+ },
{
name: "ProviderDisabled",
client: memberClient,
@@ -15647,19 +16387,6 @@ func TestUserChatPersonalModelOverrides(t *testing.T) {
}
})
- t.Run("PUTModelRejectsChatOrgModel", func(t *testing.T) {
- for _, overrideContext := range contexts {
- rawBefore := getRaw(overrideContext)
- err := memberClient.UpdateUserChatPersonalModelOverride(ctx, overrideContext, codersdk.UpdateUserChatPersonalModelOverrideRequest{
- Mode: codersdk.ChatPersonalModelOverrideModeModel,
- ModelConfigID: localModelConfig.ID.String(),
- })
- sdkErr := requireSDKError(t, err, http.StatusBadRequest)
- require.Contains(t, sdkErr.Message, "model config not found or disabled")
- require.Equal(t, rawBefore, getRaw(overrideContext))
- }
- })
-
t.Run("GETMalformedStoredValueFallsBackToContextDefault", func(t *testing.T) {
upsertRaw(codersdk.ChatPersonalModelOverrideContextRoot, "model:not-a-uuid")
@@ -15687,7 +16414,7 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
- defaultModel := createChatModelConfig(t, adminClient)
+ defaultModel := createChatModel(t, adminClient)
_ = enableUserChatProviderKey(t, adminClient, adminClient, coderdtest.TestChatProviderOpenAICompat)
overrideProvider := createAIProviderForTest(t, adminClient, "anthropic", "")
_, err := adminClient.UpsertUserAIProviderKey(ctx, "me", overrideProvider.ID, codersdk.CreateUserAIProviderKeyRequest{
@@ -15695,13 +16422,13 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) {
})
require.NoError(t, err)
contextLimit := int64(4096)
- overrideModel, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ overrideModel, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &overrideProvider.ID,
Model: "claude-root-personal-" + uuid.NewString(),
ContextLimit: &contextLimit,
})
require.NoError(t, err)
- disabledModel := createDisabledChatModelConfig(
+ disabledModel := createDisabledChatModel(
t,
adminClient,
coderdtest.TestChatProviderOpenAICompat,
@@ -15802,7 +16529,7 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) {
})
t.Run("RootModelOverrideUsesSavedReasoningEffort", func(t *testing.T) {
- reasoningModel, err := adminClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ reasoningModel, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &overrideProvider.ID,
Model: "claude-root-personal-reasoning-" + uuid.NewString(),
ContextLimit: &contextLimit,
@@ -15826,6 +16553,35 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) {
require.Equal(t, ptr.Ref("high"), chat.LastReasoningEffort)
})
+ t.Run("CrossOrgRootModelFallsBackToOrgDefault", func(t *testing.T) {
+ org := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ orgModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: defaultModel.AIProviderID, Valid: true},
+ Model: "org-root-personal-" + uuid.NewString(),
+ Enabled: true,
+ IsDefault: true,
+ OrganizationID: org.ID,
+ })
+ otherClientRaw, otherUser := coderdtest.CreateAnotherUser(
+ t,
+ adminClient.Client,
+ org.ID,
+ rbac.ScopedRoleAgentsAccess(org.ID),
+ )
+ otherClient := codersdk.NewExperimentalClient(otherClientRaw)
+ upsertRootRaw(otherUser.ID, "model:"+overrideModel.ID.String())
+
+ chat, err := otherClient.CreateChat(ctx, codersdk.CreateChatRequest{
+ OrganizationID: org.ID,
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: "cross-org root model falls back",
+ }},
+ })
+ require.NoError(t, err)
+ require.Equal(t, orgModel.ID, chat.LastModelConfigID)
+ })
+
t.Run("UnavailableRootModelFallsBackToDefault", func(t *testing.T) {
upsertRootRaw(firstUser.UserID, "model:"+disabledModel.ID.String())
chat := createChat(adminClient, "disabled root model falls back", nil)
@@ -16197,7 +16953,7 @@ func TestChatDebugRuns(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
@@ -16228,7 +16984,7 @@ func TestChatDebugRuns(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
@@ -16264,7 +17020,7 @@ func TestChatDebugRuns(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
@@ -16299,7 +17055,7 @@ func TestChatDebugRuns(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Chat owned by the first (admin) user.
chat := dbgen.Chat(t, db, database.Chat{
@@ -16329,7 +17085,7 @@ func TestChatDebugRun(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
@@ -16362,7 +17118,7 @@ func TestChatDebugRun(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
@@ -16385,7 +17141,7 @@ func TestChatDebugRun(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
@@ -16409,7 +17165,7 @@ func TestChatDebugRun(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
@@ -16429,7 +17185,7 @@ func TestChatDebugRun(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Two chats owned by the same user. A run on chat A must not
// be addressable through chat B's URL.
@@ -16572,7 +17328,7 @@ func TestChatAdvisorConfig_RoundTripModelConfigID(t *testing.T) {
adminClient := newChatClient(t)
coderdtest.CreateFirstUser(t, adminClient.Client)
- modelConfig := createAdditionalChatModelConfigWithReasoningEffort(
+ modelConfig := createAdditionalChatModelWithReasoningEffort(
t,
adminClient,
"openai",
@@ -16620,7 +17376,7 @@ func TestChatAdvisorConfig_DisabledModelConfigID(t *testing.T) {
adminClient := newChatClient(t)
coderdtest.CreateFirstUser(t, adminClient.Client)
- disabledConfig := createDisabledChatModelConfig(
+ disabledConfig := createDisabledChatModel(
t,
adminClient,
coderdtest.TestChatProviderOpenAICompat,
@@ -16640,7 +17396,7 @@ func TestChatAdvisorConfig_ProviderDisabledModelConfigID(t *testing.T) {
adminClient := newChatClient(t)
coderdtest.CreateFirstUser(t, adminClient.Client)
- providerDisabledConfig := createProviderDisabledChatModelConfig(
+ providerDisabledConfig := createProviderDisabledChatModel(
t,
adminClient,
"openai",
@@ -16653,6 +17409,28 @@ func TestChatAdvisorConfig_ProviderDisabledModelConfigID(t *testing.T) {
require.Contains(t, sdkErr.Message, "does not match any enabled model config")
}
+func TestChatAdvisorConfig_OtherOrganizationModelConfigID(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ adminClient, db := newChatClientWithDatabase(t)
+ coderdtest.CreateFirstUser(t, adminClient.Client)
+ defaultConfig := createChatModel(t, adminClient)
+ otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
+ otherOrgConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
+ AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
+ Model: "advisor-other-org-" + uuid.NewString(),
+ Enabled: true,
+ OrganizationID: otherOrg.ID,
+ })
+
+ err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{
+ ModelConfigID: otherOrgConfig.ID,
+ })
+ sdkErr := requireSDKError(t, err, http.StatusBadRequest)
+ require.Contains(t, sdkErr.Message, "does not match any enabled model config")
+}
+
func TestChatAdvisorConfig_ReasoningEffortRequiresModelConfig(t *testing.T) {
t.Parallel()
@@ -16673,7 +17451,7 @@ func TestChatAdvisorConfig_ReasoningEffortMustBeSelectable(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
coderdtest.CreateFirstUser(t, adminClient.Client)
- modelConfig := createAdditionalChatModelConfigWithReasoningEffort(
+ modelConfig := createAdditionalChatModelWithReasoningEffort(
t,
adminClient,
"openai",
@@ -16723,7 +17501,7 @@ func TestChatAdvisorConfig_OverwriteClearsPreviousValues(t *testing.T) {
adminClient := newChatClient(t)
coderdtest.CreateFirstUser(t, adminClient.Client)
- modelConfig := createAdditionalChatModelConfigWithReasoningEffort(
+ modelConfig := createAdditionalChatModelWithReasoningEffort(
t,
adminClient,
"openai",
@@ -17146,7 +17924,7 @@ func TestUserChatCompactionThresholds(t *testing.T) {
client, _ := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
t.Run("EmptyByDefault", func(t *testing.T) { //nolint:paralleltest // subtests share parent state
ctx := testutil.Context(t, testutil.WaitLong)
@@ -17291,7 +18069,7 @@ func TestGetChatsByWorkspace(t *testing.T) {
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Helper to create a workspace owned by the test user.
newWorkspace := func() dbfake.WorkspaceBuildBuilder {
@@ -17495,7 +18273,7 @@ func TestSubmitToolResults(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
const toolName = "my_dynamic_tool"
toolCallIDs := []string{"call_abc", "call_def"}
@@ -17538,7 +18316,7 @@ func TestSubmitToolResults(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Create a chat that is NOT in requires_action status.
chat := dbgen.Chat(t, db, database.Chat{
@@ -17562,7 +18340,7 @@ func TestSubmitToolResults(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
const toolName = "my_dynamic_tool"
toolCallIDs := []string{"call_one", "call_two"}
@@ -17584,7 +18362,7 @@ func TestSubmitToolResults(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
const toolName = "my_dynamic_tool"
toolCallIDs := []string{"call_real"}
@@ -17606,7 +18384,7 @@ func TestSubmitToolResults(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
const toolName = "my_dynamic_tool"
toolCallIDs := []string{"call_json"}
@@ -17636,7 +18414,7 @@ func TestSubmitToolResults(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
const toolName = "my_dynamic_tool"
toolCallIDs := []string{"call_dup1", "call_dup2"}
@@ -17659,7 +18437,7 @@ func TestSubmitToolResults(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
const toolName = "my_dynamic_tool"
toolCallIDs := []string{"call_empty"}
@@ -17678,7 +18456,7 @@ func TestSubmitToolResults(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
const toolName = "my_dynamic_tool"
toolCallIDs := []string{"call_other"}
@@ -17707,7 +18485,7 @@ func TestSubmitToolResults(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Create a member without agents-access. Without
// agents-access the member has no ResourceChat
@@ -17735,7 +18513,7 @@ func TestSubmitToolResults(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
const toolName = "my_dynamic_tool"
toolCallIDs := []string{"call_archived"}
@@ -17765,7 +18543,7 @@ func TestPostChats_DynamicToolValidation(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
tools := make([]codersdk.DynamicTool, 251)
for i := range tools {
@@ -17792,7 +18570,7 @@ func TestPostChats_DynamicToolValidation(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
@@ -17814,7 +18592,7 @@ func TestPostChats_DynamicToolValidation(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- _ = createChatModelConfig(t, client)
+ _ = createChatModel(t, client)
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
@@ -17986,7 +18764,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 5)
seedQueuedMessage(ctx, t, db, chat.ID, modelConfig.ID)
@@ -18011,7 +18789,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 5)
seedQueuedMessage(ctx, t, db, chat.ID, modelConfig.ID)
@@ -18037,7 +18815,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 5)
seedQueuedMessage(ctx, t, db, chat.ID, modelConfig.ID)
@@ -18065,7 +18843,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 5)
seedQueuedMessage(ctx, t, db, chat.ID, modelConfig.ID)
@@ -18092,7 +18870,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 5)
// Seed a queued message so the Empty assertion below verifies
@@ -18124,7 +18902,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, _ := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 1)
@@ -18155,7 +18933,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, _ := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 1)
@@ -18186,7 +18964,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 3)
// Seed a queued message to prove the cursor path suppresses
@@ -18210,7 +18988,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
chat, ids := seedChat(t, db, user.UserID, user.OrganizationID, modelConfig.ID, 3)
@@ -18244,7 +19022,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
- modelConfig := createChatModelConfig(t, client)
+ modelConfig := createChatModel(t, client)
// Simulate a polling client that has already acknowledged the
// first message (cursor = ids[0]) when a burst of
@@ -18313,7 +19091,7 @@ func TestChatReadOnlySharedWriteHandlers(t *testing.T) {
ctx = testutil.Context(t, testutil.WaitLong)
ownerClient, db = newChatClientWithDatabase(t)
owner := coderdtest.CreateFirstUser(t, ownerClient.Client)
- _ = createChatModelConfig(t, ownerClient)
+ _ = createChatModel(t, ownerClient)
sharedRaw, sharedUser := coderdtest.CreateAnotherUser(
t,
ownerClient.Client,
@@ -18540,7 +19318,7 @@ func TestChatOwnerOnlyWriteHandlers(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
ownerClient, db = newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, ownerClient.Client)
- _ = createChatModelConfig(t, ownerClient)
+ _ = createChatModel(t, ownerClient)
// Create a chat owned by the first user.
var err error
diff --git a/coderd/httpmw/chatmodelconfigparam.go b/coderd/httpmw/chatmodelconfigparam.go
new file mode 100644
index 0000000000000..2faf81392250c
--- /dev/null
+++ b/coderd/httpmw/chatmodelconfigparam.go
@@ -0,0 +1,60 @@
+package httpmw
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbauthz"
+ "github.com/coder/coder/v2/coderd/httpapi"
+ "github.com/coder/coder/v2/codersdk"
+)
+
+type chatModelConfigParamContextKey struct{}
+
+// ChatModelConfigParam returns the chat model config from the
+// ExtractChatModelConfigParam middleware.
+func ChatModelConfigParam(r *http.Request) database.ChatModelConfig {
+ config, ok := r.Context().Value(chatModelConfigParamContextKey{}).(database.ChatModelConfig)
+ if !ok {
+ panic("developer error: chat model config param middleware not provided")
+ }
+ return config
+}
+
+// ExtractChatModelConfigParam grabs a chat model config from the "model" URL
+// parameter after the organization route parameter has been resolved. It only
+// resolves route identity; each handler authorizes its own action.
+func ExtractChatModelConfigParam(db database.Store) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ modelID, parsed := ParseUUIDParam(rw, r, "model")
+ if !parsed {
+ return
+ }
+
+ organization := OrganizationParam(r)
+ //nolint:gocritic // Each model handler authorizes its concrete action.
+ config, err := db.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), modelID)
+ if httpapi.Is404Error(err) {
+ httpapi.ResourceNotFound(rw)
+ return
+ }
+ if err != nil {
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error fetching chat model config.",
+ Detail: err.Error(),
+ })
+ return
+ }
+ if config.OrganizationID != organization.ID {
+ httpapi.ResourceNotFound(rw)
+ return
+ }
+
+ ctx = context.WithValue(ctx, chatModelConfigParamContextKey{}, config)
+ next.ServeHTTP(rw, r.WithContext(ctx))
+ })
+ }
+}
diff --git a/coderd/x/chatd/advisor_internal_test.go b/coderd/x/chatd/advisor_internal_test.go
index 8b3c166885b0d..92050bf45a8f0 100644
--- a/coderd/x/chatd/advisor_internal_test.go
+++ b/coderd/x/chatd/advisor_internal_test.go
@@ -122,7 +122,7 @@ func advisorChatModelFixture(t *testing.T, options json.RawMessage) (database.Ch
configID := uuid.New()
providerID := uuid.New()
store := &advisorOverrideStubStore{
- getChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
+ getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Model: "gpt-5.2",
@@ -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 ac96eb393da4c..d0214d41554de 100644
--- a/coderd/x/chatd/chatd.go
+++ b/coderd/x/chatd/chatd.go
@@ -163,9 +163,10 @@ type Server struct {
inflightMu sync.Mutex
inflightClosed atomic.Bool
- db database.Store
- workerID uuid.UUID
- logger slog.Logger
+ db database.Store
+ workerID uuid.UUID
+ logger slog.Logger
+ modelConfigContext func(context.Context, uuid.UUID) (context.Context, error)
streamPartsDialer StreamPartsDialer
@@ -260,19 +261,24 @@ func (p *Server) resolveAdvisorModelOverride(
return resolvedModelCall{}, false, nil
}
+ modelCtx, modelCtxErr := p.callerModelConfigContext(ctx, chat.OwnerID)
+ if modelCtxErr != nil {
+ logger.Warn(
+ ctx,
+ "failed to load advisor model authorization, continuing with chat model",
+ slog.F("model_config_id", advisorCfg.ModelConfigID),
+ slog.Error(modelCtxErr),
+ )
+ return resolvedModelCall{}, false, nil
+ }
// Re-read the override instead of using the cache so disabled models
// or providers stop routing advisor prompts immediately.
overrideConfig, err := p.db.GetEnabledChatModelConfigByID(
- ctx,
+ modelCtx,
advisorCfg.ModelConfigID,
)
if err == nil && overrideConfig.OrganizationID != chat.OrganizationID {
- logger.Warn(
- ctx,
- "advisor model config belongs to another organization, continuing with chat model",
- slog.F("model_config_id", advisorCfg.ModelConfigID),
- )
- return resolvedModelCall{}, false, nil
+ err = sql.ErrNoRows
}
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
@@ -1579,10 +1585,31 @@ func (p *Server) SendMessage(
return result, nil
}
-func chatdModelConfigLookupContext(ctx context.Context) context.Context {
- //nolint:gocritic // Chat message admission needs daemon-scoped
- // deployment-config reads for model config validation.
- return dbauthz.AsChatd(ctx)
+func (p *Server) callerModelConfigContext(ctx context.Context, ownerID uuid.UUID) (context.Context, error) {
+ if p.modelConfigContext == nil {
+ return ctx, nil
+ }
+ return p.modelConfigContext(ctx, ownerID)
+}
+
+func callerModelConfigContext(
+ ctx context.Context,
+ store database.Store,
+ ownerID uuid.UUID,
+) (context.Context, error) {
+ if ownerID == uuid.Nil {
+ return ctx, nil
+ }
+ if actor, ok := dbauthz.ActorFromContext(ctx); ok &&
+ actor.Type == rbac.SubjectTypeUser && actor.ID == ownerID.String() {
+ return ctx, nil
+ }
+ actor, _, err := httpmw.UserRBACSubject(ctx, store, ownerID, rbac.ScopeAll)
+ if err != nil {
+ return nil, xerrors.Errorf("load model config authorization: %w", err)
+ }
+ //nolint:gocritic // Background Chatd work must use the chat owner's model ACLs.
+ return dbauthz.As(ctx, actor), nil
}
func resolveSendMessageModelConfigID(
@@ -1592,7 +1619,7 @@ func resolveSendMessageModelConfigID(
requested uuid.UUID,
) (uuid.UUID, error) {
if requested == uuid.Nil {
- return resolveFallbackModelConfigID(ctx, store, chat.OrganizationID, chat.LastModelConfigID)
+ return resolveFallbackModelConfigID(ctx, store, chat, chat.LastModelConfigID)
}
if err := requireEnabledChatModelConfig(ctx, store, chat.OrganizationID, requested); err != nil {
@@ -1609,8 +1636,7 @@ func requireEnabledChatModelConfig(
organizationID uuid.UUID,
modelConfigID uuid.UUID,
) error {
- chatdCtx := chatdModelConfigLookupContext(ctx)
- config, err := store.GetEnabledChatModelConfigByID(chatdCtx, modelConfigID)
+ config, err := store.GetEnabledChatModelConfigByID(ctx, modelConfigID)
if err == nil {
if config.OrganizationID == organizationID {
return nil
@@ -1640,8 +1666,7 @@ func validateCreateModelConfigID(
if modelConfigID == uuid.Nil {
return xerrors.Errorf("%w: %s", ErrInvalidModelConfigID, modelConfigID)
}
- chatdCtx := chatdModelConfigLookupContext(ctx)
- config, err := store.GetChatModelConfigByID(chatdCtx, modelConfigID)
+ config, err := store.GetChatModelConfigByID(ctx, modelConfigID)
if err == nil {
if config.OrganizationID == organizationID {
return nil
@@ -1657,17 +1682,16 @@ func validateCreateModelConfigID(
func resolveFallbackModelConfigID(
ctx context.Context,
store database.Store,
- organizationID uuid.UUID,
+ chat database.Chat,
modelConfigID uuid.UUID,
) (uuid.UUID, error) {
- chatdCtx := chatdModelConfigLookupContext(ctx)
if modelConfigID != uuid.Nil {
- config, err := store.GetEnabledChatModelConfigByID(chatdCtx, modelConfigID)
+ config, err := store.GetEnabledChatModelConfigByID(ctx, modelConfigID)
if err == nil {
- if config.OrganizationID == organizationID {
+ if config.OrganizationID == chat.OrganizationID {
return modelConfigID, nil
}
- } else if !errors.Is(err, sql.ErrNoRows) {
+ } else if !errors.Is(err, sql.ErrNoRows) && !dbauthz.IsNotAuthorizedError(err) {
return uuid.Nil, xerrors.Errorf(
"get chat model config %s: %w",
modelConfigID,
@@ -1676,7 +1700,7 @@ func resolveFallbackModelConfigID(
}
}
- defaultConfig, err := effectiveDefaultChatModelConfig(chatdCtx, store, organizationID)
+ defaultConfig, err := effectiveDefaultChatModelConfig(ctx, store, chat.OrganizationID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrNoDefaultChatModelConfig
@@ -1718,29 +1742,44 @@ func validateEditTarget(ctx context.Context, store database.Store, chatID uuid.U
return nil
}
+func loadEffectiveChatModelConfigs(
+ ctx context.Context,
+ store database.Store,
+ organizationID uuid.UUID,
+) (database.EffectiveChatModelConfigs, error) {
+ rows, err := store.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
+ if err != nil {
+ return database.EffectiveChatModelConfigs{}, err
+ }
+ return database.DeriveEffectiveChatModelConfigs(rows), nil
+}
+
func effectiveDefaultChatModelConfig(
ctx context.Context,
store database.Store,
organizationID uuid.UUID,
) (database.ChatModelConfig, error) {
- rows, err := store.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
+ effective, err := loadEffectiveChatModelConfigs(ctx, store, organizationID)
if err != nil {
return database.ChatModelConfig{}, err
}
- for _, row := range rows {
- if row.ChatModelConfig.IsDefault {
- return row.ChatModelConfig, nil
- }
+ if effective.DefaultConfig.ID == uuid.Nil {
+ return database.ChatModelConfig{}, sql.ErrNoRows
}
- return database.ChatModelConfig{}, sql.ErrNoRows
+ return effective.DefaultConfig, nil
}
+// enabledChatModelConfigsForOrganization returns enabled configs from the chat organization.
func enabledChatModelConfigsForOrganization(
ctx context.Context,
store database.Store,
organizationID uuid.UUID,
) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) {
- return store.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
+ effective, err := loadEffectiveChatModelConfigs(ctx, store, organizationID)
+ if err != nil {
+ return nil, err
+ }
+ return effective.Configs, nil
}
// EditMessage replaces an earlier user message and discards the
@@ -1848,7 +1887,7 @@ func (p *Server) EditMessage(
if target.ModelConfigID.Valid {
preserved = target.ModelConfigID.UUID
}
- resolved, err := resolveFallbackModelConfigID(ctx, store, lockedChat.OrganizationID, preserved)
+ resolved, err := resolveFallbackModelConfigID(ctx, store, lockedChat, preserved)
if err != nil {
return err
}
@@ -2761,9 +2800,6 @@ func (p *Server) resolveManualTitleModel(
modelOpts,
)
if overrideErr != nil {
- if errors.Is(overrideErr, errModelConfigOutsideOrganization) {
- return p.resolveDefaultManualTitleModel(ctx, chat, modelOpts)
- }
if overrideSet {
return resolvedModelCall{}, xerrors.Errorf(
"resolve manual title generation model override: %w",
@@ -2778,7 +2814,11 @@ func (p *Server) resolveManualTitleModel(
return overrideResolved, nil
}
- configs, err := enabledChatModelConfigsForOrganization(ctx, store, chat.OrganizationID)
+ modelCtx, err := p.callerModelConfigContext(ctx, chat.OwnerID)
+ if err != nil {
+ return resolvedModelCall{}, err
+ }
+ configs, err := enabledChatModelConfigsForOrganization(modelCtx, store, chat.OrganizationID)
if err != nil {
p.logger.Debug(ctx, "failed to list manual title model configs",
slog.F("chat_id", chat.ID),
@@ -2809,36 +2849,6 @@ func (p *Server) resolveManualTitleModel(
return resolved, nil
}
-func (p *Server) resolveDefaultManualTitleModel(
- ctx context.Context,
- chat database.Chat,
- modelOpts modelBuildOptions,
-) (resolvedModelCall, error) {
- config, err := p.configCache.DefaultModelConfig(ctx, chat.OrganizationID)
- if err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return resolvedModelCall{}, ErrNoDefaultChatModelConfig
- }
- return resolvedModelCall{}, xerrors.Errorf(
- "get default manual title model config: %w",
- err,
- )
- }
- resolved, err := p.resolveModelCall(ctx, modelCallSpec{
- purpose: "title",
- chat: chat,
- explicitConfig: &config,
- buildOptions: modelOpts,
- })
- if err != nil {
- return resolvedModelCall{}, xerrors.Errorf(
- "create default manual title model: %w",
- err,
- )
- }
- return resolved, nil
-}
-
func (p *Server) resolveFallbackManualTitleModel(
ctx context.Context,
chat database.Chat,
@@ -3111,10 +3121,13 @@ func New(ps pubsub.Pubsub, cfg Config) *Server {
hookDispatcher = nil
}
p := &Server{
- cancel: cancel,
- db: cfg.Database,
- workerID: workerID,
- logger: cfg.Logger.Named("processor"),
+ cancel: cancel,
+ db: cfg.Database,
+ workerID: workerID,
+ logger: cfg.Logger.Named("processor"),
+ modelConfigContext: func(ctx context.Context, ownerID uuid.UUID) (context.Context, error) {
+ return callerModelConfigContext(ctx, cfg.Database, ownerID)
+ },
agentConnFn: cfg.AgentConn,
agentInactiveDisconnectTimeout: cfg.AgentInactiveDisconnectTimeout,
dialTimeout: defaultDialTimeout,
@@ -3216,8 +3229,6 @@ func New(ps pubsub.Pubsub, cfg Config) *Server {
return
}
switch ev.Kind {
- case coderdpubsub.ChatConfigEventModelConfig:
- p.configCache.InvalidateModelConfig(ev.EntityID)
case coderdpubsub.ChatConfigEventUserPrompt:
p.configCache.InvalidateUserPrompt(ev.EntityID)
case coderdpubsub.ChatConfigEventAdvisorConfig:
@@ -4263,20 +4274,18 @@ func (p *Server) resolveUserProviderAPIKeys(
func (p *Server) resolveModelConfigForOrganization(
ctx context.Context,
+ ownerID uuid.UUID,
organizationID uuid.UUID,
modelConfigID uuid.UUID,
) (database.ChatModelConfig, string, error) {
- if modelConfigID == uuid.Nil {
- return database.ChatModelConfig{}, "", sql.ErrNoRows
- }
- modelConfig, err := p.configCache.ModelConfigByID(ctx, modelConfigID)
+ modelConfig, providerName, err := p.resolveModelConfigAndNormalizedProvider(ctx, ownerID, modelConfigID)
if err != nil {
return database.ChatModelConfig{}, "", err
}
if modelConfig.OrganizationID != organizationID {
return database.ChatModelConfig{}, "", errModelConfigOutsideOrganization
}
- return p.resolveNormalizedProviderForModelConfig(ctx, modelConfig)
+ return modelConfig, providerName, nil
}
// resolveModelConfig looks up the chat's enabled model config by its
@@ -4287,34 +4296,32 @@ func (p *Server) resolveModelConfig(
ctx context.Context,
chat database.Chat,
) (database.ChatModelConfig, error) {
+ modelCtx, err := p.callerModelConfigContext(ctx, chat.OwnerID)
+ if err != nil {
+ return database.ChatModelConfig{}, err
+ }
if chat.LastModelConfigID != uuid.Nil {
- modelConfig, err := p.configCache.ModelConfigByID(ctx, chat.LastModelConfigID)
+ modelConfig, err := p.db.GetEnabledChatModelConfigByID(
+ modelCtx,
+ chat.LastModelConfigID,
+ )
if err == nil {
- if modelConfig.Enabled && modelConfig.OrganizationID == chat.OrganizationID && modelConfig.AIProviderID.Valid {
- provider, providerErr := p.db.GetAIProviderByID(
- chatdModelConfigLookupContext(ctx),
- modelConfig.AIProviderID.UUID,
- )
- switch {
- case providerErr == nil && provider.Enabled:
- return modelConfig, nil
- case providerErr == nil, xerrors.Is(providerErr, sql.ErrNoRows):
- default:
- return database.ChatModelConfig{}, xerrors.Errorf(
- "get AI provider %s: %w",
- modelConfig.AIProviderID.UUID, providerErr,
- )
- }
+ if modelConfig.OrganizationID == chat.OrganizationID {
+ return modelConfig, nil
}
- } else if !xerrors.Is(err, sql.ErrNoRows) {
+ err = sql.ErrNoRows
+ }
+ if !xerrors.Is(err, sql.ErrNoRows) && !dbauthz.IsNotAuthorizedError(err) {
return database.ChatModelConfig{}, xerrors.Errorf(
"get chat model config %s: %w",
chat.LastModelConfigID, err,
)
}
+ // The model config is unavailable or belongs to another organization.
+ // Fall through to the local default.
}
- defaultConfig, err := p.configCache.DefaultModelConfig(ctx, chat.OrganizationID)
+ defaultConfig, err := effectiveDefaultChatModelConfig(modelCtx, p.db, chat.OrganizationID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return database.ChatModelConfig{}, ErrNoDefaultChatModelConfig
diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go
index f22988c00e90f..49c09d1fa35af 100644
--- a/coderd/x/chatd/chatd_internal_test.go
+++ b/coderd/x/chatd/chatd_internal_test.go
@@ -1086,7 +1086,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
aibridgeTransportFactory: aibridgeTestFactoryPointer(factory),
}
- db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
+ db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{
ID: providerID,
Name: "primary-openai",
@@ -1242,7 +1242,7 @@ func TestRegenerateChatTitle_SkipsPersistWhenTitleChangedConcurrently(t *testing
aibridgeTransportFactory: aibridgeTestFactoryPointer(factory),
}
- db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
+ db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{
ID: providerID,
Name: "primary-openai",
@@ -3673,16 +3673,8 @@ func TestResolveModelConfigProviderLookupError(t *testing.T) {
db := dbmock.NewMockStore(ctrl)
organizationID := uuid.New()
modelConfigID := uuid.New()
- providerID := uuid.New()
- modelConfig := database.ChatModelConfig{
- ID: modelConfigID,
- Enabled: true,
- OrganizationID: organizationID,
- AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
- }
- db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
- db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{}, sql.ErrConnDone)
+ db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelConfigID).Return(database.ChatModelConfig{}, sql.ErrConnDone)
server := &Server{
db: db,
@@ -3693,7 +3685,7 @@ func TestResolveModelConfigProviderLookupError(t *testing.T) {
LastModelConfigID: modelConfigID,
})
require.ErrorIs(t, err, sql.ErrConnDone)
- require.ErrorContains(t, err, "get AI provider")
+ require.ErrorContains(t, err, "get chat model config")
}
func TestResolveModelConfigOrganizationScope(t *testing.T) {
@@ -3784,7 +3776,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) {
provider := newProvider(t, db, true)
lastModel := newModelConfig(t, db, orgID, provider.ID, false)
- resolved, err := resolveFallbackModelConfigID(ctx, db, orgID, lastModel.ID)
+ resolved, err := resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, lastModel.ID)
require.NoError(t, err)
require.Equal(t, lastModel.ID, resolved)
})
@@ -3800,7 +3792,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) {
enabledProvider := newProvider(t, db, true)
defaultModel := newModelConfig(t, db, orgID, enabledProvider.ID, true)
- resolved, err := resolveFallbackModelConfigID(ctx, db, orgID, lastModel.ID)
+ resolved, err := resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, lastModel.ID)
require.NoError(t, err)
require.Equal(t, defaultModel.ID, resolved)
})
@@ -3814,7 +3806,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) {
provider := newProvider(t, db, true)
defaultModel := newModelConfig(t, db, orgID, provider.ID, true)
- resolved, err := resolveFallbackModelConfigID(ctx, db, orgID, uuid.Nil)
+ resolved, err := resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, uuid.Nil)
require.NoError(t, err)
require.Equal(t, defaultModel.ID, resolved)
})
@@ -3830,7 +3822,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) {
provider := newProvider(t, db, true)
newModelConfig(t, db, defaultOrg.ID, provider.ID, true)
- _, err = resolveFallbackModelConfigID(ctx, db, otherOrgID, uuid.Nil)
+ _, err = resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: otherOrgID}, uuid.Nil)
require.ErrorIs(t, err, ErrNoDefaultChatModelConfig)
})
@@ -3855,7 +3847,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) {
})
require.NoError(t, err)
- _, err = resolveFallbackModelConfigID(ctx, db, orgID, uuid.Nil)
+ _, err = resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, uuid.Nil)
require.ErrorIs(t, err, ErrNoDefaultChatModelConfig)
})
@@ -3868,7 +3860,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) {
disabledProvider := newProvider(t, db, false)
newModelConfig(t, db, orgID, disabledProvider.ID, true)
- _, err := resolveFallbackModelConfigID(ctx, db, orgID, uuid.Nil)
+ _, err := resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, uuid.Nil)
require.ErrorIs(t, err, ErrNoDefaultChatModelConfig)
})
@@ -3882,7 +3874,7 @@ func TestResolveFallbackModelConfigID(t *testing.T) {
lastModel := newModelConfig(t, db, orgID, disabledProvider.ID, false)
newModelConfig(t, db, orgID, disabledProvider.ID, true)
- _, err := resolveFallbackModelConfigID(ctx, db, orgID, lastModel.ID)
+ _, err := resolveFallbackModelConfigID(ctx, db, database.Chat{OrganizationID: orgID}, lastModel.ID)
require.ErrorIs(t, err, ErrNoDefaultChatModelConfig)
})
diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go
index 0b80dcc398e5c..76de22ae28906 100644
--- a/coderd/x/chatd/chatprovider/chatprovider.go
+++ b/coderd/x/chatd/chatprovider/chatprovider.go
@@ -552,6 +552,9 @@ func (*ModelCatalog) ListConfiguredModels(
}
models := modelsByProvider[provider]
+ if models == nil {
+ models = []codersdk.ChatModelCatalogEntry{}
+ }
sortChatModelCatalogEntries(models)
result := codersdk.ChatModelProvider{
diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go
index 12751d44fd67b..9bf7d07c4bc91 100644
--- a/coderd/x/chatd/chatprovider/chatprovider_test.go
+++ b/coderd/x/chatd/chatprovider/chatprovider_test.go
@@ -759,6 +759,39 @@ func TestListConfiguredModels_PolicyAwareAvailability(t *testing.T) {
}},
}}},
},
+ {
+ name: "ProviderWithoutConfiguredModelUsesEmptyArray",
+ configuredProviders: []chatprovider.ConfiguredProvider{
+ configuredProvider(fantasyanthropic.Name, "sk-anthropic"),
+ configuredProvider(fantasyopenai.Name, "sk-openai"),
+ },
+ configuredModels: []chatprovider.ConfiguredModel{{
+ Provider: fantasyanthropic.Name,
+ Model: "claude-3-5-sonnet",
+ }},
+ availabilityByProvider: map[string]chatprovider.ProviderAvailability{
+ fantasyanthropic.Name: {Available: true},
+ fantasyopenai.Name: {Available: true},
+ },
+ enabledProviders: enabledProviders(fantasyanthropic.Name, fantasyopenai.Name),
+ want: codersdk.ChatModelAvailabilityResponse{Providers: []codersdk.ChatModelProvider{
+ {
+ Provider: fantasyanthropic.Name,
+ Available: true,
+ Models: []codersdk.ChatModelCatalogEntry{{
+ ID: fantasyanthropic.Name + ":claude-3-5-sonnet",
+ Provider: fantasyanthropic.Name,
+ Model: "claude-3-5-sonnet",
+ DisplayName: "claude-3-5-sonnet",
+ }},
+ },
+ {
+ Provider: fantasyopenai.Name,
+ Available: true,
+ Models: []codersdk.ChatModelCatalogEntry{},
+ },
+ }},
+ },
{
name: "MissingAvailabilityDefaultsToMissingAPIKey",
configuredProviders: []chatprovider.ConfiguredProvider{
@@ -799,6 +832,42 @@ func TestListConfiguredModels_PolicyAwareAvailability(t *testing.T) {
}
}
+func TestListConfiguredModels_EmptyModelsJSON(t *testing.T) {
+ t.Parallel()
+
+ catalog := chatprovider.NewModelCatalog()
+ response, ok := catalog.ListConfiguredModels(
+ []chatprovider.ConfiguredProvider{
+ {ProviderID: uuid.New(), Provider: fantasyanthropic.Name, APIKey: "anthropic-key"},
+ {ProviderID: uuid.New(), Provider: fantasyopenai.Name, APIKey: "openai-key"},
+ },
+ []chatprovider.ConfiguredModel{{Provider: fantasyanthropic.Name, Model: "claude-3-5-sonnet"}},
+ map[string]chatprovider.ProviderAvailability{
+ fantasyanthropic.Name: {Available: true},
+ fantasyopenai.Name: {Available: true},
+ },
+ map[string]struct{}{fantasyanthropic.Name: {}, fantasyopenai.Name: {}},
+ )
+ require.True(t, ok)
+
+ encoded, err := json.Marshal(response)
+ require.NoError(t, err)
+ var wire struct {
+ Providers []struct {
+ Provider string `json:"provider"`
+ Models json.RawMessage `json:"models"`
+ } `json:"providers"`
+ }
+ require.NoError(t, json.Unmarshal(encoded, &wire))
+ for _, provider := range wire.Providers {
+ if provider.Provider == fantasyopenai.Name {
+ require.JSONEq(t, `[]`, string(provider.Models))
+ return
+ }
+ }
+ t.Fatal("openai provider not found")
+}
+
func TestListConfiguredProviderAvailability_PolicyAwareFiltering(t *testing.T) {
t.Parallel()
diff --git a/coderd/x/chatd/compaction_override.go b/coderd/x/chatd/compaction_override.go
index 047aa46086ba3..0aa1335c152bf 100644
--- a/coderd/x/chatd/compaction_override.go
+++ b/coderd/x/chatd/compaction_override.go
@@ -65,7 +65,7 @@ func (p *Server) resolveCompactionOverrideConfig(
raw,
chat.OwnerID,
func(ctx context.Context, modelConfigID uuid.UUID) (database.ChatModelConfig, string, error) {
- return p.resolveModelConfigForOrganization(ctx, chat.OrganizationID, modelConfigID)
+ return p.resolveModelConfigForOrganization(ctx, chat.OwnerID, chat.OrganizationID, modelConfigID)
},
func(ctx context.Context, ownerID uuid.UUID, aiProviderID uuid.UUID) (chatprovider.ProviderAPIKeys, error) {
return p.resolveUserProviderAPIKeys(ctx, ownerID, aiProviderID)
diff --git a/coderd/x/chatd/compaction_override_internal_test.go b/coderd/x/chatd/compaction_override_internal_test.go
index 30ad039f92e7f..c01d8973b19cd 100644
--- a/coderd/x/chatd/compaction_override_internal_test.go
+++ b/coderd/x/chatd/compaction_override_internal_test.go
@@ -28,10 +28,16 @@ func TestResolveCompactionOverrideConfig_ForeignConfigFallsBack(t *testing.T) {
chat.OrganizationID = uuid.New()
overrideConfig := titleOverrideModelConfig("gpt-4.1", true)
overrideConfig.OrganizationID = uuid.New()
- overrideConfig.AIProviderID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
+ overrideProviderID := uuid.New()
+ overrideConfig.AIProviderID = uuid.NullUUID{UUID: overrideProviderID, Valid: true}
db.EXPECT().GetChatCompactionModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil)
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
+ db.EXPECT().GetAIProviderByID(gomock.Any(), overrideProviderID).Return(database.AIProvider{
+ ID: overrideProviderID,
+ Type: database.AIProviderTypeOpenai,
+ Enabled: true,
+ }, nil)
server := titleOverrideTestServer(db, logger)
override, err := server.resolveCompactionOverrideConfig(ctx, chat)
diff --git a/coderd/x/chatd/configcache.go b/coderd/x/chatd/configcache.go
index ea5a0138f84f7..017aff1de9ec1 100644
--- a/coderd/x/chatd/configcache.go
+++ b/coderd/x/chatd/configcache.go
@@ -21,7 +21,6 @@ import (
const (
chatConfigProvidersTTL = 10 * time.Second
- chatConfigModelConfigTTL = 10 * time.Second
chatConfigUserPromptTTL = 5 * time.Second
chatConfigAdvisorConfigTTL = 10 * time.Second
// Bound user-prompt cache cardinality so one-shot users do not
@@ -39,37 +38,6 @@ type cachedAdvisorConfig struct {
expiresAt time.Time
}
-type cachedModelConfig struct {
- config database.ChatModelConfig
- expiresAt time.Time
-}
-
-type modelConfigSnapshot struct {
- epoch uint64
- generation uint64
-}
-
-func cloneChatACL(acl database.ChatACL) database.ChatACL {
- if acl == nil {
- return nil
- }
- clone := make(database.ChatACL, len(acl))
- for id, entry := range acl {
- entry.Permissions = slices.Clone(entry.Permissions)
- clone[id] = entry
- }
- return clone
-}
-
-// cloneModelConfig returns a copy of cfg with mutable fields cloned so the
-// cache owns their backing storage.
-func cloneModelConfig(cfg database.ChatModelConfig) database.ChatModelConfig {
- cfg.Options = slices.Clone(cfg.Options)
- cfg.GroupACL = cloneChatACL(cfg.GroupACL)
- cfg.UserACL = cloneChatACL(cfg.UserACL)
- return cfg
-}
-
type chatConfigCache struct {
db database.Store
clock quartz.Clock
@@ -90,16 +58,6 @@ type chatConfigCache struct {
providerGeneration uint64
providerFetches singleflight.Group[string, []database.AIProvider]
- // Model configs (keyed by ID).
- modelTopologyEpoch uint64
- modelConfigs map[uuid.UUID]cachedModelConfig
- modelConfigFetches singleflight.Group[string, database.ChatModelConfig]
-
- // Default model configs (keyed by organization ID).
- defaultModelConfigs map[uuid.UUID]cachedModelConfig
- defaultModelConfigGeneration uint64
- defaultModelConfigFetches singleflight.Group[string, database.ChatModelConfig]
-
// User custom prompts (keyed by user ID).
userPromptEpoch uint64
userPrompts *tlru.Cache[uuid.UUID, string]
@@ -113,11 +71,9 @@ type chatConfigCache struct {
func newChatConfigCache(ctx context.Context, db database.Store, clock quartz.Clock) *chatConfigCache {
return &chatConfigCache{
- db: db,
- clock: clock,
- ctx: ctx,
- modelConfigs: make(map[uuid.UUID]cachedModelConfig),
- defaultModelConfigs: make(map[uuid.UUID]cachedModelConfig),
+ db: db,
+ clock: clock,
+ ctx: ctx,
userPrompts: tlru.New[uuid.UUID](
tlru.ConstantCost[string],
chatConfigUserPromptEntryLimit,
@@ -221,152 +177,7 @@ func (c *chatConfigCache) InvalidateProviders() {
c.mu.Lock()
c.providers = nil
c.providerGeneration++
- // Provider topology changed — model selections depend on
- // provider existence, so flush all model-config state.
- clear(c.modelConfigs)
- c.modelTopologyEpoch++
- clear(c.defaultModelConfigs)
- c.defaultModelConfigGeneration++
- c.mu.Unlock()
-}
-
-func (c *chatConfigCache) ModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) {
- if config, ok := c.cachedModelConfig(id); ok {
- return config, nil
- }
-
- snap := c.modelConfigSnapshot()
- config, err := singleflightDoChan(ctx, &c.modelConfigFetches, fmt.Sprintf("%d:%s", snap.epoch, id), func() (database.ChatModelConfig, error) {
- if cached, ok := c.cachedModelConfig(id); ok {
- return cached, nil
- }
-
- fetched, err := c.db.GetChatModelConfigByID(c.ctx, id)
- if err != nil {
- return database.ChatModelConfig{}, err
- }
- c.storeModelConfig(snap, fetched)
- return cloneModelConfig(fetched), nil
- })
- if err != nil {
- return database.ChatModelConfig{}, err
- }
-
- return config, nil
-}
-
-func (c *chatConfigCache) cachedModelConfig(id uuid.UUID) (database.ChatModelConfig, bool) {
- c.mu.RLock()
- entry, ok := c.modelConfigs[id]
- c.mu.RUnlock()
- if !ok {
- return database.ChatModelConfig{}, false
- }
- if c.clock.Now().Before(entry.expiresAt) {
- return cloneModelConfig(entry.config), true
- }
-
- c.mu.Lock()
- if current, ok := c.modelConfigs[id]; ok && !c.clock.Now().Before(current.expiresAt) {
- delete(c.modelConfigs, id)
- }
- c.mu.Unlock()
-
- return database.ChatModelConfig{}, false
-}
-
-func (c *chatConfigCache) modelConfigSnapshot() modelConfigSnapshot {
- c.mu.RLock()
- snap := modelConfigSnapshot{epoch: c.modelTopologyEpoch}
- c.mu.RUnlock()
- return snap
-}
-
-func (c *chatConfigCache) storeModelConfig(snap modelConfigSnapshot, config database.ChatModelConfig) {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- if c.modelTopologyEpoch != snap.epoch {
- return
- }
-
- c.modelConfigs[config.ID] = cachedModelConfig{
- config: cloneModelConfig(config),
- expiresAt: c.clock.Now().Add(chatConfigModelConfigTTL),
- }
-}
-
-// DefaultModelConfig returns the usable local default model config.
-func (c *chatConfigCache) DefaultModelConfig(ctx context.Context, orgID uuid.UUID) (database.ChatModelConfig, error) {
- if config, ok := c.cachedDefaultModelConfig(orgID); ok {
- return config, nil
- }
-
- snap := c.defaultModelConfigSnapshot()
- config, err := singleflightDoChan(ctx, &c.defaultModelConfigFetches, fmt.Sprintf("%d:default:%s", snap.epoch, orgID), func() (database.ChatModelConfig, error) {
- if cached, ok := c.cachedDefaultModelConfig(orgID); ok {
- return cached, nil
- }
-
- fetched, err := effectiveDefaultChatModelConfig(c.ctx, c.db, orgID)
- if err != nil {
- return database.ChatModelConfig{}, err
- }
- c.storeDefaultModelConfig(snap, orgID, fetched)
- return cloneModelConfig(fetched), nil
- })
- if err != nil {
- return database.ChatModelConfig{}, err
- }
-
- return config, nil
-}
-
-func (c *chatConfigCache) cachedDefaultModelConfig(orgID uuid.UUID) (database.ChatModelConfig, bool) {
- c.mu.RLock()
- entry, ok := c.defaultModelConfigs[orgID]
- c.mu.RUnlock()
- if !ok {
- return database.ChatModelConfig{}, false
- }
- if c.clock.Now().Before(entry.expiresAt) {
- return cloneModelConfig(entry.config), true
- }
-
- c.mu.Lock()
- if current, ok := c.defaultModelConfigs[orgID]; ok && !c.clock.Now().Before(current.expiresAt) {
- delete(c.defaultModelConfigs, orgID)
- }
c.mu.Unlock()
-
- return database.ChatModelConfig{}, false
-}
-
-func (c *chatConfigCache) defaultModelConfigSnapshot() modelConfigSnapshot {
- c.mu.RLock()
- snap := modelConfigSnapshot{
- epoch: c.modelTopologyEpoch,
- generation: c.defaultModelConfigGeneration,
- }
- c.mu.RUnlock()
- return snap
-}
-
-func (c *chatConfigCache) storeDefaultModelConfig(snap modelConfigSnapshot, orgID uuid.UUID, config database.ChatModelConfig) {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- if c.modelTopologyEpoch != snap.epoch {
- return
- }
- if c.defaultModelConfigGeneration != snap.generation {
- return
- }
-
- c.defaultModelConfigs[orgID] = cachedModelConfig{
- config: cloneModelConfig(config),
- expiresAt: c.clock.Now().Add(chatConfigModelConfigTTL),
- }
}
func (c *chatConfigCache) UserPrompt(ctx context.Context, userID uuid.UUID) (string, error) {
@@ -424,17 +235,6 @@ func (c *chatConfigCache) storeUserPrompt(epoch uint64, userID uuid.UUID, prompt
c.userPrompts.Set(userID, prompt, chatConfigUserPromptTTL)
}
-func (c *chatConfigCache) InvalidateModelConfig(id uuid.UUID) {
- c.mu.Lock()
- delete(c.modelConfigs, id)
- c.modelTopologyEpoch++
- // Coarse invalidation: the event does not identify the changed config's
- // organization or default status, so every per-org default is dropped.
- clear(c.defaultModelConfigs)
- c.defaultModelConfigGeneration++
- c.mu.Unlock()
-}
-
func (c *chatConfigCache) InvalidateUserPrompt(userID uuid.UUID) {
c.mu.Lock()
c.userPrompts.Delete(userID)
diff --git a/coderd/x/chatd/configcache_internal_test.go b/coderd/x/chatd/configcache_internal_test.go
index 67ef60c26b509..93eda31be8a65 100644
--- a/coderd/x/chatd/configcache_internal_test.go
+++ b/coderd/x/chatd/configcache_internal_test.go
@@ -16,8 +16,6 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
- "github.com/coder/coder/v2/coderd/rbac/policy"
- "github.com/coder/coder/v2/coderd/util/syncmap"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
@@ -27,15 +25,11 @@ import (
type stubChatConfigStore struct {
database.Store
- getAIProviders func(context.Context) ([]database.AIProvider, error)
- getChatModelConfigByID func(context.Context, uuid.UUID) (database.ChatModelConfig, error)
- getEnabledChatModelConfigsByOrganization func(context.Context, uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error)
- getUserChatCustomPrompt func(context.Context, uuid.UUID) (string, error)
- getChatAdvisorConfig func(context.Context) (string, error)
+ getAIProviders func(context.Context) ([]database.AIProvider, error)
+ getUserChatCustomPrompt func(context.Context, uuid.UUID) (string, error)
+ getChatAdvisorConfig func(context.Context) (string, error)
enabledProvidersCalls atomic.Int32
- modelConfigByIDCalls atomic.Int32
- effectiveConfigCalls syncmap.Map[uuid.UUID, *atomic.Int32]
userPromptCalls atomic.Int32
advisorConfigCalls atomic.Int32
}
@@ -48,43 +42,6 @@ func (s *stubChatConfigStore) GetAIProviders(ctx context.Context, _ database.Get
return s.getAIProviders(ctx)
}
-func (s *stubChatConfigStore) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) {
- s.modelConfigByIDCalls.Add(1)
- if s.getChatModelConfigByID == nil {
- panic("unexpected GetChatModelConfigByID call")
- }
- return s.getChatModelConfigByID(ctx, id)
-}
-
-func (s *stubChatConfigStore) GetEnabledChatModelConfigsByOrganization(
- ctx context.Context,
- organizationID uuid.UUID,
-) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) {
- counter, _ := s.effectiveConfigCalls.LoadOrStore(organizationID, &atomic.Int32{})
- counter.Add(1)
- if s.getEnabledChatModelConfigsByOrganization == nil {
- panic("unexpected GetEnabledChatModelConfigsByOrganization call")
- }
- return s.getEnabledChatModelConfigsByOrganization(ctx, organizationID)
-}
-
-func (s *stubChatConfigStore) effectiveConfigCallCount(organizationID uuid.UUID) int32 {
- counter, ok := s.effectiveConfigCalls.Load(organizationID)
- if !ok {
- return 0
- }
- return counter.Load()
-}
-
-func (s *stubChatConfigStore) totalEffectiveConfigCalls() int32 {
- var total int32
- s.effectiveConfigCalls.Range(func(_ uuid.UUID, counter *atomic.Int32) bool {
- total += counter.Load()
- return true
- })
- return total
-}
-
func (s *stubChatConfigStore) GetUserChatCustomPrompt(ctx context.Context, userID uuid.UUID) (string, error) {
s.userPromptCalls.Add(1)
if s.getUserChatCustomPrompt == nil {
@@ -168,204 +125,6 @@ func TestConfigCache_EnabledProviders_Invalidation(t *testing.T) {
require.Equal(t, int32(2), store.enabledProvidersCalls.Load())
}
-func TestConfigCache_ModelConfigByID_CacheHit(t *testing.T) {
- t.Parallel()
-
- ctx := testutil.Context(t, testutil.WaitShort)
- clock := quartz.NewMock(t)
- configID := uuid.New()
- config := testChatModelConfig(configID, "model-a")
- store := &stubChatConfigStore{
- getChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
- return config, nil
- },
- }
- cache := newChatConfigCache(ctx, store, clock)
-
- first, err := cache.ModelConfigByID(ctx, configID)
- require.NoError(t, err)
- second, err := cache.ModelConfigByID(ctx, configID)
- require.NoError(t, err)
-
- require.Equal(t, config, first)
- require.Equal(t, config, second)
- require.Equal(t, int32(1), store.modelConfigByIDCalls.Load())
-}
-
-func TestConfigCache_ModelConfigByID_ClonesMutableFieldsForCache(t *testing.T) {
- t.Parallel()
-
- ctx := testutil.Context(t, testutil.WaitShort)
- clock := quartz.NewMock(t)
- configID := uuid.New()
- const options = `{"temperature":0.1}`
- config := testChatModelConfig(configID, "model-a")
- config.Options = []byte(options)
- config.GroupACL = database.ChatACL{
- "group": {Permissions: []policy.Action{policy.ActionRead, policy.ActionUpdate}},
- }
- config.UserACL = database.ChatACL{
- "user": {Permissions: []policy.Action{policy.ActionRead, policy.ActionDelete}},
- }
- store := &stubChatConfigStore{
- getChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
- return config, nil
- },
- }
- cache := newChatConfigCache(ctx, store, clock)
-
- // First call populates cache via singleflight.
- first, err := cache.ModelConfigByID(ctx, configID)
- require.NoError(t, err)
- first.Options[0] = 'x' // mutate singleflight return
- groupEntry := first.GroupACL["group"]
- groupEntry.Permissions[0] = policy.ActionDelete
- first.GroupACL["group"] = groupEntry
- delete(first.GroupACL, "group")
- userEntry := first.UserACL["user"]
- userEntry.Permissions[0] = policy.ActionUpdate
- first.UserACL["user"] = userEntry
- first.UserACL["first-mutation"] = database.ChatACLEntry{}
-
- // Second call is a cache hit.
- second, err := cache.ModelConfigByID(ctx, configID)
- require.NoError(t, err)
- require.Equal(t, options, string(second.Options))
- require.Equal(t, config.GroupACL, second.GroupACL)
- require.Equal(t, config.UserACL, second.UserACL)
- require.Equal(t, []policy.Action{policy.ActionRead, policy.ActionUpdate}, second.GroupACL["group"].Permissions)
- require.Equal(t, []policy.Action{policy.ActionRead, policy.ActionDelete}, second.UserACL["user"].Permissions)
- second.Options[0] = 'y' // mutate cache-hit return
- groupEntry = second.GroupACL["group"]
- groupEntry.Permissions[1] = policy.ActionDelete
- second.GroupACL["group"] = groupEntry
- second.GroupACL["second-mutation"] = database.ChatACLEntry{}
- delete(second.UserACL, "user")
-
- // Third call is another cache hit. It must be unaffected.
- third, err := cache.ModelConfigByID(ctx, configID)
- require.NoError(t, err)
- require.Equal(t, options, string(third.Options))
- require.Equal(t, config.GroupACL, third.GroupACL)
- require.Equal(t, config.UserACL, third.UserACL)
- require.Equal(t, []policy.Action{policy.ActionRead, policy.ActionUpdate}, third.GroupACL["group"].Permissions)
- require.Equal(t, []policy.Action{policy.ActionRead, policy.ActionDelete}, third.UserACL["user"].Permissions)
-}
-
-func TestConfigCache_ModelConfigByID_NotFound(t *testing.T) {
- t.Parallel()
-
- ctx := testutil.Context(t, testutil.WaitShort)
- clock := quartz.NewMock(t)
- configID := uuid.New()
- store := &stubChatConfigStore{
- getChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
- return database.ChatModelConfig{}, sql.ErrNoRows
- },
- }
- cache := newChatConfigCache(ctx, store, clock)
-
- _, err := cache.ModelConfigByID(ctx, configID)
- require.ErrorIs(t, err, sql.ErrNoRows)
- _, err = cache.ModelConfigByID(ctx, configID)
- require.ErrorIs(t, err, sql.ErrNoRows)
-
- require.Equal(t, int32(2), store.modelConfigByIDCalls.Load())
- _, ok := cache.modelConfigs[configID]
- require.False(t, ok)
-}
-
-func TestConfigCache_InvalidateModelConfig_CascadesToDefault(t *testing.T) {
- t.Parallel()
-
- ctx := testutil.Context(t, testutil.WaitShort)
- clock := quartz.NewMock(t)
- configID := uuid.New()
- orgID := uuid.New()
- config := testChatModelConfig(configID, "model-a")
- store := &stubChatConfigStore{}
- store.getChatModelConfigByID = func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
- return config, nil
- }
- store.getEnabledChatModelConfigsByOrganization = func(_ context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) {
- call := store.effectiveConfigCallCount(organizationID)
- config := testChatModelConfig(uuid.New(), fmt.Sprintf("default-model-%d", call))
- config.IsDefault = true
- return []database.GetEnabledChatModelConfigsByOrganizationRow{{
- ChatModelConfig: config,
- }}, nil
- }
- cache := newChatConfigCache(ctx, store, clock)
-
- _, err := cache.ModelConfigByID(ctx, configID)
- require.NoError(t, err)
- firstDefault, err := cache.DefaultModelConfig(ctx, orgID)
- require.NoError(t, err)
-
- cache.InvalidateModelConfig(configID)
- secondDefault, err := cache.DefaultModelConfig(ctx, orgID)
- require.NoError(t, err)
-
- require.NotEqual(t, firstDefault, secondDefault)
- require.Equal(t, int32(2), store.effectiveConfigCallCount(orgID))
-}
-
-func TestConfigCache_DefaultModelConfig_PerOrgKeying(t *testing.T) {
- t.Parallel()
-
- ctx := testutil.Context(t, testutil.WaitShort)
- clock := quartz.NewMock(t)
- orgA := uuid.New()
- orgB := uuid.New()
- store := &stubChatConfigStore{}
- store.getEnabledChatModelConfigsByOrganization = func(_ context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) {
- config := testChatModelConfig(uuid.New(), "default-"+organizationID.String())
- config.IsDefault = true
- return []database.GetEnabledChatModelConfigsByOrganizationRow{{
- ChatModelConfig: config,
- }}, nil
- }
- cache := newChatConfigCache(ctx, store, clock)
-
- defaultA, err := cache.DefaultModelConfig(ctx, orgA)
- require.NoError(t, err)
- defaultB, err := cache.DefaultModelConfig(ctx, orgB)
- require.NoError(t, err)
- require.NotEqual(t, defaultA.ID, defaultB.ID)
-
- defaultAAgain, err := cache.DefaultModelConfig(ctx, orgA)
- require.NoError(t, err)
- require.Equal(t, defaultA, defaultAAgain)
- require.Equal(t, int32(1), store.effectiveConfigCallCount(orgA))
- require.Equal(t, int32(1), store.effectiveConfigCallCount(orgB))
-
- cache.InvalidateModelConfig(uuid.New())
- _, err = cache.DefaultModelConfig(ctx, orgA)
- require.NoError(t, err)
- _, err = cache.DefaultModelConfig(ctx, orgB)
- require.NoError(t, err)
- require.Equal(t, int32(2), store.effectiveConfigCallCount(orgA))
- require.Equal(t, int32(2), store.effectiveConfigCallCount(orgB))
-}
-
-func TestConfigCache_DefaultModelConfig_NoEffectiveDefault(t *testing.T) {
- t.Parallel()
-
- ctx := testutil.Context(t, testutil.WaitShort)
- clock := quartz.NewMock(t)
- store := &stubChatConfigStore{
- getEnabledChatModelConfigsByOrganization: func(context.Context, uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) {
- return []database.GetEnabledChatModelConfigsByOrganizationRow{{
- ChatModelConfig: testChatModelConfig(uuid.New(), "non-default"),
- }}, nil
- },
- }
- cache := newChatConfigCache(ctx, store, clock)
-
- _, err := cache.DefaultModelConfig(ctx, uuid.New())
- require.ErrorIs(t, err, sql.ErrNoRows)
-}
-
func TestConfigCache_UserPrompt_NegativeCaching(t *testing.T) {
t.Parallel()
@@ -666,125 +425,6 @@ func TestConfigCache_InvalidateProviders_BlocksStaleInFlightProviders(t *testing
require.Equal(t, int32(2), store.enabledProvidersCalls.Load())
}
-func TestConfigCache_InvalidateProviders_CascadesToModelConfigs(t *testing.T) {
- t.Parallel()
-
- ctx := testutil.Context(t, testutil.WaitShort)
- clock := quartz.NewMock(t)
- configID := uuid.New()
- store := &stubChatConfigStore{}
- store.getChatModelConfigByID = func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
- call := store.modelConfigByIDCalls.Load()
- return testChatModelConfig(configID, fmt.Sprintf("model-%d", call)), nil
- }
- cache := newChatConfigCache(ctx, store, clock)
-
- first, err := cache.ModelConfigByID(ctx, configID)
- require.NoError(t, err)
- cache.InvalidateProviders()
- second, err := cache.ModelConfigByID(ctx, configID)
- require.NoError(t, err)
-
- require.NotEqual(t, first, second)
- require.Equal(t, int32(2), store.modelConfigByIDCalls.Load())
-}
-
-func TestConfigCache_InvalidateProviders_CascadesToDefaultModelConfig(t *testing.T) {
- t.Parallel()
-
- ctx := testutil.Context(t, testutil.WaitShort)
- clock := quartz.NewMock(t)
- orgID := uuid.New()
- store := &stubChatConfigStore{}
- store.getEnabledChatModelConfigsByOrganization = func(_ context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) {
- call := store.effectiveConfigCallCount(organizationID)
- config := testChatModelConfig(uuid.New(), fmt.Sprintf("default-model-%d", call))
- config.IsDefault = true
- return []database.GetEnabledChatModelConfigsByOrganizationRow{{
- ChatModelConfig: config,
- }}, nil
- }
- cache := newChatConfigCache(ctx, store, clock)
-
- first, err := cache.DefaultModelConfig(ctx, orgID)
- require.NoError(t, err)
- cache.InvalidateProviders()
- second, err := cache.DefaultModelConfig(ctx, orgID)
- require.NoError(t, err)
-
- require.NotEqual(t, first, second)
- require.Equal(t, int32(2), store.effectiveConfigCallCount(orgID))
-}
-
-func TestConfigCache_InvalidateProviders_BlocksStaleInFlightModelConfig(t *testing.T) {
- t.Parallel()
-
- ctx := testutil.Context(t, testutil.WaitMedium)
- clock := quartz.NewMock(t)
- configID := uuid.New()
- staleConfig := testChatModelConfig(configID, "stale-model")
- freshConfig := testChatModelConfig(configID, "fresh-model")
- firstStarted := make(chan struct{})
- secondStarted := make(chan struct{})
- releaseFirst := make(chan struct{})
- releaseSecond := make(chan struct{})
- store := &stubChatConfigStore{}
- store.getChatModelConfigByID = func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
- switch call := store.modelConfigByIDCalls.Load(); call {
- case 1:
- close(firstStarted)
- <-releaseFirst
- return staleConfig, nil
- case 2:
- close(secondStarted)
- <-releaseSecond
- return freshConfig, nil
- default:
- return database.ChatModelConfig{}, xerrors.Errorf("unexpected model config call %d", call)
- }
- }
- cache := newChatConfigCache(ctx, store, clock)
-
- type result struct {
- config database.ChatModelConfig
- err error
- }
-
- firstResult := make(chan result, 1)
- go func() {
- config, err := cache.ModelConfigByID(ctx, configID)
- firstResult <- result{config: config, err: err}
- }()
-
- waitForSignal(t, firstStarted)
- cache.InvalidateProviders()
-
- secondResult := make(chan result, 1)
- go func() {
- config, err := cache.ModelConfigByID(ctx, configID)
- secondResult <- result{config: config, err: err}
- }()
-
- waitForSignal(t, secondStarted)
- close(releaseFirst)
- first := <-firstResult
- require.NoError(t, first.err)
- require.Equal(t, staleConfig, first.config)
- _, ok := cache.modelConfigs[configID]
- require.False(t, ok)
-
- close(releaseSecond)
- second := <-secondResult
- require.NoError(t, second.err)
- require.Equal(t, freshConfig, second.config)
- require.Equal(t, int32(2), store.modelConfigByIDCalls.Load())
-
- third, err := cache.ModelConfigByID(ctx, configID)
- require.NoError(t, err)
- require.Equal(t, freshConfig, third)
- require.Equal(t, int32(2), store.modelConfigByIDCalls.Load())
-}
-
func testAIProvider(name string) database.AIProvider {
return database.AIProvider{
ID: uuid.New(),
@@ -797,19 +437,6 @@ func testAIProvider(name string) database.AIProvider {
}
}
-func testChatModelConfig(id uuid.UUID, model string) database.ChatModelConfig {
- return database.ChatModelConfig{
- ID: id,
- Model: model,
- DisplayName: model,
- Enabled: true,
- CreatedAt: time.Unix(0, 0).UTC(),
- UpdatedAt: time.Unix(0, 0).UTC(),
- ContextLimit: 128000,
- CompressionThreshold: 64000,
- }
-}
-
func waitForSignal(t *testing.T, ch <-chan struct{}) {
t.Helper()
@@ -820,8 +447,7 @@ func waitForSignal(t *testing.T, ch <-chan struct{}) {
}
}
-// TestConfigCache_CallerCancellation verifies the DoChan-based
-// cancellation semantics across all four cache methods:
+// TestConfigCache_CallerCancellation verifies the DoChan-based cancellation semantics:
// - A canceled caller returns immediately without waiting for the
// shared fill to complete.
// - One canceled waiter does not poison other coalesced waiters.
@@ -844,9 +470,7 @@ func TestConfigCache_CallerCancellation(t *testing.T) {
storeCalls func(store *stubChatConfigStore) int32
}
- configID := uuid.New()
userID := uuid.New()
- defaultOrgID := uuid.New()
methods := []cacheMethod{
{
@@ -879,70 +503,6 @@ func TestConfigCache_CallerCancellation(t *testing.T) {
return store.enabledProvidersCalls.Load()
},
},
- {
- name: "ModelConfigByID",
- setupBlocked: func(store *stubChatConfigStore, started, release chan struct{}) {
- var once sync.Once
- store.getChatModelConfigByID = func(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) {
- once.Do(func() { close(started) })
- select {
- case <-ctx.Done():
- return database.ChatModelConfig{}, ctx.Err()
- case <-release:
- return testChatModelConfig(id, "model"), nil
- }
- }
- },
- setupCtxSensitive: func(store *stubChatConfigStore, started chan struct{}) {
- var once sync.Once
- store.getChatModelConfigByID = func(ctx context.Context, _ uuid.UUID) (database.ChatModelConfig, error) {
- once.Do(func() { close(started) })
- <-ctx.Done()
- return database.ChatModelConfig{}, ctx.Err()
- }
- },
- call: func(ctx context.Context, cache *chatConfigCache) error {
- _, err := cache.ModelConfigByID(ctx, configID)
- return err
- },
- storeCalls: func(store *stubChatConfigStore) int32 {
- return store.modelConfigByIDCalls.Load()
- },
- },
- {
- name: "DefaultModelConfig",
- setupBlocked: func(store *stubChatConfigStore, started, release chan struct{}) {
- var once sync.Once
- store.getEnabledChatModelConfigsByOrganization = func(ctx context.Context, _ uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) {
- once.Do(func() { close(started) })
- select {
- case <-ctx.Done():
- return nil, ctx.Err()
- case <-release:
- config := testChatModelConfig(uuid.New(), "default")
- config.IsDefault = true
- return []database.GetEnabledChatModelConfigsByOrganizationRow{{
- ChatModelConfig: config,
- }}, nil
- }
- }
- },
- setupCtxSensitive: func(store *stubChatConfigStore, started chan struct{}) {
- var once sync.Once
- store.getEnabledChatModelConfigsByOrganization = func(ctx context.Context, _ uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) {
- once.Do(func() { close(started) })
- <-ctx.Done()
- return nil, ctx.Err()
- }
- },
- call: func(ctx context.Context, cache *chatConfigCache) error {
- _, err := cache.DefaultModelConfig(ctx, defaultOrgID)
- return err
- },
- storeCalls: func(store *stubChatConfigStore) int32 {
- return store.totalEffectiveConfigCalls()
- },
- },
{
name: "UserPrompt",
setupBlocked: func(store *stubChatConfigStore, started, release chan struct{}) {
diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go
index f0b06e6fb9344..bb79819a3f56a 100644
--- a/coderd/x/chatd/generation_preparer.go
+++ b/coderd/x/chatd/generation_preparer.go
@@ -276,7 +276,7 @@ func (server *Server) prepareGeneration(
// (e.g. Bedrock and Anthropic) can still reject the other's
// provider-executed blocks, so a mid-chat provider switch must not replay
// them.
- promptRows = server.sanitizeForeignProviderExecutedToolRows(ctx, logger, promptRows, modelConfig.ID)
+ promptRows = server.sanitizeForeignProviderExecutedToolRows(ctx, logger, promptRows, chat.OwnerID, modelConfig.ID)
if chat.WorkspaceID.Valid {
// Resolve the workspace agent so the chat row's AgentID and
diff --git a/coderd/x/chatd/integration_test.go b/coderd/x/chatd/integration_test.go
index 2ca2538e81aee..ebf89d783b24f 100644
--- a/coderd/x/chatd/integration_test.go
+++ b/coderd/x/chatd/integration_test.go
@@ -97,7 +97,7 @@ func TestAnthropicWebSearchRoundTrip(t *testing.T) {
// Create a model config that enables web_search.
contextLimit := int64(200000)
isDefault := true
- _, err := expClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := expClient.CreateChatModel(ctx, user.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "claude-sonnet-4-20250514",
ContextLimit: &contextLimit,
@@ -355,7 +355,7 @@ func TestOpenAIReasoningRoundTrip(t *testing.T) {
contextLimit := int64(200000)
isDefault := true
reasoningSummary := "auto"
- _, err := expClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := expClient.CreateChatModel(ctx, user.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "o4-mini",
ContextLimit: &contextLimit,
@@ -504,7 +504,7 @@ func TestOpenAIReasoningRoundTripStoreFalse(t *testing.T) {
contextLimit := int64(200000)
isDefault := true
reasoningSummary := "auto"
- _, err := expClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ _, err := expClient.CreateChatModel(ctx, user.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "o4-mini",
ContextLimit: &contextLimit,
diff --git a/coderd/x/chatd/modelcall_internal_test.go b/coderd/x/chatd/modelcall_internal_test.go
index 7da6ae71560c1..42ce58b2917ce 100644
--- a/coderd/x/chatd/modelcall_internal_test.go
+++ b/coderd/x/chatd/modelcall_internal_test.go
@@ -75,7 +75,7 @@ func TestResolveModelCallDerivesProviderOptions(t *testing.T) {
config.Options = modelCallSentinelOptions(t, "summary-options-sentinel")
chat.LastModelConfigID = config.ID
- db.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil)
+ db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil)
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes()
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{
ProviderID: providerID,
diff --git a/coderd/x/chatd/provider_switch_sanitize.go b/coderd/x/chatd/provider_switch_sanitize.go
index 3b300003f1853..f3ce2b8b6a020 100644
--- a/coderd/x/chatd/provider_switch_sanitize.go
+++ b/coderd/x/chatd/provider_switch_sanitize.go
@@ -105,9 +105,10 @@ func (server *Server) sanitizeForeignProviderExecutedToolRows(
ctx context.Context,
logger slog.Logger,
rows []database.ChatMessage,
+ ownerID uuid.UUID,
modelConfigID uuid.UUID,
) []database.ChatMessage {
- targetCfg, targetProvider, err := server.resolveModelConfigAndNormalizedProvider(ctx, modelConfigID)
+ targetCfg, targetProvider, err := server.resolveModelConfigAndNormalizedProvider(ctx, ownerID, modelConfigID)
if err != nil || targetProvider == "" {
logger.Debug(ctx, "skipping provider-switch sanitization: target provider unresolved",
slog.F("model_config_id", modelConfigID),
@@ -125,7 +126,7 @@ func (server *Server) sanitizeForeignProviderExecutedToolRows(
if identity, seen := cache[id.UUID]; seen {
return identity, identity != ""
}
- originCfg, provider, rErr := server.resolveModelConfigAndNormalizedProvider(ctx, id.UUID)
+ originCfg, provider, rErr := server.resolveModelConfigAndNormalizedProvider(ctx, ownerID, id.UUID)
if rErr != nil {
logger.Debug(ctx, "provider-switch sanitization: origin provider unresolved, treating as foreign",
slog.F("model_config_id", id.UUID),
diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go
index 8429fabe88136..960defa4d6c4b 100644
--- a/coderd/x/chatd/subagent.go
+++ b/coderd/x/chatd/subagent.go
@@ -382,6 +382,7 @@ func (p *Server) resolvePersonalModelOverride(
) (database.ChatModelConfig, bool, error) {
modelConfig, providerName, err := p.resolveModelConfigForOrganization(
ctx,
+ ownerID,
organizationID,
modelConfigID,
)
@@ -484,7 +485,7 @@ func (p *Server) resolveSubagentModelConfigID(
raw,
ownerID,
func(ctx context.Context, modelConfigID uuid.UUID) (database.ChatModelConfig, string, error) {
- return p.resolveModelConfigForOrganization(ctx, organizationID, modelConfigID)
+ return p.resolveModelConfigForOrganization(ctx, ownerID, organizationID, modelConfigID)
},
p.resolveUserProviderAPIKeys,
modelOverrideFailureModeSoft,
@@ -507,12 +508,17 @@ func modelConfigAIProviderID(modelConfig database.ChatModelConfig) uuid.UUID {
func (p *Server) resolveModelConfigAndNormalizedProvider(
ctx context.Context,
+ ownerID uuid.UUID,
modelConfigID uuid.UUID,
) (database.ChatModelConfig, string, error) {
if modelConfigID == uuid.Nil {
return database.ChatModelConfig{}, "", sql.ErrNoRows
}
- modelConfig, err := p.configCache.ModelConfigByID(ctx, modelConfigID)
+ modelCtx, err := p.callerModelConfigContext(ctx, ownerID)
+ if err != nil {
+ return database.ChatModelConfig{}, "", err
+ }
+ modelConfig, err := p.db.GetChatModelConfigByID(modelCtx, modelConfigID)
if err != nil {
return database.ChatModelConfig{}, "", err
}
@@ -527,7 +533,8 @@ func (p *Server) resolveNormalizedProviderForModelConfig(
return database.ChatModelConfig{}, "", sql.ErrNoRows
}
if modelConfig.AIProviderID.Valid {
- provider, err := p.db.GetAIProviderByID(ctx, modelConfig.AIProviderID.UUID)
+ //nolint:gocritic // Provider configuration remains a privileged Chatd read.
+ provider, err := p.db.GetAIProviderByID(dbauthz.AsChatd(ctx), modelConfig.AIProviderID.UUID)
if err != nil {
return database.ChatModelConfig{}, "", err
}
@@ -562,10 +569,9 @@ func (p *Server) resolveExplicitSpawnOverrides(
listSubagentModelsToolName + " to see available models",
)
}
- //nolint:gocritic // Chatd needs its scoped config and user-data access here.
- chatdCtx := dbauthz.AsChatd(ctx)
modelConfig, providerName, err := p.resolveModelConfigForOrganization(
- chatdCtx,
+ ctx,
+ ownerID,
organizationID,
modelConfigID,
)
@@ -589,8 +595,9 @@ func (p *Server) resolveExplicitSpawnOverrides(
return nil, nil, xerrors.New("internal error looking up model config")
}
}
+ //nolint:gocritic // Provider credentials remain privileged Chatd reads.
providerKeys, err := p.resolveUserProviderAPIKeys(
- chatdCtx,
+ dbauthz.AsChatd(ctx),
ownerID,
modelConfigAIProviderID(modelConfig),
)
@@ -630,9 +637,11 @@ func (p *Server) listSpawnableModelConfigs(
ownerID uuid.UUID,
organizationID uuid.UUID,
) ([]map[string]any, error) {
- //nolint:gocritic // Chatd needs its scoped config and user-data access here.
- chatdCtx := dbauthz.AsChatd(ctx)
- rows, err := enabledChatModelConfigsForOrganization(chatdCtx, p.db, organizationID)
+ modelCtx, err := p.callerModelConfigContext(ctx, ownerID)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := enabledChatModelConfigsForOrganization(modelCtx, p.db, organizationID)
if err != nil {
return nil, xerrors.Errorf("get enabled chat model configs: %w", err)
}
@@ -652,8 +661,9 @@ func (p *Server) listSpawnableModelConfigs(
providerID := modelConfigAIProviderID(row.ChatModelConfig)
providerKeys, ok := providerKeysByID[providerID]
if !ok {
+ //nolint:gocritic // Provider credentials remain privileged Chatd reads.
providerKeys, err = p.resolveUserProviderAPIKeys(
- chatdCtx,
+ dbauthz.AsChatd(ctx),
ownerID,
providerID,
)
diff --git a/coderd/x/chatd/title_override.go b/coderd/x/chatd/title_override.go
index 7b10541cae3fc..ef9c1c469cde3 100644
--- a/coderd/x/chatd/title_override.go
+++ b/coderd/x/chatd/title_override.go
@@ -55,8 +55,11 @@ func readTitleGenerationModelOverride(
// resolveTitleGenerationModelOverride resolves the deployment-wide title
// generation model override. overrideSet is true when an override was
-// configured; in that case any returned error is a hard failure. When
-// overrideSet is false, callers may fall back to the default title model.
+// configured and resolved; a configured override that does not resolve
+// because it is unknown or disabled is ignored. Model construction failures
+// after a successful resolution stay hard failures.
+// When overrideSet is false, callers may fall back to the default title
+// model.
func (p *Server) resolveTitleGenerationModelOverride(
ctx context.Context,
chat database.Chat,
@@ -76,12 +79,12 @@ func (p *Server) resolveTitleGenerationModelOverride(
raw,
chat.OwnerID,
func(ctx context.Context, modelConfigID uuid.UUID) (database.ChatModelConfig, string, error) {
- return p.resolveModelConfigForOrganization(ctx, chat.OrganizationID, modelConfigID)
+ return p.resolveModelConfigForOrganization(ctx, chat.OwnerID, chat.OrganizationID, modelConfigID)
},
func(ctx context.Context, ownerID uuid.UUID, aiProviderID uuid.UUID) (chatprovider.ProviderAPIKeys, error) {
return p.resolveUserProviderAPIKeys(ctx, ownerID, aiProviderID)
},
- modelOverrideFailureModeHard,
+ modelOverrideFailureModeSoft,
)
if err != nil {
if errors.Is(err, errModelConfigOutsideOrganization) {
diff --git a/coderd/x/chatd/title_override_internal_test.go b/coderd/x/chatd/title_override_internal_test.go
index 779a96f974b3b..8c8ee99741df8 100644
--- a/coderd/x/chatd/title_override_internal_test.go
+++ b/coderd/x/chatd/title_override_internal_test.go
@@ -281,15 +281,21 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUnusableSkips(t *testi
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
chat, messages := titleOverrideTestChatAndMessages(t)
overrideConfig := titleOverrideModelConfig("gpt-4.1", false)
+ wantTitle := "Fallback title"
fallbackModel := &chattest.FakeModel{
GenerateObjectFn: func(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
- t.Fatal("fallback model should not be called when override is unusable")
- return nil, xerrors.New("unexpected fallback model call")
+ return &fantasy.ObjectResponse{
+ Object: map[string]any{"title": wantTitle},
+ }, nil
},
}
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil)
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
+ db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{
+ ID: chat.ID,
+ Title: wantTitle,
+ }).Return(chatWithTitle(chat, wantTitle), nil)
generated := &generatedChatTitle{}
server := titleOverrideTestServer(db, logger)
@@ -308,8 +314,9 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUnusableSkips(t *testi
nil,
)
- _, ok := generated.Load()
- require.False(t, ok)
+ gotTitle, ok := generated.Load()
+ require.True(t, ok)
+ require.Equal(t, wantTitle, gotTitle)
}
func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback(t *testing.T) {
@@ -398,6 +405,7 @@ func TestResolveManualTitleModel_TitleGenerationOverrideIgnoresForeignConfig(t *
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil)
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
+ db.EXPECT().GetAIProviderByID(gomock.Any(), overrideProviderID).Return(aibridgeTestAIProvider(overrideProviderID, "foreign-openai", database.AIProviderTypeOpenai), nil)
db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{
{ChatModelConfig: localPreferred, Provider: preferredTitleModels[0].provider},
{ChatModelConfig: localDefault, Provider: "openai"},
@@ -612,6 +620,9 @@ func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *te
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil).AnyTimes()
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return(nil, nil).AnyTimes()
+ // Missing override credentials soft-fail the override. The local
+ // organization has no usable model, so fallback resolution fails.
+ db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return(nil, nil).Times(2)
server := titleOverrideTestServer(db, logger)
resolved, err := server.resolveManualTitleModel(
@@ -620,9 +631,9 @@ func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *te
chat,
modelBuildOptions{},
)
- require.Error(t, err)
- require.ErrorContains(t, err, "resolve manual title generation model override")
- require.ErrorContains(t, err, "credentials are unavailable")
+ // The chat has no model config and its org has no default, so
+ // fallback resolution errors with ErrNoDefaultChatModelConfig.
+ require.ErrorIs(t, err, ErrNoDefaultChatModelConfig)
require.Equal(t, resolvedModelCall{}, resolved)
}
@@ -717,6 +728,9 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil)
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
+ // A disabled override config soft-fails the override. The local
+ // organization has no usable model, so fallback resolution fails.
+ db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return(nil, nil).Times(2)
server := titleOverrideTestServer(db, logger)
resolved, err := server.resolveManualTitleModel(
@@ -725,9 +739,9 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T
chat,
modelBuildOptions{},
)
- require.Error(t, err)
- require.ErrorContains(t, err, "resolve manual title generation model override")
- require.ErrorContains(t, err, "title generation model override is unavailable")
+ // The chat has no model config and its org has no default, so
+ // fallback resolution errors with ErrNoDefaultChatModelConfig.
+ require.ErrorIs(t, err, ErrNoDefaultChatModelConfig)
require.Equal(t, resolvedModelCall{}, resolved)
}
diff --git a/codersdk/chats.go b/codersdk/chats.go
index 8fd24e5fef76e..3e80ced483fe6 100644
--- a/codersdk/chats.go
+++ b/codersdk/chats.go
@@ -722,9 +722,9 @@ const (
ChatModelProviderUnavailableReasonUserAPIKeyRequired ChatModelProviderUnavailableReason = "user_api_key_required"
)
-// ChatModelCatalogEntry is a discovery catalog entry for end users.
-// Its ID is a synthetic provider:model value, not the UUID in ChatModel.ID.
-// It is not an admin-managed record. See ChatModel.
+// ChatModelCatalogEntry is the runtime catalog view of a model. Its ID is the
+// synthetic catalog identity `provider:model` built by canonicalModelID, not
+// the chat_model_configs row UUID that ChatModel.ID carries.
type ChatModelCatalogEntry struct {
ID string `json:"id"`
Provider string `json:"provider"`
@@ -740,9 +740,11 @@ type ChatModelProvider struct {
Models []ChatModelCatalogEntry `json:"models"`
}
-// ChatModelAvailabilityResponse groups the discovery catalog by provider and
-// reports provider availability.
+// ChatModelAvailabilityResponse is the catalog returned from chat model discovery.
type ChatModelAvailabilityResponse struct {
+ // Models contains the effective runtime model configs for the requested
+ // organization. Each config belongs to that organization.
+ Models []ChatModel `json:"models,omitempty"`
Providers []ChatModelProvider `json:"providers"`
// UnsupportedProviders lists configured providers the Agents harness
// cannot use, so the UI can explain the empty state.
@@ -1318,10 +1320,10 @@ type CreateUserChatProviderKeyRequest struct {
APIKey string `json:"api_key"`
}
-// ChatModel is an admin-managed model record for an organization.
-// It is not a discovery catalog entry. See ChatModelCatalogEntry.
+// ChatModel is an org-scoped model configuration.
type ChatModel struct {
ID uuid.UUID `json:"id" format:"uuid"`
+ OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
AIProviderID uuid.UUID `json:"ai_provider_id" format:"uuid"`
Model string `json:"model"`
DisplayName string `json:"display_name"`
@@ -1337,6 +1339,21 @@ type ChatModel struct {
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
+// ChatModelACL is the access control list for an organization-scoped chat
+// model. Each principal is mapped to its effective model role.
+type ChatModelACL struct {
+ UserRoles map[string]ChatRole `json:"user_roles"`
+ GroupRoles map[string]ChatRole `json:"group_roles"`
+}
+
+// UpdateChatModelACLRequest is a sparse update of a chat model ACL. Only the
+// listed principals change. ChatRoleDeleted removes an entry, while an omitted
+// map or principal is unchanged.
+type UpdateChatModelACLRequest struct {
+ UserRoles map[string]ChatRole `json:"user_roles,omitempty"`
+ GroupRoles map[string]ChatRole `json:"group_roles,omitempty"`
+}
+
// ChatModelProviderOptions contains typed provider-specific options.
//
// Note: Azure models use the `openai` options shape.
@@ -2030,9 +2047,10 @@ func (c *ExperimentalClient) ListChats(ctx context.Context, opts *ListChatsOptio
return chats, ReadBodyAsJSON(res, &chats)
}
-// ChatModelAvailability returns the discovery catalog and provider availability.
-func (c *ExperimentalClient) ChatModelAvailability(ctx context.Context) (ChatModelAvailabilityResponse, error) {
- res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/models", nil)
+// ChatModelAvailability returns the provider-grouped, per-caller
+// availability view of one organization's chat models.
+func (c *ExperimentalClient) ChatModelAvailability(ctx context.Context, organizationID uuid.UUID) (ChatModelAvailabilityResponse, error) {
+ res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/available", organizationID), nil)
if err != nil {
return ChatModelAvailabilityResponse{}, err
}
@@ -2189,24 +2207,41 @@ func (c *ExperimentalClient) DeleteUserChatProviderKey(ctx context.Context, prov
return nil
}
-// ChatModels returns admin-managed chat model records.
-func (c *ExperimentalClient) ChatModels(ctx context.Context) ([]ChatModel, error) {
- res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/model-configs", nil)
+// ChatModels returns the chat model configs the caller can read in one
+// organization, plus the redacted provider descriptors the authoring page
+// needs, for org-scoped management and picker surfaces.
+func (c *ExperimentalClient) ChatModels(ctx context.Context, organizationID uuid.UUID) (OrganizationChatModelsResponse, error) {
+ res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID), nil)
if err != nil {
- return nil, err
+ return OrganizationChatModelsResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
- return nil, ReadBodyAsError(res)
+ return OrganizationChatModelsResponse{}, ReadBodyAsError(res)
+ }
+
+ var resp OrganizationChatModelsResponse
+ return resp, ReadBodyAsJSON(res, &resp)
+}
+
+// ChatModel fetches one chat model config by ID in an organization.
+func (c *ExperimentalClient) ChatModel(ctx context.Context, organizationID, modelConfigID uuid.UUID) (ChatModel, error) {
+ res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelConfigID), nil)
+ if err != nil {
+ return ChatModel{}, err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return ChatModel{}, ReadBodyAsError(res)
}
- var models []ChatModel
- return models, ReadBodyAsJSON(res, &models)
+ var config ChatModel
+ return config, ReadBodyAsJSON(res, &config)
}
-// CreateChatModel creates an admin-managed ChatModel.
-func (c *ExperimentalClient) CreateChatModel(ctx context.Context, req CreateChatModelRequest) (ChatModel, error) {
- res, err := c.Request(ctx, http.MethodPost, "/api/experimental/chats/model-configs", req)
+// CreateChatModel creates a chat model config in the given organization.
+func (c *ExperimentalClient) CreateChatModel(ctx context.Context, organizationID uuid.UUID, req CreateChatModelRequest) (ChatModel, error) {
+ res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID), req)
if err != nil {
return ChatModel{}, err
}
@@ -2219,9 +2254,9 @@ func (c *ExperimentalClient) CreateChatModel(ctx context.Context, req CreateChat
return model, ReadBodyAsJSON(res, &model)
}
-// UpdateChatModel updates an admin-managed ChatModel.
-func (c *ExperimentalClient) UpdateChatModel(ctx context.Context, modelID uuid.UUID, req UpdateChatModelRequest) (ChatModel, error) {
- res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/chats/model-configs/%s", modelID), req)
+// UpdateChatModel updates a ChatModel in an organization.
+func (c *ExperimentalClient) UpdateChatModel(ctx context.Context, organizationID, modelID uuid.UUID, req UpdateChatModelRequest) (ChatModel, error) {
+ res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelID), req)
if err != nil {
return ChatModel{}, err
}
@@ -2234,9 +2269,39 @@ func (c *ExperimentalClient) UpdateChatModel(ctx context.Context, modelID uuid.U
return model, ReadBodyAsJSON(res, &model)
}
-// DeleteChatModel deletes an admin-managed ChatModel.
-func (c *ExperimentalClient) DeleteChatModel(ctx context.Context, modelID uuid.UUID) error {
- res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/model-configs/%s", modelID), nil)
+// ChatModelACL returns the access control list for a chat model in an
+// organization.
+func (c *ExperimentalClient) ChatModelACL(ctx context.Context, organizationID, modelID uuid.UUID) (ChatModelACL, error) {
+ res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s/acl", organizationID, modelID), nil)
+ if err != nil {
+ return ChatModelACL{}, err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return ChatModelACL{}, ReadBodyAsError(res)
+ }
+
+ var modelACL ChatModelACL
+ return modelACL, ReadBodyAsJSON(res, &modelACL)
+}
+
+// UpdateChatModelACL applies a sparse access control list update to a chat
+// model in an organization.
+func (c *ExperimentalClient) UpdateChatModelACL(ctx context.Context, organizationID, modelID uuid.UUID, req UpdateChatModelACLRequest) error {
+ res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s/acl", organizationID, modelID), req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusNoContent {
+ return ReadBodyAsError(res)
+ }
+ return nil
+}
+
+// DeleteChatModel deletes a ChatModel in an organization.
+func (c *ExperimentalClient) DeleteChatModel(ctx context.Context, organizationID, modelID uuid.UUID) error {
+ res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelID), nil)
if err != nil {
return err
}
@@ -2247,6 +2312,31 @@ func (c *ExperimentalClient) DeleteChatModel(ctx context.Context, modelID uuid.U
return nil
}
+// ChatModelProviderDescriptor is the redacted view of an AI provider carried
+// on the org model collection response. It carries only the capability
+// metadata the Models UI needs; key material, base URLs, and headers are
+// never exposed. The fields mirror what /api/experimental/chats/models
+// already discloses to any authenticated caller.
+type ChatModelProviderDescriptor struct {
+ ID uuid.UUID `json:"id" format:"uuid"`
+ Type string `json:"type"`
+ DisplayName string `json:"display_name"`
+ Icon string `json:"icon"`
+ Enabled bool `json:"enabled"`
+ HasAPIKey bool `json:"has_api_key"`
+ HasUserAPIKey bool `json:"has_user_api_key"`
+ HasEffectiveAPIKey bool `json:"has_effective_api_key"`
+ AllowUserAPIKey bool `json:"allow_user_api_key"`
+}
+
+// OrganizationChatModelsResponse is the org chat model config collection:
+// the caller-readable configs plus the redacted provider descriptors the
+// authoring page needs.
+type OrganizationChatModelsResponse struct {
+ Models []ChatModel `json:"models"`
+ Providers []ChatModelProviderDescriptor `json:"providers"`
+}
+
// GetChatCost returns the AI Gateway cost for the whole chat tree that
// contains chatID.
func (c *ExperimentalClient) GetChatCost(ctx context.Context, chatID uuid.UUID) (ChatCost, error) {
diff --git a/codersdk/chats_model_acl_test.go b/codersdk/chats_model_acl_test.go
new file mode 100644
index 0000000000000..205226de81865
--- /dev/null
+++ b/codersdk/chats_model_acl_test.go
@@ -0,0 +1,88 @@
+package codersdk_test
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ "github.com/coder/coder/v2/codersdk"
+)
+
+func TestExperimentalClientChatModelACL(t *testing.T) {
+ t.Parallel()
+
+ organizationID := uuid.New()
+ modelID := uuid.New()
+ userID := uuid.New()
+ groupID := uuid.New()
+
+ server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ require.Equal(t, http.MethodGet, r.Method)
+ require.Equal(t, "/api/experimental/organizations/"+organizationID.String()+"/chats/models/"+modelID.String()+"/acl", r.URL.Path)
+ http.Error(rw, `{"user_roles":{"`+userID.String()+`":"read"},"group_roles":{"`+groupID.String()+`":"read"}}`, http.StatusOK)
+ }))
+ defer server.Close()
+
+ serverURL, err := url.Parse(server.URL)
+ require.NoError(t, err)
+ client := codersdk.NewExperimentalClient(codersdk.New(serverURL))
+
+ modelACL, err := client.ChatModelACL(context.Background(), organizationID, modelID)
+ require.NoError(t, err)
+ require.Equal(t, map[string]codersdk.ChatRole{userID.String(): codersdk.ChatRoleRead}, modelACL.UserRoles)
+ require.Equal(t, map[string]codersdk.ChatRole{groupID.String(): codersdk.ChatRoleRead}, modelACL.GroupRoles)
+}
+
+func TestExperimentalClientUpdateChatModelACL(t *testing.T) {
+ t.Parallel()
+
+ organizationID := uuid.New()
+ modelID := uuid.New()
+ userID := uuid.New()
+ groupID := uuid.New()
+
+ server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ require.Equal(t, http.MethodPatch, r.Method)
+ require.Equal(t, "/api/experimental/organizations/"+organizationID.String()+"/chats/models/"+modelID.String()+"/acl", r.URL.Path)
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ var payload map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(body, &payload))
+ require.JSONEq(t, `{"`+userID.String()+`":"read"}`, string(payload["user_roles"]))
+ require.JSONEq(t, `{"`+groupID.String()+`":""}`, string(payload["group_roles"]))
+ rw.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ serverURL, err := url.Parse(server.URL)
+ require.NoError(t, err)
+ client := codersdk.NewExperimentalClient(codersdk.New(serverURL))
+
+ err = client.UpdateChatModelACL(context.Background(), organizationID, modelID, codersdk.UpdateChatModelACLRequest{
+ UserRoles: map[string]codersdk.ChatRole{userID.String(): codersdk.ChatRoleRead},
+ GroupRoles: map[string]codersdk.ChatRole{groupID.String(): codersdk.ChatRoleDeleted},
+ })
+ require.NoError(t, err)
+}
+
+func TestUpdateChatModelACLRequestOmittedMaps(t *testing.T) {
+ t.Parallel()
+
+ payload, err := json.Marshal(codersdk.UpdateChatModelACLRequest{})
+ require.NoError(t, err)
+ require.JSONEq(t, `{}`, string(payload))
+
+ payload, err = json.Marshal(codersdk.UpdateChatModelACLRequest{
+ UserRoles: map[string]codersdk.ChatRole{},
+ GroupRoles: map[string]codersdk.ChatRole{},
+ })
+ require.NoError(t, err)
+ require.JSONEq(t, `{}`, string(payload))
+}
diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go
index 8cfc2b0b1c439..8647e4bed44e4 100644
--- a/codersdk/toolsdk/chats.go
+++ b/codersdk/toolsdk/chats.go
@@ -2,10 +2,8 @@ package toolsdk
import (
"context"
- "errors"
"fmt"
"io"
- "net/http"
"strings"
"time"
@@ -18,11 +16,6 @@ import (
const chatIDDescription = "UUID of the chat."
-func isForbiddenError(err error) bool {
- var sdkErr *codersdk.Error
- return errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusForbidden
-}
-
func parseChatID(chatID string) (uuid.UUID, error) {
if chatID == "" {
return uuid.Nil, xerrors.New("chat_id is required")
@@ -108,7 +101,7 @@ The chat runs asynchronously. Poll coder_get_chat for status and read the transc
},
"model_config_id": map[string]any{
"type": "string",
- "description": "Optional chat model config UUID from coder_list_chat_model_configs. Defaults to the deployment default model.",
+ "description": "Optional chat model config UUID from coder_list_chat_model_configs. Defaults to the organization's default model.",
},
"labels": map[string]any{
"type": "object",
@@ -866,58 +859,61 @@ type ChatModelConfigSummary struct {
IsDefault bool `json:"is_default"`
}
+type ListChatModelConfigsArgs struct {
+ OrganizationID string `json:"organization_id"`
+}
+
type ListChatModelConfigsResponse struct {
ModelConfigs []ChatModelConfigSummary `json:"model_configs"`
}
-var ListChatModelConfigs = Tool[NoArgs, ListChatModelConfigsResponse]{
+var ListChatModelConfigs = Tool[ListChatModelConfigsArgs, ListChatModelConfigsResponse]{
Tool: aisdk.Tool{
Name: ToolNameListChatModelConfigs,
Description: `List the enabled chat models available for Coder Agents chats. Use a model config ID with coder_create_chat to pick a model.
Per-user provider credentials are validated when creating a chat, so coder_create_chat can still reject a listed model with an explanatory error.`,
Schema: aisdk.Schema{
- Properties: map[string]any{},
- Required: []string{},
+ Properties: map[string]any{
+ "organization_id": map[string]any{
+ "type": "string",
+ "description": "Optional organization UUID. Defaults to the authenticated user's first organization.",
+ },
+ },
+ Required: []string{},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
- Handler: func(ctx context.Context, deps Deps, _ NoArgs) (ListChatModelConfigsResponse, error) {
- configs, err := codersdk.NewExperimentalClient(deps.coderClient).ChatModels(ctx)
+ Handler: func(ctx context.Context, deps Deps, args ListChatModelConfigsArgs) (ListChatModelConfigsResponse, error) {
+ var organizationID uuid.UUID
+ if args.OrganizationID != "" {
+ var err error
+ organizationID, err = uuid.Parse(args.OrganizationID)
+ if err != nil {
+ return ListChatModelConfigsResponse{}, xerrors.New("organization_id must be a valid UUID")
+ }
+ } else {
+ me, err := deps.coderClient.User(ctx, codersdk.Me)
+ if err != nil {
+ return ListChatModelConfigsResponse{}, xerrors.Errorf("get authenticated user: %w", err)
+ }
+ if len(me.OrganizationIDs) == 0 {
+ return ListChatModelConfigsResponse{}, xerrors.New("authenticated user belongs to no organization; pass organization_id explicitly")
+ }
+ organizationID = me.OrganizationIDs[0]
+ }
+
+ response, err := codersdk.NewExperimentalClient(deps.coderClient).ChatModels(ctx, organizationID)
if err != nil {
return ListChatModelConfigsResponse{}, xerrors.Errorf("list chat model configs: %w", err)
}
- // Admin model lists include disabled providers; non-admin lists are
- // already filtered server-side.
- var providerEnabled map[uuid.UUID]bool
- providers, err := deps.coderClient.AIProviders(ctx)
- switch {
- case err == nil:
- providerEnabled = make(map[uuid.UUID]bool, len(providers))
- for _, provider := range providers {
- providerEnabled[provider.ID] = provider.Enabled
- }
- case isForbiddenError(err):
- // Deployment-config readers can receive the unfiltered admin list
- // without provider access, so fail closed unless both requests return 403.
- _, dcErr := deps.coderClient.DeploymentConfig(ctx)
- switch {
- case dcErr == nil:
- return ListChatModelConfigsResponse{}, xerrors.New("cannot verify provider availability for the admin model config list: missing AI provider read permission")
- case !isForbiddenError(dcErr):
- return ListChatModelConfigsResponse{}, xerrors.Errorf("verify deployment config access: %w", dcErr)
- }
- default:
- return ListChatModelConfigsResponse{}, xerrors.Errorf("list AI providers: %w", err)
+ providerEnabled := make(map[uuid.UUID]bool, len(response.Providers))
+ for _, provider := range response.Providers {
+ providerEnabled[provider.ID] = provider.Enabled
}
- summaries := make([]ChatModelConfigSummary, 0, len(configs))
- for _, config := range configs {
- if !config.Enabled {
- continue
- }
- // A non-nil map is authoritative because soft-deleted providers are
- // absent while their configs remain in the admin response.
- if providerEnabled != nil && !providerEnabled[config.AIProviderID] {
+ summaries := make([]ChatModelConfigSummary, 0, len(response.Models))
+ for _, config := range response.Models {
+ if !config.Enabled || !providerEnabled[config.AIProviderID] {
continue
}
summaries = append(summaries, ChatModelConfigSummary{
diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go
index c40f0087e29d9..c6a36158af83b 100644
--- a/codersdk/toolsdk/chats_test.go
+++ b/codersdk/toolsdk/chats_test.go
@@ -13,7 +13,6 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
- "golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/aibridgedtest"
"github.com/coder/coder/v2/coderd/coderdtest"
@@ -29,17 +28,6 @@ import (
"github.com/coder/coder/v2/testutil"
)
-type failPathTransport struct {
- path string
-}
-
-func (t *failPathTransport) RoundTrip(req *http.Request) (*http.Response, error) {
- if req.URL.Path == t.path {
- return nil, xerrors.New("transport down")
- }
- return http.DefaultTransport.RoundTrip(req)
-}
-
// stallTransport hangs every request until its context is canceled.
type stallTransport struct{}
@@ -90,7 +78,7 @@ func TestChatTools(t *testing.T) {
require.NoError(t, err)
t.Run("ListChatModelConfigs", func(t *testing.T) {
- result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{})
+ result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{})
require.NoError(t, err)
require.Len(t, result.ModelConfigs, 1)
require.Equal(t, defaultModelConfig.ID.String(), result.ModelConfigs[0].ID)
@@ -98,6 +86,38 @@ func TestChatTools(t *testing.T) {
require.True(t, result.ModelConfigs[0].IsDefault)
})
+ t.Run("ListChatModelConfigsForOrganization", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+ organization := dbgen.Organization(t, api.Database, database.Organization{})
+ dbgen.OrganizationMember(t, api.Database, database.OrganizationMember{
+ OrganizationID: organization.ID,
+ UserID: firstUser.UserID,
+ })
+
+ provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
+ Type: codersdk.AIProviderTypeOpenAICompat,
+ Name: "other-org-" + uuid.NewString(),
+ BaseURL: chattest.OpenAI(t),
+ Enabled: true,
+ APIKeys: []string{"test-api-key"},
+ })
+ require.NoError(t, err)
+ contextLimit := int64(4096)
+ model, err := expClient.CreateChatModel(ctx, organization.ID, codersdk.CreateChatModelRequest{
+ AIProviderID: &provider.ID,
+ Model: "gpt-4o-other-org",
+ ContextLimit: &contextLimit,
+ })
+ require.NoError(t, err)
+
+ result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{
+ OrganizationID: organization.ID.String(),
+ })
+ require.NoError(t, err)
+ require.Len(t, result.ModelConfigs, 1)
+ require.Equal(t, model.ID.String(), result.ModelConfigs[0].ID)
+ })
+
t.Run("Lifecycle", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
@@ -671,7 +691,7 @@ func TestChatTools(t *testing.T) {
require.NoError(t, err)
require.False(t, provider.Enabled)
- result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{})
+ result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{})
require.NoError(t, err)
var ids []string
for _, config := range result.ModelConfigs {
@@ -688,7 +708,7 @@ func TestChatTools(t *testing.T) {
err := client.DeleteAIProvider(ctx, deletedProviderConfig.AIProviderID.String())
require.NoError(t, err)
- result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{})
+ result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{})
require.NoError(t, err)
var ids []string
for _, config := range result.ModelConfigs {
@@ -754,7 +774,7 @@ func TestChatTools(t *testing.T) {
memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
memberDeps, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
- result, err := testTool(t, toolsdk.ListChatModelConfigs, memberDeps, toolsdk.NoArgs{})
+ result, err := testTool(t, toolsdk.ListChatModelConfigs, memberDeps, toolsdk.ListChatModelConfigsArgs{})
require.NoError(t, err)
var ids []string
for _, config := range result.ModelConfigs {
@@ -765,19 +785,13 @@ func TestChatTools(t *testing.T) {
auditorClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID, rbac.RoleAuditor())
auditorDeps, err := toolsdk.NewDeps(auditorClient)
require.NoError(t, err)
- _, err = testTool(t, toolsdk.ListChatModelConfigs, auditorDeps, toolsdk.NoArgs{})
- require.ErrorContains(t, err, "missing AI provider read permission")
-
- brokenProbeClient := codersdk.New(auditorClient.URL)
- brokenProbeClient.SetSessionToken(auditorClient.SessionToken())
- brokenProbeClient.HTTPClient = &http.Client{
- Transport: &failPathTransport{path: "/api/v2/deployment/config"},
- }
- t.Cleanup(brokenProbeClient.HTTPClient.CloseIdleConnections)
- brokenProbeDeps, err := toolsdk.NewDeps(brokenProbeClient)
+ auditorResult, err := testTool(t, toolsdk.ListChatModelConfigs, auditorDeps, toolsdk.ListChatModelConfigsArgs{})
require.NoError(t, err)
- _, err = testTool(t, toolsdk.ListChatModelConfigs, brokenProbeDeps, toolsdk.NoArgs{})
- require.ErrorContains(t, err, "verify deployment config access")
+ var auditorIDs []string
+ for _, config := range auditorResult.ModelConfigs {
+ auditorIDs = append(auditorIDs, config.ID)
+ }
+ require.Contains(t, auditorIDs, defaultModelConfig.ID.String())
})
t.Run("CreateChatZeroOrgUser", func(t *testing.T) {
@@ -796,6 +810,9 @@ func TestChatTools(t *testing.T) {
_, err := testTool(t, toolsdk.CreateChat, tb, toolsdk.CreateChatArgs{})
require.ErrorContains(t, err, "prompt is required")
+ _, err = testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{OrganizationID: "not-a-uuid"})
+ require.ErrorContains(t, err, "organization_id must be a valid UUID")
+
_, err = testTool(t, toolsdk.GetChat, tb, toolsdk.GetChatArgs{ChatID: "not-a-uuid"})
require.ErrorContains(t, err, "chat_id must be a valid UUID")
diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md
index b08703eb54b8c..d51b4bde77da2 100644
--- a/docs/admin/security/audit-logs.md
+++ b/docs/admin/security/audit-logs.md
@@ -27,6 +27,7 @@ We track the following resources:
| AuditableUserAIBudgetOverride
write, delete |
| Field | Tracked |
| | created_at | false |
| group_id | true |
| group_name | true |
| spend_limit | true |
| spend_limit_micros | false |
| updated_at | false |
| user_id | false |
| username | false |
|
| Chat
create, write | | Field | Tracked |
| | agent_id | false |
| archived | true |
| build_id | false |
| client_type | false |
| compaction_requested_at | false |
| context_aggregate_hash | false |
| context_dirty_resources | false |
| context_dirty_since | false |
| context_error | false |
| created_at | false |
| dynamic_tools | false |
| generation_attempt | false |
| group_acl | true |
| heartbeat_at | false |
| history_version | false |
| id | true |
| labels | true |
| last_error | false |
| last_model_config_id | false |
| last_read_message_id | false |
| last_reasoning_effort | false |
| last_turn_summary | false |
| mcp_server_ids | true |
| mode | true |
| organization_id | false |
| owner_id | true |
| owner_name | false |
| owner_username | false |
| parent_chat_id | false |
| pin_order | true |
| plan_mode | false |
| queue_version | false |
| requires_action_deadline_at | false |
| retry_state | false |
| retry_state_version | false |
| root_chat_id | false |
| runner_id | false |
| snapshot_version | false |
| started_at | false |
| status | false |
| summary | false |
| summary_generated_at | false |
| title | true |
| updated_at | false |
| user_acl | true |
| worker_id | false |
| workspace_id | true |
|
| ChatInstructionSettings
write | | Field | Tracked |
| | id | false |
| include_default_system_prompt | true |
| include_default_system_prompt_set | true |
| name | false |
| plan_mode_instructions | true |
| system_prompt | true |
|
+| ChatModelConfig
write | | Field | Tracked |
| | ai_provider_id | true |
| compression_threshold | true |
| context_limit | true |
| created_at | false |
| created_by | true |
| deleted | true |
| deleted_at | false |
| display_name | true |
| enabled | true |
| group_acl | true |
| id | false |
| is_default | true |
| model | true |
| options | true |
| organization_id | false |
| updated_at | false |
| updated_by | true |
| user_acl | true |
|
| CustomRole
| | Field | Tracked |
| | created_at | false |
| display_name | true |
| id | false |
| is_system | false |
| member_permissions | true |
| name | true |
| org_permissions | true |
| organization_id | false |
| site_permissions | true |
| updated_at | false |
| user_permissions | true |
|
| GitSSHKey
create | | Field | Tracked |
| | created_at | false |
| private_key | true |
| private_key_key_id | false |
| public_key | true |
| updated_at | false |
| user_id | true |
|
| GroupSyncSettings
| | Field | Tracked |
| | auto_create_missing_groups | true |
| field | true |
| legacy_group_name_mapping | false |
| mapping | true |
| regex_filter | true |
|
diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md
index a47211652f363..d8fe3468d000a 100644
--- a/docs/reference/api/chats.md
+++ b/docs/reference/api/chats.md
@@ -596,59 +596,6 @@ Experimental: this endpoint is subject to change.
To perform this operation, you must be authenticated. [Learn more](authentication.md).
-## List chat models
-
-### Code samples
-
-```sh
-# Example request using curl
-curl -X GET http://coder-server:8080/api/experimental/chats/models \
- -H 'Accept: application/json' \
- -H 'Coder-Session-Token: API_KEY'
-```
-
-`GET /api/experimental/chats/models`
-
-Experimental: this endpoint is subject to change.
-
-### Example responses
-
-> 200 Response
-
-```json
-{
- "providers": [
- {
- "available": true,
- "models": [
- {
- "display_name": "string",
- "id": "string",
- "model": "string",
- "provider": "string"
- }
- ],
- "provider": "string",
- "unavailable_reason": "missing_api_key"
- }
- ],
- "unsupported_providers": [
- {
- "display_name": "string",
- "provider": "string"
- }
- ]
-}
-```
-
-### Responses
-
-| Status | Meaning | Description | Schema |
-|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------|
-| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ChatModelAvailabilityResponse](schemas.md#codersdkchatmodelavailabilityresponse) |
-
-To perform this operation, you must be authenticated. [Learn more](authentication.md).
-
## Watch chat events for a user via WebSockets
### Code samples
@@ -3268,3 +3215,233 @@ Experimental: this endpoint is subject to change.
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
+## List available chat models in an organization
+
+### Code samples
+
+```sh
+# Example request using curl
+curl -X GET http://coder-server:8080/api/experimental/organizations/{organization}/chats/models/available \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`GET /api/experimental/organizations/{organization}/chats/models/available`
+
+Experimental: this endpoint is subject to change.
+
+### Parameters
+
+| Name | In | Type | Required | Description |
+|----------------|------|--------|----------|-------------------------|
+| `organization` | path | string | true | Organization name or ID |
+
+### Example responses
+
+> 200 Response
+
+```json
+{
+ "models": [
+ {
+ "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819",
+ "compression_threshold": 0,
+ "context_limit": 0,
+ "created_at": "2019-08-24T14:15:22Z",
+ "display_name": "string",
+ "enabled": true,
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "is_default": true,
+ "model": "string",
+ "model_config": {
+ "frequency_penalty": 0,
+ "max_output_tokens": 0,
+ "openai_config": {
+ "use_responses_api": true
+ },
+ "presence_penalty": 0,
+ "provider_options": {
+ "anthropic": {
+ "allowed_domains": [
+ "string"
+ ],
+ "blocked_domains": [
+ "string"
+ ],
+ "context_1m_enabled": true,
+ "disable_parallel_tool_use": true,
+ "send_reasoning": true,
+ "thinking": {
+ "budget_tokens": 0
+ },
+ "thinking_display": "string",
+ "web_search_enabled": true
+ },
+ "google": {
+ "cached_content": "string",
+ "safety_settings": [
+ {
+ "category": "string",
+ "threshold": "string"
+ }
+ ],
+ "thinking_config": {
+ "include_thoughts": true,
+ "thinking_budget": 0,
+ "thinking_level": "string"
+ },
+ "threshold": "string",
+ "web_search_enabled": true
+ },
+ "openai": {
+ "allowed_domains": [
+ "string"
+ ],
+ "include": [
+ "string"
+ ],
+ "instructions": "string",
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "max_completion_tokens": 0,
+ "max_tool_calls": 0,
+ "metadata": {
+ "property1": null,
+ "property2": null
+ },
+ "parallel_tool_calls": true,
+ "prediction": {
+ "property1": null,
+ "property2": null
+ },
+ "prompt_cache_key": "string",
+ "reasoning_summary": "string",
+ "safety_identifier": "string",
+ "search_context_size": "string",
+ "service_tier": "string",
+ "store": true,
+ "strict_json_schema": true,
+ "structured_outputs": true,
+ "text_verbosity": "string",
+ "top_log_probs": 0,
+ "user": "string",
+ "web_search_enabled": true
+ },
+ "openaicompat": {
+ "user": "string"
+ },
+ "openrouter": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "include_usage": true,
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "parallel_tool_calls": true,
+ "provider": {
+ "allow_fallbacks": true,
+ "data_collection": "string",
+ "ignore": [
+ "string"
+ ],
+ "only": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ],
+ "quantizations": [
+ "string"
+ ],
+ "require_parameters": true,
+ "sort": "string"
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "user": "string"
+ },
+ "vercel": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "logprobs": true,
+ "parallel_tool_calls": true,
+ "providerOptions": {
+ "models": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ]
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "top_logprobs": 0,
+ "user": "string"
+ }
+ },
+ "reasoning_effort": {
+ "default": "string",
+ "max": "string"
+ },
+ "temperature": 0,
+ "top_k": 0,
+ "top_p": 0
+ },
+ "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
+ "reasoning_efforts": [
+ "string"
+ ],
+ "updated_at": "2019-08-24T14:15:22Z"
+ }
+ ],
+ "providers": [
+ {
+ "available": true,
+ "models": [
+ {
+ "display_name": "string",
+ "id": "string",
+ "model": "string",
+ "provider": "string"
+ }
+ ],
+ "provider": "string",
+ "unavailable_reason": "missing_api_key"
+ }
+ ],
+ "unsupported_providers": [
+ {
+ "display_name": "string",
+ "provider": "string"
+ }
+ ]
+}
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------|
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ChatModelAvailabilityResponse](schemas.md#codersdkchatmodelavailabilityresponse) |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 4bd8af6b0709f..4259672f371ce 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -3393,60 +3393,926 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `messages` | array of [codersdk.ChatMessage](#codersdkchatmessage) | false | | |
| `queued_messages` | array of [codersdk.ChatQueuedMessage](#codersdkchatqueuedmessage) | false | | |
+## codersdk.ChatModel
+
+```json
+{
+ "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819",
+ "compression_threshold": 0,
+ "context_limit": 0,
+ "created_at": "2019-08-24T14:15:22Z",
+ "display_name": "string",
+ "enabled": true,
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "is_default": true,
+ "model": "string",
+ "model_config": {
+ "frequency_penalty": 0,
+ "max_output_tokens": 0,
+ "openai_config": {
+ "use_responses_api": true
+ },
+ "presence_penalty": 0,
+ "provider_options": {
+ "anthropic": {
+ "allowed_domains": [
+ "string"
+ ],
+ "blocked_domains": [
+ "string"
+ ],
+ "context_1m_enabled": true,
+ "disable_parallel_tool_use": true,
+ "send_reasoning": true,
+ "thinking": {
+ "budget_tokens": 0
+ },
+ "thinking_display": "string",
+ "web_search_enabled": true
+ },
+ "google": {
+ "cached_content": "string",
+ "safety_settings": [
+ {
+ "category": "string",
+ "threshold": "string"
+ }
+ ],
+ "thinking_config": {
+ "include_thoughts": true,
+ "thinking_budget": 0,
+ "thinking_level": "string"
+ },
+ "threshold": "string",
+ "web_search_enabled": true
+ },
+ "openai": {
+ "allowed_domains": [
+ "string"
+ ],
+ "include": [
+ "string"
+ ],
+ "instructions": "string",
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "max_completion_tokens": 0,
+ "max_tool_calls": 0,
+ "metadata": {
+ "property1": null,
+ "property2": null
+ },
+ "parallel_tool_calls": true,
+ "prediction": {
+ "property1": null,
+ "property2": null
+ },
+ "prompt_cache_key": "string",
+ "reasoning_summary": "string",
+ "safety_identifier": "string",
+ "search_context_size": "string",
+ "service_tier": "string",
+ "store": true,
+ "strict_json_schema": true,
+ "structured_outputs": true,
+ "text_verbosity": "string",
+ "top_log_probs": 0,
+ "user": "string",
+ "web_search_enabled": true
+ },
+ "openaicompat": {
+ "user": "string"
+ },
+ "openrouter": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "include_usage": true,
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "parallel_tool_calls": true,
+ "provider": {
+ "allow_fallbacks": true,
+ "data_collection": "string",
+ "ignore": [
+ "string"
+ ],
+ "only": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ],
+ "quantizations": [
+ "string"
+ ],
+ "require_parameters": true,
+ "sort": "string"
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "user": "string"
+ },
+ "vercel": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "logprobs": true,
+ "parallel_tool_calls": true,
+ "providerOptions": {
+ "models": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ]
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "top_logprobs": 0,
+ "user": "string"
+ }
+ },
+ "reasoning_effort": {
+ "default": "string",
+ "max": "string"
+ },
+ "temperature": 0,
+ "top_k": 0,
+ "top_p": 0
+ },
+ "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
+ "reasoning_efforts": [
+ "string"
+ ],
+ "updated_at": "2019-08-24T14:15:22Z"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-------------------------|--------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------|
+| `ai_provider_id` | string | false | | |
+| `compression_threshold` | integer | false | | |
+| `context_limit` | integer | false | | |
+| `created_at` | string | false | | |
+| `display_name` | string | false | | |
+| `enabled` | boolean | false | | |
+| `id` | string | false | | |
+| `is_default` | boolean | false | | |
+| `model` | string | false | | |
+| `model_config` | [codersdk.ChatModelCallConfig](#codersdkchatmodelcallconfig) | false | | |
+| `organization_id` | string | false | | |
+| `reasoning_efforts` | array of string | false | | Reasoning efforts lists selectable reasoning effort values through the model's configured maximum. |
+| `updated_at` | string | false | | |
+
+## codersdk.ChatModelACL
+
+```json
+{
+ "group_roles": {
+ "property1": "read",
+ "property2": "read"
+ },
+ "user_roles": {
+ "property1": "read",
+ "property2": "read"
+ }
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|--------------------|----------------------------------------|----------|--------------|-------------|
+| `group_roles` | object | false | | |
+| » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | |
+| `user_roles` | object | false | | |
+| » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | |
+
+## codersdk.ChatModelAnthropicProviderOptions
+
+```json
+{
+ "allowed_domains": [
+ "string"
+ ],
+ "blocked_domains": [
+ "string"
+ ],
+ "context_1m_enabled": true,
+ "disable_parallel_tool_use": true,
+ "send_reasoning": true,
+ "thinking": {
+ "budget_tokens": 0
+ },
+ "thinking_display": "string",
+ "web_search_enabled": true
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-----------------------------|------------------------------------------------------------------------------------------|----------|--------------|-------------|
+| `allowed_domains` | array of string | false | | |
+| `blocked_domains` | array of string | false | | |
+| `context_1m_enabled` | boolean | false | | |
+| `disable_parallel_tool_use` | boolean | false | | |
+| `send_reasoning` | boolean | false | | |
+| `thinking` | [codersdk.ChatModelAnthropicThinkingOptions](#codersdkchatmodelanthropicthinkingoptions) | false | | |
+| `thinking_display` | string | false | | |
+| `web_search_enabled` | boolean | false | | |
+
+## codersdk.ChatModelAnthropicThinkingOptions
+
+```json
+{
+ "budget_tokens": 0
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-----------------|---------|----------|--------------|-------------|
+| `budget_tokens` | integer | false | | |
+
## codersdk.ChatModelAvailabilityResponse
```json
{
- "providers": [
- {
- "available": true,
- "models": [
- {
- "display_name": "string",
- "id": "string",
- "model": "string",
- "provider": "string"
- }
- ],
- "provider": "string",
- "unavailable_reason": "missing_api_key"
- }
+ "models": [
+ {
+ "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819",
+ "compression_threshold": 0,
+ "context_limit": 0,
+ "created_at": "2019-08-24T14:15:22Z",
+ "display_name": "string",
+ "enabled": true,
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "is_default": true,
+ "model": "string",
+ "model_config": {
+ "frequency_penalty": 0,
+ "max_output_tokens": 0,
+ "openai_config": {
+ "use_responses_api": true
+ },
+ "presence_penalty": 0,
+ "provider_options": {
+ "anthropic": {
+ "allowed_domains": [
+ "string"
+ ],
+ "blocked_domains": [
+ "string"
+ ],
+ "context_1m_enabled": true,
+ "disable_parallel_tool_use": true,
+ "send_reasoning": true,
+ "thinking": {
+ "budget_tokens": 0
+ },
+ "thinking_display": "string",
+ "web_search_enabled": true
+ },
+ "google": {
+ "cached_content": "string",
+ "safety_settings": [
+ {
+ "category": "string",
+ "threshold": "string"
+ }
+ ],
+ "thinking_config": {
+ "include_thoughts": true,
+ "thinking_budget": 0,
+ "thinking_level": "string"
+ },
+ "threshold": "string",
+ "web_search_enabled": true
+ },
+ "openai": {
+ "allowed_domains": [
+ "string"
+ ],
+ "include": [
+ "string"
+ ],
+ "instructions": "string",
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "max_completion_tokens": 0,
+ "max_tool_calls": 0,
+ "metadata": {
+ "property1": null,
+ "property2": null
+ },
+ "parallel_tool_calls": true,
+ "prediction": {
+ "property1": null,
+ "property2": null
+ },
+ "prompt_cache_key": "string",
+ "reasoning_summary": "string",
+ "safety_identifier": "string",
+ "search_context_size": "string",
+ "service_tier": "string",
+ "store": true,
+ "strict_json_schema": true,
+ "structured_outputs": true,
+ "text_verbosity": "string",
+ "top_log_probs": 0,
+ "user": "string",
+ "web_search_enabled": true
+ },
+ "openaicompat": {
+ "user": "string"
+ },
+ "openrouter": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "include_usage": true,
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "parallel_tool_calls": true,
+ "provider": {
+ "allow_fallbacks": true,
+ "data_collection": "string",
+ "ignore": [
+ "string"
+ ],
+ "only": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ],
+ "quantizations": [
+ "string"
+ ],
+ "require_parameters": true,
+ "sort": "string"
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "user": "string"
+ },
+ "vercel": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "logprobs": true,
+ "parallel_tool_calls": true,
+ "providerOptions": {
+ "models": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ]
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "top_logprobs": 0,
+ "user": "string"
+ }
+ },
+ "reasoning_effort": {
+ "default": "string",
+ "max": "string"
+ },
+ "temperature": 0,
+ "top_k": 0,
+ "top_p": 0
+ },
+ "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
+ "reasoning_efforts": [
+ "string"
+ ],
+ "updated_at": "2019-08-24T14:15:22Z"
+ }
+ ],
+ "providers": [
+ {
+ "available": true,
+ "models": [
+ {
+ "display_name": "string",
+ "id": "string",
+ "model": "string",
+ "provider": "string"
+ }
+ ],
+ "provider": "string",
+ "unavailable_reason": "missing_api_key"
+ }
+ ],
+ "unsupported_providers": [
+ {
+ "display_name": "string",
+ "provider": "string"
+ }
+ ]
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-------------------------|-------------------------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------|
+| `models` | array of [codersdk.ChatModel](#codersdkchatmodel) | false | | Models contains the effective runtime model configs for the requested organization. Each config belongs to that organization. |
+| `providers` | array of [codersdk.ChatModelProvider](#codersdkchatmodelprovider) | false | | |
+| `unsupported_providers` | array of [codersdk.ChatUnsupportedProvider](#codersdkchatunsupportedprovider) | false | | Unsupported providers lists configured providers the Agents harness cannot use, so the UI can explain the empty state. |
+
+## codersdk.ChatModelCallConfig
+
+```json
+{
+ "frequency_penalty": 0,
+ "max_output_tokens": 0,
+ "openai_config": {
+ "use_responses_api": true
+ },
+ "presence_penalty": 0,
+ "provider_options": {
+ "anthropic": {
+ "allowed_domains": [
+ "string"
+ ],
+ "blocked_domains": [
+ "string"
+ ],
+ "context_1m_enabled": true,
+ "disable_parallel_tool_use": true,
+ "send_reasoning": true,
+ "thinking": {
+ "budget_tokens": 0
+ },
+ "thinking_display": "string",
+ "web_search_enabled": true
+ },
+ "google": {
+ "cached_content": "string",
+ "safety_settings": [
+ {
+ "category": "string",
+ "threshold": "string"
+ }
+ ],
+ "thinking_config": {
+ "include_thoughts": true,
+ "thinking_budget": 0,
+ "thinking_level": "string"
+ },
+ "threshold": "string",
+ "web_search_enabled": true
+ },
+ "openai": {
+ "allowed_domains": [
+ "string"
+ ],
+ "include": [
+ "string"
+ ],
+ "instructions": "string",
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "max_completion_tokens": 0,
+ "max_tool_calls": 0,
+ "metadata": {
+ "property1": null,
+ "property2": null
+ },
+ "parallel_tool_calls": true,
+ "prediction": {
+ "property1": null,
+ "property2": null
+ },
+ "prompt_cache_key": "string",
+ "reasoning_summary": "string",
+ "safety_identifier": "string",
+ "search_context_size": "string",
+ "service_tier": "string",
+ "store": true,
+ "strict_json_schema": true,
+ "structured_outputs": true,
+ "text_verbosity": "string",
+ "top_log_probs": 0,
+ "user": "string",
+ "web_search_enabled": true
+ },
+ "openaicompat": {
+ "user": "string"
+ },
+ "openrouter": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "include_usage": true,
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "parallel_tool_calls": true,
+ "provider": {
+ "allow_fallbacks": true,
+ "data_collection": "string",
+ "ignore": [
+ "string"
+ ],
+ "only": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ],
+ "quantizations": [
+ "string"
+ ],
+ "require_parameters": true,
+ "sort": "string"
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "user": "string"
+ },
+ "vercel": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "logprobs": true,
+ "parallel_tool_calls": true,
+ "providerOptions": {
+ "models": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ]
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "top_logprobs": 0,
+ "user": "string"
+ }
+ },
+ "reasoning_effort": {
+ "default": "string",
+ "max": "string"
+ },
+ "temperature": 0,
+ "top_k": 0,
+ "top_p": 0
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|---------------------|------------------------------------------------------------------------------------|----------|--------------|-------------|
+| `frequency_penalty` | number | false | | |
+| `max_output_tokens` | integer | false | | |
+| `openai_config` | [codersdk.ChatModelOpenAIConfig](#codersdkchatmodelopenaiconfig) | false | | |
+| `presence_penalty` | number | false | | |
+| `provider_options` | [codersdk.ChatModelProviderOptions](#codersdkchatmodelprovideroptions) | false | | |
+| `reasoning_effort` | [codersdk.ChatModelReasoningEffortConfig](#codersdkchatmodelreasoningeffortconfig) | false | | |
+| `temperature` | number | false | | |
+| `top_k` | integer | false | | |
+| `top_p` | number | false | | |
+
+## codersdk.ChatModelCatalogEntry
+
+```json
+{
+ "display_name": "string",
+ "id": "string",
+ "model": "string",
+ "provider": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|----------------|--------|----------|--------------|-------------|
+| `display_name` | string | false | | |
+| `id` | string | false | | |
+| `model` | string | false | | |
+| `provider` | string | false | | |
+
+## codersdk.ChatModelGoogleProviderOptions
+
+```json
+{
+ "cached_content": "string",
+ "safety_settings": [
+ {
+ "category": "string",
+ "threshold": "string"
+ }
+ ],
+ "thinking_config": {
+ "include_thoughts": true,
+ "thinking_budget": 0,
+ "thinking_level": "string"
+ },
+ "threshold": "string",
+ "web_search_enabled": true
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|----------------------|-----------------------------------------------------------------------------------------|----------|--------------|-------------|
+| `cached_content` | string | false | | |
+| `safety_settings` | array of [codersdk.ChatModelGoogleSafetySetting](#codersdkchatmodelgooglesafetysetting) | false | | |
+| `thinking_config` | [codersdk.ChatModelGoogleThinkingConfig](#codersdkchatmodelgooglethinkingconfig) | false | | |
+| `threshold` | string | false | | |
+| `web_search_enabled` | boolean | false | | |
+
+## codersdk.ChatModelGoogleSafetySetting
+
+```json
+{
+ "category": "string",
+ "threshold": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-------------|--------|----------|--------------|-------------|
+| `category` | string | false | | |
+| `threshold` | string | false | | |
+
+## codersdk.ChatModelGoogleThinkingConfig
+
+```json
+{
+ "include_thoughts": true,
+ "thinking_budget": 0,
+ "thinking_level": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|--------------------|---------|----------|--------------|-------------|
+| `include_thoughts` | boolean | false | | |
+| `thinking_budget` | integer | false | | |
+| `thinking_level` | string | false | | |
+
+## codersdk.ChatModelOpenAICompatProviderOptions
+
+```json
+{
+ "user": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|--------|--------|----------|--------------|-------------|
+| `user` | string | false | | |
+
+## codersdk.ChatModelOpenAIConfig
+
+```json
+{
+ "use_responses_api": true
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|---------------------|---------|----------|--------------|-------------|
+| `use_responses_api` | boolean | false | | |
+
+## codersdk.ChatModelOpenAIProviderOptions
+
+```json
+{
+ "allowed_domains": [
+ "string"
],
- "unsupported_providers": [
- {
- "display_name": "string",
- "provider": "string"
- }
- ]
+ "include": [
+ "string"
+ ],
+ "instructions": "string",
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "max_completion_tokens": 0,
+ "max_tool_calls": 0,
+ "metadata": {
+ "property1": null,
+ "property2": null
+ },
+ "parallel_tool_calls": true,
+ "prediction": {
+ "property1": null,
+ "property2": null
+ },
+ "prompt_cache_key": "string",
+ "reasoning_summary": "string",
+ "safety_identifier": "string",
+ "search_context_size": "string",
+ "service_tier": "string",
+ "store": true,
+ "strict_json_schema": true,
+ "structured_outputs": true,
+ "text_verbosity": "string",
+ "top_log_probs": 0,
+ "user": "string",
+ "web_search_enabled": true
}
```
### Properties
-| Name | Type | Required | Restrictions | Description |
-|-------------------------|-------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------|
-| `providers` | array of [codersdk.ChatModelProvider](#codersdkchatmodelprovider) | false | | |
-| `unsupported_providers` | array of [codersdk.ChatUnsupportedProvider](#codersdkchatunsupportedprovider) | false | | Unsupported providers lists configured providers the Agents harness cannot use, so the UI can explain the empty state. |
+| Name | Type | Required | Restrictions | Description |
+|-------------------------|-----------------|----------|--------------|-------------|
+| `allowed_domains` | array of string | false | | |
+| `include` | array of string | false | | |
+| `instructions` | string | false | | |
+| `log_probs` | boolean | false | | |
+| `logit_bias` | object | false | | |
+| » `[any property]` | integer | false | | |
+| `max_completion_tokens` | integer | false | | |
+| `max_tool_calls` | integer | false | | |
+| `metadata` | object | false | | |
+| » `[any property]` | any | false | | |
+| `parallel_tool_calls` | boolean | false | | |
+| `prediction` | object | false | | |
+| » `[any property]` | any | false | | |
+| `prompt_cache_key` | string | false | | |
+| `reasoning_summary` | string | false | | |
+| `safety_identifier` | string | false | | |
+| `search_context_size` | string | false | | |
+| `service_tier` | string | false | | |
+| `store` | boolean | false | | |
+| `strict_json_schema` | boolean | false | | |
+| `structured_outputs` | boolean | false | | |
+| `text_verbosity` | string | false | | |
+| `top_log_probs` | integer | false | | |
+| `user` | string | false | | |
+| `web_search_enabled` | boolean | false | | |
+
+## codersdk.ChatModelOpenRouterProvider
+
+```json
+{
+ "allow_fallbacks": true,
+ "data_collection": "string",
+ "ignore": [
+ "string"
+ ],
+ "only": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ],
+ "quantizations": [
+ "string"
+ ],
+ "require_parameters": true,
+ "sort": "string"
+}
+```
-## codersdk.ChatModelCatalogEntry
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|----------------------|-----------------|----------|--------------|-------------|
+| `allow_fallbacks` | boolean | false | | |
+| `data_collection` | string | false | | |
+| `ignore` | array of string | false | | |
+| `only` | array of string | false | | |
+| `order` | array of string | false | | |
+| `quantizations` | array of string | false | | |
+| `require_parameters` | boolean | false | | |
+| `sort` | string | false | | |
+
+## codersdk.ChatModelOpenRouterProviderOptions
```json
{
- "display_name": "string",
- "id": "string",
- "model": "string",
- "provider": "string"
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "include_usage": true,
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "parallel_tool_calls": true,
+ "provider": {
+ "allow_fallbacks": true,
+ "data_collection": "string",
+ "ignore": [
+ "string"
+ ],
+ "only": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ],
+ "quantizations": [
+ "string"
+ ],
+ "require_parameters": true,
+ "sort": "string"
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "user": "string"
}
```
### Properties
-| Name | Type | Required | Restrictions | Description |
-|----------------|--------|----------|--------------|-------------|
-| `display_name` | string | false | | |
-| `id` | string | false | | |
-| `model` | string | false | | |
-| `provider` | string | false | | |
+| Name | Type | Required | Restrictions | Description |
+|-----------------------|------------------------------------------------------------------------------|----------|--------------|-------------|
+| `extra_body` | object | false | | |
+| » `[any property]` | any | false | | |
+| `include_usage` | boolean | false | | |
+| `log_probs` | boolean | false | | |
+| `logit_bias` | object | false | | |
+| » `[any property]` | integer | false | | |
+| `parallel_tool_calls` | boolean | false | | |
+| `provider` | [codersdk.ChatModelOpenRouterProvider](#codersdkchatmodelopenrouterprovider) | false | | |
+| `reasoning` | [codersdk.ChatModelReasoningOptions](#codersdkchatmodelreasoningoptions) | false | | |
+| `user` | string | false | | |
## codersdk.ChatModelProvider
@@ -3475,6 +4341,190 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `provider` | string | false | | |
| `unavailable_reason` | [codersdk.ChatModelProviderUnavailableReason](#codersdkchatmodelproviderunavailablereason) | false | | |
+## codersdk.ChatModelProviderDescriptor
+
+```json
+{
+ "allow_user_api_key": true,
+ "display_name": "string",
+ "enabled": true,
+ "has_api_key": true,
+ "has_effective_api_key": true,
+ "has_user_api_key": true,
+ "icon": "string",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "type": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-------------------------|---------|----------|--------------|-------------|
+| `allow_user_api_key` | boolean | false | | |
+| `display_name` | string | false | | |
+| `enabled` | boolean | false | | |
+| `has_api_key` | boolean | false | | |
+| `has_effective_api_key` | boolean | false | | |
+| `has_user_api_key` | boolean | false | | |
+| `icon` | string | false | | |
+| `id` | string | false | | |
+| `type` | string | false | | |
+
+## codersdk.ChatModelProviderOptions
+
+```json
+{
+ "anthropic": {
+ "allowed_domains": [
+ "string"
+ ],
+ "blocked_domains": [
+ "string"
+ ],
+ "context_1m_enabled": true,
+ "disable_parallel_tool_use": true,
+ "send_reasoning": true,
+ "thinking": {
+ "budget_tokens": 0
+ },
+ "thinking_display": "string",
+ "web_search_enabled": true
+ },
+ "google": {
+ "cached_content": "string",
+ "safety_settings": [
+ {
+ "category": "string",
+ "threshold": "string"
+ }
+ ],
+ "thinking_config": {
+ "include_thoughts": true,
+ "thinking_budget": 0,
+ "thinking_level": "string"
+ },
+ "threshold": "string",
+ "web_search_enabled": true
+ },
+ "openai": {
+ "allowed_domains": [
+ "string"
+ ],
+ "include": [
+ "string"
+ ],
+ "instructions": "string",
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "max_completion_tokens": 0,
+ "max_tool_calls": 0,
+ "metadata": {
+ "property1": null,
+ "property2": null
+ },
+ "parallel_tool_calls": true,
+ "prediction": {
+ "property1": null,
+ "property2": null
+ },
+ "prompt_cache_key": "string",
+ "reasoning_summary": "string",
+ "safety_identifier": "string",
+ "search_context_size": "string",
+ "service_tier": "string",
+ "store": true,
+ "strict_json_schema": true,
+ "structured_outputs": true,
+ "text_verbosity": "string",
+ "top_log_probs": 0,
+ "user": "string",
+ "web_search_enabled": true
+ },
+ "openaicompat": {
+ "user": "string"
+ },
+ "openrouter": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "include_usage": true,
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "parallel_tool_calls": true,
+ "provider": {
+ "allow_fallbacks": true,
+ "data_collection": "string",
+ "ignore": [
+ "string"
+ ],
+ "only": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ],
+ "quantizations": [
+ "string"
+ ],
+ "require_parameters": true,
+ "sort": "string"
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "user": "string"
+ },
+ "vercel": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "logprobs": true,
+ "parallel_tool_calls": true,
+ "providerOptions": {
+ "models": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ]
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "top_logprobs": 0,
+ "user": "string"
+ }
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|----------------|------------------------------------------------------------------------------------------------|----------|--------------|-------------|
+| `anthropic` | [codersdk.ChatModelAnthropicProviderOptions](#codersdkchatmodelanthropicprovideroptions) | false | | |
+| `google` | [codersdk.ChatModelGoogleProviderOptions](#codersdkchatmodelgoogleprovideroptions) | false | | |
+| `openai` | [codersdk.ChatModelOpenAIProviderOptions](#codersdkchatmodelopenaiprovideroptions) | false | | |
+| `openaicompat` | [codersdk.ChatModelOpenAICompatProviderOptions](#codersdkchatmodelopenaicompatprovideroptions) | false | | |
+| `openrouter` | [codersdk.ChatModelOpenRouterProviderOptions](#codersdkchatmodelopenrouterprovideroptions) | false | | |
+| `vercel` | [codersdk.ChatModelVercelProviderOptions](#codersdkchatmodelvercelprovideroptions) | false | | |
+
## codersdk.ChatModelProviderUnavailableReason
```json
@@ -3489,6 +4539,107 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|------------------------------------------------------------|
| `fetch_failed`, `missing_api_key`, `user_api_key_required` |
+## codersdk.ChatModelReasoningEffortConfig
+
+```json
+{
+ "default": "string",
+ "max": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-----------|--------|----------|--------------|-------------|
+| `default` | string | false | | |
+| `max` | string | false | | |
+
+## codersdk.ChatModelReasoningOptions
+
+```json
+{
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|--------------|---------|----------|--------------|-------------|
+| `enabled` | boolean | false | | |
+| `exclude` | boolean | false | | |
+| `max_tokens` | integer | false | | |
+
+## codersdk.ChatModelVercelGatewayProviderOptions
+
+```json
+{
+ "models": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ]
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|----------|-----------------|----------|--------------|-------------|
+| `models` | array of string | false | | |
+| `order` | array of string | false | | |
+
+## codersdk.ChatModelVercelProviderOptions
+
+```json
+{
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "logprobs": true,
+ "parallel_tool_calls": true,
+ "providerOptions": {
+ "models": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ]
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "top_logprobs": 0,
+ "user": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-----------------------|--------------------------------------------------------------------------------------------------|----------|--------------|-------------|
+| `extra_body` | object | false | | |
+| » `[any property]` | any | false | | |
+| `logit_bias` | object | false | | |
+| » `[any property]` | integer | false | | |
+| `logprobs` | boolean | false | | |
+| `parallel_tool_calls` | boolean | false | | |
+| `providerOptions` | [codersdk.ChatModelVercelGatewayProviderOptions](#codersdkchatmodelvercelgatewayprovideroptions) | false | | |
+| `reasoning` | [codersdk.ChatModelReasoningOptions](#codersdkchatmodelreasoningoptions) | false | | |
+| `top_logprobs` | integer | false | | |
+| `user` | string | false | | |
+
## codersdk.ChatPlanMode
```json
@@ -4930,6 +6081,186 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `queued_message` | [codersdk.ChatQueuedMessage](#codersdkchatqueuedmessage) | false | | |
| `warnings` | array of string | false | | |
+## codersdk.CreateChatModelRequest
+
+```json
+{
+ "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819",
+ "compression_threshold": 0,
+ "context_limit": 0,
+ "display_name": "string",
+ "enabled": true,
+ "is_default": true,
+ "model": "string",
+ "model_config": {
+ "frequency_penalty": 0,
+ "max_output_tokens": 0,
+ "openai_config": {
+ "use_responses_api": true
+ },
+ "presence_penalty": 0,
+ "provider_options": {
+ "anthropic": {
+ "allowed_domains": [
+ "string"
+ ],
+ "blocked_domains": [
+ "string"
+ ],
+ "context_1m_enabled": true,
+ "disable_parallel_tool_use": true,
+ "send_reasoning": true,
+ "thinking": {
+ "budget_tokens": 0
+ },
+ "thinking_display": "string",
+ "web_search_enabled": true
+ },
+ "google": {
+ "cached_content": "string",
+ "safety_settings": [
+ {
+ "category": "string",
+ "threshold": "string"
+ }
+ ],
+ "thinking_config": {
+ "include_thoughts": true,
+ "thinking_budget": 0,
+ "thinking_level": "string"
+ },
+ "threshold": "string",
+ "web_search_enabled": true
+ },
+ "openai": {
+ "allowed_domains": [
+ "string"
+ ],
+ "include": [
+ "string"
+ ],
+ "instructions": "string",
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "max_completion_tokens": 0,
+ "max_tool_calls": 0,
+ "metadata": {
+ "property1": null,
+ "property2": null
+ },
+ "parallel_tool_calls": true,
+ "prediction": {
+ "property1": null,
+ "property2": null
+ },
+ "prompt_cache_key": "string",
+ "reasoning_summary": "string",
+ "safety_identifier": "string",
+ "search_context_size": "string",
+ "service_tier": "string",
+ "store": true,
+ "strict_json_schema": true,
+ "structured_outputs": true,
+ "text_verbosity": "string",
+ "top_log_probs": 0,
+ "user": "string",
+ "web_search_enabled": true
+ },
+ "openaicompat": {
+ "user": "string"
+ },
+ "openrouter": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "include_usage": true,
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "parallel_tool_calls": true,
+ "provider": {
+ "allow_fallbacks": true,
+ "data_collection": "string",
+ "ignore": [
+ "string"
+ ],
+ "only": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ],
+ "quantizations": [
+ "string"
+ ],
+ "require_parameters": true,
+ "sort": "string"
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "user": "string"
+ },
+ "vercel": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "logprobs": true,
+ "parallel_tool_calls": true,
+ "providerOptions": {
+ "models": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ]
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "top_logprobs": 0,
+ "user": "string"
+ }
+ },
+ "reasoning_effort": {
+ "default": "string",
+ "max": "string"
+ },
+ "temperature": 0,
+ "top_k": 0,
+ "top_p": 0
+ }
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-------------------------|--------------------------------------------------------------|----------|--------------|-------------|
+| `ai_provider_id` | string | false | | |
+| `compression_threshold` | integer | false | | |
+| `context_limit` | integer | false | | |
+| `display_name` | string | false | | |
+| `enabled` | boolean | false | | |
+| `is_default` | boolean | false | | |
+| `model` | string | false | | |
+| `model_config` | [codersdk.ChatModelCallConfig](#codersdkchatmodelcallconfig) | false | | |
+
## codersdk.CreateChatRequest
```json
@@ -10051,6 +11382,204 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| `name` | string | false | | |
| `updated_at` | string | true | | |
+## codersdk.OrganizationChatModelsResponse
+
+```json
+{
+ "models": [
+ {
+ "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819",
+ "compression_threshold": 0,
+ "context_limit": 0,
+ "created_at": "2019-08-24T14:15:22Z",
+ "display_name": "string",
+ "enabled": true,
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "is_default": true,
+ "model": "string",
+ "model_config": {
+ "frequency_penalty": 0,
+ "max_output_tokens": 0,
+ "openai_config": {
+ "use_responses_api": true
+ },
+ "presence_penalty": 0,
+ "provider_options": {
+ "anthropic": {
+ "allowed_domains": [
+ "string"
+ ],
+ "blocked_domains": [
+ "string"
+ ],
+ "context_1m_enabled": true,
+ "disable_parallel_tool_use": true,
+ "send_reasoning": true,
+ "thinking": {
+ "budget_tokens": 0
+ },
+ "thinking_display": "string",
+ "web_search_enabled": true
+ },
+ "google": {
+ "cached_content": "string",
+ "safety_settings": [
+ {
+ "category": "string",
+ "threshold": "string"
+ }
+ ],
+ "thinking_config": {
+ "include_thoughts": true,
+ "thinking_budget": 0,
+ "thinking_level": "string"
+ },
+ "threshold": "string",
+ "web_search_enabled": true
+ },
+ "openai": {
+ "allowed_domains": [
+ "string"
+ ],
+ "include": [
+ "string"
+ ],
+ "instructions": "string",
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "max_completion_tokens": 0,
+ "max_tool_calls": 0,
+ "metadata": {
+ "property1": null,
+ "property2": null
+ },
+ "parallel_tool_calls": true,
+ "prediction": {
+ "property1": null,
+ "property2": null
+ },
+ "prompt_cache_key": "string",
+ "reasoning_summary": "string",
+ "safety_identifier": "string",
+ "search_context_size": "string",
+ "service_tier": "string",
+ "store": true,
+ "strict_json_schema": true,
+ "structured_outputs": true,
+ "text_verbosity": "string",
+ "top_log_probs": 0,
+ "user": "string",
+ "web_search_enabled": true
+ },
+ "openaicompat": {
+ "user": "string"
+ },
+ "openrouter": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "include_usage": true,
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "parallel_tool_calls": true,
+ "provider": {
+ "allow_fallbacks": true,
+ "data_collection": "string",
+ "ignore": [
+ "string"
+ ],
+ "only": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ],
+ "quantizations": [
+ "string"
+ ],
+ "require_parameters": true,
+ "sort": "string"
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "user": "string"
+ },
+ "vercel": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "logprobs": true,
+ "parallel_tool_calls": true,
+ "providerOptions": {
+ "models": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ]
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "top_logprobs": 0,
+ "user": "string"
+ }
+ },
+ "reasoning_effort": {
+ "default": "string",
+ "max": "string"
+ },
+ "temperature": 0,
+ "top_k": 0,
+ "top_p": 0
+ },
+ "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
+ "reasoning_efforts": [
+ "string"
+ ],
+ "updated_at": "2019-08-24T14:15:22Z"
+ }
+ ],
+ "providers": [
+ {
+ "allow_user_api_key": true,
+ "display_name": "string",
+ "enabled": true,
+ "has_api_key": true,
+ "has_effective_api_key": true,
+ "has_user_api_key": true,
+ "icon": "string",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "type": "string"
+ }
+ ]
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-------------|---------------------------------------------------------------------------------------|----------|--------------|-------------|
+| `models` | array of [codersdk.ChatModel](#codersdkchatmodel) | false | | |
+| `providers` | array of [codersdk.ChatModelProviderDescriptor](#codersdkchatmodelproviderdescriptor) | false | | |
+
## codersdk.OrganizationGroupAISpend
```json
@@ -13670,6 +15199,210 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
| `user_roles` | object | false | | |
| » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | |
+## codersdk.UpdateChatModelACLRequest
+
+```json
+{
+ "group_roles": {
+ "property1": "read",
+ "property2": "read"
+ },
+ "user_roles": {
+ "property1": "read",
+ "property2": "read"
+ }
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|--------------------|----------------------------------------|----------|--------------|-------------|
+| `group_roles` | object | false | | |
+| » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | |
+| `user_roles` | object | false | | |
+| » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | |
+
+## codersdk.UpdateChatModelRequest
+
+```json
+{
+ "ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819",
+ "compression_threshold": 0,
+ "context_limit": 0,
+ "display_name": "string",
+ "enabled": true,
+ "is_default": true,
+ "model": "string",
+ "model_config": {
+ "frequency_penalty": 0,
+ "max_output_tokens": 0,
+ "openai_config": {
+ "use_responses_api": true
+ },
+ "presence_penalty": 0,
+ "provider_options": {
+ "anthropic": {
+ "allowed_domains": [
+ "string"
+ ],
+ "blocked_domains": [
+ "string"
+ ],
+ "context_1m_enabled": true,
+ "disable_parallel_tool_use": true,
+ "send_reasoning": true,
+ "thinking": {
+ "budget_tokens": 0
+ },
+ "thinking_display": "string",
+ "web_search_enabled": true
+ },
+ "google": {
+ "cached_content": "string",
+ "safety_settings": [
+ {
+ "category": "string",
+ "threshold": "string"
+ }
+ ],
+ "thinking_config": {
+ "include_thoughts": true,
+ "thinking_budget": 0,
+ "thinking_level": "string"
+ },
+ "threshold": "string",
+ "web_search_enabled": true
+ },
+ "openai": {
+ "allowed_domains": [
+ "string"
+ ],
+ "include": [
+ "string"
+ ],
+ "instructions": "string",
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "max_completion_tokens": 0,
+ "max_tool_calls": 0,
+ "metadata": {
+ "property1": null,
+ "property2": null
+ },
+ "parallel_tool_calls": true,
+ "prediction": {
+ "property1": null,
+ "property2": null
+ },
+ "prompt_cache_key": "string",
+ "reasoning_summary": "string",
+ "safety_identifier": "string",
+ "search_context_size": "string",
+ "service_tier": "string",
+ "store": true,
+ "strict_json_schema": true,
+ "structured_outputs": true,
+ "text_verbosity": "string",
+ "top_log_probs": 0,
+ "user": "string",
+ "web_search_enabled": true
+ },
+ "openaicompat": {
+ "user": "string"
+ },
+ "openrouter": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "include_usage": true,
+ "log_probs": true,
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "parallel_tool_calls": true,
+ "provider": {
+ "allow_fallbacks": true,
+ "data_collection": "string",
+ "ignore": [
+ "string"
+ ],
+ "only": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ],
+ "quantizations": [
+ "string"
+ ],
+ "require_parameters": true,
+ "sort": "string"
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "user": "string"
+ },
+ "vercel": {
+ "extra_body": {
+ "property1": null,
+ "property2": null
+ },
+ "logit_bias": {
+ "property1": 0,
+ "property2": 0
+ },
+ "logprobs": true,
+ "parallel_tool_calls": true,
+ "providerOptions": {
+ "models": [
+ "string"
+ ],
+ "order": [
+ "string"
+ ]
+ },
+ "reasoning": {
+ "enabled": true,
+ "exclude": true,
+ "max_tokens": 0
+ },
+ "top_logprobs": 0,
+ "user": "string"
+ }
+ },
+ "reasoning_effort": {
+ "default": "string",
+ "max": "string"
+ },
+ "temperature": 0,
+ "top_k": 0,
+ "top_p": 0
+ }
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-------------------------|--------------------------------------------------------------|----------|--------------|-------------|
+| `ai_provider_id` | string | false | | |
+| `compression_threshold` | integer | false | | |
+| `context_limit` | integer | false | | |
+| `display_name` | string | false | | |
+| `enabled` | boolean | false | | |
+| `is_default` | boolean | false | | |
+| `model` | string | false | | |
+| `model_config` | [codersdk.ChatModelCallConfig](#codersdkchatmodelcallconfig) | false | | |
+
## codersdk.UpdateChatRequest
```json
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index 4303eaba86036..3d881231addf3 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -35,6 +35,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{
"AuditableGroupAIBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"AuditableUserAIBudgetOverride": {codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted.
+ "ChatModelConfig": {codersdk.AuditActionWrite},
"MCPServerConfig": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
@@ -502,6 +503,26 @@ var auditableResourcesTypes = map[any]map[string]Action{
"requires_action_deadline_at": ActionIgnore, // Internal pending-action deadline.
"compaction_requested_at": ActionIgnore, // Internal one-shot manual compaction signal.
},
+ &database.ChatModelConfig{}: {
+ "id": ActionIgnore, // Conveyed by resource_id.
+ "model": ActionTrack,
+ "display_name": ActionTrack,
+ "created_by": ActionTrack,
+ "updated_by": ActionTrack,
+ "enabled": ActionTrack,
+ "is_default": ActionTrack,
+ "deleted": ActionTrack,
+ "deleted_at": ActionIgnore,
+ "created_at": ActionIgnore,
+ "updated_at": ActionIgnore,
+ "context_limit": ActionTrack,
+ "compression_threshold": ActionTrack,
+ "options": ActionTrack,
+ "ai_provider_id": ActionTrack,
+ "organization_id": ActionIgnore,
+ "group_acl": ActionTrack,
+ "user_acl": ActionTrack,
+ },
&database.MCPServerConfig{}: {
"id": ActionIgnore, // Conveyed by resource_id, not useful in a diff.
"display_name": ActionTrack,
diff --git a/enterprise/coderd/exp_chats_test.go b/enterprise/coderd/exp_chats_test.go
index e5ea072f272ee..6e1b301a4cb08 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 4649f16b6e675..bb75e93af35e1 100644
--- a/scaletest/chat/client.go
+++ b/scaletest/chat/client.go
@@ -22,8 +22,8 @@ type chatClient interface {
var _ chatClient = (*codersdk.ExperimentalClient)(nil)
type chatModelClient interface {
- ChatModels(ctx context.Context) ([]codersdk.ChatModel, error)
- CreateChatModel(ctx context.Context, req codersdk.CreateChatModelRequest) (codersdk.ChatModel, error)
+ ChatModels(ctx context.Context, organizationID uuid.UUID) (codersdk.OrganizationChatModelsResponse, error)
+ CreateChatModel(ctx context.Context, organizationID uuid.UUID, req codersdk.CreateChatModelRequest) (codersdk.ChatModel, error)
}
var _ chatModelClient = (*codersdk.ExperimentalClient)(nil)
diff --git a/scaletest/chat/provider.go b/scaletest/chat/provider.go
index c0faf98a9f7c3..929cecd34c145 100644
--- a/scaletest/chat/provider.go
+++ b/scaletest/chat/provider.go
@@ -38,17 +38,18 @@ const (
scaletestAIProviderActionReused scaletestAIProviderAction = "reused"
)
-// EnsureScaletestChatModel bootstraps the shared AI provider and ChatModel
-// used by chat scaletests. When the provider was created or updated, it waits
-// for propagationWait so every coderd replica's cached provider expires.
-func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logger slog.Logger, llmMockURL string, propagationWait time.Duration) (uuid.UUID, error) {
+// EnsureScaletestChatModel bootstraps the shared AI provider used by chat
+// scaletests. It returns a function that creates or reuses the ChatModel for
+// each organization. When the provider changes, it waits for cached provider
+// configurations to expire before chats start.
+func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logger slog.Logger, llmMockURL string, propagationWait time.Duration) (func(organizationID uuid.UUID) (uuid.UUID, error), error) {
expClient := codersdk.NewExperimentalClient(client)
logger.Info(ctx, "bootstrapping mock LLM provider", slog.F("llm_mock_url", llmMockURL))
provider, providerAction, err := ensureScaletestAIProvider(ctx, expClient, llmMockURL)
if err != nil {
- return uuid.Nil, err
+ return nil, err
}
switch providerAction {
@@ -71,11 +72,6 @@ func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logg
)
}
- modelID, err := ensureScaletestChatModelRecord(ctx, expClient, logger, provider)
- if err != nil {
- return uuid.Nil, err
- }
-
if providerAction != scaletestAIProviderActionReused && propagationWait > 0 {
logger.Info(ctx, "waiting for mock LLM provider propagation",
slog.F("provider_name", provider.Name),
@@ -83,38 +79,40 @@ func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logg
)
select {
case <-ctx.Done():
- return uuid.Nil, ctx.Err()
+ return nil, ctx.Err()
case <-time.After(propagationWait):
}
}
- return modelID, nil
+ return func(organizationID uuid.UUID) (uuid.UUID, error) {
+ return ensureScaletestChatModelRecord(ctx, expClient, logger, provider, organizationID)
+ }, nil
}
-func ensureScaletestChatModelRecord(ctx context.Context, client chatModelClient, logger slog.Logger, provider codersdk.AIProvider) (uuid.UUID, error) {
- models, err := client.ChatModels(ctx)
+func ensureScaletestChatModelRecord(ctx context.Context, client chatModelClient, logger slog.Logger, provider codersdk.AIProvider, organizationID uuid.UUID) (uuid.UUID, error) {
+ resp, err := client.ChatModels(ctx, organizationID)
if err != nil {
return uuid.Nil, xerrors.Errorf("list chat models: %w", err)
}
- for i := range models {
- matchesProvider := models[i].AIProviderID == provider.ID
- matchesModel := models[i].Model == scaletestModelName
+ for i := range resp.Models {
+ matchesProvider := resp.Models[i].AIProviderID == provider.ID
+ matchesModel := resp.Models[i].Model == scaletestModelName
if !matchesProvider || !matchesModel {
continue
}
- if !models[i].Enabled {
- return uuid.Nil, xerrors.Errorf("existing scaletest ChatModel %s is disabled; re-enable or delete it before running scaletests", models[i].ID)
+ if !resp.Models[i].Enabled {
+ return uuid.Nil, xerrors.Errorf("existing scaletest ChatModel %s is disabled; re-enable or delete it before running scaletests", resp.Models[i].ID)
}
- modelID := models[i].ID
- logger.Info(ctx, "reusing scaletest ChatModel", slog.F("model_id", modelID))
+ modelID := resp.Models[i].ID
+ logger.Info(ctx, "reusing scaletest ChatModel", slog.F("model_id", modelID), slog.F("organization_id", organizationID))
return modelID, nil
}
enabled := true
isDefault := false
contextLimit := scaletestModelContextLimit
- created, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
+ created, err := client.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: scaletestModelName,
DisplayName: scaletestModelDisplayName,
@@ -125,7 +123,7 @@ func ensureScaletestChatModelRecord(ctx context.Context, client chatModelClient,
if err != nil {
return uuid.Nil, xerrors.Errorf("create scaletest ChatModel: %w", err)
}
- logger.Info(ctx, "created scaletest ChatModel", slog.F("model_id", created.ID))
+ logger.Info(ctx, "created scaletest ChatModel", slog.F("model_id", created.ID), slog.F("organization_id", organizationID))
return created.ID, nil
}
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 6e797f677c407..0b426ba5a84cb 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -2830,11 +2830,11 @@ export interface ChatMessagesResponse {
// From codersdk/chats.go
/**
- * ChatModel is an admin-managed model record for an organization.
- * It is not a discovery catalog entry. See ChatModelCatalogEntry.
+ * ChatModel is an org-scoped model configuration.
*/
export interface ChatModel {
readonly id: string;
+ readonly organization_id: string;
readonly ai_provider_id: string;
readonly model: string;
readonly display_name: string;
@@ -2852,6 +2852,16 @@ export interface ChatModel {
readonly updated_at: string;
}
+// From codersdk/chats.go
+/**
+ * ChatModelACL is the access control list for an organization-scoped chat
+ * model. Each principal is mapped to its effective model role.
+ */
+export interface ChatModelACL {
+ readonly user_roles: Record;
+ readonly group_roles: Record;
+}
+
// From codersdk/chats.go
/**
* ChatModelAnthropicProviderOptions configures Anthropic provider behavior.
@@ -2877,10 +2887,14 @@ export interface ChatModelAnthropicThinkingOptions {
// From codersdk/chats.go
/**
- * ChatModelAvailabilityResponse groups the discovery catalog by provider and
- * reports provider availability.
+ * ChatModelAvailabilityResponse is the catalog returned from chat model discovery.
*/
export interface ChatModelAvailabilityResponse {
+ /**
+ * Models contains the effective runtime model configs for the requested
+ * organization. Each config belongs to that organization.
+ */
+ readonly models?: readonly ChatModel[];
readonly providers: readonly ChatModelProvider[];
/**
* UnsupportedProviders lists configured providers the Agents harness
@@ -2907,9 +2921,9 @@ export interface ChatModelCallConfig {
// From codersdk/chats.go
/**
- * ChatModelCatalogEntry is a discovery catalog entry for end users.
- * Its ID is a synthetic provider:model value, not the UUID in ChatModel.ID.
- * It is not an admin-managed record. See ChatModel.
+ * ChatModelCatalogEntry is the runtime catalog view of a model. Its ID is the
+ * synthetic catalog identity `provider:model` built by canonicalModelID, not
+ * the chat_model_configs row UUID that ChatModel.ID carries.
*/
export interface ChatModelCatalogEntry {
readonly id: string;
@@ -3065,6 +3079,26 @@ export interface ChatModelProvider {
readonly models: readonly ChatModelCatalogEntry[];
}
+// From codersdk/chats.go
+/**
+ * ChatModelProviderDescriptor is the redacted view of an AI provider carried
+ * on the org model collection response. It carries only the capability
+ * metadata the Models UI needs; key material, base URLs, and headers are
+ * never exposed. The fields mirror what /api/experimental/chats/models
+ * already discloses to any authenticated caller.
+ */
+export interface ChatModelProviderDescriptor {
+ readonly id: string;
+ readonly type: string;
+ readonly display_name: string;
+ readonly icon: string;
+ readonly enabled: boolean;
+ readonly has_api_key: boolean;
+ readonly has_user_api_key: boolean;
+ readonly has_effective_api_key: boolean;
+ readonly allow_user_api_key: boolean;
+}
+
// From codersdk/chats.go
/**
* ChatModelProviderOptions contains typed provider-specific options.
@@ -7019,6 +7053,17 @@ export interface Organization extends MinimalOrganization {
readonly default_org_member_roles: readonly string[];
}
+// From codersdk/chats.go
+/**
+ * OrganizationChatModelsResponse is the org chat model config collection:
+ * the caller-readable configs plus the redacted provider descriptors the
+ * authoring page needs.
+ */
+export interface OrganizationChatModelsResponse {
+ readonly models: readonly ChatModel[];
+ readonly providers: readonly ChatModelProviderDescriptor[];
+}
+
// From codersdk/aibridge.go
/**
* OrganizationGroupAISpend is the current AI spend snapshot for a group
@@ -9780,6 +9825,17 @@ export interface UpdateChatDebugRetentionDaysRequest {
readonly debug_retention_days: number;
}
+// From codersdk/chats.go
+/**
+ * UpdateChatModelACLRequest is a sparse update of a chat model ACL. Only the
+ * listed principals change. ChatRoleDeleted removes an entry, while an omitted
+ * map or principal is unchanged.
+ */
+export interface UpdateChatModelACLRequest {
+ readonly user_roles?: Record;
+ readonly group_roles?: Record;
+}
+
// From codersdk/chats.go
/**
* UpdateChatModelOverrideRequest is the request body for updating the chat
diff --git a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts
index 3c1e1111862ff..37853db3a70bd 100644
--- a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts
+++ b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts
@@ -27,6 +27,7 @@ const MockAnthropicProviderConfig: ChatProviderConfig = {
};
export const mockGPT5: ChatModel = {
+ organization_id: "00000000-0000-0000-0000-000000000000",
id: "model-gpt5",
ai_provider_id: "prov-openai",
model: "gpt-5",
diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx
index 53baea63d6dcc..f1bf39bba26bc 100644
--- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx
+++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx
@@ -61,6 +61,7 @@ const defaultModelID = "model-config-1";
const defaultModels: TypesGen.ChatModel[] = [
{
+ organization_id: "00000000-0000-0000-0000-000000000000",
id: defaultModelID,
ai_provider_id: "provider-openai",
model: "gpt-4o",
@@ -366,6 +367,7 @@ const meta: Meta = {
});
spyOn(API.experimental, "getChatModels").mockResolvedValue([
{
+ organization_id: "00000000-0000-0000-0000-000000000000",
id: defaultModelID,
ai_provider_id: "provider-openai",
model: "gpt-4o",
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx
index af2787cde3e6e..8dfa87d3fa519 100644
--- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx
@@ -51,6 +51,7 @@ const defaultModelOptions: ModelSelectorOption[] = [
const defaultModels: TypesGen.ChatModel[] = [
{
+ organization_id: "00000000-0000-0000-0000-000000000000",
id: "config-openai-gpt-4o",
ai_provider_id: "prov-1",
model: "gpt-4o",
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx
index 31c741135b1a4..3562fe58702a2 100644
--- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx
+++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx
@@ -582,6 +582,7 @@ describe("ChatsSidebar model display names", () => {
];
const models: TypesGen.ChatModel[] = [
{
+ organization_id: "00000000-0000-0000-0000-000000000000",
id: "config-fast",
ai_provider_id: "prov-openai",
model: "gpt-4o",
@@ -594,6 +595,7 @@ describe("ChatsSidebar model display names", () => {
updated_at: oneWeekAgo,
},
{
+ organization_id: "00000000-0000-0000-0000-000000000000",
id: "config-quality",
ai_provider_id: "prov-openai",
model: "gpt-4o",
diff --git a/site/src/testHelpers/chatModels.ts b/site/src/testHelpers/chatModels.ts
index 2aea7ea5c2109..7efde31ca8ac6 100644
--- a/site/src/testHelpers/chatModels.ts
+++ b/site/src/testHelpers/chatModels.ts
@@ -7,6 +7,7 @@ import type {
import { MOCK_TIMESTAMP } from "./chatEntities";
export const MockChatModel: ChatModel = {
+ organization_id: "00000000-0000-0000-0000-000000000000",
id: "model-1",
ai_provider_id: "provider-1",
model: "gpt-5",