From fe13bb2a20dfbcf5deb75857787b73cf72a1e801 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 26 May 2026 13:16:32 +1000 Subject: [PATCH 001/249] fix(coderd/x/chatd): seed afterMessageID test directly (#25665) This fixes the flaky `TestSubscribeAfterMessageID` by seeding its chat and messages directly, so the test no longer creates pending work that a chat worker can pick up. The assertion now covers only the `afterMessageID` subscription behavior, independent of chat processing lifecycle timing. Closes DEVEX-326 Closes https://github.com/coder/internal/issues/1489 --- coderd/x/chatd/chatd_test.go | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 678ad842bdcbb..d8be67eaaef79 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -4782,18 +4782,30 @@ func TestSubscribeAfterMessageID(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) user, org, model := seedChatDependencies(t, db) - // Create a chat. This inserts one initial "user" message. - chat, err := replica.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - Title: "after-id-test", - ModelConfigID: model.ID, - InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("first")}, + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + Title: "after-id-test", + Status: database.ChatStatusWaiting, + }) + + // Seed all messages directly so this subscription test is independent + // of chat processing lifecycle behavior. + firstContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + codersdk.ChatMessageText("first"), }) require.NoError(t, err) - // Insert two more messages so we have three total visible - // messages (the initial user message plus these two). + _ = 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, + ContentVersion: chatprompt.CurrentContentVersion, + Content: firstContent, + }) + secondContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ codersdk.ChatMessageText("second"), }) @@ -4814,6 +4826,7 @@ func TestSubscribeAfterMessageID(t *testing.T) { _ = 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, ContentVersion: chatprompt.CurrentContentVersion, From 4f1043a50aa6b1d653d3ff2795bab4ef903d5963 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 26 May 2026 14:19:36 +1000 Subject: [PATCH 002/249] feat(scaletest): add chat scaletest command (#25553) Adds `coder exp scaletest chat`, a harness for creating Coder Agents chat load. Start the mock LLM separately, prepare the scaletest workspaces you want to target, then run the chat scaletest against the existing `scaletest-*` fleet selected by the shared workspace targeting flags: ```sh coder exp scaletest llm-mock --address 127.0.0.1:18080 coder exp scaletest chat --llm-mock-url http://127.0.0.1:18080/v1 --chats-per-workspace 10 --turns 1 coder exp scaletest chat --llm-mock-url http://127.0.0.1:18080/v1 --template docker --target-workspaces 0:10 --chats-per-workspace 1 --turns 10 --turn-start-delay 30s ``` This is the same pattern used by the `workspace-traffic` load generator. Keeping the fake LLM as a separate process is intentional so it can be scaled independently from the Coder deployment, which will likely be necessary as we scale up and up. This PR is the starting point: it provides the command, mock provider/model bootstrap, existing workspace selection, chat streaming, follow-up turns, metrics, and cleanup. Follow-up PRs will add multi-step turns via tool calls. I'm still a bit iffy on the mechanism I have for that. It'll likely involve having the runner send some magic strings that the mock will recognise. Relates to CODAGT-307 Relates to GRU-48 Relates to https://github.com/coder/scaletest/issues/124 Generated by Mux, but reviewed by a human --- cli/exp_scaletest.go | 5 +- cli/exp_scaletest_chat.go | 254 +++++++++++++++++ scaletest/chat/client.go | 54 ++++ scaletest/chat/config.go | 78 ++++++ scaletest/chat/metrics.go | 137 +++++++++ scaletest/chat/provider.go | 148 ++++++++++ scaletest/chat/run.go | 413 ++++++++++++++++++++++++++++ scaletest/chat/run_internal_test.go | 391 ++++++++++++++++++++++++++ 8 files changed, 1478 insertions(+), 2 deletions(-) create mode 100644 cli/exp_scaletest_chat.go create mode 100644 scaletest/chat/client.go create mode 100644 scaletest/chat/config.go create mode 100644 scaletest/chat/metrics.go create mode 100644 scaletest/chat/provider.go create mode 100644 scaletest/chat/run.go create mode 100644 scaletest/chat/run_internal_test.go diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go index 06af372e151df..a4d5b14d65a49 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -70,6 +70,7 @@ func (r *RootCmd) scaletestCmd() *serpent.Command { r.scaletestSMTP(), r.scaletestPrebuilds(), r.scaletestBridge(), + r.scaletestChat(), r.scaletestLLMMock(), }, } @@ -404,13 +405,13 @@ func (f *workspaceTargetFlags) attach(opts *serpent.OptionSet) { Flag: "template", FlagShorthand: "t", Env: "CODER_SCALETEST_TEMPLATE", - Description: "Name or ID of the template. Traffic generation will be limited to workspaces created from this template.", + Description: "Name or ID of the template. Only workspaces created from this template are targeted.", Value: serpent.StringOf(&f.template), }, serpent.Option{ Flag: "target-workspaces", Env: "CODER_SCALETEST_TARGET_WORKSPACES", - Description: "Target a specific range of workspaces in the format [START]:[END] (exclusive). Example: 0:10 will target the 10 first alphabetically sorted workspaces (0-9).", + Description: "Target a specific range of matching workspaces in the format [START]:[END] (exclusive). Example: 0:10 targets the first 10 matching workspaces returned by the workspace query.", Value: serpent.StringOf(&f.targetWorkspaces), }, serpent.Option{ diff --git a/cli/exp_scaletest_chat.go b/cli/exp_scaletest_chat.go new file mode 100644 index 0000000000000..bbde5f67abe07 --- /dev/null +++ b/cli/exp_scaletest_chat.go @@ -0,0 +1,254 @@ +//go:build !slim + +package cli + +import ( + "fmt" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/sloghuman" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/scaletest/chat" + "github.com/coder/coder/v2/scaletest/harness" + "github.com/coder/coder/v2/scaletest/loadtestutil" + "github.com/coder/serpent" +) + +func (r *RootCmd) scaletestChat() *serpent.Command { + var ( + chatsPerWorkspace int64 + prompt string + turns int64 + turnStartDelay time.Duration + llmMockURL string + targetFlags = &workspaceTargetFlags{} + tracingFlags = &scaletestTracingFlags{} + prometheusFlags = &scaletestPrometheusFlags{} + timeoutStrategy = &timeoutFlags{} + cleanupStrategy = newScaletestCleanupStrategy() + output = &scaletestOutputFlags{} + ) + + cmd := &serpent.Command{ + Use: "chat", + Short: "Generate Coder Agents load.", + Handler: func(inv *serpent.Invocation) error { + baseCtx := inv.Context() + ctx, stop := inv.SignalNotifyContext(baseCtx, StopSignals...) + defer stop() + + outputs, err := output.parse() + if err != nil { + return xerrors.Errorf("could not parse --output flags: %w", err) + } + switch { + case turns < 1: + return xerrors.Errorf("--turns must be at least 1") + case chatsPerWorkspace < 1: + return xerrors.Errorf("--chats-per-workspace must be at least 1") + } + + client, err := r.InitClient(inv) + if err != nil { + return err + } + me, err := RequireAdmin(ctx, client) + if err != nil { + return err + } + client.HTTPClient.Transport = &codersdk.HeaderTransport{ + Transport: client.HTTPClient.Transport, + Header: BypassHeader, + } + + workspaces, err := targetFlags.getTargetedWorkspaces(ctx, client, me.OrganizationIDs, inv.Stdout) + if err != nil { + return err + } + + logger := slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelDebug) + modelConfigID, err := chat.EnsureScaletestModelConfig(ctx, codersdk.NewExperimentalClient(client), logger, llmMockURL) + if err != nil { + return err + } + + // Start metrics and tracing before creating runners. + reg := prometheus.NewRegistry() + metrics := chat.NewMetrics(reg) + + prometheusSrvClose := ServeHandler(baseCtx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus") + + tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(baseCtx) + if err != nil { + prometheusSrvClose() + return xerrors.Errorf("create tracer provider: %w", err) + } + defer func() { + if tracingEnabled { + _, _ = fmt.Fprintln(inv.Stderr, "Uploading traces...") + } + if err := closeTracing(baseCtx); err != nil { + _, _ = fmt.Fprintf(inv.Stderr, "Error uploading traces: %+v\n", err) + } + _, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait) + <-time.After(prometheusFlags.Wait) + prometheusSrvClose() + }() + + tracer := tracerProvider.Tracer(scaletestTracerName) + + var turnStartReadyWaitGroup *sync.WaitGroup + var startTurnsChan chan struct{} + if turnStartDelay > 0 && turns > 1 { + turnStartReadyWaitGroup = &sync.WaitGroup{} + startTurnsChan = make(chan struct{}) + } + + chatHarness := harness.NewTestHarness( + timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), + cleanupStrategy.toStrategy(), + ) + for workspaceIndex, targetWorkspace := range workspaces { + for chatIndex := int64(0); chatIndex < chatsPerWorkspace; chatIndex++ { + if turnStartReadyWaitGroup != nil { + turnStartReadyWaitGroup.Add(1) + } + + cfg := chat.Config{ + OrganizationID: targetWorkspace.OrganizationID, + WorkspaceID: targetWorkspace.ID, + Prompt: prompt, + ModelConfigID: modelConfigID, + Turns: int(turns), + TurnStartDelay: turnStartDelay, + TurnStartReadyWaitGroup: turnStartReadyWaitGroup, + StartTurnsChan: startTurnsChan, + Metrics: metrics, + } + if err := cfg.Validate(); err != nil { + return xerrors.Errorf("validate config for workspace %d chat %d: %w", workspaceIndex, chatIndex, err) + } + + runnerClient, err := loadtestutil.DupClientCopyingHeaders(client, BypassHeader) + if err != nil { + return xerrors.Errorf("duplicate client for workspace %d chat %d: %w", workspaceIndex, chatIndex, err) + } + var runner harness.Runnable = chat.NewRunner(runnerClient, cfg) + if tracingEnabled { + runner = &runnableTraceWrapper{ + tracer: tracer, + runner: runner, + spanName: fmt.Sprintf("chat/workspace-%d-chat-%d", workspaceIndex, chatIndex), + } + } + chatHarness.AddRun("chat", fmt.Sprintf("workspace-%d-chat-%d", workspaceIndex, chatIndex), runner) + } + } + + // Run the chat harness in the background so the CLI can release the + // follow-up turns after every runner finishes its initial turn. + totalChats := int64(len(workspaces)) * chatsPerWorkspace + _, _ = fmt.Fprintf(inv.Stderr, "Starting chat scale test with %d chats across %d workspaces...\n", totalChats, len(workspaces)) + testCtx, testCancel := timeoutStrategy.toContext(ctx) + defer testCancel() + testDone := make(chan error, 1) + go func() { + testDone <- chatHarness.Run(testCtx) + }() + + if turnStartReadyWaitGroup != nil { + initialTurnsDone := make(chan struct{}) + go func() { + turnStartReadyWaitGroup.Wait() + close(initialTurnsDone) + }() + + select { + case <-testCtx.Done(): + return testCtx.Err() + case <-initialTurnsDone: + } + + _, _ = fmt.Fprintf(inv.Stderr, "All %d initial turns completed, waiting %s before starting the follow-up turns...\n", totalChats, turnStartDelay) + select { + case <-testCtx.Done(): + return testCtx.Err() + case <-time.After(turnStartDelay): + } + + close(startTurnsChan) + } + + if err := <-testDone; err != nil { + return xerrors.Errorf("run harness: %w", err) + } + + results := chatHarness.Results() + for _, o := range outputs { + if err := o.write(results, inv.Stdout); err != nil { + return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err) + } + } + + _, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up (archiving chats)...") + cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx) + defer cleanupCancel() + if err := chatHarness.Cleanup(cleanupCtx); err != nil { + return xerrors.Errorf("cleanup chats: %w", err) + } + + if results.TotalFail > 0 { + return xerrors.Errorf("scale test failed: %d/%d runs failed", results.TotalFail, results.TotalRuns) + } + + _, _ = fmt.Fprintf(inv.Stderr, "Scale test passed: %d/%d runs succeeded\n", results.TotalPass, results.TotalRuns) + return nil + }, + } + + cmd.Options = serpent.OptionSet{ + { + Flag: "chats-per-workspace", + Description: "Number of chats to run against each targeted workspace. Required and must be greater than 0.", + Value: serpent.Int64Of(&chatsPerWorkspace), + Required: true, + }, + { + Flag: "prompt", + Description: "Text prompt to send on every turn in each chat.", + Default: "Reply with one short sentence.", + Value: serpent.StringOf(&prompt), + }, + { + Flag: "turns", + Description: "Number of user to assistant exchanges per chat conversation.", + Default: "10", + Value: serpent.Int64Of(&turns), + }, + { + Flag: "turn-start-delay", + Description: "Delay between every chat completing its initial turn and starting the follow-up turns. Use this to separate initial-turn load from follow-up-turn load.", + Default: "0s", + Value: serpent.DurationOf(&turnStartDelay), + }, + { + Flag: "llm-mock-url", + Description: "URL of the mock LLM server (e.g. http://127.0.0.1:8080/v1). Creates or updates the Scaletest LLM Mock openai-compat provider and model config to point at this URL.", + Value: serpent.StringOf(&llmMockURL), + Required: true, + }, + } + targetFlags.attach(&cmd.Options) + output.attach(&cmd.Options) + tracingFlags.attach(&cmd.Options) + prometheusFlags.attach(&cmd.Options) + timeoutStrategy.attach(&cmd.Options) + cleanupStrategy.attach(&cmd.Options) + return cmd +} diff --git a/scaletest/chat/client.go b/scaletest/chat/client.go new file mode 100644 index 0000000000000..552bbd87e1982 --- /dev/null +++ b/scaletest/chat/client.go @@ -0,0 +1,54 @@ +package chat + +import ( + "context" + "io" + + "github.com/google/uuid" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/codersdk" +) + +type chatClient interface { + SetLogger(logger slog.Logger) + SetLogBodies(logBodies bool) + CreateChat(ctx context.Context, req codersdk.CreateChatRequest) (codersdk.Chat, error) + StreamChat(ctx context.Context, chatID uuid.UUID, opts *codersdk.StreamChatOptions) (<-chan codersdk.ChatStreamEvent, io.Closer, error) + CreateChatMessage(ctx context.Context, chatID uuid.UUID, req codersdk.CreateChatMessageRequest) (codersdk.CreateChatMessageResponse, error) + UpdateChat(ctx context.Context, chatID uuid.UUID, req codersdk.UpdateChatRequest) error +} + +type sdkChatClient struct { + client *codersdk.ExperimentalClient +} + +func newChatClient(client *codersdk.Client) chatClient { + return &sdkChatClient{client: codersdk.NewExperimentalClient(client)} +} + +func (c *sdkChatClient) SetLogger(logger slog.Logger) { + c.client.SetLogger(logger) +} + +func (c *sdkChatClient) SetLogBodies(logBodies bool) { + c.client.SetLogBodies(logBodies) +} + +func (c *sdkChatClient) CreateChat(ctx context.Context, req codersdk.CreateChatRequest) (codersdk.Chat, error) { + return c.client.CreateChat(ctx, req) +} + +func (c *sdkChatClient) StreamChat(ctx context.Context, chatID uuid.UUID, opts *codersdk.StreamChatOptions) (<-chan codersdk.ChatStreamEvent, io.Closer, error) { + return c.client.StreamChat(ctx, chatID, opts) +} + +func (c *sdkChatClient) CreateChatMessage(ctx context.Context, chatID uuid.UUID, req codersdk.CreateChatMessageRequest) (codersdk.CreateChatMessageResponse, error) { + return c.client.CreateChatMessage(ctx, chatID, req) +} + +func (c *sdkChatClient) UpdateChat(ctx context.Context, chatID uuid.UUID, req codersdk.UpdateChatRequest) error { + return c.client.UpdateChat(ctx, chatID, req) +} + +var _ chatClient = (*sdkChatClient)(nil) diff --git a/scaletest/chat/config.go b/scaletest/chat/config.go new file mode 100644 index 0000000000000..5b6b36baa2ea9 --- /dev/null +++ b/scaletest/chat/config.go @@ -0,0 +1,78 @@ +package chat + +import ( + "sync" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +// Config describes a single chat runner within a scaletest invocation. +type Config struct { + // OrganizationID is the organization that owns the target workspace. + OrganizationID uuid.UUID `json:"organization_id"` + + // WorkspaceID is the pre-existing workspace to use for this chat run. + WorkspaceID uuid.UUID `json:"workspace_id"` + + // Prompt is the text content sent on every turn. + Prompt string `json:"prompt"` + + // ModelConfigID is the scaletest mock LLM model config. + ModelConfigID uuid.UUID `json:"model_config_id"` + + // Turns is the total number of user to assistant exchanges per chat. + // Must be at least 1. + Turns int `json:"turns"` + + // TurnStartDelay is the shared delay between every runner completing + // its initial turn and the release of the follow-up turns. Set + // to 0 to send all turns without an inter-phase pause. + TurnStartDelay time.Duration `json:"turn_start_delay"` + + // TurnStartReadyWaitGroup coordinates the gap between the initial turn + // finishing and the follow-up turns. Each runner signals exactly + // once after its first turn reaches a terminal status, or when it + // knows it will never reach that point. + TurnStartReadyWaitGroup *sync.WaitGroup `json:"-"` + + // StartTurnsChan blocks follow-up turns until the CLI layer releases them. + StartTurnsChan chan struct{} `json:"-"` + + Metrics *Metrics `json:"-"` +} + +func (c Config) Validate() error { + if c.OrganizationID == uuid.Nil { + return xerrors.Errorf("validate organization_id: must not be empty") + } + if c.WorkspaceID == uuid.Nil { + return xerrors.Errorf("validate workspace_id: must not be empty") + } + if c.Prompt == "" { + return xerrors.Errorf("validate prompt: must not be empty") + } + if c.ModelConfigID == uuid.Nil { + return xerrors.Errorf("validate model_config_id: must not be empty") + } + if c.Turns < 1 { + return xerrors.Errorf("validate turns: must be at least 1") + } + if c.TurnStartDelay < 0 { + return xerrors.Errorf("validate turn_start_delay: must not be negative") + } + if c.TurnStartDelay > 0 && c.Turns > 1 { + if c.TurnStartReadyWaitGroup == nil { + return xerrors.Errorf("validate turn_start_ready_wait_group: must not be nil when turn start delay is enabled for more than one turn") + } + if c.StartTurnsChan == nil { + return xerrors.Errorf("validate start_turns_chan: must not be nil when turn start delay is enabled for more than one turn") + } + } + if c.Metrics == nil { + return xerrors.Errorf("validate metrics: must not be nil") + } + + return nil +} diff --git a/scaletest/chat/metrics.go b/scaletest/chat/metrics.go new file mode 100644 index 0000000000000..829931cd81c0c --- /dev/null +++ b/scaletest/chat/metrics.go @@ -0,0 +1,137 @@ +package chat + +import "github.com/prometheus/client_golang/prometheus" + +const ( + metricLabelPhase = "phase" + metricLabelStatus = "status" + metricLabelStage = "stage" + + phaseInitial = "initial" + phaseFollowUp = "follow_up" + + failureStageCreateChat = "create_chat" + failureStageCreateMessage = "create_message" + failureStageStreamOpen = "stream_open" + failureStageStreamEndedEarly = "stream_ended_early" + failureStageStatusError = "status_error" +) + +var ( + chatRequestLatencyBuckets = prometheus.ExponentialBucketsRange(0.05, 120, 18) + chatProcessingLatencyBuckets = prometheus.ExponentialBucketsRange(0.1, 300, 18) +) + +// Metrics holds the Prometheus metrics emitted by the chat scaletest. +type Metrics struct { + ChatCreateLatencySeconds prometheus.Histogram + ChatMessageLatencySeconds *prometheus.HistogramVec + ChatConversationDurationSeconds prometheus.Histogram + ChatTimeToRunningSeconds *prometheus.HistogramVec + ChatTimeToFirstOutputSeconds *prometheus.HistogramVec + ChatTimeToTerminalStatusSeconds *prometheus.HistogramVec + ChatStageFailuresTotal *prometheus.CounterVec + ChatTerminalStatusTotal *prometheus.CounterVec + ChatTurnsCompletedTotal prometheus.Counter + ChatRetryEventsTotal prometheus.Counter + ActiveChatStreams prometheus.Gauge +} + +func NewMetrics(reg prometheus.Registerer) *Metrics { + if reg == nil { + reg = prometheus.DefaultRegisterer + } + + phaseLabelNames := []string{metricLabelPhase} + terminalStatusLabelNames := []string{metricLabelStatus} + failureStageLabelNames := []string{metricLabelStage} + + m := &Metrics{ + ChatCreateLatencySeconds: prometheus.NewHistogram(prometheus.HistogramOpts{ + Namespace: "coderd", + Subsystem: "scaletest", + Name: "chat_create_latency_seconds", + Help: "Time in seconds to create a chat and enqueue the initial turn.", + Buckets: chatRequestLatencyBuckets, + }), + ChatMessageLatencySeconds: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "coderd", + Subsystem: "scaletest", + Name: "chat_message_latency_seconds", + Help: "Time in seconds to add a follow-up message to an existing chat.", + Buckets: chatRequestLatencyBuckets, + }, phaseLabelNames), + ChatConversationDurationSeconds: prometheus.NewHistogram(prometheus.HistogramOpts{ + Namespace: "coderd", + Subsystem: "scaletest", + Name: "chat_conversation_duration_seconds", + Help: "Time in seconds from chat creation start until the conversation finishes or errors.", + Buckets: chatProcessingLatencyBuckets, + }), + ChatTimeToRunningSeconds: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "coderd", + Subsystem: "scaletest", + Name: "chat_time_to_running_seconds", + Help: "Time in seconds from the start of a chat turn until the chat enters running status.", + Buckets: chatProcessingLatencyBuckets, + }, phaseLabelNames), + ChatTimeToFirstOutputSeconds: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "coderd", + Subsystem: "scaletest", + Name: "chat_time_to_first_output_seconds", + Help: "Time in seconds from the start of a chat turn until the first output is received.", + Buckets: chatProcessingLatencyBuckets, + }, phaseLabelNames), + ChatTimeToTerminalStatusSeconds: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "coderd", + Subsystem: "scaletest", + Name: "chat_time_to_terminal_status_seconds", + Help: "Time in seconds from the start of a chat turn until a terminal status is received.", + Buckets: chatProcessingLatencyBuckets, + }, phaseLabelNames), + ChatStageFailuresTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "coderd", + Subsystem: "scaletest", + Name: "chat_stage_failures_total", + Help: "Total number of terminal stage-specific chat runner failures.", + }, failureStageLabelNames), + ChatTerminalStatusTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "coderd", + Subsystem: "scaletest", + Name: "chat_terminal_status_total", + Help: "Total number of terminal chat statuses observed.", + }, terminalStatusLabelNames), + ChatTurnsCompletedTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "coderd", + Subsystem: "scaletest", + Name: "chat_turns_completed_total", + Help: "Total number of chat turns completed successfully.", + }), + ChatRetryEventsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "coderd", + Subsystem: "scaletest", + Name: "chat_retry_events_total", + Help: "Total number of chat retry events observed.", + }), + ActiveChatStreams: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "coderd", + Subsystem: "scaletest", + Name: "active_chat_streams", + Help: "Current number of active chat streams.", + }), + } + + reg.MustRegister(m.ChatCreateLatencySeconds) + reg.MustRegister(m.ChatMessageLatencySeconds) + reg.MustRegister(m.ChatConversationDurationSeconds) + reg.MustRegister(m.ChatTimeToRunningSeconds) + reg.MustRegister(m.ChatTimeToFirstOutputSeconds) + reg.MustRegister(m.ChatTimeToTerminalStatusSeconds) + reg.MustRegister(m.ChatStageFailuresTotal) + reg.MustRegister(m.ChatTerminalStatusTotal) + reg.MustRegister(m.ChatTurnsCompletedTotal) + reg.MustRegister(m.ChatRetryEventsTotal) + reg.MustRegister(m.ActiveChatStreams) + + return m +} diff --git a/scaletest/chat/provider.go b/scaletest/chat/provider.go new file mode 100644 index 0000000000000..ba946d7db2720 --- /dev/null +++ b/scaletest/chat/provider.go @@ -0,0 +1,148 @@ +package chat + +import ( + "context" + "net/http" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/codersdk" +) + +const ( + scaletestProviderType = "openai-compat" + scaletestProviderDisplayName = "Scaletest LLM Mock" + scaletestModelName = "scaletest-model" + scaletestModelDisplayName = "Scaletest Model" +) + +type scaletestProviderAction string + +const ( + scaletestProviderActionCreated scaletestProviderAction = "created" + scaletestProviderActionUpdated scaletestProviderAction = "updated" + scaletestProviderActionReused scaletestProviderAction = "reused" +) + +// EnsureScaletestModelConfig bootstraps the shared chat provider and model +// config used by chat scaletests. +func EnsureScaletestModelConfig(ctx context.Context, client *codersdk.ExperimentalClient, logger slog.Logger, llmMockURL string) (uuid.UUID, error) { + logger.Info(ctx, "bootstrapping mock LLM provider", slog.F("llm_mock_url", llmMockURL)) + + provider, providerAction, err := ensureScaletestProvider(ctx, client, llmMockURL) + if err != nil { + return uuid.Nil, err + } + + switch providerAction { + case scaletestProviderActionCreated: + logger.Info(ctx, "created mock LLM provider", + slog.F("provider_type", scaletestProviderType), + slog.F("llm_mock_url", llmMockURL), + ) + case scaletestProviderActionUpdated: + logger.Info(ctx, "updated mock LLM provider", + slog.F("provider_type", scaletestProviderType), + slog.F("provider_id", provider.ID), + slog.F("llm_mock_url", llmMockURL), + ) + case scaletestProviderActionReused: + logger.Info(ctx, "reusing mock LLM provider", + slog.F("provider_type", scaletestProviderType), + slog.F("provider_id", provider.ID), + ) + } + + modelConfigs, err := client.ListChatModelConfigs(ctx) + if err != nil { + return uuid.Nil, xerrors.Errorf("list chat model configs: %w", err) + } + + for i := range modelConfigs { + if modelConfigs[i].Provider != provider.Provider || modelConfigs[i].Model != scaletestModelName { + continue + } + if !modelConfigs[i].Enabled { + return uuid.Nil, xerrors.Errorf("existing scaletest chat model config %s is disabled; re-enable or delete it before running scaletests", modelConfigs[i].ID) + } + modelConfigID := modelConfigs[i].ID + logger.Info(ctx, "reusing scaletest model config", slog.F("model_config_id", modelConfigID)) + return modelConfigID, nil + } + + enabled := true + isDefault := false + contextLimit := int64(4096) + created, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + Provider: provider.Provider, + Model: scaletestModelName, + DisplayName: scaletestModelDisplayName, + Enabled: &enabled, + IsDefault: &isDefault, + ContextLimit: &contextLimit, + }) + if err != nil { + return uuid.Nil, xerrors.Errorf("create scaletest chat model config: %w", err) + } + logger.Info(ctx, "created scaletest model config", slog.F("model_config_id", created.ID)) + return created.ID, nil +} + +func ensureScaletestProvider(ctx context.Context, client *codersdk.ExperimentalClient, llmMockURL string) (codersdk.ChatProviderConfig, scaletestProviderAction, error) { + enabled := true + mockProviderToken := uuid.NewString() + created, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: scaletestProviderType, + DisplayName: scaletestProviderDisplayName, + APIKey: mockProviderToken, + BaseURL: llmMockURL, + Enabled: &enabled, + }) + if err == nil { + return created, scaletestProviderActionCreated, nil + } + + var sdkErr *codersdk.Error + if !xerrors.As(err, &sdkErr) || sdkErr.StatusCode() != http.StatusConflict { + return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("create scaletest chat provider: %w", err) + } + + providers, err := client.ListChatProviders(ctx) + if err != nil { + return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("list chat providers: %w", err) + } + + var existing *codersdk.ChatProviderConfig + for i := range providers { + if providers[i].Provider == scaletestProviderType { + existing = &providers[i] + break + } + } + if existing == nil { + return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("find existing %s provider after conflict: not found", scaletestProviderType) + } + if existing.DisplayName != scaletestProviderDisplayName { + return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("refusing to overwrite existing %s provider %s with display name %q", scaletestProviderType, existing.ID, existing.DisplayName) + } + + if !existing.Enabled { + return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("existing scaletest chat provider %s is disabled; re-enable or delete it before running scaletests", existing.ID) + } + if existing.BaseURL == llmMockURL { + return *existing, scaletestProviderActionReused, nil + } + + updated, err := client.UpdateChatProvider(ctx, existing.ID, codersdk.UpdateChatProviderConfigRequest{ + DisplayName: scaletestProviderDisplayName, + APIKey: &mockProviderToken, + BaseURL: &llmMockURL, + Enabled: &enabled, + }) + if err != nil { + return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("update scaletest chat provider: %w", err) + } + return updated, scaletestProviderActionUpdated, nil +} diff --git a/scaletest/chat/run.go b/scaletest/chat/run.go new file mode 100644 index 0000000000000..b2e591fab6b78 --- /dev/null +++ b/scaletest/chat/run.go @@ -0,0 +1,413 @@ +package chat + +import ( + "context" + "io" + "sync" + "time" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/sloghuman" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/scaletest/harness" + "github.com/coder/coder/v2/scaletest/loadtestutil" +) + +// Runner executes a single chat conversation as part of a scaletest run. +type Runner struct { + client chatClient + cfg Config + + chatID uuid.UUID + result runnerResult + + conversationStart time.Time + turnStartTime time.Time + currentPhase string + lastStreamError string + lastStatus codersdk.ChatStatus + sawTurnRunning bool + sawTurnFirstOutput bool + markTurnStartReady func() +} + +type runnerResult struct { + finalStatus string + failureStage string + totalDuration time.Duration + sawFirstOutput bool + retryCount int + eventCount int + turnsCompleted int +} + +var ( + _ harness.Runnable = &Runner{} + _ harness.Cleanable = &Runner{} + _ harness.Collectable = &Runner{} +) + +func NewRunner(client *codersdk.Client, cfg Config) *Runner { + return &Runner{ + client: newChatClient(client), + cfg: cfg, + } +} + +func (r *Runner) Run(ctx context.Context, id string, logs io.Writer) error { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + + logs = loadtestutil.NewSyncWriter(logs) + logger := slog.Make(sloghuman.Sink(logs)).Leveled(slog.LevelDebug).Named(id) + r.client.SetLogger(logger) + r.client.SetLogBodies(true) + + span.SetAttributes( + attribute.String("chat.runner_id", id), + attribute.String("chat.workspace_id", r.cfg.WorkspaceID.String()), + attribute.Int("chat.turns_requested", r.cfg.Turns), + attribute.Int64("chat.turn_start_delay_ms", r.cfg.TurnStartDelay.Milliseconds()), + ) + span.SetAttributes(attribute.String("chat.model_config_id", r.cfg.ModelConfigID.String())) + + markTurnStartReady := func() {} + if r.cfg.TurnStartReadyWaitGroup != nil { + markTurnStartReady = sync.OnceFunc(r.cfg.TurnStartReadyWaitGroup.Done) + } + r.markTurnStartReady = markTurnStartReady + defer r.markTurnStartReady() + + defer func() { + if !r.conversationStart.IsZero() { + r.result.totalDuration = time.Since(r.conversationStart) + r.cfg.Metrics.ChatConversationDurationSeconds.Observe(r.result.totalDuration.Seconds()) + } + span.SetAttributes( + attribute.String("chat.final_status", r.result.finalStatus), + attribute.String("chat.failure_stage", r.result.failureStage), + attribute.Int("chat.retry_count", r.result.retryCount), + attribute.Int("chat.turns_completed", r.result.turnsCompleted), + attribute.Bool("chat.saw_first_output", r.result.sawFirstOutput), + ) + if r.result.totalDuration > 0 { + span.SetAttributes(attribute.Float64("chat.total_duration_seconds", r.result.totalDuration.Seconds())) + } + }() + + workspaceID := r.cfg.WorkspaceID + modelConfigID := r.cfg.ModelConfigID + logger = logger.With(slog.F("workspace_id", workspaceID)) + logger.Info(ctx, "starting chat runner") + + r.resetConversation(time.Now(), markTurnStartReady) + + createStartedAt := time.Now() + chat, err := r.client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: r.cfg.OrganizationID, + WorkspaceID: &workspaceID, + ModelConfigID: &modelConfigID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: r.cfg.Prompt, + }}, + }) + if err != nil { + r.result.failureStage = failureStageCreateChat + r.cfg.Metrics.ChatStageFailuresTotal.WithLabelValues(r.result.failureStage).Inc() + return xerrors.Errorf("create chat: %w", err) + } + r.cfg.Metrics.ChatCreateLatencySeconds.Observe(time.Since(createStartedAt).Seconds()) + + r.chatID = chat.ID + span.SetAttributes(attribute.String("chat.chat_id", chat.ID.String())) + logger = logger.With(slog.F("chat_id", chat.ID)) + logger.Info(ctx, "created chat session", slog.F("duration", time.Since(createStartedAt))) + + // CreateChat already queues the first prompt for processing on the + // server, so the initial turn is in flight as soon as CreateChat + // returns. Open the stream immediately and let the conversation loop + // drive the gate at the natural phase boundary (after the first turn + // reaches a terminal Waiting status), rather than fencing here on a + // turn that has already started running. + events, closer, err := r.client.StreamChat(ctx, chat.ID, nil) + if err != nil { + r.result.failureStage = failureStageStreamOpen + r.cfg.Metrics.ChatStageFailuresTotal.WithLabelValues(r.result.failureStage).Inc() + return xerrors.Errorf("stream chat: %w", err) + } + + r.cfg.Metrics.ActiveChatStreams.Inc() + defer func() { + r.cfg.Metrics.ActiveChatStreams.Dec() + _ = closer.Close() + }() + + logger.Info(ctx, "streaming chat events") + + return r.runConversation(ctx, chat.ID, logger, events) +} + +func (r *Runner) resetConversation(conversationStart time.Time, markTurnStartReady func()) { + if markTurnStartReady == nil { + markTurnStartReady = func() {} + } + + r.result = runnerResult{} + r.conversationStart = conversationStart + r.turnStartTime = conversationStart + r.currentPhase = phaseInitial + r.lastStreamError = "" + r.lastStatus = "" + r.sawTurnRunning = false + r.sawTurnFirstOutput = false + r.markTurnStartReady = markTurnStartReady +} + +func (r *Runner) runConversation(ctx context.Context, chatID uuid.UUID, logger slog.Logger, events <-chan codersdk.ChatStreamEvent) error { + r.chatID = chatID + + for event := range events { + r.result.eventCount++ + + switch event.Type { + case codersdk.ChatStreamEventTypeStatus: + if event.Status == nil { + continue + } + done, err := r.handleStatusEvent(ctx, chatID, logger, event.Status.Status) + if err != nil { + return err + } + if done { + return nil + } + case codersdk.ChatStreamEventTypeMessagePart: + r.handleMessagePartEvent(ctx, logger) + case codersdk.ChatStreamEventTypeMessage: + // StreamChat replays persisted rows as message events, not + // message_part deltas, when a turn finished server-side before + // the stream attached. Route assistant rows through the same + // first-output path; skip user rows so persisted prompts do not + // count as model output. + if event.Message == nil || event.Message.Role != codersdk.ChatMessageRoleAssistant { + continue + } + r.handleMessagePartEvent(ctx, logger) + case codersdk.ChatStreamEventTypeRetry: + r.handleRetryEvent(ctx, logger, event.Retry) + case codersdk.ChatStreamEventTypeError: + r.handleErrorEvent(ctx, logger, event.Error) + } + } + + if ctx.Err() != nil { + return ctx.Err() + } + + r.result.failureStage = failureStageStreamEndedEarly + r.cfg.Metrics.ChatStageFailuresTotal.WithLabelValues(r.result.failureStage).Inc() + if r.lastStreamError != "" { + return xerrors.Errorf("chat %s stream ended before completing %d of %d turns: %s", chatID, r.result.turnsCompleted, r.cfg.Turns, r.lastStreamError) + } + return xerrors.Errorf("chat %s stream ended before completing %d of %d turns", chatID, r.result.turnsCompleted, r.cfg.Turns) +} + +func (r *Runner) handleStatusEvent(ctx context.Context, chatID uuid.UUID, logger slog.Logger, status codersdk.ChatStatus) (bool, error) { + if status == r.lastStatus { + return false, nil + } + if status == codersdk.ChatStatusWaiting && + !r.sawTurnFirstOutput && + (r.sawTurnRunning || r.result.turnsCompleted > 0) { + return false, nil + } + r.lastStatus = status + + switch status { + case codersdk.ChatStatusRunning: + r.sawTurnRunning = true + r.cfg.Metrics.ChatTimeToRunningSeconds.WithLabelValues(r.currentPhase).Observe(time.Since(r.turnStartTime).Seconds()) + logger.Info(ctx, "chat reached running status", + slog.F("phase", r.currentPhase), + ) + return false, nil + case codersdk.ChatStatusWaiting: + r.result.turnsCompleted++ + turnDuration := time.Since(r.turnStartTime) + r.cfg.Metrics.ChatTimeToTerminalStatusSeconds.WithLabelValues(r.currentPhase).Observe(turnDuration.Seconds()) + r.cfg.Metrics.ChatTerminalStatusTotal.WithLabelValues(string(codersdk.ChatStatusWaiting)).Inc() + r.cfg.Metrics.ChatTurnsCompletedTotal.Inc() + logger.Info(ctx, "chat completed turn", + slog.F("turn", r.result.turnsCompleted), + slog.F("turns", r.cfg.Turns), + slog.F("duration", turnDuration), + ) + if r.result.turnsCompleted >= r.cfg.Turns { + r.result.finalStatus = string(codersdk.ChatStatusWaiting) + conversationDuration := time.Since(r.conversationStart) + logger.Info(ctx, "chat reached terminal status", + slog.F("status", codersdk.ChatStatusWaiting), + slog.F("duration", conversationDuration), + slog.F("turns_completed", r.result.turnsCompleted), + ) + return true, nil + } + + // After the very first turn completes, mark this runner ready + // for the CLI-coordinated turn-start gate. The inter-phase + // delay measures the gap between every chat actually finishing its + // initial turn and the start of the follow-up turns, not the gap + // between CreateChat returning and the next turn. + if r.result.turnsCompleted == 1 { + r.markTurnStartReady() + if r.cfg.StartTurnsChan != nil { + logger.Info(ctx, "chat waiting for turn start release", + slog.F("turn_start_delay", r.cfg.TurnStartDelay), + ) + select { + case <-ctx.Done(): + return false, ctx.Err() + case <-r.cfg.StartTurnsChan: + } + } + } + + nextTurn := r.result.turnsCompleted + 1 + r.currentPhase = phaseFollowUp + r.turnStartTime = time.Now() + r.lastStreamError = "" + r.lastStatus = "" + r.sawTurnRunning = false + r.sawTurnFirstOutput = false + if err := r.sendNextTurn(ctx, chatID, logger, nextTurn, r.currentPhase); err != nil { + r.result.failureStage = failureStageCreateMessage + r.cfg.Metrics.ChatStageFailuresTotal.WithLabelValues(r.result.failureStage).Inc() + return false, err + } + return false, nil + case codersdk.ChatStatusError: + r.result.finalStatus = string(codersdk.ChatStatusError) + r.result.failureStage = failureStageStatusError + turnDuration := time.Since(r.turnStartTime) + r.cfg.Metrics.ChatTimeToTerminalStatusSeconds.WithLabelValues(r.currentPhase).Observe(turnDuration.Seconds()) + r.cfg.Metrics.ChatTerminalStatusTotal.WithLabelValues(string(codersdk.ChatStatusError)).Inc() + r.cfg.Metrics.ChatStageFailuresTotal.WithLabelValues(r.result.failureStage).Inc() + + errMessage := r.lastStreamError + if errMessage == "" { + errMessage = "chat reached error status" + } + logger.Error(ctx, "chat reached terminal status", + slog.F("status", codersdk.ChatStatusError), + slog.F("turns_completed", r.result.turnsCompleted), + slog.F("turns", r.cfg.Turns), + slog.F("error", errMessage), + ) + return false, xerrors.Errorf("chat %s reached error status: %s", chatID, errMessage) + default: + return false, nil + } +} + +func (r *Runner) sendNextTurn(ctx context.Context, chatID uuid.UUID, logger slog.Logger, nextTurn int, phase string) error { + messageStartedAt := time.Now() + modelConfigID := r.cfg.ModelConfigID + _, err := r.client.CreateChatMessage(ctx, chatID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: r.cfg.Prompt, + }}, + ModelConfigID: &modelConfigID, + }) + if err != nil { + return xerrors.Errorf("create chat message for turn %d: %w", nextTurn, err) + } + + r.cfg.Metrics.ChatMessageLatencySeconds.WithLabelValues(phase).Observe(time.Since(messageStartedAt).Seconds()) + logger.Info(ctx, "chat sent message", + slog.F("turn", nextTurn), + slog.F("turns", r.cfg.Turns), + ) + return nil +} + +func (r *Runner) handleMessagePartEvent(ctx context.Context, logger slog.Logger) { + if r.sawTurnFirstOutput { + return + } + r.sawTurnFirstOutput = true + r.result.sawFirstOutput = true + firstOutputDuration := time.Since(r.turnStartTime) + r.cfg.Metrics.ChatTimeToFirstOutputSeconds.WithLabelValues(r.currentPhase).Observe(firstOutputDuration.Seconds()) + logger.Info(ctx, "chat received first output", + slog.F("phase", r.currentPhase), + slog.F("duration", firstOutputDuration), + ) +} + +func (r *Runner) handleRetryEvent(ctx context.Context, logger slog.Logger, retry *codersdk.ChatStreamRetry) { + r.result.retryCount++ + r.cfg.Metrics.ChatRetryEventsTotal.Inc() + if retry != nil { + logger.Warn(ctx, "chat retry event", + slog.F("attempt", retry.Attempt), + slog.F("delay_ms", retry.DelayMs), + slog.F("error", retry.Error), + ) + return + } + logger.Warn(ctx, "chat retry event") +} + +func (r *Runner) handleErrorEvent(ctx context.Context, logger slog.Logger, eventErr *codersdk.ChatError) { + if eventErr != nil && eventErr.Message != "" { + r.lastStreamError = eventErr.Message + logger.Warn(ctx, "chat stream error", + slog.F("error", r.lastStreamError), + ) + return + } + logger.Warn(ctx, "chat stream error event") +} + +func (r *Runner) Cleanup(ctx context.Context, id string, logs io.Writer) error { + if r.chatID == uuid.Nil { + return nil + } + + logs = loadtestutil.NewSyncWriter(logs) + logger := slog.Make(sloghuman.Sink(logs)).Leveled(slog.LevelDebug).Named(id).With(slog.F("chat_id", r.chatID)) + r.client.SetLogger(logger) + r.client.SetLogBodies(true) + + archived := true + logger.Info(ctx, "archiving chat session") + if err := r.client.UpdateChat(ctx, r.chatID, codersdk.UpdateChatRequest{Archived: &archived}); err != nil { + logger.Error(ctx, "failed to archive chat", slog.Error(err)) + return xerrors.Errorf("archive chat: %w", err) + } + logger.Info(ctx, "archived chat session") + return nil +} + +func (r *Runner) GetMetrics() map[string]any { + return map[string]any{ + "workspace_id": r.cfg.WorkspaceID.String(), + "turn_start_delay_ms": r.cfg.TurnStartDelay.Milliseconds(), + "chat_id": r.chatID.String(), + "final_status": r.result.finalStatus, + "failure_stage": r.result.failureStage, + "total_duration_seconds": r.result.totalDuration.Seconds(), + "saw_first_output": r.result.sawFirstOutput, + "retry_count": r.result.retryCount, + "event_count": r.result.eventCount, + "turns_requested": r.cfg.Turns, + "turns_completed": r.result.turnsCompleted, + } +} diff --git a/scaletest/chat/run_internal_test.go b/scaletest/chat/run_internal_test.go new file mode 100644 index 0000000000000..2d93737fae4c5 --- /dev/null +++ b/scaletest/chat/run_internal_test.go @@ -0,0 +1,391 @@ +package chat + +import ( + "bytes" + "context" + "io" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/sloghuman" + "github.com/coder/coder/v2/codersdk" +) + +func TestRunnerRunConversation(t *testing.T) { + t.Parallel() + + chatID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + noopMarkTurnStartReady := func() {} + + t.Run("OneTurnHappyPath", func(t *testing.T) { + t.Parallel() + + runner := newTestRunner(t, newRunConfig(t)) + events := make(chan codersdk.ChatStreamEvent, 3) + events <- statusEvent(chatID, codersdk.ChatStatusRunning) + events <- messagePartEvent(chatID) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + close(events) + + err := runTestConversation(t, runner, chatID, events, noopMarkTurnStartReady) + require.NoError(t, err) + result := runner.result + require.Equal(t, string(codersdk.ChatStatusWaiting), result.finalStatus) + require.Empty(t, result.failureStage) + require.True(t, result.sawFirstOutput) + require.Equal(t, 1, result.turnsCompleted) + require.Equal(t, 3, result.eventCount) + }) + + t.Run("DuplicateWaitingDoesNotAdvanceTurn", func(t *testing.T) { + t.Parallel() + + cfg := newRunConfig(t) + cfg.Turns = 2 + + events := make(chan codersdk.ChatStreamEvent, 7) + events <- statusEvent(chatID, codersdk.ChatStatusRunning) + events <- messagePartEvent(chatID) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + + var sendCount atomic.Int64 + runner := newTestRunnerWithChatMessage(t, cfg, chatID, func() { + sendCount.Add(1) + events <- statusEvent(chatID, codersdk.ChatStatusRunning) + events <- messagePartEvent(chatID) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + close(events) + }) + + err := runTestConversation(t, runner, chatID, events, noopMarkTurnStartReady) + require.NoError(t, err) + result := runner.result + require.Equal(t, int64(1), sendCount.Load()) + require.Equal(t, 2, result.turnsCompleted) + require.Equal(t, 7, result.eventCount) + require.Equal(t, string(codersdk.ChatStatusWaiting), result.finalStatus) + }) + + t.Run("StaleWaitingAfterNextTurnRunningDoesNotAdvanceTurn", func(t *testing.T) { + t.Parallel() + + cfg := newRunConfig(t) + cfg.Turns = 2 + + events := make(chan codersdk.ChatStreamEvent, 7) + events <- statusEvent(chatID, codersdk.ChatStatusRunning) + events <- messagePartEvent(chatID) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + + var sendCount atomic.Int64 + runner := newTestRunnerWithChatMessage(t, cfg, chatID, func() { + sendCount.Add(1) + events <- statusEvent(chatID, codersdk.ChatStatusRunning) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + events <- messagePartEvent(chatID) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + close(events) + }) + + err := runTestConversation(t, runner, chatID, events, noopMarkTurnStartReady) + require.NoError(t, err) + result := runner.result + require.Equal(t, int64(1), sendCount.Load()) + require.Equal(t, 2, result.turnsCompleted) + require.Equal(t, 7, result.eventCount) + require.Equal(t, string(codersdk.ChatStatusWaiting), result.finalStatus) + }) + + t.Run("FirstTurnGatesFollowUpStorm", func(t *testing.T) { + t.Parallel() + + // Reproduces the contract that the turn-start gate is checked + // after the first turn finishes, not before it begins. The runner + // must mark itself ready, wait for the release channel, and only + // then send turn 2. + cfg := newRunConfig(t) + cfg.Turns = 2 + readyWG := &sync.WaitGroup{} + readyWG.Add(1) + releaseChan := make(chan struct{}) + cfg.TurnStartReadyWaitGroup = readyWG + cfg.StartTurnsChan = releaseChan + + events := make(chan codersdk.ChatStreamEvent, 4) + events <- statusEvent(chatID, codersdk.ChatStatusRunning) + events <- messagePartEvent(chatID) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + + ready := make(chan struct{}) + go func() { + readyWG.Wait() + close(ready) + }() + + errCh := make(chan error, 1) + var sendCount atomic.Int64 + runner := newTestRunnerWithChatMessage(t, cfg, chatID, func() { + sendCount.Add(1) + events <- statusEvent(chatID, codersdk.ChatStatusRunning) + events <- messagePartEvent(chatID) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + close(events) + }) + + runner.resetConversation(time.Now(), sync.OnceFunc(readyWG.Done)) + + go func() { + runErr := runner.runConversation(context.Background(), chatID, testLogger(), events) + errCh <- runErr + }() + + select { + case <-ready: + case <-time.After(2 * time.Second): + t.Fatal("runner did not mark turn-start gate ready after first turn") + } + + require.Equal(t, int64(0), sendCount.Load(), "next turn was sent before turn-start release") + + close(releaseChan) + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("runner did not finish after turn-start release") + } + require.Equal(t, int64(1), sendCount.Load()) + }) + + t.Run("FirstOutputFromAssistantMessageEvent", func(t *testing.T) { + t.Parallel() + + // Snapshot race: when a turn finishes before stream attach, + // StreamChat replays rows as message events, never as + // message_part deltas; the assistant row must record first output. + runner := newTestRunner(t, newRunConfig(t)) + events := make(chan codersdk.ChatStreamEvent, 3) + events <- messageEvent(chatID, codersdk.ChatMessageRoleUser) + events <- messageEvent(chatID, codersdk.ChatMessageRoleAssistant) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + close(events) + + err := runTestConversation(t, runner, chatID, events, noopMarkTurnStartReady) + require.NoError(t, err) + result := runner.result + require.True(t, result.sawFirstOutput, "first output not recorded from assistant message event") + require.Equal(t, 1, result.turnsCompleted) + require.Equal(t, string(codersdk.ChatStatusWaiting), result.finalStatus) + }) + + t.Run("ImmediateWaitingCountsNextTurn", func(t *testing.T) { + t.Parallel() + + cfg := newRunConfig(t) + cfg.Turns = 2 + + events := make(chan codersdk.ChatStreamEvent, 3) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + + var sendCount atomic.Int64 + runner := newTestRunnerWithChatMessage(t, cfg, chatID, func() { + sendCount.Add(1) + events <- statusEvent(chatID, codersdk.ChatStatusRunning) + events <- messagePartEvent(chatID) + events <- statusEvent(chatID, codersdk.ChatStatusWaiting) + close(events) + }) + + err := runTestConversation(t, runner, chatID, events, noopMarkTurnStartReady) + require.NoError(t, err) + result := runner.result + require.Equal(t, int64(1), sendCount.Load()) + require.Equal(t, 2, result.turnsCompleted) + require.Equal(t, string(codersdk.ChatStatusWaiting), result.finalStatus) + }) +} + +func runTestConversation(t *testing.T, runner *Runner, chatID uuid.UUID, events <-chan codersdk.ChatStreamEvent, markTurnStartReady func()) error { + t.Helper() + runner.resetConversation(time.Now(), markTurnStartReady) + return runner.runConversation(context.Background(), chatID, testLogger(), events) +} + +func TestRunnerCleanup(t *testing.T) { + t.Parallel() + + chatID := uuid.MustParse("22222222-2222-2222-2222-222222222222") + + t.Run("ArchivesChat", func(t *testing.T) { + t.Parallel() + + runner, archived := newTestRunnerWithChatArchive(t, chatID, nil) + + logs := bytes.NewBuffer(nil) + err := runner.Cleanup(context.Background(), "runner-1", logs) + require.NoError(t, err) + require.True(t, archived()) + require.Contains(t, logs.String(), "archived chat") + }) + + t.Run("ArchiveErrorIsReturned", func(t *testing.T) { + t.Parallel() + + runner, archived := newTestRunnerWithChatArchive(t, chatID, xerrors.New("boom")) + + err := runner.Cleanup(context.Background(), "runner-1", bytes.NewBuffer(nil)) + require.Error(t, err) + require.ErrorContains(t, err, "archive chat") + require.True(t, archived()) + }) +} + +func testLogger() slog.Logger { + return slog.Make(sloghuman.Sink(io.Discard)).Leveled(slog.LevelDebug) +} + +func newRunConfig(t *testing.T) Config { + t.Helper() + reg := prometheus.NewRegistry() + return Config{ + OrganizationID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), + WorkspaceID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + ModelConfigID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), + Prompt: "Reply with one short sentence.", + Turns: 1, + Metrics: NewMetrics(reg), + } +} + +type fakeChatClient struct { + createChatFunc func(context.Context, codersdk.CreateChatRequest) (codersdk.Chat, error) + streamChatFunc func(context.Context, uuid.UUID, *codersdk.StreamChatOptions) (<-chan codersdk.ChatStreamEvent, io.Closer, error) + createChatMessageFunc func(context.Context, uuid.UUID, codersdk.CreateChatMessageRequest) (codersdk.CreateChatMessageResponse, error) + updateChatFunc func(context.Context, uuid.UUID, codersdk.UpdateChatRequest) error +} + +func newFakeChatClient(t *testing.T) *fakeChatClient { + t.Helper() + return &fakeChatClient{} +} + +func (*fakeChatClient) SetLogger(logger slog.Logger) {} + +func (*fakeChatClient) SetLogBodies(logBodies bool) {} + +func (f *fakeChatClient) CreateChat(ctx context.Context, req codersdk.CreateChatRequest) (codersdk.Chat, error) { + if f.createChatFunc == nil { + return codersdk.Chat{}, xerrors.New("unexpected CreateChat call") + } + return f.createChatFunc(ctx, req) +} + +func (f *fakeChatClient) StreamChat(ctx context.Context, chatID uuid.UUID, opts *codersdk.StreamChatOptions) (<-chan codersdk.ChatStreamEvent, io.Closer, error) { + if f.streamChatFunc == nil { + return nil, nil, xerrors.New("unexpected StreamChat call") + } + return f.streamChatFunc(ctx, chatID, opts) +} + +func (f *fakeChatClient) CreateChatMessage(ctx context.Context, chatID uuid.UUID, req codersdk.CreateChatMessageRequest) (codersdk.CreateChatMessageResponse, error) { + if f.createChatMessageFunc == nil { + return codersdk.CreateChatMessageResponse{}, xerrors.New("unexpected CreateChatMessage call") + } + return f.createChatMessageFunc(ctx, chatID, req) +} + +func (f *fakeChatClient) UpdateChat(ctx context.Context, chatID uuid.UUID, req codersdk.UpdateChatRequest) error { + if f.updateChatFunc == nil { + return xerrors.New("unexpected UpdateChat call") + } + return f.updateChatFunc(ctx, chatID, req) +} + +var _ chatClient = (*fakeChatClient)(nil) + +func newTestRunner(t *testing.T, cfg Config) *Runner { + t.Helper() + return &Runner{client: newFakeChatClient(t), cfg: cfg} +} + +func newTestRunnerWithChatArchive(t *testing.T, chatID uuid.UUID, updateErr error) (*Runner, func() bool) { + t.Helper() + + var archived atomic.Bool + client := newFakeChatClient(t) + client.updateChatFunc = func(ctx context.Context, gotChatID uuid.UUID, req codersdk.UpdateChatRequest) error { + if gotChatID != chatID { + return xerrors.Errorf("unexpected chat archive ID: %s", gotChatID) + } + if req.Archived == nil || !*req.Archived { + return xerrors.Errorf("unexpected archived value: %v", req.Archived) + } + archived.Store(true) + return updateErr + } + runner := &Runner{client: client, cfg: Config{}, chatID: chatID} + return runner, archived.Load +} + +func newTestRunnerWithChatMessage(t *testing.T, cfg Config, chatID uuid.UUID, onMessage func()) *Runner { + t.Helper() + + client := newFakeChatClient(t) + client.createChatMessageFunc = func(ctx context.Context, gotChatID uuid.UUID, req codersdk.CreateChatMessageRequest) (codersdk.CreateChatMessageResponse, error) { + if gotChatID != chatID { + return codersdk.CreateChatMessageResponse{}, xerrors.Errorf("unexpected chat message ID: %s", gotChatID) + } + if err := validatePromptParts(req.Content, cfg.Prompt); err != nil { + return codersdk.CreateChatMessageResponse{}, err + } + if req.ModelConfigID == nil || *req.ModelConfigID != cfg.ModelConfigID { + return codersdk.CreateChatMessageResponse{}, xerrors.Errorf("unexpected chat message model config ID: %v", req.ModelConfigID) + } + + if onMessage != nil { + onMessage() + } + return codersdk.CreateChatMessageResponse{Queued: true}, nil + } + return &Runner{client: client, cfg: cfg} +} + +func validatePromptParts(parts []codersdk.ChatInputPart, prompt string) error { + if len(parts) != 1 || parts[0].Type != codersdk.ChatInputPartTypeText || parts[0].Text != prompt { + return xerrors.Errorf("unexpected chat message content: %#v", parts) + } + return nil +} + +func statusEvent(chatID uuid.UUID, status codersdk.ChatStatus) codersdk.ChatStreamEvent { + return codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeStatus, + ChatID: chatID, + Status: &codersdk.ChatStreamStatus{Status: status}, + } +} + +func messagePartEvent(chatID uuid.UUID) codersdk.ChatStreamEvent { + return codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeMessagePart, + ChatID: chatID, + } +} + +func messageEvent(chatID uuid.UUID, role codersdk.ChatMessageRole) codersdk.ChatStreamEvent { + return codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeMessage, + ChatID: chatID, + Message: &codersdk.ChatMessage{Role: role}, + } +} From c801dcbbc8c4d44068855e05c5952ad0158446cc Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 26 May 2026 10:04:26 +0200 Subject: [PATCH 003/249] fix: strip route prefix when passing request to aibridged handler (#25671) We weren't stripping the API base (`/api/v2/aibridge`), leading to 404s when using the in-memory transport. Signed-off-by: Danny Kopping --- coderd/aibridged.go | 4 ++-- enterprise/coderd/aibridge.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/aibridged.go b/coderd/aibridged.go index 30439752ccd9b..f448be39d07ed 100644 --- a/coderd/aibridged.go +++ b/coderd/aibridged.go @@ -42,9 +42,9 @@ func (api *API) RegisterInMemoryAIBridgedHTTPHandler(srv http.Handler) { panic("aibridged cannot be nil") } - api.aibridgedHandler = srv + api.aibridgedHandler = http.StripPrefix("/api/v2/aibridge", srv) - factory := aibridged.NewTransportFactory(srv) + factory := aibridged.NewTransportFactory(api.aibridgedHandler) var asInterface agplaibridge.TransportFactory = factory api.AIBridgeTransportFactory.Store(&asInterface) } diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index f08fd5b5363ef..9773dba352dad 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -86,7 +86,7 @@ func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) f return } - http.StripPrefix("/api/v2/aibridge", api.AGPL.GetAIBridgedHandler()).ServeHTTP(rw, r) + api.AGPL.GetAIBridgedHandler().ServeHTTP(rw, r) }) }) } From 32ed9f1f39d263b2ba730d8ebbbff1397c1595f2 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 26 May 2026 11:11:47 +0300 Subject: [PATCH 004/249] fix: use old_text/new_text in edit_files tool schema (#25658) Models frequently confuse the search and replace fields in the edit_files tool (CODAGT-312). Rename the model-facing JSON fields to old_text/new_text so the intent is unambiguous. Backend: custom UnmarshalJSON on editFileEdit falls back to deprecated search/replace when old_text/new_text are empty. The workspace agent API is unchanged; toSDKFiles maps old_text/new_text back to search/replace for agent/agentfiles. Frontend: normalizeEdit in parseEditFilesArgs accepts both old_text/new_text and search/replace, normalizing to the internal { search, replace } representation so streaming diff rendering works with either field naming convention. --- coderd/x/chatd/chattool/editfiles.go | 78 ++++++++- coderd/x/chatd/chattool/editfiles_test.go | 148 ++++++++++++++++++ .../ChatElements/tools/utils.test.ts | 51 ++++++ .../components/ChatElements/tools/utils.ts | 39 +++-- 4 files changed, 297 insertions(+), 19 deletions(-) diff --git a/coderd/x/chatd/chattool/editfiles.go b/coderd/x/chatd/chattool/editfiles.go index 51518c1c9c678..1c1c584c406ac 100644 --- a/coderd/x/chatd/chattool/editfiles.go +++ b/coderd/x/chatd/chattool/editfiles.go @@ -2,6 +2,7 @@ package chattool import ( "context" + "encoding/json" "strings" "charm.land/fantasy" @@ -15,19 +16,80 @@ type EditFilesOptions struct { IsPlanTurn bool } +// EditFilesArgs is the tool input schema, auto-generated by the +// fantasy framework from these struct tags. type EditFilesArgs struct { - Files []workspacesdk.FileEdits `json:"files"` + Files []editFileEdits `json:"files"` +} + +type editFileEdits struct { + Path string `json:"path"` + Edits []editFileEdit `json:"edits"` +} + +// editFileEdit uses "old_text"/"new_text" instead of "search"/"replace" +// because models confused the direction (CODAGT-312). Deprecated +// "search"/"replace" accepted via UnmarshalJSON; toSDKFiles maps back +// to "search"/"replace" for agent/agentfiles. +type editFileEdit struct { + OldText string `json:"old_text"` + NewText string `json:"new_text"` + ReplaceAll bool `json:"replace_all,omitempty"` +} + +// UnmarshalJSON falls back to deprecated "search"/"replace" when +// "old_text"/"new_text" are empty. +func (e *editFileEdit) UnmarshalJSON(data []byte) error { + var raw struct { + OldText string `json:"old_text"` + Search string `json:"search"` + NewText string `json:"new_text"` + Replace string `json:"replace"` + ReplaceAll bool `json:"replace_all"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + e.OldText = raw.OldText + if e.OldText == "" { + e.OldText = raw.Search + } + e.NewText = raw.NewText + if e.NewText == "" { + e.NewText = raw.Replace + } + e.ReplaceAll = raw.ReplaceAll + return nil +} + +func (a EditFilesArgs) toSDKFiles() []workspacesdk.FileEdits { + files := make([]workspacesdk.FileEdits, len(a.Files)) + for i, f := range a.Files { + edits := make([]workspacesdk.FileEdit, len(f.Edits)) + for j, e := range f.Edits { + edits[j] = workspacesdk.FileEdit{ + Search: e.OldText, + Replace: e.NewText, + ReplaceAll: e.ReplaceAll, + } + } + files[i] = workspacesdk.FileEdits{ + Path: f.Path, + Edits: edits, + } + } + return files } func EditFiles(options EditFilesOptions) fantasy.AgentTool { return fantasy.NewAgentTool( "edit_files", - "Perform search-and-replace edits on one or more files. Matching"+ - " is fuzzy (tolerates whitespace and indentation differences) and"+ - " preserves the file's existing indentation and line endings."+ - " Errors if search matches zero locations, or more than one unless"+ - " replace_all is set. All edits in a batch are validated before any"+ - " file is written.", + "Perform edits on one or more files by replacing old_text with"+ + " new_text. Matching is fuzzy (tolerates whitespace and indentation"+ + " differences) and preserves the file's existing indentation and"+ + " line endings. Errors if old_text matches zero locations, or more"+ + " than one unless replace_all is set. All edits in a batch are"+ + " validated before any file is written.", func(ctx context.Context, args EditFilesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { var planPath string if options.IsPlanTurn && len(args.Files) > 0 { @@ -101,7 +163,7 @@ func executeEditFilesTool( } resp, err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{ - Files: args.Files, + Files: args.toSDKFiles(), IncludeDiff: true, }) if err != nil { diff --git a/coderd/x/chatd/chattool/editfiles_test.go b/coderd/x/chatd/chattool/editfiles_test.go index d025d1ca4bb52..2cafdfe7968ed 100644 --- a/coderd/x/chatd/chattool/editfiles_test.go +++ b/coderd/x/chatd/chattool/editfiles_test.go @@ -20,6 +20,44 @@ import ( func TestEditFiles(t *testing.T) { t.Parallel() + // Verify the generated tool schema exposes old_text/new_text + // (not the deprecated search/replace) so the rename is + // auditable without running a separate program. + t.Run("SchemaUsesOldTextNewText", func(t *testing.T) { + t.Parallel() + tool := chattool.EditFiles(chattool.EditFilesOptions{}) + info := tool.Info() + + // Dig into: files -> items -> properties -> edits -> items -> properties + filesSchema := info.Parameters["files"] + require.NotNil(t, filesSchema, "missing files parameter") + filesMap, ok := filesSchema.(map[string]any) + require.True(t, ok) + items, ok := filesMap["items"].(map[string]any) + require.True(t, ok) + props, ok := items["properties"].(map[string]any) + require.True(t, ok) + editsSchema, ok := props["edits"].(map[string]any) + require.True(t, ok) + editItems, ok := editsSchema["items"].(map[string]any) + require.True(t, ok) + editProps, ok := editItems["properties"].(map[string]any) + require.True(t, ok) + + assert.Contains(t, editProps, "old_text", "schema should expose old_text") + assert.Contains(t, editProps, "new_text", "schema should expose new_text") + assert.Contains(t, editProps, "replace_all", "schema should expose replace_all") + assert.NotContains(t, editProps, "search", "schema should not expose deprecated search") + assert.NotContains(t, editProps, "replace", "schema should not expose deprecated replace") + + // Verify required fields. + editRequired, ok := editItems["required"].([]string) + require.True(t, ok) + assert.Contains(t, editRequired, "old_text") + assert.Contains(t, editRequired, "new_text") + assert.NotContains(t, editRequired, "replace_all", "replace_all should be optional") + }) + t.Run("PlanTurnRejectsNonPlanPath", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) @@ -470,6 +508,116 @@ func TestEditFiles(t *testing.T) { }) } +func TestEditFiles_OldTextNewTextFieldsPreferred(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + targetPath := "/home/coder/main.go" + + // The agent API should map old_text->Search and new_text->Replace. + mockConn.EXPECT(). + EditFiles(gomock.Any(), workspacesdk.FileEditRequest{ + Files: []workspacesdk.FileEdits{{ + Path: targetPath, + Edits: []workspacesdk.FileEdit{{ + Search: "old content", + Replace: "new content", + }}, + }}, + IncludeDiff: true, + }). + Return(workspacesdk.FileEditResponse{}, nil) + + tool := chattool.EditFiles(chattool.EditFilesOptions{ + GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) { + return mockConn, nil + }, + }) + + resp, err := tool.Run(context.Background(), fantasy.ToolCall{ + ID: "call-1", + Name: "edit_files", + Input: `{"files":[{"path":"` + targetPath + `","edits":[{"old_text":"old content","new_text":"new content"}]}]}`, + }) + require.NoError(t, err) + assert.False(t, resp.IsError) +} + +func TestEditFiles_DeprecatedSearchReplaceFieldsStillWork(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + targetPath := "/home/coder/main.go" + + // Agents with cached schemas may still send "search"/"replace". + // Also exercises replace_all through the new unmarshal+convert path. + mockConn.EXPECT(). + EditFiles(gomock.Any(), workspacesdk.FileEditRequest{ + Files: []workspacesdk.FileEdits{{ + Path: targetPath, + Edits: []workspacesdk.FileEdit{{ + Search: "old", + Replace: "replacement", + ReplaceAll: true, + }}, + }}, + IncludeDiff: true, + }). + Return(workspacesdk.FileEditResponse{}, nil) + + tool := chattool.EditFiles(chattool.EditFilesOptions{ + GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) { + return mockConn, nil + }, + }) + + resp, err := tool.Run(context.Background(), fantasy.ToolCall{ + ID: "call-1", + Name: "edit_files", + Input: `{"files":[{"path":"` + targetPath + `","edits":[{"search":"old","replace":"replacement","replace_all":true}]}]}`, + }) + require.NoError(t, err) + assert.False(t, resp.IsError) +} + +func TestEditFiles_NewFieldNamesTakePrecedenceOverOld(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + targetPath := "/home/coder/main.go" + + // If both old and new field names are present, new names win. + mockConn.EXPECT(). + EditFiles(gomock.Any(), workspacesdk.FileEditRequest{ + Files: []workspacesdk.FileEdits{{ + Path: targetPath, + Edits: []workspacesdk.FileEdit{{ + Search: "from-oldText", + Replace: "from-newText", + }}, + }}, + IncludeDiff: true, + }). + Return(workspacesdk.FileEditResponse{}, nil) + + tool := chattool.EditFiles(chattool.EditFilesOptions{ + GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) { + return mockConn, nil + }, + }) + + resp, err := tool.Run(context.Background(), fantasy.ToolCall{ + ID: "call-1", + Name: "edit_files", + Input: `{"files":[{"path":"` + targetPath + `","edits":[{"old_text":"from-oldText","search":"from-search","new_text":"from-newText","replace":"from-replace"}]}]}`, + }) + require.NoError(t, err) + assert.False(t, resp.IsError) +} + func TestEditFiles_ToolResponseCarriesFileResults(t *testing.T) { t.Parallel() diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts index 2d7dd802281b3..b0dd5384d84f5 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts @@ -763,6 +763,57 @@ describe("parseEditFilesArgs", () => { expect(parsed[0].edits[0].replace).toBe(""); }); + it("accepts old_text/new_text field names", () => { + const args = { + files: [ + { + path: "a.ts", + edits: [{ old_text: "before", new_text: "after" }], + }, + ], + }; + const result = parseEditFilesArgs(args); + expect(result).toHaveLength(1); + expect(result[0].edits).toHaveLength(1); + expect(result[0].edits[0]).toEqual({ search: "before", replace: "after" }); + }); + + it("prefers old_text/new_text over search/replace when both present", () => { + const args = { + files: [ + { + path: "a.ts", + edits: [ + { + old_text: "from-old-text", + new_text: "from-new-text", + search: "from-search", + replace: "from-replace", + }, + ], + }, + ], + }; + const result = parseEditFilesArgs(args); + expect(result[0].edits[0]).toEqual({ + search: "from-old-text", + replace: "from-new-text", + }); + }); + + it("preserves deletion via old_text/new_text (empty new_text)", () => { + const args = { + files: [ + { + path: "a.ts", + edits: [{ old_text: "remove me", new_text: "" }], + }, + ], + }; + const result = parseEditFilesArgs(args); + expect(result[0].edits[0]).toEqual({ search: "remove me", replace: "" }); + }); + // During streaming the model may emit a file entry before any // edit is complete. Every edit has a missing replace, so all are // filtered out. The file entry survives with an empty edits diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts index 8c74d5874ae75..8e5595f15f3fa 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts @@ -12,12 +12,29 @@ export interface EditFilesFileEntry { edits: Array<{ search: string; replace: string }>; } -const searchReplaceSchema = Yup.object({ - search: Yup.string().required(), - replace: Yup.string().defined(), -}).required(); - -type SearchReplace = Yup.InferType; +// Validates that the edit has at least the shape of an object with +// string-typed text fields. Accepts both current field names +// (old_text/new_text) and deprecated names (search/replace). +const normalizeEdit = ( + e: unknown, +): { search: string; replace: string } | null => { + if (typeof e !== "object" || e === null) return null; + const raw = e as Record; + const search = + typeof raw.old_text === "string" + ? raw.old_text + : typeof raw.search === "string" + ? raw.search + : null; + const replace = + typeof raw.new_text === "string" + ? raw.new_text + : typeof raw.replace === "string" + ? raw.replace + : null; + if (!search || replace === null) return null; + return { search, replace }; +}; const fileEntrySchema = Yup.object({ path: Yup.string().required(), @@ -688,15 +705,15 @@ export const parseEditFilesArgs = (args: unknown): EditFilesFileEntry[] => { .filter((f): f is FileEntry => isValid(fileEntrySchema, f)) .map((f) => ({ path: f.path, - edits: f.edits.filter((e): e is SearchReplace => - isValid(searchReplaceSchema, e), - ), + edits: f.edits + .map(normalizeEdit) + .filter((e): e is { search: string; replace: string } => e !== null), })); }; /** - * Builds a synthetic unified diff from search/replace edit pairs - * for a single file. Each edit becomes a separate + * Builds a synthetic unified diff from edit pairs (normalized to + * search/replace) for a single file. Each edit becomes a separate * `Diff.createPatch` call; the patches are concatenated and * parsed into a single FileDiffMetadata. */ From d8dc782da5663c0bea7f355088dbf48faae4b507 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 26 May 2026 11:20:52 +0200 Subject: [PATCH 005/249] docs: require substantive, concise comments describing behaviour (#25674) *Disclaimer: implemented by a Coder Agent using Claude Opus 4.6* Adds a clause under **Writing Comments and Avoiding Unnecessary Changes** in `AGENTS.md` specifying that comments must be: - **Substantive** and **concise** - Describe the **behaviour** of the code - NOT describe the reasoning the agent used to produce the change Includes concrete anti-pattern examples (`// Added per PR feedback`, `// Refactored for clarity`) to make the expectation clear. --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4517ffe21c2b6..4dcbc114d4663 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,6 +164,11 @@ See [Modern Go](.claude/docs/GO.md) for comment formatting and the rule to avoid unrelated edits. Preserve existing comments that explain non-obvious behavior unless the task directly requires changing them. +Comments MUST be **substantive** and **concise**. Describe the **behaviour** +of the code, not the reasoning the agent used to produce the change. Do not +leave comments like `// Added per PR feedback` or `// Refactored for +clarity`. Instead, explain what the code does and why the behaviour matters. + ### No Emdash or Endash Do not use emdash (U+2014), endash (U+2013), or ` -- ` as punctuation From 51836e681e79e648b3f3a8e6a92753cbc7306317 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 26 May 2026 14:52:21 +0200 Subject: [PATCH 006/249] refactor: build dogfood image as base + mise oci layers (#25448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the dogfood image into two artifacts: - `ghcr.io/coder/oss-dogfood-base:-`: Ubuntu base with apt packages, chrome, rustup, brew, gh, and the mise binary. The base-sha is a cache key over `Dockerfile.base` and `files/`, so commits that don't touch those inputs reuse the previous build. - `codercom/oss-dogfood:-` and rolling tags (`:22.04`, `:26.04`, `:latest`, `:`): produced by `mise oci build` on top of the base, with one content-addressed OCI layer per mise tool. The rolling tag scheme is unchanged, so the workspace template doesn't need updating. Single-tool version bumps now invalidate only that tool's OCI layer, so workspaces re-pull just what changed instead of the entire 5-6 GB image on every recreate. Also: - Drops the build-time `pnpm dlx playwright@1.47.0 install --with-deps chromium` step (~400 MB) and the equivalent `playwright-driver.browsers` install from `flake.nix`. `@playwright/mcp` (used by the claude-code and codex MCP servers in `dogfood/coder/main.tf`) does NOT auto-install browsers, so the existing `install-deps` `coder_script` now runs two installs on workspace start: `pnpm exec playwright install chromium` for the site's pinned `@playwright/test`, and `npx --package=@playwright/mcp@latest playwright-core install --no-shell chromium` so the MCP servers find their matching browser revision. Browser revisions coexist under `~/.cache/ms-playwright/chromium-/`, which lives on the home volume so both downloads happen once per workspace recreate and persist across restarts. Net effect: same MCP behavior as before, +~1-2 min on first workspace start. Nix devshell users running site e2e tests locally now need `pnpm exec playwright install` once (instead of getting browsers via nixpkgs). - Bumps the pinned mise binary to v2026.5.12 (matching main after #25521) and adds top-level `min_version = "2026.5.12"` to `mise.toml` so every consumer (devs, CI, the embedded mise inside the dogfood image, mise oci builds) fails fast on an older mise. - Adds bison, flex, libicu-dev, libreadline-dev, uuid-dev, and zlib1g-dev to both Ubuntu base images for source-build use cases (e.g., building Postgres from source). - Replaces skopeo with crane as the registry client `mise oci push` shells out to: crane is added to `mise.toml`, the workflow drops its `apt-get install skopeo` and forces `--tool crane`, and the local wrapper image stops bundling skopeo. One source of truth for tool versions, no apt drift, smaller wrapper image, and workspace users get a registry client on PATH for free via mise oci's tool layers. - Removes `nix.hash`/`mise.hash` and their Makefile rules. The registry digest already captures every effective change since CI rebuilds when any baked-in input moves; the per-file `filesha1()` entries in `pull_triggers` are redundant. Supersedes #25400 (the `mise.hash` pull trigger landed there in `2b612abe7b`; this PR removes it as part of the broader simplification). > [!NOTE] > `mise oci build` is experimental and requires `MISE_EXPERIMENTAL=1` (set at job level in the workflow). The local-only `scripts/dogfood/mise-oci-wrapper.sh` builds a tiny `coderdev/mise-oci-wrapper:` Debian image with curl-installed mise on first invocation (cached by version tag thereafter); we don't reuse `jdxcode/mise:latest` because that tag lags upstream GitHub releases by days and would defeat the `min_version` enforcement above. > [!NOTE] > `compute-base-sha.sh` and `compute-final-sha.sh` are cache keys, not strict content addresses: the base Dockerfile still pulls dynamic resources at build time (gh/buildx `releases/latest`, chrome `stable_current_amd64.deb`, apt mirror state). Two runs with identical checked-in files can produce slightly different bytes, which is acceptable here because the cache-hit savings on irrelevant commits outweigh that drift. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Thomas Kosiewski Co-authored-by: Claude Opus 4.7 (1M context) --- .dockerignore | 14 +- .github/workflows/dogfood.yaml | 285 ++++++++++++------ .gitignore | 9 + Makefile | 6 - dogfood/coder/Makefile | 97 +++--- dogfood/coder/main.tf | 42 ++- dogfood/coder/mise.hash | 2 - dogfood/coder/nix.hash | 2 - .../{Dockerfile => Dockerfile.base} | 67 ++-- .../{Dockerfile => Dockerfile.base} | 67 ++-- flake.nix | 14 - mise.lock | 50 +++ mise.toml | 21 ++ scripts/dogfood/compute-base-sha.sh | 43 +++ scripts/dogfood/compute-final-sha.sh | 20 ++ scripts/dogfood/mise-oci-wrapper.sh | 109 +++++++ scripts/dogfood_test_image.sh | 14 +- scripts/update-flake.sh | 2 - 18 files changed, 606 insertions(+), 258 deletions(-) delete mode 100644 dogfood/coder/mise.hash delete mode 100644 dogfood/coder/nix.hash rename dogfood/coder/ubuntu-22.04/{Dockerfile => Dockerfile.base} (79%) rename dogfood/coder/ubuntu-26.04/{Dockerfile => Dockerfile.base} (80%) create mode 100755 scripts/dogfood/compute-base-sha.sh create mode 100755 scripts/dogfood/compute-final-sha.sh create mode 100755 scripts/dogfood/mise-oci-wrapper.sh diff --git a/.dockerignore b/.dockerignore index 9b4d2a599782b..9a9bc82b8716e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,9 @@ # This file controls what docker/BuildKit may send to the daemon when # the build context is the repository root. Today only the dogfood -# images at dogfood/coder/ubuntu-{22,26}.04/Dockerfile use the repo -# root as context; other docker builds in this repo (scripts/Dockerfile, -# scripts/Dockerfile.base, scripts/ironbank/Dockerfile) cd into a -# temporary directory and have their own contexts. +# base images at dogfood/coder/ubuntu-{22,26}.04/Dockerfile.base use the +# repo root as context; other docker builds in this repo +# (scripts/Dockerfile, scripts/Dockerfile.base, scripts/ironbank/Dockerfile) +# cd into a temporary directory and have their own contexts. # # We use an allowlist so the context stays small and predictable, and # new top-level files added to the repo do not silently inflate every @@ -14,15 +14,15 @@ # file under a directory requires re-including the directory itself. ** -# Re-allow paths the dogfood Dockerfiles consume. -!mise.toml -!mise.lock +# Re-allow paths the dogfood Dockerfile.base files consume. !dogfood !dogfood/coder !dogfood/coder/ubuntu-22.04 +!dogfood/coder/ubuntu-22.04/Dockerfile.base !dogfood/coder/ubuntu-22.04/configure-chrome-flags.sh !dogfood/coder/ubuntu-22.04/files !dogfood/coder/ubuntu-22.04/files/** !dogfood/coder/ubuntu-26.04 +!dogfood/coder/ubuntu-26.04/Dockerfile.base !dogfood/coder/ubuntu-26.04/files !dogfood/coder/ubuntu-26.04/files/** diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml index 2bd0e6d29f42f..9dcd853f79294 100644 --- a/.github/workflows/dogfood.yaml +++ b/.github/workflows/dogfood.yaml @@ -8,21 +8,24 @@ on: # # Effects vary by event: # - # PRs: `build_image` builds the image variants but never pushes - # (each `depot/build-push-action` step's `push:` and the - # `Push Nix image` step are gated on `github.ref == - # 'refs/heads/main'`). `test_image` rebuilds the Ubuntu images - # from Depot cache with `load: true` and runs `make gen`, `fmt`, - # `lint`, and a Linux build inside each image to validate that - # the baked-in tooling works. `deploy_template` runs - # `terraform init` + `validate` only; the apply step and - # SHA/title gathering are gated on main. + # PRs: `build_image` builds the base and runs `mise oci build`, + # loads the result into the local Docker daemon, and runs + # `make gen`, `fmt`, `lint`, and a Linux build inside the image + # to validate the baked-in tooling. Only the base image is pushed + # (to ghcr.io so the mise oci step can pull --from a real + # registry); the Docker Hub push is gated on + # `github.ref == 'refs/heads/main'`. Fork PRs skip the entire + # base+mise-oci pipeline since GITHUB_TOKEN is read-only for + # packages; the nix matrix entry still runs. + # `deploy_template` runs `terraform init` + `validate` only; the + # apply step and SHA/title gathering are gated on main. # # Pushes to main: `build_image` retags rolling tags on # `codercom/oss-dogfood` (`:latest`, `:22.04`, `:26.04`), # `codercom/oss-dogfood-vscode-coder` (`:latest`), and # `codercom/oss-dogfood-nix` (`:latest`), plus a per-branch tag on - # each. `test_image` validates tooling as above. + # each. The image-tooling validation runs as above before any + # push, so a broken image never reaches Docker Hub. # `deploy_template` runs `terraform apply` and creates new # `coderd_template` versions on dev.coder.com whose `name` is the # commit short SHA. Content is unchanged when neither `dogfood/**` @@ -37,6 +40,8 @@ on: - "flake.nix" - "mise.toml" - "mise.lock" + - "scripts/dogfood/**" + - "scripts/dogfood_test_image.sh" pull_request: paths: - "dogfood/**" @@ -45,6 +50,8 @@ on: - "flake.nix" - "mise.toml" - "mise.lock" + - "scripts/dogfood/**" + - "scripts/dogfood_test_image.sh" workflow_dispatch: permissions: @@ -58,7 +65,16 @@ jobs: image-version: ["22.04", "26.04", "nix"] if: github.actor != 'dependabot[bot]' # Skip Dependabot PRs - runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }} + runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} + permissions: + contents: read + packages: write # push the dogfood base image to ghcr.io/coder/oss-dogfood-base + env: + # MISE_EXPERIMENTAL opts into the experimental `oci` subcommand. + # Trust is set via a config file (see the Install mise step + # below) rather than MISE_TRUSTED_CONFIG_PATHS so the workspace + # template can keep parity with the same file-based approach. + MISE_EXPERIMENTAL: "1" steps: - name: Harden Runner uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 @@ -119,6 +135,58 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 if: matrix.image-version != 'nix' + - name: Install mise + if: matrix.image-version != 'nix' + # MISE_VERSION + MISE_SHA256 match dogfood/coder/ubuntu-*/Dockerfile.base + # so the mise binary baking the image is the same one a workspace + # ships with. `min_version` in mise.toml catches downgrades. + # Write trust config to ~/.config/mise/conf.d/ instead of using + # MISE_TRUSTED_CONFIG_PATHS so the same file-based approach + # works in workspaces (where the user owns the file). + env: + MISE_VERSION: v2026.5.12 + MISE_SHA256: a238972a3162d710b85b28c324372e96ca4e4b486c81fe78695000d9fbc77c48 + WORKSPACE: ${{ github.workspace }} + run: | + set -euo pipefail + curl --silent --show-error --location --fail \ + "https://github.com/jdx/mise/releases/download/${MISE_VERSION}/mise-${MISE_VERSION}-linux-x64" \ + --output /tmp/mise + echo "${MISE_SHA256} /tmp/mise" | sha256sum -c + sudo install -m 0755 /tmp/mise /usr/local/bin/mise + rm /tmp/mise + mise --version + mkdir -p "$HOME/.config/mise/conf.d" + cat > "$HOME/.config/mise/conf.d/00-ci-trust.toml" <> "$GITHUB_OUTPUT" + echo "final_sha=${final_sha}" >> "$GITHUB_OUTPUT" + + - name: Login to GHCR + # Fork PRs get a read-only GITHUB_TOKEN that cannot push to + # ghcr.io. Skip the entire GHCR-dependent pipeline (base push + + # mise oci build) for fork PRs; the nix matrix entry still runs. + if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to DockerHub if: github.ref == 'refs/heads/main' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 @@ -126,48 +194,122 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - - name: Build and push Ubuntu 22.04 image + - name: Build base image uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0 + if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork with: project: b4q6ltmpzh token: ${{ secrets.DEPOT_TOKEN }} buildx-fallback: true - # Context is the repo root so the Dockerfile can COPY the - # project mise.toml that the image installs from. The - # github_token secret raises aqua's GitHub API quota during - # `mise install`. + # Context is the repo root so Dockerfile.base can COPY the + # distro-specific files/ tree and configure-chrome-flags.sh. context: "{{defaultContext}}" - file: dogfood/coder/ubuntu-22.04/Dockerfile - secrets: | - github_token=${{ secrets.GITHUB_TOKEN }} + file: dogfood/coder/ubuntu-${{ matrix.image-version }}/Dockerfile.base pull: true - save: true - push: ${{ github.ref == 'refs/heads/main' }} - # TODO: move the `latest` tag to 26.04 soon. we don't want to transition - # it immediately because that would make workspaces switch to it - # automatically without any grace period. - tags: "codercom/oss-dogfood:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood:22.04,codercom/oss-dogfood:latest" - if: matrix.image-version == '22.04' + # Push to ghcr.io on every non-fork CI run so the downstream + # mise oci build can --from a real registry. The base-sha tag + # is a cache key (see scripts/dogfood/compute-base-sha.sh) so + # commits that don't change base inputs reuse the previous + # build. + push: true + tags: | + ghcr.io/coder/oss-dogfood-base:${{ matrix.image-version }}-${{ steps.shas.outputs.base_sha }} + ghcr.io/coder/oss-dogfood-base:${{ matrix.image-version }}-${{ steps.docker-tag-name.outputs.tag }} + + - name: Install mise tools + if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork + # `mise oci build` packages already-installed tools into OCI + # layers; it does not install them. Run `mise install` first so + # the tools land in MISE_DATA_DIR on the runner. + # github_token raises aqua's API quota during tool installs. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # --locked refuses to resolve URLs at install time and forces + # the runner to consume what mise.lock already committed, + # so a forgotten lockfile entry fails CI instead of silently + # being added on next run. + mise install --yes --locked + # Put mise's shims dir on PATH for subsequent steps so + # `mise oci push --tool crane` can find crane (and any other + # mise-managed binary it shells out to). + echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" + + - name: Build mise oci layer + if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork + env: + IMAGE_VERSION: ${{ matrix.image-version }} + BASE_SHA: ${{ steps.shas.outputs.base_sha }} + FINAL_SHA: ${{ steps.shas.outputs.final_sha }} + # --output makes the OCI layout location explicit so the later + # `mise oci push --image-dir` steps point at the right path even + # if mise oci's default ever changes (it's experimental). + run: | + mise oci build \ + --from "ghcr.io/coder/oss-dogfood-base:${IMAGE_VERSION}-${BASE_SHA}" \ + --tag "codercom/oss-dogfood:${FINAL_SHA}-${IMAGE_VERSION}" \ + --output ./mise-oci + + # Load the OCI layout into the local Docker daemon so the next + # step can `docker run` it. crane lacks a direct OCI-layout-to- + # daemon command, but its built-in registry server gives us a + # simple two-hop path with no extra dependencies. + - name: Load mise oci image into Docker daemon + if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork + env: + IMAGE_VERSION: ${{ matrix.image-version }} + run: | + set -euo pipefail + crane registry serve --address localhost:5000 & + reg_pid=$! + trap 'kill $reg_pid 2>/dev/null || true' EXIT + for _ in 1 2 3 4 5; do + curl -sf http://localhost:5000/v2/ >/dev/null && break + sleep 1 + done + crane push ./mise-oci "localhost:5000/dogfood-test:${IMAGE_VERSION}" + docker pull "localhost:5000/dogfood-test:${IMAGE_VERSION}" + docker tag "localhost:5000/dogfood-test:${IMAGE_VERSION}" "dogfood-test:${IMAGE_VERSION}" + + # Validate the dogfood image's tooling by running make gen, fmt, + # lint, and a fat build inside it. Failures here block the + # Docker Hub push below so broken images never reach workspaces. + - name: Test image tooling + if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./scripts/dogfood_test_image.sh "dogfood-test:${{ matrix.image-version }}" - - name: Build and push Ubuntu 26.04 image - uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0 - with: - project: b4q6ltmpzh - token: ${{ secrets.DEPOT_TOKEN }} - buildx-fallback: true - # Context is the repo root so the Dockerfile can COPY the - # project mise.toml that the image installs from. The - # github_token secret raises aqua's GitHub API quota during - # `mise install`. - context: "{{defaultContext}}" - file: dogfood/coder/ubuntu-26.04/Dockerfile - secrets: | - github_token=${{ secrets.GITHUB_TOKEN }} - pull: true - save: true - push: ${{ github.ref == 'refs/heads/main' }} - tags: "codercom/oss-dogfood:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood:26.04" - if: matrix.image-version == '26.04' + - name: Push final Ubuntu 22.04 image + if: matrix.image-version == '22.04' && github.ref == 'refs/heads/main' + env: + FINAL_SHA: ${{ steps.shas.outputs.final_sha }} + DOCKER_TAG: ${{ steps.docker-tag-name.outputs.tag }} + # --image-dir points at the OCI layout written by the previous + # `mise oci build` step. Without it, `mise oci push` rebuilds + # from mise.toml and forgets the --from base. --tool crane + # forces the registry client mise oci shells out to, so we + # don't drift between the apt-shipped skopeo on whatever runner + # image we land on. + # TODO: move the `latest` tag to 26.04 soon. we don't want to + # transition it immediately because that would make workspaces + # switch to it automatically without any grace period. + run: | + set -euo pipefail + for tag in "${FINAL_SHA}-22.04" "$DOCKER_TAG" 22.04 latest; do + mise oci push --tool crane --image-dir ./mise-oci "codercom/oss-dogfood:$tag" + done + + - name: Push final Ubuntu 26.04 image + if: matrix.image-version == '26.04' && github.ref == 'refs/heads/main' + env: + FINAL_SHA: ${{ steps.shas.outputs.final_sha }} + DOCKER_TAG: ${{ steps.docker-tag-name.outputs.tag }} + run: | + set -euo pipefail + for tag in "${FINAL_SHA}-26.04" "$DOCKER_TAG" 26.04; do + mise oci push --tool crane --image-dir ./mise-oci "codercom/oss-dogfood:$tag" + done - name: Build and push vscode-coder image uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0 @@ -201,59 +343,8 @@ jobs: env: DOCKER_TAG: ${{ steps.docker-tag-name.outputs.tag }} - # Validate that the Ubuntu dogfood images contain working tooling. - # Failures here block template deployment (deploy_template). - test_image: - needs: build_image - strategy: - fail-fast: false - matrix: - image-version: ["22.04", "26.04"] - runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1 - persist-credentials: false - - - name: Set up Depot CLI - uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - # Near-instant cache hit from build_image; loads into local daemon - # without pushing to a registry. - - name: Load dogfood image from Depot cache - uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0 - with: - project: b4q6ltmpzh - token: ${{ secrets.DEPOT_TOKEN }} - buildx-fallback: true - context: "{{defaultContext}}" - file: dogfood/coder/ubuntu-${{ matrix.image-version }}/Dockerfile - secrets: | - github_token=${{ secrets.GITHUB_TOKEN }} - pull: true - load: true - push: false - tags: "dogfood-test:${{ matrix.image-version }}" - - - name: Test image tooling - run: ./scripts/dogfood_test_image.sh "dogfood-test:${{ matrix.image-version }}" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - deploy_template: - needs: - - build_image - - test_image + needs: build_image runs-on: ubuntu-latest permissions: # Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage) diff --git a/.gitignore b/.gitignore index 65dd97caf70e5..28e8f26c27596 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,15 @@ __debug_bin* # Local agent configuration AGENTS.local.md +# mise local overrides +mise.local.toml +.mise.local.toml +mise.*.local.toml +.mise.*.local.toml + +# `mise oci build` writes its OCI image layout here by default. +mise-oci/ + /.env # Ignore plans written by AI agents. diff --git a/Makefile b/Makefile index 5b8af71bb22bb..3d92ff40dfea3 100644 --- a/Makefile +++ b/Makefile @@ -1636,12 +1636,6 @@ else endif .PHONY: test-e2e -dogfood/coder/nix.hash: flake.nix flake.lock - sha256sum flake.nix flake.lock >./dogfood/coder/nix.hash - -dogfood/coder/mise.hash: mise.toml mise.lock - sha256sum mise.toml mise.lock >./dogfood/coder/mise.hash - # Count the number of test databases created per test package. count-test-databases: PGPASSWORD=postgres psql -h localhost -U postgres -d coder_testing -P pager=off -c 'SELECT test_package, count(*) as count from test_databases GROUP BY test_package ORDER BY count DESC' diff --git a/dogfood/coder/Makefile b/dogfood/coder/Makefile index 48693019fc3c2..ab7000a795d74 100644 --- a/dogfood/coder/Makefile +++ b/dogfood/coder/Makefile @@ -3,62 +3,79 @@ # tag names. build_tag ?= $(shell git rev-parse --abbrev-ref HEAD | sed "s/\\//-/") -# The Dockerfiles consume the repo root as build context so they can -# reach the project mise.toml. Each variant still tracks its own -# files/ tree under dogfood/coder/ubuntu-/. +# The base Dockerfile consumes the repo root as build context so it can +# reach the distro-specific files/ tree and configure-chrome-flags.sh +# under dogfood/coder/ubuntu-/. REPO_ROOT := $(shell git rev-parse --show-toplevel) -# Mise's aqua backend exhausts GitHub's unauthenticated API quota -# quickly. Plumb a token through to the mise install layer when one -# is available. Two equivalent ways to supply it: -# GITHUB_TOKEN=ghp_... - taken straight from the environment -# (matches GitHub Actions, where -# secrets.GITHUB_TOKEN is auto-provided) -# GITHUB_TOKEN_FILE=/path - read the token from a file -# If neither is set the build still runs but may hit 403s. -ifneq ($(GITHUB_TOKEN_FILE),) -docker_secret_arg := --secret id=github_token,src="$(GITHUB_TOKEN_FILE)" -else ifneq ($(GITHUB_TOKEN),) -docker_secret_arg := --secret id=github_token,env=GITHUB_TOKEN +# Pick a container runtime. On macOS we prefer Apple's `container` CLI +# when present (it produces a Linux VM-backed amd64 image without +# Docker Desktop); otherwise fall back to docker. Linux always uses +# docker. +OS := $(shell uname -s) +ifeq ($(OS),Darwin) + CONTAINER_RUNTIME ?= $(shell command -v container >/dev/null 2>&1 && echo container || echo docker) +else + CONTAINER_RUNTIME ?= docker endif +# Apple's `container` defaults to the host arch; the dogfood image is +# amd64-only, so pin it. +ifeq ($(CONTAINER_RUNTIME),container) + PLATFORM_ARG := --platform linux/amd64 +else + PLATFORM_ARG := +endif + +ifeq ($(OS),Linux) + # `mise oci build` packages already-installed tools; the install + # has to run first. The macOS wrapper does this inside the + # container; on Linux we chain it here. + MISE_OCI := mise install --yes && MISE_EXPERIMENTAL=1 mise oci +else + MISE_OCI := CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) $(REPO_ROOT)/scripts/dogfood/mise-oci-wrapper.sh +endif + +.PHONY: build build-ubuntu-22.04 build-ubuntu-26.04 \ + build-base-ubuntu-22.04 build-base-ubuntu-26.04 \ + update-keys update-keys-ubuntu-22.04 update-keys-ubuntu-26.04 + build: build-ubuntu-22.04 build-ubuntu-26.04 -.PHONY: build -build-ubuntu-22.04: - DOCKER_BUILDKIT=1 docker build \ - -f dogfood/coder/ubuntu-22.04/Dockerfile \ - -t "codercom/oss-dogfood:22.04-$(build_tag)" \ - $(docker_secret_arg) \ +# Caveat: `build-ubuntu-*` requires the base image to be pullable from a +# registry that `mise oci`'s HTTPS client can reach (ghcr.io, a local +# `registry:2` sidecar, etc.). `--from coderdev/oss-dogfood-base:*-local` +# only resolves when a registry mirror is set up alongside; without it, +# `mise oci build` fails because the wrapper container cannot see the +# host's local image store. The `build-base-ubuntu-*` targets on their +# own work end to end without any registry. See +# scripts/dogfood/mise-oci-wrapper.sh for the full story. +build-base-ubuntu-22.04: + $(CONTAINER_RUNTIME) build $(PLATFORM_ARG) \ + -f "$(REPO_ROOT)/dogfood/coder/ubuntu-22.04/Dockerfile.base" \ + -t "coderdev/oss-dogfood-base:22.04-local" \ "$(REPO_ROOT)" -.PHONY: build-ubuntu-22.04 -build-ubuntu-26.04: - DOCKER_BUILDKIT=1 docker build \ - -f dogfood/coder/ubuntu-26.04/Dockerfile \ - -t "codercom/oss-dogfood:26.04-$(build_tag)" \ - $(docker_secret_arg) \ +build-base-ubuntu-26.04: + $(CONTAINER_RUNTIME) build $(PLATFORM_ARG) \ + -f "$(REPO_ROOT)/dogfood/coder/ubuntu-26.04/Dockerfile.base" \ + -t "coderdev/oss-dogfood-base:26.04-local" \ "$(REPO_ROOT)" -.PHONY: build-ubuntu-26.04 - -push: push-ubuntu-22.04 push-ubuntu-26.04 -.PHONY: push -push-ubuntu-22.04: build-ubuntu-22.04 - docker push ${build_tag} -.PHONY: push-ubuntu-22.04 +build-ubuntu-22.04: build-base-ubuntu-22.04 + $(MISE_OCI) build \ + --from "coderdev/oss-dogfood-base:22.04-local" \ + --tag "codercom/oss-dogfood:22.04-$(build_tag)" -push-ubuntu-26.04: build-ubuntu-26.04 - docker push ${build_tag} -.PHONY: push-ubuntu-26.04 +build-ubuntu-26.04: build-base-ubuntu-26.04 + $(MISE_OCI) build \ + --from "coderdev/oss-dogfood-base:26.04-local" \ + --tag "codercom/oss-dogfood:26.04-$(build_tag)" update-keys: update-keys-ubuntu-22.04 update-keys-ubuntu-26.04 -.PHONY: update-keys update-keys-ubuntu-22.04: ./ubuntu-22.04/update-keys.sh -.PHONY: update-keys-ubuntu-22.04 update-keys-ubuntu-26.04: ./ubuntu-26.04/update-keys.sh -.PHONY: update-keys-ubuntu-26.04 diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf index e013a8560c85d..81475418b9a18 100644 --- a/dogfood/coder/main.tf +++ b/dogfood/coder/main.tf @@ -687,10 +687,41 @@ resource "coder_script" "install-deps" { coder exp sync want install-deps git-clone coder exp sync start install-deps + # Seed a user-owned mise trust config on the persistent home + # volume. The image ships /etc/mise/conf.d/00-coder-trust.toml as + # a fallback, but mise's `trusted_config_paths` setting doesn't + # merge across config layers, so anything the user adds to their + # own file would otherwise replace the system fallback. Writing + # this file once (if-absent) seeds the defaults under + # ~/.config/mise/conf.d/ where the user can edit it and the edits + # survive workspace restart. + TRUST_FILE="$HOME/.config/mise/conf.d/00-coder-trust.toml" + if [ ! -f "$TRUST_FILE" ]; then + mkdir -p "$(dirname "$TRUST_FILE")" + cat > "$TRUST_FILE" <<'TRUST' + # mise trust paths for the dogfood workspace. Edit to add your own + # paths; this file lives on the persistent home volume so changes + # survive workspace restart. The install-deps coder_script only + # writes this file when it's absent. + [settings] + trusted_config_paths = [ + "/home/coder/coder", + "/etc/mise", + ] + TRUST + fi + # Install playwright dependencies # We want to use the playwright version from site/package.json cd "${local.repo_dir}" && make clean cd "${local.repo_dir}/site" && pnpm install + + # Two playwright installs: site/'s @playwright/test and + # @playwright/mcp@0.0.75 bundle different playwright-core versions + # with different chromium revisions, and both are used at runtime + # (site tests + the claude-code/codex MCP servers below). + cd "${local.repo_dir}/site" && pnpm exec playwright install chromium + npx --yes --package=@playwright/mcp@0.0.75 playwright-core install --no-shell chromium EOT } @@ -823,13 +854,10 @@ data "docker_registry_image" "dogfood" { resource "docker_image" "dogfood" { name = "${local.image_tags[data.coder_parameter.image_type.value]}@${data.docker_registry_image.dogfood.sha256_digest}" + # CI rebuilds and pushes when any baked-in input changes, so the + # digest captures every effective change on its own. pull_triggers = [ data.docker_registry_image.dogfood.sha256_digest, - sha1(join("", [for f in fileset(path.module, "files/*") : filesha1(f)])), - filesha1("ubuntu-22.04/Dockerfile"), - filesha1("ubuntu-26.04/Dockerfile"), - filesha1("nix.hash"), - filesha1("mise.hash"), ] keep_locally = true } @@ -969,7 +997,7 @@ module "claude-code" { "mcpServers": { "playwright": { "command": "npx", - "args": ["--", "@playwright/mcp@latest", "--headless", "--isolated", "--no-sandbox"] + "args": ["--", "@playwright/mcp@0.0.75", "--headless", "--isolated", "--no-sandbox"] } } } @@ -1000,7 +1028,7 @@ module "codex" { mcp = <<-EOT [mcp_servers.playwright] command = "npx" - args = ["--", "@playwright/mcp@latest", "--headless", "--isolated", "--no-sandbox"] + args = ["--", "@playwright/mcp@0.0.75", "--headless", "--isolated", "--no-sandbox"] type = "stdio" EOT } diff --git a/dogfood/coder/mise.hash b/dogfood/coder/mise.hash deleted file mode 100644 index f8ccac7148cc8..0000000000000 --- a/dogfood/coder/mise.hash +++ /dev/null @@ -1,2 +0,0 @@ -b5226f4cb3256b5f67df1344f46968f7275b1b8309380506d25782168bab5622 mise.toml -b5cf72024409932659abde978440fca1d01a75bb11f1476e2410f7d4b83aa9c0 mise.lock diff --git a/dogfood/coder/nix.hash b/dogfood/coder/nix.hash deleted file mode 100644 index a25b9709f4d78..0000000000000 --- a/dogfood/coder/nix.hash +++ /dev/null @@ -1,2 +0,0 @@ -f09cd2cbbcdf00f5e855c6ddecab6008d11d871dc4ca5e1bc90aa14d4e3a2cfd flake.nix -0d2489a26d149dade9c57ba33acfdb309b38100ac253ed0c67a2eca04a187e37 flake.lock diff --git a/dogfood/coder/ubuntu-22.04/Dockerfile b/dogfood/coder/ubuntu-22.04/Dockerfile.base similarity index 79% rename from dogfood/coder/ubuntu-22.04/Dockerfile rename to dogfood/coder/ubuntu-22.04/Dockerfile.base index e40f5f868a0b6..24d5e3afb5afd 100644 --- a/dogfood/coder/ubuntu-22.04/Dockerfile +++ b/dogfood/coder/ubuntu-22.04/Dockerfile.base @@ -38,6 +38,7 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u bat \ bats \ bind9-dnsutils \ + bison \ build-essential \ ca-certificates \ containerd.io \ @@ -50,6 +51,7 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u fd-find \ file \ fish \ + flex \ gettext-base \ git \ gnupg \ @@ -66,6 +68,8 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u language-pack-en \ less \ libgbm-dev \ + libicu-dev \ + libreadline-dev \ libssl-dev \ lsb-release \ lsof \ @@ -93,10 +97,12 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u tmux \ traceroute \ unzip \ + uuid-dev \ vim \ wget \ xauth \ zip \ + zlib1g-dev \ zsh \ zstd && \ # Delete package cache to avoid consuming space in layer @@ -183,45 +189,28 @@ RUN install --directory --owner=coder --group=coder --mode=0755 "${MISE_INSTALL_ test -x /usr/local/bin/mise && \ sudo --login --user=coder /bin/bash -lc 'set -euo pipefail && mise_bin="$(readlink --canonicalize /usr/local/bin/mise)" && test -w "$(dirname "$mise_bin")" && /usr/local/bin/mise --version && /usr/local/bin/mise self-update --help >/dev/null && /usr/local/bin/mise upgrade --help >/dev/null' -# Trusted paths skip mise's per-config trust prompt for the baked-in -# system config and the coder repo when cloned at the canonical -# /home/coder/coder location. Other repos a user clones still get -# the one-time `mise trust` prompt; pre-trusting all of /home/coder -# would let any mise.toml under the home dir auto-run [hooks]/[tasks]. -ENV MISE_DATA_DIR=/home/coder/.local/share/mise \ - MISE_TRUSTED_CONFIG_PATHS=/home/coder/coder:/etc/mise +ENV MISE_DATA_DIR=/home/coder/.local/share/mise -# Bake the project manifest in as mise's system config and ship -# the lockfile alongside it so mise verifies download checksums -# during install. We do NOT override MISE_GLOBAL_CONFIG_FILE; that -# would re-target `mise use --global` away from the user's -# ~/.config/mise/config.toml (on the home volume) into this -# image-only path, breaking the workflow. -# -# We pre-create /etc/mise as 0755 because COPY's implicitly-created -# parent dirs inherit the --chmod, which would leave /etc/mise -# without the `x` bit and unreachable to the coder user. We also -# chown to coder so mise can write the temp lockfile it uses for -# atomic rename when updating /etc/mise/mise.lock during installs. -RUN install --directory --owner=coder --group=coder --mode=0755 /etc/mise -COPY --chown=coder:coder --chmod=0644 mise.toml /etc/mise/config.toml -COPY --chown=coder:coder --chmod=0644 mise.lock /etc/mise/mise.lock +# Bake a system fallback for trusted_config_paths so the canonical +# /home/coder/coder repo and the mise-oci-synthesized /etc/mise/config.toml +# are trusted without a per-config prompt. The workspace template +# (dogfood/coder/main.tf install-deps coder_script) seeds a matching +# user-owned ~/.config/mise/conf.d/00-coder-trust.toml on workspace +# start, which the user can edit to add their own paths; that file +# lives on the persistent home volume and overrides this fallback. +RUN install --directory --mode=0755 /etc/mise /etc/mise/conf.d +COPY --chmod=0644 <<'EOF' /etc/mise/conf.d/00-coder-trust.toml +[settings] +trusted_config_paths = [ + "/home/coder/coder", + "/etc/mise", +] +EOF -# Pre-install tools into /opt/mise/data so they survive the home -# volume's copy-on-first-mount. MISE_SHARED_INSTALL_DIRS (set below) -# exposes them at runtime; MISE_DATA_DIR stays on the home volume. -# github_token authenticates aqua's API calls (optional secret). +# Reserve the mount_point declared in mise.toml [oci]. The path is +# duplicated below in MISE_SHARED_INSTALL_DIRS and PATH; if it ever +# changes, update all three plus mise.toml. RUN install --directory --owner=coder --group=coder --mode=0755 /opt/mise /opt/mise/data -RUN --mount=type=secret,id=github_token,required=false \ - gh_token="$(cat /run/secrets/github_token 2>/dev/null || true)" && \ - sudo --user=coder env \ - "MISE_DATA_DIR=/opt/mise/data" \ - "MISE_TRUSTED_CONFIG_PATHS=$MISE_TRUSTED_CONFIG_PATHS" \ - "GITHUB_TOKEN=$gh_token" \ - /usr/local/bin/mise install --yes && \ - PATH="/opt/mise/data/shims:$PATH" MISE_DATA_DIR=/opt/mise/data pnpm dlx playwright@1.47.0 install --with-deps chromium && \ - rm -rf /opt/mise/data/cache /opt/mise/data/downloads && \ - apt-get clean && rm -rf /var/lib/apt/lists/* # Install Homebrew as the coder user so the supported Linux prefix remains # writable after the image build. @@ -229,10 +218,12 @@ RUN sudo --login --user=coder env NONINTERACTIVE=1 CI=1 /bin/bash -lc 'set -euo test -x /home/linuxbrew/.linuxbrew/bin/brew && \ sudo --login --user=coder /bin/bash -lc '/home/linuxbrew/.linuxbrew/bin/brew --version' -# Adjust OpenSSH config +# Adjust OpenSSH config and drop the apt lists / cache that survived +# the package installs above. No later step in this image needs apt. RUN echo "PermitUserEnvironment yes" >>/etc/ssh/sshd_config && \ echo "X11Forwarding yes" >>/etc/ssh/sshd_config && \ - echo "X11UseLocalhost no" >>/etc/ssh/sshd_config + echo "X11UseLocalhost no" >>/etc/ssh/sshd_config && \ + apt-get clean && rm -rf /var/lib/apt/lists/* USER coder diff --git a/dogfood/coder/ubuntu-26.04/Dockerfile b/dogfood/coder/ubuntu-26.04/Dockerfile.base similarity index 80% rename from dogfood/coder/ubuntu-26.04/Dockerfile rename to dogfood/coder/ubuntu-26.04/Dockerfile.base index 47f66f0174d1c..84be3b5024f5e 100644 --- a/dogfood/coder/ubuntu-26.04/Dockerfile +++ b/dogfood/coder/ubuntu-26.04/Dockerfile.base @@ -37,6 +37,7 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u bat \ bats \ bind9-dnsutils \ + bison \ build-essential \ ca-certificates \ containerd.io \ @@ -49,6 +50,7 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u fd-find \ file \ fish \ + flex \ gettext-base \ git \ gnupg \ @@ -65,6 +67,8 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u language-pack-en \ less \ libgbm-dev \ + libicu-dev \ + libreadline-dev \ libssl-dev \ lsb-release \ lsof \ @@ -92,10 +96,12 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u tmux \ traceroute \ unzip \ + uuid-dev \ vim \ wget \ xauth \ zip \ + zlib1g-dev \ zsh \ zstd && \ # Keep Docker's engine, CLI, runtime, and plugins on the versions selected by @@ -193,45 +199,28 @@ RUN install --directory --owner=coder --group=coder --mode=0755 "${MISE_INSTALL_ test -x /usr/local/bin/mise && \ sudo --login --user=coder /bin/bash -lc 'set -euo pipefail && mise_bin="$(readlink --canonicalize /usr/local/bin/mise)" && test -w "$(dirname "$mise_bin")" && /usr/local/bin/mise --version && /usr/local/bin/mise self-update --help >/dev/null && /usr/local/bin/mise upgrade --help >/dev/null' -# Trusted paths skip mise's per-config trust prompt for the baked-in -# system config and the coder repo when cloned at the canonical -# /home/coder/coder location. Other repos a user clones still get -# the one-time `mise trust` prompt; pre-trusting all of /home/coder -# would let any mise.toml under the home dir auto-run [hooks]/[tasks]. -ENV MISE_DATA_DIR=/home/coder/.local/share/mise \ - MISE_TRUSTED_CONFIG_PATHS=/home/coder/coder:/etc/mise +ENV MISE_DATA_DIR=/home/coder/.local/share/mise -# Bake the project manifest in as mise's system config and ship -# the lockfile alongside it so mise verifies download checksums -# during install. We do NOT override MISE_GLOBAL_CONFIG_FILE; that -# would re-target `mise use --global` away from the user's -# ~/.config/mise/config.toml (on the home volume) into this -# image-only path, breaking the workflow. -# -# We pre-create /etc/mise as 0755 because COPY's implicitly-created -# parent dirs inherit the --chmod, which would leave /etc/mise -# without the `x` bit and unreachable to the coder user. We also -# chown to coder so mise can write the temp lockfile it uses for -# atomic rename when updating /etc/mise/mise.lock during installs. -RUN install --directory --owner=coder --group=coder --mode=0755 /etc/mise -COPY --chown=coder:coder --chmod=0644 mise.toml /etc/mise/config.toml -COPY --chown=coder:coder --chmod=0644 mise.lock /etc/mise/mise.lock +# Bake a system fallback for trusted_config_paths so the canonical +# /home/coder/coder repo and the mise-oci-synthesized /etc/mise/config.toml +# are trusted without a per-config prompt. The workspace template +# (dogfood/coder/main.tf install-deps coder_script) seeds a matching +# user-owned ~/.config/mise/conf.d/00-coder-trust.toml on workspace +# start, which the user can edit to add their own paths; that file +# lives on the persistent home volume and overrides this fallback. +RUN install --directory --mode=0755 /etc/mise /etc/mise/conf.d +COPY --chmod=0644 <<'EOF' /etc/mise/conf.d/00-coder-trust.toml +[settings] +trusted_config_paths = [ + "/home/coder/coder", + "/etc/mise", +] +EOF -# Pre-install tools into /opt/mise/data so they survive the home -# volume's copy-on-first-mount. MISE_SHARED_INSTALL_DIRS (set below) -# exposes them at runtime; MISE_DATA_DIR stays on the home volume. -# github_token authenticates aqua's API calls (optional secret). +# Reserve the mount_point declared in mise.toml [oci]. The path is +# duplicated below in MISE_SHARED_INSTALL_DIRS and PATH; if it ever +# changes, update all three plus mise.toml. RUN install --directory --owner=coder --group=coder --mode=0755 /opt/mise /opt/mise/data -RUN --mount=type=secret,id=github_token,required=false \ - gh_token="$(cat /run/secrets/github_token 2>/dev/null || true)" && \ - sudo --user=coder env \ - "MISE_DATA_DIR=/opt/mise/data" \ - "MISE_TRUSTED_CONFIG_PATHS=$MISE_TRUSTED_CONFIG_PATHS" \ - "GITHUB_TOKEN=$gh_token" \ - /usr/local/bin/mise install --yes && \ - PATH="/opt/mise/data/shims:$PATH" MISE_DATA_DIR=/opt/mise/data pnpm dlx playwright@1.47.0 install --with-deps chromium && \ - rm -rf /opt/mise/data/cache /opt/mise/data/downloads && \ - apt-get clean && rm -rf /var/lib/apt/lists/* # Install Homebrew as the coder user so the supported Linux prefix remains # writable after the image build. @@ -239,10 +228,12 @@ RUN sudo --login --user=coder env NONINTERACTIVE=1 CI=1 /bin/bash -lc 'set -euo test -x /home/linuxbrew/.linuxbrew/bin/brew && \ sudo --login --user=coder /bin/bash -lc '/home/linuxbrew/.linuxbrew/bin/brew --version' -# Adjust OpenSSH config +# Adjust OpenSSH config and drop the apt lists / cache that survived +# the package installs above. No later step in this image needs apt. RUN echo "PermitUserEnvironment yes" >>/etc/ssh/sshd_config && \ echo "X11Forwarding yes" >>/etc/ssh/sshd_config && \ - echo "X11UseLocalhost no" >>/etc/ssh/sshd_config + echo "X11UseLocalhost no" >>/etc/ssh/sshd_config && \ + apt-get clean && rm -rf /var/lib/apt/lists/* USER coder diff --git a/flake.nix b/flake.nix index 204d91e579f0e..2465f94fac361 100644 --- a/flake.nix +++ b/flake.nix @@ -198,7 +198,6 @@ pango pixman pkg-config - playwright-driver.browsers pnpm postgresql_16 proto_gen_go_1_30 @@ -278,16 +277,6 @@ ''; }; in - # "Keep in mind that you need to use the same version of playwright in your node playwright project as in your nixpkgs, or else playwright will try to use browsers versions that aren't installed!" - # - https://nixos.wiki/wiki/Playwright - assert pkgs.lib.assertMsg - ( - (pkgs.lib.importJSON ./site/package.json).devDependencies."@playwright/test" - == pkgs.playwright-driver.version - ) - "There is a mismatch between the playwright versions in the ./nix.flake (${pkgs.playwright-driver.version}) and the ./site/package.json (${ - (pkgs.lib.importJSON ./site/package.json).devDependencies."@playwright/test" - }) file. Please make sure that they use the exact same version."; rec { inherit formatter; @@ -301,9 +290,6 @@ { buildInputs = devShellPackages; - PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.browsers; - PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = true; - LOCALE_ARCHIVE = with pkgs; lib.optionalDrvAttr stdenv.isLinux "${glibcLocales}/lib/locale/locale-archive"; diff --git a/mise.lock b/mise.lock index c63a3cf0140b0..7f96bd9e3cc5f 100644 --- a/mise.lock +++ b/mise.lock @@ -288,6 +288,54 @@ url = "https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-window checksum = "sha256:a2ac24e197111c9430cb2a98f10a641164381afb83df036504868e4ea5720800" url = "https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-windows-amd64.exe" +[[tools.crane]] +version = "0.21.6" +backend = "aqua:google/go-containerregistry" + +[tools.crane."platforms.linux-arm64"] +checksum = "sha256:6f61571ca0c2a5da27c2927fcb143255ccb2b74b8977dfcb44645b372ab0f951" +url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_arm64.tar.gz" + +[tools.crane."platforms.linux-arm64-musl"] +checksum = "sha256:6f61571ca0c2a5da27c2927fcb143255ccb2b74b8977dfcb44645b372ab0f951" +url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_arm64.tar.gz" + +[tools.crane."platforms.linux-x64"] +checksum = "sha256:7ebbdcd05b652345c1f5105f8475e518534b90d66f3bdb50017be63f426ea435" +url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_x86_64.tar.gz" + +[tools.crane."platforms.linux-x64-baseline"] +checksum = "sha256:7ebbdcd05b652345c1f5105f8475e518534b90d66f3bdb50017be63f426ea435" +url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_x86_64.tar.gz" + +[tools.crane."platforms.linux-x64-musl"] +checksum = "sha256:7ebbdcd05b652345c1f5105f8475e518534b90d66f3bdb50017be63f426ea435" +url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_x86_64.tar.gz" + +[tools.crane."platforms.linux-x64-musl-baseline"] +checksum = "sha256:7ebbdcd05b652345c1f5105f8475e518534b90d66f3bdb50017be63f426ea435" +url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_x86_64.tar.gz" + +[tools.crane."platforms.macos-arm64"] +checksum = "sha256:a124f297d1e63e8b6c63c2463e43565290d2fd074c1dadb5ca73d737bc7b2484" +url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Darwin_arm64.tar.gz" + +[tools.crane."platforms.macos-x64"] +checksum = "sha256:f1e653737a1d6e8a412734d0ac25009e04eccec98853be2eb59b8c744dede834" +url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Darwin_x86_64.tar.gz" + +[tools.crane."platforms.macos-x64-baseline"] +checksum = "sha256:f1e653737a1d6e8a412734d0ac25009e04eccec98853be2eb59b8c744dede834" +url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Darwin_x86_64.tar.gz" + +[tools.crane."platforms.windows-x64"] +checksum = "sha256:fb78f814f68ab47266458f319ca7e642a303453ea25c8993a14eb9850c56e870" +url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Windows_x86_64.tar.gz" + +[tools.crane."platforms.windows-x64-baseline"] +checksum = "sha256:fb78f814f68ab47266458f319ca7e642a303453ea25c8993a14eb9850c56e870" +url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Windows_x86_64.tar.gz" + [[tools.doctl]] version = "1.158.0" backend = "aqua:digitalocean/doctl" @@ -734,6 +782,7 @@ url = "https://github.com/protocolbuffers/protobuf/releases/download/v23.4/proto url = "https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-aarch_64.zip" [tools.protoc."platforms.linux-x64"] +checksum = "blake3:b1d1a517cb9c8c3cbfc98c708f93e6d3bd8b3ce0e2db1ad8c1491ae8a4067ad2" url = "https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip" [tools.protoc."platforms.linux-x64-baseline"] @@ -771,6 +820,7 @@ url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.30.0/ url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.30.0/protoc-gen-go.v1.30.0.linux.arm64.tar.gz" [tools.protoc-gen-go."platforms.linux-x64"] +checksum = "blake3:127ed3a8005b199a8451c258ea8fe8ae0f68dd01b4e52c21c881eb7f1d69a333" url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.30.0/protoc-gen-go.v1.30.0.linux.amd64.tar.gz" [tools.protoc-gen-go."platforms.linux-x64-baseline"] diff --git a/mise.toml b/mise.toml index a09c86bc9479e..c7366deecd4f5 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,7 @@ +# Keep in lockstep with MISE_VERSION in dogfood/coder/ubuntu-*/Dockerfile.base, +# .github/workflows/dogfood.yaml, and scripts/dogfood/mise-oci-wrapper.sh. +min_version = "2026.5.12" + [settings] lockfile = true @@ -28,6 +32,10 @@ protoc-gen-go = "1.30.0" # Infrastructure, release, and lint CLIs. "aqua:ahmetb/kubectx/kubens" = "0.9.4" cosign = "2.4.3" +# crane is the registry client `mise oci push` shells out to. Sourced +# here so it travels with the rest of the mise toolset (one source of +# truth, deterministic version, no apt drift across CI / wrapper). +crane = "0.21.6" golangci-lint = "1.64.8" helm = "3.21.0" kubectx = "0.9.4" @@ -61,3 +69,16 @@ lazygit = "0.61.1" [tools."go:github.com/coder/sqlc/cmd/sqlc"] version = "337309bfb9524f38466a5090e310040fc7af0203" install_env = { CGO_ENABLED = "1" } + +# Consumed by `mise oci build` to produce the dogfood image on top of +# ghcr.io/coder/oss-dogfood-base. The `from` and `--tag` fields are +# overridden by CLI args at build time per distro; `mount_point`, +# `user`, and `workdir` always apply. +# +# mount_point MUST match the path the base image reserves and exposes +# via `MISE_SHARED_INSTALL_DIRS`. Both Dockerfile.base files hardcode +# /opt/mise/data in their `install --directory`, ENV, and PATH lines. +[oci] +mount_point = "/opt/mise/data" +user = "coder" +workdir = "/home/coder" diff --git a/scripts/dogfood/compute-base-sha.sh b/scripts/dogfood/compute-base-sha.sh new file mode 100755 index 0000000000000..cf0659da5d46e --- /dev/null +++ b/scripts/dogfood/compute-base-sha.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Deterministic 12-char content hash of base-image inputs for a distro. +# Used as a cache key for the ghcr.io/coder/oss-dogfood-base tag so +# commits that don't touch the base inputs reuse the previous build. +# +# This is NOT a strict content address: the base Dockerfile still +# pulls dynamic resources at build time (gh/buildx releases/latest, +# chrome stable_current_amd64.deb, apt mirror state, sh.rustup.rs). +# Two runs with identical checked-in files can still produce slightly +# different bytes. That's acceptable here because the dynamic drift +# is small and the cache-hit savings (no full base rebuild for a +# typo-fix commit, doc change, mise.toml bump, etc.) is large. +set -euo pipefail + +# 12 hex chars matches docker/OCI short-digest displays. +HASH_LEN=12 + +distro="${1:?usage: $0 <22.04|26.04>}" + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +paths=( + "dogfood/coder/ubuntu-${distro}/Dockerfile.base" + "dogfood/coder/ubuntu-${distro}/files" +) +if [ "$distro" = "22.04" ]; then + paths+=("dogfood/coder/ubuntu-${distro}/configure-chrome-flags.sh") +fi + +# Skip editor turds; .swp / ~-files / dotfiles are noise for a build +# hash. Include symlinks too: `COPY dogfood/coder/ubuntu-*/files /` +# bakes their target paths into the image, so swapping a symlink +# changes base content and must invalidate the cache key. +find "${paths[@]}" \( -type f -o -type l \) \ + ! -name '.*' \ + ! -name '*.swp' \ + ! -name '*~' \ + -print0 | + LC_ALL=C sort -z | + xargs -0 sha256sum | + sha256sum | + cut -c"1-$HASH_LEN" diff --git a/scripts/dogfood/compute-final-sha.sh b/scripts/dogfood/compute-final-sha.sh new file mode 100755 index 0000000000000..d843399dd4f4b --- /dev/null +++ b/scripts/dogfood/compute-final-sha.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Deterministic 12-char content hash of (base inputs + mise inputs) for +# a distro. Used as the primary tag for the dogfood image produced by +# `mise oci build`, so re-running CI on an unchanged commit reuses the +# previous tag. Same cache-key (not strict content address) semantics +# as `compute-base-sha.sh`. +set -euo pipefail + +# 12 hex chars; see comment in compute-base-sha.sh. +HASH_LEN=12 + +distro="${1:?usage: $0 <22.04|26.04>}" + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +base_sha="$("$repo_root/scripts/dogfood/compute-base-sha.sh" "$distro")" +mise_hash="$(sha256sum mise.toml mise.lock | sha256sum | cut -c"1-$HASH_LEN")" + +printf '%s\n' "$base_sha-$mise_hash" | sha256sum | cut -c"1-$HASH_LEN" diff --git a/scripts/dogfood/mise-oci-wrapper.sh b/scripts/dogfood/mise-oci-wrapper.sh new file mode 100755 index 0000000000000..c5f0698ba7449 --- /dev/null +++ b/scripts/dogfood/mise-oci-wrapper.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Local-only helper: runs `mise oci ...` inside a Linux container so +# macOS and Windows developers don't need a local Linux VM or a host +# install of mise. CI runs `mise oci` directly on its Linux runner; it +# does not use this script. +# +# Builds a small Debian-based wrapper image with the mise binary on +# first invocation, then reuses it. Pinning to the same `MISE_VERSION` +# baked into `Dockerfile.base` avoids depending on jdxcode/mise Docker +# Hub publication cadence, which lags upstream GitHub releases by days. +# +# `oci build --from ` requires to be a registry-resolvable +# reference; the host's local Docker daemon images are not visible +# inside the wrapper. See the Makefile comment. +# +# Honors CONTAINER_RUNTIME=docker (default) or CONTAINER_RUNTIME=container +# (Apple's `container` CLI on macOS). +set -euo pipefail + +# Keep MISE_VERSION + MISE_SHA256 in lockstep with the same vars in +# .github/workflows/dogfood.yaml and dogfood/coder/ubuntu-*/Dockerfile.base. +# A `min_version` check in mise.toml catches downgrades. +MISE_VERSION="v2026.5.12" +MISE_SHA256="a238972a3162d710b85b28c324372e96ca4e4b486c81fe78695000d9fbc77c48" +# Bump the -rN suffix when the Dockerfile heredoc below changes +# (mise version, apt packages, trust config, etc.) so cached wrapper +# images get rebuilt automatically. +WRAPPER_REVISION="r2" +RUNTIME="${CONTAINER_RUNTIME:-docker}" +WRAPPER_IMAGE="coderdev/mise-oci-wrapper:$MISE_VERSION-$WRAPPER_REVISION" + +# Mount the repo root rather than $PWD: `make -C dogfood/coder` invokes +# the wrapper from dogfood/coder/, but the project mise.toml/mise.lock +# `mise oci build` consumes live at the repo root. +REPO_ROOT="$(git rev-parse --show-toplevel)" + +platform_arg=() +if [ "$RUNTIME" = "container" ]; then + platform_arg=(--platform linux/amd64) +fi + +# Build the wrapper image on first invocation. The tag includes the +# mise version so a bump automatically invalidates the cache; the old +# image becomes orphaned and the user can prune it manually. +if ! "$RUNTIME" image inspect "$WRAPPER_IMAGE" >/dev/null 2>&1; then + echo "[$0] Building $WRAPPER_IMAGE (first-time setup)..." >&2 + build_dir="$(mktemp -d)" + trap 'rm -rf "$build_dir"' EXIT + cat >"$build_dir/Dockerfile" < /etc/mise/conf.d/00-trust.toml +DOCKERFILE + "$RUNTIME" build ${platform_arg[@]+"${platform_arg[@]}"} -t "$WRAPPER_IMAGE" "$build_dir" + rm -rf "$build_dir" + trap - EXIT +fi + +token_arg=() +if [ -n "${GITHUB_TOKEN:-}" ]; then + token_arg=(-e "GITHUB_TOKEN=$GITHUB_TOKEN") +fi + +# Mount ~/.docker when present so crane can find registry creds. +# Apple `container` CLI users without Docker Desktop won't have it; +# local builds don't push, so the skip is fine. +docker_config_arg=() +if [ -d "$HOME/.docker" ]; then + docker_config_arg=(-v "$HOME/.docker:/root/.docker:ro") +fi + +# `oci build` needs all mise tools installed so it can package them +# into layers. `oci push` needs crane on PATH (mise oci shells out to +# it). Both end up running `mise install` first; build installs every +# tool, push only crane. The `export PATH=...` exposes mise's shims +# dir so `which crane` succeeds when mise oci spawns it as a child. +# Single quotes are intentional: $HOME and $@ expand inside the +# container's `sh -c`, not in this script. +# shellcheck disable=SC2016 +inner_cmd='mise oci "$@"' +case "${1:-}" in +build) + # shellcheck disable=SC2016 + inner_cmd='mise install --yes && export PATH="$HOME/.local/share/mise/shims:$PATH" && mise oci "$@"' + ;; +push) + # shellcheck disable=SC2016 + inner_cmd='mise install --yes crane && export PATH="$HOME/.local/share/mise/shims:$PATH" && mise oci "$@"' + ;; +esac + +exec "$RUNTIME" run --rm ${platform_arg[@]+"${platform_arg[@]}"} \ + -v "$REPO_ROOT":/src -w /src \ + ${docker_config_arg[@]+"${docker_config_arg[@]}"} \ + -e MISE_EXPERIMENTAL=1 \ + ${token_arg[@]+"${token_arg[@]}"} \ + --entrypoint /bin/sh \ + "$WRAPPER_IMAGE" \ + -c "$inner_cmd" -- "$@" diff --git a/scripts/dogfood_test_image.sh b/scripts/dogfood_test_image.sh index 08360d7511fef..b7547937a391e 100755 --- a/scripts/dogfood_test_image.sh +++ b/scripts/dogfood_test_image.sh @@ -50,17 +50,21 @@ else fi # Helper: run a make target inside the image. -# Caches are persisted in named Docker volumes so that subsequent steps (and -# repeated local runs) reuse downloaded modules and compiled artifacts. +# +# Mounts /home/coder/ as a single named volume to mirror the dogfood +# workspace template (dogfood/coder/main.tf), so caches (Go modules, +# Go build, pnpm store, mise data, etc.) persist the same way they do +# in real workspaces. Per-cache subpath volumes would come up +# root-owned on first mount because Docker creates non-existent +# subpaths root-owned; the home-level volume inherits coder:coder +# from the image's existing /home/coder (`useradd --create-home`). run_make() { docker run --rm \ + --volume coder-dogfood-home:/home/coder \ --volume "$(pwd)":/home/coder/coder \ --env GIT_CONFIG_COUNT=1 \ --env GIT_CONFIG_KEY_0=safe.directory \ --env GIT_CONFIG_VALUE_0=/home/coder/coder \ - --volume coder-dogfood-gomod:/home/coder/go/pkg/mod \ - --volume coder-dogfood-gobuild:/home/coder/.cache/go-build \ - --volume coder-dogfood-pnpm:/home/coder/.local/share/pnpm/store \ --workdir /home/coder/coder \ --network=host \ --env GITHUB_TOKEN \ diff --git a/scripts/update-flake.sh b/scripts/update-flake.sh index 7007b6b001a5d..f89dd179df75f 100755 --- a/scripts/update-flake.sh +++ b/scripts/update-flake.sh @@ -37,6 +37,4 @@ echo "protoc-gen-go version: $PROTOC_GEN_GO_REV" PROTOC_GEN_GO_SHA256=$(nix-prefetch-git https://github.com/protocolbuffers/protobuf-go --rev "$PROTOC_GEN_GO_REV" | jq -r .hash) sed -i "s#\(sha256 = \"\)[^\"]*#\1${PROTOC_GEN_GO_SHA256}#" ./flake.nix -make dogfood/coder/nix.hash - echo "Flake updated successfully!" From dfd7ca3b98749d39396d078d8a4d00e3da3b805e Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Tue, 26 May 2026 18:29:10 +0500 Subject: [PATCH 007/249] docs: improve discoverability of automatic port forwarding via Coder Desktop (#25675) --- docs/admin/networking/port-forwarding.md | 22 ++++++++++++++----- .../desktop/desktop-connect-sync.md | 8 ++++++- docs/user-guides/desktop/index.md | 7 +++++- docs/user-guides/workspace-access/index.md | 15 ++++++++++++- .../workspace-access/port-forwarding.md | 8 +++++-- 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/docs/admin/networking/port-forwarding.md b/docs/admin/networking/port-forwarding.md index 3c4e9777d0960..f5678403adb94 100644 --- a/docs/admin/networking/port-forwarding.md +++ b/docs/admin/networking/port-forwarding.md @@ -4,18 +4,28 @@ Port forwarding lets developers securely access processes on their Coder workspace from a local machine. A common use case is testing web applications in a browser. -There are three ways to forward ports in Coder: +There are four ways to forward ports in Coder: -- The `coder port-forward` command -- Dashboard -- SSH +| Method | Details | +|:---------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [Coder Desktop](#coder-desktop) | Automatic port forwarding via VPN tunnel. All workspace ports are available at `workspace.coder:PORT` with no manual setup. Supports peer-to-peer connections. | +| [CLI](#the-coder-port-forward-command) | Forwards specific TCP or UDP ports from the workspace to local ports. Supports peer-to-peer connections. | +| [Dashboard](#dashboard) | Proxies traffic through the Coder control plane. | +| [SSH](#ssh) | Forwards ports over an SSH connection. | -The `coder port-forward` command is generally more performant than: +Coder Desktop and `coder port-forward` are generally more performant than: 1. The Dashboard which proxies traffic through the Coder control plane versus - peer-to-peer which is possible with the Coder CLI + peer-to-peer which is possible with the Coder CLI and Coder Desktop 1. `sshd` which does double encryption of traffic with both Wireguard and SSH +## Coder Desktop + +[Coder Desktop](../../user-guides/desktop/index.md) provides automatic port forwarding to every service running in your workspace. +Once Coder Connect is enabled, any port your application listens on is instantly accessible at `.coder:PORT` from your local machine, with no additional commands or configuration. + +This is the simplest option for most users. See the [Coder Desktop documentation](../../user-guides/desktop/index.md) for installation and setup. + ## The `coder port-forward` command This command can be used to forward TCP or UDP ports from the remote workspace diff --git a/docs/user-guides/desktop/desktop-connect-sync.md b/docs/user-guides/desktop/desktop-connect-sync.md index f6a45a598477f..5ea445c672d9e 100644 --- a/docs/user-guides/desktop/desktop-connect-sync.md +++ b/docs/user-guides/desktop/desktop-connect-sync.md @@ -19,7 +19,13 @@ You can also connect to the SSH server in your workspace using any SSH client, s ssh your-workspace.coder ``` -Any services listening on ports in your workspace will be available on the same hostname. For example, you can access a web server on port `8080` by visiting `http://your-workspace.coder:8080` in your browser. +### Automatic port forwarding + +Any services listening on ports in your workspace are automatically available on the same hostname, with no manual port forwarding required. For example, you can access a web server on port `8080` by visiting `http://your-workspace.coder:8080` in your browser. + +This works for all TCP ports. Start a service in your workspace and access it immediately from your local machine at `http://your-workspace.coder:PORT`. + +For other port forwarding methods (CLI, dashboard, SSH), see [Workspace Ports](../workspace-access/port-forwarding.md). > [!NOTE] > For Coder versions v2.21.3 and earlier: the Coder IDE extensions for VSCode and JetBrains create their own tunnel and do not utilize the Coder Connect tunnel to connect to workspaces. diff --git a/docs/user-guides/desktop/index.md b/docs/user-guides/desktop/index.md index 12bd664f173ce..bbcb657df637f 100644 --- a/docs/user-guides/desktop/index.md +++ b/docs/user-guides/desktop/index.md @@ -1,6 +1,9 @@ # Coder Desktop -Coder Desktop provides seamless access to your remote workspaces through a native application. Connect to workspace services using simple hostnames like `myworkspace.coder`, launch applications with one click, and synchronize files between local and remote environments—all without installing a CLI or configuring manual port forwarding. +Coder Desktop provides seamless access to your remote workspaces through a native application. Connect to workspace services using simple hostnames like `myworkspace.coder`, launch applications with one click, and synchronize files between local and remote environments, all without installing a CLI or configuring manual port forwarding. + +> [!TIP] +> Coder Desktop provides **automatic port forwarding** to every service running in your workspace. Any port your application listens on is instantly accessible at `workspace-name.coder:PORT` with no manual setup required. For a comparison of all port forwarding methods, see [Workspace Ports](../workspace-access/port-forwarding.md). ## What You'll Need @@ -21,6 +24,7 @@ Coder Desktop provides seamless access to your remote workspaces through a nativ **Coder Connect**, the primary component of Coder Desktop, creates a secure tunnel to your Coder deployment, allowing you to: - **Access workspaces directly**: Connect via `workspace-name.coder` hostnames +- **Automatic port forwarding**: All workspace ports are available at `workspace-name.coder:PORT` with no configuration - **Use any application**: SSH clients, browsers, IDEs work seamlessly - **Sync files**: Bidirectional sync between local and remote directories - **Work offline**: Edit files locally, sync when reconnected @@ -196,3 +200,4 @@ If you encounter issues not covered here: ## Next Steps - [Using Coder Connect and File Sync](./desktop-connect-sync.md) +- [Compare port forwarding methods](../workspace-access/port-forwarding.md) diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index 05dca3beea407..da72459cbbd66 100644 --- a/docs/user-guides/workspace-access/index.md +++ b/docs/user-guides/workspace-access/index.md @@ -155,12 +155,25 @@ of tools for extending the capability of your workspace. If you have a request for a new IDE or tool, please file an issue in our [Modules repo](https://github.com/coder/registry/issues). +## Coder Desktop + +[Coder Desktop](../desktop/index.md) is a native application that provides seamless access to your workspaces via a VPN tunnel. With Coder Desktop, you get: + +- **Automatic port forwarding**: All workspace ports are available at `workspace-name.coder:PORT` with no manual setup +- **SSH access**: Connect with `ssh workspace-name.coder` using any SSH client +- **File sync**: Bidirectional file synchronization between local and remote directories + +Coder Desktop is the recommended way to access workspace services for developers who want a seamless, native experience. + ## Ports and Port forwarding -You can manage listening ports on your workspace page through with the listening +You can manage listening ports on your workspace page through the listening ports window in the dashboard. These ports are often used to run internal services or preview environments. +> [!TIP] +> For automatic access to all ports without manual configuration, use [Coder Desktop](../desktop/index.md). + You can also [share ports](./port-forwarding.md#sharing-ports) with other users, or [port-forward](./port-forwarding.md#the-coder-port-forward-command) through the CLI with `coder port forward`. Read more in the diff --git a/docs/user-guides/workspace-access/port-forwarding.md b/docs/user-guides/workspace-access/port-forwarding.md index 3bcfb1e2b5196..26843bcb936f0 100644 --- a/docs/user-guides/workspace-access/port-forwarding.md +++ b/docs/user-guides/workspace-access/port-forwarding.md @@ -17,8 +17,12 @@ There are multiple ways to forward ports in Coder: ## Coder Desktop -[Coder Desktop](../desktop/index.md) provides seamless access to your remote workspaces, eliminating the need to install a CLI or manually configure port forwarding. -Access all your ports at `.coder:PORT`. +> [!TIP] +> Coder Desktop is the recommended way to access workspace ports. It provides automatic port forwarding with no manual setup. + +[Coder Desktop](../desktop/index.md) creates a VPN tunnel that automatically forwards every port in your workspace. Any service listening on a port is instantly accessible at `.coder:PORT` from your local machine, with no additional commands or configuration. + +This is the simplest option for most developers: install Coder Desktop, enable Coder Connect, and all ports just work. Connections are peer-to-peer for the best performance. ## The `coder port-forward` command From 282ab7de34041c8ce6ed418b1f95a3c72bf7302e Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 26 May 2026 15:57:01 +0200 Subject: [PATCH 008/249] refactor: load AI providers from the database at startup (#25672) Replace the env-based `BuildProviders` with a DB-backed loader. The database is now the single source of truth for runtime provider configuration; env config arrives via `SeedAIProvidersFromEnv` (run at boot) and `BuildProviders` reads it back as `aibridge.Provider` instances. `cli/server.go` and `enterprise/cli/server.go` both call the same path, so aibridged and aibridgeproxyd see the same provider set. Per-provider `DumpDir` is replaced by a top-level `CODER_AI_GATEWAY_DUMP_DIR` base; each provider's effective dump path is `/`. --- cli/aibridged.go | 311 ++++++++++-------- cli/aibridged_internal_test.go | 236 +++++++++++-- cli/server.go | 46 +-- cli/server_aibridge_internal_test.go | 53 ++- cli/testdata/coder_server_--help.golden | 6 + cli/testdata/server-config.yaml.golden | 5 + coderd/ai_providers_migrate.go | 5 + coderd/apidoc/docs.go | 8 +- coderd/apidoc/swagger.json | 8 +- coderd/database/dbauthz/dbauthz.go | 1 + codersdk/deployment.go | 17 +- docs/ai-coder/ai-gateway/setup.md | 53 ++- docs/reference/api/general.md | 2 +- docs/reference/api/schemas.md | 11 +- docs/reference/cli/server.md | 10 + .../cli/aibridgeproxyd_internal_test.go | 36 +- enterprise/cli/server.go | 2 +- .../cli/testdata/coder_server_--help.golden | 6 + site/src/api/typesGenerated.ts | 10 +- 19 files changed, 569 insertions(+), 257 deletions(-) diff --git a/cli/aibridged.go b/cli/aibridged.go index 42bcc6d3d5314..8bb21a8cbfe8c 100644 --- a/cli/aibridged.go +++ b/cli/aibridged.go @@ -5,15 +5,21 @@ package cli import ( "context" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" + "cdr.dev/slog/v3" "github.com/coder/coder/v2/aibridge" "github.com/coder/coder/v2/aibridge/config" "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/aibridged" + "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/tracing" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/quartz" ) @@ -44,183 +50,200 @@ func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider) (*ai return srv, nil } -// BuildProviders constructs the list of AI providers from config. -// It merges legacy single-provider env vars and indexed provider configs: -// 1. Legacy providers (from CODER_AI_GATEWAY_OPENAI_KEY, etc.) are added first. -// If a legacy name conflicts with an indexed provider, startup fails with -// a clear error asking the admin to remove one or the other. -// 2. Indexed providers (from CODER_AI_GATEWAY_PROVIDER__*) are added next. -func BuildProviders(cfg codersdk.AIBridgeConfig) ([]aibridge.Provider, error) { - var cbConfig *config.CircuitBreaker - if cfg.CircuitBreakerEnabled.Value() { - cbConfig = &config.CircuitBreaker{ - FailureThreshold: uint32(cfg.CircuitBreakerFailureThreshold.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options. - Interval: cfg.CircuitBreakerInterval.Value(), - Timeout: cfg.CircuitBreakerTimeout.Value(), - MaxRequests: uint32(cfg.CircuitBreakerMaxRequests.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options. +// BuildProviders loads every enabled ai_providers row, attaches its +// keys, and constructs the equivalent [aibridge.Provider] instances. +// The database is the single source of truth for runtime provider +// configuration. +// +// Per-provider construction errors are logged and the offending row is +// excluded from the returned snapshot; only a failure of the DB query +// itself is propagated. This keeps a single misconfigured row from +// taking the whole daemon down. +func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridgeConfig, logger slog.Logger) ([]aibridge.Provider, error) { + //nolint:gocritic // AsAIBridged has a minimal permission set for this purpose. + authCtx := dbauthz.AsAIBridged(ctx) + + var rows []database.AIProvider + keysByProvider := make(map[uuid.UUID][]database.AIProviderKey) + + // Wrap both queries in a read-only transaction so the provider list + // and the key list are consistent with each other. + err := db.InTx(func(tx database.Store) error { + var err error + rows, err = tx.GetAIProviders(authCtx, database.GetAIProvidersParams{ + IncludeDisabled: false, + }) + if err != nil { + return xerrors.Errorf("load ai providers: %w", err) } - } - var providers []aibridge.Provider - usedNames := make(map[string]struct{}) + if len(rows) == 0 { + return nil + } - // Collect names from indexed providers so we can detect conflicts - // with legacy providers. - for _, p := range cfg.Providers { - name := p.Name - if name == "" { - name = p.Type + // Load keys only for the enabled providers to avoid materializing + // secrets for disabled rows. + ids := make([]uuid.UUID, len(rows)) + for i, r := range rows { + ids[i] = r.ID + } + keyRows, err := tx.GetAIProviderKeysByProviderIDs(authCtx, ids) + if err != nil { + return xerrors.Errorf("load ai provider keys: %w", err) + } + for _, k := range keyRows { + keysByProvider[k.ProviderID] = append(keysByProvider[k.ProviderID], k) } - usedNames[name] = struct{}{} + return nil + }, &database.TxOptions{ReadOnly: true, TxIdentifier: "build_ai_providers"}) + if err != nil { + return nil, err } - // Add legacy OpenAI provider if configured. - if cfg.LegacyOpenAI.Key.String() != "" { - if _, conflict := usedNames[aibridge.ProviderOpenAI]; conflict { - return nil, xerrors.Errorf("legacy CODER_AI_GATEWAY_OPENAI_KEY (or CODER_AIBRIDGE_OPENAI_KEY) conflicts with indexed provider named %q; remove one or the other", aibridge.ProviderOpenAI) + out := make([]aibridge.Provider, 0, len(rows)) + for _, row := range rows { + prov, err := buildAIProviderFromRow(row, keysByProvider[row.ID], cfg) + if err != nil { + logger.Error(ctx, "skipping misconfigured ai provider", + slog.F("provider_id", row.ID), + slog.F("provider_name", row.Name), + slog.F("provider_type", string(row.Type)), + slog.Error(err), + ) + continue } - providers = append(providers, aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{ - Name: aibridge.ProviderOpenAI, - BaseURL: cfg.LegacyOpenAI.BaseURL.String(), - Key: cfg.LegacyOpenAI.Key.String(), - CircuitBreaker: cbConfig, - SendActorHeaders: cfg.SendActorHeaders.Value(), - })) - usedNames[aibridge.ProviderOpenAI] = struct{}{} + out = append(out, prov) + } + + if len(rows) > 0 && len(out) == 0 { + logger.Warn(ctx, "all enabled ai providers failed to build; daemon will start with zero providers") } - // Add legacy Anthropic provider if configured. Bedrock credentials - // alone are sufficient, an Anthropic API key is not required when - // using AWS Bedrock. - if cfg.LegacyAnthropic.Key.String() != "" || getBedrockConfig(cfg.LegacyBedrock) != nil { - if _, conflict := usedNames[aibridge.ProviderAnthropic]; conflict { - return nil, xerrors.Errorf("legacy CODER_AI_GATEWAY_ANTHROPIC_KEY (or CODER_AIBRIDGE_ANTHROPIC_KEY) conflicts with indexed provider named %q; remove one or the other", aibridge.ProviderAnthropic) + return out, nil +} + +// buildAIProviderFromRow decodes the settings blob and constructs the +// appropriate [aibridge.Provider] for a single ai_providers row. +func buildAIProviderFromRow( + row database.AIProvider, + keys []database.AIProviderKey, + cfg codersdk.AIBridgeConfig, +) (aibridge.Provider, error) { + settings, err := db2sdk.AIProviderSettings(row.Settings) + if err != nil { + return nil, xerrors.Errorf("decode settings: %w", err) + } + + cbCfg := circuitBreakerConfig(cfg) + sendActorHeaders := cfg.SendActorHeaders.Value() + dumpDir := cfg.APIDumpDir.Value() + + switch row.Type { + case database.AiProviderTypeOpenai: + if len(keys) == 0 && !cfg.AllowBYOK.Value() { + return nil, xerrors.New("openai provider has no api keys configured and BYOK is not enabled") } var pool *keypool.Pool - if key := cfg.LegacyAnthropic.Key.String(); key != "" { + if len(keys) > 0 { var err error - pool, err = keypool.New([]string{key}, quartz.NewReal()) + pool, err = buildAIProviderKeyPool(keys) if err != nil { - return nil, xerrors.Errorf("create legacy anthropic key pool: %w", err) + return nil, xerrors.Errorf("openai key pool: %w", err) } } - providers = append(providers, aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{ - Name: aibridge.ProviderAnthropic, - BaseURL: cfg.LegacyAnthropic.BaseURL.String(), + return aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{ + Name: row.Name, + BaseURL: row.BaseUrl, KeyPool: pool, - CircuitBreaker: cbConfig, - SendActorHeaders: cfg.SendActorHeaders.Value(), - }, getBedrockConfig(cfg.LegacyBedrock))) - usedNames[aibridge.ProviderAnthropic] = struct{}{} - } - - // Add indexed providers. - for _, p := range cfg.Providers { - name := p.Name - if name == "" { - name = p.Type + APIDumpDir: dumpDir, + CircuitBreaker: cbCfg, + SendActorHeaders: sendActorHeaders, + }), nil + + case database.AiProviderTypeAnthropic: + bedrock := bedrockConfigFromRow(row, settings) + // Bedrock-backed Anthropic authenticates via AWS credentials in + // the settings blob, not the api_keys table. A bearer-token + // Anthropic without any key cannot make upstream calls. + if bedrock == nil && len(keys) == 0 && !cfg.AllowBYOK.Value() { + return nil, xerrors.New("anthropic provider has no api keys, no bedrock credentials, and BYOK is not enabled") } - switch p.Type { - case aibridge.ProviderOpenAI: - var pool *keypool.Pool - if len(p.Keys) > 0 { - var err error - pool, err = keypool.New(p.Keys, quartz.NewReal()) - if err != nil { - return nil, xerrors.Errorf("create openai key pool for provider %q: %w", name, err) - } - } - providers = append(providers, aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{ - Name: name, - BaseURL: p.BaseURL, - KeyPool: pool, - APIDumpDir: p.DumpDir, - CircuitBreaker: cbConfig, - SendActorHeaders: cfg.SendActorHeaders.Value(), - })) - case aibridge.ProviderAnthropic: - var pool *keypool.Pool - if len(p.Keys) > 0 { - var err error - pool, err = keypool.New(p.Keys, quartz.NewReal()) - if err != nil { - return nil, xerrors.Errorf("create anthropic key pool for provider %q: %w", name, err) - } + var pool *keypool.Pool + if len(keys) > 0 { + var err error + pool, err = buildAIProviderKeyPool(keys) + if err != nil { + return nil, xerrors.Errorf("anthropic key pool: %w", err) } - providers = append(providers, aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{ - Name: name, - BaseURL: p.BaseURL, - KeyPool: pool, - APIDumpDir: p.DumpDir, - CircuitBreaker: cbConfig, - SendActorHeaders: cfg.SendActorHeaders.Value(), - }, bedrockConfigFromProvider(p))) - case aibridge.ProviderCopilot: - providers = append(providers, aibridge.NewCopilotProvider(aibridge.CopilotConfig{ - Name: name, - BaseURL: p.BaseURL, - APIDumpDir: p.DumpDir, - CircuitBreaker: cbConfig, - })) - default: - return nil, xerrors.Errorf("unknown provider type %q for provider %q", p.Type, name) } + return aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{ + Name: row.Name, + BaseURL: row.BaseUrl, + KeyPool: pool, + APIDumpDir: dumpDir, + CircuitBreaker: cbCfg, + SendActorHeaders: sendActorHeaders, + }, bedrock), nil + + case database.AiProviderTypeCopilot: + // Copilot is always BYOK; the per-user token is supplied on each + // request via the Authorization header, so no keypool is built. + return aibridge.NewCopilotProvider(aibridge.CopilotConfig{ + Name: row.Name, + BaseURL: row.BaseUrl, + APIDumpDir: dumpDir, + CircuitBreaker: cbCfg, + }), nil + + default: + return nil, xerrors.Errorf("unsupported provider type: %q", row.Type) } - - return providers, nil } -// bedrockConfigFromProvider converts Bedrock fields from an indexed -// AIProviderConfig into an aibridge AWSBedrockConfig. -// Returns nil if no Bedrock fields are set. -func bedrockConfigFromProvider(p codersdk.AIProviderConfig) *aibridge.AWSBedrockConfig { - // Currently, only the first key pair is used, if any. - // TODO(ssncferreira): pass a keypool.Pool instead. - var accessKey, accessKeySecret string - if len(p.BedrockAccessKeys) > 0 { - accessKey = p.BedrockAccessKeys[0] +// buildAIProviderKeyPool builds a [keypool.Pool]. Callers must check +// len(keys) > 0 first; keypool.New rejects empty input. +func buildAIProviderKeyPool(keys []database.AIProviderKey) (*keypool.Pool, error) { + raw := make([]string, 0, len(keys)) + for _, k := range keys { + raw = append(raw, k.APIKey) } - if len(p.BedrockAccessKeySecrets) > 0 { - accessKeySecret = p.BedrockAccessKeySecrets[0] + return keypool.New(raw, quartz.NewReal()) +} + +// bedrockConfigFromRow returns nil when the settings have no Bedrock +// discriminator or when the Bedrock fields are not actually configured. +// The provider row's BaseUrl is the generic upstream endpoint and is +// always non-empty, so it cannot serve as a Bedrock detection signal; +// gate on the settings blob alone via [codersdk.AIProviderBedrockSettings.IsConfigured]. +func bedrockConfigFromRow(row database.AIProvider, settings codersdk.AIProviderSettings) *aibridge.AWSBedrockConfig { + if settings.Bedrock == nil { + return nil } - settings := codersdk.NewAIProviderBedrockSettings( - p.BedrockRegion, accessKey, accessKeySecret, - p.BedrockModel, p.BedrockSmallFastModel, - ) - if !codersdk.IsBedrockConfigured(p.BedrockBaseURL, settings) { + bedrockSettings := *settings.Bedrock + if !bedrockSettings.IsConfigured() { return nil } + accessKey := ptr.NilToEmpty(bedrockSettings.AccessKey) + accessKeySecret := ptr.NilToEmpty(bedrockSettings.AccessKeySecret) return &aibridge.AWSBedrockConfig{ - BaseURL: p.BedrockBaseURL, - Region: p.BedrockRegion, + BaseURL: row.BaseUrl, + Region: bedrockSettings.Region, AccessKey: accessKey, AccessKeySecret: accessKeySecret, - Model: p.BedrockModel, - SmallFastModel: p.BedrockSmallFastModel, + Model: bedrockSettings.Model, + SmallFastModel: bedrockSettings.SmallFastModel, } } -func getBedrockConfig(cfg codersdk.AIBridgeBedrockConfig) *aibridge.AWSBedrockConfig { - // codersdk.IsBedrockConfigured decides what counts as Bedrock; when - // it returns false, the AWS SDK default credential chain (env vars, - // shared config, IAM roles, etc.) is left to resolve credentials. - settings := codersdk.NewAIProviderBedrockSettings( - cfg.Region.String(), - cfg.AccessKey.String(), - cfg.AccessKeySecret.String(), - cfg.Model.String(), - cfg.SmallFastModel.String(), - ) - if !codersdk.IsBedrockConfigured(cfg.BaseURL.String(), settings) { +// circuitBreakerConfig returns nil when the breaker is disabled. +func circuitBreakerConfig(cfg codersdk.AIBridgeConfig) *config.CircuitBreaker { + if !cfg.CircuitBreakerEnabled.Value() { return nil } - - return &aibridge.AWSBedrockConfig{ - BaseURL: cfg.BaseURL.String(), - Region: cfg.Region.String(), - AccessKey: cfg.AccessKey.String(), - AccessKeySecret: cfg.AccessKeySecret.String(), - Model: cfg.Model.String(), - SmallFastModel: cfg.SmallFastModel.String(), + return &config.CircuitBreaker{ + FailureThreshold: uint32(cfg.CircuitBreakerFailureThreshold.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options. + Interval: cfg.CircuitBreakerInterval.Value(), + Timeout: cfg.CircuitBreakerTimeout.Value(), + MaxRequests: uint32(cfg.CircuitBreakerMaxRequests.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options. } } diff --git a/cli/aibridged_internal_test.go b/cli/aibridged_internal_test.go index 2c2651cc11476..1f9f51267864e 100644 --- a/cli/aibridged_internal_test.go +++ b/cli/aibridged_internal_test.go @@ -3,23 +3,47 @@ package cli import ( + "database/sql" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/coderd" agplaibridge "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" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" "github.com/coder/serpent" ) +// buildFromEnv exercises the same env-config-in/providers-out path that +// production uses on boot: SeedAIProvidersFromEnv writes the env-derived +// rows to the database, and BuildProviders reads them back as runtime +// [aibridge.Provider] instances. This keeps the existing TestBuildProviders +// table intact while reflecting the post-refactor flow where the database +// is the single source of truth. +func buildFromEnv(t *testing.T, cfg codersdk.AIBridgeConfig) ([]aibridge.Provider, error) { + t.Helper() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, nil) + if err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, logger); err != nil { + return nil, err + } + return BuildProviders(ctx, db, cfg, logger) +} + func TestBuildProviders(t *testing.T) { t.Parallel() t.Run("EmptyConfig", func(t *testing.T) { t.Parallel() - providers, err := BuildProviders(codersdk.AIBridgeConfig{}) + providers, err := buildFromEnv(t, codersdk.AIBridgeConfig{}) require.NoError(t, err) assert.Empty(t, providers) }) @@ -30,7 +54,7 @@ func TestBuildProviders(t *testing.T) { cfg.LegacyOpenAI.Key = serpent.String("sk-openai") cfg.LegacyAnthropic.Key = serpent.String("sk-anthropic") - providers, err := BuildProviders(cfg) + providers, err := buildFromEnv(t, cfg) require.NoError(t, err) names := providerNames(providers) @@ -44,28 +68,29 @@ func TestBuildProviders(t *testing.T) { cfg := codersdk.AIBridgeConfig{ Providers: []codersdk.AIProviderConfig{ { - Type: aibridge.ProviderAnthropic, - Name: "anthropic-zdr", - Keys: []string{"sk-zdr"}, - DumpDir: "/tmp/anthropic-dump", + Type: aibridge.ProviderAnthropic, + Name: "anthropic-zdr", + Keys: []string{"sk-zdr"}, }, { Type: aibridge.ProviderOpenAI, Name: "openai-azure", Keys: []string{"sk-azure"}, BaseURL: "https://azure.openai.com", - DumpDir: "/tmp/openai-dump", }, }, } - providers, err := BuildProviders(cfg) + providers, err := buildFromEnv(t, cfg) require.NoError(t, err) + require.Len(t, providers, 2) - names := providerNames(providers) - assert.Equal(t, []string{"anthropic-zdr", "openai-azure"}, names) - assert.Equal(t, "/tmp/anthropic-dump", providers[0].APIDumpDir()) - assert.Equal(t, "/tmp/openai-dump", providers[1].APIDumpDir()) + byName := make(map[string]aibridge.Provider, len(providers)) + for _, p := range providers { + byName[p.Name()] = p + } + require.Contains(t, byName, "anthropic-zdr") + require.Contains(t, byName, "openai-azure") }) t.Run("LegacyOpenAIConflictsWithIndexed", func(t *testing.T) { @@ -77,9 +102,9 @@ func TestBuildProviders(t *testing.T) { } cfg.LegacyOpenAI.Key = serpent.String("sk-legacy") - _, err := BuildProviders(cfg) + _, err := buildFromEnv(t, cfg) require.Error(t, err) - assert.Contains(t, err.Error(), "conflicts with indexed provider") + assert.Contains(t, err.Error(), "conflicts with the legacy env var") }) t.Run("LegacyAnthropicConflictsWithIndexed", func(t *testing.T) { @@ -91,9 +116,9 @@ func TestBuildProviders(t *testing.T) { } cfg.LegacyAnthropic.Key = serpent.String("sk-legacy") - _, err := BuildProviders(cfg) + _, err := buildFromEnv(t, cfg) require.Error(t, err) - assert.Contains(t, err.Error(), "conflicts with indexed provider") + assert.Contains(t, err.Error(), "conflicts with the legacy env var") }) t.Run("MixedLegacyAndIndexed", func(t *testing.T) { @@ -106,7 +131,7 @@ func TestBuildProviders(t *testing.T) { cfg.LegacyOpenAI.Key = serpent.String("sk-openai") cfg.LegacyAnthropic.Key = serpent.String("sk-anthropic") - providers, err := BuildProviders(cfg) + providers, err := buildFromEnv(t, cfg) require.NoError(t, err) names := providerNames(providers) @@ -123,7 +148,7 @@ func TestBuildProviders(t *testing.T) { cfg.LegacyBedrock.AccessKey = serpent.String("AKID") cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret") - providers, err := BuildProviders(cfg) + providers, err := buildFromEnv(t, cfg) require.NoError(t, err) names := providerNames(providers) @@ -139,7 +164,7 @@ func TestBuildProviders(t *testing.T) { cfg.LegacyBedrock.AccessKey = serpent.String("AKID") cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret") - providers, err := BuildProviders(cfg) + providers, err := buildFromEnv(t, cfg) require.NoError(t, err) require.Len(t, providers, 1) @@ -150,15 +175,18 @@ func TestBuildProviders(t *testing.T) { t.Run("UnknownType", func(t *testing.T) { t.Parallel() + // Unknown provider types are dropped by the seed step (logged + // and skipped) so one misconfigured row cannot stop the daemon + // from starting. The end state is "no providers", not an error. cfg := codersdk.AIBridgeConfig{ Providers: []codersdk.AIProviderConfig{ {Type: "gemini", Name: "gemini-pro"}, }, } - _, err := BuildProviders(cfg) - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown provider type") + providers, err := buildFromEnv(t, cfg) + require.NoError(t, err) + assert.Empty(t, providers) }) t.Run("CopilotVariants", func(t *testing.T) { @@ -167,22 +195,25 @@ func TestBuildProviders(t *testing.T) { // Copilot API hosts via an explicit BASE_URL. cfg := codersdk.AIBridgeConfig{ Providers: []codersdk.AIProviderConfig{ - {Type: aibridge.ProviderCopilot, Name: aibridge.ProviderCopilot, DumpDir: "/tmp/copilot-dump"}, + {Type: aibridge.ProviderCopilot, Name: aibridge.ProviderCopilot}, {Type: aibridge.ProviderCopilot, Name: agplaibridge.ProviderCopilotBusiness, BaseURL: "https://" + agplaibridge.HostCopilotBusiness}, {Type: aibridge.ProviderCopilot, Name: agplaibridge.ProviderCopilotEnterprise, BaseURL: "https://" + agplaibridge.HostCopilotEnterprise}, }, } - providers, err := BuildProviders(cfg) + providers, err := buildFromEnv(t, cfg) require.NoError(t, err) require.Len(t, providers, 3) - assert.Equal(t, aibridge.ProviderCopilot, providers[0].Name()) - assert.Equal(t, "/tmp/copilot-dump", providers[0].APIDumpDir()) - assert.Equal(t, agplaibridge.ProviderCopilotBusiness, providers[1].Name()) - assert.Equal(t, "https://"+agplaibridge.HostCopilotBusiness, providers[1].BaseURL()) - assert.Equal(t, agplaibridge.ProviderCopilotEnterprise, providers[2].Name()) - assert.Equal(t, "https://"+agplaibridge.HostCopilotEnterprise, providers[2].BaseURL()) + byName := make(map[string]aibridge.Provider, len(providers)) + for _, p := range providers { + byName[p.Name()] = p + } + require.Contains(t, byName, aibridge.ProviderCopilot) + require.Contains(t, byName, agplaibridge.ProviderCopilotBusiness) + require.Contains(t, byName, agplaibridge.ProviderCopilotEnterprise) + assert.Equal(t, "https://"+agplaibridge.HostCopilotBusiness, byName[agplaibridge.ProviderCopilotBusiness].BaseURL()) + assert.Equal(t, "https://"+agplaibridge.HostCopilotEnterprise, byName[agplaibridge.ProviderCopilotEnterprise].BaseURL()) }) t.Run("ChatGPTProvider", func(t *testing.T) { @@ -191,17 +222,158 @@ func TestBuildProviders(t *testing.T) { // base URL. Admins configure it as an indexed openai provider. cfg := codersdk.AIBridgeConfig{ Providers: []codersdk.AIProviderConfig{ - {Type: aibridge.ProviderOpenAI, Name: agplaibridge.ProviderChatGPT, BaseURL: agplaibridge.BaseURLChatGPT}, + {Type: aibridge.ProviderOpenAI, Name: agplaibridge.ProviderChatGPT, Keys: []string{"sk-chatgpt"}, BaseURL: agplaibridge.BaseURLChatGPT}, }, } - providers, err := BuildProviders(cfg) + providers, err := buildFromEnv(t, cfg) require.NoError(t, err) require.Len(t, providers, 1) assert.Equal(t, agplaibridge.ProviderChatGPT, providers[0].Name()) assert.Equal(t, agplaibridge.BaseURLChatGPT, providers[0].BaseURL()) }) + + t.Run("NativeAnthropicDefaultBaseURL", func(t *testing.T) { + t.Parallel() + row := database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Name: aibridge.ProviderAnthropic, + BaseUrl: "https://api.anthropic.com/", + } + assert.Nil(t, bedrockConfigFromRow(row, codersdk.AIProviderSettings{})) + }) + + t.Run("NativeAnthropicCustomBaseURL", func(t *testing.T) { + t.Parallel() + row := database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Name: "anthropic-proxy", + BaseUrl: "https://internal-proxy.example.com/anthropic/", + } + assert.Nil(t, bedrockConfigFromRow(row, codersdk.AIProviderSettings{})) + }) + + t.Run("BedrockSettingsPresent", func(t *testing.T) { + t.Parallel() + accessKey := "AKID" + secret := "secret" + model := "anthropic.claude-3-5-sonnet-20241022-v2:0" + smallModel := "anthropic.claude-3-5-haiku-20241022-v1:0" + row := database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Name: "anthropic-bedrock", + BaseUrl: "https://bedrock-runtime.us-west-2.amazonaws.com/", + } + settings := codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-west-2", + AccessKey: &accessKey, + AccessKeySecret: &secret, + Model: model, + SmallFastModel: smallModel, + }, + } + got := bedrockConfigFromRow(row, settings) + require.NotNil(t, got) + assert.Equal(t, row.BaseUrl, got.BaseURL) + assert.Equal(t, "us-west-2", got.Region) + assert.Equal(t, accessKey, got.AccessKey) + assert.Equal(t, secret, got.AccessKeySecret) + assert.Equal(t, model, got.Model) + assert.Equal(t, smallModel, got.SmallFastModel) + }) + + t.Run("BedrockSettingsEmpty", func(t *testing.T) { + t.Parallel() + // A non-nil but zero-valued Bedrock settings blob should not + // produce a Bedrock config; the provider's generic BaseUrl is + // not a Bedrock detection signal. + row := database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Name: "anthropic-empty-bedrock", + BaseUrl: "https://api.anthropic.com/", + } + settings := codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{}, + } + assert.Nil(t, bedrockConfigFromRow(row, settings)) + }) +} + +// TestBuildProvidersSkipsBadRows exercises the skip-and-continue path +// directly: rows whose settings blob is malformed or whose type is not +// supported by the runtime builder are logged and excluded from the +// returned snapshot without surfacing a top-level error. The seed path +// filters most of these out before insert, so we bypass it and insert +// rows straight into the database via dbgen. +func TestBuildProvidersSkipsBadRows(t *testing.T) { + t.Parallel() + + t.Run("CorruptSettings", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Name: "anthropic-broken", + BaseUrl: "https://api.anthropic.com/", + Settings: sql.NullString{String: "not-json", Valid: true}, + }) + + providers, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) + require.NoError(t, err) + assert.Empty(t, providers) + }) + + t.Run("UnsupportedType", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + // Azure is a valid DB-level provider type but has no runtime + // builder yet; it must hit the default branch and be skipped. + dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeAzure, + Name: "azure-openai", + BaseUrl: "https://example.openai.azure.com/", + }) + + providers, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) + require.NoError(t, err) + assert.Empty(t, providers) + }) + + t.Run("BadRowDoesNotBlockGoodRow", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Name: "anthropic-broken", + BaseUrl: "https://api.anthropic.com/", + Settings: sql.NullString{String: "{not valid json", Valid: true}, + }) + good := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeOpenai, + Name: "openai-good", + BaseUrl: "https://api.openai.com/", + }) + dbgen.AIProviderKey(t, db, database.AIProviderKey{ + ProviderID: good.ID, + APIKey: "sk-good", + }) + + providers, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) + require.NoError(t, err) + require.Len(t, providers, 1) + assert.Equal(t, "openai-good", providers[0].Name()) + }) } func providerNames(providers []aibridge.Provider) []string { diff --git a/cli/server.go b/cli/server.go index 1b2350d931bb3..02e6b16564a0d 100644 --- a/cli/server.go +++ b/cli/server.go @@ -899,6 +899,32 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. if err != nil { return xerrors.Errorf("remove secrets from deployment values: %w", err) } + + // AI provider DB initialization runs synchronously here so + // authorized reads complete before any background goroutine + // starts. Otherwise a mid-startup cancellation can interrupt + // them and fail startup. Seeding must also happen before + // newAPI so the aibridgeproxyd in the enterprise closure + // observes env-configured providers. + // + // This is a once-off operation; once completed, all providers + // will be sourced from the database. + if err := coderd.SeedAIProvidersFromEnv( + ctx, + options.Database, + vals.AI.BridgeConfig, + logger.Named("aibridge.envseed"), + ); err != nil { + return xerrors.Errorf("seed ai providers from env: %w", err) + } + var aibridgeProviders []aibridge.Provider + if vals.AI.BridgeConfig.Enabled.Value() { + aibridgeProviders, err = BuildProviders(ctx, options.Database, vals.AI.BridgeConfig, logger.Named("aibridge.providers")) + if err != nil { + return xerrors.Errorf("build AI providers: %w", err) + } + } + telemetryReporter, err := telemetry.New(telemetry.Options{ Disabled: !vals.Telemetry.Enable.Value(), BuiltinPostgres: builtinPostgres, @@ -1006,18 +1032,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. notificationReportGenerator := reports.NewReportGenerator(ctx, logger.Named("notifications.report_generator"), options.Database, options.NotificationsEnqueuer, quartz.NewReal()) defer notificationReportGenerator.Close() - // Seed providers before newAPI so the aibridgeproxyd inside - // the enterprise closure observes env-configured providers - // at init. - if err := coderd.SeedAIProvidersFromEnv( - ctx, - options.Database, - vals.AI.BridgeConfig, - logger.Named("aibridge.envseed"), - ); err != nil { - return xerrors.Errorf("seed ai providers from env: %w", err) - } - // We use a separate coderAPICloser so the Enterprise API // can have its own close functions. This is cleaner // than abstracting the Coder API itself. @@ -1034,11 +1048,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // unconditionally when the bridge feature is enabled by config so // chatd can use it regardless of license entitlement. if vals.AI.BridgeConfig.Enabled.Value() { - providers, err := BuildProviders(vals.AI.BridgeConfig) - if err != nil { - return xerrors.Errorf("build AI providers: %w", err) - } - aibridgeDaemon, err := newAIBridgeDaemon(coderAPI, providers) + aibridgeDaemon, err := newAIBridgeDaemon(coderAPI, aibridgeProviders) if err != nil { return xerrors.Errorf("create aibridged: %w", err) } @@ -3114,8 +3124,6 @@ func readAIProvidersForPrefix(logger slog.Logger, environ []string, prefix strin } case "BASE_URL": provider.BaseURL = v.Value - case "DUMP_DIR": - provider.DumpDir = v.Value case "BEDROCK_BASE_URL": provider.BedrockBaseURL = v.Value case "BEDROCK_REGION": diff --git a/cli/server_aibridge_internal_test.go b/cli/server_aibridge_internal_test.go index 8afed4c749ad2..1797f1c7edf94 100644 --- a/cli/server_aibridge_internal_test.go +++ b/cli/server_aibridge_internal_test.go @@ -10,8 +10,10 @@ import ( "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" ) func TestReadAIProvidersFromEnv(t *testing.T) { @@ -34,7 +36,6 @@ func TestReadAIProvidersFromEnv(t *testing.T) { "CODER_AIBRIDGE_PROVIDER_0_NAME=anthropic-zdr", "CODER_AIBRIDGE_PROVIDER_0_KEY=sk-ant-xxx", "CODER_AIBRIDGE_PROVIDER_0_BASE_URL=https://api.anthropic.com/", - "CODER_AIBRIDGE_PROVIDER_0_DUMP_DIR=/tmp/aibridge-dump", }, expected: []codersdk.AIProviderConfig{ { @@ -42,7 +43,6 @@ func TestReadAIProvidersFromEnv(t *testing.T) { Name: "anthropic-zdr", Keys: []string{"sk-ant-xxx"}, BaseURL: "https://api.anthropic.com/", - DumpDir: "/tmp/aibridge-dump", }, }, }, @@ -537,3 +537,52 @@ func TestValidateLegacyAIBridgeConfig(t *testing.T) { }) } } + +func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { + t.Parallel() + + const dumpDir = "/tmp/coder-aibridge-dumps" + + tests := []struct { + name string + row database.AIProvider + }{ + { + name: "OpenAI", + row: database.AIProvider{ + Type: database.AiProviderTypeOpenai, + Name: "openai", + BaseUrl: "https://api.openai.com/", + }, + }, + { + name: "Anthropic", + row: database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Name: "anthropic", + BaseUrl: "https://api.anthropic.com/", + }, + }, + { + name: "Copilot", + row: database.AIProvider{ + Type: database.AiProviderTypeCopilot, + Name: "copilot", + BaseUrl: "https://api.githubcopilot.com/", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + provider, err := buildAIProviderFromRow(tt.row, nil, codersdk.AIBridgeConfig{ + AllowBYOK: serpent.Bool(true), + APIDumpDir: serpent.String(dumpDir), + }) + require.NoError(t, err) + assert.Equal(t, dumpDir, provider.APIDumpDir()) + }) + } +} diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 32225cedc7b47..4bcf9efda9e07 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -113,6 +113,12 @@ AI GATEWAY OPTIONS: with AI budgets. "highest" selects the group with the largest spend limit, and is currently the only supported value. + --ai-gateway-dump-dir string, $CODER_AI_GATEWAY_DUMP_DIR + Base directory for dumping AI Bridge request/response pairs to disk + for debugging. When set, each provider writes under a subdirectory + named after the provider. Sensitive headers are redacted. Leave empty + to disable. + --ai-gateway-allow-byok bool, $CODER_AI_GATEWAY_ALLOW_BYOK (default: true) Allow users to provide their own LLM API keys or subscriptions. When disabled, only centralized key authentication is permitted. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index c9f6725210a3d..3e73b7ca3b66a 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -920,6 +920,11 @@ ai_gateway: # X-Ai-Bridge-Actor-Metadata-Username (their username). # (default: false, type: bool) send_actor_headers: false + # Base directory for dumping AI Bridge request/response pairs to disk for + # debugging. When set, each provider writes under a subdirectory named after the + # provider. Sensitive headers are redacted. Leave empty to disable. + # (default: , type: string) + api_dump_dir: "" # Allow users to provide their own LLM API keys or subscriptions. When disabled, # only centralized key authentication is permitted. # (default: true, type: bool) diff --git a/coderd/ai_providers_migrate.go b/coderd/ai_providers_migrate.go index c54cd12e2815c..98cfba2226469 100644 --- a/coderd/ai_providers_migrate.go +++ b/coderd/ai_providers_migrate.go @@ -292,6 +292,11 @@ func providersFromEnv(ctx context.Context, cfg codersdk.AIBridgeConfig, logger s Type: database.AiProviderTypeAnthropic, } if hasLegacyBedrock { + if hasAnthropicKey { + logger.Warn(ctx, "ignoring legacy Anthropic API key because Bedrock credentials are configured; Bedrock authenticates via access keys or credential chain", + slog.F("provider", aibridge.ProviderAnthropic), + ) + } // Bedrock-only deployments use CODER_AIBRIDGE_BEDROCK_BASE_URL // for custom VPC, FIPS, or proxy endpoints. dp.BaseURL = cfg.LegacyBedrock.BaseURL.String() diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index dce2daec4cbb0..ae5715bd78e4b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14450,6 +14450,10 @@ const docTemplate = `{ } ] }, + "api_dump_dir": { + "description": "APIDumpDir is the base directory under which each provider's\nrequest/response dumps are written, in a subdirectory named after\nthe provider. Empty disables dumping.", + "type": "string" + }, "bedrock": { "description": "Deprecated: Use Providers with indexed CODER_AI_GATEWAY_PROVIDER_\u003cN\u003e_* env vars instead.", "allOf": [ @@ -15062,10 +15066,6 @@ const docTemplate = `{ "bedrock_small_fast_model": { "type": "string" }, - "dump_dir": { - "description": "DumpDir is the directory path for dumping API requests and responses.", - "type": "string" - }, "name": { "description": "Name is the unique instance identifier used for routing.\nDefaults to Type if not provided.", "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 88839dc5ac538..21ee879158ea4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12854,6 +12854,10 @@ } ] }, + "api_dump_dir": { + "description": "APIDumpDir is the base directory under which each provider's\nrequest/response dumps are written, in a subdirectory named after\nthe provider. Empty disables dumping.", + "type": "string" + }, "bedrock": { "description": "Deprecated: Use Providers with indexed CODER_AI_GATEWAY_PROVIDER_\u003cN\u003e_* env vars instead.", "allOf": [ @@ -13466,10 +13470,6 @@ "bedrock_small_fast_model": { "type": "string" }, - "dump_dir": { - "description": "DumpDir is the directory path for dumping API requests and responses.", - "type": "string" - }, "name": { "description": "Name is the unique instance identifier used for routing.\nDefaults to Type if not provided.", "type": "string" diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 086a35d229f78..74aa3f9f468a9 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -627,6 +627,7 @@ var ( rbac.ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceAiModelPrice.Type: {policy.ActionUpdate}, // Required for the startup price seeder. rbac.ResourceAiSeat.Type: {policy.ActionCreate}, // Required for UpsertAISeatState. + rbac.ResourceAIProvider.Type: {policy.ActionRead}, // Required to load the provider snapshot (and per-provider keys) at startup. }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 97dcd6e27d72e..dab861590fad7 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1863,6 +1863,16 @@ func (c *DeploymentValues) Options() serpent.OptionSet { Group: &deploymentGroupAIGateway, YAML: "structured_logging", } + aiGatewayAPIDumpDir := serpent.Option{ + Name: "AI Gateway API Dump Directory", + Description: "Base directory for dumping AI Bridge request/response pairs to disk for debugging. When set, each provider writes under a subdirectory named after the provider. Sensitive headers are redacted. Leave empty to disable.", + Flag: "ai-gateway-dump-dir", + Env: "CODER_AI_GATEWAY_DUMP_DIR", + Value: &c.AI.BridgeConfig.APIDumpDir, + Default: "", + Group: &deploymentGroupAIGateway, + YAML: "api_dump_dir", + } aiGatewaySendActorHeaders := serpent.Option{ Name: "AI Gateway Send Actor Headers", Description: "Once enabled, extra headers will be added to upstream requests to identify the user (actor) making requests to AI Gateway. " + @@ -4275,6 +4285,7 @@ Write out the current server config as YAML to stdout.`, UseInstead: serpent.OptionSet{aiGatewaySendActorHeaders}, }, aiGatewaySendActorHeaders, + aiGatewayAPIDumpDir, { Name: "AI Bridge Allow BYOK", Description: "Deprecated: use --ai-gateway-allow-byok or CODER_AI_GATEWAY_ALLOW_BYOK instead. Allow users to provide their own LLM API keys or subscriptions. When disabled, only centralized key authentication is permitted.", @@ -4632,6 +4643,10 @@ type AIBridgeConfig struct { CircuitBreakerInterval serpent.Duration `json:"circuit_breaker_interval" typescript:",notnull"` CircuitBreakerTimeout serpent.Duration `json:"circuit_breaker_timeout" typescript:",notnull"` CircuitBreakerMaxRequests serpent.Int64 `json:"circuit_breaker_max_requests" typescript:",notnull"` + // APIDumpDir is the base directory under which each provider's + // request/response dumps are written, in a subdirectory named after + // the provider. Empty disables dumping. + APIDumpDir serpent.String `json:"api_dump_dir" typescript:",notnull"` } type AIBridgeOpenAIConfig struct { @@ -4669,8 +4684,6 @@ type AIProviderConfig struct { Keys []string `json:"-"` // BaseURL is the base URL of the upstream provider API. BaseURL string `json:"base_url"` - // DumpDir is the directory path for dumping API requests and responses. - DumpDir string `json:"dump_dir,omitempty"` // Bedrock fields (only applicable when Type == "anthropic"). BedrockBaseURL string `json:"-"` diff --git a/docs/ai-coder/ai-gateway/setup.md b/docs/ai-coder/ai-gateway/setup.md index dd95afdaac2d8..27aed7c0506dc 100644 --- a/docs/ai-coder/ai-gateway/setup.md +++ b/docs/ai-coder/ai-gateway/setup.md @@ -218,20 +218,12 @@ requests to `/api/v2/aibridge//` to target a specific instance: **Supported keys per provider:** -| Key | Required | Description | -|------------|----------|-------------------------------------------------------| -| `TYPE` | Yes | Provider type: `openai`, `anthropic`, or `copilot` | -| `NAME` | No | Unique instance name for routing. Defaults to `TYPE` | -| `KEY` | No | API key for upstream authentication (alias: `KEYS`) | -| `BASE_URL` | No | Base URL of the upstream API | -| `DUMP_DIR` | No | Directory for provider API request and response dumps | - -> [!WARNING] -> `DUMP_DIR` is not intended for regular use. Setting this option -> results in a high number of writes. Dump files contain raw request and -> response data, which may include proprietary or sensitive information -> (prompts, completions, tool inputs). Enable only briefly for diagnostic -> purposes and protect the target directory. +| Key | Required | Description | +|------------|----------|------------------------------------------------------| +| `TYPE` | Yes | Provider type: `openai`, `anthropic`, or `copilot` | +| `NAME` | No | Unique instance name for routing. Defaults to `TYPE` | +| `KEY` | No | API key for upstream authentication (alias: `KEYS`) | +| `BASE_URL` | No | Base URL of the upstream API | For `anthropic` providers using AWS Bedrock, the following keys are also available: `BEDROCK_BASE_URL`, `BEDROCK_REGION`, @@ -251,6 +243,39 @@ available: `BEDROCK_BASE_URL`, `BEDROCK_REGION`, > will produce a startup error. Remove one or the other to resolve the > conflict. +## API Dumps + +AI Gateway can dump provider request and response pairs to disk for debugging. +Configure the dump directory with `--ai-gateway-dump-dir` or +`CODER_AI_GATEWAY_DUMP_DIR`: + +```sh +coder server --ai-gateway-dump-dir=/var/lib/coder/ai-gateway-dumps +``` + +Or in YAML: + +```yaml +ai_gateway: + api_dump_dir: /var/lib/coder/ai-gateway-dumps +``` + +This top-level setting replaces the previous per-provider `DUMP_DIR` field. +For each provider, AI Gateway writes dumps under `/`, where +`` is the configured dump directory and `` is the provider +instance name used in the route. For example, a provider named `anthropic-corp` +with `/var/lib/coder/ai-gateway-dumps` configured writes to +`/var/lib/coder/ai-gateway-dumps/anthropic-corp`. + +Sensitive headers are redacted before dumps are written. Leave the value empty +to disable dumping. + +> [!WARNING] +> API dumps are intended for short diagnostic sessions only. Dump files contain +> raw request and response data, which may include proprietary or sensitive +> information such as prompts, completions, and tool inputs. Protect the target +> directory and disable dumping when diagnostics are complete. + ## Data Retention AI Gateway records prompts, token usage, tool invocations, and model reasoning for auditing and diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 57e564aee31bb..02dbfe4135c47 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -185,6 +185,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "base_url": "string", "key": "string" }, + "api_dump_dir": "string", "bedrock": { "access_key": "string", "access_key_secret": "string", @@ -213,7 +214,6 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "bedrock_model": "string", "bedrock_region": "string", "bedrock_small_fast_model": "string", - "dump_dir": "string", "name": "string", "type": "string" } diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index c2cffd13d441b..aaee58512c263 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -442,6 +442,7 @@ "base_url": "string", "key": "string" }, + "api_dump_dir": "string", "bedrock": { "access_key": "string", "access_key_secret": "string", @@ -470,7 +471,6 @@ "bedrock_model": "string", "bedrock_region": "string", "bedrock_small_fast_model": "string", - "dump_dir": "string", "name": "string", "type": "string" } @@ -488,6 +488,7 @@ |-------------------------------------|----------------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `allow_byok` | boolean | false | | | | `anthropic` | [codersdk.AIBridgeAnthropicConfig](#codersdkaibridgeanthropicconfig) | false | | Deprecated: Use Providers with indexed CODER_AI_GATEWAY_PROVIDER__* env vars instead. | +| `api_dump_dir` | string | false | | Api dump dir is the base directory under which each provider's request/response dumps are written, in a subdirectory named after the provider. Empty disables dumping. | | `bedrock` | [codersdk.AIBridgeBedrockConfig](#codersdkaibridgebedrockconfig) | false | | Deprecated: Use Providers with indexed CODER_AI_GATEWAY_PROVIDER__* env vars instead. | | `budget_period` | string | false | | | | `budget_policy` | string | false | | Budget settings for AI Governance cost controls. | @@ -1245,6 +1246,7 @@ "base_url": "string", "key": "string" }, + "api_dump_dir": "string", "bedrock": { "access_key": "string", "access_key_secret": "string", @@ -1273,7 +1275,6 @@ "bedrock_model": "string", "bedrock_region": "string", "bedrock_small_fast_model": "string", - "dump_dir": "string", "name": "string", "type": "string" } @@ -1344,7 +1345,6 @@ "bedrock_model": "string", "bedrock_region": "string", "bedrock_small_fast_model": "string", - "dump_dir": "string", "name": "string", "type": "string" } @@ -1358,7 +1358,6 @@ | `bedrock_model` | string | false | | | | `bedrock_region` | string | false | | | | `bedrock_small_fast_model` | string | false | | | -| `dump_dir` | string | false | | Dump dir is the directory path for dumping API requests and responses. | | `name` | string | false | | Name is the unique instance identifier used for routing. Defaults to Type if not provided. | | `type` | string | false | | Type is the provider type: "openai", "anthropic", or "copilot". | @@ -5706,6 +5705,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "base_url": "string", "key": "string" }, + "api_dump_dir": "string", "bedrock": { "access_key": "string", "access_key_secret": "string", @@ -5734,7 +5734,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "bedrock_model": "string", "bedrock_region": "string", "bedrock_small_fast_model": "string", - "dump_dir": "string", "name": "string", "type": "string" } @@ -6305,6 +6304,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "base_url": "string", "key": "string" }, + "api_dump_dir": "string", "bedrock": { "access_key": "string", "access_key_secret": "string", @@ -6333,7 +6333,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "bedrock_model": "string", "bedrock_region": "string", "bedrock_small_fast_model": "string", - "dump_dir": "string", "name": "string", "type": "string" } diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 79c63a5c9cb6a..de694faa7940f 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1889,6 +1889,16 @@ Emit structured logs for AI Gateway interception records. Use this for exporting Once enabled, extra headers will be added to upstream requests to identify the user (actor) making requests to AI Gateway. This is only needed if you are using a proxy between AI Gateway and an upstream AI provider. This will send X-Ai-Bridge-Actor-Id (the ID of the user making the request) and X-Ai-Bridge-Actor-Metadata-Username (their username). +### --ai-gateway-dump-dir + +| | | +|-------------|-----------------------------------------| +| Type | string | +| Environment | $CODER_AI_GATEWAY_DUMP_DIR | +| YAML | ai_gateway.api_dump_dir | + +Base directory for dumping AI Bridge request/response pairs to disk for debugging. When set, each provider writes under a subdirectory named after the provider. Sensitive headers are redacted. Leave empty to disable. + ### --ai-gateway-allow-byok | | | diff --git a/enterprise/cli/aibridgeproxyd_internal_test.go b/enterprise/cli/aibridgeproxyd_internal_test.go index f95d3414fd53a..f7b25a549c8fa 100644 --- a/enterprise/cli/aibridgeproxyd_internal_test.go +++ b/enterprise/cli/aibridgeproxyd_internal_test.go @@ -6,11 +6,8 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/coder/coder/v2/aibridge" - agplcli "github.com/coder/coder/v2/cli" - "github.com/coder/coder/v2/codersdk" ) func TestDomainsFromProviders(t *testing.T) { @@ -19,14 +16,11 @@ func TestDomainsFromProviders(t *testing.T) { t.Run("ExtractsHostnames", func(t *testing.T) { t.Parallel() - providers, err := agplcli.BuildProviders(codersdk.AIBridgeConfig{ - Providers: []codersdk.AIProviderConfig{ - {Type: aibridge.ProviderOpenAI, Name: "openai", Keys: []string{"k"}}, - {Type: aibridge.ProviderAnthropic, Name: "anthropic", Keys: []string{"k"}}, - {Type: aibridge.ProviderOpenAI, Name: "custom", Keys: []string{"k"}, BaseURL: "https://custom-llm.example.com:8443/api"}, - }, - }) - require.NoError(t, err) + providers := []aibridge.Provider{ + aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "openai", BaseURL: "https://api.openai.com/v1/"}), + aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{Name: "anthropic", BaseURL: "https://api.anthropic.com/"}, nil), + aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "custom", BaseURL: "https://custom-llm.example.com:8443/api"}), + } domains, mapping := domainsFromProviders(providers) @@ -43,13 +37,10 @@ func TestDomainsFromProviders(t *testing.T) { t.Run("DeduplicatesSameHost", func(t *testing.T) { t.Parallel() - providers, err := agplcli.BuildProviders(codersdk.AIBridgeConfig{ - Providers: []codersdk.AIProviderConfig{ - {Type: aibridge.ProviderOpenAI, Name: "first", Keys: []string{"k"}, BaseURL: "https://api.example.com/v1"}, - {Type: aibridge.ProviderOpenAI, Name: "second", Keys: []string{"k"}, BaseURL: "https://api.example.com/v2"}, - }, - }) - require.NoError(t, err) + providers := []aibridge.Provider{ + aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "first", BaseURL: "https://api.example.com/v1"}), + aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "second", BaseURL: "https://api.example.com/v2"}), + } domains, mapping := domainsFromProviders(providers) @@ -68,12 +59,9 @@ func TestDomainsFromProviders(t *testing.T) { t.Run("CaseInsensitive", func(t *testing.T) { t.Parallel() - providers, err := agplcli.BuildProviders(codersdk.AIBridgeConfig{ - Providers: []codersdk.AIProviderConfig{ - {Type: aibridge.ProviderOpenAI, Name: "provider", Keys: []string{"k"}, BaseURL: "https://API.Example.COM/v1"}, - }, - }) - require.NoError(t, err) + providers := []aibridge.Provider{ + aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "provider", BaseURL: "https://API.Example.COM/v1"}), + } domains, mapping := domainsFromProviders(providers) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 0ffc730a37a1a..adba4f92187ce 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -167,7 +167,7 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { // in-memory roundtripper regardless of license); only the proxy // daemon remains enterprise-gated by config. if options.DeploymentValues.AI.BridgeProxyConfig.Enabled.Value() { - providers, err := agplcli.BuildProviders(options.DeploymentValues.AI.BridgeConfig) + providers, err := agplcli.BuildProviders(ctx, options.Database, options.DeploymentValues.AI.BridgeConfig, options.Logger.Named("aibridge.providers")) if err != nil { return nil, nil, xerrors.Errorf("build AI providers: %w", err) } diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 1eab828120938..a9062a426f3d5 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -114,6 +114,12 @@ AI GATEWAY OPTIONS: with AI budgets. "highest" selects the group with the largest spend limit, and is currently the only supported value. + --ai-gateway-dump-dir string, $CODER_AI_GATEWAY_DUMP_DIR + Base directory for dumping AI Bridge request/response pairs to disk + for debugging. When set, each provider writes under a subdirectory + named after the provider. Sensitive headers are redacted. Leave empty + to disable. + --ai-gateway-allow-byok bool, $CODER_AI_GATEWAY_ALLOW_BYOK (default: true) Allow users to provide their own LLM API keys or subscriptions. When disabled, only centralized key authentication is permitted. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index af46758f9fd89..29a28130a15ab 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -82,6 +82,12 @@ export interface AIBridgeConfig { readonly circuit_breaker_interval: number; readonly circuit_breaker_timeout: number; readonly circuit_breaker_max_requests: number; + /** + * APIDumpDir is the base directory under which each provider's + * request/response dumps are written, in a subdirectory named after + * the provider. Empty disables dumping. + */ + readonly api_dump_dir: string; } // From codersdk/aibridge.go @@ -384,10 +390,6 @@ export interface AIProviderConfig { * BaseURL is the base URL of the upstream provider API. */ readonly base_url: string; - /** - * DumpDir is the directory path for dumping API requests and responses. - */ - readonly dump_dir?: string; readonly bedrock_region?: string; readonly bedrock_model?: string; readonly bedrock_small_fast_model?: string; From 8a2f28fa6a2ea8bf755dd7836cf331eead9628d2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 26 May 2026 15:57:48 +0200 Subject: [PATCH 009/249] fix(dogfood/coder): fix install-deps heredoc and /opt/mise ownership (#25678) Co-authored-by: Claude Opus 4.7 (1M context) Signed-off-by: Thomas Kosiewski --- dogfood/coder/main.tf | 17 ++++++++++++++++- dogfood/coder/ubuntu-22.04/Dockerfile.base | 7 ++++++- dogfood/coder/ubuntu-26.04/Dockerfile.base | 7 ++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf index 81475418b9a18..0a5a20c0f7a49 100644 --- a/dogfood/coder/main.tf +++ b/dogfood/coder/main.tf @@ -679,11 +679,26 @@ resource "coder_script" "install-deps" { display_name = "Installing Dependencies" run_on_start = true start_blocks_login = false - script = < Date: Wed, 27 May 2026 00:01:10 +1000 Subject: [PATCH 010/249] fix: resolve `stopWorkspaceIfRunning` common path (#25639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #24333 This was a common setup in `updateWorkspace()` but was not appropriately ported to `changeWorkspaceVersion()`. Some tests have been added also to ensure this works 🙂 Simple smooth and easy. --- site/src/api/api.test.ts | 97 ++++++++++++++++++++++++++++++++++++++++ site/src/api/api.ts | 36 +++++++++------ 2 files changed, 120 insertions(+), 13 deletions(-) diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 5d3abc999db4b..99eb768384782 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -1,4 +1,5 @@ import { + MockProvisionerJob, MockStoppedWorkspace, MockTemplate, MockTemplateVersion2, @@ -275,6 +276,102 @@ describe("api.ts", () => { }); }); + describe("changeWorkspaceVersion", () => { + it("stops workspace before changing version if running", async () => { + vi.spyOn(API, "stopWorkspace").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + transition: "stop", + }); + vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({ + ...MockProvisionerJob, + status: "succeeded", + }); + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); + vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce( + [], + ); + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + template_version_id: MockTemplateVersion2.id, + transition: "start", + }); + + await API.changeWorkspaceVersion(MockWorkspace, MockTemplateVersion2.id); + + expect(API.stopWorkspace).toHaveBeenCalledWith(MockWorkspace.id); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { + transition: "start", + template_version_id: MockTemplateVersion2.id, + rich_parameter_values: [], + }); + }); + + it("does not stop workspace if already stopped", async () => { + vi.spyOn(API, "stopWorkspace"); + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); + vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce( + [], + ); + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + template_version_id: MockTemplateVersion2.id, + transition: "start", + }); + + await API.changeWorkspaceVersion( + MockStoppedWorkspace, + MockTemplateVersion2.id, + ); + + expect(API.stopWorkspace).not.toHaveBeenCalled(); + }); + + it("rejects if stop is canceled", async () => { + vi.spyOn(API, "stopWorkspace").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + transition: "stop", + }); + vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({ + ...MockProvisionerJob, + status: "canceled", + }); + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); + vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce( + [], + ); + vi.spyOn(API, "postWorkspaceBuild"); + + await expect( + API.changeWorkspaceVersion(MockWorkspace, MockTemplateVersion2.id), + ).rejects.toThrow("Workspace stop was canceled"); + expect(API.postWorkspaceBuild).not.toHaveBeenCalled(); + }); + + it("throws MissingBuildParameters for missing params", async () => { + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); + vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([ + MockTemplateVersionParameter1, + { ...MockTemplateVersionParameter2, mutable: false }, + ]); + + let error = new Error(); + try { + await API.changeWorkspaceVersion( + MockStoppedWorkspace, + MockTemplateVersion2.id, + ); + } catch (e) { + error = e as Error; + } + + expect(error).toBeInstanceOf(MissingBuildParameters); + expect((error as MissingBuildParameters).parameters).toEqual([ + MockTemplateVersionParameter1, + { ...MockTemplateVersionParameter2, mutable: false }, + ]); + }); + }); + describe("chat configuration endpoints", () => { it.each<[string, () => Promise, unknown]>([ [ diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 99d3e44e384ff..4fef6cbc169de 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2498,6 +2498,24 @@ class ApiMethods { })); }; + /** + * Stops a workspace if it is currently running and waits for the stop + * to complete. Throws if the stop build is canceled. + */ + private stopWorkspaceIfRunning = async ( + workspace: TypesGen.Workspace, + ): Promise => { + // Workspace is already in a state where it's "stopped". + if (workspace.latest_build.status !== "running") return; + + const stopBuild = await this.stopWorkspace(workspace.id); + const awaitedStopBuild = await this.waitForBuild(stopBuild); + + if (awaitedStopBuild?.status === "canceled") { + throw new Error("Workspace stop was canceled."); + } + }; + /** Steps to change the workspace version * - Get the latest template to access the latest active version * - Get the current build parameters @@ -2505,6 +2523,7 @@ class ApiMethods { * - Update the build parameters and check if there are missed parameters for * the new version * - If there are missing parameters raise an error + * - Stop the workspace if it is already running * - Create a build with the version and updated build parameters */ changeWorkspaceVersion = async ( @@ -2539,6 +2558,8 @@ class ApiMethods { throw new MissingBuildParameters(missingParameters, templateVersionId); } + await this.stopWorkspaceIfRunning(workspace); + return this.postWorkspaceBuild(workspace.id, { transition: "start", template_version_id: templateVersionId, @@ -2553,7 +2574,7 @@ class ApiMethods { * - Update the build parameters and check if there are missed parameters for * the newest version * - If there are missing parameters raise an error - * - Stop the workspace with the current template version if it is already running + * - Stop the workspace if it is already running * - Create a build with the latest version and updated build parameters */ updateWorkspace = async ( @@ -2585,18 +2606,7 @@ class ApiMethods { } } - // Stop the workspace if it is already running. - if (workspace.latest_build.status === "running") { - const stopBuild = await this.stopWorkspace(workspace.id); - const awaitedStopBuild = await this.waitForBuild(stopBuild); - // If the stop is canceled halfway through, we bail. - // This is the same behaviour as restartWorkspace. - if (awaitedStopBuild?.status === "canceled") { - return Promise.reject( - new Error("Workspace stop was canceled, not proceeding with update."), - ); - } - } + await this.stopWorkspaceIfRunning(workspace); try { return await this.postWorkspaceBuild(workspace.id, { From 90d8f56cc27b2fafc8a4b551b1cb6612a785ca6f Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 26 May 2026 17:08:45 +0200 Subject: [PATCH 011/249] fix(site): rename "AI Bridge" to "AI Gateway" in paywall card (#25677) *Disclaimer: implemented by a Coder Agent using Claude Opus 4.6* ## Summary Renames "AI Bridge" to "AI Gateway" in the paywall card shown on the AI Governance settings page when the feature is not entitled. ## Changes In `PaywallAIGovernance.tsx`: - Title: `AI Bridge` -> `AI Gateway` - Description text: `AI Bridge provides...` / `AI Bridge requires...` -> `AI Gateway provides...` / `AI Gateway requires...` - Docs link label: `AI Bridge Docs` -> `AI Gateway Docs` --- site/src/components/Paywall/PaywallAIGovernance.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/site/src/components/Paywall/PaywallAIGovernance.tsx b/site/src/components/Paywall/PaywallAIGovernance.tsx index 0b8b7f9bec400..9aff0257f48d5 100644 --- a/site/src/components/Paywall/PaywallAIGovernance.tsx +++ b/site/src/components/Paywall/PaywallAIGovernance.tsx @@ -19,13 +19,13 @@ const PaywallAIGovernance = () => { - AI Bridge + AI Gateway AI Governance - AI Bridge provides auditable visibility into user prompts and LLM tool - calls from developer tools within Coder Workspaces. AI Bridge requires - a Premium license with AI Governance add-on. + AI Gateway provides auditable visibility into user prompts and LLM + tool calls from developer tools within Coder Workspaces. AI Gateway + requires a Premium license with AI Governance add-on. Learn about AI Governance @@ -49,7 +49,7 @@ const PaywallAIGovernance = () => { rel="noreferrer" className="text-content-link" > - AI Bridge Docs + AI Gateway Docs From e2f01e5531388f63c217b77a83bb931a7a55f52d Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 27 May 2026 01:09:14 +1000 Subject: [PATCH 012/249] chore: prettify agents usage indicator (#25428) Restyles the Agents page usage trigger to match the new quota meter presentation by Tracy. The trigger now shows one compact row per section with the existing severity colors, the same spend icon used in settings, a server icon for workspace quota, and right-aligned counters. The related stories were updated to reflect the new trigger layout. **Before:** image **After:** image Relates to CODAGT-197 --- .../ChatsSidebar/chats/UserSidebarFooter.tsx | 6 +- .../components/UsageIndicator.stories.tsx | 66 ++++++++++++++++--- .../AgentsPage/components/UsageIndicator.tsx | 57 +++++++++++----- 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx index 2eb0ae1970ea4..53533f5dbc9c0 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx @@ -16,11 +16,13 @@ export const UserSidebarFooter: FC = () => { return (
-
+ {/* This footer is resizable, so child sizing must follow its container width instead of the viewport. */} +
diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx index 6f024a07fb92f..dbdb6c4bf4a4b 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; import { useQueryClient } from "react-query"; import { expect, userEvent, within } from "storybook/test"; @@ -57,11 +57,22 @@ const withUnavailableWorkspaceCount = (Story: FC) => { return ; }; -const withUsageIndicatorFrame = (Story: FC) => ( -
- -
-); +// Mirrors the sidebar footer wrapper: a fixed-width container with +// container-type set so the trigger inside reacts to the wrapper's width +// instead of the viewport's. +const withUsageIndicatorFrame = ( + widthClassName = "w-[320px]", + frameTestId?: string, +): Decorator => { + return (Story) => ( +
+ +
+ ); +}; const openUsageMenu = async (canvasElement: HTMLElement) => { const canvas = within(canvasElement); @@ -104,7 +115,7 @@ const meta: Meta = { decorators: [ withAuthProvider, withDashboardProvider, - withUsageIndicatorFrame, + withUsageIndicatorFrame(), ], parameters: { user: MockUserOwner, @@ -167,6 +178,9 @@ export const WorkspaceQuotaOnly: Story = { withWorkspaceCount(3), ], play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("30/100")).toBeVisible(); await openUsageMenu(canvasElement); }, }; @@ -181,12 +195,44 @@ export const UsageAndWorkspaceQuota: Story = { const canvas = within(canvasElement); const progressBars = canvas.getAllByRole("progressbar"); - expect(canvas.getByText("Usage")).toBeInTheDocument(); + expect(canvas.getByRole("button", { name: "Usage" })).toBeVisible(); + expect(canvas.getByText("$12.50")).toBeVisible(); + expect(canvas.getByText("30/100")).toBeVisible(); expect(progressBars.map((bar) => bar.getAttribute("aria-label"))).toEqual([ "Monthly spend usage", "Workspace quota usage", ]); - await userEvent.click(canvas.getByRole("button")); + await openUsageMenu(canvasElement); + }, +}; + +// The tiny story covers the responsive edge case where the trigger keeps +// the bars visible and drops the numeric details. +const expectTriggerContentFits = (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + const frame = canvas.getByTestId("usage-indicator-frame"); + + expect(canvas.getByRole("button", { name: "Usage" })).toBeVisible(); + expect(frame.scrollWidth).toBeLessThanOrEqual(frame.clientWidth); +}; + +const expectTriggerDetailsHidden = (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("$12.50")).not.toBeVisible(); + expect(canvas.getByText("30/100")).not.toBeVisible(); +}; + +export const TriggerTiny: Story = { + decorators: [ + withUsageIndicatorFrame("w-[240px]", "usage-indicator-frame"), + withUsageLimitStatus(limitedUsageStatus()), + withWorkspaceQuota(defaultWorkspaceQuota), + withWorkspaceCount(3), + ], + play: ({ canvasElement }) => { + expectTriggerContentFits(canvasElement); + expectTriggerDetailsHidden(canvasElement); }, }; @@ -220,7 +266,7 @@ export const WorkspaceQuotaWithoutBudget: Story = { name: "Workspace quota usage", }); - expect(canvas.getByText("Workspace quota")).toBeInTheDocument(); + expect(canvas.getByText("20")).toBeInTheDocument(); expect(progressbar).toHaveAttribute("aria-valuenow", "100"); await openUsageMenu(canvasElement); diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.tsx index 2a07878f12b3d..00516e62601fc 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.tsx @@ -1,5 +1,5 @@ import dayjs from "dayjs"; -import { InfoIcon } from "lucide-react"; +import { CoinsIcon, InfoIcon, ServerIcon } from "lucide-react"; import { type FC, Fragment, type ReactNode } from "react"; import { useQuery } from "react-query"; import { Link } from "react-router"; @@ -36,6 +36,8 @@ type UsageSectionData = { progressLabel: string; percent: number; detail: ReactNode; + icon: ReactNode; + summaryValue: string; secondaryDetail?: ReactNode; tooltip?: ReactNode; severity?: UsageSeverity; @@ -79,6 +81,8 @@ export const UsageIndicator: FC = () => { progressLabel: `${periodLabel} spend usage`, percent: getPercent(currentSpend, spendLimit), severity: getSeverity(currentSpend, spendLimit), + icon: , + summaryValue: formatCostMicros(currentSpend), detail: ( <> {formatCostMicros(currentSpend)} of {formatCostMicros(spendLimit)}{" "} @@ -112,6 +116,11 @@ export const UsageIndicator: FC = () => { progressLabel: "Workspace quota usage", percent: getPercent(creditsConsumed, quota.budget), severity: getSeverity(creditsConsumed, quota.budget), + icon: , + summaryValue: + quota.budget > 0 + ? `${formatNumber(creditsConsumed)}/${formatNumber(quota.budget)}` + : formatNumber(creditsConsumed), detail: quotaDetail, tooltip: "Workspaces, stopped or running, may consume credits. Stop or delete unused ones to free quota.", @@ -128,7 +137,7 @@ export const UsageIndicator: FC = () => { const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({ sections, }) => { - const triggerLabel = + const triggerAriaLabel = sections.length > 1 ? "Usage" : (sections[0]?.title ?? "Usage"); return ( @@ -136,11 +145,9 @@ const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({ @@ -166,19 +173,35 @@ const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({ const UsageTriggerProgress: FC<{ sections: readonly UsageSectionData[] }> = ({ sections, }) => { - const size = sections.length > 1 ? "compact" : "default"; - return ( -
+
{sections.map((section) => ( - +
+ + + + {section.summaryValue} + +
))}
); From c56af60d1236079a9bff10561c09111f344a8002 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 26 May 2026 16:19:50 +0100 Subject: [PATCH 013/249] feat(site/src/pages/AgentsPage/components): collapse sequential read file events (#25075) --- .../ConversationTimeline.stories.tsx | 305 ++++++++++++++++-- .../ChatConversation/ConversationTimeline.tsx | 114 +++++-- .../StreamingOutput.stories.tsx | 38 +-- .../ChatConversation/blockUtils.test.ts | 75 ++++- .../components/ChatConversation/blockUtils.ts | 49 ++- .../ChatConversation/messageHelpers.test.ts | 291 ++++++++++++++--- .../ChatConversation/messageHelpers.ts | 91 +++++- .../ChatElements/tools/ReadFileTool.tsx | 85 +++-- .../ChatElements/tools/ReadFilesTool.tsx | 98 ++++++ .../components/ChatElements/tools/Tool.tsx | 24 +- 10 files changed, 1002 insertions(+), 168 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 0e94afc57a2f4..0d004362deb97 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -236,6 +236,104 @@ const buildStoryArgs = (...messages: TypesGen.ChatMessage[]) => ({ parsedMessages: buildMessages(messages), }); +const buildParsedReadFileEntry = ({ + messageId, + toolId, + path, + status, + content = "", + errorMessage, + isError = status === "error", +}: { + messageId: number; + toolId: string; + path: string; + status: "completed" | "error" | "running"; + content?: string; + errorMessage?: string; + isError?: boolean; +}): ParsedMessageEntry => { + const args = { path }; + const result = + content || errorMessage + ? { + ...(content ? { content } : {}), + ...(errorMessage ? { error: errorMessage } : {}), + } + : undefined; + + return { + message: { + ...baseMessage, + id: messageId, + role: "assistant", + content: [ + { + type: "tool-call", + tool_call_id: toolId, + tool_name: "read_file", + args, + }, + ], + }, + parsed: { + markdown: "", + reasoning: "", + toolCalls: [{ id: toolId, name: "read_file", args }], + toolResults: [], + tools: [ + { + id: toolId, + name: "read_file", + args, + result, + isError, + status, + }, + ], + blocks: [{ type: "tool", id: toolId }], + sources: [], + }, + }; +}; + +const buildReadFileExchange = ( + callMessageId: number, + toolId: string, + path: string, + content: string, +): TypesGen.ChatMessage[] => { + const args = { path }; + return [ + { + ...baseMessage, + id: callMessageId, + role: "assistant", + content: [ + { + type: "tool-call", + tool_call_id: toolId, + tool_name: "read_file", + args, + }, + ], + }, + { + ...baseMessage, + id: callMessageId + 1, + role: "tool", + content: [ + { + type: "tool-result", + tool_call_id: toolId, + tool_name: "read_file", + result: { content }, + }, + ], + }, + ]; +}; + const LONG_USER_MESSAGE = [ "This is a deliberately long user message that should stay pinned to the", "right edge while the bubble stops short of filling the entire timeline", @@ -2207,6 +2305,163 @@ export const ThinkingBlockAlwaysCollapsed: Story = { }, }; +export const SequentialReadFilesCollapsed: Story = { + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "assistant", + content: [{ type: "text", text: "I'll inspect the relevant files." }], + }, + ...buildReadFileExchange( + 2, + "read-1", + "site/src/a.ts", + "export const a = 1;", + ), + ...buildReadFileExchange( + 4, + "read-2", + "site/src/b.ts", + "export const b = 2;", + ), + ...buildReadFileExchange( + 6, + "read-3", + "site/src/c.ts", + "export const c = 3;", + ), + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const groupButton = canvas.getByRole("button", { name: /read 3 files/i }); + expect(groupButton).toBeInTheDocument(); + expect( + canvas.queryByRole("button", { name: /read a\.ts/i }), + ).not.toBeInTheDocument(); + await userEvent.click(groupButton); + await waitFor(() => { + expect(canvas.getByRole("button", { name: /read a\.ts/i })).toBeVisible(); + expect(canvas.getByRole("button", { name: /read b\.ts/i })).toBeVisible(); + expect(canvas.getByRole("button", { name: /read c\.ts/i })).toBeVisible(); + }); + const firstFileButton = canvas.getByRole("button", { name: /read a\.ts/i }); + expect(firstFileButton).toHaveAttribute("aria-expanded", "false"); + + await userEvent.click(firstFileButton); + await waitFor(() => { + expect(firstFileButton).toHaveAttribute("aria-expanded", "true"); + }); + }, +}; + +export const SequentialReadFilesEmptyAndErrorStates: Story = { + args: { + ...defaultArgs, + parsedMessages: [ + buildParsedReadFileEntry({ + messageId: 1, + toolId: "read-empty-1", + path: "site/src/empty-a.ts", + status: "completed", + }), + buildParsedReadFileEntry({ + messageId: 2, + toolId: "read-empty-2", + path: "site/src/empty-b.ts", + status: "completed", + }), + ...buildMessages([ + { + ...baseMessage, + id: 3, + role: "assistant", + content: [{ type: "text", text: "Trying a different file set." }], + }, + ]), + buildParsedReadFileEntry({ + messageId: 4, + toolId: "read-error-1", + path: "site/src/missing-a.ts", + status: "error", + errorMessage: "ENOENT: no such file or directory", + }), + buildParsedReadFileEntry({ + messageId: 5, + toolId: "read-error-2", + path: "site/src/missing-b.ts", + status: "error", + errorMessage: "permission denied", + }), + ] satisfies ParsedMessageEntry[], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const buttons = canvas.getAllByRole("button", { name: /read 2 files/i }); + expect(buttons).toHaveLength(2); + + await userEvent.click(buttons[0]); + await waitFor(() => { + expect(canvas.getByText("Read empty-a.ts")).toBeVisible(); + expect(canvas.getByText("Read empty-b.ts")).toBeVisible(); + }); + + await userEvent.click(buttons[1]); + await waitFor(() => { + expect( + canvas.getByRole("button", { name: /read missing-a\.ts/i }), + ).toBeVisible(); + expect( + canvas.getByRole("button", { name: /read missing-b\.ts/i }), + ).toBeVisible(); + }); + + await userEvent.click( + canvas.getByRole("button", { name: /read missing-a\.ts/i }), + ); + await waitFor(() => { + expect( + canvas.getByText("ENOENT: no such file or directory"), + ).toBeVisible(); + }); + }, +}; + +export const SequentialReadFilesRunningState: Story = { + args: { + ...defaultArgs, + parsedMessages: [ + buildParsedReadFileEntry({ + messageId: 1, + toolId: "read-running-1", + path: "site/src/one.ts", + status: "running", + }), + buildParsedReadFileEntry({ + messageId: 2, + toolId: "read-running-2", + path: "site/src/two.ts", + status: "running", + }), + buildParsedReadFileEntry({ + messageId: 3, + toolId: "read-running-3", + path: "site/src/three.ts", + status: "running", + }), + ] satisfies ParsedMessageEntry[], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /reading 3 files/i }), + ).toBeInTheDocument(); + }, +}; + /** Collapsed thinking should visually align with adjacent tool calls. */ export const ThinkingBlockWithToolCall: Story = { parameters: { @@ -2268,14 +2523,16 @@ export const ThinkingBlockWithToolCall: Story = { const toolButton = canvas.getByRole("button", { name: /read package\.json/i, }); - const thinkingContainer = thinkingButton.closest("[data-transcript-row]"); - const toolContainer = toolButton.closest("[data-transcript-row]"); - expect(thinkingContainer).toBeInstanceOf(HTMLElement); - expect(toolContainer).toBeInstanceOf(HTMLElement); - expect(toolContainer?.firstElementChild).not.toHaveAttribute("data-state"); - expect(thinkingContainer?.firstElementChild).not.toHaveAttribute( - "data-state", - ); + const thinkingContainer = + thinkingButton.closest("[data-transcript-row]") ?? thinkingButton; + const toolContainer = + toolButton.closest("[data-transcript-row]") ?? toolButton; + expect( + toolContainer.firstElementChild ?? toolContainer, + ).not.toHaveAttribute("data-state"); + expect( + thinkingContainer.firstElementChild ?? thinkingContainer, + ).not.toHaveAttribute("data-state"); expect( canvas.queryByTestId("assistant-bottom-spacer"), ).not.toBeInTheDocument(); @@ -2362,30 +2619,20 @@ export const ThinkingBlockWithShellTools: Story = { name: /expand process output/i, }); - const thinkingRow = thinkingButton.closest( - "[data-transcript-row]", - )?.firstElementChild; - const executeRow = executeButton.closest( - "[data-transcript-row]", - )?.firstElementChild; - const processOutputRow = processOutputButton.closest( - "[data-transcript-row]", - )?.firstElementChild; - - expect(thinkingRow).toBeInstanceOf(HTMLElement); - expect(executeRow).toBeInstanceOf(HTMLElement); - expect(processOutputRow).toBeInstanceOf(HTMLElement); + const wrappers = [ + thinkingButton.closest("[data-transcript-row]") ?? thinkingButton, + executeButton.closest("[data-transcript-row]") ?? executeButton, + processOutputButton.closest("[data-transcript-row]") ?? + processOutputButton, + ]; - const rowHeights = [thinkingRow, executeRow, processOutputRow].map((row) => - Math.round((row as HTMLElement).getBoundingClientRect().height), + const rows = wrappers.map( + (wrapper) => wrapper.firstElementChild ?? wrapper, + ); + const rowHeights = rows.map((row) => + Math.round(row.getBoundingClientRect().height), ); expect(new Set(rowHeights)).toHaveLength(1); - - const wrappers = [ - thinkingButton.closest("[data-transcript-row]"), - executeButton.closest("[data-transcript-row]"), - processOutputButton.closest("[data-transcript-row]"), - ].map((row) => row as HTMLElement); const gaps = [ Math.round( wrappers[1].getBoundingClientRect().top - diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 5896627f1ba3d..daf1b4c007b52 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -32,6 +32,11 @@ import { Tool, } from "../ChatElements"; import { WebSearchSources } from "../ChatElements/tools"; +import { ReadFilesTool } from "../ChatElements/tools/ReadFilesTool"; +import { + getReadFileToolData, + ReadFileTool, +} from "../ChatElements/tools/ReadFileTool"; import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor"; import { ToolCollapsible } from "../ChatElements/tools/ToolCollapsible"; import { ImageLightbox } from "../ImageLightbox"; @@ -40,8 +45,12 @@ import { AttachmentBlock, type PreviewTextAttachment, } from "./AttachmentBlocks"; +import { groupSequentialReadFileBlocks } from "./blockUtils"; import { FileProbeProvider } from "./FileProbeContext"; -import { deriveMessageDisplayState } from "./messageHelpers"; +import { + deriveMessageDisplayState, + groupSequentialReadFileMessages, +} from "./messageHelpers"; import { getEditableUserMessagePayload } from "./messageParsing"; import { useSmoothStreamingText } from "./SmoothText"; import { getThinkingDisclosureDisplay } from "./thinkingTitle"; @@ -204,6 +213,38 @@ const SmoothedResponse = memo<{ ); }); +const ReadFileTimelineBlock = memo<{ + tools: readonly MergedTool[]; +}>(({ tools }) => { + const [expanded, setExpanded] = useState(false); + const [firstTool] = tools; + if (!firstTool) { + return null; + } + + if (tools.length === 1) { + const readFile = getReadFileToolData(firstTool); + return ( +
+ +
+ ); + } + + return ( + + ); +}); + // Shared block renderer used by both ChatMessageItem (historical // messages) and StreamingOutput (live stream). Encapsulates the // response / thinking / tool / file / sources switch so both @@ -257,16 +298,20 @@ export const BlockList: FC<{ prefQuery.data?.code_diff_display_mode || "auto"; const toolByID = new Map(tools.map((tool) => [tool.id, tool])); + const displayBlocks = groupSequentialReadFileBlocks(blocks, tools); // Pre-compute which tool IDs have a corresponding block so // we can render "remaining" (block-less) tools afterwards. const blockToolIDs = new Set( - blocks - .filter( - (b): b is Extract => - b.type === "tool" && (toolByID.has(b.id) || isStreaming), - ) - .map((b) => b.id), + displayBlocks.flatMap((block) => { + if (block.type === "tool") { + return toolByID.has(block.id) || isStreaming ? [block.id] : []; + } + if (block.type === "tool-group") { + return block.ids; + } + return []; + }), ); const remainingTools = tools.filter((tool) => !blockToolIDs.has(tool.id)); @@ -274,12 +319,13 @@ export const BlockList: FC<{ // A thinking block is actively streaming only when it is the // very last block in the list. Once newer content arrives // (response, tool call, etc.) the thinking phase is over. - const lastBlockIsThinking = - blocks.length > 0 && blocks[blocks.length - 1].type === "thinking"; + const lastDisplayBlockIsThinking = + displayBlocks.length > 0 && + displayBlocks[displayBlocks.length - 1].type === "thinking"; return ( <> - {blocks.map((block, index) => { + {displayBlocks.map((block, index) => { switch (block.type) { case "response": { const responseEl = isStreaming ? ( @@ -311,8 +357,8 @@ export const BlockList: FC<{ text={block.text} isStreaming={ isStreaming && - lastBlockIsThinking && - index === blocks.length - 1 + lastDisplayBlockIsThinking && + index === displayBlocks.length - 1 } urlTransform={urlTransform} thinkingDisplayMode={thinkingDisplayMode} @@ -332,6 +378,21 @@ export const BlockList: FC<{
); + case "tool-group": { + const groupTools = block.ids + .map((id) => toolByID.get(id)) + .filter((tool) => tool !== undefined); + const [firstGroupTool] = groupTools; + if (!firstGroupTool) { + return null; + } + return ( + + ); + } case "tool": { const tool = toolByID.get(block.id); if (!tool) { @@ -354,6 +415,9 @@ export const BlockList: FC<{ /> ); } + if (tool.name === "read_file") { + return ; + } return ( (parsedMessages.length).fill(false); - let nextVisibleIsUser = true; // no next visible => treat as chain end - for (let i = parsedMessages.length - 1; i >= 0; i--) { - const entry = parsedMessages[i]; - const { shouldHide } = deriveMessageDisplayState({ - message: entry.message, - parsed: entry.parsed, - hideActions: false, - hasActiveStream: false, - isAwaitingFirstStreamChunk: false, - }); + const flags = new Array(displayMessages.length).fill(false); + let nextVisibleIsUser = true; + for (let i = displayMessages.length - 1; i >= 0; i--) { + const entry = displayMessages[i]; if (entry.message.role !== "user") { flags[i] = nextVisibleIsUser; } - if (!shouldHide) { - nextVisibleIsUser = entry.message.role === "user"; - } + nextVisibleIsUser = entry.message.role === "user"; } return flags; } @@ -1086,7 +1141,8 @@ export const ConversationTimeline = memo( }); }; - const lastInChainFlags = computeLastInChainFlags(parsedMessages); + const displayMessages = groupSequentialReadFileMessages(parsedMessages); + const lastInChainFlags = computeLastInChainFlags(displayMessages); if (parsedMessages.length === 0) { return null; @@ -1178,7 +1234,7 @@ export const ConversationTimeline = memo( data-testid="conversation-timeline" className="flex flex-col gap-2" > - {parsedMessages.map(({ message, parsed }, msgIdx) => { + {displayMessages.map(({ message, parsed }, msgIdx) => { if (message.role === "user") { const { shouldHide } = deriveMessageDisplayState({ message, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx index 44922f69c24f1..e78762dd6c83a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx @@ -281,32 +281,28 @@ export const ThinkingDuringStreamingWithToolCalls: Story = { // Tool-only stream chunks can otherwise clear the activity indicator before text arrives. expect(canvas.getAllByText("Thinking").length).toBeGreaterThanOrEqual(1); - const toolCallWrappers = Array.from( - canvasElement.querySelectorAll("[data-transcript-row]"), - ); - expect(toolCallWrappers).toHaveLength(3); + const executeButton = canvas.getByRole("button", { + name: /collapse command/i, + }); + const readFileLabel = canvas.getByText(/reading README\.md/i); + const thinkingText = canvas.getAllByText("Thinking").at(-1); + expect(thinkingText).toBeInstanceOf(HTMLElement); - const thinkingWrapper = toolCallWrappers.at(-1); - const previousToolWrapper = toolCallWrappers.at(-2); - expect(thinkingWrapper).toBeInstanceOf(HTMLElement); - expect(previousToolWrapper).toBeInstanceOf(HTMLElement); - expect(thinkingWrapper).toHaveTextContent("Thinking"); + const wrappers = [ + executeButton.closest("[data-transcript-row]") ?? executeButton, + readFileLabel.closest("[data-tool-call]") ?? readFileLabel, + (thinkingText as HTMLElement).closest("[data-transcript-row]") ?? + (thinkingText as HTMLElement), + ]; + expect(wrappers.at(-1)).toHaveTextContent("Thinking"); const gap = Math.round( - (thinkingWrapper as HTMLElement).getBoundingClientRect().top - - (previousToolWrapper as HTMLElement).getBoundingClientRect().bottom, + wrappers[2].getBoundingClientRect().top - + wrappers[1].getBoundingClientRect().bottom, ); expect(gap).toBe(8); - // The placeholder inner row must match the committed collapsed - // Thinking row height so the transition from streaming to settled - // does not jump. ToolCollapsible enforces min-h-6 (24px). - const placeholderRow = (thinkingWrapper as HTMLElement).firstElementChild; - expect(placeholderRow).toBeInstanceOf(HTMLElement); - expect( - Math.round( - (placeholderRow as HTMLElement).getBoundingClientRect().height, - ), - ).toBe(24); + const placeholderRow = wrappers[2].firstElementChild ?? wrappers[2]; + expect(Math.round(placeholderRow.getBoundingClientRect().height)).toBe(24); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts index 16955b4b4557d..b6e3c50fe1014 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { appendTextBlock, asNonEmptyString } from "./blockUtils"; -import type { RenderBlock } from "./types"; +import { + appendTextBlock, + asNonEmptyString, + groupSequentialReadFileBlocks, +} from "./blockUtils"; +import type { MergedTool, RenderBlock } from "./types"; // --------------------------------------------------------------------------- // asNonEmptyString @@ -96,3 +100,70 @@ describe("appendTextBlock", () => { expect(result).not.toBe(blocks); }); }); + +describe("groupSequentialReadFileBlocks", () => { + const tool = (id: string, name = "read_file"): MergedTool => ({ + id, + name, + isError: false, + status: "completed", + }); + const tools = [tool("read-1"), tool("read-2"), tool("execute-1", "execute")]; + + it("collapses consecutive read_file tool blocks", () => { + const result = groupSequentialReadFileBlocks( + [ + { type: "tool", id: "read-1" }, + { type: "tool", id: "read-2" }, + ], + tools, + ); + + expect(result).toEqual([ + { + type: "tool-group", + ids: ["read-1", "read-2"], + }, + ]); + }); + + it("leaves a single read_file tool block ungrouped", () => { + const result = groupSequentialReadFileBlocks( + [{ type: "tool", id: "read-1" }], + tools, + ); + + expect(result).toEqual([{ type: "tool", id: "read-1" }]); + }); + + it.each([ + [ + "response content", + [ + { type: "tool", id: "read-1" }, + { type: "response", text: "middle" }, + { type: "tool", id: "read-2" }, + ], + ], + [ + "another tool", + [ + { type: "tool", id: "read-1" }, + { type: "tool", id: "execute-1" }, + { type: "tool", id: "read-2" }, + ], + ], + [ + "an unresolved tool", + [ + { type: "tool", id: "read-1" }, + { type: "tool", id: "missing" }, + { type: "tool", id: "read-2" }, + ], + ], + ] satisfies Array< + [string, RenderBlock[]] + >)("does not collapse read_file blocks across %s", (_, blocks) => { + expect(groupSequentialReadFileBlocks(blocks, tools)).toEqual(blocks); + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts b/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts index 3d13255139146..aeb3442be17e5 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts @@ -1,5 +1,5 @@ import { asString } from "../ChatElements/runtimeTypeUtils"; -import type { RenderBlock } from "./types"; +import type { MergedTool, RenderBlock } from "./types"; export const asNonEmptyString = (value: unknown): string | undefined => { const next = asString(value).trim(); @@ -30,3 +30,50 @@ export const appendTextBlock = ( nextBlocks.push({ type, text }); return nextBlocks; }; + +type ToolGroupRenderBlock = { + type: "tool-group"; + ids: string[]; +}; + +type TimelineRenderBlock = RenderBlock | ToolGroupRenderBlock; + +export const groupSequentialReadFileBlocks = ( + blocks: readonly RenderBlock[], + tools: readonly MergedTool[], +): TimelineRenderBlock[] => { + const toolByID = new Map(tools.map((tool) => [tool.id, tool])); + const grouped: TimelineRenderBlock[] = []; + let currentReadFileIDs: string[] = []; + + const flushReadFileIDs = () => { + if (currentReadFileIDs.length === 0) { + return; + } + if (currentReadFileIDs.length === 1) { + grouped.push({ type: "tool", id: currentReadFileIDs[0] }); + } else { + grouped.push({ + type: "tool-group", + ids: currentReadFileIDs, + }); + } + currentReadFileIDs = []; + }; + + for (const block of blocks) { + if (block.type === "tool") { + const tool = toolByID.get(block.id); + if (tool?.name === "read_file") { + currentReadFileIDs.push(block.id); + continue; + } + } + + flushReadFileIDs(); + grouped.push(block); + } + + flushReadFileIDs(); + return grouped; +}; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts index bd95a6f6ab37f..1503ae373d2ae 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts @@ -1,13 +1,20 @@ import { describe, expect, it } from "vitest"; -import type { ChatMessage, ChatMessagePart } from "#/api/typesGenerated"; -import { deriveMessageDisplayState } from "./messageHelpers"; -import { parseMessagesWithMergedTools } from "./messageParsing"; -import type { ParsedMessageContent } from "./types"; +import type * as TypesGen from "#/api/typesGenerated"; +import { + deriveMessageDisplayState, + groupSequentialReadFileMessages, +} from "./messageHelpers"; +import { parseMessageContent } from "./messageParsing"; +import type { + MergedTool, + ParsedMessageContent, + ParsedMessageEntry, +} from "./types"; const buildMessage = ( - content: ChatMessagePart[], + content: TypesGen.ChatMessagePart[], role: "user" | "assistant" = "user", -): ChatMessage => ({ +): TypesGen.ChatMessage => ({ id: 1, chat_id: "chat-1", created_at: "2026-05-11T00:00:00.000Z", @@ -15,21 +22,135 @@ const buildMessage = ( content, }); -const getParsedMessage = (message: ChatMessage) => - parseMessagesWithMergedTools([message])[0].parsed; - const getDisplayState = ( - message: ChatMessage, + message: TypesGen.ChatMessage, overrides: Partial[0]> = {}, ) => deriveMessageDisplayState({ message, - parsed: getParsedMessage(message), + parsed: parseMessageContent(message.content), hideActions: false, hasActiveStream: false, ...overrides, }); +const baseMessage = { + chat_id: "chat", + created_at: "2026-03-10T00:00:00.000Z", +} as const; + +const parsed = ( + overrides: Partial = {}, +): ParsedMessageContent => ({ + markdown: "", + reasoning: "", + toolCalls: [], + toolResults: [], + tools: [], + blocks: [], + sources: [], + ...overrides, +}); + +const entry = ({ + messageID, + role = "assistant", + content = [], + parsedOverrides, +}: { + messageID: number; + role?: TypesGen.ChatMessageRole; + content?: TypesGen.ChatMessagePart[]; + parsedOverrides: Partial; +}): ParsedMessageEntry => ({ + message: { ...baseMessage, id: messageID, role, content }, + parsed: parsed(parsedOverrides), +}); + +const readFileArgs = (id: string) => ({ path: `${id}.ts` }); + +const readFileTool = (id: string): MergedTool => ({ + id, + name: "read_file", + args: readFileArgs(id), + result: { content: id }, + isError: false, + status: "completed", +}); + +const readFileToolResult = (id: string) => ({ + id, + name: "read_file" as const, + result: { content: id }, + isError: false, +}); + +const readFileMessage = ( + messageID: number, + toolID: string, + parsedOverrides: Partial = {}, +): ParsedMessageEntry => { + const args = readFileArgs(toolID); + return entry({ + messageID, + parsedOverrides: { + toolCalls: [{ id: toolID, name: "read_file", args }], + toolResults: [readFileToolResult(toolID)], + tools: [readFileTool(toolID)], + blocks: [{ type: "tool", id: toolID }], + ...parsedOverrides, + }, + }); +}; + +const hiddenToolResultMessage = ( + messageID: number, + toolID: string, +): ParsedMessageEntry => + entry({ + messageID, + role: "tool", + parsedOverrides: { + toolResults: [readFileToolResult(toolID)], + tools: [readFileTool(toolID)], + blocks: [{ type: "tool", id: toolID }], + }, + }); + +const textMessage = ( + messageID: number, + text: string, + role: TypesGen.ChatMessageRole = "assistant", +): ParsedMessageEntry => + entry({ + messageID, + role, + content: [{ type: "text", text }], + parsedOverrides: { + markdown: text, + blocks: [{ type: "response", text }], + }, + }); + +const executeMessage = (messageID: number): ParsedMessageEntry => { + const args = { command: "pnpm test" }; + const tool: MergedTool = { + id: "execute-1", + name: "execute", + args, + isError: false, + status: "completed", + }; + return entry({ + messageID, + parsedOverrides: { + toolCalls: [{ id: tool.id, name: tool.name, args }], + tools: [tool], + blocks: [{ type: "tool", id: tool.id }], + }, + }); +}; + describe("deriveMessageDisplayState", () => { it("marks text-only user messages as copyable", () => { const message = buildMessage([{ type: "text", text: "Copy this" }]); @@ -67,7 +188,7 @@ describe("deriveMessageDisplayState", () => { expect(getDisplayState(message).hasCopyableContent).toBe(false); }); - it("shows the assistant spacer for thinking-only messages when no suppressing flags apply", () => { + it("shows the assistant spacer for reasoning messages when no suppressing flags apply", () => { const message = buildMessage( [{ type: "reasoning", text: "I should think before answering." }], "assistant", @@ -76,40 +197,6 @@ describe("deriveMessageDisplayState", () => { expect(getDisplayState(message).needsAssistantBottomSpacer).toBe(true); }); - it("hides the assistant spacer when thinking is followed by a tool call", () => { - const message = buildMessage( - [ - { type: "reasoning", text: "I should think before acting." }, - { - type: "tool-call", - tool_call_id: "tool-1", - tool_name: "execute", - args: { command: "pnpm storybook --no-open" }, - }, - ], - "assistant", - ); - - expect(getDisplayState(message).needsAssistantBottomSpacer).toBe(false); - }); - - it("shows the assistant spacer when thinking is followed by a hidden execute tool", () => { - const message = buildMessage( - [ - { type: "reasoning", text: "I should think before acting." }, - { - type: "tool-call", - tool_call_id: "tool-1", - tool_name: "execute", - args: {}, - }, - ], - "assistant", - ); - - expect(getDisplayState(message).needsAssistantBottomSpacer).toBe(true); - }); - it("suppresses the assistant spacer while awaiting the first stream chunk", () => { const message = buildMessage( [{ type: "reasoning", text: "I should think before answering." }], @@ -187,13 +274,28 @@ describe("deriveMessageDisplayState", () => { "assistant", ); - expect(getDisplayState(message).shouldHide).toBe(false); + expect( + getDisplayState(message, { + parsed: parsed({ + tools: [ + { + id: "tool-1", + name: "execute", + args: { command: "pnpm test" }, + isError: false, + status: "completed", + }, + ], + blocks: [{ type: "tool", id: "tool-1" }], + }), + }).shouldHide, + ).toBe(false); }); it("hides running wait_agent messages until the chat id is available", () => { const message = buildMessage([], "assistant"); - const parsed: ParsedMessageContent = { - ...getParsedMessage(message), + const parsedContent: ParsedMessageContent = { + ...parseMessageContent(message.content), blocks: [{ type: "tool", id: "wait-1" }], tools: [ { @@ -209,10 +311,99 @@ describe("deriveMessageDisplayState", () => { expect( deriveMessageDisplayState({ message, - parsed, + parsed: parsedContent, hideActions: false, hasActiveStream: false, }).shouldHide, ).toBe(true); }); }); + +describe("groupSequentialReadFileMessages", () => { + it("returns a single read_file-only message unchanged", () => { + const readFile = readFileMessage(1, "read-1"); + + const result = groupSequentialReadFileMessages([readFile]); + + expect(result).toHaveLength(1); + expect(result[0]).toBe(readFile); + }); + + it("collapses read_file-only assistant messages across hidden tool results", () => { + const result = groupSequentialReadFileMessages([ + readFileMessage(1, "read-1"), + hiddenToolResultMessage(2, "read-1"), + readFileMessage(3, "read-2"), + hiddenToolResultMessage(4, "read-2"), + ]); + + expect(result).toHaveLength(1); + expect(result[0].message.id).toBe(1); + expect(result[0].parsed.toolCalls).toEqual([ + { id: "read-1", name: "read_file", args: { path: "read-1.ts" } }, + { id: "read-2", name: "read_file", args: { path: "read-2.ts" } }, + ]); + expect(result[0].parsed.toolResults).toEqual([ + readFileToolResult("read-1"), + readFileToolResult("read-2"), + ]); + expect(result[0].parsed.blocks).toEqual([ + { type: "tool", id: "read-1" }, + { type: "tool", id: "read-2" }, + ]); + expect(result[0].parsed.tools.map((tool) => tool.id)).toEqual([ + "read-1", + "read-2", + ]); + }); + + it.each([ + ["assistant", textMessage(2, "middle")], + ["user", textMessage(2, "middle", "user")], + ] satisfies Array< + [string, ParsedMessageEntry] + >)("does not collapse read_file messages across visible %s content", (_, message) => { + const result = groupSequentialReadFileMessages([ + readFileMessage(1, "read-1"), + message, + readFileMessage(3, "read-2"), + ]); + + expect(result.map((entry) => entry.message.id)).toEqual([1, 2, 3]); + expect(result[0].parsed.blocks).toEqual([{ type: "tool", id: "read-1" }]); + expect(result[2].parsed.blocks).toEqual([{ type: "tool", id: "read-2" }]); + }); + + it.each([ + ["markdown", { markdown: "Visible markdown" }], + ["reasoning", { reasoning: "Visible reasoning" }], + [ + "sources", + { + sources: [ + { url: "https://example.com/read-2", title: "Read 2 source" }, + ], + }, + ], + ] satisfies Array< + [string, Partial] + >)("does not collapse read_file messages with visible %s", (_, overrides) => { + const result = groupSequentialReadFileMessages([ + readFileMessage(1, "read-1"), + readFileMessage(2, "read-2", overrides), + readFileMessage(3, "read-3"), + ]); + + expect(result.map((entry) => entry.message.id)).toEqual([1, 2, 3]); + }); + + it("does not collapse read_file messages across another visible tool", () => { + const result = groupSequentialReadFileMessages([ + readFileMessage(1, "read-1"), + executeMessage(2), + readFileMessage(3, "read-2"), + ]); + + expect(result.map((entry) => entry.message.id)).toEqual([1, 2, 3]); + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts index c86624416eb41..6a9da7a589a31 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts @@ -1,6 +1,10 @@ import type * as TypesGen from "#/api/typesGenerated"; import { shouldRenderTool } from "../ChatElements/tools/toolVisibility"; -import type { ParsedMessageContent, RenderBlock } from "./types"; +import type { + ParsedMessageContent, + ParsedMessageEntry, + RenderBlock, +} from "./types"; export type UserInlineRenderBlock = | Extract @@ -119,3 +123,88 @@ export const deriveMessageDisplayState = ({ needsAssistantBottomSpacer, }; }; + +const isReadFileOnlyMessage = (entry: ParsedMessageEntry): boolean => { + if (entry.message.role !== "assistant") { + return false; + } + if ( + entry.parsed.blocks.length === 0 || + entry.parsed.markdown.trim() || + entry.parsed.reasoning.trim() || + entry.parsed.sources.length > 0 + ) { + return false; + } + + const toolByID = new Map(entry.parsed.tools.map((tool) => [tool.id, tool])); + return entry.parsed.blocks.every( + (block) => + block.type === "tool" && toolByID.get(block.id)?.name === "read_file", + ); +}; + +const mergeReadFileMessageGroup = ( + group: readonly ParsedMessageEntry[], +): ParsedMessageEntry => { + if (group.length === 1) { + return group[0]; + } + + const [first] = group; + return { + message: first.message, + parsed: { + markdown: "", + reasoning: "", + toolCalls: group.flatMap((entry) => entry.parsed.toolCalls), + toolResults: group.flatMap((entry) => entry.parsed.toolResults), + tools: group.flatMap((entry) => entry.parsed.tools), + blocks: group.flatMap((entry) => entry.parsed.blocks), + sources: [], + }, + }; +}; + +// Real transcripts place hidden tool-result-only messages between +// sequential read_file assistant messages. Those hidden entries stay +// transparent so the visible timeline reflects one file-reading run instead +// of one row per persisted message. Synthetic grouped entries deliberately +// render from merged parsed fields because their raw message payload still +// belongs to the first persisted message. +export const groupSequentialReadFileMessages = ( + entries: readonly ParsedMessageEntry[], +): ParsedMessageEntry[] => { + const grouped: ParsedMessageEntry[] = []; + let currentReadFileEntries: ParsedMessageEntry[] = []; + + const flushReadFileEntries = () => { + if (currentReadFileEntries.length === 0) { + return; + } + grouped.push(mergeReadFileMessageGroup(currentReadFileEntries)); + currentReadFileEntries = []; + }; + + for (const entry of entries) { + const displayState = deriveMessageDisplayState({ + message: entry.message, + parsed: entry.parsed, + hideActions: false, + hasActiveStream: false, + }); + if (displayState.shouldHide) { + continue; + } + if (isReadFileOnlyMessage(entry)) { + currentReadFileEntries.push(entry); + continue; + } + + flushReadFileEntries(); + grouped.push(entry); + } + + flushReadFileEntries(); + return grouped; +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx index 8d7243fa9189e..2459a846b3d6d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx @@ -8,13 +8,60 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { asRecord, asString } from "../runtimeTypeUtils"; import { ToolCollapsible } from "./ToolCollapsible"; import { DIFFS_FONT_STYLE, getFileViewerOptionsMinimal, + parseArgs, type ToolStatus, } from "./utils"; +const ReadFileContent: React.FC<{ + path: string; + content: string; +}> = ({ path, content }) => { + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + + return ( + + + + ); +}; + +export const getReadFileToolData = ({ + args, + result, + isError, +}: { + args?: unknown; + result?: unknown; + isError: boolean; +}) => { + const parsedArgs = parseArgs(args); + const path = parsedArgs ? asString(parsedArgs.path).trim() : ""; + const rec = asRecord(result); + return { + path: path || "file", + content: rec ? asString(rec.content).trim() : "", + isError, + errorMessage: rec ? asString(rec.error || rec.message) : undefined, + }; +}; + /** * Collapsed-by-default rendering for `read_file` tool calls. Shows * "Read " with a chevron; expanding reveals the file viewer. @@ -25,10 +72,18 @@ export const ReadFileTool: React.FC<{ status: ToolStatus; isError: boolean; errorMessage?: string; -}> = ({ path, content, status, isError, errorMessage }) => { - const theme = useTheme(); - const isDark = theme.palette.mode === "dark"; - const hasContent = content.length > 0; + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; +}> = ({ + path, + content, + status, + isError, + errorMessage, + expanded, + onExpandedChange, +}) => { + const hasContent = content.length > 0 || isError; const isRunning = status === "running"; const filename = path.split("/").pop() || path; const label = isRunning ? `Reading ${filename}…` : `Read ${filename}`; @@ -37,6 +92,8 @@ export const ReadFileTool: React.FC<{ {label} @@ -56,20 +113,12 @@ export const ReadFileTool: React.FC<{ } > - - - + {isError && ( +
+ {errorMessage || "Failed to read file"} +
+ )} + {content.length > 0 && }
); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx new file mode 100644 index 0000000000000..93473bdb73a3b --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx @@ -0,0 +1,98 @@ +import { LoaderIcon, TriangleAlertIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import type { MergedTool } from "../../ChatConversation/types"; +import { getReadFileToolData, ReadFileTool } from "./ReadFileTool"; +import { ToolCollapsible } from "./ToolCollapsible"; + +type ReadFileItem = { + id: string; + path: string; + content: string; + status: MergedTool["status"]; + isError: boolean; + errorMessage?: string; +}; + +const getReadFileItem = (tool: MergedTool): ReadFileItem => ({ + id: tool.id, + status: tool.status, + ...getReadFileToolData(tool), +}); + +export const ReadFilesTool: FC<{ + tools: readonly MergedTool[]; + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; +}> = ({ tools, expanded, onExpandedChange }) => { + const [expandedFileIDs, setExpandedFileIDs] = useState>( + new Set(), + ); + const items = tools.map(getReadFileItem); + const isRunning = tools.some((tool) => tool.status === "running"); + const isError = tools.some((tool) => tool.isError); + const hasContent = items.length > 0; + const label = isRunning + ? `Reading ${tools.length} files…` + : `Read ${tools.length} files`; + const errorMessage = items.find((item) => item.errorMessage)?.errorMessage; + + return ( +
+ + {label} + {isError && ( + + + + + + {errorMessage || "Failed to read one or more files"} + + + )} + {isRunning && ( + + )} + + } + > +
+ {items.map((item) => ( +
+ { + setExpandedFileIDs((previous) => { + const next = new Set(previous); + if (nextExpanded) { + next.add(item.id); + } else { + next.delete(item.id); + } + return next; + }); + }} + /> +
+ ))} +
+
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index e854718a7bdb8..10ea071eda794 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -27,7 +27,7 @@ import { import { ListTemplatesTool } from "./ListTemplatesTool"; import { ProcessOutputTool } from "./ProcessOutputTool"; import { ProposePlanTool } from "./ProposePlanTool"; -import { ReadFileTool } from "./ReadFileTool"; +import { getReadFileToolData, ReadFileTool } from "./ReadFileTool"; import { ReadSkillTool } from "./ReadSkillTool"; import { ReadTemplateTool } from "./ReadTemplateTool"; import { StartWorkspaceTool } from "./StartWorkspaceTool"; @@ -315,22 +315,12 @@ const ReadFileRenderer: FC = ({ args, result, isError, -}) => { - const parsedArgs = parseArgs(args); - const path = parsedArgs ? asString(parsedArgs.path).trim() : ""; - const rec = asRecord(result); - const content = rec ? asString(rec.content).trim() : ""; - - return ( - - ); -}; +}) => ( + +); const ReadSkillRenderer: FC = ({ status, From 8ae732000cdfc89df0245d63bc9320be04104518 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 27 May 2026 02:01:53 +1000 Subject: [PATCH 014/249] feat(site): add UI primitives for the AI settings stack (#25579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was modified by Coder Agents on behalf of Jake Howell. Linear: [DEVEX-355](https://linear.app/coder/issue/DEVEX-355) First PR in a 5-PR stack splitting #25328. Adds the small UI primitives the AI settings stack depends on. - `FormField` accepts a `description` prop and renders a required marker. `aria-describedby` is composed from the description, helper, and error IDs. - `PageHeader` title, subtitle, and caption forward `className` and other intrinsic `h1`/`h2`/`span` props to their root elements. - `AvatarData` gains an opt-in `truncate` prop that clips overflowing title and subtitle with an ellipsis. Off by default so existing consumers passing non-text nodes (icons, badges) do not clip silently. - Bundles the Vercel provider icon (`vercel.svg`) and registers it in `icons.json` and `externalImages.ts`. No new pages or routes here; later PRs in the stack consume these primitives.
Stack 1. **jakehwll/DEVEX-355/01-primitives, primitives (this PR)** 2. jakehwll/DEVEX-355/02-api, API client and query layer 3. jakehwll/DEVEX-355/03-components, provider form components 4. jakehwll/DEVEX-355/04-pages, pages and routes 5. jakehwll/DEVEX-355/05-section, section reshuffle Replaces #25328 once the stack lands.
--- .../components/Avatar/AvatarData.stories.tsx | 10 ++ site/src/components/Avatar/AvatarData.tsx | 27 ++- .../FormField/FormField.stories.tsx | 160 ++++++++++++++++++ site/src/components/FormField/FormField.tsx | 31 +++- site/src/components/PageHeader/PageHeader.tsx | 50 ++++-- .../StarterTemplatePageView.tsx | 2 +- .../pages/TemplatePage/TemplatePageHeader.tsx | 6 +- site/src/theme/externalImages.ts | 1 + site/src/theme/icons.json | 1 + site/static/icon/vercel.svg | 3 + 10 files changed, 269 insertions(+), 22 deletions(-) create mode 100644 site/src/components/FormField/FormField.stories.tsx create mode 100644 site/static/icon/vercel.svg diff --git a/site/src/components/Avatar/AvatarData.stories.tsx b/site/src/components/Avatar/AvatarData.stories.tsx index 22f8cb45d7699..62185254c41cf 100644 --- a/site/src/components/Avatar/AvatarData.stories.tsx +++ b/site/src/components/Avatar/AvatarData.stories.tsx @@ -20,3 +20,13 @@ export const WithImage: Story = { src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4", }, }; + +export const WithLongTitle: Story = { + args: { + truncate: true, + title: "a-workspace-with-an-unreasonably-long-name-that-should-be-clipped", + subtitle: + "and-an-even-longer-organization-or-template-subtitle-that-truncates", + }, + decorators: [(Story) =>
{Story()}
], +}; diff --git a/site/src/components/Avatar/AvatarData.tsx b/site/src/components/Avatar/AvatarData.tsx index 7e2515c9340b6..698e7df608cd0 100644 --- a/site/src/components/Avatar/AvatarData.tsx +++ b/site/src/components/Avatar/AvatarData.tsx @@ -1,5 +1,6 @@ import type { FC, ReactNode } from "react"; import { Avatar } from "#/components/Avatar/Avatar"; +import { cn } from "#/utils/cn"; interface AvatarDataProps { title: ReactNode; @@ -15,6 +16,13 @@ interface AvatarDataProps { * from the title prop if it is a string. */ imgFallbackText?: string; + + /** + * When true, the title and subtitle clip with an ellipsis if they overflow + * the available width. Off by default because callers that pass non-text + * nodes (icons, badges) as `title` would otherwise clip silently. + */ + truncate?: boolean; } export const AvatarData: FC = ({ @@ -23,6 +31,7 @@ export const AvatarData: FC = ({ src, imgFallbackText, avatar, + truncate = false, }) => { if (!avatar) { avatar = ( @@ -38,12 +47,24 @@ export const AvatarData: FC = ({
{avatar} -
- +
+ {title} {subtitle && ( - + {subtitle} )} diff --git a/site/src/components/FormField/FormField.stories.tsx b/site/src/components/FormField/FormField.stories.tsx new file mode 100644 index 0000000000000..1fee8410fc6ce --- /dev/null +++ b/site/src/components/FormField/FormField.stories.tsx @@ -0,0 +1,160 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useFormik } from "formik"; +import type { FC } from "react"; +import { expect, within } from "storybook/test"; +import { FormField } from "./FormField"; + +interface ExampleFormFieldProps { + id?: string; + label: string; + description?: string; + helperText?: string; + required?: boolean; + error?: string; + value?: string; +} + +const ExampleFormField: FC = ({ + id, + label, + description, + helperText, + required, + error, + value = "", +}) => { + const form = useFormik({ + initialValues: { value }, + onSubmit: () => {}, + }); + + return ( + + ); +}; + +const meta: Meta = { + title: "components/FormField", + component: ExampleFormField, + args: { + id: "story-field", + label: "Provider name", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox", { name: /Provider name/ }); + await expect(input).not.toHaveAttribute("aria-describedby"); + await expect(input).not.toHaveAttribute("aria-invalid", "true"); + await expect(canvas.queryByText("*")).not.toBeInTheDocument(); + }, +}; + +export const Required: Story = { + args: { + required: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("*")).toBeVisible(); + }, +}; + +export const WithDescription: Story = { + args: { + description: "Shown to users when selecting this provider.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox", { name: /Provider name/ }); + await expect(input).toHaveAttribute( + "aria-describedby", + "story-field-description", + ); + const description = canvas.getByText( + "Shown to users when selecting this provider.", + ); + await expect(description).toHaveAttribute("id", "story-field-description"); + }, +}; + +export const WithHelperText: Story = { + args: { + helperText: "Lowercase letters and dashes only.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox", { name: /Provider name/ }); + await expect(input).toHaveAttribute( + "aria-describedby", + "story-field-helper", + ); + }, +}; + +export const WithError: Story = { + args: { + error: "Provider name is required.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox", { name: /Provider name/ }); + await expect(input).toHaveAttribute( + "aria-describedby", + "story-field-error", + ); + await expect(input).toHaveAttribute("aria-invalid", "true"); + await expect(canvas.getByText("Provider name is required.")).toBeVisible(); + }, +}; + +export const WithDescriptionAndError: Story = { + args: { + description: "Shown to users when selecting this provider.", + error: "Provider name is required.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox", { name: /Provider name/ }); + await expect(input).toHaveAttribute( + "aria-describedby", + "story-field-description story-field-error", + ); + await expect(input).toHaveAttribute("aria-invalid", "true"); + }, +}; + +export const RequiredWithDescription: Story = { + args: { + required: true, + description: "Shown to users when selecting this provider.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox", { name: /Provider name/ }); + await expect(canvas.getByText("*")).toBeVisible(); + await expect(input).toHaveAttribute( + "aria-describedby", + "story-field-description", + ); + }, +}; diff --git a/site/src/components/FormField/FormField.tsx b/site/src/components/FormField/FormField.tsx index 0f7ba8df16d49..e87eb637d69c9 100644 --- a/site/src/components/FormField/FormField.tsx +++ b/site/src/components/FormField/FormField.tsx @@ -7,11 +7,13 @@ import type { FormHelpers } from "#/utils/formUtils"; type FormFieldProps = React.ComponentPropsWithRef<"input"> & { field: FormHelpers; label: ReactNode; + description?: ReactNode; }; export const FormField: FC = ({ field, label, + description, className, ...inputProps }) => { @@ -19,10 +21,33 @@ export const FormField: FC = ({ const id = inputProps.id ?? generatedId; const errorId = `${id}-error`; const helperId = `${id}-helper`; + const descriptionId = `${id}-description`; + const describedBy = [ + description ? descriptionId : null, + field.error ? errorId : field.helperText ? helperId : null, + ] + .filter(Boolean) + .join(" "); + const required = inputProps.required ?? false; return (
- + + {description && ( +
+ {description} +
+ )} = ({ {...inputProps} id={id} aria-invalid={field.error} - aria-describedby={ - field.error ? errorId : field.helperText ? helperId : undefined - } + aria-describedby={describedBy || undefined} className={cn(field.error && "border-border-destructive", className)} /> {field.error ? ( diff --git a/site/src/components/PageHeader/PageHeader.tsx b/site/src/components/PageHeader/PageHeader.tsx index 0e7852889e8a0..215b88b800924 100644 --- a/site/src/components/PageHeader/PageHeader.tsx +++ b/site/src/components/PageHeader/PageHeader.tsx @@ -1,4 +1,5 @@ -import type { FC, PropsWithChildren, ReactNode } from "react"; +import type React from "react"; +import type { FC, ReactNode } from "react"; import { cn } from "#/utils/cn"; interface PageHeaderProps { @@ -31,32 +32,61 @@ export const PageHeader: FC = ({ ); }; -export const PageHeaderTitle: FC = ({ children }) => { +type PageHeaderTitleProps = React.ComponentPropsWithRef<"h1">; + +export const PageHeaderTitle: FC = ({ + children, + className, + ...props +}) => { return ( -

+

{children}

); }; -interface PageHeaderSubtitleProps { - children?: ReactNode; - condensed?: boolean; -} +type PageHeaderSubtitleProps = React.ComponentPropsWithRef<"h2">; export const PageHeaderSubtitle: FC = ({ children, + className, + ...props }) => { return ( -

+

{children}

); }; -export const PageHeaderCaption: FC = ({ children }) => { +type PageHeaderCaptionProps = React.ComponentPropsWithRef<"span">; + +export const PageHeaderCaption: FC = ({ + children, + className, + ...props +}) => { return ( - + {children} ); diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx index 76e6462aea1f7..f1a7de8f729c3 100644 --- a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx @@ -61,7 +61,7 @@ export const StarterTemplatePageView: FC = ({
{starterTemplate.name} - + {starterTemplate.description}
diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.tsx index f894adc6d29d5..05aa9d87e6689 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.tsx @@ -267,16 +267,14 @@ export const TemplatePageHeader: FC = ({
{template.deprecation_message !== "" ? ( - + {template.deprecation_message} ) : ( template.description !== "" && ( - - {template.description} - + {template.description} ) )}
diff --git a/site/src/theme/externalImages.ts b/site/src/theme/externalImages.ts index 3c60a36bf6e5a..ca2efcd2f614e 100644 --- a/site/src/theme/externalImages.ts +++ b/site/src/theme/externalImages.ts @@ -175,6 +175,7 @@ export const defaultParametersForBuiltinIcons = new Map([ ["/icon/rust.svg", "monochrome"], ["/icon/tasks.svg", "monochrome"], ["/icon/terminal.svg", "monochrome"], + ["/icon/vercel.svg", "whiteWithColor"], ["/icon/widgets.svg", "monochrome"], ["/icon/windsurf.svg", "monochrome"], ["/icon/zed.svg", "monochrome"], diff --git a/site/src/theme/icons.json b/site/src/theme/icons.json index 32df0d6119f30..ec3b21da7db3f 100644 --- a/site/src/theme/icons.json +++ b/site/src/theme/icons.json @@ -139,6 +139,7 @@ "typescript.svg", "ubuntu.svg", "vault.svg", + "vercel.svg", "vsphere.svg", "webstorm.svg", "widgets.svg", diff --git a/site/static/icon/vercel.svg b/site/static/icon/vercel.svg new file mode 100644 index 0000000000000..98ab5dcfe3078 --- /dev/null +++ b/site/static/icon/vercel.svg @@ -0,0 +1,3 @@ + + + From 5d39c833f82830dc3eb00b788f95cae879035d32 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 27 May 2026 02:13:11 +1000 Subject: [PATCH 015/249] feat(site): add AI provider API client and query layer (#25580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Linear: [DEVEX-355](https://linear.app/coder/issue/DEVEX-355) Second PR in a 5-PR stack splitting #25328. Adds the frontend layer that talks to the existing `/api/v2/ai/providers` endpoints already shipped on `main`: - API client: `getAIProviders`, `getAIProvider`, `createAIProvider`, `updateAIProvider`, `deleteAIProvider`. - React Query wrappers in `queries/aiProviders.ts` with a shared key helper and matching cache invalidations. - Mock fixtures for OpenAI, Anthropic, and Bedrock providers in `testHelpers/entities.ts` for stories and unit tests. - `viewAnyAIProvider` registered in `permissions.json` so the existing permissions hook can read it. - `viewAnyAIProvider` added to `canViewDeploymentSettings` so admins who can only manage providers still see the deployment dropdown. The `aiProviders` query module and the per-provider mocks are temporarily added to the `knip` ignore list / annotated with `@lintignore`; the next PRs in the stack consume them and remove the exclusions.
Stack 1. #25579 jakehwll/DEVEX-355/01-primitives, primitives 2. **jakehwll/DEVEX-355/02-api, API client and query layer (this PR)** 3. jakehwll/DEVEX-355/03-components, provider form components 4. jakehwll/DEVEX-355/04-pages, pages and routes 5. jakehwll/DEVEX-355/05-section, section reshuffle Replaces #25328 once the stack lands.
--- site/.knip.jsonc | 4 ++ site/permissions.json | 4 ++ site/src/api/api.ts | 69 +++++++++++++++++++++------ site/src/api/queries/aiProviders.ts | 55 +++++++++++++++++++++ site/src/modules/permissions/index.ts | 3 +- site/src/testHelpers/entities.ts | 69 +++++++++++++++++++++++++++ 6 files changed, 189 insertions(+), 15 deletions(-) create mode 100644 site/src/api/queries/aiProviders.ts diff --git a/site/.knip.jsonc b/site/.knip.jsonc index d12174a6f96e6..dc6b31edbc2f0 100644 --- a/site/.knip.jsonc +++ b/site/.knip.jsonc @@ -11,6 +11,10 @@ "ignore": [ "**/*Generated.ts", "src/api/chatModelOptions.ts", + // TODO(ai-settings): aiProviders.ts queries are staged in PR 2 of the + // AI settings stack; they are consumed by the provider pages in PR 4. + // Remove this exclusion once those pages land. + "src/api/queries/aiProviders.ts", // TODO(devtools): debugPanelUtils.ts is staged in PR 7; its exports are // consumed by the Debug panel components in PRs 8 and 9. Remove this // exclusion once the panel components land. diff --git a/site/permissions.json b/site/permissions.json index 7ec8da4087f34..63c26797b5f11 100644 --- a/site/permissions.json +++ b/site/permissions.json @@ -103,6 +103,10 @@ "object": { "resource_type": "aibridge_interception", "any_org": true }, "action": "read" }, + "viewAnyAIProvider": { + "object": { "resource_type": "ai_provider" }, + "action": "read" + }, "createOAuth2App": { "object": { "resource_type": "oauth2_app" }, "action": "create" diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4fef6cbc169de..8976bf901c6e1 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3090,6 +3090,47 @@ class ApiMethods { const response = await this.axios.get(url); return response.data; }; + + getAIProviders = async (): Promise => { + const response = await this.axios.get( + "/api/v2/ai/providers", + ); + return response.data; + }; + + getAIProvider = async (idOrName: string): Promise => { + const response = await this.axios.get( + `/api/v2/ai/providers/${encodeURIComponent(idOrName)}`, + ); + return response.data; + }; + + createAIProvider = async ( + req: TypesGen.CreateAIProviderRequest, + ): Promise => { + const response = await this.axios.post( + "/api/v2/ai/providers", + req, + ); + return response.data; + }; + + updateAIProvider = async ( + idOrName: string, + req: TypesGen.UpdateAIProviderRequest, + ): Promise => { + const response = await this.axios.patch( + `/api/v2/ai/providers/${encodeURIComponent(idOrName)}`, + req, + ); + return response.data; + }; + + deleteAIProvider = async (idOrName: string): Promise => { + await this.axios.delete( + `/api/v2/ai/providers/${encodeURIComponent(idOrName)}`, + ); + }; } export type TaskFeedbackRating = "good" | "okay" | "bad"; @@ -3162,6 +3203,20 @@ class ExperimentalApiMethods { }; // Chat API methods + getChatACL = async (chatId: string): Promise => { + const response = await this.axios.get( + `/api/experimental/chats/${chatId}/acl`, + ); + return response.data; + }; + + updateChatACL = async ( + chatId: string, + req: TypesGen.UpdateChatACL, + ): Promise => { + await this.axios.patch(`/api/experimental/chats/${chatId}/acl`, req); + }; + getChats = async (req?: { after_id?: string; limit?: number; @@ -3179,20 +3234,6 @@ class ExperimentalApiMethods { ); return response.data; }; - getChatACL = async (chatId: string): Promise => { - const response = await this.axios.get( - `/api/experimental/chats/${chatId}/acl`, - ); - return response.data; - }; - - updateChatACL = async ( - chatId: string, - req: TypesGen.UpdateChatACL, - ): Promise => { - await this.axios.patch(`/api/experimental/chats/${chatId}/acl`, req); - }; - getChatMessages = async ( chatId: string, opts?: { before_id?: number; after_id?: number; limit?: number }, diff --git a/site/src/api/queries/aiProviders.ts b/site/src/api/queries/aiProviders.ts new file mode 100644 index 0000000000000..ad8d722352c32 --- /dev/null +++ b/site/src/api/queries/aiProviders.ts @@ -0,0 +1,55 @@ +import type { QueryClient } from "react-query"; +import { API } from "#/api/api"; +import type { + AIProvider, + CreateAIProviderRequest, + UpdateAIProviderRequest, +} from "#/api/typesGenerated"; + +const aiProvidersListKey = ["ai", "providers"] as const; + +const aiProviderKeyFor = (idOrName: string) => + [...aiProvidersListKey, idOrName] as const; + +export const aiProvidersList = () => ({ + queryKey: aiProvidersListKey, + queryFn: (): Promise => API.getAIProviders(), +}); + +export const aiProvider = (idOrName: string) => ({ + queryKey: aiProviderKeyFor(idOrName), + queryFn: (): Promise => API.getAIProvider(idOrName), +}); + +export const createAIProviderMutation = (queryClient: QueryClient) => ({ + mutationFn: (request: CreateAIProviderRequest): Promise => + API.createAIProvider(request), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: aiProvidersListKey }); + }, +}); + +export const updateAIProviderMutation = ( + queryClient: QueryClient, + idOrName: string, +) => ({ + mutationFn: (request: UpdateAIProviderRequest): Promise => + API.updateAIProvider(idOrName, request), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: aiProvidersListKey }); + await queryClient.invalidateQueries({ + queryKey: aiProviderKeyFor(idOrName), + }); + }, +}); + +export const deleteAIProviderMutation = ( + queryClient: QueryClient, + idOrName: string, +) => ({ + mutationFn: () => API.deleteAIProvider(idOrName), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: aiProvidersListKey }); + queryClient.removeQueries({ queryKey: aiProviderKeyFor(idOrName) }); + }, +}); diff --git a/site/src/modules/permissions/index.ts b/site/src/modules/permissions/index.ts index 1dccce7b622a1..f192ae1a57a54 100644 --- a/site/src/modules/permissions/index.ts +++ b/site/src/modules/permissions/index.ts @@ -25,7 +25,8 @@ export const canViewDeploymentSettings = ( permissions.viewAllUsers || permissions.viewAnyGroup || permissions.viewNotificationTemplate || - permissions.viewOrganizationIDPSyncSettings) + permissions.viewOrganizationIDPSyncSettings || + permissions.viewAnyAIProvider) ); }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 73348109b1cc9..f28637b1c4770 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3298,6 +3298,7 @@ export const MockPermissions: Permissions = { viewAnyIdpSyncSettings: true, viewAnyMembers: true, viewAnyAIBridgeInterception: true, + viewAnyAIProvider: true, createOAuth2App: true, editOAuth2App: true, deleteOAuth2App: true, @@ -3332,6 +3333,7 @@ export const MockNoPermissions: Permissions = { viewAnyIdpSyncSettings: false, viewAnyMembers: false, viewAnyAIBridgeInterception: true, + viewAnyAIProvider: false, createOAuth2App: false, editOAuth2App: false, deleteOAuth2App: false, @@ -5515,3 +5517,70 @@ export const MockSession: TypesGen.AIBridgeSession = { last_prompt: "But *can* I really fix it?", last_active_at: "2026-03-09T10:28:15.03152Z", }; + +/** @lintignore Consumed by component stories landing in the next PR of the AI settings stack. */ +export const MockAIProviderOpenAI: TypesGen.AIProvider = { + id: "7a5d6b6a-5f02-4a9c-9c4e-2b3e2a3d2f01", + type: "openai", + name: "openai", + display_name: "OpenAI", + base_url: "https://api.openai.com", + enabled: false, + api_keys: [ + { + id: "6d7c1f3a-1f0b-4a12-a1b5-0fb1f8e72e01", + masked: "sk-***\u2026***ABCD", + created_at: "2026-05-14T10:00:00Z", + }, + ], + settings: null as unknown as TypesGen.AIProviderSettings, + created_at: "2026-05-14T10:00:00Z", + updated_at: "2026-05-14T10:00:00Z", +}; + +/** @lintignore Consumed by component stories landing in the next PR of the AI settings stack. */ +export const MockAIProviderAnthropic: TypesGen.AIProvider = { + id: "4f81f1ee-37c1-4a37-a9d5-7e0c1c8c0c11", + type: "anthropic", + name: "anthropic", + display_name: "Anthropic", + base_url: "https://api.anthropic.com", + enabled: false, + api_keys: [], + settings: null as unknown as TypesGen.AIProviderSettings, + created_at: "2026-05-14T10:00:00Z", + updated_at: "2026-05-14T10:00:00Z", +}; + +/** + * Bedrock providers come over the wire with `type: "anthropic"` and a + * `settings._type: "bedrock"` discriminator. `isBedrockProvider` and the + * backend (see `coderd/ai_providers.go`) enforce this convention. + * + * @lintignore Consumed by component stories landing in the next PR of the AI settings stack. + */ +export const MockAIProviderBedrock: TypesGen.AIProvider = { + id: "9c2e3b41-2e9f-4c97-9a4f-2e1a3d8f9f21", + type: "anthropic", + name: "bedrock", + display_name: "Bedrock", + base_url: "https://bedrock-runtime.us-east-2.amazonaws.com", + enabled: true, + api_keys: [], + settings: { + _type: "bedrock", + _version: 1, + region: "us-east-2", + model: "anthropic.claude-opus-4-7", + small_fast_model: "anthropic.claude-haiku-4-5", + } as unknown as TypesGen.AIProviderSettings, + created_at: "2026-05-14T10:00:00Z", + updated_at: "2026-05-14T10:00:00Z", +}; + +/** @lintignore Consumed by page stories landing in PR 4 of the AI settings stack. */ +export const MockAIProviders: TypesGen.AIProvider[] = [ + MockAIProviderOpenAI, + MockAIProviderAnthropic, + MockAIProviderBedrock, +]; From 99a00259eb5a3276bbdaa3c358b310b792e7cea1 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 27 May 2026 02:27:41 +1000 Subject: [PATCH 016/249] feat(site): add AI settings provider form components (#25581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Linear: [DEVEX-355](https://linear.app/coder/issue/DEVEX-355) Third PR in a 5-PR stack splitting #25328. Adds the component-level pieces used by the provider management pages landing in the next PR of the stack. - `ProviderForm` + `CredentialField` + a provider type-to-form mapping for reading and editing the per-type credential and config fields, with the form API map covered by unit tests. - `ProviderIcon` resolves the bundled per-provider SVG icons and falls back to a building glyph for unknown types. - `ProviderRow` renders a single provider entry for the list view. - `useUnsavedChangesPrompt` hook intercepts unsaved-form navigation. - Storybook stories for `ProviderForm`, `ProviderIcon`, and `ProviderRow` exercise each provider type and form state and consume the mock providers from PR 2. Stories now consume `MockAIProviderOpenAI` / `Anthropic` / `Bedrock` so their per-mock `@lintignore` tags are removed; the `MockAIProviders` aggregate and the `addableProviderTypes` / `aiProviders` query modules keep their exclusions for the page stories in the next PR.
Stack 1. #25579 jakehwll/DEVEX-355/01-primitives, primitives 2. #25580 jakehwll/DEVEX-355/02-api, API client and query layer 3. **jakehwll/DEVEX-355/03-components, provider form components (this PR)** 4. jakehwll/DEVEX-355/04-pages, pages and routes 5. jakehwll/DEVEX-355/05-section, section reshuffle Replaces #25328 once the stack lands.
--- site/.knip.jsonc | 4 + site/src/hooks/useUnsavedChangesPrompt.ts | 43 ++ .../components/CredentialField.tsx | 84 +++ .../components/ProviderForm.stories.tsx | 162 ++++++ .../ProvidersPage/components/ProviderForm.tsx | 439 +++++++++++++++ .../components/ProviderIcon.stories.tsx | 54 ++ .../ProvidersPage/components/ProviderIcon.tsx | 60 +++ .../components/ProviderRow.stories.tsx | 77 +++ .../ProvidersPage/components/ProviderRow.tsx | 58 ++ .../components/addableProviderTypes.ts | 17 + .../components/providerFormApiMap.test.ts | 507 ++++++++++++++++++ .../components/providerFormApiMap.ts | 253 +++++++++ site/src/testHelpers/entities.ts | 4 - 13 files changed, 1758 insertions(+), 4 deletions(-) create mode 100644 site/src/hooks/useUnsavedChangesPrompt.ts create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts diff --git a/site/.knip.jsonc b/site/.knip.jsonc index dc6b31edbc2f0..d4cedd0c7506d 100644 --- a/site/.knip.jsonc +++ b/site/.knip.jsonc @@ -15,6 +15,10 @@ // AI settings stack; they are consumed by the provider pages in PR 4. // Remove this exclusion once those pages land. "src/api/queries/aiProviders.ts", + // TODO(ai-settings): addableProviderTypes.ts is staged in PR 3 of the + // AI settings stack; its exports are consumed by the provider pages + // in PR 4. Remove this exclusion once those pages land. + "src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts", // TODO(devtools): debugPanelUtils.ts is staged in PR 7; its exports are // consumed by the Debug panel components in PRs 8 and 9. Remove this // exclusion once the panel components land. diff --git a/site/src/hooks/useUnsavedChangesPrompt.ts b/site/src/hooks/useUnsavedChangesPrompt.ts new file mode 100644 index 0000000000000..f5022fc932446 --- /dev/null +++ b/site/src/hooks/useUnsavedChangesPrompt.ts @@ -0,0 +1,43 @@ +import { useEffect } from "react"; +import { useBlocker } from "react-router"; + +type UnsavedChangesPromptState = { + isOpen: boolean; + onCancel: () => void; + onConfirm: () => void; +}; + +/** + * Warns the user before leaving while there are unsaved changes. Pairs a + * `beforeunload` listener for hard navigations (tab close, refresh, address + * bar) with `useBlocker` for in-app navigations. The browser owns the dialog + * for hard navigations; the caller renders one for in-app navigations using + * the returned state. + */ +export const useUnsavedChangesPrompt = ( + enabled: boolean, +): UnsavedChangesPromptState => { + useEffect(() => { + if (!enabled) return; + const onBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + // Older browsers also require a return value to trigger the prompt. + return ""; + }; + window.addEventListener("beforeunload", onBeforeUnload); + return () => { + window.removeEventListener("beforeunload", onBeforeUnload); + }; + }, [enabled]); + + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + enabled && currentLocation.pathname !== nextLocation.pathname, + ); + + return { + isOpen: blocker.state === "blocked", + onCancel: () => blocker.reset?.(), + onConfirm: () => blocker.proceed?.(), + }; +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx new file mode 100644 index 0000000000000..888818f859602 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx @@ -0,0 +1,84 @@ +import { useId } from "react"; +import { Input } from "#/components/Input/Input"; +import { Label } from "#/components/Label/Label"; +import type { FormHelpers } from "#/utils/formUtils"; + +type CredentialFieldProps = { + label: string; + helpers: FormHelpers; + autoComplete?: string; + placeholder?: string; + description?: React.ReactNode; + required?: boolean; + onFocus?: () => void; +}; + +export const CredentialField: React.FC = ({ + label, + helpers, + autoComplete, + placeholder, + description, + required = false, + onFocus, +}) => { + const inputId = useId(); + const errorId = `${inputId}-error`; + const helperId = `${inputId}-helper`; + const descriptionId = `${inputId}-description`; + const describedBy = [ + description ? descriptionId : null, + helpers.error ? errorId : helpers.helperText ? helperId : null, + ] + .filter(Boolean) + .join(" "); + + const labelNode = ( + + ); + + const descriptionNode = description && ( +
+ {description} +
+ ); + + const helperNode = helpers.error ? ( + + {helpers.helperText} + + ) : helpers.helperText ? ( + + {helpers.helperText} + + ) : null; + + const inputNode = ( + + ); + + return ( +
+ {labelNode} + {descriptionNode} + {inputNode} + {helperNode} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx new file mode 100644 index 0000000000000..8fda28fae2644 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -0,0 +1,162 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; +import { ProviderForm } from "./ProviderForm"; + +const meta: Meta = { + title: "pages/AISettingsPage/ProviderForm", + component: ProviderForm, + args: { + editing: false, + isLoading: false, + onSubmit: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const AddAnthropicDefault: Story = {}; + +export const AddOpenAI: Story = { + args: { + initialValues: { + type: "openai", + name: "corporate-openai", + displayName: "Corporate OpenAI", + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-example", + enabled: true, + }, + }, +}; + +export const AddBedrock: Story = { + args: { + initialValues: { + type: "bedrock", + name: "bedrock-prod", + displayName: "Bedrock Prod", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + model: "anthropic.claude-3-5-sonnet-20241022-v2:0", + smallFastModel: "anthropic.claude-3-5-haiku-20241022-v1:0", + accessKey: "AKIAIOSFODNN7EXAMPLE", + accessKeySecret: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + enabled: true, + }, + }, +}; + +export const EditBedrockKeepCredentials: Story = { + args: { + editing: true, + bedrockSavedAccessCredentials: true, + initialValues: { + type: "bedrock", + name: "bedrock", + displayName: "Bedrock", + baseUrl: "https://bedrock-runtime.us-east-2.amazonaws.com", + model: "anthropic.claude-opus-4-7", + smallFastModel: "anthropic.claude-haiku-4-5", + accessKey: "", + accessKeySecret: "", + enabled: true, + }, + }, +}; + +export const EditProvider: Story = { + args: { + editing: true, + openAiAnthropicSavedApiKey: true, + openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD", + initialValues: { + type: "anthropic", + name: "production-anthropic", + displayName: "Production Anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "", + enabled: true, + }, + }, +}; + +export const EditOpenAiAnthropicNoSavedKey: Story = { + args: { + editing: true, + openAiAnthropicSavedApiKey: false, + initialValues: { + type: "anthropic", + name: "production-anthropic", + displayName: "Production Anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "", + enabled: true, + }, + }, +}; + +export const Submitting: Story = { + args: { + isLoading: true, + initialValues: { + type: "openai", + name: "openai", + displayName: "OpenAI", + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-example", + }, + }, +}; + +export const CredentialFocusClear: Story = { + args: { + editing: true, + openAiAnthropicSavedApiKey: true, + openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD", + initialValues: { + type: "anthropic", + name: "production-anthropic", + displayName: "Production Anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "", + enabled: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const apiKeyInput = await canvas.findByLabelText(/api key/i); + expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD"); + await userEvent.click(apiKeyInput); + await waitFor(() => expect(apiKeyInput).toHaveValue("")); + }, +}; +export const UnsavedChangesPrompt: Story = { + args: { + editing: true, + initialValues: { + type: "openai", + name: "corporate-openai", + displayName: "Corporate OpenAI", + baseUrl: "https://api.openai.com/v1", + apiKey: "", + enabled: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Dirty the form by editing the display name. + const displayName = await canvas.findByLabelText(/display name/i); + await userEvent.type(displayName, " Edited"); + // Attempt to leave via the in-form Cancel link. + const cancelLink = canvas.getByRole("link", { name: /cancel/i }); + await userEvent.click(cancelLink); + // The dialog renders in a portal, so search the document. + const dialog = await screen.findByRole("dialog"); + await expect( + within(dialog).getByText("Unsaved changes"), + ).toBeInTheDocument(); + await expect( + within(dialog).getByText(/your updates haven't been saved/i), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx new file mode 100644 index 0000000000000..0a609de2acd68 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -0,0 +1,439 @@ +import { useFormik } from "formik"; +import { TriangleAlertIcon } from "lucide-react"; +import { type FC, useEffect, useRef } from "react"; +import { Link } from "react-router"; +import * as Yup from "yup"; +import type { AIProviderType } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Button } from "#/components/Button/Button"; +import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { Form, FormFields } from "#/components/Form/Form"; +import { FormField } from "#/components/FormField/FormField"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; +import { getFormHelpers } from "#/utils/formUtils"; +import { CredentialField } from "./CredentialField"; + +export type ProviderFormValues = { + type: AIProviderType | ""; + name: string; + displayName: string; + baseUrl: string; + model: string; + smallFastModel: string; + accessKey: string; + accessKeySecret: string; + apiKey: string; + enabled: boolean; +}; + +const HTTP_SCHEME_REGEX = /^https?:\/\//i; +const BEDROCK_CANONICAL_URL_REGEX = + /^https:\/\/bedrock-runtime\.([a-z0-9-]+)\.amazonaws\.com\/?$/i; +const PROVIDER_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +export const SAVED_CREDENTIAL_MASK = "********"; + +export const parseBedrockRegionFromBaseUrl = ( + baseUrl: string, +): string | undefined => { + const match = BEDROCK_CANONICAL_URL_REGEX.exec(baseUrl.trim()); + return match?.[1]?.toLowerCase(); +}; + +const makeNameSchema = (editing: boolean) => + editing + ? Yup.string() + : Yup.string() + .matches( + PROVIDER_NAME_REGEX, + "Name must be lowercase, hyphen-separated (e.g. 'my-anthropic').", + ) + .required("Name is required"); + +// Display name is always optional. The form copy says blank falls back +// to the provider name, and the update API supports clearing the value. +const makeDisplayNameSchema = (_editing: boolean) => Yup.string(); + +const defaultInitialValues: ProviderFormValues = { + type: "anthropic", + name: "", + displayName: "", + baseUrl: "", + model: "", + smallFastModel: "", + accessKey: "", + accessKeySecret: "", + apiKey: "", + enabled: true, +}; + +const providerDefaults: Partial< + Record> +> = { + openai: { name: "openai", baseUrl: "https://api.openai.com/v1/" }, + anthropic: { name: "anthropic", baseUrl: "https://api.anthropic.com" }, + bedrock: { + name: "bedrock", + baseUrl: "https://bedrock-runtime.us-east-2.amazonaws.com", + }, + azure: { + name: "azure", + baseUrl: "https://YOUR-RESOURCE.openai.azure.com/openai/v1", + }, + google: { + name: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", + }, + "openai-compat": { name: "openai-compat", baseUrl: "" }, + openrouter: { name: "openrouter", baseUrl: "https://openrouter.ai/api/v1" }, + vercel: { name: "vercel", baseUrl: "https://ai-gateway.vercel.sh/v1" }, +}; + +const makeOpenAiAnthropicSchema = (editing: boolean) => + Yup.object({ + type: Yup.string() + .oneOf([ + "openai", + "anthropic", + "azure", + "google", + "openai-compat", + "openrouter", + "vercel", + ] as const) + .required(), + name: makeNameSchema(editing), + displayName: makeDisplayNameSchema(editing), + baseUrl: Yup.string() + .url("Endpoint must be a valid URL") + .matches(HTTP_SCHEME_REGEX, "Endpoint must use http or https.") + .required("Endpoint is required"), + apiKey: editing + ? Yup.string() + : Yup.string().required("API key is required"), + enabled: Yup.boolean(), + }); + +const credentialFilled = (value: string | undefined): boolean => { + if (!value) return false; + const trimmed = value.trim(); + return trimmed !== "" && trimmed !== SAVED_CREDENTIAL_MASK; +}; + +const makeBedrockSchema = (editing: boolean) => + Yup.object({ + type: Yup.string() + .oneOf(["bedrock"] as const) + .required(), + name: makeNameSchema(editing), + displayName: makeDisplayNameSchema(editing), + baseUrl: Yup.string() + .url("Endpoint must be a valid URL") + .matches( + BEDROCK_CANONICAL_URL_REGEX, + "Endpoint must be a standard AWS Bedrock URL.", + ) + .required("Endpoint is required"), + apiKey: Yup.string(), + model: Yup.string().required("Model is required"), + smallFastModel: Yup.string().required("Small-fast model is required"), + accessKey: (editing + ? Yup.string() + : Yup.string().required("Access key is required") + ).test( + "access-key-paired", + "Enter both access key and secret to rotate credentials.", + function (value) { + const secret = (this.parent as { accessKeySecret?: string }) + .accessKeySecret; + return !(credentialFilled(secret) && !credentialFilled(value)); + }, + ), + accessKeySecret: (editing + ? Yup.string() + : Yup.string().required("Access key secret is required") + ).test( + "access-key-secret-paired", + "Enter both access key and secret to rotate credentials.", + function (value) { + const accessKey = (this.parent as { accessKey?: string }).accessKey; + return !(credentialFilled(accessKey) && !credentialFilled(value)); + }, + ), + enabled: Yup.boolean(), + }); + +const getProviderFormSchema = (editing: boolean) => + Yup.lazy((value: { type?: AIProviderType } | undefined) => { + switch (value?.type) { + case "openai": + case "anthropic": + case "azure": + case "google": + case "openai-compat": + case "openrouter": + case "vercel": + return makeOpenAiAnthropicSchema(editing); + case "bedrock": + return makeBedrockSchema(editing); + default: + return Yup.object({ + type: Yup.string() + .oneOf([ + "openai", + "anthropic", + "bedrock", + "azure", + "google", + "openai-compat", + "openrouter", + "vercel", + ]) + .required(), + }); + } + }); + +type ProviderFormProps = { + editing?: boolean; + /** When editing Bedrock and the API already has keys, show masked placeholders until cleared. */ + bedrockSavedAccessCredentials?: boolean; + /** When editing openai/anthropic and a key is on file, show a masked placeholder until cleared. */ + openAiAnthropicSavedApiKey?: boolean; + /** Masked rendering of the saved openai/anthropic key (e.g. `sk-***...ABCD`). Falls back to a generic mask when omitted. */ + openAiAnthropicMaskedApiKey?: string; + initialValues?: Partial; + onSubmit?: (values: ProviderFormValues) => void; + isLoading?: boolean; + submitError?: unknown; +}; + +const namePlaceholder = (provider: string) => + providerDefaults[provider as keyof typeof providerDefaults]?.name; + +const apiKeyPlaceholder = (provider: string) => { + switch (provider) { + case "openai": + return "sk-proj-..."; + case "anthropic": + return "sk-ant-..."; + } +}; + +const baseUrlPlaceholder = (provider: string) => + providerDefaults[provider as keyof typeof providerDefaults]?.baseUrl; + +export const ProviderForm: FC = ({ + editing = false, + bedrockSavedAccessCredentials = false, + openAiAnthropicSavedApiKey = false, + openAiAnthropicMaskedApiKey, + initialValues, + onSubmit, + isLoading = false, + submitError, +}) => { + const resolvedType = initialValues?.type ?? defaultInitialValues.type; + const typeDefaults = + providerDefaults[resolvedType as keyof typeof providerDefaults]; + + const form = useFormik({ + initialValues: { + ...defaultInitialValues, + // Layer order: base defaults < type prefills < parent's initialValues. + // Edit overrides prefills with server values; create gets them as-is. + ...(typeDefaults ?? {}), + ...initialValues, + // Seed Bedrock credentials with the mask when on file; focus clears it, + // and a re-submitted "" tells the API mapping to keep the value. + accessKey: bedrockSavedAccessCredentials ? SAVED_CREDENTIAL_MASK : "", + accessKeySecret: bedrockSavedAccessCredentials + ? SAVED_CREDENTIAL_MASK + : "", + // Same pattern for openai/anthropic. Prefer the API-supplied masked + // rendering so the user sees the key's identifying suffix. + apiKey: openAiAnthropicSavedApiKey + ? (openAiAnthropicMaskedApiKey ?? SAVED_CREDENTIAL_MASK) + : "", + }, + validationSchema: getProviderFormSchema(editing), + validateOnMount: true, + onSubmit: onSubmit ?? (() => {}), + }); + const getFieldHelpers = getFormHelpers(form, submitError); + + const typeSelectValue = form.values.type; + + // Clears the field once if it's still showing the seeded mask; + // subsequent focuses are no-ops. + const handleCredentialFocus = ( + field: "apiKey" | "accessKey" | "accessKeySecret", + ) => { + const initial = form.initialValues[field]; + if (form.values[field] === initial && initial !== "") { + void form.setFieldValue(field, ""); + } + }; + + // When the parent's mutation finishes without an error, treat the just- + // submitted values as the new baseline so the unsaved-changes prompt does + // not fire on subsequent navigations. React Query reports a missing error + // as `null`, so a truthy check covers both null and undefined. + const previousIsLoading = useRef(isLoading); + useEffect(() => { + if (previousIsLoading.current && !isLoading && !submitError) { + form.resetForm({ values: form.values }); + } + previousIsLoading.current = isLoading; + }, [isLoading, submitError, form]); + + const unsavedChanges = useUnsavedChangesPrompt( + form.dirty && !form.isSubmitting, + ); + + return ( +
+ + {Boolean(submitError) && } + {typeSelectValue !== "" && typeSelectValue !== "bedrock" && ( + <> +
+ + +
+ + handleCredentialFocus("apiKey")} + autoComplete="new-password" + placeholder={apiKeyPlaceholder(form.values.type)} + /> + + )} + + {typeSelectValue === "bedrock" && ( + <> +
+ + +
+ + In the format of{" "} + + {"https://bedrock-runtime.{region}.amazonaws.com"} + + + } + className="w-full" + placeholder={baseUrlPlaceholder(form.values.type)} + /> +
+ + +
+
+ handleCredentialFocus("accessKey")} + /> + handleCredentialFocus("accessKeySecret")} + autoComplete="new-password" + /> +
+ + )} + +
+ + + + +
+
+ + +

+ Your updates haven't been saved. Leave anyway? +

+
+ } + /> + + ); +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.stories.tsx new file mode 100644 index 0000000000000..dc16ada4807e2 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProviderIcon } from "./ProviderIcon"; + +const meta: Meta = { + title: "pages/AISettingsPage/ProviderIcon", + component: ProviderIcon, +}; + +export default meta; +type Story = StoryObj; + +export const OpenAI: Story = { + args: { + provider: "openai", + }, +}; + +export const Anthropic: Story = { + args: { + provider: "anthropic", + }, +}; + +export const Bedrock: Story = { + args: { + provider: "bedrock", + }, +}; + +export const Azure: Story = { + args: { + provider: "azure", + }, +}; + +export const Google: Story = { + args: { + provider: "google", + }, +}; + +export const Vercel: Story = { + args: { + provider: "vercel", + }, +}; + +// Provider types without a bundled icon (openai-compat, openrouter, or +// anything we don't recognize) render the generic Building2 glyph. +export const Fallback: Story = { + args: { + provider: "openai-compat", + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx new file mode 100644 index 0000000000000..feaf29f8985a5 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx @@ -0,0 +1,60 @@ +import { Building2Icon } from "lucide-react"; +import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; + +type ProviderIconProps = { + provider: string; +}; + +/** @lintignore Consumed by provider pages landing in the next PR of the AI settings stack. */ +export const getProviderIcon = (provider: string): string | undefined => { + switch (provider) { + case "openai": + return "/icon/openai.svg"; + case "anthropic": + return "/icon/anthropic.svg"; + case "bedrock": + return "/icon/aws.svg"; + case "azure": + return "/icon/azure.svg"; + case "google": + return "/icon/google.svg"; + case "vercel": + return "/icon/vercel.svg"; + default: + return undefined; + } +}; + +const getProviderName = (provider: string): string => { + switch (provider) { + case "openai": + return "OpenAI"; + case "anthropic": + return "Anthropic"; + case "bedrock": + return "AWS Bedrock"; + case "azure": + return "Azure OpenAI"; + case "google": + return "Google"; + case "openai-compat": + return "OpenAI-compatible"; + case "openrouter": + return "OpenRouter"; + case "vercel": + return "Vercel"; + default: + return provider || "Unknown provider"; + } +}; + +export const ProviderIcon: React.FC = ({ provider }) => { + const iconSrc = getProviderIcon(provider); + const name = getProviderName(provider); + if (iconSrc === undefined) { + return ( + + ); + } + return ; +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx new file mode 100644 index 0000000000000..902c22c7c5332 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +import { + MockAIProviderAnthropic, + MockAIProviderBedrock, + MockAIProviderOpenAI, +} from "#/testHelpers/entities"; +import { ProviderRow } from "./ProviderRow"; + +const meta: Meta = { + title: "pages/AISettingsPage/ProviderRow", + component: ProviderRow, + args: { + onClick: fn(), + }, + decorators: [ + (Story) => ( + + + + Name + Base URL + + Enabled + + + Open provider + + + + + + +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const OpenAI: Story = { + args: { + provider: MockAIProviderOpenAI, + }, +}; + +export const Anthropic: Story = { + args: { + provider: MockAIProviderAnthropic, + }, +}; + +export const Bedrock: Story = { + args: { + provider: MockAIProviderBedrock, + }, +}; + +export const LongText: Story = { + args: { + provider: { + ...MockAIProviderBedrock, + name: "bedrock12341234bedrock12341234bedrock12341234", + display_name: "thisisacoolexample11", + base_url: + "https://bedrock-runtime.us-east-2.amazonaws.com/very/long/path/segment", + }, + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx new file mode 100644 index 0000000000000..bce69fdb3da31 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx @@ -0,0 +1,58 @@ +import { ChevronRightIcon } from "lucide-react"; +import type { AIProvider } from "#/api/typesGenerated"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Badge } from "#/components/Badge/Badge"; +import { TableCell, TableRow } from "#/components/Table/Table"; +import { useClickableTableRow } from "#/hooks/useClickableTableRow"; +import { ProviderIcon } from "./ProviderIcon"; +import { getProviderDisplayType } from "./providerFormApiMap"; + +type ProviderRowProps = { + provider: AIProvider; + onClick?: () => void; +}; + +export const ProviderRow: React.FC = ({ + provider, + onClick, +}) => { + const clickableProps = useClickableTableRow({ + onClick: () => onClick?.(), + }); + const displayName = provider.display_name || provider.name; + + return ( + + + + + + } + /> + + + + {provider.base_url} + + + + {provider.enabled && Enabled} + + +
+ +
+
+
+ ); +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts new file mode 100644 index 0000000000000..aebf2af08be82 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts @@ -0,0 +1,17 @@ +import type { AIProviderType } from "#/api/typesGenerated"; + +export type AddableProvider = { + value: AIProviderType; + label: string; +}; + +export const addableProviders: readonly AddableProvider[] = [ + { value: "anthropic", label: "Anthropic" }, + { value: "bedrock", label: "AWS Bedrock" }, + { value: "azure", label: "Azure OpenAI" }, + { value: "google", label: "Google" }, + { value: "openai", label: "OpenAI" }, + { value: "openai-compat", label: "OpenAI-compatible" }, + { value: "openrouter", label: "OpenRouter" }, + { value: "vercel", label: "Vercel" }, +]; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts new file mode 100644 index 0000000000000..3f7ab9ca597a3 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts @@ -0,0 +1,507 @@ +import { describe, expect, it } from "vitest"; +import type { AIProvider } from "#/api/typesGenerated"; +import { + MockAIProviderAnthropic, + MockAIProviderBedrock, + MockAIProviderOpenAI, +} from "#/testHelpers/entities"; +import { + type ProviderFormValues, + parseBedrockRegionFromBaseUrl, + SAVED_CREDENTIAL_MASK, +} from "./ProviderForm"; +import { + aiProviderToFormValues, + getProviderDisplayType, + hasBedrockStoredCredentials, + isBedrockProvider, + providerFormValuesToCreate, + providerFormValuesToUpdate, +} from "./providerFormApiMap"; + +const baseOpenAIFormValues: ProviderFormValues = { + type: "openai", + name: "primary-openai", + displayName: "Primary OpenAI", + baseUrl: "https://api.openai.com", + model: "", + smallFastModel: "", + accessKey: "", + accessKeySecret: "", + apiKey: "sk-test", + enabled: true, +}; + +const baseBedrockFormValues: ProviderFormValues = { + type: "bedrock", + name: "primary-bedrock", + displayName: "Primary Bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + model: "anthropic.claude-sonnet-4-5", + smallFastModel: "anthropic.claude-haiku-4-5", + accessKey: "AKIA-test", + accessKeySecret: "secret", + apiKey: "", + enabled: true, +}; + +// Cast a plain object to AIProvider's discriminated `settings` shape; +// the generated TS interface is empty and the wire form carries the +// discriminator keys flattened in alongside the variant fields. +const settings = (raw: Record): AIProvider["settings"] => + raw as unknown as AIProvider["settings"]; + +describe("parseBedrockRegionFromBaseUrl", () => { + it("extracts the region from a canonical AWS Bedrock URL", () => { + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-runtime.us-east-1.amazonaws.com", + ), + ).toBe("us-east-1"); + }); + + it("accepts a trailing slash", () => { + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-runtime.us-west-2.amazonaws.com/", + ), + ).toBe("us-west-2"); + }); + + it("lowercases the region", () => { + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-runtime.US-EAST-1.amazonaws.com", + ), + ).toBe("us-east-1"); + }); + + it("trims surrounding whitespace before matching", () => { + expect( + parseBedrockRegionFromBaseUrl( + " https://bedrock-runtime.us-east-1.amazonaws.com ", + ), + ).toBe("us-east-1"); + }); + + it("returns undefined for a non-AWS URL", () => { + expect( + parseBedrockRegionFromBaseUrl("https://bedrock.internal.example.com"), + ).toBeUndefined(); + }); + + it("returns undefined for an empty string", () => { + expect(parseBedrockRegionFromBaseUrl("")).toBeUndefined(); + }); + + it("returns undefined for an http (non-https) URL", () => { + expect( + parseBedrockRegionFromBaseUrl( + "http://bedrock-runtime.us-east-1.amazonaws.com", + ), + ).toBeUndefined(); + }); + + it("returns undefined for a URL with a path", () => { + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-runtime.us-east-1.amazonaws.com/v1/something", + ), + ).toBeUndefined(); + }); + + it("returns undefined for the China partition (different TLD)", () => { + // AWS China uses *.amazonaws.com.cn, which the canonical regex does + // not match by design; cn-* users get the explicit Region input. + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-runtime.cn-north-1.amazonaws.com.cn", + ), + ).toBeUndefined(); + }); +}); + +describe("isBedrockProvider", () => { + it("recognises a discriminated bedrock provider", () => { + expect(isBedrockProvider(MockAIProviderBedrock)).toBe(true); + }); + + it("rejects an OpenAI provider", () => { + expect(isBedrockProvider(MockAIProviderOpenAI)).toBe(false); + }); + + it("rejects an anthropic provider whose settings are null", () => { + // MockAIProviderAnthropic carries `settings: null`, which the Go + // server emits when there is no type-specific config. The helper + // must null-check before reading `_type`. + expect(isBedrockProvider(MockAIProviderAnthropic)).toBe(false); + }); + + it("rejects an anthropic provider whose settings carry a different discriminator", () => { + const provider: AIProvider = { + ...MockAIProviderAnthropic, + settings: settings({ _type: "copilot", _version: 1 }), + }; + expect(isBedrockProvider(provider)).toBe(false); + }); +}); + +describe("hasBedrockStoredCredentials", () => { + it("is true whenever the provider is Bedrock", () => { + // Bedrock secrets are write-only, so we cannot inspect their + // presence; the helper assumes any persisted Bedrock config + // implies credentials are on file. + expect(hasBedrockStoredCredentials(MockAIProviderBedrock)).toBe(true); + }); + + it("is false for non-Bedrock providers", () => { + expect(hasBedrockStoredCredentials(MockAIProviderOpenAI)).toBe(false); + expect(hasBedrockStoredCredentials(MockAIProviderAnthropic)).toBe(false); + }); +}); + +describe("getProviderDisplayType", () => { + it("returns bedrock for a Bedrock provider", () => { + expect(getProviderDisplayType(MockAIProviderBedrock)).toBe("bedrock"); + }); + + it("returns anthropic for a non-Bedrock Anthropic provider", () => { + expect(getProviderDisplayType(MockAIProviderAnthropic)).toBe("anthropic"); + }); + + it("returns openai for the canonical OpenAI host", () => { + expect(getProviderDisplayType(MockAIProviderOpenAI)).toBe("openai"); + }); + + it.each([ + ["azure", "https://my-resource.openai.azure.com/openai/v1"], + ["azure", "https://YOUR-RESOURCE.openai.azure.com/openai/v1"], + ["google", "https://generativelanguage.googleapis.com/v1beta/openai/"], + ["openrouter", "https://openrouter.ai/api/v1"], + ["vercel", "https://ai-gateway.vercel.sh/v1"], + ])("recovers the %s preset from a canonical base_url", (expected, baseUrl) => { + const provider: AIProvider = { + ...MockAIProviderOpenAI, + base_url: baseUrl, + }; + expect(getProviderDisplayType(provider)).toBe(expected); + }); + + it("falls back to the wire type for an unrecognized base_url", () => { + // Internal proxies and custom OpenAI-compatible endpoints keep the + // OpenAI glyph rather than dropping to a question mark. + const provider: AIProvider = { + ...MockAIProviderOpenAI, + base_url: "https://llm-proxy.internal.example.com/v1", + }; + expect(getProviderDisplayType(provider)).toBe("openai"); + }); + + it("falls back to the wire type when base_url is not a parseable URL", () => { + const provider: AIProvider = { + ...MockAIProviderOpenAI, + base_url: "not a url", + }; + expect(getProviderDisplayType(provider)).toBe("openai"); + }); +}); + +describe("providerFormValuesToCreate", () => { + describe("OpenAI/Anthropic", () => { + it("sends a plaintext API key in the api_keys list", () => { + const req = providerFormValuesToCreate(baseOpenAIFormValues); + expect(req.type).toBe("openai"); + expect(req.api_keys).toEqual(["sk-test"]); + }); + + it("omits api_keys when the user did not type a key", () => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + apiKey: "", + }); + expect(req.api_keys).toBeUndefined(); + }); + + it("omits api_keys when the value is only whitespace", () => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + apiKey: " ", + }); + expect(req.api_keys).toBeUndefined(); + }); + + it("does not round-trip the saved-credential mask back to the API", () => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + apiKey: SAVED_CREDENTIAL_MASK, + }); + expect(req.api_keys).toBeUndefined(); + }); + + it("omits display_name when blank so the server stores NULL", () => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + displayName: "", + }); + expect(req.display_name).toBeUndefined(); + }); + + it("trims whitespace from name and baseUrl", () => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + name: " primary-openai ", + baseUrl: " https://api.openai.com ", + }); + expect(req.name).toBe("primary-openai"); + expect(req.base_url).toBe("https://api.openai.com"); + }); + + it.each([ + ["azure", "https://YOUR-RESOURCE.openai.azure.com/openai/v1"], + ["google", "https://generativelanguage.googleapis.com/v1beta/openai/"], + ["openai-compat", "https://compat.example.com/v1"], + ["openrouter", "https://openrouter.ai/api/v1"], + ["vercel", "https://ai-gateway.vercel.sh/v1"], + ] as const)("collapses the %s UI type to type=openai on the wire", (type, baseUrl) => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + type, + baseUrl, + }); + expect(req.type).toBe("openai"); + expect(req.base_url).toBe(baseUrl); + expect(req.api_keys).toEqual(["sk-test"]); + }); + + it("rejects an empty type", () => { + // `type: ""` is blocked by the Yup schema; the helper still has + // to refuse to send a malformed payload if a caller bypasses it. + expect(() => + providerFormValuesToCreate({ ...baseOpenAIFormValues, type: "" }), + ).toThrowError(/provider type is required/); + }); + }); + + describe("Bedrock", () => { + it('maps Bedrock to a wire `type:"anthropic"`', () => { + const req = providerFormValuesToCreate(baseBedrockFormValues); + expect(req.type).toBe("anthropic"); + }); + + it("derives the region from a canonical AWS URL", () => { + const req = providerFormValuesToCreate(baseBedrockFormValues); + const s = req.settings as unknown as Record; + expect(s._type).toBe("bedrock"); + expect(s.region).toBe("us-east-1"); + }); + + it("omits the region when the URL is non-canonical", () => { + // The form schema blocks non-canonical endpoints before submit; the + // helper itself stays strict, returning an undefined region rather + // than inventing a value. + const req = providerFormValuesToCreate({ + ...baseBedrockFormValues, + baseUrl: "https://bedrock.internal.example.com", + }); + const s = req.settings as unknown as Record; + expect(s.region).toBeUndefined(); + }); + + it("includes access_key and access_key_secret when provided", () => { + const req = providerFormValuesToCreate(baseBedrockFormValues); + const s = req.settings as unknown as Record; + expect(s.access_key).toBe("AKIA-test"); + expect(s.access_key_secret).toBe("secret"); + }); + + it("omits the access fields when the form values are blank", () => { + const req = providerFormValuesToCreate({ + ...baseBedrockFormValues, + accessKey: "", + accessKeySecret: "", + }); + const s = req.settings as unknown as Record; + expect(s.access_key).toBeUndefined(); + expect(s.access_key_secret).toBeUndefined(); + }); + + it("ignores the OpenAI/Anthropic api key field", () => { + const req = providerFormValuesToCreate({ + ...baseBedrockFormValues, + apiKey: "should-be-ignored", + }); + expect(req.api_keys).toBeUndefined(); + }); + }); +}); + +describe("providerFormValuesToUpdate", () => { + describe("OpenAI/Anthropic", () => { + it("sends api_keys as a single-entry rotation list when a new key is typed", () => { + const req = providerFormValuesToUpdate( + { ...baseOpenAIFormValues, apiKey: "sk-new" }, + MockAIProviderOpenAI, + ); + expect(req.api_keys).toEqual([{ api_key: "sk-new" }]); + }); + + it("retains the saved key by id when the user left the masked rendering", () => { + // Seed the form with the saved masked rendering exactly as + // the API returns it; the declarative payload must reference + // the saved id so the server keeps the row. + const req = providerFormValuesToUpdate( + { + ...baseOpenAIFormValues, + apiKey: MockAIProviderOpenAI.api_keys[0].masked, + }, + MockAIProviderOpenAI, + ); + expect(req.api_keys).toEqual([ + { id: MockAIProviderOpenAI.api_keys[0].id }, + ]); + }); + + it("retains the saved key by id when the user left SAVED_CREDENTIAL_MASK", () => { + const req = providerFormValuesToUpdate( + { ...baseOpenAIFormValues, apiKey: SAVED_CREDENTIAL_MASK }, + MockAIProviderOpenAI, + ); + expect(req.api_keys).toEqual([ + { id: MockAIProviderOpenAI.api_keys[0].id }, + ]); + }); + + it("sends an empty api_keys list when no key was saved and none was typed", () => { + // Declarative wire shape: an empty list is the explicit "no keys" + // state, matching the user's intent for a provider that never had + // a credential on file. + const req = providerFormValuesToUpdate( + { ...baseOpenAIFormValues, apiKey: "" }, + MockAIProviderAnthropic, + ); + expect(req.api_keys).toEqual([]); + }); + }); + + describe("Bedrock", () => { + it("derives the region from the canonical URL", () => { + const req = providerFormValuesToUpdate( + { + ...baseBedrockFormValues, + baseUrl: "https://bedrock-runtime.us-west-2.amazonaws.com", + accessKey: SAVED_CREDENTIAL_MASK, + accessKeySecret: SAVED_CREDENTIAL_MASK, + }, + MockAIProviderBedrock, + ); + const s = req.settings as unknown as Record; + expect(s.region).toBe("us-west-2"); + }); + + it("omits the region when the URL is non-canonical", () => { + // The form schema blocks non-canonical endpoints before submit; the + // helper itself stays strict, returning an undefined region rather + // than inventing a value. + const req = providerFormValuesToUpdate( + { + ...baseBedrockFormValues, + baseUrl: "https://bedrock.internal.example.com", + accessKey: SAVED_CREDENTIAL_MASK, + accessKeySecret: SAVED_CREDENTIAL_MASK, + }, + MockAIProviderBedrock, + ); + const s = req.settings as unknown as Record; + expect(s.region).toBeUndefined(); + }); + + it("omits access_key/access_key_secret when the user left both masked (empty = keep)", () => { + const req = providerFormValuesToUpdate( + { + ...baseBedrockFormValues, + accessKey: SAVED_CREDENTIAL_MASK, + accessKeySecret: SAVED_CREDENTIAL_MASK, + }, + MockAIProviderBedrock, + ); + const s = req.settings as unknown as Record; + expect(s.access_key).toBeUndefined(); + expect(s.access_key_secret).toBeUndefined(); + }); + + it("sends new access keys when both were typed", () => { + const req = providerFormValuesToUpdate( + { + ...baseBedrockFormValues, + accessKey: "AKIA-rotate", + accessKeySecret: "rotated-secret", + }, + MockAIProviderBedrock, + ); + const s = req.settings as unknown as Record; + expect(s.access_key).toBe("AKIA-rotate"); + expect(s.access_key_secret).toBe("rotated-secret"); + }); + + it('treats a half-rotated credential pair as "do not rotate"', () => { + // Yup blocks this at the schema layer; the helper still has + // to refuse to send a partial rotation, lest a partial wire + // payload corrupt the stored credential. + const req = providerFormValuesToUpdate( + { + ...baseBedrockFormValues, + accessKey: "AKIA-rotate", + accessKeySecret: SAVED_CREDENTIAL_MASK, + }, + MockAIProviderBedrock, + ); + const s = req.settings as unknown as Record; + expect(s.access_key).toBeUndefined(); + expect(s.access_key_secret).toBeUndefined(); + }); + }); +}); + +describe("aiProviderToFormValues", () => { + it("seeds OpenAI form values from a wire provider", () => { + const values = aiProviderToFormValues(MockAIProviderOpenAI); + expect(values.type).toBe("openai"); + expect(values.name).toBe(MockAIProviderOpenAI.name); + expect(values.baseUrl).toBe(MockAIProviderOpenAI.base_url); + expect(values.apiKey).toBe(""); + }); + + it("seeds Bedrock form values from settings", () => { + const values = aiProviderToFormValues(MockAIProviderBedrock); + expect(values.type).toBe("bedrock"); + expect(values.model).toBe("anthropic.claude-opus-4-7"); + expect(values.smallFastModel).toBe("anthropic.claude-haiku-4-5"); + }); + + it("never round-trips Bedrock secrets back to the form", () => { + // AccessKey and AccessKeySecret are write-only; the API strips + // them from responses, so the form must seed them as empty. + const values = aiProviderToFormValues(MockAIProviderBedrock); + expect(values.accessKey).toBe(""); + expect(values.accessKeySecret).toBe(""); + }); + + it("falls back to the slug when display_name is empty", () => { + const provider: AIProvider = { + ...MockAIProviderOpenAI, + display_name: "", + }; + expect(aiProviderToFormValues(provider).displayName).toBe(provider.name); + }); + + it("handles a Bedrock provider whose settings are null", () => { + // `isBedrockProvider` will return false, so the provider falls + // through to the anthropic branch. The helper must not throw. + const provider: AIProvider = { + ...MockAIProviderBedrock, + settings: null as unknown as AIProvider["settings"], + }; + const values = aiProviderToFormValues(provider); + expect(values.type).toBe("anthropic"); + }); +}); diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts new file mode 100644 index 0000000000000..4f958fdea7488 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts @@ -0,0 +1,253 @@ +import type { + AIProvider, + AIProviderBedrockSettings, + AIProviderKeyMutation, + AIProviderSettings, + AIProviderType, + CreateAIProviderRequest, + UpdateAIProviderRequest, +} from "#/api/typesGenerated"; +import { + type ProviderFormValues, + parseBedrockRegionFromBaseUrl, + SAVED_CREDENTIAL_MASK, +} from "./ProviderForm"; + +/** Drop placeholder masks so they don't round-trip back to the API. */ +const sanitizeCredential = ( + value: string, + ...extraMasks: (string | undefined)[] +): string => { + const trimmed = value.trim(); + if (trimmed === "" || trimmed === SAVED_CREDENTIAL_MASK) { + return ""; + } + if (extraMasks.some((m) => m !== undefined && m === trimmed)) { + return ""; + } + return trimmed; +}; + +// The generated `AIProviderSettings` interface is empty (the Go side uses +// a custom marshaler), so we redeclare the structural wire shape here. +const BEDROCK_SETTINGS_TYPE = "bedrock"; +const BEDROCK_SETTINGS_VERSION = 1; + +type BedrockSettingsWire = AIProviderBedrockSettings & { + _type: typeof BEDROCK_SETTINGS_TYPE; + _version: typeof BEDROCK_SETTINGS_VERSION; +}; + +type SettingsWire = AIProviderSettings & + Partial & { + _type?: string; + _version?: number; + }; + +// Bedrock providers carry an Anthropic wire type plus a +// `settings._type === "bedrock"` discriminator. `settings` is non-null in +// the generated type but Go serializes zero settings as JSON `null`, so we +// null-check before reading the discriminator. +export const isBedrockProvider = (provider: AIProvider): boolean => { + if (provider.type !== "anthropic") { + return false; + } + const s = provider.settings as SettingsWire | null; + return s !== null && s._type === BEDROCK_SETTINGS_TYPE; +}; + +export const hasBedrockStoredCredentials = (provider: AIProvider): boolean => { + if (!isBedrockProvider(provider)) { + return false; + } + // Bedrock secrets are write-only. The server only persists Bedrock + // settings if credentials were supplied, so presence implies "on file". + return true; +}; + +const parseProviderHost = (url: string): string => { + try { + return new URL(url).host.toLowerCase(); + } catch { + return ""; + } +}; + +// UI types we recover from a saved provider's base_url because the wire +// `type` collapses them to `openai`. Matches the bare domain or any +// subdomain (Azure ships per-resource subdomains). +const displayTypeHosts: ReadonlyArray<[string, AIProviderType]> = [ + ["openai.azure.com", "azure"], + ["generativelanguage.googleapis.com", "google"], + ["openrouter.ai", "openrouter"], + ["ai-gateway.vercel.sh", "vercel"], +]; + +const matchesHost = (host: string, suffix: string): boolean => + host === suffix || host.endsWith(`.${suffix}`); + +// Wire `type` collapses azure/google/openrouter/vercel to `openai`, so +// we recover the original choice from the saved host. Bedrock comes +// through the settings discriminator. Unknown hosts fall back to wire. +export const getProviderDisplayType = ( + provider: AIProvider, +): AIProviderType => { + if (isBedrockProvider(provider)) { + return "bedrock"; + } + if (provider.type === "anthropic") { + return "anthropic"; + } + const host = parseProviderHost(provider.base_url ?? ""); + const match = displayTypeHosts.find(([h]) => matchesHost(host, h)); + return match?.[1] ?? provider.type; +}; + +const buildBedrockSettings = ( + region: string | undefined, + model: string, + smallFastModel: string, + accessKey: string, + accessKeySecret: string, +): BedrockSettingsWire => ({ + _type: BEDROCK_SETTINGS_TYPE, + _version: BEDROCK_SETTINGS_VERSION, + ...(region ? { region } : {}), + model, + small_fast_model: smallFastModel, + ...(accessKey ? { access_key: accessKey } : {}), + ...(accessKeySecret ? { access_key_secret: accessKeySecret } : {}), +}); + +// Bedrock credentials live in `settings`; openai/anthropic keys go in +// `api_keys`. `display_name` is omitted when blank so the server stores +// NULL and the UI falls back to `name`. +export const providerFormValuesToCreate = ( + values: ProviderFormValues, +): CreateAIProviderRequest => { + const name = values.name.trim(); + const displayName = values.displayName.trim(); + const baseUrl = values.baseUrl.trim(); + + if (values.type === "bedrock") { + const region = parseBedrockRegionFromBaseUrl(baseUrl); + const settings = buildBedrockSettings( + region, + values.model.trim(), + values.smallFastModel.trim(), + sanitizeCredential(values.accessKey), + sanitizeCredential(values.accessKeySecret), + ); + return { + type: "anthropic", + name, + ...(displayName ? { display_name: displayName } : {}), + base_url: baseUrl, + enabled: values.enabled, + settings: settings as AIProviderSettings, + }; + } + + const apiKey = sanitizeCredential(values.apiKey); + // `""` is unreachable here (Yup blocks it, Bedrock branched out), but the + // union still includes it; narrow so TS stays honest. + if (values.type === "") { + throw new Error("provider type is required"); + } + // Wire only accepts `openai` and `anthropic`; the other UI types are + // presets that collapse to `openai`. + const wireType: AIProvider["type"] = + values.type === "anthropic" ? "anthropic" : "openai"; + return { + type: wireType, + name, + ...(displayName ? { display_name: displayName } : {}), + base_url: baseUrl, + enabled: values.enabled, + ...(apiKey ? { api_keys: [apiKey] } : {}), + }; +}; + +// Bedrock secrets follow an "empty = keep" contract: blank inputs are +// omitted and the server leaves them unchanged. OpenAI/Anthropic keys ship +// as a declarative list: `{ id }` retains a saved key, `{ api_key }` inserts +// a new one, and any saved id missing from the list is deleted. +export const providerFormValuesToUpdate = ( + values: ProviderFormValues, + existingProvider: AIProvider, +): UpdateAIProviderRequest => { + const base: UpdateAIProviderRequest = { + display_name: values.displayName.trim(), + enabled: values.enabled, + base_url: values.baseUrl.trim(), + }; + + if (values.type !== "bedrock") { + // If the user didn't touch the input, the form still holds the seeded + // mask and sanitizes to `""` (no rotation). + const savedMasked = existingProvider.api_keys[0]?.masked; + const newApiKey = sanitizeCredential(values.apiKey, savedMasked); + // Rotation goes out as the new plaintext alone: the saved key's id is + // omitted (which deletes it) and the plaintext is inserted as a fresh + // row. The backend rejects sending both fields on the same entry today. + const apiKeys: AIProviderKeyMutation[] = + newApiKey === "" + ? existingProvider.api_keys.map((k) => ({ id: k.id })) + : [{ api_key: newApiKey }]; + return { ...base, api_keys: apiKeys }; + } + + const newAccessKey = sanitizeCredential(values.accessKey); + const newAccessKeySecret = sanitizeCredential(values.accessKeySecret); + // Yup enforces "both keys together"; if both survived the mask filter, + // the user is rotating credentials. + const credentialsChanged = newAccessKey !== "" && newAccessKeySecret !== ""; + + // Yup blocks non-canonical Bedrock URLs upstream, so any `undefined` + // region here is a real bug that should surface, not be papered over. + const region = parseBedrockRegionFromBaseUrl(base.base_url ?? ""); + + const settings = buildBedrockSettings( + region, + values.model.trim(), + values.smallFastModel.trim(), + credentialsChanged ? newAccessKey : "", + credentialsChanged ? newAccessKeySecret : "", + ); + + return { ...base, settings: settings as AIProviderSettings }; +}; + +// `name` is immutable on the server and the edit form hides it; we seed +// it anyway so the form values stay aligned with `ProviderFormValues`. +// `displayName` falls back to the slug for providers that never had one set. +export const aiProviderToFormValues = ( + provider: AIProvider, +): Partial => { + const displayName = provider.display_name || provider.name; + if (isBedrockProvider(provider)) { + const s = (provider.settings as SettingsWire | null) ?? {}; + return { + type: "bedrock", + name: provider.name, + displayName, + baseUrl: provider.base_url, + model: s.model ?? "", + smallFastModel: s.small_fast_model ?? "", + accessKey: "", + accessKeySecret: "", + enabled: provider.enabled, + }; + } + + // Wire `type` is only `openai` or `anthropic`; the dropdown's richer + // labels apply only on create. + return { + type: provider.type === "anthropic" ? "anthropic" : "openai", + name: provider.name, + displayName, + baseUrl: provider.base_url, + apiKey: "", + enabled: provider.enabled, + }; +}; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f28637b1c4770..f15d839e14115 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -5518,7 +5518,6 @@ export const MockSession: TypesGen.AIBridgeSession = { last_active_at: "2026-03-09T10:28:15.03152Z", }; -/** @lintignore Consumed by component stories landing in the next PR of the AI settings stack. */ export const MockAIProviderOpenAI: TypesGen.AIProvider = { id: "7a5d6b6a-5f02-4a9c-9c4e-2b3e2a3d2f01", type: "openai", @@ -5538,7 +5537,6 @@ export const MockAIProviderOpenAI: TypesGen.AIProvider = { updated_at: "2026-05-14T10:00:00Z", }; -/** @lintignore Consumed by component stories landing in the next PR of the AI settings stack. */ export const MockAIProviderAnthropic: TypesGen.AIProvider = { id: "4f81f1ee-37c1-4a37-a9d5-7e0c1c8c0c11", type: "anthropic", @@ -5556,8 +5554,6 @@ export const MockAIProviderAnthropic: TypesGen.AIProvider = { * Bedrock providers come over the wire with `type: "anthropic"` and a * `settings._type: "bedrock"` discriminator. `isBedrockProvider` and the * backend (see `coderd/ai_providers.go`) enforce this convention. - * - * @lintignore Consumed by component stories landing in the next PR of the AI settings stack. */ export const MockAIProviderBedrock: TypesGen.AIProvider = { id: "9c2e3b41-2e9f-4c97-9a4f-2e1a3d8f9f21", From 0a7ac7c986e9e549dc1ebcfe02bced7daa3d740f Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 27 May 2026 02:38:34 +1000 Subject: [PATCH 017/249] feat(site): add AI settings providers pages and routes (#25583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Linear: [DEVEX-355](https://linear.app/coder/issue/DEVEX-355) Fourth PR in a 5-PR stack splitting #25328. Wires the new `/ai/settings` provider management UI. - `AISettingsLayout` hosts the section under `/ai/settings` with a sidebar outlet. - `AISettingsSidebar(View)` shows a single "Providers" nav entry. The remaining sidebar entries arrive with the broader AI settings section reshuffle in the next PR. - `ProvidersPage` lists configured AI providers via the queries added in PR 2. - `AddProviderPage` walks through provider-type selection and form submission, with type-specific credential fields. - `UpdateProviderPage` edits an existing provider with the same form components. - Storybook stories cover each view's loading, empty, populated, error, and form states using the mock providers from `testHelpers/entities.ts`. - `router.tsx` mounts the new `/ai/settings` layout with index, `add`, and `:providerId` child routes. The `governance` child route lands together with the dashboard navigation changes in the next PR. Removes the now-unused knip ignore entries for `src/api/queries/aiProviders.ts` and `src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts`, and drops the matching `@lintignore` tags on `getProviderIcon` and `MockAIProviders` since the pages and page stories now consume them.
Stack 1. #25579 jakehwll/DEVEX-355/01-primitives, primitives 2. #25580 jakehwll/DEVEX-355/02-api, API client and query layer 3. #25581 jakehwll/DEVEX-355/03-components, provider form components 4. **jakehwll/DEVEX-355/04-pages, pages and routes (this PR)** 5. jakehwll/DEVEX-355/05-section, section reshuffle Replaces #25328 once the stack lands.
--- site/.knip.jsonc | 8 - .../modules/management/AISettingsSidebar.tsx | 8 + .../management/AISettingsSidebarView.tsx | 16 ++ .../pages/AISettingsPage/AISettingsLayout.tsx | 21 ++ .../AddProviderPage/AddProviderPage.tsx | 49 ++++ .../AddProviderPageView.stories.tsx | 41 +++ .../AddProviderPage/AddProviderPageView.tsx | 81 ++++++ .../ProvidersPage/ProvidersPage.tsx | 28 ++ .../ProvidersPageView.stories.tsx | 63 +++++ .../ProvidersPage/ProvidersPageView.tsx | 126 +++++++++ .../UpdateProviderPage/UpdateProviderPage.tsx | 16 ++ .../UpdateProviderPageView.stories.tsx | 79 ++++++ .../UpdateProviderPageView.tsx | 248 ++++++++++++++++++ .../ProvidersPage/components/ProviderIcon.tsx | 1 - site/src/router.tsx | 28 ++ site/src/testHelpers/entities.ts | 1 - 16 files changed, 804 insertions(+), 10 deletions(-) create mode 100644 site/src/modules/management/AISettingsSidebar.tsx create mode 100644 site/src/modules/management/AISettingsSidebarView.tsx create mode 100644 site/src/pages/AISettingsPage/AISettingsLayout.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPage.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/ProvidersPage.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPage.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx diff --git a/site/.knip.jsonc b/site/.knip.jsonc index d4cedd0c7506d..d12174a6f96e6 100644 --- a/site/.knip.jsonc +++ b/site/.knip.jsonc @@ -11,14 +11,6 @@ "ignore": [ "**/*Generated.ts", "src/api/chatModelOptions.ts", - // TODO(ai-settings): aiProviders.ts queries are staged in PR 2 of the - // AI settings stack; they are consumed by the provider pages in PR 4. - // Remove this exclusion once those pages land. - "src/api/queries/aiProviders.ts", - // TODO(ai-settings): addableProviderTypes.ts is staged in PR 3 of the - // AI settings stack; its exports are consumed by the provider pages - // in PR 4. Remove this exclusion once those pages land. - "src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts", // TODO(devtools): debugPanelUtils.ts is staged in PR 7; its exports are // consumed by the Debug panel components in PRs 8 and 9. Remove this // exclusion once the panel components land. diff --git a/site/src/modules/management/AISettingsSidebar.tsx b/site/src/modules/management/AISettingsSidebar.tsx new file mode 100644 index 0000000000000..c01bbb528597f --- /dev/null +++ b/site/src/modules/management/AISettingsSidebar.tsx @@ -0,0 +1,8 @@ +import AISettingsSidebarView from "#/modules/management/AISettingsSidebarView"; + +/** + * A sidebar for AI settings. + */ +export const AISettingsSidebar: React.FC = () => { + return ; +}; diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx new file mode 100644 index 0000000000000..81447b4e5c559 --- /dev/null +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -0,0 +1,16 @@ +import { + Sidebar as BaseSidebar, + SettingsSidebarNavItem as SidebarNavItem, +} from "#/components/Sidebar/Sidebar"; + +const AISettingsSidebarView: React.FC = () => { + return ( + +
+ Providers +
+
+ ); +}; + +export default AISettingsSidebarView; diff --git a/site/src/pages/AISettingsPage/AISettingsLayout.tsx b/site/src/pages/AISettingsPage/AISettingsLayout.tsx new file mode 100644 index 0000000000000..8a44e10352f6c --- /dev/null +++ b/site/src/pages/AISettingsPage/AISettingsLayout.tsx @@ -0,0 +1,21 @@ +import { Suspense } from "react"; +import { Outlet } from "react-router"; +import { Loader } from "#/components/Loader/Loader"; +import { AISettingsSidebar } from "#/modules/management/AISettingsSidebar"; + +const AISettingsLayout = () => { + return ( +
+
+ +
+ }> + + +
+
+
+ ); +}; + +export default AISettingsLayout; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPage.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPage.tsx new file mode 100644 index 0000000000000..74df05f4799c3 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPage.tsx @@ -0,0 +1,49 @@ +import { ArrowLeftIcon } from "lucide-react"; +import { Link, useSearchParams } from "react-router"; +import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; +import { Button } from "#/components/Button/Button"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import { pageTitle } from "#/utils/page"; +import { addableProviders } from "../components/addableProviderTypes"; +import AddProviderPageView from "./AddProviderPageView"; + +const AddProviderPage: React.FC = () => { + const { permissions } = useAuthenticated(); + const hasPermission = permissions.viewAnyAIProvider; + const [searchParams] = useSearchParams(); + const typeParam = searchParams.get("type"); + + const provider = addableProviders.find((p) => p.value === typeParam); + if (!provider) { + return ( +
+ + + + + Provider type not found + + The provider type you are trying to add is not valid. Please try + again. + + +
+ ); + } + + return ( + + + {pageTitle(`New ${provider.label} Provider`, "AI Providers")} + + + + + ); +}; + +export default AddProviderPage; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx new file mode 100644 index 0000000000000..f7bbab97617cd --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { withToaster } from "#/testHelpers/storybook"; +import { addableProviders } from "../components/addableProviderTypes"; +import AddProviderPageView from "./AddProviderPageView"; + +const meta: Meta = { + title: "pages/AISettingsPage/AddProviderPage", + component: AddProviderPageView, + decorators: [withToaster], + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/add" }, + routing: [ + { path: "/ai/settings", useStoryElement: true }, + { path: "/ai/settings/add", useStoryElement: true }, + ], + }), + }, +}; + +export default meta; +type Story = StoryObj; + +export const AddAnthropic: Story = { + args: { + provider: addableProviders.find((p) => p.value === "anthropic")!, + }, +}; + +export const AddOpenAI: Story = { + args: { + provider: addableProviders.find((p) => p.value === "openai")!, + }, +}; + +export const AddBedrock: Story = { + args: { + provider: addableProviders.find((p) => p.value === "bedrock")!, + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx new file mode 100644 index 0000000000000..6f21156077e59 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx @@ -0,0 +1,81 @@ +import { ArrowLeftIcon } from "lucide-react"; +import { useMutation, useQueryClient } from "react-query"; +import { Link, useNavigate } from "react-router"; +import { toast } from "sonner"; +import { getErrorMessage } from "#/api/errors"; +import { createAIProviderMutation } from "#/api/queries/aiProviders"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { Button } from "#/components/Button/Button"; +import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader"; +import type { AddableProvider } from "../components/addableProviderTypes"; +import { ProviderForm } from "../components/ProviderForm"; +import { getProviderIcon } from "../components/ProviderIcon"; +import { providerFormValuesToCreate } from "../components/providerFormApiMap"; + +interface AddProviderPageViewProps { + provider: AddableProvider; +} + +const AddProviderPageView: React.FC = ({ + provider, +}) => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const createMutation = useMutation(createAIProviderMutation(queryClient)); + + return ( + <> + + + +
+
+ + {`Add a ${provider.label} provider`} +
+

+ Configure connection details and credentials. +

+
+ { + const request = providerFormValuesToCreate(values); + try { + const res = await createMutation.mutateAsync(request); + toast.success( + `Provider "${res.display_name || res.name}" added.`, + ); + // Awaited so the form's submitting state stays true through + // navigation, keeping the unsaved-changes prompt suppressed. + await navigate(`/ai/settings/${res.name}`); + } catch (error) { + const name = values.name.trim(); + toast.error( + getErrorMessage( + error, + name + ? `Failed to add provider "${name}".` + : "Failed to add provider.", + ), + ); + } + }} + /> +
+
+ + ); +}; + +export default AddProviderPageView; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPage.tsx b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPage.tsx new file mode 100644 index 0000000000000..6c08c9507eb13 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPage.tsx @@ -0,0 +1,28 @@ +import { useQuery } from "react-query"; +import { aiProvidersList } from "#/api/queries/aiProviders"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import ProvidersPageView from "#/pages/AISettingsPage/ProvidersPage/ProvidersPageView"; +import { pageTitle } from "#/utils/page"; + +const ProvidersPage: React.FC = () => { + const { permissions } = useAuthenticated(); + const hasPermission = permissions.viewAnyAIProvider; + + const providersQuery = useQuery(aiProvidersList()); + + return ( + + {pageTitle("AI Providers")} + + + + ); +}; + +export default ProvidersPage; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.stories.tsx new file mode 100644 index 0000000000000..e92a52409becc --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { userEvent, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { MockAIProviders } from "#/testHelpers/entities"; +import ProvidersPageView from "./ProvidersPageView"; + +const meta: Meta = { + title: "pages/AISettingsPage/ProvidersPageView", + component: ProvidersPageView, + args: { + isLoading: false, + isFetching: false, + error: null, + providers: MockAIProviders, + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings" }, + routing: [ + { path: "/ai/settings", useStoryElement: true }, + { path: "/ai/settings/add", useStoryElement: true }, + { path: "/ai/settings/:providerId", useStoryElement: true }, + ], + }), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + args: { + isLoading: true, + isFetching: true, + }, +}; + +export const EmptyProviders: Story = { + args: { + providers: [], + }, +}; + +export const LoadError: Story = { + args: { + isLoading: false, + isFetching: false, + error: new Error("Failed to load providers"), + providers: [], + }, +}; + +export const AddProviderDropdownOpen: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = await canvas.findByRole("button", { + name: /add provider/i, + }); + await userEvent.click(trigger); + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx new file mode 100644 index 0000000000000..dde7c8757b261 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx @@ -0,0 +1,126 @@ +import { ChevronDownIcon, PlusIcon } from "lucide-react"; +import { useNavigate } from "react-router"; +import type { AIProvider } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Button } from "#/components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; +import { TableLoader } from "#/components/TableLoader/TableLoader"; +import { addableProviders } from "#/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes"; +import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon"; +import { ProviderRow } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderRow"; + +interface ProvidersPageViewProps { + isLoading: boolean; + isFetching: boolean; + error: unknown; + providers: AIProvider[]; +} + +const AddProviderDropdown: React.FC<{ align?: "start" | "end" }> = ({ + align = "end", +}) => { + const navigate = useNavigate(); + return ( + + + + + +
+ Select a provider +
+ {addableProviders.map((entry) => ( + + void navigate( + `/ai/settings/add?type=${encodeURIComponent(entry.value)}`, + ) + } + > + + {entry.label} + + ))} +
+
+ ); +}; + +const ProvidersPageView: React.FC = ({ + isLoading, + isFetching, + error, + providers, +}) => { + const navigate = useNavigate(); + + return ( +
+ }> + Providers + + Connect third-party LLM services like OpenAI, Anthropic, or Amazon + Bedrock. Each provider supplies models that users can select for their + conversations. + + + {Boolean(error) && ( +
+ +
+ )} + + + + Name + Base URL + Status + + + + {isLoading || isFetching ? ( + + ) : providers.length === 0 ? ( + } + /> + ) : ( + providers.map((provider) => ( + navigate(`/ai/settings/${provider.name}`)} + /> + )) + )} + +
+
+ ); +}; + +export default ProvidersPageView; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPage.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPage.tsx new file mode 100644 index 0000000000000..0ae990311bfa0 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPage.tsx @@ -0,0 +1,16 @@ +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import UpdateProviderPageView from "./UpdateProviderPageView"; + +const UpdateProviderPage: React.FC = () => { + const { permissions } = useAuthenticated(); + const hasPermission = permissions.viewAnyAIProvider; + + return ( + + + + ); +}; + +export default UpdateProviderPage; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx new file mode 100644 index 0000000000000..15713428f9d77 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import type { AIProvider } from "#/api/typesGenerated"; +import { + MockAIProviderAnthropic, + MockAIProviderBedrock, + MockAIProviderOpenAI, +} from "#/testHelpers/entities"; +import { withToaster } from "#/testHelpers/storybook"; +import UpdateProviderPageView from "./UpdateProviderPageView"; + +const routingFor = (path: string) => + reactRouterParameters({ + location: { path }, + routing: [ + { path: "/ai/settings", useStoryElement: true }, + { path: "/ai/settings/:providerId", useStoryElement: true }, + ], + }); + +const seed = (provider: AIProvider) => ({ + queries: [{ key: ["ai", "providers", provider.name], data: provider }], +}); + +const meta: Meta = { + title: "pages/AISettingsPage/UpdateProviderPageView", + component: UpdateProviderPageView, + decorators: [withToaster], +}; + +export default meta; +type Story = StoryObj; + +export const OpenAI: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderOpenAI.name}`), + ...seed(MockAIProviderOpenAI), + }, +}; + +export const Anthropic: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderAnthropic.name}`), + ...seed(MockAIProviderAnthropic), + }, +}; + +export const Bedrock: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderBedrock.name}`), + ...seed(MockAIProviderBedrock), + }, +}; + +// No seeded query: the page renders the loader while useQuery fetches. +export const Loading: Story = { + parameters: { + reactRouter: routingFor("/ai/settings/loading-provider"), + }, +}; + +export const DeleteDialogOpen: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderOpenAI.name}`), + ...seed(MockAIProviderOpenAI), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const deleteButton = await canvas.findByRole("button", { + name: /^delete$/i, + }); + await userEvent.click(deleteButton); + // DeleteDialog renders via Radix portal, so search the document, not + // just the story canvas. + await expect(await screen.findByRole("dialog")).toBeInTheDocument(); + await expect(await screen.findByText(/irreversible/i)).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx new file mode 100644 index 0000000000000..dbeb16003d99f --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx @@ -0,0 +1,248 @@ +import { isAxiosError } from "axios"; +import { ArrowLeftIcon } from "lucide-react"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Link, Navigate, useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; +import { getErrorMessage } from "#/api/errors"; +import { + aiProvider, + deleteAIProviderMutation, + updateAIProviderMutation, +} from "#/api/queries/aiProviders"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { Badge } from "#/components/Badge/Badge"; +import { Button } from "#/components/Button/Button"; +import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog"; +import { Loader } from "#/components/Loader/Loader"; +import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader"; +import { Switch } from "#/components/Switch/Switch"; +import { pageTitle } from "#/utils/page"; +import { ProviderForm } from "../components/ProviderForm"; +import { getProviderIcon } from "../components/ProviderIcon"; +import { + aiProviderToFormValues, + getProviderDisplayType, + hasBedrockStoredCredentials, + isBedrockProvider, + providerFormValuesToUpdate, +} from "../components/providerFormApiMap"; + +const BACK_HREF = "/ai/settings"; + +const UpdateProviderPageView: React.FC = () => { + const { providerId } = useParams<{ providerId: string }>(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const providerQuery = useQuery({ + ...aiProvider(providerId ?? ""), + enabled: Boolean(providerId), + }); + + const provider = providerQuery.data; + const providerIsOpenAiAnthropic = + provider !== undefined && !isBedrockProvider(provider); + + const updateMutation = useMutation( + updateAIProviderMutation(queryClient, providerId ?? ""), + ); + + const deleteMutation = useMutation( + deleteAIProviderMutation(queryClient, providerId ?? ""), + ); + + // Rendered into every non-redirect return so the document title reflects + // the provider as soon as we know it; falls back to a placeholder while + // the query is in flight. + const title = ( + + {pageTitle( + (provider?.display_name || provider?.name) ?? "Loading...", + "AI Providers", + )} + + ); + + if (!providerId) { + return ; + } + + if (providerQuery.isLoading) { + return ( + <> + {title} + + + ); + } + + if (providerQuery.isError) { + const status = isAxiosError(providerQuery.error) + ? providerQuery.error.response?.status + : undefined; + if (status === 404) { + return ; + } + return ( + <> + {title} +
+

+ {getErrorMessage(providerQuery.error, "Failed to load provider.")} +

+ + + +
+ + ); + } + + if (!provider) { + return ; + } + + const openAiAnthropicSavedApiKey = + providerIsOpenAiAnthropic && provider.api_keys.length > 0; + const openAiAnthropicMaskedApiKey = providerIsOpenAiAnthropic + ? provider.api_keys[0]?.masked + : undefined; + + return ( + <> + {title} +
+ + + + +
+
+
+ + + + {provider.display_name || provider.name} + + + {!provider.enabled && Disabled} +
+
+

+ Add or update models for this provider.{" "} + + Model settings + +

+
+ { + updateMutation.mutate( + { enabled: checked }, + { + onSuccess: (updated) => { + toast.success( + `Provider "${updated.display_name || updated.name}" ${checked ? "enabled" : "disabled"}.`, + ); + }, + }, + ); + }} + disabled={updateMutation.isPending} + aria-label="Provider enabled" + /> + Enable +
+
+
+ { + const request = providerFormValuesToUpdate(values, provider); + try { + const updated = await updateMutation.mutateAsync(request); + toast.success( + `Provider "${updated.display_name || updated.name}" updated.`, + ); + } catch (error) { + toast.error( + getErrorMessage( + error, + `Failed to update provider "${provider.display_name || provider.name}".`, + ), + ); + } + }} + /> +
+ { + setDeleteDialogOpen(false); + }} + onConfirm={() => { + deleteMutation.mutate(undefined, { + onSuccess: () => { + toast.success( + `Provider "${provider.display_name || provider.name}" deleted.`, + ); + setDeleteDialogOpen(false); + void navigate(BACK_HREF, { replace: true }); + }, + onError: (error) => { + toast.error( + getErrorMessage( + error, + `Failed to delete provider "${provider.display_name || provider.name}".`, + ), + ); + }, + }); + }} + /> +
+ + ); +}; + +export default UpdateProviderPageView; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx index feaf29f8985a5..f84dcc8375398 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx @@ -5,7 +5,6 @@ type ProviderIconProps = { provider: string; }; -/** @lintignore Consumed by provider pages landing in the next PR of the AI settings stack. */ export const getProviderIcon = (provider: string): string | undefined => { switch (provider) { case "openai": diff --git a/site/src/router.tsx b/site/src/router.tsx index e84bec537e5ba..6a7b04d15135c 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -429,6 +429,25 @@ const AIBridgeSessionThreadsPage = lazy( () => import("./pages/AIBridgePage/SessionThreadsPage/SessionThreadsPage"), ); +const AISettingsLayout = lazy( + () => import("./pages/AISettingsPage/AISettingsLayout"), +); +const AISettingsProvidersPage = lazy( + () => import("./pages/AISettingsPage/ProvidersPage/ProvidersPage"), +); +const AISettingsUpdateProviderPage = lazy( + () => + import( + "./pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPage" + ), +); +const AISettingsAddProviderPage = lazy( + () => + import( + "./pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPage" + ), +); + const GlobalLayout = () => { return ( }> @@ -678,6 +697,15 @@ export const router = createBrowserRouter( } /> + }> + } /> + } /> + } + /> + + }> } /> } /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f15d839e14115..c8b8a083ba2f9 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -5574,7 +5574,6 @@ export const MockAIProviderBedrock: TypesGen.AIProvider = { updated_at: "2026-05-14T10:00:00Z", }; -/** @lintignore Consumed by page stories landing in PR 4 of the AI settings stack. */ export const MockAIProviders: TypesGen.AIProvider[] = [ MockAIProviderOpenAI, MockAIProviderAnthropic, From e9f0f81d76c9448148720d292cfb83f79c95e775 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 26 May 2026 18:44:54 +0200 Subject: [PATCH 018/249] fix(dogfood/coder): override mise oci build MISE_CONFIG_DIR bake (#25684) Co-authored-by: Claude Opus 4.7 (1M context) Signed-off-by: Thomas Kosiewski --- dogfood/coder/main.tf | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf index 0a5a20c0f7a49..176b99392d2a1 100644 --- a/dogfood/coder/main.tf +++ b/dogfood/coder/main.tf @@ -508,6 +508,16 @@ resource "coder_agent" "dev" { env = merge( { OIDC_TOKEN : data.coder_workspace_owner.me.oidc_access_token, + # `mise oci build` bakes `ENV MISE_CONFIG_DIR=/etc/mise` into + # the image layer above Dockerfile.base, so mise treats + # /etc/mise as the user config dir and never reads + # ~/.config/mise/conf.d/*, silently dropping the trust file + # the install-deps coder_script below seeds. `[oci.env]` in + # mise.toml would be the natural place for this, but mise's + # internal env bake currently wins on MISE_* key collisions + # (non-MISE keys flow through). Move this back to `[oci.env]` + # once upstream mise fixes that. + MISE_CONFIG_DIR : "/home/coder/.config/mise", }, data.coder_parameter.enable_ai_gateway.value ? { ANTHROPIC_BASE_URL : "https://dev.coder.com/api/v2/aibridge/anthropic", From d80b484487f2b42f9917fcc405d4c408a3d48b70 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 27 May 2026 02:50:36 +1000 Subject: [PATCH 019/249] feat(site): promote AI settings to a top-level section (#25582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Linear: [DEVEX-355](https://linear.app/coder/issue/DEVEX-355) Fifth and final PR in a 5-PR stack splitting #25328. Surfaces the AI settings section in the dashboard chrome and moves the existing AI Governance page out of `/deployment`. - `Navbar` / `NavbarView` / `DeploymentDropdown` gain a `canViewAISettings` prop sourced from the `viewAnyAIProvider` permission added in PR 2. The deployment dropdown gets a new AI entry that links to `/ai/settings`. - `DeploymentSidebarView` drops the AI-related entries that now live under `/ai/settings`. - `AISettingsSidebarView` expands to include AI Governance and a cross-section link to Manage Coder Agents. - `router.tsx` removes the `/deployment/ai-governance` route and mounts the matching `/ai/settings/governance` child route under the new AI settings layout. - `ChatsSidebar` settings panel repoints the Providers link from `/deployment/ai-providers` to `/ai/settings`.
Stack 1. #25579 jakehwll/DEVEX-355/01-primitives, primitives 2. #25580 jakehwll/DEVEX-355/02-api, API client and query layer 3. #25581 jakehwll/DEVEX-355/03-components, provider form components 4. #25583 jakehwll/DEVEX-355/04-pages, pages and routes 5. **jakehwll/DEVEX-355/05-section, section reshuffle (this PR)** Replaces #25328 once the stack lands.
--- .../dashboard/Navbar/DeploymentDropdown.tsx | 22 ++++++++++++----- site/src/modules/dashboard/Navbar/Navbar.tsx | 2 ++ .../dashboard/Navbar/NavbarView.stories.tsx | 8 +++++++ .../modules/dashboard/Navbar/NavbarView.tsx | 5 +++- .../modules/management/AISettingsSidebar.tsx | 7 ++++-- .../management/AISettingsSidebarView.tsx | 24 ++++++++++++++++++- .../management/DeploymentSidebarView.tsx | 13 +--------- .../ChatsSidebar/settings/SettingsPanel.tsx | 7 +++--- site/src/router.tsx | 8 +++---- 9 files changed, 67 insertions(+), 29 deletions(-) diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx index 70c81f21dd271..6517feca268ef 100644 --- a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -15,8 +15,9 @@ interface DeploymentDropdownProps { canViewOrganizations: boolean; canViewAuditLog: boolean; canViewConnectionLog: boolean; - canViewHealth: boolean; canViewAIBridge: boolean; + canViewAISettings: boolean; + canViewHealth: boolean; } export const DeploymentDropdown: FC = ({ @@ -24,16 +25,18 @@ export const DeploymentDropdown: FC = ({ canViewOrganizations, canViewAuditLog, canViewConnectionLog, - canViewHealth, canViewAIBridge, + canViewAISettings, + canViewHealth, }) => { if ( !canViewAuditLog && !canViewConnectionLog && !canViewDeployment && !canViewOrganizations && - !canViewHealth && - !canViewAIBridge + !canViewAIBridge && + !canViewAISettings && + !canViewHealth ) { return null; } @@ -53,8 +56,9 @@ export const DeploymentDropdown: FC = ({ canViewOrganizations={canViewOrganizations} canViewAuditLog={canViewAuditLog} canViewConnectionLog={canViewConnectionLog} - canViewHealth={canViewHealth} canViewAIBridge={canViewAIBridge} + canViewAISettings={canViewAISettings} + canViewHealth={canViewHealth} />
@@ -64,9 +68,10 @@ export const DeploymentDropdown: FC = ({ const DeploymentDropdownContent: FC = ({ canViewDeployment, canViewAuditLog, - canViewHealth, canViewConnectionLog, canViewAIBridge, + canViewAISettings, + canViewHealth, }) => { return (
diff --git a/site/src/modules/management/AISettingsSidebar.tsx b/site/src/modules/management/AISettingsSidebar.tsx index c01bbb528597f..998b0427ed57a 100644 --- a/site/src/modules/management/AISettingsSidebar.tsx +++ b/site/src/modules/management/AISettingsSidebar.tsx @@ -1,8 +1,11 @@ +import type { FC } from "react"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import AISettingsSidebarView from "#/modules/management/AISettingsSidebarView"; /** * A sidebar for AI settings. */ -export const AISettingsSidebar: React.FC = () => { - return ; +export const AISettingsSidebar: FC = () => { + const { permissions } = useAuthenticated(); + return ; }; diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index 81447b4e5c559..894c824656862 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -1,13 +1,35 @@ +import { ArrowUpRightIcon } from "lucide-react"; +import type { FC } from "react"; import { Sidebar as BaseSidebar, SettingsSidebarNavItem as SidebarNavItem, } from "#/components/Sidebar/Sidebar"; +import type { Permissions } from "#/modules/permissions"; -const AISettingsSidebarView: React.FC = () => { +interface AISettingsSidebarViewProps { + /** Site-wide permissions. */ + permissions: Permissions; +} + +const AISettingsSidebarView: FC = ({ + permissions, +}) => { return (
+ {permissions.viewDeploymentConfig && ( + + AI Governance + + )} Providers + {permissions.editDeploymentConfig && ( + +
+ Manage Coder Agents +
+
+ )}
); diff --git a/site/src/modules/management/DeploymentSidebarView.tsx b/site/src/modules/management/DeploymentSidebarView.tsx index 2e6306ac10e90..18ae75f57691b 100644 --- a/site/src/modules/management/DeploymentSidebarView.tsx +++ b/site/src/modules/management/DeploymentSidebarView.tsx @@ -75,11 +75,7 @@ export const DeploymentSidebarView: FC = ({ Observability )} - {permissions.viewDeploymentConfig && ( - - AI Governance - - )} + {permissions.viewAllUsers && ( Users )} @@ -105,13 +101,6 @@ export const DeploymentSidebarView: FC = ({ {!hasPremiumLicense && ( Premium )} - {permissions.editDeploymentConfig && ( - -
- Manage Coder Agents -
-
- )}
); diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx index 6e1ce838e9cf5..7141550fdcb8c 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx @@ -1,5 +1,6 @@ import { ArrowLeftIcon, + ArrowUpRightIcon, BotIcon, BoxesIcon, ChevronRightIcon, @@ -165,9 +166,9 @@ export const SettingsPanel: FC = ({ } /> - } - /> + } /> } /> }> + }> + } /> + } /> } /> Date: Tue, 26 May 2026 19:46:26 +0200 Subject: [PATCH 020/249] fix: extract key when BYOK header is given with delegated auth (#25688) Previously we were only extracting the API when _not_ delegating auth; this is incorrect. We need to extract the key _always_ when BYOK is intended. --------- Signed-off-by: Danny Kopping Co-authored-by: Claude Opus 4.7 (1M context) --- coderd/aibridged/aibridged_test.go | 26 ++++++++++++++---- coderd/aibridged/http.go | 43 +++++++++++++++--------------- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/coderd/aibridged/aibridged_test.go b/coderd/aibridged/aibridged_test.go index caa162888ce00..8b29b2653aaa1 100644 --- a/coderd/aibridged/aibridged_test.go +++ b/coderd/aibridged/aibridged_test.go @@ -196,6 +196,10 @@ func TestServeHTTP_DelegatedAPIKey(t *testing.T) { expectAbsent []string }{ { + // Delegated + centralized: identity comes from the + // api key ID on the context, in lieu of a session + // token. No header credentials are sent and SessionKey + // is empty downstream. name: "valid centralized", applyMocks: func(t *testing.T, client *mock.MockDRPCClient, pool *mock.MockPooler, mockH *mockHandler) { client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).DoAndReturn( @@ -208,7 +212,12 @@ func TestServeHTTP_DelegatedAPIKey(t *testing.T) { Username: "u", }, nil }) - pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockH, nil) + pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, req aibridged.Request, _ aibridged.ClientFunc, _ aibridged.MCPProxyBuilder) (http.Handler, error) { + assert.Empty(t, req.SessionKey, + "delegated centralized request carries no session token") + return mockH, nil + }) }, expectStatus: http.StatusOK, expectHandled: true, @@ -222,18 +231,25 @@ func TestServeHTTP_DelegatedAPIKey(t *testing.T) { name: "valid BYOK preserves user credentials", reqHeaders: map[string]string{ // Marks BYOK; this header must be stripped before - // forwarding upstream. - agplaibridge.HeaderCoderToken: "should-not-be-present", + // forwarding upstream. Its value is what gets + // surfaced downstream as the SessionKey because + // ExtractAuthToken prefers HeaderCoderToken. + agplaibridge.HeaderCoderToken: "coder-token-byok", // The user's own LLM credential; must be preserved. "Authorization": "Bearer sk-ant-oat01-user-token", }, - applyMocks: func(_ *testing.T, client *mock.MockDRPCClient, pool *mock.MockPooler, mockH *mockHandler) { + applyMocks: func(t *testing.T, client *mock.MockDRPCClient, pool *mock.MockPooler, mockH *mockHandler) { client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).Return(&proto.IsAuthorizedResponse{ OwnerId: uuid.NewString(), ApiKeyId: testKeyID, Username: "u", }, nil) - pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockH, nil) + pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, req aibridged.Request, _ aibridged.ClientFunc, _ aibridged.MCPProxyBuilder) (http.Handler, error) { + assert.Equal(t, "coder-token-byok", req.SessionKey, + "BYOK delegated request must still surface the extracted Coder token as SessionKey") + return mockH, nil + }) }, expectStatus: http.StatusOK, expectHandled: true, diff --git a/coderd/aibridged/http.go b/coderd/aibridged/http.go index 5d75fa623f070..640716f37a9c2 100644 --- a/coderd/aibridged/http.go +++ b/coderd/aibridged/http.go @@ -64,30 +64,31 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) { // the user's own LLM credentials in Authorization/X-Api-Key when BYOK // is in effect. var ( - authReq *proto.IsAuthorizedRequest - sessionKey string - delegated bool + authReq *proto.IsAuthorizedRequest ) - if delegatedID, ok := agplaibridge.DelegatedAPIKeyIDFromContext(ctx); ok { + + delegatedID, delegated := agplaibridge.DelegatedAPIKeyIDFromContext(ctx) + + key := strings.TrimSpace(agplaibridge.ExtractAuthToken(r.Header)) + + // When a BYOK header is present, a key is ALWAYS required. + // Delegated auth only requires a key when using BYOK. + if key == "" && !delegated { + // Some clients (e.g. Claude) send a HEAD request + // without credentials to check connectivity. + if r.Method == http.MethodHead { + logger.Info(ctx, "unauthenticated HEAD request") + } else { + logger.Warn(ctx, "no auth key provided") + } + http.Error(rw, ErrNoAuthKey.Error(), http.StatusBadRequest) + return + } + + if delegated { authReq = &proto.IsAuthorizedRequest{KeyId: delegatedID} - delegated = true - // SessionKey is consumed only by the injected MCP path, which is - // not available to delegated callers (they have no secret). } else { - key := strings.TrimSpace(agplaibridge.ExtractAuthToken(r.Header)) - if key == "" { - // Some clients (e.g. Claude) send a HEAD request - // without credentials to check connectivity. - if r.Method == http.MethodHead { - logger.Info(ctx, "unauthenticated HEAD request") - } else { - logger.Warn(ctx, "no auth key provided") - } - http.Error(rw, ErrNoAuthKey.Error(), http.StatusBadRequest) - return - } authReq = &proto.IsAuthorizedRequest{Key: key} - sessionKey = key } // Strip every header that may carry the Coder token so it is never @@ -151,7 +152,7 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) { } handler, err := s.GetRequestHandler(ctx, Request{ - SessionKey: sessionKey, + SessionKey: key, APIKeyID: resp.ApiKeyId, InitiatorID: id, }) From 5ab5e070121b9767d8a82a3969cce404f517e2de Mon Sep 17 00:00:00 2001 From: uzair-coder07 Date: Tue, 26 May 2026 13:13:41 -0500 Subject: [PATCH 021/249] docs: fix multi-select form type description (#25685) The `multi-select` form type description in the dynamic parameters docs incorrectly stated it renders checkboxes. The actual UI is a searchable dropdown combobox (`MultiSelectCombobox`) with selected items shown as removable chips. > This PR was authored by Coder Agents on behalf of @uzair-coder07. --- docs/admin/templates/extending-templates/dynamic-parameters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin/templates/extending-templates/dynamic-parameters.md b/docs/admin/templates/extending-templates/dynamic-parameters.md index d45323fdcbd6d..b0f0229ca23fa 100644 --- a/docs/admin/templates/extending-templates/dynamic-parameters.md +++ b/docs/admin/templates/extending-templates/dynamic-parameters.md @@ -124,7 +124,7 @@ where each option has a `name` (displayed to the user) and a `value` (used in yo |----------------|--------------------------------------------|---------|------------------------------------------------------------------------------------------------------------------------| | `radio` | `string`, `number`, `bool`, `list(string)` | Yes | Radio buttons for selecting a single option with all choices visible at once.
The classic parameter option. | | `dropdown` | `string`, `number` | Yes | Choose a single option from a searchable dropdown list.
Default for `string` or `number` parameters with options. | -| `multi-select` | `list(string)` | Yes | Select multiple items from a list with checkboxes. | +| `multi-select` | `list(string)` | Yes | Select multiple items from a searchable dropdown list.
Selected items are shown as removable chips. | | `tag-select` | `list(string)` | No | Default for `list(string)` parameters without options. | | `input` | `string`, `number` | No | Standard single-line text input field.
Default for `string/number` parameters without options. | | `textarea` | `string` | No | Multi-line text input field for longer content. | From 7887cff9d03a886edd65506d3cfef3904d5277d8 Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Tue, 26 May 2026 14:51:00 -0400 Subject: [PATCH 022/249] feat: add user secrets management page (#25371) Adds the account settings UI for managing user secrets, including the table, add/edit/delete dialog, Storybook coverage, and route/sidebar entry. Also updates the shared `FeatureStageBadge` beta variant with dedicated beta styling, sizing, and label casing for the Secrets page. Stacked on #25370. _This PR was generated by Coder Agents._ --- docs/user-guides/user-secrets.md | 3 + site/e2e/helpers.ts | 16 +- site/src/api/queries/userSecrets.ts | 52 ++ .../FeatureStageBadge.stories.tsx | 9 + .../FeatureStageBadge/FeatureStageBadge.tsx | 27 +- site/src/components/Textarea/Textarea.tsx | 2 +- site/src/index.css | 2 + .../SecretsPage/SecretDialog.tsx | 463 ++++++++++++++ .../SecretsPage/SecretsPage.tsx | 72 +++ .../SecretsPage/SecretsPageView.stories.tsx | 594 ++++++++++++++++++ .../SecretsPage/SecretsPageView.tsx | 155 +++++ .../SecretsPage/SecretsTable.tsx | 250 ++++++++ .../SecretsPage/secretForm.test.ts | 56 +- .../SecretsPage/secretForm.ts | 15 +- site/src/pages/UserSettingsPage/Sidebar.tsx | 11 + site/src/router.tsx | 4 + site/src/testHelpers/entities.ts | 14 +- 17 files changed, 1701 insertions(+), 44 deletions(-) create mode 100644 site/src/api/queries/userSecrets.ts create mode 100644 site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx create mode 100644 site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx create mode 100644 site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx create mode 100644 site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx create mode 100644 site/src/pages/UserSettingsPage/SecretsPage/SecretsTable.tsx diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index d3b6cc45b6c89..70e2f2bc785a4 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -79,6 +79,9 @@ distinct paths to avoid the collision. ## Create a secret +You can create, edit, and delete user secrets in the Coder dashboard. Click your +avatar, select **Account**, then select **Secrets**. + Use `coder secret create ` to create a user secret. For sensitive values, provide the value through non-interactive stdin with a pipe or redirect. This keeps the value out of your shell history and process arguments. diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 38205be20d839..dc68cba15f2ae 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -432,7 +432,7 @@ export const startWorkspaceWithEphemeralParameters = async ( await fillParameters(page, richParameters, buildParameters); - await page.getByRole("button", { name: /update and start/i }).click(); + await clickWorkspaceUpdateSubmit(page, /update and start/i); await page.waitForSelector("text=Workspace status: Running", { state: "visible", @@ -1107,6 +1107,12 @@ const fillParameters = async ( } }; +const clickWorkspaceUpdateSubmit = async (page: Page, name: RegExp) => { + const submitButton = page.getByRole("button", { name }); + await expect(submitButton).toBeEnabled({ timeout: 30_000 }); + await submitButton.click(); +}; + export const updateTemplate = async ( page: Page, organization: string, @@ -1205,11 +1211,11 @@ export const updateWorkspace = async ( await fillParameters(page, richParameters, buildParameters); if (workspaceStatus === "running") { - await page.getByRole("button", { name: /update and restart/i }).click(); + await clickWorkspaceUpdateSubmit(page, /update and restart/i); // Confirmation dialog. await page.getByRole("button", { name: /restart/i }).click(); } else { - await page.getByRole("button", { name: /update and start/i }).click(); + await clickWorkspaceUpdateSubmit(page, /update and start/i); } }; @@ -1228,11 +1234,11 @@ export const updateWorkspaceParameters = async ( await fillParameters(page, richParameters, buildParameters); if (workspaceStatus === "running") { - await page.getByRole("button", { name: /update and restart/i }).click(); + await clickWorkspaceUpdateSubmit(page, /update and restart/i); // Confirmation dialog. await page.getByRole("button", { name: /restart/i }).click(); } else { - await page.getByRole("button", { name: /update and start/i }).click(); + await clickWorkspaceUpdateSubmit(page, /update and start/i); } await page.waitForSelector("text=Workspace status: Running", { diff --git a/site/src/api/queries/userSecrets.ts b/site/src/api/queries/userSecrets.ts new file mode 100644 index 0000000000000..40463d7851070 --- /dev/null +++ b/site/src/api/queries/userSecrets.ts @@ -0,0 +1,52 @@ +import type { QueryClient } from "react-query"; +import { API } from "#/api/api"; +import type * as TypesGen from "#/api/typesGenerated"; + +const userSecretsKey = (userId: string) => ["users", userId, "secrets"]; + +export const userSecrets = (userId: string) => { + return { + queryKey: userSecretsKey(userId), + queryFn: () => API.getUserSecrets(userId), + }; +}; + +export const createUserSecret = (queryClient: QueryClient, userId: string) => { + return { + mutationFn: (request: TypesGen.CreateUserSecretRequest) => + API.createUserSecret(userId, request), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: userSecretsKey(userId), + }); + }, + }; +}; + +export const updateUserSecret = (queryClient: QueryClient, userId: string) => { + return { + mutationFn: ({ + name, + request, + }: { + name: string; + request: TypesGen.UpdateUserSecretRequest; + }) => API.updateUserSecret(userId, name, request), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: userSecretsKey(userId), + }); + }, + }; +}; + +export const deleteUserSecret = (queryClient: QueryClient, userId: string) => { + return { + mutationFn: (name: string) => API.deleteUserSecret(userId, name), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: userSecretsKey(userId), + }); + }, + }; +}; diff --git a/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx b/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx index 520970cd5440e..fe1c8489889b5 100644 --- a/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx +++ b/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { chromatic } from "#/testHelpers/chromatic"; import { FeatureStageBadge } from "./FeatureStageBadge"; const meta: Meta = { title: "components/FeatureStageBadge", component: FeatureStageBadge, + parameters: { chromatic }, args: { contentType: "beta", }, @@ -19,6 +21,13 @@ export const ExtraSmallBeta: Story = { }, }; +export const ExtraSmallEarlyAccess: Story = { + args: { + size: "xs", + contentType: "early_access", + }, +}; + export const SmallBeta: Story = { args: { size: "sm", diff --git a/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx index 5c7621969697f..bedfd6222c590 100644 --- a/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx +++ b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx @@ -13,8 +13,8 @@ import { docs } from "#/utils/docs"; * ensure that we can't accidentally make typos when writing the badge text. */ const featureStageBadgeTypes = { - early_access: "early access", - beta: "beta", + early_access: "Early Access", + beta: "Beta", } as const satisfies Record; type FeatureStageBadgeProps = Readonly< @@ -26,14 +26,21 @@ type FeatureStageBadgeProps = Readonly< >; const badgeColorClasses = { - early_access: "bg-surface-orange text-content-warning", + early_access: "border-border-pending bg-surface-sky text-highlight-sky", beta: "bg-surface-sky text-highlight-sky", } as const; const badgeSizeClasses = { - xs: "text-2xs font-normal px-1.5 py-0.5 h-[18px] rounded border-0", - sm: "text-xs font-medium px-2 py-1", - md: "text-base px-2 py-1", + early_access: { + xs: "rounded-[5px] px-1.5 py-0.5 text-2xs font-normal leading-4", + sm: "rounded-[5px] px-2 py-0.5 text-[10px] font-normal leading-4", + md: "rounded-[5px] px-[7px] py-[3.5px] text-xs font-normal leading-4", + }, + beta: { + xs: "text-2xs font-normal px-1.5 py-0.5 h-[18px] rounded border-0", + sm: "text-xs font-medium px-2 py-1", + md: "text-base px-2 py-1", + }, } as const; export const FeatureStageBadge: FC = ({ @@ -44,21 +51,23 @@ export const FeatureStageBadge: FC = ({ ...delegatedProps }) => { const colorClasses = badgeColorClasses[contentType]; - const sizeClasses = badgeSizeClasses[size]; + const sizeClasses = badgeSizeClasses[contentType][size]; return ( - (This is a + + {` (This is ${contentType === "early_access" ? "an" : "a"} `} + {labelText && `${labelText} `} {featureStageBadgeTypes[contentType]} diff --git a/site/src/components/Textarea/Textarea.tsx b/site/src/components/Textarea/Textarea.tsx index 51a248e5adf54..f735b73d4c04a 100644 --- a/site/src/components/Textarea/Textarea.tsx +++ b/site/src/components/Textarea/Textarea.tsx @@ -11,7 +11,7 @@ export const Textarea: React.FC> = ({ return (