diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 1b34833f8d019..a9c041da0fad8 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -483,7 +483,6 @@ type sqlcQuerier interface { GetChatMessagesForPromptByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error) GetChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error) - // Returns all model configurations for telemetry snapshot collection. // 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 diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a992982283781..65e8b7e47462b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8651,22 +8651,22 @@ func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatI } const getChatModelConfigsForTelemetry = `-- name: GetChatModelConfigsForTelemetry :many -SELECT cmc.id, ap.type::text AS provider, cmc.model, cmc.context_limit, cmc.enabled, cmc.is_default +SELECT cmc.id, ap.type::text AS provider, cmc.model, cmc.context_limit, cmc.enabled, cmc.is_default, cmc.organization_id FROM chat_model_configs cmc JOIN ai_providers ap ON ap.id = cmc.ai_provider_id WHERE cmc.deleted = false ` type GetChatModelConfigsForTelemetryRow struct { - ID uuid.UUID `db:"id" json:"id"` - Provider string `db:"provider" json:"provider"` - Model string `db:"model" json:"model"` - ContextLimit int64 `db:"context_limit" json:"context_limit"` - Enabled bool `db:"enabled" json:"enabled"` - IsDefault bool `db:"is_default" json:"is_default"` + ID uuid.UUID `db:"id" json:"id"` + Provider string `db:"provider" json:"provider"` + Model string `db:"model" json:"model"` + ContextLimit int64 `db:"context_limit" json:"context_limit"` + Enabled bool `db:"enabled" json:"enabled"` + IsDefault bool `db:"is_default" json:"is_default"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` } -// Returns all model configurations for telemetry snapshot collection. // deleted = false guarantees ai_provider_id is non-null, so INNER JOIN is safe. func (q *sqlQuerier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]GetChatModelConfigsForTelemetryRow, error) { rows, err := q.db.QueryContext(ctx, getChatModelConfigsForTelemetry) @@ -8684,6 +8684,7 @@ func (q *sqlQuerier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]Get &i.ContextLimit, &i.Enabled, &i.IsDefault, + &i.OrganizationID, ); err != nil { return nil, err } @@ -9672,7 +9673,7 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID const getChatsUpdatedAfter = `-- name: GetChatsUpdatedAfter :many SELECT - c.id, c.owner_id, c.created_at, c.updated_at, c.status, + c.id, c.owner_id, c.organization_id, c.created_at, c.updated_at, c.status, (c.parent_chat_id IS NOT NULL)::bool AS has_parent, c.root_chat_id, c.workspace_id, c.mode, c.archived, c.last_model_config_id, c.client_type, @@ -9685,6 +9686,7 @@ WHERE c.updated_at > $1 type GetChatsUpdatedAfterRow struct { ID uuid.UUID `db:"id" json:"id"` OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Status ChatStatus `db:"status" json:"status"` @@ -9713,6 +9715,7 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time if err := rows.Scan( &i.ID, &i.OwnerID, + &i.OrganizationID, &i.CreatedAt, &i.UpdatedAt, &i.Status, diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index a320e0163dfc4..7f6d667bea1f8 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -2246,7 +2246,7 @@ WHERE chats.id = deletable.id -- snapshot collection. Uses updated_at so that long-running chats -- still appear in each snapshot window while they are active. SELECT - c.id, c.owner_id, c.created_at, c.updated_at, c.status, + c.id, c.owner_id, c.organization_id, c.created_at, c.updated_at, c.status, (c.parent_chat_id IS NOT NULL)::bool AS has_parent, c.root_chat_id, c.workspace_id, c.mode, c.archived, c.last_model_config_id, c.client_type, @@ -2280,9 +2280,8 @@ WHERE cm.created_at > @created_after GROUP BY cm.chat_id; -- name: GetChatModelConfigsForTelemetry :many --- Returns all model configurations for telemetry snapshot collection. -- deleted = false guarantees ai_provider_id is non-null, so INNER JOIN is safe. -SELECT cmc.id, ap.type::text AS provider, cmc.model, cmc.context_limit, cmc.enabled, cmc.is_default +SELECT cmc.id, ap.type::text AS provider, cmc.model, cmc.context_limit, cmc.enabled, cmc.is_default, cmc.organization_id FROM chat_model_configs cmc JOIN ai_providers ap ON ap.id = cmc.ai_provider_id WHERE cmc.deleted = false; diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 3574fdbd8d022..5a6ea82f2c202 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -2270,6 +2270,7 @@ func ConvertChat(dbChat database.GetChatsUpdatedAfterRow) Chat { c := Chat{ ID: dbChat.ID, OwnerID: dbChat.OwnerID, + OrganizationID: dbChat.OrganizationID, CreatedAt: dbChat.CreatedAt, UpdatedAt: dbChat.UpdatedAt, Status: string(dbChat.Status), @@ -2319,12 +2320,13 @@ func ConvertChatMessageSummary(dbRow database.GetChatMessageSummariesPerChatRow) // telemetry ChatModelConfig. func ConvertChatModelConfig(dbRow database.GetChatModelConfigsForTelemetryRow) ChatModelConfig { return ChatModelConfig{ - ID: dbRow.ID, - Provider: dbRow.Provider, - Model: dbRow.Model, - ContextLimit: dbRow.ContextLimit, - Enabled: dbRow.Enabled, - IsDefault: dbRow.IsDefault, + ID: dbRow.ID, + OrganizationID: dbRow.OrganizationID, + Provider: dbRow.Provider, + Model: dbRow.Model, + ContextLimit: dbRow.ContextLimit, + Enabled: dbRow.Enabled, + IsDefault: dbRow.IsDefault, } } @@ -2577,6 +2579,7 @@ type BoundaryUsageSummary struct { type Chat struct { ID uuid.UUID `json:"id"` OwnerID uuid.UUID `json:"owner_id"` + OrganizationID uuid.UUID `json:"organization_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Status string `json:"status"` @@ -2612,12 +2615,14 @@ type ChatMessageSummary struct { // ChatModelConfig contains model configuration metadata for // telemetry. Sensitive fields like API keys are excluded. type ChatModelConfig struct { - ID uuid.UUID `json:"id"` - Provider string `json:"provider"` - Model string `json:"model"` - ContextLimit int64 `json:"context_limit"` - Enabled bool `json:"enabled"` - IsDefault bool `json:"is_default"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + Provider string `json:"provider"` + Model string `json:"model"` + ContextLimit int64 `json:"context_limit"` + Enabled bool `json:"enabled"` + // Each organization has at most one default configuration. + IsDefault bool `json:"is_default"` } // ChatDiffStatusSummary contains aggregate PR counts across all diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index a03fe05abdb69..30d2e881766b0 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -1648,14 +1648,15 @@ func TestChatsTelemetry(t *testing.T) { ContextLimit: 200000, }) - // Create a second model config to test full dump. + org2 := dbgen.Organization(t, db, database.Organization{}) modelCfg2 := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - AIProviderID: uuid.NullUUID{UUID: openaiProvider.ID, Valid: true}, - Model: "gpt-4o", - DisplayName: "GPT-4o", + AIProviderID: uuid.NullUUID{UUID: openaiProvider.ID, Valid: true}, + OrganizationID: org2.ID, + Model: "gpt-4o", + DisplayName: "GPT-4o", }) - // Create a soft-deleted model config — should NOT appear in telemetry. + // Soft-deleted model configurations must not appear in telemetry. deletedCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ AIProviderID: uuid.NullUUID{UUID: anthropicProvider.ID, Valid: true}, Model: "claude-deleted", @@ -1814,9 +1815,7 @@ func TestChatsTelemetry(t *testing.T) { ProviderResponseID: sql.NullString{String: "resp-3", Valid: true}, }) - // Insert a soft-deleted message on root chat with large token values. - // This acts as "poison" — if the deleted filter is missing, totals - // will be inflated and assertions below will fail. + // Large token values expose a missing soft-delete filter in the totals. poisonMsg := dbgen.ChatMessage(t, db, database.ChatMessage{ ChatID: rootChat.ID, ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, @@ -1854,6 +1853,7 @@ func TestChatsTelemetry(t *testing.T) { // Root chat assertions. assert.Equal(t, rootChat.ID, foundRoot.ID) assert.Equal(t, user.ID, foundRoot.OwnerID) + assert.Equal(t, org.ID, foundRoot.OrganizationID) assert.Equal(t, "running", foundRoot.Status) assert.False(t, foundRoot.HasParent) assert.Nil(t, foundRoot.RootChatID) @@ -1935,6 +1935,7 @@ func TestChatsTelemetry(t *testing.T) { cfg1, ok := configMap[modelCfg.ID] require.True(t, ok) + assert.Equal(t, org.ID, cfg1.OrganizationID) assert.Equal(t, "anthropic", cfg1.Provider) assert.Equal(t, "claude-sonnet-4-20250514", cfg1.Model) assert.Equal(t, int64(200000), cfg1.ContextLimit) @@ -1943,6 +1944,7 @@ func TestChatsTelemetry(t *testing.T) { cfg2, ok := configMap[modelCfg2.ID] require.True(t, ok) + assert.Equal(t, org2.ID, cfg2.OrganizationID) assert.Equal(t, "openai", cfg2.Provider) assert.Equal(t, "gpt-4o", cfg2.Model) assert.Equal(t, int64(128000), cfg2.ContextLimit)