feat: add synthetic gateway keys (#27170)

> Mux is working on behalf of Mike.

## Summary

Add a per-user synthetic API key for chatd AI Gateway attribution. Chatd
resolves the key from the chat owner, extends it before expiry, and
discards the generated bearer token so the key is never a usable
credential.

There is no mapping table. The key is resolved from `api_keys` by a
deterministic token name (`chatd_<owner_id>_session_token`), mirroring
the provisionerd session token model, with three deltas that chatd
needs:

- **Login type guard**: token names are unvalidated user input, so a
user can create a bearer token with the colliding name. The lookup
excludes `login_type = 'token'` rows, so chatd never picks up (or
extends) a real user token. Synthetic keys are minted with the owner's
login type, which is never `token`.
- **In-place expiry extension instead of delete-and-reinsert**: chat
generations have no stop boundary, and an in-flight generation may have
already delegated the current key ID to aibridged. Extending
`expires_at` keeps the key ID stable forever.
- **Advisory-lock mint**: the unique index on token names is partial
(`WHERE login_type = 'token'`), so nothing DB-enforces uniqueness for
synthetic keys. A per-user advisory lock serializes concurrent mints.

Keys carry a minimal scope (`api_key:read`) as defense in depth; the
delegated gateway path never evaluates scopes and the secret is
discarded at mint.

Migration 000544 removes the foreign keys from the legacy message and
queue `api_key_id` columns while chatd continues stamping them for
rolling compatibility. Stale IDs are tolerated because routing uses
`chats.owner_id`. Individual key deletion, delete-all, and password
reset remove the key without changing chat history or queue versions,
and the next lookup remints it. Suspension does not delete the key;
delegated gateway authorization rejects inactive users at request time.

This is the first PR in a three-PR rollout and must be fully deployed
before #27171.

Refs
https://linear.app/codercom/issue/CODAGT-561/maintain-synthetic-api-key-per-user-per-chat
This commit is contained in:
Michael Suchacz
2026-07-18 20:45:13 +02:00
committed by GitHub
parent 4d4d2575e4
commit 997b5d0843
35 changed files with 1047 additions and 876 deletions
+10
View File
@@ -7,6 +7,16 @@ Chatd has 4 main pieces:
- **chat worker**: lives inside every coderd replica. It acquires chats, calls the LLM API, executes tools, handles interrupts and tool-result waits, and commits completed outcomes through the core state machine.
- **stream loop**: powers `GET /api/experimental/chats/{chat}/stream`, the WebSocket endpoint that the UI uses to consume a live chat. It combines two kinds of data: messages committed to the database and streaming message parts emitted by the chat worker. It receives notifications over pubsub whenever the chat state is updated, fetches messages from the database, and connects to the coderd replica that currently owns the chat to relay the streaming message parts to the client.
# Gateway attribution keys
Chatd attributes AI Gateway requests with a synthetic API key owned by the chat owner, one key per user. There is no mapping table: the key is found in `api_keys` by its deterministic token name, `chatd_<owner_id>_session_token`, excluding `login_type = 'token'` rows. Token names are unvalidated user input, so the login type filter ensures chatd never picks up (or extends) a real bearer token a user created with the colliding name. Synthetic keys are minted with the owner's login type, which is never `'token'`. All chatd AI Gateway attribution resolves the key from `chats.owner_id`; callers do not provide the key ID.
Synthetic keys expire after 30 days. When less than 24 hours remain, chatd extends the expiry of the existing row in place instead of replacing it, because an in-flight generation may have already delegated the current key ID to the gateway. The key ID is therefore stable for the lifetime of the user. Mints and extensions are serialized with a per-user advisory lock, since the partial unique index on token names only covers `login_type = 'token'` rows. The generated token is discarded, so the stored key cannot be used as a bearer credential, and it carries a minimal scope as defense in depth.
The legacy `api_key_id` columns on messages and queued messages are still stamped with the synthetic key for rolling deployment compatibility, but they no longer have foreign keys to `api_keys`. They are not the source of gateway routing. Stale IDs are harmless because chatd resolves attribution from `chats.owner_id`.
Deleting a synthetic key (password reset, explicit key deletion, dbpurge of long-expired keys) does not touch chat messages, queued messages, or their version fields. Chatd mints a replacement on the next request without mutating history. User suspension and deletion still block delegated gateway authorization.
# Core state machine
The core state machine describes how a chat's execution state in the database can change over time. A fundamental component of the state machine is the set of valid **states** it can be in. We will consider 2 kinds of states: **execution states** and **ownership states**. These states let us describe what the runtime components of chatd can do with a chat at a given point in time.
+29 -53
View File
@@ -1174,7 +1174,6 @@ type CreateOptions struct {
ClientType database.ChatClientType
SystemPrompt string
InitialUserContent []codersdk.ChatMessagePart
APIKeyID string
MCPServerIDs []uuid.UUID
Labels database.StringMap
DynamicTools json.RawMessage
@@ -1200,7 +1199,6 @@ type SendMessageOptions struct {
Content []codersdk.ChatMessagePart
ModelConfigID uuid.UUID
ReasoningEffort *string
APIKeyID string
BusyBehavior SendMessageBusyBehavior
PlanMode *database.NullChatPlanMode
MCPServerIDs *[]uuid.UUID
@@ -1220,7 +1218,6 @@ type EditMessageOptions struct {
CreatedBy uuid.UUID
EditedMessageID int64
Content []codersdk.ChatMessagePart
APIKeyID string
// ModelConfigID, when non-zero, overrides the model used for
// the replacement user message. When set to uuid.Nil the
// original message's model is preserved.
@@ -1249,13 +1246,6 @@ type PromoteQueuedResult struct {
PromotedMessage database.ChatMessage
}
func validateChatUserMessageAPIKeyID(apiKeyID string) error {
if apiKeyID == "" {
return xerrors.New("api_key_id is required for user chat messages")
}
return nil
}
// CreateChat creates a chat with its initial history through
// chatstate.CreateChat. The new chat starts in `running` status per
// the chat execution state model. Ownership hints wake chat workers.
@@ -1272,9 +1262,6 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
if len(opts.InitialUserContent) == 0 {
return database.Chat{}, xerrors.New("initial user content is required")
}
if err := validateChatUserMessageAPIKeyID(opts.APIKeyID); err != nil {
return database.Chat{}, err
}
// Ensure MCPServerIDs is non-nil so pq.Array produces '{}'
// instead of SQL NULL, which violates the NOT NULL column
// constraint.
@@ -1298,6 +1285,11 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
return database.Chat{}, limitErr
}
apiKeyID, err := p.ensureSyntheticAPIKeyID(ctx, opts.OwnerID)
if err != nil {
return database.Chat{}, xerrors.Errorf("ensure synthetic API key: %w", err)
}
labelsJSON, err := json.Marshal(opts.Labels)
if err != nil {
return database.Chat{}, xerrors.Errorf("marshal labels: %w", err)
@@ -1339,7 +1331,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
initialMessages = append(initialMessages, systemMessage(userPromptContent, opts.ModelConfigID))
}
initialMessages = append(initialMessages, systemMessage(workspaceAwarenessContent, opts.ModelConfigID))
initialMessages = append(initialMessages, userMessageWithAPIKeyID(userContent, opts.ModelConfigID, opts.OwnerID, opts.APIKeyID, opts.ReasoningEffort))
initialMessages = append(initialMessages, userMessageWithAPIKeyID(userContent, opts.ModelConfigID, opts.OwnerID, apiKeyID, opts.ReasoningEffort))
result, err := chatstate.CreateChat(ctx, p.db, p.pubsub, chatstate.CreateChatInput{
OrganizationID: opts.OrganizationID,
@@ -1400,9 +1392,6 @@ func (p *Server) SendMessage(
if len(opts.Content) == 0 {
return SendMessageResult{}, xerrors.New("content is required")
}
if err := validateChatUserMessageAPIKeyID(opts.APIKeyID); err != nil {
return SendMessageResult{}, err
}
busyBehavior := opts.BusyBehavior
if busyBehavior == "" {
@@ -1422,6 +1411,15 @@ func (p *Server) SendMessage(
requestedPlanMode := opts.PlanMode
requestedMCPServerIDs := opts.MCPServerIDs
chat, err := p.db.GetChatByID(ctx, opts.ChatID)
if err != nil {
return SendMessageResult{}, xerrors.Errorf("load chat: %w", err)
}
apiKeyID, err := p.ensureSyntheticAPIKeyID(ctx, chat.OwnerID)
if err != nil {
return SendMessageResult{}, xerrors.Errorf("ensure synthetic API key: %w", err)
}
var result SendMessageResult
machine := p.newChatMachine(opts.ChatID)
updateErr := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
@@ -1486,7 +1484,7 @@ func (p *Server) SendMessage(
// Queue capacity is enforced inside tx.SendMessage; this
// wrapper only propagates the typed error.
sendResult, err := tx.SendMessage(chatstate.SendMessageInput{
Message: userMessageWithAPIKeyID(content, modelConfigID, messageCreatedBy, opts.APIKeyID, opts.ReasoningEffort),
Message: userMessageWithAPIKeyID(content, modelConfigID, messageCreatedBy, apiKeyID, opts.ReasoningEffort),
BusyBehavior: busyBehaviorToChatState(busyBehavior),
})
if err != nil {
@@ -1627,14 +1625,19 @@ func (p *Server) EditMessage(
if len(opts.Content) == 0 {
return EditMessageResult{}, xerrors.New("content is required")
}
if err := validateChatUserMessageAPIKeyID(opts.APIKeyID); err != nil {
return EditMessageResult{}, err
}
content, err := chatprompt.MarshalParts(opts.Content)
if err != nil {
return EditMessageResult{}, xerrors.Errorf("marshal message content: %w", err)
}
chat, err := p.db.GetChatByID(ctx, opts.ChatID)
if err != nil {
return EditMessageResult{}, xerrors.Errorf("load chat: %w", err)
}
apiKeyID, err := p.ensureSyntheticAPIKeyID(ctx, chat.OwnerID)
if err != nil {
return EditMessageResult{}, xerrors.Errorf("ensure synthetic API key: %w", err)
}
var (
result EditMessageResult
@@ -1705,7 +1708,7 @@ func (p *Server) EditMessage(
Content: content,
ModelConfigIDOverride: modelOverride,
ReasoningEffortOverride: reasoningEffortOverride,
APIKeyID: sql.NullString{String: opts.APIKeyID, Valid: opts.APIKeyID != ""},
APIKeyID: sql.NullString{String: apiKeyID, Valid: true},
})
if err != nil {
if errors.Is(err, chatstate.ErrEditedMessageNotUser) {
@@ -2248,7 +2251,6 @@ func (p *Server) ProposeChatTitle(
// generateManualTitleCandidate generates a title candidate from the chat's
// visible messages. It returns "" when the chat has no messages to summarize.
// Endpoint-specific commit paths decide whether to persist the title.
// The context may carry the caller's delegated API key for manual title routes.
func (p *Server) generateManualTitleCandidate(
ctx context.Context,
store database.Store,
@@ -2288,14 +2290,11 @@ func (p *Server) generateManualTitleCandidate(
if err != nil {
return "", xerrors.Errorf("get pasted-text attachments for manual title: %w", err)
}
modelOpts := modelBuildOptionsFromMessages(messages)
// Manual title routes can run over messages that lack API key attribution.
// Fall back to the authenticated caller's delegated key for AI Gateway routing.
if modelOpts.ActiveAPIKeyID == "" {
if apiKeyID, ok := aibridge.DelegatedAPIKeyIDFromContext(ctx); ok {
modelOpts.ActiveAPIKeyID = apiKeyID
}
apiKeyID, err := p.ensureSyntheticAPIKeyID(ctx, chat.OwnerID)
if err != nil {
return "", xerrors.Errorf("ensure synthetic API key: %w", err)
}
modelOpts := modelBuildOptions{ActiveAPIKeyID: apiKeyID}
model, modelConfig, err := p.resolveManualTitleModel(ctx, store, chat, modelOpts)
if err != nil {
@@ -3286,29 +3285,6 @@ type runChatResult struct {
HistoryTipMessageID int64
}
func activeTurnAPIKeyIDFromMessages(messages []database.ChatMessage) (string, bool) {
for i := len(messages) - 1; i >= 0; i-- {
message := messages[i]
if message.Role != database.ChatMessageRoleUser {
continue
}
if !isUserVisibleChatMessage(message) &&
!(message.Visibility == database.ChatMessageVisibilityModel && message.Compressed) {
continue
}
if !message.APIKeyID.Valid || message.APIKeyID.String == "" {
return "", false
}
return message.APIKeyID.String, true
}
return "", false
}
func isUserVisibleChatMessage(message database.ChatMessage) bool {
return message.Visibility == database.ChatMessageVisibilityBoth ||
message.Visibility == database.ChatMessageVisibilityUser
}
func allToolNames(allTools []fantasy.AgentTool) []string {
toolNames := make([]string, 0, len(allTools))
for _, tool := range allTools {
-2
View File
@@ -57,7 +57,6 @@ func filterAnthropicStreamingRequests(requests []chattest.AnthropicRequest) []ch
func seedAnthropicChatDependencies(t *testing.T, db database.Store, baseURL string) (database.User, database.Organization, database.ChatModelConfig) {
t.Helper()
user := dbgen.User(t, db, database.User{})
_ = testAPIKeyID(t, db, user.ID)
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
provider := dbgen.AIProvider(t, db, database.AIProvider{Type: database.AIProviderTypeAnthropic}, func(params *database.InsertAIProviderParams) {
@@ -157,7 +156,6 @@ func createChatThroughServer(
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: orgID,
OwnerID: userID,
APIKeyID: testAPIKeyID(t, db, userID),
Title: "test chat",
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText(text)},
ModelConfigID: modelID,
+2
View File
@@ -858,6 +858,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(database.ChatUsageLimitConfig{}, sql.ErrNoRows)
db.EXPECT().GetChatGatewayAPIKey(gomock.Any(), database.GetChatGatewayAPIKeyParams{UserID: ownerID, TokenName: GatewayTokenName(ownerID)}).Return(database.APIKey{ID: activeAPIKeyID, UserID: ownerID, ExpiresAt: time.Now().Add(48 * time.Hour)}, nil)
db.EXPECT().GetChatMessagesByChatIDAscPaginated(
gomock.Any(),
database.GetChatMessagesByChatIDAscPaginatedParams{
@@ -1009,6 +1010,7 @@ func TestRegenerateChatTitle_SkipsPersistWhenTitleChangedConcurrently(t *testing
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(database.ChatUsageLimitConfig{}, sql.ErrNoRows)
db.EXPECT().GetChatGatewayAPIKey(gomock.Any(), database.GetChatGatewayAPIKeyParams{UserID: ownerID, TokenName: GatewayTokenName(ownerID)}).Return(database.APIKey{ID: activeAPIKeyID, UserID: ownerID, ExpiresAt: time.Now().Add(48 * time.Hour)}, nil)
db.EXPECT().GetChatMessagesByChatIDAscPaginated(
gomock.Any(),
database.GetChatMessagesByChatIDAscPaginatedParams{
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -651,12 +651,7 @@ func (s *taskStarter) executeLocalTools(
provider = prepared.Model.Provider()
modelName = prepared.Model.Model()
}
// Local tool callbacks (e.g. spawn_agent, message_agent) read the
// active turn's delegated API key ID from the context to route
// subagent traffic through the AI Gateway. prepareGeneration sets it
// only on its own context, so re-derive it here for tool execution.
toolCtx := withActiveTurnAPIKeyID(ctx, prepared.ModelBuildOptions)
outcome, err := chatloop.ExecuteLocalTools(toolCtx, chatloop.ExecuteLocalToolsOptions{
outcome, err := chatloop.ExecuteLocalTools(ctx, chatloop.ExecuteLocalToolsOptions{
Tools: prepared.Tools,
ActiveTools: prepared.ActiveTools,
ProviderTools: prepared.ProviderTools,
+11 -5
View File
@@ -80,10 +80,12 @@ func (server *Server) prepareGeneration(
return generationPrepared{}, err
}
modelOpts = modelBuildOptionsFromMessages(promptRows)
ctx = withActiveTurnAPIKeyID(ctx, modelOpts)
apiKeyID, err := server.ensureSyntheticAPIKeyID(ctx, chat.OwnerID)
if err != nil {
return generationPrepared{}, xerrors.Errorf("ensure synthetic API key: %w", err)
}
modelOpts = modelBuildOptions{ActiveAPIKeyID: apiKeyID}
var err error
model, modelConfig, modelRoute, debugEnabled, resolvedProvider, debugModel, err = server.resolveChatModel(ctx, chat, modelOpts)
if err != nil {
return generationPrepared{}, err
@@ -760,8 +762,12 @@ func (server *Server) deriveFinalTurnRunResult(
// resolvedProvider/resolvedModel describe the model the fallback handle was
// built from; they only feed the status-label fallback candidate's labels.
modelOpts := modelBuildOptionsFromMessages(promptRows)
ctx = withActiveTurnAPIKeyID(ctx, modelOpts)
apiKeyID, err := server.ensureSyntheticAPIKeyID(ctx, chat.OwnerID)
if err != nil {
logger.Warn(ctx, "derive final turn status label: ensure synthetic API key", slog.Error(err))
return runChatResult{FinalAssistantText: finalAssistantText, TriggerMessageID: triggerMessageID, HistoryTipMessageID: historyTipMessageID}
}
modelOpts := modelBuildOptions{ActiveAPIKeyID: apiKeyID}
model, _, modelRoute, _, resolvedProvider, resolvedModel, err := server.resolveChatModel(ctx, chat, modelOpts)
if err != nil {
// Return what we have; generateFinalTurnStatusLabel falls back to a
-6
View File
@@ -23,12 +23,6 @@ import (
"github.com/coder/coder/v2/testutil"
)
func testAPIKeyID(t testing.TB, db database.Store, userID uuid.UUID) string {
t.Helper()
key, _ := dbgen.APIKey(t, db, database.APIKey{ID: uuid.NewString(), UserID: userID})
return key.ID
}
type workerTestFixture struct {
db database.Store
pubsub dbpubsub.Pubsub
@@ -72,7 +72,6 @@ func TestOpenAIResponsesNoStaleWebSearchReplay(t *testing.T) {
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: uniqueResponsesTitle(t, "no-stale"),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
@@ -87,7 +86,6 @@ func TestOpenAIResponsesNoStaleWebSearchReplay(t *testing.T) {
_, err = server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
CreatedBy: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
ModelConfigID: model.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("summarize the result without searching again"),
@@ -159,7 +157,6 @@ func TestOpenAIResponsesFullReplayPairsReasoningAndWebSearch(t *testing.T) {
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: uniqueResponsesTitle(t, "full-replay"),
ModelConfigID: firstModel.ID,
InitialUserContent: []codersdk.ChatMessagePart{
@@ -174,7 +171,6 @@ func TestOpenAIResponsesFullReplayPairsReasoningAndWebSearch(t *testing.T) {
_, err = server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
CreatedBy: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
ModelConfigID: secondModel.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("summarize the result without searching again"),
-17
View File
@@ -9,7 +9,6 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
)
@@ -29,22 +28,6 @@ type modelBuildOptions struct {
RecordHTTP bool
}
func modelBuildOptionsFromMessages(messages []database.ChatMessage) modelBuildOptions {
apiKeyID, _ := activeTurnAPIKeyIDFromMessages(messages)
return modelBuildOptions{ActiveAPIKeyID: apiKeyID}
}
// withActiveTurnAPIKeyID augments ctx with the active turn's delegated API
// key ID when one is known. AI Gateway routing and subagent tool callbacks
// read this value from the context to attribute requests to the correct
// turn. When no key is known, ctx is returned unchanged.
func withActiveTurnAPIKeyID(ctx context.Context, opts modelBuildOptions) context.Context {
if opts.ActiveAPIKeyID == "" {
return ctx
}
return aibridge.WithDelegatedAPIKeyID(ctx, opts.ActiveAPIKeyID)
}
func (p *Server) enabledAIProviderByID(ctx context.Context, providerID uuid.UUID) (database.AIProvider, error) {
provider, err := p.db.GetAIProviderByID(ctx, providerID)
if err != nil {
@@ -18,9 +18,7 @@ import (
"github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
@@ -367,215 +365,6 @@ func TestAIGatewayModelForwardsProviderAuth(t *testing.T) {
})
}
func TestActiveTurnAPIKeyIDFromMessages(t *testing.T) {
t.Parallel()
oldKeyID := uuid.NewString()
currentKeyID := uuid.NewString()
tests := []struct {
name string
messages []database.ChatMessage
wantKey string
wantOK bool
}{
{
name: "CurrentUserMessage",
messages: []database.ChatMessage{
{ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, APIKeyID: sqlNullString(oldKeyID)},
{ID: 2, Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth},
{ID: 3, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, APIKeyID: sqlNullString(currentKeyID)},
},
wantKey: currentKeyID,
wantOK: true,
},
{
name: "MissingCurrentUserAPIKeyDoesNotFallBack",
messages: []database.ChatMessage{
{ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, APIKeyID: sqlNullString(oldKeyID)},
{ID: 2, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth},
},
},
{
name: "SkipsUncompressedModelOnlyUserMessages",
messages: []database.ChatMessage{
{ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, APIKeyID: sqlNullString(oldKeyID)},
{ID: 2, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, APIKeyID: sqlNullString(currentKeyID)},
},
wantKey: oldKeyID,
wantOK: true,
},
{
name: "CompressedSummaryFallback",
messages: []database.ChatMessage{
{ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true, APIKeyID: sqlNullString(currentKeyID)},
{ID: 2, Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth},
},
wantKey: currentKeyID,
wantOK: true,
},
{
name: "LatestCompressedSummaryWins",
messages: []database.ChatMessage{
{ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true, APIKeyID: sqlNullString(oldKeyID)},
{ID: 2, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true, APIKeyID: sqlNullString(currentKeyID)},
{ID: 3, Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth},
},
wantKey: currentKeyID,
wantOK: true,
},
{
name: "VisibleUserWinsOverCompressedSummary",
messages: []database.ChatMessage{
{ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true, APIKeyID: sqlNullString(oldKeyID)},
{ID: 2, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, APIKeyID: sqlNullString(currentKeyID)},
},
wantKey: currentKeyID,
wantOK: true,
},
{
name: "MissingVisibleUserKeyDoesNotFallBackToCompressedSummary",
messages: []database.ChatMessage{
{ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true, APIKeyID: sqlNullString(oldKeyID)},
{ID: 2, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth},
},
},
{
name: "UncompressedModelOnlyUserIgnored",
messages: []database.ChatMessage{
{ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, APIKeyID: sqlNullString(currentKeyID)},
},
},
{
name: "CompressedSummaryMissingKeyDoesNotFallBack",
messages: []database.ChatMessage{
{ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, APIKeyID: sqlNullString(oldKeyID)},
{ID: 2, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotKey, gotOK := activeTurnAPIKeyIDFromMessages(tt.messages)
require.Equal(t, tt.wantOK, gotOK)
require.Equal(t, tt.wantKey, gotKey)
})
}
}
func TestPromptMessagesForVisibleUserPreserveActiveAPIKeyID(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := t.Context()
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{})
chat := dbgen.Chat(t, db, database.Chat{OrganizationID: org.ID, OwnerID: user.ID, LastModelConfigID: model.ID})
oldKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
currentKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
modelOnlyKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
APIKeyID: sqlNullString(oldKey.ID),
})
dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleSystem,
Visibility: database.ChatMessageVisibilityModel,
Compressed: true,
})
dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
APIKeyID: sqlNullString(currentKey.ID),
})
dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityModel,
APIKeyID: sqlNullString(modelOnlyKey.ID),
})
messages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID)
require.NoError(t, err)
gotKey, ok := activeTurnAPIKeyIDFromMessages(messages)
require.True(t, ok)
require.Equal(t, currentKey.ID, gotKey)
}
func TestPromptMessagesForCompactedChatPreserveActiveAPIKeyID(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := t.Context()
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{})
chat := dbgen.Chat(t, db, database.Chat{OrganizationID: org.ID, OwnerID: user.ID, LastModelConfigID: model.ID})
key, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
visibleUser := dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
APIKeyID: sqlNullString(key.ID),
})
dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
Visibility: database.ChatMessageVisibilityBoth,
})
compressedSummary := dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityModel,
Compressed: true,
APIKeyID: sqlNullString(key.ID),
})
afterSummary := dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
Visibility: database.ChatMessageVisibilityBoth,
})
messages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID)
require.NoError(t, err)
ids := make(map[int64]struct{}, len(messages))
for _, message := range messages {
ids[message.ID] = struct{}{}
}
_, hasVisibleUser := ids[visibleUser.ID]
require.False(t, hasVisibleUser)
_, hasSummary := ids[compressedSummary.ID]
require.True(t, hasSummary)
_, hasAfterSummary := ids[afterSummary.ID]
require.True(t, hasAfterSummary)
gotKey, ok := activeTurnAPIKeyIDFromMessages(messages)
require.True(t, ok)
require.Equal(t, key.ID, gotKey)
}
func sqlNullString(value string) sql.NullString {
return sql.NullString{String: value, Valid: value != ""}
}
+8 -3
View File
@@ -221,11 +221,16 @@ func (p *Server) GenerateChatTitleAsync(ctx context.Context, chat database.Chat)
titleCtx, stopTitleCtx := p.inflightContext(ctx)
if err := p.goInflight(func() {
defer stopTitleCtx()
modelOpts := modelBuildOptionsFromMessages(messages)
turnCtx := withActiveTurnAPIKeyID(titleCtx, modelOpts)
apiKeyID, err := p.ensureSyntheticAPIKeyID(titleCtx, chat.OwnerID)
if err != nil {
logger.Debug(titleCtx, "failed to ensure synthetic API key for automatic title generation", slog.Error(err))
return
}
modelOpts := modelBuildOptions{ActiveAPIKeyID: apiKeyID}
turnCtx := titleCtx
model, modelConfig, route, _, _, _, err := p.resolveChatModel(turnCtx, chat, modelOpts)
if err != nil {
logger.Debug(turnCtx, "failed to resolve model for automatic title generation",
logger.Debug(titleCtx, "failed to resolve model for automatic title generation",
slog.Error(err),
)
return
+2 -26
View File
@@ -17,7 +17,6 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
@@ -1024,23 +1023,6 @@ func (p *Server) resolveExploreToolSnapshot(
return inheritedMCPServerIDs, nil
}
func (*Server) delegatedAPIKeyIDForSubagent(ctx context.Context) (string, error) {
apiKeyID, ok := aibridge.DelegatedAPIKeyIDFromContext(ctx)
if !ok || apiKeyID == "" {
return "", xerrors.New("active turn API key ID is required for subagent messages")
}
return apiKeyID, nil
}
func (p *Server) createChildSubagentChat(
ctx context.Context,
parent database.Chat,
prompt string,
title string,
) (database.Chat, error) {
return p.createChildSubagentChatWithOptions(ctx, parent, prompt, title, childSubagentChatOptions{})
}
func (p *Server) createChildSubagentChatWithOptions(
ctx context.Context,
parent database.Chat,
@@ -1074,9 +1056,9 @@ func (p *Server) createChildSubagentChatWithOptions(
if modelConfigID == uuid.Nil {
return database.Chat{}, xerrors.New("model config is required")
}
childAPIKeyID, err := p.delegatedAPIKeyIDForSubagent(ctx)
childAPIKeyID, err := p.ensureSyntheticAPIKeyID(ctx, parent.OwnerID)
if err != nil {
return database.Chat{}, err
return database.Chat{}, xerrors.Errorf("ensure synthetic API key: %w", err)
}
childPlanMode := parent.PlanMode
@@ -1218,16 +1200,10 @@ func (p *Server) sendSubagentMessage(
return database.Chat{}, xerrors.Errorf("get target chat: %w", err)
}
apiKeyID, err := p.delegatedAPIKeyIDForSubagent(ctx)
if err != nil {
return database.Chat{}, err
}
sendResult, err := p.SendMessage(ctx, SendMessageOptions{
ChatID: targetChatID,
CreatedBy: targetChat.OwnerID,
Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText(message)},
APIKeyID: apiKeyID,
BusyBehavior: busyBehavior,
})
if err != nil {
@@ -10,7 +10,6 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
@@ -72,7 +71,6 @@ func createWorkspaceBoundParentChat(
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent-with-context",
ModelConfigID: model.ID,
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
@@ -114,7 +112,6 @@ func TestSpawnComputerUseAgentInheritsPinnedContext(t *testing.T) {
// (OpenAI only) that was cached before the Anthropic provider was inserted.
server.configCache.InvalidateProviders()
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, db, parentChat.OwnerID))
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, spawnAgentToolName)
require.NotNil(t, tool)
+83 -274
View File
@@ -216,7 +216,6 @@ func seedInternalChatDeps(
t.Helper()
user := dbgen.User(t, db, database.User{})
_ = testAPIKeyID(t, db, user.ID)
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
@@ -268,222 +267,6 @@ func insertInternalAIProvider(
})
}
func TestCreateChildSubagentChatPropagatesActiveTurnAPIKeyID(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{})
parent := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: model.ID,
})
apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
ctx = aibridge.WithDelegatedAPIKeyID(ctx, apiKey.ID)
server := &Server{db: db, logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})}
child, err := server.createChildSubagentChat(ctx, parent, "inspect the workspace", "")
require.NoError(t, err)
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: child.ID})
require.NoError(t, err)
var childUserMessage database.ChatMessage
for _, message := range messages {
if message.Role == database.ChatMessageRoleUser {
childUserMessage = message
break
}
}
require.NotZero(t, childUserMessage.ID)
require.True(t, childUserMessage.APIKeyID.Valid)
require.Equal(t, apiKey.ID, childUserMessage.APIKeyID.String)
}
func TestSendSubagentMessagePropagatesActiveTurnAPIKeyID(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "parent-send-subagent-key",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
APIKeyID: apiKey.ID,
})
require.NoError(t, err)
child, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
Title: "child-send-subagent-key",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("do work"),
},
})
require.NoError(t, err)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
ctx = aibridge.WithDelegatedAPIKeyID(ctx, apiKey.ID)
_, err = server.sendSubagentMessage(
ctx,
parent.ID,
child.ID,
"follow up",
SendMessageBusyBehaviorInterrupt,
)
require.NoError(t, err)
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: child.ID})
require.NoError(t, err)
var latestUserMessage database.ChatMessage
for _, message := range messages {
if message.Role == database.ChatMessageRoleUser && message.ID > latestUserMessage.ID {
latestUserMessage = message
}
}
require.NotZero(t, latestUserMessage.ID)
require.True(t, latestUserMessage.APIKeyID.Valid)
require.Equal(t, apiKey.ID, latestUserMessage.APIKeyID.String)
}
func TestCreateChildSubagentChatRequiresActiveTurnAPIKeyIDForAIGateway(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{})
parent := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: model.ID,
})
server := &Server{
db: db,
logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
}
_, err := server.createChildSubagentChat(ctx, parent, "inspect the workspace", "")
require.ErrorContains(t, err, "active turn API key ID is required for subagent messages")
}
func TestSendSubagentMessageRequiresActiveTurnAPIKeyIDForAIGateway(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent-send-subagent-missing-key",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
child, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
Title: "child-send-subagent-missing-key",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("do work"),
},
})
require.NoError(t, err)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
_, err = server.sendSubagentMessage(
ctx,
parent.ID,
child.ID,
"follow up",
SendMessageBusyBehaviorInterrupt,
)
require.ErrorContains(t, err, "active turn API key ID is required for subagent messages")
}
// TestSpawnAgentUsesActiveTurnAPIKeyIDFromContext verifies that, with AI
// Gateway routing enabled, the spawn_agent tool succeeds when the active
// turn's delegated API key ID is present on the context and fails without
// it. The generation worker supplies that key by enriching the tool
// execution context with withActiveTurnAPIKeyID, derived from the prompt
// rows' model build options. This guards the regression where
// executeLocalTools passed an un-enriched context to tool callbacks,
// breaking subagent spawning under AI Gateway routing.
func TestSpawnAgentUsesActiveTurnAPIKeyIDFromContext(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "parent-active-turn-key",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
APIKeyID: apiKey.ID,
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
// The generation worker derives model build options from the prompt
// rows; this is the source executeLocalTools uses to enrich the tool
// execution context.
promptRows, err := server.db.GetChatMessagesForPromptByChatID(ctx, parentChat.ID)
require.NoError(t, err)
modelOpts := modelBuildOptionsFromMessages(promptRows)
require.Equal(t, apiKey.ID, modelOpts.ActiveAPIKeyID)
// Without the delegated key on the context the spawn fails, matching
// the original un-enriched executeLocalTools behavior.
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate work",
})
require.True(t, resp.IsError, "expected error without active turn key, got: %s", resp.Content)
require.Contains(t, resp.Content, "active turn API key ID is required for subagent messages")
// With the key on the context (as withActiveTurnAPIKeyID supplies in
// executeLocalTools), the spawn succeeds.
enrichedCtx := withActiveTurnAPIKeyID(ctx, modelOpts)
resp = runSpawnAgentTool(enrichedCtx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate work",
})
result := requireSpawnAgentResponse(t, resp)
require.Equal(t, subagentTypeGeneral, result.SubagentType)
}
func TestResolveUserProviderAPIKeys_AIProvider(t *testing.T) {
t.Parallel()
@@ -868,6 +651,89 @@ func upsertInternalUserChatPersonalModelOverride(
)
}
func TestCreateChildSubagentChatPersistsOwnerSyntheticAPIKeyID(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-child-key",
)
child, err := server.createChildSubagentChatWithOptions(
ctx,
parent,
"inspect the workspace",
"",
childSubagentChatOptions{},
)
require.NoError(t, err)
gatewayKey, err := db.GetChatGatewayAPIKey(ctx, database.GetChatGatewayAPIKeyParams{
UserID: user.ID,
TokenName: GatewayTokenName(user.ID),
})
require.NoError(t, err)
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: child.ID,
AfterID: 0,
})
require.NoError(t, err)
for _, message := range messages {
if message.Role != database.ChatMessageRoleUser {
continue
}
require.True(t, message.APIKeyID.Valid)
require.Equal(t, gatewayKey.ID, message.APIKeyID.String)
return
}
require.Fail(t, "child user message not found")
}
func TestSendSubagentMessagePersistsOwnerSyntheticAPIKeyID(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
_, err := server.sendSubagentMessage(
ctx,
parent.ID,
child.ID,
"follow up",
SendMessageBusyBehaviorInterrupt,
)
require.NoError(t, err)
gatewayKey, err := db.GetChatGatewayAPIKey(ctx, database.GetChatGatewayAPIKeyParams{
UserID: user.ID,
TokenName: GatewayTokenName(user.ID),
})
require.NoError(t, err)
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: child.ID,
AfterID: 0,
})
require.NoError(t, err)
var latestUserMessage database.ChatMessage
for _, message := range messages {
if message.Role == database.ChatMessageRoleUser && message.ID > latestUserMessage.ID {
latestUserMessage = message
}
}
require.NotZero(t, latestUserMessage.ID)
require.True(t, latestUserMessage.APIKeyID.Valid)
require.Equal(t, gatewayKey.ID, latestUserMessage.APIKeyID.String)
}
func TestCreateChildSubagentChatInheritsWorkspaceBinding(t *testing.T) {
t.Parallel()
@@ -881,7 +747,6 @@ func TestCreateChildSubagentChatInheritsWorkspaceBinding(t *testing.T) {
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
WorkspaceID: uuid.NullUUID{
UUID: workspace.ID,
Valid: true,
@@ -903,7 +768,6 @@ func TestCreateChildSubagentChatInheritsWorkspaceBinding(t *testing.T) {
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, server.db, parentChat.OwnerID))
child, err := server.createChildSubagentChatWithOptions(ctx, parentChat, "inspect bindings", "", childSubagentChatOptions{})
require.NoError(t, err)
@@ -930,7 +794,6 @@ func createInternalParentChat(
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: orgID,
OwnerID: userID,
APIKeyID: testAPIKeyID(t, db, userID),
Title: title,
ModelConfigID: modelConfigID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
@@ -943,15 +806,6 @@ func createInternalParentChat(
return parentChat
}
// withSubagentDelegatedKey enriches ctx with a delegated API key ID for
// subagent tool callbacks. AI Gateway routing requires this key on the
// context; tests that do not otherwise set it should call this helper
// before invoking runSpawnAgentTool or runSubagentTool with spawn_agent.
func withSubagentDelegatedKey(ctx context.Context, t *testing.T, db database.Store, ownerID uuid.UUID) context.Context {
t.Helper()
return aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, db, ownerID))
}
func runSubagentTool(
ctx context.Context,
t *testing.T,
@@ -1064,7 +918,6 @@ func TestCreateChildSubagentChatCopiesPlanMode(t *testing.T) {
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "plan-parent",
ModelConfigID: model.ID,
PlanMode: planMode,
@@ -1078,7 +931,6 @@ func TestCreateChildSubagentChatCopiesPlanMode(t *testing.T) {
require.NoError(t, err)
require.Equal(t, planMode, parentChat.PlanMode)
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, server.db, parentChat.OwnerID))
child, err := server.createChildSubagentChatWithOptions(ctx, parentChat, "inspect bindings", "", childSubagentChatOptions{})
require.NoError(t, err)
@@ -1099,7 +951,6 @@ func TestSpawnAgent_GeneralInheritsParentModelWhenOmitted(t *testing.T) {
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-inherited-model",
)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate work",
@@ -1130,7 +981,6 @@ func TestSpawnAgent_GeneralUsesConfiguredModelOverride(t *testing.T) {
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-general-override",
)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate general work",
@@ -1323,7 +1173,6 @@ func TestSpawnAgent_GeneralHonorsPersonalModelOverrides(t *testing.T) {
"parent-general-personal-override",
)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate general work",
@@ -1374,7 +1223,6 @@ func TestSpawnAgent_GeneralOverrideLogsAndFallsBackWhenCredentialsUnavailable(t
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent-general-credentials-fallback",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
@@ -1385,7 +1233,6 @@ func TestSpawnAgent_GeneralOverrideLogsAndFallsBackWhenCredentialsUnavailable(t
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "inspect provider credentials",
@@ -1445,7 +1292,6 @@ func TestSpawnAgent_GeneralOverrideLogsAndFallsBackWhenProviderDisabled(t *testi
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent-general-disabled-provider-fallback",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
@@ -1456,7 +1302,6 @@ func TestSpawnAgent_GeneralOverrideLogsAndFallsBackWhenProviderDisabled(t *testi
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "inspect disabled providers",
@@ -1579,7 +1424,6 @@ func TestCreateChildSubagentChat_StoresReasoningEffortOverride(t *testing.T) {
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-effort-override",
)
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, server.db, parentChat.OwnerID))
child, err := server.createChildSubagentChatWithOptions(
ctx,
parentChat,
@@ -1612,7 +1456,6 @@ func TestCreateChildSubagentChat_OverrideWorksWhenParentHasNoModel(t *testing.T)
// The chats table enforces a foreign key for last_model_config_id, so
// use a synthetic parent value here to exercise the override path.
parentChat.LastModelConfigID = uuid.Nil
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, server.db, parentChat.OwnerID))
child, err := server.createChildSubagentChatWithOptions(
ctx,
parentChat,
@@ -1643,7 +1486,6 @@ func TestSpawnAgent_ExploreUsesConfiguredModelOverride(t *testing.T) {
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-explore-override",
)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSubagentTool(
ctx,
t,
@@ -1681,7 +1523,6 @@ func TestSpawnAgent_ExploreFallsBackToCurrentTurnModel(t *testing.T) {
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-fallback",
)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSubagentTool(
ctx,
t,
@@ -1886,7 +1727,6 @@ func TestSpawnAgent_ExploreHonorsPersonalModelOverrides(t *testing.T) {
"parent-explore-personal-override",
)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSubagentTool(
ctx,
t,
@@ -1924,7 +1764,6 @@ func TestCreateChat_ExploreRootStartsWithoutMCPSnapshot(t *testing.T) {
root, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "root-explore",
ModelConfigID: model.ID,
ChatMode: database.NullChatMode{
@@ -2043,7 +1882,6 @@ func TestCreateChildSubagentChatWithOptions_ExplorePersistsMCPSnapshot(t *testin
t, db, user.ID, "snapshot-"+uuid.NewString(), false,
)
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, server.db, parentChat.OwnerID))
child, err := server.createChildSubagentChatWithOptions(
ctx,
parentChat,
@@ -2082,7 +1920,6 @@ func TestSpawnAgent_ExploreSnapshotsTurnStateParentState(t *testing.T) {
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent-turn-state-snapshot",
ModelConfigID: model.ID,
MCPServerIDs: []uuid.UUID{turnStartConfig.ID},
@@ -2095,7 +1932,6 @@ func TestSpawnAgent_ExploreSnapshotsTurnStateParentState(t *testing.T) {
turnParent, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, db, user.ID))
tools := server.subagentTools(
ctx,
func() database.Chat { return turnParent },
@@ -2163,7 +1999,6 @@ func TestSpawnAgent_ExploreFallsBackOnInvalidUUID(t *testing.T) {
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-invalid-override",
)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSubagentTool(
ctx,
t,
@@ -2199,7 +2034,6 @@ func TestSpawnAgent_ExploreFallsBackWhenOverrideIsUnavailable(t *testing.T) {
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-disabled",
)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSubagentTool(
ctx,
t,
@@ -2247,7 +2081,6 @@ func TestSpawnAgent_ExploreFallsBackWhenOverrideCredentialsAreUnavailable(t *tes
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-missing-user-key",
)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSubagentTool(
ctx,
t,
@@ -2356,7 +2189,6 @@ func TestSpawnAgent_PlanModeDescriptionOmitsComputerUse(t *testing.T) {
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "plan-parent-description",
ModelConfigID: model.ID,
PlanMode: database.NullChatPlanMode{
@@ -2396,7 +2228,6 @@ func TestSpawnAgent_PlanModeRejectsComputerUse(t *testing.T) {
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "plan-parent-computer-use-reject",
ModelConfigID: model.ID,
PlanMode: database.NullChatPlanMode{
@@ -2504,7 +2335,6 @@ func TestSpawnAgent_ComputerUseRejectsMissingConfiguredProvider(t *testing.T) {
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
user := dbgen.User(t, db, database.User{})
_ = testAPIKeyID(t, db, user.ID)
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
@@ -2688,7 +2518,6 @@ func TestSpawnAgent_NotAvailableForExploreChats(t *testing.T) {
exploreChat, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "root-explore",
ModelConfigID: model.ID,
ChatMode: database.NullChatMode{
@@ -2750,7 +2579,6 @@ func TestSubagentLifecycleToolsIncludePersistedSubagentTypeAcrossVariants(t *tes
model.ID,
"parent-lifecycle-"+tt.variant,
)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
spawnResp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: tt.variant,
@@ -2812,7 +2640,6 @@ func TestSubagentLifecycleToolErrorsIncludePersistedSubagentType(t *testing.T) {
unrelated, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "unrelated-lifecycle-parent",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("other")},
@@ -2885,7 +2712,6 @@ func TestSpawnAgent_ComputerUseUsesComputerUseModelNotParent(t *testing.T) {
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
WorkspaceID: uuid.NullUUID{UUID: workspace.ID, Valid: true},
BuildID: uuid.NullUUID{UUID: build.ID, Valid: true},
AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
@@ -2898,7 +2724,6 @@ func TestSpawnAgent_ComputerUseUsesComputerUseModelNotParent(t *testing.T) {
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSubagentTool(
ctx,
t,
@@ -2952,7 +2777,6 @@ func TestSpawnAgent_ComputerUseInheritsMCPServerIDs(t *testing.T) {
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent-cu-mcp",
ModelConfigID: model.ID,
MCPServerIDs: parentMCPIDs,
@@ -2963,7 +2787,6 @@ func TestSpawnAgent_ComputerUseInheritsMCPServerIDs(t *testing.T) {
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
ctx = withSubagentDelegatedKey(ctx, t, db, parentChat.OwnerID)
resp := runSubagentTool(
ctx,
t,
@@ -3014,7 +2837,6 @@ func TestCreateChildSubagentChat_InheritsMCPServerIDs(t *testing.T) {
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent-with-mcp",
ModelConfigID: model.ID,
MCPServerIDs: parentMCPIDs,
@@ -3029,7 +2851,6 @@ func TestCreateChildSubagentChat_InheritsMCPServerIDs(t *testing.T) {
"parent chat must have the MCP server IDs we set")
// Spawn a child subagent chat.
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, server.db, parentChat.OwnerID))
child, err := server.createChildSubagentChatWithOptions(
ctx,
parentChat,
@@ -3059,7 +2880,6 @@ func TestCreateChildSubagentChat_NoMCPServersStaysEmpty(t *testing.T) {
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent-no-mcp",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
@@ -3070,7 +2890,6 @@ func TestCreateChildSubagentChat_NoMCPServersStaysEmpty(t *testing.T) {
require.NoError(t, err)
// Spawn a child.
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, server.db, parentChat.OwnerID))
child, err := server.createChildSubagentChatWithOptions(
ctx,
parentChat,
@@ -3099,7 +2918,6 @@ func TestIsSubagentDescendant(t *testing.T) {
root, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "root",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("root")},
@@ -3109,7 +2927,6 @@ func TestIsSubagentDescendant(t *testing.T) {
child, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
ParentChatID: uuid.NullUUID{
UUID: root.ID,
Valid: true,
@@ -3127,7 +2944,6 @@ func TestIsSubagentDescendant(t *testing.T) {
grandchild, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
ParentChatID: uuid.NullUUID{
UUID: child.ID,
Valid: true,
@@ -3146,7 +2962,6 @@ func TestIsSubagentDescendant(t *testing.T) {
unrelated, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "unrelated-root",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("unrelated")},
@@ -3156,7 +2971,6 @@ func TestIsSubagentDescendant(t *testing.T) {
unrelatedChild, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
ParentChatID: uuid.NullUUID{
UUID: unrelated.ID,
Valid: true,
@@ -3247,7 +3061,6 @@ func createParentChildChats(
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, server.db, user.ID),
Title: "parent-" + t.Name(),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
@@ -3257,7 +3070,6 @@ func createParentChildChats(
child, err = server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, server.db, user.ID),
ParentChatID: uuid.NullUUID{
UUID: parent.ID,
Valid: true,
@@ -3499,7 +3311,6 @@ func TestAwaitSubagentCompletion(t *testing.T) {
unrelated, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "unrelated",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("other")},
@@ -4002,7 +3813,6 @@ func TestListAgents(t *testing.T) {
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: title,
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
@@ -4015,7 +3825,6 @@ func TestListAgents(t *testing.T) {
child, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
Title: title,
-8
View File
@@ -27,7 +27,6 @@ func TestSpawnComputerUseAgent_CreatesChildWithChatMode(t *testing.T) {
parent, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
@@ -41,7 +40,6 @@ func TestSpawnComputerUseAgent_CreatesChildWithChatMode(t *testing.T) {
child, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: parent.OwnerID,
APIKeyID: testAPIKeyID(t, db, parent.OwnerID),
ParentChatID: uuid.NullUUID{
UUID: parent.ID,
Valid: true,
@@ -84,7 +82,6 @@ func TestSpawnComputerUseAgent_SystemPromptFormat(t *testing.T) {
parent, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
@@ -97,7 +94,6 @@ func TestSpawnComputerUseAgent_SystemPromptFormat(t *testing.T) {
child, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: parent.OwnerID,
APIKeyID: testAPIKeyID(t, db, parent.OwnerID),
ParentChatID: uuid.NullUUID{
UUID: parent.ID,
Valid: true,
@@ -145,7 +141,6 @@ func TestSpawnComputerUseAgent_ChildIsListedUnderParent(t *testing.T) {
parent, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
@@ -157,7 +152,6 @@ func TestSpawnComputerUseAgent_ChildIsListedUnderParent(t *testing.T) {
child, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: parent.OwnerID,
APIKeyID: testAPIKeyID(t, db, parent.OwnerID),
ParentChatID: uuid.NullUUID{
UUID: parent.ID,
Valid: true,
@@ -193,7 +187,6 @@ func TestSpawnComputerUseAgent_RootChatIDPropagation(t *testing.T) {
parent, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "root-parent",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
@@ -205,7 +198,6 @@ func TestSpawnComputerUseAgent_RootChatIDPropagation(t *testing.T) {
child, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: parent.OwnerID,
APIKeyID: testAPIKeyID(t, db, parent.OwnerID),
ParentChatID: uuid.NullUUID{
UUID: parent.ID,
Valid: true,
+115
View File
@@ -0,0 +1,115 @@
package chatd
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
)
const (
syntheticAPIKeyLifetime = 30 * 24 * time.Hour
syntheticAPIKeyRenewMargin = 24 * time.Hour
)
// GatewayTokenName returns the deterministic token name of the synthetic
// gateway key for a user. The name is the lookup key: no mapping table exists,
// so attribution resolves the key by (user_id, token_name, login_type !=
// 'token').
func GatewayTokenName(ownerID uuid.UUID) string {
return fmt.Sprintf("chatd_%s_session_token", ownerID)
}
// ensureSyntheticAPIKeyID returns the ID of the synthetic gateway key for the
// given user, minting or extending it as needed. The key ID is stable for the
// lifetime of the user: near-expiry keys are extended in place rather than
// replaced, because an in-flight generation may have already delegated the
// current key ID to the gateway.
func (p *Server) ensureSyntheticAPIKeyID(ctx context.Context, ownerID uuid.UUID) (string, error) {
ctx = dbauthz.AsChatdKeyMinter(ctx, ownerID)
key, err := p.db.GetChatGatewayAPIKey(ctx, database.GetChatGatewayAPIKeyParams{
UserID: ownerID,
TokenName: GatewayTokenName(ownerID),
})
switch {
case err == nil && key.ExpiresAt.After(p.clock.Now().Add(syntheticAPIKeyRenewMargin)):
return key.ID, nil
case err != nil && !xerrors.Is(err, sql.ErrNoRows):
return "", xerrors.Errorf("get synthetic API key: %w", err)
}
return p.mintSyntheticAPIKey(ctx, ownerID)
}
// mintSyntheticAPIKey extends or mints the synthetic gateway key under a
// per-user advisory lock. The lock serializes concurrent mints because the
// partial unique index on token names only covers login_type 'token' rows, so
// nothing else prevents duplicate synthetic keys.
func (p *Server) mintSyntheticAPIKey(ctx context.Context, ownerID uuid.UUID) (string, error) {
tokenName := GatewayTokenName(ownerID)
var keyID string
err := p.db.InTx(func(tx database.Store) error {
err := tx.AcquireLock(ctx, database.GenLockID("chatd_gateway_key:"+ownerID.String()))
if err != nil {
return xerrors.Errorf("acquire chat gateway key lock: %w", err)
}
key, err := tx.GetChatGatewayAPIKey(ctx, database.GetChatGatewayAPIKeyParams{
UserID: ownerID,
TokenName: tokenName,
})
if err == nil {
keyID = key.ID
if key.ExpiresAt.After(p.clock.Now().Add(syntheticAPIKeyRenewMargin)) {
return nil
}
err = tx.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{
ID: key.ID,
LastUsed: key.LastUsed,
ExpiresAt: p.clock.Now().Add(syntheticAPIKeyLifetime),
IPAddress: key.IPAddress,
})
if err != nil {
return xerrors.Errorf("extend synthetic API key: %w", err)
}
return nil
}
if !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get synthetic API key: %w", err)
}
owner, err := tx.GetUserForChatSyntheticAPIKeyByID(ctx, ownerID)
if err != nil {
return xerrors.Errorf("get synthetic API key owner: %w", err)
}
params, _, err := apikey.Generate(apikey.CreateParams{
UserID: ownerID,
LoginType: owner.LoginType,
ExpiresAt: p.clock.Now().Add(syntheticAPIKeyLifetime),
LifetimeSeconds: int64(syntheticAPIKeyLifetime.Seconds()),
TokenName: tokenName,
// The key only attributes gateway requests; the secret is
// discarded, so it is never usable as a bearer credential. The
// minimal scope is defense in depth on top of that.
Scopes: database.APIKeyScopes{database.ApiKeyScopeApiKeyRead},
})
if err != nil {
return xerrors.Errorf("generate synthetic API key: %w", err)
}
inserted, err := tx.InsertAPIKey(ctx, params)
if err != nil {
return xerrors.Errorf("insert synthetic API key: %w", err)
}
keyID = inserted.ID
return nil
}, nil)
if err != nil {
return "", err
}
return keyID, nil
}
@@ -0,0 +1,265 @@
package chatd
import (
"context"
"database/sql"
"encoding/json"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"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/database/dbtime"
"github.com/coder/quartz"
)
func getGatewayKey(ctx context.Context, db database.Store, userID uuid.UUID) (database.APIKey, error) {
return db.GetChatGatewayAPIKey(ctx, database.GetChatGatewayAPIKeyParams{
UserID: userID,
TokenName: GatewayTokenName(userID),
})
}
func TestSyntheticAPIKeyLifecycle(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{})
server := &Server{db: db, clock: quartz.NewReal()}
firstID, err := server.ensureSyntheticAPIKeyID(t.Context(), user.ID)
require.NoError(t, err)
secondID, err := server.ensureSyntheticAPIKeyID(t.Context(), user.ID)
require.NoError(t, err)
require.Equal(t, firstID, secondID)
first, err := db.GetAPIKeyByID(t.Context(), firstID)
require.NoError(t, err)
require.Equal(t, user.LoginType, first.LoginType)
require.Equal(t, GatewayTokenName(user.ID), first.TokenName)
require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeApiKeyRead}, first.Scopes)
require.WithinDuration(t, server.clock.Now().Add(syntheticAPIKeyLifetime), first.ExpiresAt, time.Second)
// Within the renew margin the key is extended in place: the ID stays
// stable because in-flight generations may have delegated it already.
err = db.UpdateAPIKeyByID(t.Context(), database.UpdateAPIKeyByIDParams{
ID: first.ID,
LastUsed: first.LastUsed,
ExpiresAt: server.clock.Now().Add(time.Hour),
IPAddress: first.IPAddress,
})
require.NoError(t, err)
renewedID, err := server.ensureSyntheticAPIKeyID(t.Context(), user.ID)
require.NoError(t, err)
require.Equal(t, firstID, renewedID)
renewed, err := db.GetAPIKeyByID(t.Context(), renewedID)
require.NoError(t, err)
require.WithinDuration(t, server.clock.Now().Add(syntheticAPIKeyLifetime), renewed.ExpiresAt, time.Second)
// A fully expired key is extended the same way.
err = db.UpdateAPIKeyByID(t.Context(), database.UpdateAPIKeyByIDParams{
ID: first.ID,
LastUsed: first.LastUsed,
ExpiresAt: server.clock.Now().Add(-time.Hour),
IPAddress: first.IPAddress,
})
require.NoError(t, err)
revivedID, err := server.ensureSyntheticAPIKeyID(t.Context(), user.ID)
require.NoError(t, err)
require.Equal(t, firstID, revivedID)
revived, err := db.GetAPIKeyByID(t.Context(), revivedID)
require.NoError(t, err)
require.WithinDuration(t, server.clock.Now().Add(syntheticAPIKeyLifetime), revived.ExpiresAt, time.Second)
// External deletion (password reset, dbpurge) causes a remint.
require.NoError(t, db.DeleteAPIKeyByID(t.Context(), firstID))
_, err = getGatewayKey(t.Context(), db, user.ID)
require.ErrorIs(t, err, sql.ErrNoRows)
recreatedID, err := server.ensureSyntheticAPIKeyID(t.Context(), user.ID)
require.NoError(t, err)
require.NotEqual(t, firstID, recreatedID)
}
func TestSyntheticAPIKeyIgnoresUserTokenCollision(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{})
server := &Server{db: db, clock: quartz.NewReal()}
// Token names are unvalidated user input, so a user can create a token
// named exactly like the synthetic gateway key. It must never be picked
// up or extended; its near-margin expiry would otherwise trigger the
// extension path.
collisionExpiry := server.clock.Now().Add(time.Hour).UTC()
collision, _ := dbgen.APIKey(t, db, database.APIKey{
UserID: user.ID,
LoginType: database.LoginTypeToken,
TokenName: GatewayTokenName(user.ID),
ExpiresAt: collisionExpiry,
})
syntheticID, err := server.ensureSyntheticAPIKeyID(t.Context(), user.ID)
require.NoError(t, err)
require.NotEqual(t, collision.ID, syntheticID)
synthetic, err := db.GetAPIKeyByID(t.Context(), syntheticID)
require.NoError(t, err)
require.Equal(t, user.LoginType, synthetic.LoginType)
unchanged, err := db.GetAPIKeyByID(t.Context(), collision.ID)
require.NoError(t, err)
require.WithinDuration(t, collisionExpiry, unchanged.ExpiresAt, time.Millisecond)
}
func TestSyntheticAPIKeySurvivesSuspension(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{})
server := &Server{db: db, clock: quartz.NewReal()}
keyID, err := server.ensureSyntheticAPIKeyID(t.Context(), user.ID)
require.NoError(t, err)
_, err = db.UpdateUserStatus(t.Context(), database.UpdateUserStatusParams{
ID: user.ID,
Status: database.UserStatusSuspended,
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)
// Suspension does not delete the key. Delegated gateway authorization
// rejects suspended owners at request time instead; see the
// aibridgedserver IsAuthorized tests for that rejection.
_, err = db.GetAPIKeyByID(t.Context(), keyID)
require.NoError(t, err)
sameID, err := server.ensureSyntheticAPIKeyID(t.Context(), user.ID)
require.NoError(t, err)
require.Equal(t, keyID, sameID)
}
func TestSyntheticAPIKeyConcurrentMint(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{})
server := &Server{db: db, clock: quartz.NewReal()}
const workers = 8
ids := make([]string, workers)
errs := make([]error, workers)
var wg sync.WaitGroup
for i := range workers {
wg.Add(1)
go func() {
defer wg.Done()
ids[i], errs[i] = server.ensureSyntheticAPIKeyID(t.Context(), user.ID)
}()
}
wg.Wait()
for i := range workers {
require.NoError(t, errs[i])
require.Equal(t, ids[0], ids[i])
}
keys, err := db.GetAPIKeysByUserID(t.Context(), database.GetAPIKeysByUserIDParams{
LoginType: user.LoginType,
UserID: user.ID,
IncludeExpired: true,
})
require.NoError(t, err)
require.Len(t, keys, 1)
}
func TestSyntheticAPIKeyDeletionDoesNotMutateChatState(t *testing.T) {
t.Parallel()
tests := []struct {
name string
deleteKey func(context.Context, database.Store, uuid.UUID, string) error
}{
{
name: "individual",
deleteKey: func(ctx context.Context, db database.Store, _ uuid.UUID, keyID string) error {
return db.DeleteAPIKeyByID(ctx, keyID)
},
},
{
name: "all user keys",
deleteKey: func(ctx context.Context, db database.Store, userID uuid.UUID, _ string) error {
return db.DeleteAPIKeysByUserID(ctx, userID)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{})
server := &Server{db: db, clock: quartz.NewReal()}
syntheticID, err := server.ensureSyntheticAPIKeyID(t.Context(), user.ID)
require.NoError(t, err)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: model.ID,
})
message := dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleUser,
APIKeyID: sql.NullString{String: syntheticID, Valid: true},
})
queued, err := db.InsertChatQueuedMessage(t.Context(), database.InsertChatQueuedMessageParams{
ChatID: chat.ID,
Content: json.RawMessage(`[]`),
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
APIKeyID: sql.NullString{String: syntheticID, Valid: true},
})
require.NoError(t, err)
before, err := db.GetChatByID(t.Context(), chat.ID)
require.NoError(t, err)
require.NoError(t, test.deleteKey(t.Context(), db, user.ID, syntheticID))
_, err = db.GetAPIKeyByID(t.Context(), syntheticID)
require.ErrorIs(t, err, sql.ErrNoRows)
_, err = getGatewayKey(t.Context(), db, user.ID)
require.ErrorIs(t, err, sql.ErrNoRows)
after, err := db.GetChatByID(t.Context(), chat.ID)
require.NoError(t, err)
require.Equal(t, before.HistoryVersion, after.HistoryVersion)
require.Equal(t, before.QueueVersion, after.QueueVersion)
require.Equal(t, before.GenerationAttempt, after.GenerationAttempt)
stored, err := db.GetChatMessageByID(t.Context(), message.ID)
require.NoError(t, err)
require.Equal(t, sql.NullString{String: syntheticID, Valid: true}, stored.APIKeyID)
storedQueued, err := db.GetChatQueuedMessageByID(t.Context(), database.GetChatQueuedMessageByIDParams{
ID: queued.ID,
ChatID: chat.ID,
})
require.NoError(t, err)
require.Equal(t, sql.NullString{String: syntheticID, Valid: true}, storedQueued.APIKeyID)
remintedID, err := server.ensureSyntheticAPIKeyID(t.Context(), user.ID)
require.NoError(t, err)
require.NotEqual(t, syntheticID, remintedID)
})
}
}
+64 -96
View File
@@ -10,6 +10,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"
"charm.land/fantasy"
fantasyopenai "charm.land/fantasy/providers/openai"
@@ -551,107 +552,74 @@ func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *te
require.Equal(t, database.ChatModelConfig{}, gotConfig)
}
func TestGenerateManualTitleCandidate_ActiveAPIKeyIDFallback(t *testing.T) {
func TestGenerateManualTitleCandidate_UsesSyntheticAPIKey(t *testing.T) {
t.Parallel()
contextAPIKeyID := uuid.NewString()
messageAPIKeyID := uuid.NewString()
shadowedContextAPIKeyID := uuid.NewString()
tests := []struct {
name string
messageAPIKeyID string
contextAPIKeyID string
wantAPIKeyID string
wantErrContains string
}{
{
name: "ContextFallback",
contextAPIKeyID: contextAPIKeyID,
wantAPIKeyID: contextAPIKeyID,
},
{
name: "MessageTakesPrecedence",
messageAPIKeyID: messageAPIKeyID,
contextAPIKeyID: shadowedContextAPIKeyID,
wantAPIKeyID: messageAPIKeyID,
},
{
name: "NoKeyAnywhereFailsClosed",
wantErrContains: "AI Gateway routing requires the active turn API key ID",
},
ctx := testutil.Context(t, testutil.WaitShort)
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
chat, messages := titleOverrideTestChatAndMessages(t)
chat.OrganizationID = uuid.New()
overrideConfig := titleOverrideModelConfig("gpt-4.1", true)
providerID := uuid.New()
overrideConfig.AIProviderID = uuid.NullUUID{UUID: providerID, Valid: true}
provider := database.AIProvider{
ID: providerID,
Name: "primary-openai",
Type: database.AIProviderTypeOpenai,
Enabled: true,
}
apiKeyID := uuid.NewString()
wantTitle := "Synthetic title"
seenAPIKeyID := make(chan string, 1)
factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) {
delegatedID, _ := aibridge.DelegatedAPIKeyIDFromContext(req.Context())
seenAPIKeyID <- delegatedID
text := strconv.Quote(`{"title":"` + wantTitle + `"}`)
body := `{"id":"resp_test","object":"response","created_at":0,"status":"completed","model":"gpt-4.1","output":[{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"output_text","text":` + text + `}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(database.ChatUsageLimitConfig{}, sql.ErrNoRows)
db.EXPECT().GetChatMessagesByChatIDAscPaginated(gomock.Any(), database.GetChatMessagesByChatIDAscPaginatedParams{
ChatID: chat.ID,
AfterID: 0,
LimitVal: manualTitleMessageWindowLimit,
}).Return(messages, nil)
db.EXPECT().GetChatMessagesByChatIDDescPaginated(gomock.Any(), database.GetChatMessagesByChatIDDescPaginatedParams{
ChatID: chat.ID,
BeforeID: 0,
LimitVal: manualTitleMessageWindowLimit,
}).Return(nil, nil)
db.EXPECT().GetChatGatewayAPIKey(gomock.Any(), database.GetChatGatewayAPIKeyParams{
UserID: chat.OwnerID,
TokenName: GatewayTokenName(chat.OwnerID),
}).Return(database.APIKey{
ID: apiKeyID,
UserID: chat.OwnerID,
ExpiresAt: time.Now().Add(48 * time.Hour),
}, nil)
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(), providerID).Return(provider, nil).AnyTimes()
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{
ProviderID: providerID,
APIKey: "test-key",
}}, nil).AnyTimes()
ctx := testutil.Context(t, testutil.WaitShort)
if tt.contextAPIKeyID != "" {
ctx = aibridge.WithDelegatedAPIKeyID(ctx, tt.contextAPIKeyID)
}
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
chat, messages := titleOverrideTestChatAndMessages(t)
chat.OrganizationID = uuid.New()
if tt.messageAPIKeyID != "" {
messages[0] = withChatMessageAPIKeyID(messages[0], tt.messageAPIKeyID)
}
overrideConfig := titleOverrideModelConfig("gpt-4.1", true)
providerID := uuid.New()
overrideConfig.AIProviderID = uuid.NullUUID{UUID: providerID, Valid: true}
provider := database.AIProvider{
ID: providerID,
Name: "primary-openai",
Type: database.AIProviderTypeOpenai,
Enabled: true,
}
wantTitle := "Context title"
seenAPIKeyID := make(chan string, 1)
factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) {
apiKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(req.Context())
seenAPIKeyID <- apiKeyID
text := strconv.Quote(`{"title":"` + wantTitle + `"}`)
body := `{"id":"resp_test","object":"response","created_at":0,"status":"completed","model":"gpt-4.1","output":[{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"output_text","text":` + text + `}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})}
db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(database.ChatUsageLimitConfig{}, sql.ErrNoRows)
db.EXPECT().GetChatMessagesByChatIDAscPaginated(gomock.Any(), database.GetChatMessagesByChatIDAscPaginatedParams{
ChatID: chat.ID,
AfterID: 0,
LimitVal: manualTitleMessageWindowLimit,
}).Return(messages, nil)
db.EXPECT().GetChatMessagesByChatIDDescPaginated(gomock.Any(), database.GetChatMessagesByChatIDDescPaginatedParams{
ChatID: chat.ID,
BeforeID: 0,
LimitVal: manualTitleMessageWindowLimit,
}).Return(nil, nil)
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(), providerID).Return(provider, nil).AnyTimes()
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{
ProviderID: providerID,
APIKey: "test-key",
}}, nil).AnyTimes()
server := titleOverrideTestServer(db, logger)
server.aibridgeTransportFactory = aibridgeTestFactoryPointer(factory)
title, err := server.generateManualTitleCandidate(ctx, db, chat)
if tt.wantErrContains != "" {
require.ErrorContains(t, err, tt.wantErrContains)
return
}
require.NoError(t, err)
require.Equal(t, wantTitle, title)
require.Equal(t, tt.wantAPIKeyID, testutil.RequireReceive(ctx, t, seenAPIKeyID))
})
}
server := titleOverrideTestServer(db, logger)
server.clock = quartz.NewReal()
server.aibridgeTransportFactory = aibridgeTestFactoryPointer(factory)
title, err := server.generateManualTitleCandidate(ctx, db, chat)
require.NoError(t, err)
require.Equal(t, wantTitle, title)
require.Equal(t, apiKeyID, testutil.RequireReceive(ctx, t, seenAPIKeyID))
}
func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T) {