From afd1c7092f1fc2809ae7a23818894bc3adecaaf0 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 12 Aug 2025 16:26:26 +0000 Subject: [PATCH 1/5] feat(coderd): generate task name based on prompt using llm Generate the name of a task by querying an LLM --- coderd/aitasks.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index e1d72f264a025..60fe19021c1c1 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -1,15 +1,21 @@ package coderd import ( + "context" "database/sql" "errors" "fmt" + "io" "net/http" + "os" "slices" "strings" + "github.com/anthropics/anthropic-sdk-go" + anthropicoption "github.com/anthropics/anthropic-sdk-go/option" "github.com/google/uuid" + "github.com/coder/aisdk-go" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" @@ -69,6 +75,69 @@ func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) { }) } +func (api *API) generateTaskName(ctx context.Context, prompt, fallback string) (string, error) { + // TODO(DanielleMaywood): + // Should we extract this out into our typical coderd option handling? + anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY") + + // The deployment doesn't have a valid external cloud AI provider, so we'll + // fallback to the user supplied name for now. + if anthropicAPIKey == "" { + return fallback, nil + } + + anthropicClient := anthropic.NewClient(anthropicoption.WithAPIKey(anthropicAPIKey)) + + messages, system, err := aisdk.MessagesToAnthropic([]aisdk.Message{ + { + Role: "system", + Parts: []aisdk.Part{{ + Type: aisdk.PartTypeText, + Text: ` + You are a task summarizer. + You summarize AI prompts into workspace names. + You will only respond with a workspace name. + The workspace name **MUST** follow this regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + The workspace name **MUST** be 32 characters or **LESS**. + The workspace name **MUST** be all lower case. + The workspace name **MUST** end in a number between 0 and 100. + The workspace name **MUST** be prefixed with "task". + `, + }}, + }, + { + Role: "user", + Parts: []aisdk.Part{{ + Type: aisdk.PartTypeText, + Text: prompt, + }}, + }, + }) + if err != nil { + return "", err + } + + stream := aisdk.AnthropicToDataStream(anthropicClient.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaude3_5HaikuLatest, + Messages: messages, + System: system, + MaxTokens: 24, + })) + + var acc aisdk.DataStreamAccumulator + stream = stream.WithAccumulator(&acc) + + if err := stream.Pipe(io.Discard); err != nil { + return "", err + } + + if len(acc.Messages()) == 0 { + return fallback, nil + } + + return acc.Messages()[0].Content, nil +} + // This endpoint is experimental and not guaranteed to be stable, so we're not // generating public-facing documentation for it. func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { @@ -104,8 +173,21 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { return } + taskName, err := api.generateTaskName(ctx, req.Prompt, req.Name) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error generating name for task.", + Detail: err.Error(), + }) + return + } + + if taskName == "" { + taskName = req.Name + } + createReq := codersdk.CreateWorkspaceRequest{ - Name: req.Name, + Name: taskName, TemplateVersionID: req.TemplateVersionID, TemplateVersionPresetID: req.TemplateVersionPresetID, RichParameterValues: []codersdk.WorkspaceBuildParameter{ From 1ed234e91ec97a0b7dbbd914dda377e89841cdb8 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 12 Aug 2025 16:41:55 +0000 Subject: [PATCH 2/5] refactor: slightly --- coderd/aitasks.go | 70 +++++++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 60fe19021c1c1..6b3f439e252ef 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -14,6 +14,7 @@ import ( "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/aisdk-go" "github.com/coder/coder/v2/coderd/audit" @@ -76,33 +77,26 @@ func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) { } func (api *API) generateTaskName(ctx context.Context, prompt, fallback string) (string, error) { - // TODO(DanielleMaywood): - // Should we extract this out into our typical coderd option handling? - anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY") - - // The deployment doesn't have a valid external cloud AI provider, so we'll - // fallback to the user supplied name for now. - if anthropicAPIKey == "" { - return fallback, nil - } - - anthropicClient := anthropic.NewClient(anthropicoption.WithAPIKey(anthropicAPIKey)) + var ( + stream aisdk.DataStream + err error + ) - messages, system, err := aisdk.MessagesToAnthropic([]aisdk.Message{ + conversation := []aisdk.Message{ { Role: "system", Parts: []aisdk.Part{{ Type: aisdk.PartTypeText, Text: ` - You are a task summarizer. - You summarize AI prompts into workspace names. - You will only respond with a workspace name. - The workspace name **MUST** follow this regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/ - The workspace name **MUST** be 32 characters or **LESS**. - The workspace name **MUST** be all lower case. - The workspace name **MUST** end in a number between 0 and 100. - The workspace name **MUST** be prefixed with "task". - `, + You are a task summarizer. + You summarize AI prompts into workspace names. + You will only respond with a workspace name. + The workspace name **MUST** follow this regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + The workspace name **MUST** be 32 characters or **LESS**. + The workspace name **MUST** be all lower case. + The workspace name **MUST** end in a number between 0 and 100. + The workspace name **MUST** be prefixed with "task". + `, }}, }, { @@ -112,17 +106,19 @@ func (api *API) generateTaskName(ctx context.Context, prompt, fallback string) ( Text: prompt, }}, }, - }) - if err != nil { - return "", err } - stream := aisdk.AnthropicToDataStream(anthropicClient.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ - Model: anthropic.ModelClaude3_5HaikuLatest, - Messages: messages, - System: system, - MaxTokens: 24, - })) + if anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY"); anthropicAPIKey != "" { + anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY") + anthropicClient := anthropic.NewClient(anthropicoption.WithAPIKey(anthropicAPIKey)) + + stream, err = anthropicDataStream(ctx, anthropicClient, conversation) + if err != nil { + return "", xerrors.Errorf("create anthropic data stream: %w", err) + } + } else { + return fallback, nil + } var acc aisdk.DataStreamAccumulator stream = stream.WithAccumulator(&acc) @@ -138,6 +134,20 @@ func (api *API) generateTaskName(ctx context.Context, prompt, fallback string) ( return acc.Messages()[0].Content, nil } +func anthropicDataStream(ctx context.Context, client anthropic.Client, input []aisdk.Message) (aisdk.DataStream, error) { + messages, system, err := aisdk.MessagesToAnthropic(input) + if err != nil { + return nil, xerrors.Errorf("convert messages to anthropic format: %w", err) + } + + return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaude3_5HaikuLatest, + MaxTokens: 24, + System: system, + Messages: messages, + })), nil +} + // This endpoint is experimental and not guaranteed to be stable, so we're not // generating public-facing documentation for it. func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { From 706c78915e4da9f52a9e54f8bd8229cf9db07b8f Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 13 Aug 2025 09:21:44 +0000 Subject: [PATCH 3/5] refactor: slightly again --- cli/server.go | 10 ++++++++++ coderd/aitasks.go | 27 +++++++++++---------------- coderd/coderd.go | 12 +++++++++++- codersdk/deployment.go | 8 ++++++++ 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/cli/server.go b/cli/server.go index f9e744761b22e..4d78cb47e475f 100644 --- a/cli/server.go +++ b/cli/server.go @@ -31,6 +31,8 @@ import ( "sync/atomic" "time" + "github.com/anthropics/anthropic-sdk-go" + anthropicoption "github.com/anthropics/anthropic-sdk-go/option" "github.com/charmbracelet/lipgloss" "github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-systemd/daemon" @@ -629,6 +631,13 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. vals.WorkspaceHostnameSuffix.String()) } + var anthropicClient atomic.Pointer[anthropic.Client] + if vals.AnthropicAPIKey.String() != "" { + client := anthropic.NewClient(anthropicoption.WithAPIKey(vals.AnthropicAPIKey.String())) + + anthropicClient.Store(&client) + } + options := &coderd.Options{ AccessURL: vals.AccessURL.Value(), AppHostname: appHostname, @@ -666,6 +675,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. AllowWorkspaceRenames: vals.AllowWorkspaceRenames.Value(), Entitlements: entitlements.New(), NotificationsEnqueuer: notifications.NewNoopEnqueuer(), // Changed further down if notifications enabled. + AnthropicClient: &anthropicClient, } if httpServers.TLSConfig != nil { options.TLSCertificates = httpServers.TLSConfig.Certificates diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 6b3f439e252ef..42dbc71fa33fa 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -7,12 +7,10 @@ import ( "fmt" "io" "net/http" - "os" "slices" "strings" "github.com/anthropics/anthropic-sdk-go" - anthropicoption "github.com/anthropics/anthropic-sdk-go/option" "github.com/google/uuid" "golang.org/x/xerrors" @@ -88,15 +86,15 @@ func (api *API) generateTaskName(ctx context.Context, prompt, fallback string) ( Parts: []aisdk.Part{{ Type: aisdk.PartTypeText, Text: ` - You are a task summarizer. - You summarize AI prompts into workspace names. - You will only respond with a workspace name. - The workspace name **MUST** follow this regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/ - The workspace name **MUST** be 32 characters or **LESS**. - The workspace name **MUST** be all lower case. - The workspace name **MUST** end in a number between 0 and 100. - The workspace name **MUST** be prefixed with "task". - `, + You are a task summarizer. + You summarize AI prompts into workspace names. + You will only respond with a workspace name. + The workspace name **MUST** follow this regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + The workspace name **MUST** be 32 characters or **LESS**. + The workspace name **MUST** be all lower case. + The workspace name **MUST** end in a number between 0 and 100. + The workspace name **MUST** be prefixed with "task". + `, }}, }, { @@ -108,11 +106,8 @@ func (api *API) generateTaskName(ctx context.Context, prompt, fallback string) ( }, } - if anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY"); anthropicAPIKey != "" { - anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY") - anthropicClient := anthropic.NewClient(anthropicoption.WithAPIKey(anthropicAPIKey)) - - stream, err = anthropicDataStream(ctx, anthropicClient, conversation) + if anthropicClient := api.anthropicClient.Load(); anthropicClient != nil { + stream, err = anthropicDataStream(ctx, *anthropicClient, conversation) if err != nil { return "", xerrors.Errorf("create anthropic data stream: %w", err) } diff --git a/coderd/coderd.go b/coderd/coderd.go index 2aa30c9d7a45c..988ecca11960b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -20,6 +20,7 @@ import ( "sync/atomic" "time" + "github.com/anthropics/anthropic-sdk-go" "github.com/coder/coder/v2/coderd/oauth2provider" "github.com/coder/coder/v2/coderd/pproflabel" "github.com/coder/coder/v2/coderd/prebuilds" @@ -276,6 +277,8 @@ type Options struct { // WebPushDispatcher is a way to send notifications over Web Push. WebPushDispatcher webpush.Dispatcher + + AnthropicClient *atomic.Pointer[anthropic.Client] } // @title Coder API @@ -475,6 +478,10 @@ func New(options *Options) *API { options.NotificationsEnqueuer = notifications.NewNoopEnqueuer() } + if options.AnthropicClient == nil { + options.AnthropicClient = &atomic.Pointer[anthropic.Client]{} + } + r := chi.NewRouter() // We add this middleware early, to make sure that authorization checks made // by other middleware get recorded. @@ -600,7 +607,8 @@ func New(options *Options) *API { options.Database, options.Pubsub, ), - dbRolluper: options.DatabaseRolluper, + dbRolluper: options.DatabaseRolluper, + anthropicClient: options.AnthropicClient, } api.WorkspaceAppsProvider = workspaceapps.NewDBTokenProvider( options.Logger.Named("workspaceapps"), @@ -1723,6 +1731,8 @@ type API struct { // dbRolluper rolls up template usage stats from raw agent and app // stats. This is used to provide insights in the WebUI. dbRolluper *dbrollup.Rolluper + + anthropicClient *atomic.Pointer[anthropic.Client] } // Close waits for all WebSocket connections to drain before returning. diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 1d6fa4572772e..9ffe71aa229a6 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -497,6 +497,7 @@ type DeploymentValues struct { WorkspaceHostnameSuffix serpent.String `json:"workspace_hostname_suffix,omitempty" typescript:",notnull"` Prebuilds PrebuildsConfig `json:"workspace_prebuilds,omitempty" typescript:",notnull"` HideAITasks serpent.Bool `json:"hide_ai_tasks,omitempty" typescript:",notnull"` + AnthropicAPIKey serpent.String `json:"anthropic_api_key,omitempty" typescript:",notnull"` Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -3205,6 +3206,13 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupClient, YAML: "hideAITasks", }, + { + Name: "Anthropic API Key", + Description: "API Key for accessing Anthropic's API platform.", + Env: "ANTHROPIC_API_KEY", + Value: &c.AnthropicAPIKey, + Group: &deploymentGroupClient, + }, } return opts From 29f446a16edb798141ccf5c5cafb14c3c230a969 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 13 Aug 2025 09:34:36 +0000 Subject: [PATCH 4/5] refactor: remove space from prompt --- coderd/aitasks.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 42dbc71fa33fa..46439ca47526a 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -85,16 +85,14 @@ func (api *API) generateTaskName(ctx context.Context, prompt, fallback string) ( Role: "system", Parts: []aisdk.Part{{ Type: aisdk.PartTypeText, - Text: ` - You are a task summarizer. - You summarize AI prompts into workspace names. - You will only respond with a workspace name. - The workspace name **MUST** follow this regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/ - The workspace name **MUST** be 32 characters or **LESS**. - The workspace name **MUST** be all lower case. - The workspace name **MUST** end in a number between 0 and 100. - The workspace name **MUST** be prefixed with "task". - `, + Text: `You are a task summarizer. +You summarize AI prompts into workspace names. +You will only respond with a workspace name. +The workspace name **MUST** follow this regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +The workspace name **MUST** be 32 characters or **LESS**. +The workspace name **MUST** be all lower case. +The workspace name **MUST** end in a number between 0 and 100. +The workspace name **MUST** be prefixed with "task".`, }}, }, { From 7bd118e611d57ce8223fb392da6beb756416c3e3 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 13 Aug 2025 11:16:11 +0000 Subject: [PATCH 5/5] chore: appease linter and formatter --- coderd/aitasks.go | 13 +++++++------ coderd/coderd.go | 1 + go.mod | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 46439ca47526a..0c45a880ac1f8 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -104,15 +104,16 @@ The workspace name **MUST** be prefixed with "task".`, }, } - if anthropicClient := api.anthropicClient.Load(); anthropicClient != nil { - stream, err = anthropicDataStream(ctx, *anthropicClient, conversation) - if err != nil { - return "", xerrors.Errorf("create anthropic data stream: %w", err) - } - } else { + anthropicClient := api.anthropicClient.Load() + if anthropicClient == nil { return fallback, nil } + stream, err = anthropicDataStream(ctx, *anthropicClient, conversation) + if err != nil { + return "", xerrors.Errorf("create anthropic data stream: %w", err) + } + var acc aisdk.DataStreamAccumulator stream = stream.WithAccumulator(&acc) diff --git a/coderd/coderd.go b/coderd/coderd.go index 988ecca11960b..39724c174b972 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -21,6 +21,7 @@ import ( "time" "github.com/anthropics/anthropic-sdk-go" + "github.com/coder/coder/v2/coderd/oauth2provider" "github.com/coder/coder/v2/coderd/pproflabel" "github.com/coder/coder/v2/coderd/prebuilds" diff --git a/go.mod b/go.mod index e10c7a248db7e..6d703cdd1245e 100644 --- a/go.mod +++ b/go.mod @@ -477,6 +477,7 @@ require ( ) require ( + github.com/anthropics/anthropic-sdk-go v1.4.0 github.com/brianvoe/gofakeit/v7 v7.3.0 github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 github.com/coder/aisdk-go v0.0.9 @@ -500,7 +501,6 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect - github.com/anthropics/anthropic-sdk-go v1.4.0 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/aquasecurity/trivy v0.58.2 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect