mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
347ab5b348
The previous logic verified a generated name was valid, _and then appended a suffix to it_. This was flawed as it would allow a 32 character name, and then append an extra 5 characters to it. Instead we now append the suffix _and then_ verify it is valid.
180 lines
4.6 KiB
Go
180 lines
4.6 KiB
Go
package taskname
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"math/rand/v2"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/anthropics/anthropic-sdk-go"
|
|
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
|
"github.com/moby/moby/pkg/namesgenerator"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/aisdk-go"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
const (
|
|
defaultModel = anthropic.ModelClaude3_5HaikuLatest
|
|
systemPrompt = `Generate a short workspace name from this AI task prompt.
|
|
|
|
Requirements:
|
|
- Only lowercase letters, numbers, and hyphens
|
|
- Start with "task-"
|
|
- Maximum 27 characters total
|
|
- Descriptive of the main task
|
|
|
|
Examples:
|
|
- "Help me debug a Python script" → "task-python-debug"
|
|
- "Create a React dashboard component" → "task-react-dashboard"
|
|
- "Analyze sales data from Q3" → "task-analyze-q3-sales"
|
|
- "Set up CI/CD pipeline" → "task-setup-cicd"
|
|
|
|
If you cannot create a suitable name:
|
|
- Respond with "task-unnamed"`
|
|
)
|
|
|
|
var (
|
|
ErrNoAPIKey = xerrors.New("no api key provided")
|
|
ErrNoNameGenerated = xerrors.New("no task name generated")
|
|
)
|
|
|
|
type options struct {
|
|
apiKey string
|
|
model anthropic.Model
|
|
}
|
|
|
|
type Option func(o *options)
|
|
|
|
func WithAPIKey(apiKey string) Option {
|
|
return func(o *options) {
|
|
o.apiKey = apiKey
|
|
}
|
|
}
|
|
|
|
func WithModel(model anthropic.Model) Option {
|
|
return func(o *options) {
|
|
o.model = model
|
|
}
|
|
}
|
|
|
|
func GetAnthropicAPIKeyFromEnv() string {
|
|
return os.Getenv("ANTHROPIC_API_KEY")
|
|
}
|
|
|
|
func GetAnthropicModelFromEnv() anthropic.Model {
|
|
return anthropic.Model(os.Getenv("ANTHROPIC_MODEL"))
|
|
}
|
|
|
|
// generateSuffix generates a random hex string between `0000` and `ffff`.
|
|
func generateSuffix() string {
|
|
numMin := 0x00000
|
|
numMax := 0x10000
|
|
//nolint:gosec // We don't need a cryptographically secure random number generator for generating a task name suffix.
|
|
num := rand.IntN(numMax-numMin) + numMin
|
|
|
|
return fmt.Sprintf("%04x", num)
|
|
}
|
|
|
|
func GenerateFallback() string {
|
|
// We have a 32 character limit for the name.
|
|
// We have a 5 character prefix `task-`.
|
|
// We have a 5 character suffix `-ffff`.
|
|
// This leaves us with 22 characters for the middle.
|
|
//
|
|
// Unfortunately, `namesgenerator.GetRandomName(0)` will
|
|
// generate names that are longer than 22 characters, so
|
|
// we just trim these down to length.
|
|
name := strings.ReplaceAll(namesgenerator.GetRandomName(0), "_", "-")
|
|
name = name[:min(len(name), 22)]
|
|
name = strings.TrimSuffix(name, "-")
|
|
|
|
return fmt.Sprintf("task-%s-%s", name, generateSuffix())
|
|
}
|
|
|
|
func Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
|
|
o := options{}
|
|
for _, opt := range opts {
|
|
opt(&o)
|
|
}
|
|
|
|
if o.model == "" {
|
|
o.model = defaultModel
|
|
}
|
|
if o.apiKey == "" {
|
|
return "", ErrNoAPIKey
|
|
}
|
|
|
|
conversation := []aisdk.Message{
|
|
{
|
|
Role: "system",
|
|
Parts: []aisdk.Part{{
|
|
Type: aisdk.PartTypeText,
|
|
Text: systemPrompt,
|
|
}},
|
|
},
|
|
{
|
|
Role: "user",
|
|
Parts: []aisdk.Part{{
|
|
Type: aisdk.PartTypeText,
|
|
Text: prompt,
|
|
}},
|
|
},
|
|
}
|
|
|
|
anthropicOptions := anthropic.DefaultClientOptions()
|
|
anthropicOptions = append(anthropicOptions, anthropicoption.WithAPIKey(o.apiKey))
|
|
anthropicClient := anthropic.NewClient(anthropicOptions...)
|
|
|
|
stream, err := anthropicDataStream(ctx, anthropicClient, o.model, conversation)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("create anthropic data stream: %w", err)
|
|
}
|
|
|
|
var acc aisdk.DataStreamAccumulator
|
|
stream = stream.WithAccumulator(&acc)
|
|
|
|
if err := stream.Pipe(io.Discard); err != nil {
|
|
return "", xerrors.Errorf("pipe data stream")
|
|
}
|
|
|
|
if len(acc.Messages()) == 0 {
|
|
return "", ErrNoNameGenerated
|
|
}
|
|
|
|
taskName := acc.Messages()[0].Content
|
|
if taskName == "task-unnamed" {
|
|
return "", ErrNoNameGenerated
|
|
}
|
|
|
|
// We append a suffix to the end of the task name to reduce
|
|
// the chance of collisions. We truncate the task name to
|
|
// to a maximum of 27 bytes, so that when we append the
|
|
// 5 byte suffix (`-` and 4 byte hex slug), it should
|
|
// remain within the 32 byte workspace name limit.
|
|
taskName = taskName[:min(len(taskName), 27)]
|
|
taskName = fmt.Sprintf("%s-%s", taskName, generateSuffix())
|
|
if err := codersdk.NameValid(taskName); err != nil {
|
|
return "", xerrors.Errorf("generated name %v not valid: %w", taskName, err)
|
|
}
|
|
|
|
return taskName, nil
|
|
}
|
|
|
|
func anthropicDataStream(ctx context.Context, client anthropic.Client, model anthropic.Model, 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: model,
|
|
MaxTokens: 24,
|
|
System: system,
|
|
Messages: messages,
|
|
})), nil
|
|
}
|