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
+20 -1
View File
@@ -7,6 +7,7 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/xerrors"
@@ -77,10 +78,23 @@ func (r *RootCmd) scaletestChat() *serpent.Command {
}
logger := inv.Logger
modelID, err := chat.EnsureScaletestChatModel(ctx, client, logger, llmMockURL, providerPropagationWait)
modelForOrg, err := chat.EnsureScaletestChatModel(ctx, client, logger, llmMockURL, providerPropagationWait)
if err != nil {
return err
}
uncachedModelForOrg := modelForOrg
modelIDsByOrganization := make(map[uuid.UUID]uuid.UUID)
modelForOrg = func(organizationID uuid.UUID) (uuid.UUID, error) {
if modelID, ok := modelIDsByOrganization[organizationID]; ok {
return modelID, nil
}
modelID, err := uncachedModelForOrg(organizationID)
if err != nil {
return uuid.Nil, err
}
modelIDsByOrganization[organizationID] = modelID
return modelID, nil
}
// Start metrics and tracing before creating runners.
reg := prometheus.NewRegistry()
@@ -124,6 +138,11 @@ func (r *RootCmd) scaletestChat() *serpent.Command {
turnStartReadyWaitGroup.Add(1)
}
modelID, err := modelForOrg(targetWorkspace.OrganizationID)
if err != nil {
return xerrors.Errorf("ensure scaletest model config for organization %s: %w", targetWorkspace.OrganizationID, err)
}
cfg := chat.Config{
OrganizationID: targetWorkspace.OrganizationID,
WorkspaceID: targetWorkspace.ID,
+5 -3
View File
@@ -77,9 +77,11 @@ func TestScaleTestChat(t *testing.T) {
require.Equal(t, mockURL, provider.BaseURL)
expClient := codersdk.NewExperimentalClient(client)
configs, err := expClient.ChatModels(ctx)
defaultOrg, err := client.OrganizationByName(ctx, codersdk.DefaultOrganization)
require.NoError(t, err)
matchingConfigs := scaletestModelsForProvider(configs, provider.ID)
configs, err := expClient.ChatModels(ctx, defaultOrg.ID)
require.NoError(t, err)
matchingConfigs := scaletestModelConfigsForProvider(configs.Models, provider.ID)
require.Len(t, matchingConfigs, 1)
require.True(t, matchingConfigs[0].Enabled)
@@ -125,7 +127,7 @@ func chatMessageText(messages []codersdk.ChatMessage, role codersdk.ChatMessageR
return b.String(), found
}
func scaletestModelsForProvider(configs []codersdk.ChatModel, providerID uuid.UUID) []codersdk.ChatModel {
func scaletestModelConfigsForProvider(configs []codersdk.ChatModel, providerID uuid.UUID) []codersdk.ChatModel {
matches := make([]codersdk.ChatModel, 0, 1)
for _, config := range configs {
if config.AIProviderID != providerID {
+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)
}
+116 -26
View File
@@ -722,9 +722,9 @@ const (
ChatModelProviderUnavailableReasonUserAPIKeyRequired ChatModelProviderUnavailableReason = "user_api_key_required"
)
// ChatModelCatalogEntry is a discovery catalog entry for end users.
// Its ID is a synthetic provider:model value, not the UUID in ChatModel.ID.
// It is not an admin-managed record. See ChatModel.
// ChatModelCatalogEntry is the runtime catalog view of a model. Its ID is the
// synthetic catalog identity `provider:model` built by canonicalModelID, not
// the chat_model_configs row UUID that ChatModel.ID carries.
type ChatModelCatalogEntry struct {
ID string `json:"id"`
Provider string `json:"provider"`
@@ -740,9 +740,11 @@ type ChatModelProvider struct {
Models []ChatModelCatalogEntry `json:"models"`
}
// ChatModelAvailabilityResponse groups the discovery catalog by provider and
// reports provider availability.
// ChatModelAvailabilityResponse is the catalog returned from chat model discovery.
type ChatModelAvailabilityResponse struct {
// Models contains the effective runtime model configs for the requested
// organization. Each config belongs to that organization.
Models []ChatModel `json:"models,omitempty"`
Providers []ChatModelProvider `json:"providers"`
// UnsupportedProviders lists configured providers the Agents harness
// cannot use, so the UI can explain the empty state.
@@ -1318,10 +1320,10 @@ type CreateUserChatProviderKeyRequest struct {
APIKey string `json:"api_key"`
}
// ChatModel is an admin-managed model record for an organization.
// It is not a discovery catalog entry. See ChatModelCatalogEntry.
// ChatModel is an org-scoped model configuration.
type ChatModel struct {
ID uuid.UUID `json:"id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
AIProviderID uuid.UUID `json:"ai_provider_id" format:"uuid"`
Model string `json:"model"`
DisplayName string `json:"display_name"`
@@ -1337,6 +1339,21 @@ type ChatModel struct {
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
// ChatModelACL is the access control list for an organization-scoped chat
// model. Each principal is mapped to its effective model role.
type ChatModelACL struct {
UserRoles map[string]ChatRole `json:"user_roles"`
GroupRoles map[string]ChatRole `json:"group_roles"`
}
// UpdateChatModelACLRequest is a sparse update of a chat model ACL. Only the
// listed principals change. ChatRoleDeleted removes an entry, while an omitted
// map or principal is unchanged.
type UpdateChatModelACLRequest struct {
UserRoles map[string]ChatRole `json:"user_roles,omitempty"`
GroupRoles map[string]ChatRole `json:"group_roles,omitempty"`
}
// ChatModelProviderOptions contains typed provider-specific options.
//
// Note: Azure models use the `openai` options shape.
@@ -2030,9 +2047,10 @@ func (c *ExperimentalClient) ListChats(ctx context.Context, opts *ListChatsOptio
return chats, ReadBodyAsJSON(res, &chats)
}
// ChatModelAvailability returns the discovery catalog and provider availability.
func (c *ExperimentalClient) ChatModelAvailability(ctx context.Context) (ChatModelAvailabilityResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/models", nil)
// ChatModelAvailability returns the provider-grouped, per-caller
// availability view of one organization's chat models.
func (c *ExperimentalClient) ChatModelAvailability(ctx context.Context, organizationID uuid.UUID) (ChatModelAvailabilityResponse, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/available", organizationID), nil)
if err != nil {
return ChatModelAvailabilityResponse{}, err
}
@@ -2189,24 +2207,41 @@ func (c *ExperimentalClient) DeleteUserChatProviderKey(ctx context.Context, prov
return nil
}
// ChatModels returns admin-managed chat model records.
func (c *ExperimentalClient) ChatModels(ctx context.Context) ([]ChatModel, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/model-configs", nil)
// ChatModels returns the chat model configs the caller can read in one
// organization, plus the redacted provider descriptors the authoring page
// needs, for org-scoped management and picker surfaces.
func (c *ExperimentalClient) ChatModels(ctx context.Context, organizationID uuid.UUID) (OrganizationChatModelsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID), nil)
if err != nil {
return nil, err
return OrganizationChatModelsResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
return OrganizationChatModelsResponse{}, ReadBodyAsError(res)
}
var models []ChatModel
return models, ReadBodyAsJSON(res, &models)
var resp OrganizationChatModelsResponse
return resp, ReadBodyAsJSON(res, &resp)
}
// CreateChatModel creates an admin-managed ChatModel.
func (c *ExperimentalClient) CreateChatModel(ctx context.Context, req CreateChatModelRequest) (ChatModel, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/experimental/chats/model-configs", req)
// ChatModel fetches one chat model config by ID in an organization.
func (c *ExperimentalClient) ChatModel(ctx context.Context, organizationID, modelConfigID uuid.UUID) (ChatModel, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelConfigID), nil)
if err != nil {
return ChatModel{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatModel{}, ReadBodyAsError(res)
}
var config ChatModel
return config, ReadBodyAsJSON(res, &config)
}
// CreateChatModel creates a chat model config in the given organization.
func (c *ExperimentalClient) CreateChatModel(ctx context.Context, organizationID uuid.UUID, req CreateChatModelRequest) (ChatModel, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID), req)
if err != nil {
return ChatModel{}, err
}
@@ -2219,9 +2254,9 @@ func (c *ExperimentalClient) CreateChatModel(ctx context.Context, req CreateChat
return model, ReadBodyAsJSON(res, &model)
}
// UpdateChatModel updates an admin-managed ChatModel.
func (c *ExperimentalClient) UpdateChatModel(ctx context.Context, modelID uuid.UUID, req UpdateChatModelRequest) (ChatModel, error) {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/chats/model-configs/%s", modelID), req)
// UpdateChatModel updates a ChatModel in an organization.
func (c *ExperimentalClient) UpdateChatModel(ctx context.Context, organizationID, modelID uuid.UUID, req UpdateChatModelRequest) (ChatModel, error) {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelID), req)
if err != nil {
return ChatModel{}, err
}
@@ -2234,9 +2269,26 @@ func (c *ExperimentalClient) UpdateChatModel(ctx context.Context, modelID uuid.U
return model, ReadBodyAsJSON(res, &model)
}
// DeleteChatModel deletes an admin-managed ChatModel.
func (c *ExperimentalClient) DeleteChatModel(ctx context.Context, modelID uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/model-configs/%s", modelID), nil)
// ChatModelACL returns the access control list for a chat model in an
// organization.
func (c *ExperimentalClient) ChatModelACL(ctx context.Context, organizationID, modelID uuid.UUID) (ChatModelACL, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s/acl", organizationID, modelID), nil)
if err != nil {
return ChatModelACL{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatModelACL{}, ReadBodyAsError(res)
}
var modelACL ChatModelACL
return modelACL, ReadBodyAsJSON(res, &modelACL)
}
// UpdateChatModelACL applies a sparse access control list update to a chat
// model in an organization.
func (c *ExperimentalClient) UpdateChatModelACL(ctx context.Context, organizationID, modelID uuid.UUID, req UpdateChatModelACLRequest) error {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s/acl", organizationID, modelID), req)
if err != nil {
return err
}
@@ -2247,6 +2299,44 @@ func (c *ExperimentalClient) DeleteChatModel(ctx context.Context, modelID uuid.U
return nil
}
// DeleteChatModel deletes a ChatModel in an organization.
func (c *ExperimentalClient) DeleteChatModel(ctx context.Context, organizationID, modelID uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelID), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// ChatModelProviderDescriptor is the redacted view of an AI provider carried
// on the org model collection response. It carries only the capability
// metadata the Models UI needs; key material, base URLs, and headers are
// never exposed. The fields mirror what /api/experimental/chats/models
// already discloses to any authenticated caller.
type ChatModelProviderDescriptor struct {
ID uuid.UUID `json:"id" format:"uuid"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
Icon string `json:"icon"`
Enabled bool `json:"enabled"`
HasAPIKey bool `json:"has_api_key"`
HasUserAPIKey bool `json:"has_user_api_key"`
HasEffectiveAPIKey bool `json:"has_effective_api_key"`
AllowUserAPIKey bool `json:"allow_user_api_key"`
}
// OrganizationChatModelsResponse is the org chat model config collection:
// the caller-readable configs plus the redacted provider descriptors the
// authoring page needs.
type OrganizationChatModelsResponse struct {
Models []ChatModel `json:"models"`
Providers []ChatModelProviderDescriptor `json:"providers"`
}
// GetChatCost returns the AI Gateway cost for the whole chat tree that
// contains chatID.
func (c *ExperimentalClient) GetChatCost(ctx context.Context, chatID uuid.UUID) (ChatCost, error) {
+88
View File
@@ -0,0 +1,88 @@
package codersdk_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
)
func TestExperimentalClientChatModelACL(t *testing.T) {
t.Parallel()
organizationID := uuid.New()
modelID := uuid.New()
userID := uuid.New()
groupID := uuid.New()
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/experimental/organizations/"+organizationID.String()+"/chats/models/"+modelID.String()+"/acl", r.URL.Path)
http.Error(rw, `{"user_roles":{"`+userID.String()+`":"read"},"group_roles":{"`+groupID.String()+`":"read"}}`, http.StatusOK)
}))
defer server.Close()
serverURL, err := url.Parse(server.URL)
require.NoError(t, err)
client := codersdk.NewExperimentalClient(codersdk.New(serverURL))
modelACL, err := client.ChatModelACL(context.Background(), organizationID, modelID)
require.NoError(t, err)
require.Equal(t, map[string]codersdk.ChatRole{userID.String(): codersdk.ChatRoleRead}, modelACL.UserRoles)
require.Equal(t, map[string]codersdk.ChatRole{groupID.String(): codersdk.ChatRoleRead}, modelACL.GroupRoles)
}
func TestExperimentalClientUpdateChatModelACL(t *testing.T) {
t.Parallel()
organizationID := uuid.New()
modelID := uuid.New()
userID := uuid.New()
groupID := uuid.New()
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPatch, r.Method)
require.Equal(t, "/api/experimental/organizations/"+organizationID.String()+"/chats/models/"+modelID.String()+"/acl", r.URL.Path)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
var payload map[string]json.RawMessage
require.NoError(t, json.Unmarshal(body, &payload))
require.JSONEq(t, `{"`+userID.String()+`":"read"}`, string(payload["user_roles"]))
require.JSONEq(t, `{"`+groupID.String()+`":""}`, string(payload["group_roles"]))
rw.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
serverURL, err := url.Parse(server.URL)
require.NoError(t, err)
client := codersdk.NewExperimentalClient(codersdk.New(serverURL))
err = client.UpdateChatModelACL(context.Background(), organizationID, modelID, codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{userID.String(): codersdk.ChatRoleRead},
GroupRoles: map[string]codersdk.ChatRole{groupID.String(): codersdk.ChatRoleDeleted},
})
require.NoError(t, err)
}
func TestUpdateChatModelACLRequestOmittedMaps(t *testing.T) {
t.Parallel()
payload, err := json.Marshal(codersdk.UpdateChatModelACLRequest{})
require.NoError(t, err)
require.JSONEq(t, `{}`, string(payload))
payload, err = json.Marshal(codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{},
GroupRoles: map[string]codersdk.ChatRole{},
})
require.NoError(t, err)
require.JSONEq(t, `{}`, string(payload))
}
+39 -43
View File
@@ -2,10 +2,8 @@ package toolsdk
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
@@ -18,11 +16,6 @@ import (
const chatIDDescription = "UUID of the chat."
func isForbiddenError(err error) bool {
var sdkErr *codersdk.Error
return errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusForbidden
}
func parseChatID(chatID string) (uuid.UUID, error) {
if chatID == "" {
return uuid.Nil, xerrors.New("chat_id is required")
@@ -108,7 +101,7 @@ The chat runs asynchronously. Poll coder_get_chat for status and read the transc
},
"model_config_id": map[string]any{
"type": "string",
"description": "Optional chat model config UUID from coder_list_chat_model_configs. Defaults to the deployment default model.",
"description": "Optional chat model config UUID from coder_list_chat_model_configs. Defaults to the organization's default model.",
},
"labels": map[string]any{
"type": "object",
@@ -866,58 +859,61 @@ type ChatModelConfigSummary struct {
IsDefault bool `json:"is_default"`
}
type ListChatModelConfigsArgs struct {
OrganizationID string `json:"organization_id"`
}
type ListChatModelConfigsResponse struct {
ModelConfigs []ChatModelConfigSummary `json:"model_configs"`
}
var ListChatModelConfigs = Tool[NoArgs, ListChatModelConfigsResponse]{
var ListChatModelConfigs = Tool[ListChatModelConfigsArgs, ListChatModelConfigsResponse]{
Tool: aisdk.Tool{
Name: ToolNameListChatModelConfigs,
Description: `List the enabled chat models available for Coder Agents chats. Use a model config ID with coder_create_chat to pick a model.
Per-user provider credentials are validated when creating a chat, so coder_create_chat can still reject a listed model with an explanatory error.`,
Schema: aisdk.Schema{
Properties: map[string]any{},
Required: []string{},
Properties: map[string]any{
"organization_id": map[string]any{
"type": "string",
"description": "Optional organization UUID. Defaults to the authenticated user's first organization.",
},
},
Required: []string{},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
Handler: func(ctx context.Context, deps Deps, _ NoArgs) (ListChatModelConfigsResponse, error) {
configs, err := codersdk.NewExperimentalClient(deps.coderClient).ChatModels(ctx)
Handler: func(ctx context.Context, deps Deps, args ListChatModelConfigsArgs) (ListChatModelConfigsResponse, error) {
var organizationID uuid.UUID
if args.OrganizationID != "" {
var err error
organizationID, err = uuid.Parse(args.OrganizationID)
if err != nil {
return ListChatModelConfigsResponse{}, xerrors.New("organization_id must be a valid UUID")
}
} else {
me, err := deps.coderClient.User(ctx, codersdk.Me)
if err != nil {
return ListChatModelConfigsResponse{}, xerrors.Errorf("get authenticated user: %w", err)
}
if len(me.OrganizationIDs) == 0 {
return ListChatModelConfigsResponse{}, xerrors.New("authenticated user belongs to no organization; pass organization_id explicitly")
}
organizationID = me.OrganizationIDs[0]
}
response, err := codersdk.NewExperimentalClient(deps.coderClient).ChatModels(ctx, organizationID)
if err != nil {
return ListChatModelConfigsResponse{}, xerrors.Errorf("list chat model configs: %w", err)
}
// Admin model lists include disabled providers; non-admin lists are
// already filtered server-side.
var providerEnabled map[uuid.UUID]bool
providers, err := deps.coderClient.AIProviders(ctx)
switch {
case err == nil:
providerEnabled = make(map[uuid.UUID]bool, len(providers))
for _, provider := range providers {
providerEnabled[provider.ID] = provider.Enabled
}
case isForbiddenError(err):
// Deployment-config readers can receive the unfiltered admin list
// without provider access, so fail closed unless both requests return 403.
_, dcErr := deps.coderClient.DeploymentConfig(ctx)
switch {
case dcErr == nil:
return ListChatModelConfigsResponse{}, xerrors.New("cannot verify provider availability for the admin model config list: missing AI provider read permission")
case !isForbiddenError(dcErr):
return ListChatModelConfigsResponse{}, xerrors.Errorf("verify deployment config access: %w", dcErr)
}
default:
return ListChatModelConfigsResponse{}, xerrors.Errorf("list AI providers: %w", err)
providerEnabled := make(map[uuid.UUID]bool, len(response.Providers))
for _, provider := range response.Providers {
providerEnabled[provider.ID] = provider.Enabled
}
summaries := make([]ChatModelConfigSummary, 0, len(configs))
for _, config := range configs {
if !config.Enabled {
continue
}
// A non-nil map is authoritative because soft-deleted providers are
// absent while their configs remain in the admin response.
if providerEnabled != nil && !providerEnabled[config.AIProviderID] {
summaries := make([]ChatModelConfigSummary, 0, len(response.Models))
for _, config := range response.Models {
if !config.Enabled || !providerEnabled[config.AIProviderID] {
continue
}
summaries = append(summaries, ChatModelConfigSummary{
+45 -28
View File
@@ -13,7 +13,6 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/aibridgedtest"
"github.com/coder/coder/v2/coderd/coderdtest"
@@ -29,17 +28,6 @@ import (
"github.com/coder/coder/v2/testutil"
)
type failPathTransport struct {
path string
}
func (t *failPathTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Path == t.path {
return nil, xerrors.New("transport down")
}
return http.DefaultTransport.RoundTrip(req)
}
// stallTransport hangs every request until its context is canceled.
type stallTransport struct{}
@@ -90,7 +78,7 @@ func TestChatTools(t *testing.T) {
require.NoError(t, err)
t.Run("ListChatModelConfigs", func(t *testing.T) {
result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{})
result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{})
require.NoError(t, err)
require.Len(t, result.ModelConfigs, 1)
require.Equal(t, defaultModelConfig.ID.String(), result.ModelConfigs[0].ID)
@@ -98,6 +86,38 @@ func TestChatTools(t *testing.T) {
require.True(t, result.ModelConfigs[0].IsDefault)
})
t.Run("ListChatModelConfigsForOrganization", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
organization := dbgen.Organization(t, api.Database, database.Organization{})
dbgen.OrganizationMember(t, api.Database, database.OrganizationMember{
OrganizationID: organization.ID,
UserID: firstUser.UserID,
})
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAICompat,
Name: "other-org-" + uuid.NewString(),
BaseURL: chattest.OpenAI(t),
Enabled: true,
APIKeys: []string{"test-api-key"},
})
require.NoError(t, err)
contextLimit := int64(4096)
model, err := expClient.CreateChatModel(ctx, organization.ID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-4o-other-org",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{
OrganizationID: organization.ID.String(),
})
require.NoError(t, err)
require.Len(t, result.ModelConfigs, 1)
require.Equal(t, model.ID.String(), result.ModelConfigs[0].ID)
})
t.Run("Lifecycle", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
@@ -671,7 +691,7 @@ func TestChatTools(t *testing.T) {
require.NoError(t, err)
require.False(t, provider.Enabled)
result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{})
result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{})
require.NoError(t, err)
var ids []string
for _, config := range result.ModelConfigs {
@@ -688,7 +708,7 @@ func TestChatTools(t *testing.T) {
err := client.DeleteAIProvider(ctx, deletedProviderConfig.AIProviderID.String())
require.NoError(t, err)
result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{})
result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{})
require.NoError(t, err)
var ids []string
for _, config := range result.ModelConfigs {
@@ -754,7 +774,7 @@ func TestChatTools(t *testing.T) {
memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
memberDeps, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
result, err := testTool(t, toolsdk.ListChatModelConfigs, memberDeps, toolsdk.NoArgs{})
result, err := testTool(t, toolsdk.ListChatModelConfigs, memberDeps, toolsdk.ListChatModelConfigsArgs{})
require.NoError(t, err)
var ids []string
for _, config := range result.ModelConfigs {
@@ -765,19 +785,13 @@ func TestChatTools(t *testing.T) {
auditorClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID, rbac.RoleAuditor())
auditorDeps, err := toolsdk.NewDeps(auditorClient)
require.NoError(t, err)
_, err = testTool(t, toolsdk.ListChatModelConfigs, auditorDeps, toolsdk.NoArgs{})
require.ErrorContains(t, err, "missing AI provider read permission")
brokenProbeClient := codersdk.New(auditorClient.URL)
brokenProbeClient.SetSessionToken(auditorClient.SessionToken())
brokenProbeClient.HTTPClient = &http.Client{
Transport: &failPathTransport{path: "/api/v2/deployment/config"},
}
t.Cleanup(brokenProbeClient.HTTPClient.CloseIdleConnections)
brokenProbeDeps, err := toolsdk.NewDeps(brokenProbeClient)
auditorResult, err := testTool(t, toolsdk.ListChatModelConfigs, auditorDeps, toolsdk.ListChatModelConfigsArgs{})
require.NoError(t, err)
_, err = testTool(t, toolsdk.ListChatModelConfigs, brokenProbeDeps, toolsdk.NoArgs{})
require.ErrorContains(t, err, "verify deployment config access")
var auditorIDs []string
for _, config := range auditorResult.ModelConfigs {
auditorIDs = append(auditorIDs, config.ID)
}
require.Contains(t, auditorIDs, defaultModelConfig.ID.String())
})
t.Run("CreateChatZeroOrgUser", func(t *testing.T) {
@@ -796,6 +810,9 @@ func TestChatTools(t *testing.T) {
_, err := testTool(t, toolsdk.CreateChat, tb, toolsdk.CreateChatArgs{})
require.ErrorContains(t, err, "prompt is required")
_, err = testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.ListChatModelConfigsArgs{OrganizationID: "not-a-uuid"})
require.ErrorContains(t, err, "organization_id must be a valid UUID")
_, err = testTool(t, toolsdk.GetChat, tb, toolsdk.GetChatArgs{ChatID: "not-a-uuid"})
require.ErrorContains(t, err, "chat_id must be a valid UUID")
+1
View File
@@ -27,6 +27,7 @@ We track the following resources:
| AuditableUserAIBudgetOverride<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>group_id</td><td>true</td></tr><tr><td>group_name</td><td>true</td></tr><tr><td>spend_limit</td><td>true</td></tr><tr><td>spend_limit_micros</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>false</td></tr><tr><td>username</td><td>false</td></tr></tbody></table> |
| Chat<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>agent_id</td><td>false</td></tr><tr><td>archived</td><td>true</td></tr><tr><td>build_id</td><td>false</td></tr><tr><td>client_type</td><td>false</td></tr><tr><td>compaction_requested_at</td><td>false</td></tr><tr><td>context_aggregate_hash</td><td>false</td></tr><tr><td>context_dirty_resources</td><td>false</td></tr><tr><td>context_dirty_since</td><td>false</td></tr><tr><td>context_error</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>dynamic_tools</td><td>false</td></tr><tr><td>generation_attempt</td><td>false</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>heartbeat_at</td><td>false</td></tr><tr><td>history_version</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>labels</td><td>true</td></tr><tr><td>last_error</td><td>false</td></tr><tr><td>last_model_config_id</td><td>false</td></tr><tr><td>last_read_message_id</td><td>false</td></tr><tr><td>last_reasoning_effort</td><td>false</td></tr><tr><td>last_turn_summary</td><td>false</td></tr><tr><td>mcp_server_ids</td><td>true</td></tr><tr><td>mode</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>owner_name</td><td>false</td></tr><tr><td>owner_username</td><td>false</td></tr><tr><td>parent_chat_id</td><td>false</td></tr><tr><td>pin_order</td><td>true</td></tr><tr><td>plan_mode</td><td>false</td></tr><tr><td>queue_version</td><td>false</td></tr><tr><td>requires_action_deadline_at</td><td>false</td></tr><tr><td>retry_state</td><td>false</td></tr><tr><td>retry_state_version</td><td>false</td></tr><tr><td>root_chat_id</td><td>false</td></tr><tr><td>runner_id</td><td>false</td></tr><tr><td>snapshot_version</td><td>false</td></tr><tr><td>started_at</td><td>false</td></tr><tr><td>status</td><td>false</td></tr><tr><td>summary</td><td>false</td></tr><tr><td>summary_generated_at</td><td>false</td></tr><tr><td>title</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr><tr><td>worker_id</td><td>false</td></tr><tr><td>workspace_id</td><td>true</td></tr></tbody></table> |
| ChatInstructionSettings<br><i>write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>id</td><td>false</td></tr><tr><td>include_default_system_prompt</td><td>true</td></tr><tr><td>include_default_system_prompt_set</td><td>true</td></tr><tr><td>name</td><td>false</td></tr><tr><td>plan_mode_instructions</td><td>true</td></tr><tr><td>system_prompt</td><td>true</td></tr></tbody></table> |
| ChatModelConfig<br><i>write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>ai_provider_id</td><td>true</td></tr><tr><td>compression_threshold</td><td>true</td></tr><tr><td>context_limit</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>deleted_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>enabled</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_default</td><td>true</td></tr><tr><td>model</td><td>true</td></tr><tr><td>options</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>updated_by</td><td>true</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| CustomRole<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_system</td><td>false</td></tr><tr><td>member_permissions</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>org_permissions</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>site_permissions</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_permissions</td><td>true</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>private_key_key_id</td><td>false</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| GroupSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>auto_create_missing_groups</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>legacy_group_name_mapping</td><td>false</td></tr><tr><td>mapping</td><td>true</td></tr><tr><td>regex_filter</td><td>true</td></tr></tbody></table> |
+230 -53
View File
@@ -596,59 +596,6 @@ Experimental: this endpoint is subject to change.
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## List chat models
### Code samples
```sh
# Example request using curl
curl -X GET http://coder-server:8080/api/experimental/chats/models \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /api/experimental/chats/models`
Experimental: this endpoint is subject to change.
### Example responses
> 200 Response
```json
{
"providers": [
{
"available": true,
"models": [
{
"display_name": "string",
"id": "string",
"model": "string",
"provider": "string"
}
],
"provider": "string",
"unavailable_reason": "missing_api_key"
}
],
"unsupported_providers": [
{
"display_name": "string",
"provider": "string"
}
]
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ChatModelAvailabilityResponse](schemas.md#codersdkchatmodelavailabilityresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Watch chat events for a user via WebSockets
### Code samples
@@ -3268,3 +3215,233 @@ Experimental: this endpoint is subject to change.
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## List available chat models in an organization
### Code samples
```sh
# Example request using curl
curl -X GET http://coder-server:8080/api/experimental/organizations/{organization}/chats/models/available \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /api/experimental/organizations/{organization}/chats/models/available`
Experimental: this endpoint is subject to change.
### Parameters
| Name | In | Type | Required | Description |
|----------------|------|--------|----------|-------------------------|
| `organization` | path | string | true | Organization name or ID |
### Example responses
> 200 Response
```json
{
"models": [
{
"ai_provider_id": "5a3b8ff9-20e7-4c37-ba1a-5b433e355819",
"compression_threshold": 0,
"context_limit": 0,
"created_at": "2019-08-24T14:15:22Z",
"display_name": "string",
"enabled": true,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_default": true,
"model": "string",
"model_config": {
"frequency_penalty": 0,
"max_output_tokens": 0,
"openai_config": {
"use_responses_api": true
},
"presence_penalty": 0,
"provider_options": {
"anthropic": {
"allowed_domains": [
"string"
],
"blocked_domains": [
"string"
],
"context_1m_enabled": true,
"disable_parallel_tool_use": true,
"send_reasoning": true,
"thinking": {
"budget_tokens": 0
},
"thinking_display": "string",
"web_search_enabled": true
},
"google": {
"cached_content": "string",
"safety_settings": [
{
"category": "string",
"threshold": "string"
}
],
"thinking_config": {
"include_thoughts": true,
"thinking_budget": 0,
"thinking_level": "string"
},
"threshold": "string",
"web_search_enabled": true
},
"openai": {
"allowed_domains": [
"string"
],
"include": [
"string"
],
"instructions": "string",
"log_probs": true,
"logit_bias": {
"property1": 0,
"property2": 0
},
"max_completion_tokens": 0,
"max_tool_calls": 0,
"metadata": {
"property1": null,
"property2": null
},
"parallel_tool_calls": true,
"prediction": {
"property1": null,
"property2": null
},
"prompt_cache_key": "string",
"reasoning_summary": "string",
"safety_identifier": "string",
"search_context_size": "string",
"service_tier": "string",
"store": true,
"strict_json_schema": true,
"structured_outputs": true,
"text_verbosity": "string",
"top_log_probs": 0,
"user": "string",
"web_search_enabled": true
},
"openaicompat": {
"user": "string"
},
"openrouter": {
"extra_body": {
"property1": null,
"property2": null
},
"include_usage": true,
"log_probs": true,
"logit_bias": {
"property1": 0,
"property2": 0
},
"parallel_tool_calls": true,
"provider": {
"allow_fallbacks": true,
"data_collection": "string",
"ignore": [
"string"
],
"only": [
"string"
],
"order": [
"string"
],
"quantizations": [
"string"
],
"require_parameters": true,
"sort": "string"
},
"reasoning": {
"enabled": true,
"exclude": true,
"max_tokens": 0
},
"user": "string"
},
"vercel": {
"extra_body": {
"property1": null,
"property2": null
},
"logit_bias": {
"property1": 0,
"property2": 0
},
"logprobs": true,
"parallel_tool_calls": true,
"providerOptions": {
"models": [
"string"
],
"order": [
"string"
]
},
"reasoning": {
"enabled": true,
"exclude": true,
"max_tokens": 0
},
"top_logprobs": 0,
"user": "string"
}
},
"reasoning_effort": {
"default": "string",
"max": "string"
},
"temperature": 0,
"top_k": 0,
"top_p": 0
},
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"reasoning_efforts": [
"string"
],
"updated_at": "2019-08-24T14:15:22Z"
}
],
"providers": [
{
"available": true,
"models": [
{
"display_name": "string",
"id": "string",
"model": "string",
"provider": "string"
}
],
"provider": "string",
"unavailable_reason": "missing_api_key"
}
],
"unsupported_providers": [
{
"display_name": "string",
"provider": "string"
}
]
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ChatModelAvailabilityResponse](schemas.md#codersdkchatmodelavailabilityresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+1737 -4
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -35,6 +35,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{
"AuditableGroupAIBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"AuditableUserAIBudgetOverride": {codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted.
"ChatModelConfig": {codersdk.AuditActionWrite},
"MCPServerConfig": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
@@ -502,6 +503,26 @@ var auditableResourcesTypes = map[any]map[string]Action{
"requires_action_deadline_at": ActionIgnore, // Internal pending-action deadline.
"compaction_requested_at": ActionIgnore, // Internal one-shot manual compaction signal.
},
&database.ChatModelConfig{}: {
"id": ActionIgnore, // Conveyed by resource_id.
"model": ActionTrack,
"display_name": ActionTrack,
"created_by": ActionTrack,
"updated_by": ActionTrack,
"enabled": ActionTrack,
"is_default": ActionTrack,
"deleted": ActionTrack,
"deleted_at": ActionIgnore,
"created_at": ActionIgnore,
"updated_at": ActionIgnore,
"context_limit": ActionTrack,
"compression_threshold": ActionTrack,
"options": ActionTrack,
"ai_provider_id": ActionTrack,
"organization_id": ActionIgnore,
"group_acl": ActionTrack,
"user_acl": ActionTrack,
},
&database.MCPServerConfig{}: {
"id": ActionIgnore, // Conveyed by resource_id, not useful in a diff.
"display_name": ActionTrack,
+110 -41
View File
@@ -14,7 +14,6 @@ import (
"github.com/coder/coder/v2/coderd/aibridgedtest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/ptr"
@@ -55,7 +54,9 @@ func createOpenAIChatModelForTest(
) codersdk.ChatModel {
t.Helper()
provider := createOpenAIProviderForTest(ctx, t, client, apiKey, baseURL)
model, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
defaultOrg, err := client.OrganizationByName(ctx, codersdk.DefaultOrganization)
require.NoError(t, err)
model, err := client.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-4",
DisplayName: "GPT-4",
@@ -945,6 +946,8 @@ func TestChatModelDefault(t *testing.T) {
expClient := codersdk.NewExperimentalClient(client)
provider := createOpenAIProviderForTest(ctx, t, expClient, "test", "https://example.com")
defaultOrg, err := expClient.OrganizationByName(ctx, codersdk.DefaultOrganization)
require.NoError(t, err)
contextLimit := int64(1000)
compressionThreshold := int32(70)
@@ -953,6 +956,7 @@ func TestChatModelDefault(t *testing.T) {
firstModel, err := expClient.CreateChatModel(
ctx,
defaultOrg.ID,
codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-5-a",
@@ -967,6 +971,7 @@ func TestChatModelDefault(t *testing.T) {
secondModel, err := expClient.CreateChatModel(
ctx,
defaultOrg.ID,
codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-5-b",
@@ -979,15 +984,16 @@ func TestChatModelDefault(t *testing.T) {
require.NoError(t, err)
require.True(t, secondModel.IsDefault)
models, err := expClient.ChatModels(ctx)
models, err := expClient.ChatModels(ctx, defaultOrg.ID)
require.NoError(t, err)
firstStored := findChatModelByID(t, models, firstModel.ID)
secondStored := findChatModelByID(t, models, secondModel.ID)
firstStored := findChatModelByID(t, models.Models, firstModel.ID)
secondStored := findChatModelByID(t, models.Models, secondModel.ID)
require.False(t, firstStored.IsDefault)
require.True(t, secondStored.IsDefault)
updatedFirst, err := expClient.UpdateChatModel(
ctx,
defaultOrg.ID,
firstModel.ID,
codersdk.UpdateChatModelRequest{
IsDefault: &trueValue,
@@ -996,15 +1002,16 @@ func TestChatModelDefault(t *testing.T) {
require.NoError(t, err)
require.True(t, updatedFirst.IsDefault)
models, err = expClient.ChatModels(ctx)
models, err = expClient.ChatModels(ctx, defaultOrg.ID)
require.NoError(t, err)
firstStored = findChatModelByID(t, models, firstModel.ID)
secondStored = findChatModelByID(t, models, secondModel.ID)
firstStored = findChatModelByID(t, models.Models, firstModel.ID)
secondStored = findChatModelByID(t, models.Models, secondModel.ID)
require.True(t, firstStored.IsDefault)
require.False(t, secondStored.IsDefault)
updatedFirst, err = expClient.UpdateChatModel(
ctx,
defaultOrg.ID,
firstModel.ID,
codersdk.UpdateChatModelRequest{
IsDefault: &falseValue,
@@ -1013,10 +1020,10 @@ func TestChatModelDefault(t *testing.T) {
require.NoError(t, err)
require.False(t, updatedFirst.IsDefault)
models, err = expClient.ChatModels(ctx)
models, err = expClient.ChatModels(ctx, defaultOrg.ID)
require.NoError(t, err)
firstStored = findChatModelByID(t, models, firstModel.ID)
secondStored = findChatModelByID(t, models, secondModel.ID)
firstStored = findChatModelByID(t, models.Models, firstModel.ID)
secondStored = findChatModelByID(t, models.Models, secondModel.ID)
require.False(t, firstStored.IsDefault)
require.True(t, secondStored.IsDefault)
}
@@ -1080,7 +1087,7 @@ func TestCreateChatUsesOrganizationLocalModel(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
client, firstUser := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: func() *codersdk.DeploymentValues {
v := coderdtest.DeploymentValues(t)
@@ -1096,36 +1103,31 @@ func TestCreateChatUsesOrganizationLocalModel(t *testing.T) {
expClient := codersdk.NewExperimentalClient(client)
provider := createOpenAIProviderForTest(ctx, t, expClient, "test-key", "https://example.com")
defaultModel, err := expClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
// Create a second (non-default) org via the API.
secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
// Strict organization scoping requires the chat and its default model in the same organization.
localModel, err := expClient.CreateChatModel(ctx, secondOrg.ID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-4o-mini",
DisplayName: "Default Organization Model",
Model: "gpt-4o-mini-local",
DisplayName: "Second Organization Model",
IsDefault: ptr.Ref(true),
ContextLimit: ptr.Ref(int64(1000)),
CompressionThreshold: ptr.Ref(int32(70)),
})
require.NoError(t, err)
secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
localModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "gpt-4o-mini-local",
DisplayName: "Second Organization Model",
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
OrganizationID: secondOrg.ID,
IsDefault: true,
})
require.NotEqual(t, defaultModel.ID, localModel.ID)
// Create a member with agents-access in both orgs.
memberClientRaw, member := coderdtest.CreateAnotherUser(
t, client, firstUser.OrganizationID,
rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID),
rbac.ScopedRoleAgentsAccess(secondOrg.ID),
)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
// Create a chat in the non-default org.
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: secondOrg.ID,
ModelConfigID: &localModel.ID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
@@ -1137,6 +1139,79 @@ func TestCreateChatUsesOrganizationLocalModel(t *testing.T) {
require.Equal(t, secondOrg.ID, chat.OrganizationID)
require.Equal(t, member.ID, chat.OwnerID)
require.Equal(t, localModel.ID, chat.LastModelConfigID)
// Verify the chat is visible when listing.
chats, err := memberClient.ListChats(ctx, nil)
require.NoError(t, err)
var found bool
for _, c := range chats {
if c.ID == chat.ID {
found = true
require.Equal(t, secondOrg.ID, c.OrganizationID)
break
}
}
require.True(t, found, "chat should be visible in list")
}
// TestCreateChatCrossOrgModelConfigRejected proves an explicit
// model_config_id naming a config in a DIFFERENT org than the chat is
// rejected as unavailable: post-cutover validation is org-aware, so a
// config outside the chat's org is "not found or disabled" to the creator.
func TestCreateChatCrossOrgModelConfigRejected(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, firstUser := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
expClient := codersdk.NewExperimentalClient(client)
provider := createOpenAIProviderForTest(ctx, t, expClient, "test-key", "https://example.com")
// The config lives in the default org.
defaultConfig, err := expClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-4o-mini",
DisplayName: "Default Org Model",
IsDefault: ptr.Ref(true),
ContextLimit: ptr.Ref(int64(1000)),
CompressionThreshold: ptr.Ref(int32(70)),
})
require.NoError(t, err)
secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
// A member with agents-access in both orgs still cannot bind a chat in
// the second org to the default org's config.
memberClientRaw, _ := coderdtest.CreateAnotherUser(
t, client, firstUser.OrganizationID,
rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID),
rbac.ScopedRoleAgentsAccess(secondOrg.ID),
)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
_, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: secondOrg.ID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
}},
ModelConfigID: ptr.Ref(defaultConfig.ID),
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message)
}
func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) {
@@ -1144,7 +1219,7 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
client, firstUser := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: func() *codersdk.DeploymentValues {
v := coderdtest.DeploymentValues(t)
@@ -1160,7 +1235,13 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) {
expClient := codersdk.NewExperimentalClient(client)
provider := createOpenAIProviderForTest(ctx, t, expClient, "test-key", "https://example.com")
_, err := expClient.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
// Create a second (non-default) org.
secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
// Strict org scoping: the chats below live in the second org, so
// their default model config must too.
_, err := expClient.CreateChatModel(ctx, secondOrg.ID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-4o-mini",
DisplayName: "Test Model",
@@ -1170,16 +1251,6 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) {
})
require.NoError(t, err)
// Create a second (non-default) org.
secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
localModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "gpt-4o-mini-local",
DisplayName: "Second Organization Model",
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
OrganizationID: secondOrg.ID,
IsDefault: true,
})
// Create a member with agents-access in both orgs.
memberClientRaw, _ := coderdtest.CreateAnotherUser(
t, client, firstUser.OrganizationID,
@@ -1190,7 +1261,6 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) {
// Member creates a chat in the second org.
memberChat, err := memberExp.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: secondOrg.ID,
ModelConfigID: &localModel.ID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
@@ -1211,7 +1281,6 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) {
// Admin creates a chat in the second org.
adminChat, err := adminExp.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: secondOrg.ID,
ModelConfigID: &localModel.ID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
+2 -2
View File
@@ -22,8 +22,8 @@ type chatClient interface {
var _ chatClient = (*codersdk.ExperimentalClient)(nil)
type chatModelClient interface {
ChatModels(ctx context.Context) ([]codersdk.ChatModel, error)
CreateChatModel(ctx context.Context, req codersdk.CreateChatModelRequest) (codersdk.ChatModel, error)
ChatModels(ctx context.Context, organizationID uuid.UUID) (codersdk.OrganizationChatModelsResponse, error)
CreateChatModel(ctx context.Context, organizationID uuid.UUID, req codersdk.CreateChatModelRequest) (codersdk.ChatModel, error)
}
var _ chatModelClient = (*codersdk.ExperimentalClient)(nil)
+21 -23
View File
@@ -38,17 +38,18 @@ const (
scaletestAIProviderActionReused scaletestAIProviderAction = "reused"
)
// EnsureScaletestChatModel bootstraps the shared AI provider and ChatModel
// used by chat scaletests. When the provider was created or updated, it waits
// for propagationWait so every coderd replica's cached provider expires.
func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logger slog.Logger, llmMockURL string, propagationWait time.Duration) (uuid.UUID, error) {
// EnsureScaletestChatModel bootstraps the shared AI provider used by chat
// scaletests. It returns a function that creates or reuses the ChatModel for
// each organization. When the provider changes, it waits for cached provider
// configurations to expire before chats start.
func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logger slog.Logger, llmMockURL string, propagationWait time.Duration) (func(organizationID uuid.UUID) (uuid.UUID, error), error) {
expClient := codersdk.NewExperimentalClient(client)
logger.Info(ctx, "bootstrapping mock LLM provider", slog.F("llm_mock_url", llmMockURL))
provider, providerAction, err := ensureScaletestAIProvider(ctx, expClient, llmMockURL)
if err != nil {
return uuid.Nil, err
return nil, err
}
switch providerAction {
@@ -71,11 +72,6 @@ func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logg
)
}
modelID, err := ensureScaletestChatModelRecord(ctx, expClient, logger, provider)
if err != nil {
return uuid.Nil, err
}
if providerAction != scaletestAIProviderActionReused && propagationWait > 0 {
logger.Info(ctx, "waiting for mock LLM provider propagation",
slog.F("provider_name", provider.Name),
@@ -83,38 +79,40 @@ func EnsureScaletestChatModel(ctx context.Context, client *codersdk.Client, logg
)
select {
case <-ctx.Done():
return uuid.Nil, ctx.Err()
return nil, ctx.Err()
case <-time.After(propagationWait):
}
}
return modelID, nil
return func(organizationID uuid.UUID) (uuid.UUID, error) {
return ensureScaletestChatModelRecord(ctx, expClient, logger, provider, organizationID)
}, nil
}
func ensureScaletestChatModelRecord(ctx context.Context, client chatModelClient, logger slog.Logger, provider codersdk.AIProvider) (uuid.UUID, error) {
models, err := client.ChatModels(ctx)
func ensureScaletestChatModelRecord(ctx context.Context, client chatModelClient, logger slog.Logger, provider codersdk.AIProvider, organizationID uuid.UUID) (uuid.UUID, error) {
resp, err := client.ChatModels(ctx, organizationID)
if err != nil {
return uuid.Nil, xerrors.Errorf("list chat models: %w", err)
}
for i := range models {
matchesProvider := models[i].AIProviderID == provider.ID
matchesModel := models[i].Model == scaletestModelName
for i := range resp.Models {
matchesProvider := resp.Models[i].AIProviderID == provider.ID
matchesModel := resp.Models[i].Model == scaletestModelName
if !matchesProvider || !matchesModel {
continue
}
if !models[i].Enabled {
return uuid.Nil, xerrors.Errorf("existing scaletest ChatModel %s is disabled; re-enable or delete it before running scaletests", models[i].ID)
if !resp.Models[i].Enabled {
return uuid.Nil, xerrors.Errorf("existing scaletest ChatModel %s is disabled; re-enable or delete it before running scaletests", resp.Models[i].ID)
}
modelID := models[i].ID
logger.Info(ctx, "reusing scaletest ChatModel", slog.F("model_id", modelID))
modelID := resp.Models[i].ID
logger.Info(ctx, "reusing scaletest ChatModel", slog.F("model_id", modelID), slog.F("organization_id", organizationID))
return modelID, nil
}
enabled := true
isDefault := false
contextLimit := scaletestModelContextLimit
created, err := client.CreateChatModel(ctx, codersdk.CreateChatModelRequest{
created, err := client.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: scaletestModelName,
DisplayName: scaletestModelDisplayName,
@@ -125,7 +123,7 @@ func ensureScaletestChatModelRecord(ctx context.Context, client chatModelClient,
if err != nil {
return uuid.Nil, xerrors.Errorf("create scaletest ChatModel: %w", err)
}
logger.Info(ctx, "created scaletest ChatModel", slog.F("model_id", created.ID))
logger.Info(ctx, "created scaletest ChatModel", slog.F("model_id", created.ID), slog.F("organization_id", organizationID))
return created.ID, nil
}
+63 -7
View File
@@ -2830,11 +2830,11 @@ export interface ChatMessagesResponse {
// From codersdk/chats.go
/**
* ChatModel is an admin-managed model record for an organization.
* It is not a discovery catalog entry. See ChatModelCatalogEntry.
* ChatModel is an org-scoped model configuration.
*/
export interface ChatModel {
readonly id: string;
readonly organization_id: string;
readonly ai_provider_id: string;
readonly model: string;
readonly display_name: string;
@@ -2852,6 +2852,16 @@ export interface ChatModel {
readonly updated_at: string;
}
// From codersdk/chats.go
/**
* ChatModelACL is the access control list for an organization-scoped chat
* model. Each principal is mapped to its effective model role.
*/
export interface ChatModelACL {
readonly user_roles: Record<string, ChatRole>;
readonly group_roles: Record<string, ChatRole>;
}
// From codersdk/chats.go
/**
* ChatModelAnthropicProviderOptions configures Anthropic provider behavior.
@@ -2877,10 +2887,14 @@ export interface ChatModelAnthropicThinkingOptions {
// From codersdk/chats.go
/**
* ChatModelAvailabilityResponse groups the discovery catalog by provider and
* reports provider availability.
* ChatModelAvailabilityResponse is the catalog returned from chat model discovery.
*/
export interface ChatModelAvailabilityResponse {
/**
* Models contains the effective runtime model configs for the requested
* organization. Each config belongs to that organization.
*/
readonly models?: readonly ChatModel[];
readonly providers: readonly ChatModelProvider[];
/**
* UnsupportedProviders lists configured providers the Agents harness
@@ -2907,9 +2921,9 @@ export interface ChatModelCallConfig {
// From codersdk/chats.go
/**
* ChatModelCatalogEntry is a discovery catalog entry for end users.
* Its ID is a synthetic provider:model value, not the UUID in ChatModel.ID.
* It is not an admin-managed record. See ChatModel.
* ChatModelCatalogEntry is the runtime catalog view of a model. Its ID is the
* synthetic catalog identity `provider:model` built by canonicalModelID, not
* the chat_model_configs row UUID that ChatModel.ID carries.
*/
export interface ChatModelCatalogEntry {
readonly id: string;
@@ -3065,6 +3079,26 @@ export interface ChatModelProvider {
readonly models: readonly ChatModelCatalogEntry[];
}
// From codersdk/chats.go
/**
* ChatModelProviderDescriptor is the redacted view of an AI provider carried
* on the org model collection response. It carries only the capability
* metadata the Models UI needs; key material, base URLs, and headers are
* never exposed. The fields mirror what /api/experimental/chats/models
* already discloses to any authenticated caller.
*/
export interface ChatModelProviderDescriptor {
readonly id: string;
readonly type: string;
readonly display_name: string;
readonly icon: string;
readonly enabled: boolean;
readonly has_api_key: boolean;
readonly has_user_api_key: boolean;
readonly has_effective_api_key: boolean;
readonly allow_user_api_key: boolean;
}
// From codersdk/chats.go
/**
* ChatModelProviderOptions contains typed provider-specific options.
@@ -7019,6 +7053,17 @@ export interface Organization extends MinimalOrganization {
readonly default_org_member_roles: readonly string[];
}
// From codersdk/chats.go
/**
* OrganizationChatModelsResponse is the org chat model config collection:
* the caller-readable configs plus the redacted provider descriptors the
* authoring page needs.
*/
export interface OrganizationChatModelsResponse {
readonly models: readonly ChatModel[];
readonly providers: readonly ChatModelProviderDescriptor[];
}
// From codersdk/aibridge.go
/**
* OrganizationGroupAISpend is the current AI spend snapshot for a group
@@ -9780,6 +9825,17 @@ export interface UpdateChatDebugRetentionDaysRequest {
readonly debug_retention_days: number;
}
// From codersdk/chats.go
/**
* UpdateChatModelACLRequest is a sparse update of a chat model ACL. Only the
* listed principals change. ChatRoleDeleted removes an entry, while an omitted
* map or principal is unchanged.
*/
export interface UpdateChatModelACLRequest {
readonly user_roles?: Record<string, ChatRole>;
readonly group_roles?: Record<string, ChatRole>;
}
// From codersdk/chats.go
/**
* UpdateChatModelOverrideRequest is the request body for updating the chat
@@ -27,6 +27,7 @@ const MockAnthropicProviderConfig: ChatProviderConfig = {
};
export const mockGPT5: ChatModel = {
organization_id: "00000000-0000-0000-0000-000000000000",
id: "model-gpt5",
ai_provider_id: "prov-openai",
model: "gpt-5",
@@ -61,6 +61,7 @@ const defaultModelID = "model-config-1";
const defaultModels: TypesGen.ChatModel[] = [
{
organization_id: "00000000-0000-0000-0000-000000000000",
id: defaultModelID,
ai_provider_id: "provider-openai",
model: "gpt-4o",
@@ -366,6 +367,7 @@ const meta: Meta<typeof AgentsPageLayout> = {
});
spyOn(API.experimental, "getChatModels").mockResolvedValue([
{
organization_id: "00000000-0000-0000-0000-000000000000",
id: defaultModelID,
ai_provider_id: "provider-openai",
model: "gpt-4o",
@@ -51,6 +51,7 @@ const defaultModelOptions: ModelSelectorOption[] = [
const defaultModels: TypesGen.ChatModel[] = [
{
organization_id: "00000000-0000-0000-0000-000000000000",
id: "config-openai-gpt-4o",
ai_provider_id: "prov-1",
model: "gpt-4o",
@@ -582,6 +582,7 @@ describe("ChatsSidebar model display names", () => {
];
const models: TypesGen.ChatModel[] = [
{
organization_id: "00000000-0000-0000-0000-000000000000",
id: "config-fast",
ai_provider_id: "prov-openai",
model: "gpt-4o",
@@ -594,6 +595,7 @@ describe("ChatsSidebar model display names", () => {
updated_at: oneWeekAgo,
},
{
organization_id: "00000000-0000-0000-0000-000000000000",
id: "config-quality",
ai_provider_id: "prov-openai",
model: "gpt-4o",
+1
View File
@@ -7,6 +7,7 @@ import type {
import { MOCK_TIMESTAMP } from "./chatEntities";
export const MockChatModel: ChatModel = {
organization_id: "00000000-0000-0000-0000-000000000000",
id: "model-1",
ai_provider_id: "provider-1",
model: "gpt-5",