mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+947
-26
File diff suppressed because it is too large
Load Diff
Generated
+913
-22
@@ -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": {
|
||||
|
||||
@@ -39,6 +39,7 @@ type Auditable interface {
|
||||
database.AIProviderKey |
|
||||
database.AIGatewayKey |
|
||||
database.Chat |
|
||||
database.ChatModelConfig |
|
||||
database.MCPServerConfig |
|
||||
database.AuditableGroupAIBudget |
|
||||
database.AuditableUserAIBudgetOverride |
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Generated
+23
-38
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
|
||||
Generated
+63
-70
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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> |
|
||||
|
||||
Generated
+230
-53
@@ -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).
|
||||
|
||||
Generated
+1737
-4
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Generated
+63
-7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user