feat!: enforce organization scope for chat models (#27959)

## Summary

Move chat model management and runtime availability to
organization-scoped API routes. The API now identifies the organization
in every model-management operation, applies chat model RBAC and ACLs
within that organization, and returns only models the caller can use
there.

Depends on #27958.

## Problem

Chat model configurations now belong to organizations, but the existing
experimental management and discovery APIs are deployment-shaped. Their
routes do not identify an organization, SDK methods do not accept an
organization ID, and item operations address a model configuration by ID
alone.

That contract cannot safely expose organization-owned model
configurations. It also does not provide the organization-specific
provider information, authorization behavior, ACL management, or
recovery guidance needed by the settings and chat clients later in the
stack.

## Fix

Add organization-scoped chat model endpoints under:

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

The new API provides:

- Organization-scoped model listing and creation.
- Model get, update, and delete operations scoped by both organization
and model ID.
- Model ACL read and update operations.
- Organization-scoped runtime availability and provider status.
- Redacted provider descriptors for model-management clients.
- Chat model RBAC and row ACL enforcement for writes, runtime discovery,
and Chatd model selection.
- Recovery guidance when a chat's organization has no usable local
model.

Historical chats can retain a model reference owned by another
organization, but runtime selection uses an authorized local default
when one is available. Foreign, inaccessible, disabled, and otherwise
unusable models are not exposed as usable selections for the
organization.

The existing collection and availability routes remain as
default-organization compatibility endpoints in this layer:

```text
GET  /api/experimental/chats/model-configs
POST /api/experimental/chats/model-configs
GET  /api/experimental/chats/models
```

These compatibility routes do not provide access to models in
non-default organizations. The previous unscoped item update and delete
routes are removed because item operations must include the owning
organization.

## Breaking change

This changes the experimental HTTP API and Go SDK contracts for chat
model management.

Clients must migrate model-management and availability requests to the
organization-scoped routes and provide an organization identifier. In
the Go SDK, the following methods now require an organization ID:

- `ChatModelAvailability`
- `ChatModels`
- `CreateChatModel`
- `UpdateChatModel`
- `DeleteChatModel`

`ChatModels` also changes its return value from `[]ChatModel` to
`OrganizationChatModelsResponse`, which includes the organization models
and the redacted provider descriptors required by management clients.
New item and ACL methods likewise require both the organization ID and
model ID.

The default-organization compatibility routes reduce the immediate
impact for collection and availability callers, but they do not preserve
the previous item API or SDK method signatures. Generated clients and
direct consumers of the experimental API must be updated before adopting
this change.

_This pull request description was generated by Coder Agents._
This commit is contained in:
Ethan
2026-08-24 11:20:25 +01:00
committed by GitHub
parent 9bd8899359
commit aefc761fcf
58 changed files with 7995 additions and 2558 deletions
+947 -26
View File
File diff suppressed because it is too large Load Diff
+913 -22
View File
@@ -425,28 +425,6 @@
}
}
},
"/api/experimental/chats/models": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "List chat models",
"operationId": "list-chat-models",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatModelAvailabilityResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/experimental/chats/watch": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -1232,6 +1210,319 @@
}
}
},
"/api/experimental/organizations/{organization}/chats/models": {
"get": {
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "List AI models and provider descriptors in an organization",
"operationId": "list-ai-models-by-organization",
"parameters": [
{
"type": "string",
"description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OrganizationChatModelsResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"post": {
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "Create an AI model in an organization",
"operationId": "create-ai-model",
"parameters": [
{
"type": "string",
"description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"description": "Model",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.CreateChatModelRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/codersdk.ChatModel"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/organizations/{organization}/chats/models/available": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "List available chat models in an organization",
"operationId": "list-chat-model-availability",
"parameters": [
{
"type": "string",
"description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatModelAvailabilityResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/experimental/organizations/{organization}/chats/models/{model}": {
"get": {
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "Get an AI model",
"operationId": "get-ai-model",
"parameters": [
{
"type": "string",
"description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Model ID",
"name": "model",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatModel"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"delete": {
"tags": ["Chats"],
"summary": "Delete an AI model",
"operationId": "delete-ai-model",
"parameters": [
{
"type": "string",
"description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Model ID",
"name": "model",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"patch": {
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "Update an AI model",
"operationId": "update-ai-model",
"parameters": [
{
"type": "string",
"description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Model ID",
"name": "model",
"in": "path",
"required": true
},
{
"description": "Model updates",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateChatModelRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatModel"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/organizations/{organization}/chats/models/{model}/acl": {
"get": {
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "Get an AI model ACL",
"operationId": "get-ai-model-acl",
"parameters": [
{
"type": "string",
"description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "Model ID",
"name": "model",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatModelACL"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"patch": {
"consumes": ["application/json"],
"tags": ["Chats"],
"summary": "Update an AI model ACL",
"operationId": "update-ai-model-acl",
"parameters": [
{
"type": "string",
"description": "Organization name or ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "Model ID",
"name": "model",
"in": "path",
"required": true
},
{
"description": "Sparse model ACL update",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateChatModelACLRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/organizations/{organization}/mcp-servers": {
"get": {
"produces": ["application/json"],
@@ -16809,9 +17100,129 @@
}
}
},
"codersdk.ChatModel": {
"type": "object",
"properties": {
"ai_provider_id": {
"type": "string",
"format": "uuid"
},
"compression_threshold": {
"type": "integer"
},
"context_limit": {
"type": "integer"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"display_name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
},
"is_default": {
"type": "boolean"
},
"model": {
"type": "string"
},
"model_config": {
"$ref": "#/definitions/codersdk.ChatModelCallConfig"
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"reasoning_efforts": {
"description": "ReasoningEfforts lists selectable reasoning effort values through\nthe model's configured maximum.",
"type": "array",
"items": {
"type": "string"
}
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.ChatModelACL": {
"type": "object",
"properties": {
"group_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.ChatRole"
}
},
"user_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.ChatRole"
}
}
}
},
"codersdk.ChatModelAnthropicProviderOptions": {
"type": "object",
"properties": {
"allowed_domains": {
"type": "array",
"items": {
"type": "string"
}
},
"blocked_domains": {
"type": "array",
"items": {
"type": "string"
}
},
"context_1m_enabled": {
"type": "boolean"
},
"disable_parallel_tool_use": {
"type": "boolean"
},
"send_reasoning": {
"type": "boolean"
},
"thinking": {
"$ref": "#/definitions/codersdk.ChatModelAnthropicThinkingOptions"
},
"thinking_display": {
"type": "string"
},
"web_search_enabled": {
"type": "boolean"
}
}
},
"codersdk.ChatModelAnthropicThinkingOptions": {
"type": "object",
"properties": {
"budget_tokens": {
"type": "integer"
}
}
},
"codersdk.ChatModelAvailabilityResponse": {
"type": "object",
"properties": {
"models": {
"description": "Models contains the effective runtime model configs for the requested\norganization. Each config belongs to that organization.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatModel"
}
},
"providers": {
"type": "array",
"items": {
@@ -16827,6 +17238,38 @@
}
}
},
"codersdk.ChatModelCallConfig": {
"type": "object",
"properties": {
"frequency_penalty": {
"type": "number"
},
"max_output_tokens": {
"type": "integer"
},
"openai_config": {
"$ref": "#/definitions/codersdk.ChatModelOpenAIConfig"
},
"presence_penalty": {
"type": "number"
},
"provider_options": {
"$ref": "#/definitions/codersdk.ChatModelProviderOptions"
},
"reasoning_effort": {
"$ref": "#/definitions/codersdk.ChatModelReasoningEffortConfig"
},
"temperature": {
"type": "number"
},
"top_k": {
"type": "integer"
},
"top_p": {
"type": "number"
}
}
},
"codersdk.ChatModelCatalogEntry": {
"type": "object",
"properties": {
@@ -16844,6 +17287,228 @@
}
}
},
"codersdk.ChatModelGoogleProviderOptions": {
"type": "object",
"properties": {
"cached_content": {
"type": "string"
},
"safety_settings": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatModelGoogleSafetySetting"
}
},
"thinking_config": {
"$ref": "#/definitions/codersdk.ChatModelGoogleThinkingConfig"
},
"threshold": {
"type": "string"
},
"web_search_enabled": {
"type": "boolean"
}
}
},
"codersdk.ChatModelGoogleSafetySetting": {
"type": "object",
"properties": {
"category": {
"type": "string"
},
"threshold": {
"type": "string"
}
}
},
"codersdk.ChatModelGoogleThinkingConfig": {
"type": "object",
"properties": {
"include_thoughts": {
"type": "boolean"
},
"thinking_budget": {
"type": "integer"
},
"thinking_level": {
"type": "string"
}
}
},
"codersdk.ChatModelOpenAICompatProviderOptions": {
"type": "object",
"properties": {
"user": {
"type": "string"
}
}
},
"codersdk.ChatModelOpenAIConfig": {
"type": "object",
"properties": {
"use_responses_api": {
"type": "boolean"
}
}
},
"codersdk.ChatModelOpenAIProviderOptions": {
"type": "object",
"properties": {
"allowed_domains": {
"type": "array",
"items": {
"type": "string"
}
},
"include": {
"type": "array",
"items": {
"type": "string"
}
},
"instructions": {
"type": "string"
},
"log_probs": {
"type": "boolean"
},
"logit_bias": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
}
},
"max_completion_tokens": {
"type": "integer"
},
"max_tool_calls": {
"type": "integer"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"parallel_tool_calls": {
"type": "boolean"
},
"prediction": {
"type": "object",
"additionalProperties": {}
},
"prompt_cache_key": {
"type": "string"
},
"reasoning_summary": {
"type": "string"
},
"safety_identifier": {
"type": "string"
},
"search_context_size": {
"type": "string"
},
"service_tier": {
"type": "string"
},
"store": {
"type": "boolean"
},
"strict_json_schema": {
"type": "boolean"
},
"structured_outputs": {
"type": "boolean"
},
"text_verbosity": {
"type": "string"
},
"top_log_probs": {
"type": "integer"
},
"user": {
"type": "string"
},
"web_search_enabled": {
"type": "boolean"
}
}
},
"codersdk.ChatModelOpenRouterProvider": {
"type": "object",
"properties": {
"allow_fallbacks": {
"type": "boolean"
},
"data_collection": {
"type": "string"
},
"ignore": {
"type": "array",
"items": {
"type": "string"
}
},
"only": {
"type": "array",
"items": {
"type": "string"
}
},
"order": {
"type": "array",
"items": {
"type": "string"
}
},
"quantizations": {
"type": "array",
"items": {
"type": "string"
}
},
"require_parameters": {
"type": "boolean"
},
"sort": {
"type": "string"
}
}
},
"codersdk.ChatModelOpenRouterProviderOptions": {
"type": "object",
"properties": {
"extra_body": {
"type": "object",
"additionalProperties": {}
},
"include_usage": {
"type": "boolean"
},
"log_probs": {
"type": "boolean"
},
"logit_bias": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
}
},
"parallel_tool_calls": {
"type": "boolean"
},
"provider": {
"$ref": "#/definitions/codersdk.ChatModelOpenRouterProvider"
},
"reasoning": {
"$ref": "#/definitions/codersdk.ChatModelReasoningOptions"
},
"user": {
"type": "string"
}
}
},
"codersdk.ChatModelProvider": {
"type": "object",
"properties": {
@@ -16864,6 +17529,62 @@
}
}
},
"codersdk.ChatModelProviderDescriptor": {
"type": "object",
"properties": {
"allow_user_api_key": {
"type": "boolean"
},
"display_name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"has_api_key": {
"type": "boolean"
},
"has_effective_api_key": {
"type": "boolean"
},
"has_user_api_key": {
"type": "boolean"
},
"icon": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"type": {
"type": "string"
}
}
},
"codersdk.ChatModelProviderOptions": {
"type": "object",
"properties": {
"anthropic": {
"$ref": "#/definitions/codersdk.ChatModelAnthropicProviderOptions"
},
"google": {
"$ref": "#/definitions/codersdk.ChatModelGoogleProviderOptions"
},
"openai": {
"$ref": "#/definitions/codersdk.ChatModelOpenAIProviderOptions"
},
"openaicompat": {
"$ref": "#/definitions/codersdk.ChatModelOpenAICompatProviderOptions"
},
"openrouter": {
"$ref": "#/definitions/codersdk.ChatModelOpenRouterProviderOptions"
},
"vercel": {
"$ref": "#/definitions/codersdk.ChatModelVercelProviderOptions"
}
}
},
"codersdk.ChatModelProviderUnavailableReason": {
"type": "string",
"enum": ["missing_api_key", "fetch_failed", "user_api_key_required"],
@@ -16873,6 +17594,82 @@
"ChatModelProviderUnavailableReasonUserAPIKeyRequired"
]
},
"codersdk.ChatModelReasoningEffortConfig": {
"type": "object",
"properties": {
"default": {
"type": "string"
},
"max": {
"type": "string"
}
}
},
"codersdk.ChatModelReasoningOptions": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"exclude": {
"type": "boolean"
},
"max_tokens": {
"type": "integer"
}
}
},
"codersdk.ChatModelVercelGatewayProviderOptions": {
"type": "object",
"properties": {
"models": {
"type": "array",
"items": {
"type": "string"
}
},
"order": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"codersdk.ChatModelVercelProviderOptions": {
"type": "object",
"properties": {
"extra_body": {
"type": "object",
"additionalProperties": {}
},
"logit_bias": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
}
},
"logprobs": {
"type": "boolean"
},
"parallel_tool_calls": {
"type": "boolean"
},
"providerOptions": {
"$ref": "#/definitions/codersdk.ChatModelVercelGatewayProviderOptions"
},
"reasoning": {
"$ref": "#/definitions/codersdk.ChatModelReasoningOptions"
},
"top_logprobs": {
"type": "integer"
},
"user": {
"type": "string"
}
}
},
"codersdk.ChatPlanMode": {
"type": "string",
"enum": ["plan"],
@@ -17497,6 +18294,36 @@
}
}
},
"codersdk.CreateChatModelRequest": {
"type": "object",
"properties": {
"ai_provider_id": {
"type": "string",
"format": "uuid"
},
"compression_threshold": {
"type": "integer"
},
"context_limit": {
"type": "integer"
},
"display_name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"is_default": {
"type": "boolean"
},
"model": {
"type": "string"
},
"model_config": {
"$ref": "#/definitions/codersdk.ChatModelCallConfig"
}
}
},
"codersdk.CreateChatRequest": {
"type": "object",
"properties": {
@@ -21246,6 +22073,23 @@
}
}
},
"codersdk.OrganizationChatModelsResponse": {
"type": "object",
"properties": {
"models": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatModel"
}
},
"providers": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatModelProviderDescriptor"
}
}
}
},
"codersdk.OrganizationGroupAISpend": {
"type": "object",
"properties": {
@@ -24495,6 +25339,53 @@
}
}
},
"codersdk.UpdateChatModelACLRequest": {
"type": "object",
"properties": {
"group_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.ChatRole"
}
},
"user_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.ChatRole"
}
}
}
},
"codersdk.UpdateChatModelRequest": {
"type": "object",
"properties": {
"ai_provider_id": {
"type": "string",
"format": "uuid"
},
"compression_threshold": {
"type": "integer"
},
"context_limit": {
"type": "integer"
},
"display_name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"is_default": {
"type": "boolean"
},
"model": {
"type": "string"
},
"model_config": {
"$ref": "#/definitions/codersdk.ChatModelCallConfig"
}
}
},
"codersdk.UpdateChatRequest": {
"type": "object",
"properties": {
+1
View File
@@ -39,6 +39,7 @@ type Auditable interface {
database.AIProviderKey |
database.AIGatewayKey |
database.Chat |
database.ChatModelConfig |
database.MCPServerConfig |
database.AuditableGroupAIBudget |
database.AuditableUserAIBudgetOverride |
+8
View File
@@ -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
+47 -10
View File
@@ -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) {
+3 -1
View File
@@ -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,
+21
View File
@@ -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
}
+106 -60
View File
@@ -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) {
+74 -35
View File
@@ -802,10 +802,16 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), msg.ID).Return(nil).AnyTimes()
check.Args(msg.ID).Asserts(chat, policy.ActionUpdate).Returns()
}))
s.Run("DeleteChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
configID := uuid.New()
dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), configID).Return(configID, nil).AnyTimes()
check.Args(configID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(configID)
s.Run("DeleteChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
config := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
dbm.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes()
dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), config.ID).Return(config.ID, nil).AnyTimes()
object := rbac.ResourceChatModelConfig.
WithID(config.ID).
InOrg(config.OrganizationID).
WithACLUserList(config.UserACL.RBACACL()).
WithGroupACL(config.GroupACL.RBACACL())
check.Args(config.ID).Asserts(object, policy.ActionDelete).Returns(config.ID)
}))
s.Run("DeleteChatQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
@@ -1175,30 +1181,40 @@ func (s *MethodTestSuite) TestChats() {
s.Run("GetChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
config := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
dbm.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes()
check.Args(config.ID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config)
object := rbac.ResourceChatModelConfig.
WithID(config.ID).
InOrg(config.OrganizationID).
WithACLUserList(config.UserACL.RBACACL()).
WithGroupACL(config.GroupACL.RBACACL())
check.Args(config.ID).Asserts(object, policy.ActionRead).Returns(config)
}))
s.Run("GetDefaultChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
config := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
dbm.EXPECT().GetDefaultChatModelConfig(gomock.Any(), config.OrganizationID).Return(config, nil).AnyTimes()
check.Args(config.OrganizationID).Asserts().Returns(config)
object := rbac.ResourceChatModelConfig.
WithID(config.ID).
InOrg(config.OrganizationID).
WithACLUserList(config.UserACL.RBACACL()).
WithGroupACL(config.GroupACL.RBACACL())
check.Args(config.OrganizationID).Asserts(object, policy.ActionRead).Returns(config)
}))
s.Run("GetChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes()
orgID := uuid.New()
dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), orgID, gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args().Asserts()
check.Args(orgID).Asserts()
}))
s.Run("GetChatModelConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
orgID := uuid.New()
dbm.EXPECT().GetChatModelConfigsByOrganization(gomock.Any(), orgID).Return([]database.ChatModelConfig{}, nil).AnyTimes()
check.Args(orgID).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), policy.ActionUpdate).Returns([]database.ChatModelConfig{})
}))
s.Run("GetAuthorizedChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes()
orgID := uuid.New()
dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), orgID, gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes()
// No asserts here because callers provide the SQL filter.
check.Args(emptyPreparedAuthorized{}).Asserts()
check.Args(orgID, emptyPreparedAuthorized{}).Asserts()
}))
s.Run("GetDefaultChatModelConfigCandidates", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
configB := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
dbm.EXPECT().GetDefaultChatModelConfigCandidates(gomock.Any()).Return([]database.ChatModelConfig{configA, configB}, nil).AnyTimes()
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.ChatModelConfig{configA, configB})
}))
s.Run("GetChats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
params := database.GetChatsParams{}
dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.GetChatsRow{}, nil).AnyTimes()
@@ -1303,21 +1319,33 @@ func (s *MethodTestSuite) TestChats() {
s.Run("GetEnabledChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
config := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
dbm.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes()
check.Args(config.ID).Asserts(rbac.ResourceChatModelConfig, policy.ActionRead).Returns(config)
object := rbac.ResourceChatModelConfig.
WithID(config.ID).
InOrg(config.OrganizationID).
WithACLUserList(config.UserACL.RBACACL()).
WithGroupACL(config.GroupACL.RBACACL())
check.Args(config.ID).Asserts(object, policy.ActionRead).Returns(config)
}))
s.Run("GetEnabledChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
rowA := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsRow{})
rowB := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsRow{})
dbm.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return([]database.GetEnabledChatModelConfigsRow{rowA, rowB}, nil).AnyTimes()
check.Args().Asserts(rbac.ResourceChatModelConfig, policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsRow{rowA, rowB})
}))
s.Run("GetEnabledChatModelConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
orgID := uuid.New()
rowA := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{})
rowB := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{})
rowA := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{
ChatModelConfig: database.ChatModelConfig{OrganizationID: orgID},
})
rowB := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{
ChatModelConfig: database.ChatModelConfig{OrganizationID: orgID},
})
dbm.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), orgID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB}, nil).AnyTimes()
check.Args(orgID).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB})
objectA := rbac.ResourceChatModelConfig.
WithID(rowA.ChatModelConfig.ID).
InOrg(rowA.ChatModelConfig.OrganizationID).
WithACLUserList(rowA.ChatModelConfig.UserACL.RBACACL()).
WithGroupACL(rowA.ChatModelConfig.GroupACL.RBACACL())
objectB := rbac.ResourceChatModelConfig.
WithID(rowB.ChatModelConfig.ID).
InOrg(rowB.ChatModelConfig.OrganizationID).
WithACLUserList(rowB.ChatModelConfig.UserACL.RBACACL()).
WithGroupACL(rowB.ChatModelConfig.GroupACL.RBACACL())
check.Args(orgID).Asserts(objectA, policy.ActionRead, objectB, policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB})
}))
s.Run("GetStaleChats", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
@@ -1360,13 +1388,14 @@ func (s *MethodTestSuite) TestChats() {
}))
s.Run("InsertChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := database.InsertChatModelConfigParams{
Model: "test-model",
DisplayName: "Test Model",
Enabled: true,
Model: "test-model",
DisplayName: "Test Model",
Enabled: true,
OrganizationID: uuid.New(),
}
config := testutil.Fake(s.T(), faker, database.ChatModelConfig{Model: arg.Model, DisplayName: arg.DisplayName, Enabled: arg.Enabled})
dbm.EXPECT().InsertChatModelConfig(gomock.Any(), arg).Return(config, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config)
check.Args(arg).Asserts(rbac.ResourceChatModelConfig.InOrg(arg.OrganizationID), policy.ActionCreate).Returns(config)
}))
s.Run("PopNextQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
@@ -1594,8 +1623,14 @@ func (s *MethodTestSuite) TestChats() {
DisplayName: "Updated Model",
Enabled: true,
}
dbm.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes()
dbm.EXPECT().UpdateChatModelConfig(gomock.Any(), arg).Return(config, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config)
object := rbac.ResourceChatModelConfig.
WithID(config.ID).
InOrg(config.OrganizationID).
WithACLUserList(config.UserACL.RBACACL()).
WithGroupACL(config.GroupACL.RBACACL())
check.Args(arg).Asserts(object, policy.ActionUpdate).Returns(config)
}))
s.Run("UpdateChatModelConfigACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
@@ -1668,7 +1703,7 @@ func (s *MethodTestSuite) TestChats() {
s.Run("UnsetDefaultChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
orgID := uuid.New()
dbm.EXPECT().UnsetDefaultChatModelConfigs(gomock.Any(), orgID).Return(nil).AnyTimes()
check.Args(orgID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
check.Args(orgID).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), policy.ActionUpdate)
}))
s.Run("UpsertChatDiffStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
@@ -7760,6 +7795,10 @@ func TestAsChatd(t *testing.T) {
err = auth.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig)
require.Error(t, err, "deployment config update should not be allowed")
// Chatd resolves organization model configs during background work.
err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceChatModelConfig.InOrg(uuid.New()))
require.NoError(t, err, "chat model config read should be allowed")
// Pin the complete ResourceUser action set: read_personal only.
// Token refresh persistence uses the per-user AsChatdTokenOwner subject.
for _, action := range rbac.ResourceUser.AvailableActions() {
@@ -7771,7 +7810,7 @@ func TestAsChatd(t *testing.T) {
}
}
// Organization read is temporarily needed for the pre-cutover default-org fallback.
// Organization reads support the pre-cutover default-org fallback.
err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceOrganization)
require.NoError(t, err, "organization read should be allowed")
})
+12 -20
View File
@@ -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)
+23 -38
View File
@@ -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()
+9 -6
View File
@@ -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,
+3 -11
View File
@@ -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
}
+6 -2
View File
@@ -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)
+4 -4
View File
@@ -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
}))
+63 -70
View File
@@ -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,
+13 -14
View File
@@ -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
+481 -332
View File
File diff suppressed because it is too large Load Diff
+299
View File
@@ -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)
}
+436
View File
@@ -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")
})
}
}
+123 -81
View File
@@ -20,7 +20,7 @@ import (
)
// TestChatModelConfigListReadContracts pins the visible config set for each
// role. The legacy endpoint reads only the default organization's configs.
// role after chat model config reads become organization-scoped.
func TestChatModelConfigListReadContracts(t *testing.T) {
t.Parallel()
@@ -68,19 +68,34 @@ func TestChatModelConfigListReadContracts(t *testing.T) {
require.True(t, otherEnabled.Enabled)
for _, testCase := range []struct {
name string
scope codersdk.APIKeyScope
wantStatus int
name string
scopes []codersdk.APIKeyScope
wantCollectionStatus int
wantItemStatus int
wantACLStatus int
}{
{name: "ModelRead", scope: codersdk.APIKeyScopeChatModelConfigRead},
{name: "WorkspaceRead", scope: codersdk.APIKeyScopeWorkspaceRead, wantStatus: http.StatusForbidden},
{
name: "ModelReadWithOrganizationRead",
scopes: []codersdk.APIKeyScope{
codersdk.APIKeyScopeOrganizationRead,
codersdk.APIKeyScopeChatModelConfigRead,
},
wantACLStatus: http.StatusNotFound,
},
{
name: "WorkspaceRead",
scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeOrganizationRead, codersdk.APIKeyScopeWorkspaceRead},
wantCollectionStatus: http.StatusForbidden,
wantItemStatus: http.StatusNotFound,
wantACLStatus: http.StatusNotFound,
},
} {
t.Run("TokenScope/"+testCase.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Scopes: []codersdk.APIKeyScope{testCase.scope},
Scopes: testCase.scopes,
})
require.NoError(t, err)
scopedClient := codersdk.New(
@@ -90,52 +105,87 @@ func TestChatModelConfigListReadContracts(t *testing.T) {
)
t.Cleanup(scopedClient.HTTPClient.CloseIdleConnections)
configs, err := codersdk.NewExperimentalClient(scopedClient).ChatModels(ctx)
if testCase.wantStatus != 0 {
requireSDKError(t, err, testCase.wantStatus)
return
experimentalClient := codersdk.NewExperimentalClient(scopedClient)
response, listErr := experimentalClient.ChatModels(ctx, defaultOrg.ID)
_, availabilityErr := experimentalClient.ChatModelAvailability(ctx, defaultOrg.ID)
model, itemErr := experimentalClient.ChatModel(ctx, defaultOrg.ID, ownEnabled.ID)
_, aclErr := experimentalClient.ChatModelACL(ctx, defaultOrg.ID, ownEnabled.ID)
if testCase.wantCollectionStatus != 0 {
requireSDKError(t, listErr, testCase.wantCollectionStatus)
requireSDKError(t, availabilityErr, testCase.wantCollectionStatus)
} else {
require.NoError(t, listErr)
require.NotEmpty(t, response.Models)
require.NoError(t, availabilityErr)
}
if testCase.wantItemStatus != 0 {
requireSDKError(t, itemErr, testCase.wantItemStatus)
} else {
require.NoError(t, itemErr)
require.Equal(t, ownEnabled.ID, model.ID)
}
requireSDKError(t, aclErr, testCase.wantACLStatus)
})
}
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
for _, endpoint := range []struct {
name string
call func(context.Context, uuid.UUID) error
}{
{
name: "Collection",
call: func(ctx context.Context, organizationID uuid.UUID) error {
_, err := memberClient.ChatModels(ctx, organizationID)
return err
},
},
{
name: "Availability",
call: func(ctx context.Context, organizationID uuid.UUID) error {
_, err := memberClient.ChatModelAvailability(ctx, organizationID)
return err
},
},
{
name: "Item",
call: func(ctx context.Context, organizationID uuid.UUID) error {
_, err := memberClient.ChatModel(ctx, organizationID, otherEnabled.ID)
return err
},
},
{
name: "ACL",
call: func(ctx context.Context, organizationID uuid.UUID) error {
_, err := memberClient.ChatModelACL(ctx, organizationID, otherEnabled.ID)
return err
},
},
} {
t.Run("ConcealsHiddenOrganization/"+endpoint.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
var concealedMessage string
for _, organizationID := range []uuid.UUID{otherOrg.ID, uuid.New()} {
err := endpoint.call(ctx, organizationID)
sdkErr := requireSDKError(t, err, http.StatusNotFound)
if concealedMessage == "" {
concealedMessage = sdkErr.Message
}
require.Equal(t, concealedMessage, sdkErr.Message)
}
require.NoError(t, err)
require.NotEmpty(t, configs)
})
}
testCases := []struct {
name string
client func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient
visible []uuid.UUID
hidden []uuid.UUID
name string
client func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient
seesDenied bool
}{
{
name: "OwnerSeesDefaultOrgConfigs",
client: func(*testing.T, context.Context) *codersdk.ExperimentalClient {
return client
},
visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID},
hidden: []uuid.UUID{otherEnabled.ID},
},
{
name: "SiteAuditorSeesDefaultOrgConfigs",
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.RoleAuditor())
return codersdk.NewExperimentalClient(rawClient)
},
visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID},
hidden: []uuid.UUID{otherEnabled.ID},
},
{
name: "CustomSiteReadRoleSeesDefaultOrgConfigs",
client: func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient {
return newSiteCustomRoleClient(ctx, t, client, rawDB, defaultOrg.ID, database.CustomRolePermission{
ResourceType: rbac.ResourceChatModelConfig.Type,
Action: policy.ActionRead,
})
},
visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID},
hidden: []uuid.UUID{otherEnabled.ID},
},
{
name: "AgentsAccessUsesRowACL",
name: "AgentsAccess",
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(
t,
@@ -145,53 +195,47 @@ func TestChatModelConfigListReadContracts(t *testing.T) {
)
return codersdk.NewExperimentalClient(rawClient)
},
visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID},
hidden: []uuid.UUID{denied.ID, otherEnabled.ID},
},
{
name: "DeploymentConfigReadOnlyUsesRowACL",
name: "Owner",
seesDenied: true,
client: func(*testing.T, context.Context) *codersdk.ExperimentalClient {
return client
},
},
{
name: "SiteAuditor",
seesDenied: true,
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.RoleAuditor())
return codersdk.NewExperimentalClient(rawClient)
},
},
{
name: "CustomSiteReadRole",
seesDenied: true,
client: func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient {
return newSiteCustomRoleClient(ctx, t, client, rawDB, defaultOrg.ID, database.CustomRolePermission{
ResourceType: rbac.ResourceDeploymentConfig.Type,
ResourceType: rbac.ResourceChatModelConfig.Type,
Action: policy.ActionRead,
})
},
visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID},
hidden: []uuid.UUID{denied.ID, otherEnabled.ID},
},
{
name: "DefaultOrgAdminKeepsEnabledList",
name: "OrgAdmin",
seesDenied: true,
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAdmin(defaultOrg.ID))
return codersdk.NewExperimentalClient(rawClient)
},
visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID},
hidden: []uuid.UUID{otherEnabled.ID},
},
{
name: "DefaultOrgAuditorKeepsEnabledList",
name: "OrgAuditor",
seesDenied: true,
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAuditor(defaultOrg.ID))
return codersdk.NewExperimentalClient(rawClient)
},
visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID},
hidden: []uuid.UUID{otherEnabled.ID},
},
{
name: "NonDefaultOrgAdminSeesNoDefaultOrgConfigs",
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleOrgAdmin(otherOrg.ID))
return codersdk.NewExperimentalClient(rawClient)
},
hidden: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID, otherEnabled.ID},
},
{
name: "NonDefaultOrgAuditorSeesNoDefaultOrgConfigs",
client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient {
rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleOrgAuditor(otherOrg.ID))
return codersdk.NewExperimentalClient(rawClient)
},
hidden: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID, otherEnabled.ID},
},
}
@@ -200,14 +244,12 @@ func TestChatModelConfigListReadContracts(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
models, err := testCase.client(t, ctx).ChatModels(ctx)
models, err := testCase.client(t, ctx).ChatModels(ctx, defaultOrg.ID)
require.NoError(t, err)
for _, id := range testCase.visible {
require.True(t, containsChatModel(models, id), "must see ChatModel %s", id)
}
for _, id := range testCase.hidden {
require.False(t, containsChatModel(models, id), "must not see ChatModel %s", id)
}
require.True(t, containsChatModel(models.Models, ownEnabled.ID))
require.True(t, containsChatModel(models.Models, ownDisabled.ID))
require.Equal(t, testCase.seesDenied, containsChatModel(models.Models, denied.ID))
require.False(t, containsChatModel(models.Models, otherEnabled.ID))
})
}
}
+1574 -796
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -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))
})
}
}
+3 -3
View File
@@ -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")
},
}
+103 -96
View File
@@ -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
+11 -19
View File
@@ -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)
})
@@ -552,6 +552,9 @@ func (*ModelCatalog) ListConfiguredModels(
}
models := modelsByProvider[provider]
if models == nil {
models = []codersdk.ChatModelCatalogEntry{}
}
sortChatModelCatalogEntries(models)
result := codersdk.ChatModelProvider{
@@ -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()
+1 -1
View File
@@ -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)
@@ -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)
+3 -203
View File
@@ -21,7 +21,6 @@ import (
const (
chatConfigProvidersTTL = 10 * time.Second
chatConfigModelConfigTTL = 10 * time.Second
chatConfigUserPromptTTL = 5 * time.Second
chatConfigAdvisorConfigTTL = 10 * time.Second
// Bound user-prompt cache cardinality so one-shot users do not
@@ -39,37 +38,6 @@ type cachedAdvisorConfig struct {
expiresAt time.Time
}
type cachedModelConfig struct {
config database.ChatModelConfig
expiresAt time.Time
}
type modelConfigSnapshot struct {
epoch uint64
generation uint64
}
func cloneChatACL(acl database.ChatACL) database.ChatACL {
if acl == nil {
return nil
}
clone := make(database.ChatACL, len(acl))
for id, entry := range acl {
entry.Permissions = slices.Clone(entry.Permissions)
clone[id] = entry
}
return clone
}
// cloneModelConfig returns a copy of cfg with mutable fields cloned so the
// cache owns their backing storage.
func cloneModelConfig(cfg database.ChatModelConfig) database.ChatModelConfig {
cfg.Options = slices.Clone(cfg.Options)
cfg.GroupACL = cloneChatACL(cfg.GroupACL)
cfg.UserACL = cloneChatACL(cfg.UserACL)
return cfg
}
type chatConfigCache struct {
db database.Store
clock quartz.Clock
@@ -90,16 +58,6 @@ type chatConfigCache struct {
providerGeneration uint64
providerFetches singleflight.Group[string, []database.AIProvider]
// Model configs (keyed by ID).
modelTopologyEpoch uint64
modelConfigs map[uuid.UUID]cachedModelConfig
modelConfigFetches singleflight.Group[string, database.ChatModelConfig]
// Default model configs (keyed by organization ID).
defaultModelConfigs map[uuid.UUID]cachedModelConfig
defaultModelConfigGeneration uint64
defaultModelConfigFetches singleflight.Group[string, database.ChatModelConfig]
// User custom prompts (keyed by user ID).
userPromptEpoch uint64
userPrompts *tlru.Cache[uuid.UUID, string]
@@ -113,11 +71,9 @@ type chatConfigCache struct {
func newChatConfigCache(ctx context.Context, db database.Store, clock quartz.Clock) *chatConfigCache {
return &chatConfigCache{
db: db,
clock: clock,
ctx: ctx,
modelConfigs: make(map[uuid.UUID]cachedModelConfig),
defaultModelConfigs: make(map[uuid.UUID]cachedModelConfig),
db: db,
clock: clock,
ctx: ctx,
userPrompts: tlru.New[uuid.UUID](
tlru.ConstantCost[string],
chatConfigUserPromptEntryLimit,
@@ -221,154 +177,9 @@ func (c *chatConfigCache) InvalidateProviders() {
c.mu.Lock()
c.providers = nil
c.providerGeneration++
// Provider topology changed — model selections depend on
// provider existence, so flush all model-config state.
clear(c.modelConfigs)
c.modelTopologyEpoch++
clear(c.defaultModelConfigs)
c.defaultModelConfigGeneration++
c.mu.Unlock()
}
func (c *chatConfigCache) ModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) {
if config, ok := c.cachedModelConfig(id); ok {
return config, nil
}
snap := c.modelConfigSnapshot()
config, err := singleflightDoChan(ctx, &c.modelConfigFetches, fmt.Sprintf("%d:%s", snap.epoch, id), func() (database.ChatModelConfig, error) {
if cached, ok := c.cachedModelConfig(id); ok {
return cached, nil
}
fetched, err := c.db.GetChatModelConfigByID(c.ctx, id)
if err != nil {
return database.ChatModelConfig{}, err
}
c.storeModelConfig(snap, fetched)
return cloneModelConfig(fetched), nil
})
if err != nil {
return database.ChatModelConfig{}, err
}
return config, nil
}
func (c *chatConfigCache) cachedModelConfig(id uuid.UUID) (database.ChatModelConfig, bool) {
c.mu.RLock()
entry, ok := c.modelConfigs[id]
c.mu.RUnlock()
if !ok {
return database.ChatModelConfig{}, false
}
if c.clock.Now().Before(entry.expiresAt) {
return cloneModelConfig(entry.config), true
}
c.mu.Lock()
if current, ok := c.modelConfigs[id]; ok && !c.clock.Now().Before(current.expiresAt) {
delete(c.modelConfigs, id)
}
c.mu.Unlock()
return database.ChatModelConfig{}, false
}
func (c *chatConfigCache) modelConfigSnapshot() modelConfigSnapshot {
c.mu.RLock()
snap := modelConfigSnapshot{epoch: c.modelTopologyEpoch}
c.mu.RUnlock()
return snap
}
func (c *chatConfigCache) storeModelConfig(snap modelConfigSnapshot, config database.ChatModelConfig) {
c.mu.Lock()
defer c.mu.Unlock()
if c.modelTopologyEpoch != snap.epoch {
return
}
c.modelConfigs[config.ID] = cachedModelConfig{
config: cloneModelConfig(config),
expiresAt: c.clock.Now().Add(chatConfigModelConfigTTL),
}
}
// DefaultModelConfig returns the usable local default model config.
func (c *chatConfigCache) DefaultModelConfig(ctx context.Context, orgID uuid.UUID) (database.ChatModelConfig, error) {
if config, ok := c.cachedDefaultModelConfig(orgID); ok {
return config, nil
}
snap := c.defaultModelConfigSnapshot()
config, err := singleflightDoChan(ctx, &c.defaultModelConfigFetches, fmt.Sprintf("%d:default:%s", snap.epoch, orgID), func() (database.ChatModelConfig, error) {
if cached, ok := c.cachedDefaultModelConfig(orgID); ok {
return cached, nil
}
fetched, err := effectiveDefaultChatModelConfig(c.ctx, c.db, orgID)
if err != nil {
return database.ChatModelConfig{}, err
}
c.storeDefaultModelConfig(snap, orgID, fetched)
return cloneModelConfig(fetched), nil
})
if err != nil {
return database.ChatModelConfig{}, err
}
return config, nil
}
func (c *chatConfigCache) cachedDefaultModelConfig(orgID uuid.UUID) (database.ChatModelConfig, bool) {
c.mu.RLock()
entry, ok := c.defaultModelConfigs[orgID]
c.mu.RUnlock()
if !ok {
return database.ChatModelConfig{}, false
}
if c.clock.Now().Before(entry.expiresAt) {
return cloneModelConfig(entry.config), true
}
c.mu.Lock()
if current, ok := c.defaultModelConfigs[orgID]; ok && !c.clock.Now().Before(current.expiresAt) {
delete(c.defaultModelConfigs, orgID)
}
c.mu.Unlock()
return database.ChatModelConfig{}, false
}
func (c *chatConfigCache) defaultModelConfigSnapshot() modelConfigSnapshot {
c.mu.RLock()
snap := modelConfigSnapshot{
epoch: c.modelTopologyEpoch,
generation: c.defaultModelConfigGeneration,
}
c.mu.RUnlock()
return snap
}
func (c *chatConfigCache) storeDefaultModelConfig(snap modelConfigSnapshot, orgID uuid.UUID, config database.ChatModelConfig) {
c.mu.Lock()
defer c.mu.Unlock()
if c.modelTopologyEpoch != snap.epoch {
return
}
if c.defaultModelConfigGeneration != snap.generation {
return
}
c.defaultModelConfigs[orgID] = cachedModelConfig{
config: cloneModelConfig(config),
expiresAt: c.clock.Now().Add(chatConfigModelConfigTTL),
}
}
func (c *chatConfigCache) UserPrompt(ctx context.Context, userID uuid.UUID) (string, error) {
if prompt, ok := c.cachedUserPrompt(userID); ok {
return prompt, nil
@@ -424,17 +235,6 @@ func (c *chatConfigCache) storeUserPrompt(epoch uint64, userID uuid.UUID, prompt
c.userPrompts.Set(userID, prompt, chatConfigUserPromptTTL)
}
func (c *chatConfigCache) InvalidateModelConfig(id uuid.UUID) {
c.mu.Lock()
delete(c.modelConfigs, id)
c.modelTopologyEpoch++
// Coarse invalidation: the event does not identify the changed config's
// organization or default status, so every per-org default is dropped.
clear(c.defaultModelConfigs)
c.defaultModelConfigGeneration++
c.mu.Unlock()
}
func (c *chatConfigCache) InvalidateUserPrompt(userID uuid.UUID) {
c.mu.Lock()
c.userPrompts.Delete(userID)
+4 -444
View File
@@ -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{}) {
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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,
+3 -2
View File
@@ -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),
+21 -11
View File
@@ -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,
)
+7 -4
View File
@@ -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) {
+24 -10
View File
@@ -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)
}