diff --git a/README.md b/README.md index 360247b..5bb19a5 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ This means `flare-edge-cli` is not just a deploy wrapper. It is an agent-oriente - Validates Go code against a Workers/Wasm compatibility profile - Builds `.wasm` artifacts and the JavaScript Worker shim - Runs local or remote dev sessions through Wrangler +- Configures Workers AI bindings for Go-based AI Workers - Deploys versioned Workers and manages routes, secrets, KV, D1, R2, and releases - Tears down Workers and optional side-effect resources cleanly @@ -235,10 +236,42 @@ Supported templates: - `edge-http` - `edge-json` - `scheduled` +- `ai-text` +- `ai-chat` +- `ai-vision` +- `ai-stt` +- `ai-tts` +- `ai-image` +- `ai-embeddings` - `kv-api` - `d1-api` - `r2-api` +AI templates: + +- `ai-text`: backward-compatible simple text inference scaffold, now using `@cf/moonshotai/kimi-k2.5` +- `ai-chat`: chat-style text generation scaffold using `@cf/moonshotai/kimi-k2.5` +- `ai-vision`: vision scaffold using `@cf/moonshotai/kimi-k2.5` +- `ai-stt`: speech-to-text scaffold using `@cf/deepgram/nova-3` +- `ai-tts`: text-to-speech scaffold using `@cf/deepgram/aura-2-en` +- `ai-image`: text-to-image scaffold using `@cf/black-forest-labs/flux-2-klein-9b` +- `ai-embeddings`: embeddings scaffold using `@cf/qwen/qwen3-embedding-0.6b` + +### `ai` + +Manage the Workers AI binding stored in local project config. + +```bash +./flare-edge-cli ai binding set [--path ] [--binding ] [--remote] [--json] +./flare-edge-cli ai binding clear [--path ] [--json] +``` + +Notes: + +- `ai binding set` updates both `flare-edge.json` and `wrangler.jsonc` +- the default binding name is `AI` +- `--remote` should remain enabled for Workers AI development + ### `compat` Run the Go/Wasm compatibility analyzer or inspect built-in rules. @@ -427,6 +460,30 @@ Scaffold a JSON worker: ./flare-edge-cli init test-project --module-path github.com/example/test-project --template edge-json ``` +Scaffold a Go-based AI Worker: + +```bash +./flare-edge-cli init ai-worker --module-path github.com/example/ai-worker --template ai-text +``` + +Scaffold a vision Worker: + +```bash +./flare-edge-cli init ai-vision-worker --module-path github.com/example/ai-vision-worker --template ai-vision +``` + +Scaffold a speech-to-text Worker: + +```bash +./flare-edge-cli init ai-stt-worker --module-path github.com/example/ai-stt-worker --template ai-stt +``` + +Add Workers AI to an existing project: + +```bash +./flare-edge-cli ai binding set --path ./ai-worker --binding AI --remote +``` + Check compatibility and emit JSON: ```bash diff --git a/internal/app/app.go b/internal/app/app.go index 4b7f9de..47308e2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,6 +11,7 @@ import ( "github.com/paolo/flare-edge-cli/internal/infra/toolchain" "github.com/paolo/flare-edge-cli/internal/infra/wrangler" "github.com/paolo/flare-edge-cli/internal/logging" + aisvc "github.com/paolo/flare-edge-cli/internal/service/ai" authsvc "github.com/paolo/flare-edge-cli/internal/service/auth" buildsvc "github.com/paolo/flare-edge-cli/internal/service/build" compatsvc "github.com/paolo/flare-edge-cli/internal/service/compat" @@ -43,6 +44,7 @@ func Run() int { deps := cli.Dependencies{ Services: cli.Services{ + AI: aisvc.NewService(store, filesystem), Auth: authsvc.NewService(wranglerClient, authState), Build: buildService, Compat: compatsvc.NewService(), diff --git a/internal/cli/ai.go b/internal/cli/ai.go new file mode 100644 index 0000000..1dbaaea --- /dev/null +++ b/internal/cli/ai.go @@ -0,0 +1,58 @@ +package cli + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/paolo/flare-edge-cli/internal/output" + aisvc "github.com/paolo/flare-edge-cli/internal/service/ai" +) + +func newAICommand(deps Dependencies) *cobra.Command { + cmd := &cobra.Command{Use: "ai", Short: "Manage Workers AI bindings"} + binding := &cobra.Command{Use: "binding", Short: "Manage Workers AI binding config"} + binding.AddCommand(newAIBindingSetCommand(deps), newAIBindingClearCommand(deps)) + cmd.AddCommand(binding) + return cmd +} + +func newAIBindingSetCommand(deps Dependencies) *cobra.Command { + var options aisvc.SetBindingOptions + var jsonOutput bool + cmd := &cobra.Command{ + Use: "set", + Short: "Configure the Workers AI binding", + RunE: func(cmd *cobra.Command, _ []string) error { + result, err := deps.Services.AI.SetBinding(context.Background(), options) + if err != nil { + return err + } + return output.NewPrinter(cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOutput).Print(result) + }, + } + cmd.Flags().StringVar(&options.Dir, "path", ".", "Project path") + cmd.Flags().StringVar(&options.Binding, "binding", "AI", "Workers AI binding name") + cmd.Flags().BoolVar(&options.Remote, "remote", true, "Use the remote Workers AI binding") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Emit machine-readable JSON") + return cmd +} + +func newAIBindingClearCommand(deps Dependencies) *cobra.Command { + var options aisvc.ClearBindingOptions + var jsonOutput bool + cmd := &cobra.Command{ + Use: "clear", + Short: "Remove the Workers AI binding from local config", + RunE: func(cmd *cobra.Command, _ []string) error { + result, err := deps.Services.AI.ClearBinding(context.Background(), options) + if err != nil { + return err + } + return output.NewPrinter(cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOutput).Print(result) + }, + } + cmd.Flags().StringVar(&options.Dir, "path", ".", "Project path") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Emit machine-readable JSON") + return cmd +} diff --git a/internal/cli/aliases.go b/internal/cli/aliases.go index 983effe..0e1096b 100644 --- a/internal/cli/aliases.go +++ b/internal/cli/aliases.go @@ -30,7 +30,7 @@ func newInitAliasCommand(deps Dependencies) *cobra.Command { cmd.Flags().StringVar(&options.Dir, "cwd", ".", "Target parent directory") cmd.Flags().StringVar(&options.ModulePath, "module-path", "", "Go module path") cmd.Flags().StringVar(&options.PackageName, "package", "", "Package name") - cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template") + cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template: edge-http|edge-json|scheduled|kv-api|d1-api|r2-api|ai-text|ai-chat|ai-vision|ai-stt|ai-tts|ai-image|ai-embeddings") cmd.Flags().StringVar(&options.CompatDate, "compat-date", "", "Compatibility date") cmd.Flags().StringVar(&options.Env, "env", "", "Wrangler environment") cmd.Flags().BoolVar(&options.UseJSONC, "use-jsonc", false, "Generate wrangler.jsonc") diff --git a/internal/cli/deps.go b/internal/cli/deps.go index 7848363..40141c0 100644 --- a/internal/cli/deps.go +++ b/internal/cli/deps.go @@ -1,6 +1,7 @@ package cli import ( + aisvc "github.com/paolo/flare-edge-cli/internal/service/ai" authsvc "github.com/paolo/flare-edge-cli/internal/service/auth" buildsvc "github.com/paolo/flare-edge-cli/internal/service/build" compatsvc "github.com/paolo/flare-edge-cli/internal/service/compat" @@ -19,6 +20,7 @@ import ( ) type Services struct { + AI *aisvc.Service Auth *authsvc.Service Build *buildsvc.Service Compat *compatsvc.Service diff --git a/internal/cli/project.go b/internal/cli/project.go index 14ed34f..706d56c 100644 --- a/internal/cli/project.go +++ b/internal/cli/project.go @@ -41,7 +41,7 @@ func newProjectInitCommand(deps Dependencies) *cobra.Command { cmd.Flags().StringVar(&options.ModulePath, "module-path", "", "Go module path to initialize") cmd.Flags().StringVar(&options.PackageName, "package", "", "Package name for generated Go entrypoint") - cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template: edge-http|edge-json|scheduled|kv-api|d1-api|r2-api") + cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template: edge-http|edge-json|scheduled|kv-api|d1-api|r2-api|ai-text|ai-chat|ai-vision|ai-stt|ai-tts|ai-image|ai-embeddings") cmd.Flags().StringVar(&options.CompatDate, "compat-date", "", "Cloudflare compatibility date") cmd.Flags().StringVar(&options.Env, "env", "", "Default Wrangler environment") cmd.Flags().BoolVar(&options.UseJSONC, "use-jsonc", false, "Generate wrangler.jsonc output") diff --git a/internal/cli/root.go b/internal/cli/root.go index d5f9dac..d85660c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -23,6 +23,7 @@ func NewRootCommand(deps Dependencies) *cobra.Command { } cmd.PersistentFlags().StringVar(&accountID, "account-id", "", "Cloudflare account ID override for all commands") + cmd.AddCommand(newAICommand(deps)) cmd.AddCommand(newAuthCommand(deps)) cmd.AddCommand(newBuildCommand(deps)) cmd.AddCommand(newCompatCommand(deps)) diff --git a/internal/domain/config/project.go b/internal/domain/config/project.go index eddf011..8cc70c8 100644 --- a/internal/domain/config/project.go +++ b/internal/domain/config/project.go @@ -32,6 +32,7 @@ type Project struct { type ProjectBindings struct { Vars map[string]string `json:"vars,omitempty"` Secrets []string `json:"secrets,omitempty"` + AI *AIBinding `json:"ai,omitempty"` KV []KVBinding `json:"kv,omitempty"` D1 []D1Binding `json:"d1,omitempty"` R2 []R2Binding `json:"r2,omitempty"` @@ -47,6 +48,11 @@ type Environment struct { Bindings ProjectBindings `json:"bindings"` } +type AIBinding struct { + Binding string `json:"binding"` + Remote bool `json:"remote,omitempty"` +} + type KVBinding struct { Binding string `json:"binding"` ID string `json:"id,omitempty"` @@ -74,7 +80,7 @@ func DefaultProject(name, modulePath, packageName, template, compatDate, env str } entry := "./cmd/worker" - return Project{ + project := Project{ SchemaVersion: 1, ProjectName: name, ModulePath: modulePath, @@ -96,8 +102,21 @@ func DefaultProject(name, modulePath, packageName, template, compatDate, env str WasmExecSource: "internal/generated/wasm_exec.js", }, } + if UsesAIBinding(template) { + project.Bindings.AI = &AIBinding{Binding: "AI", Remote: true} + } + return project } func (p Project) ArtifactPath() string { return fmt.Sprintf("%s/%s", p.OutDir, p.WasmFile) } + +func UsesAIBinding(template string) bool { + switch template { + case "ai-text", "ai-chat", "ai-vision", "ai-stt", "ai-tts", "ai-image", "ai-embeddings": + return true + default: + return false + } +} diff --git a/internal/domain/config/wrangler.go b/internal/domain/config/wrangler.go index d1a86b1..783853f 100644 --- a/internal/domain/config/wrangler.go +++ b/internal/domain/config/wrangler.go @@ -5,6 +5,7 @@ type WranglerConfig struct { Main string `json:"main"` CompatibilityDate string `json:"compatibility_date"` Observability *WranglerObservability `json:"observability,omitempty"` + AI *WranglerAIBinding `json:"ai,omitempty"` Vars map[string]string `json:"vars,omitempty"` KVNamespaces []WranglerKVNamespace `json:"kv_namespaces,omitempty"` D1Databases []WranglerD1Database `json:"d1_databases,omitempty"` @@ -19,6 +20,7 @@ type WranglerObservability struct { type WranglerEnvConfig struct { Name string `json:"name,omitempty"` + AI *WranglerAIBinding `json:"ai,omitempty"` Vars map[string]string `json:"vars,omitempty"` KVNamespaces []WranglerKVNamespace `json:"kv_namespaces,omitempty"` D1Databases []WranglerD1Database `json:"d1_databases,omitempty"` @@ -26,6 +28,11 @@ type WranglerEnvConfig struct { Routes []WranglerRoute `json:"routes,omitempty"` } +type WranglerAIBinding struct { + Binding string `json:"binding"` + Remote bool `json:"remote,omitempty"` +} + type WranglerKVNamespace struct { Binding string `json:"binding"` ID string `json:"id,omitempty"` diff --git a/internal/service/ai/service.go b/internal/service/ai/service.go new file mode 100644 index 0000000..eb6ca04 --- /dev/null +++ b/internal/service/ai/service.go @@ -0,0 +1,93 @@ +package ai + +import ( + "context" + + "github.com/paolo/flare-edge-cli/internal/domain/config" + "github.com/paolo/flare-edge-cli/internal/infra/configstore" + "github.com/paolo/flare-edge-cli/internal/support/fs" +) + +type Service struct { + store *configstore.Store + fs *fs.FileSystem +} + +type SetBindingOptions struct { + Dir string + Binding string + Remote bool +} + +type ClearBindingOptions struct { + Dir string +} + +type BindingResult struct { + Binding string `json:"binding,omitempty"` + Remote bool `json:"remote,omitempty"` + Cleared bool `json:"cleared,omitempty"` +} + +func NewService(store *configstore.Store, fs *fs.FileSystem) *Service { + return &Service{store: store, fs: fs} +} + +func (s *Service) SetBinding(_ context.Context, options SetBindingOptions) (BindingResult, error) { + project, wranglerCfg, err := loadProjectAndWrangler(options.Dir, s.store) + if err != nil { + return BindingResult{}, err + } + + project.Bindings.AI = &config.AIBinding{ + Binding: options.Binding, + Remote: options.Remote, + } + wranglerCfg.AI = &config.WranglerAIBinding{ + Binding: options.Binding, + Remote: options.Remote, + } + + if err := s.store.SaveProject(options.Dir, project); err != nil { + return BindingResult{}, err + } + if err := s.store.SaveWrangler(options.Dir, project.WranglerConfig, wranglerCfg); err != nil { + return BindingResult{}, err + } + + return BindingResult{ + Binding: options.Binding, + Remote: options.Remote, + }, nil +} + +func (s *Service) ClearBinding(_ context.Context, options ClearBindingOptions) (BindingResult, error) { + project, wranglerCfg, err := loadProjectAndWrangler(options.Dir, s.store) + if err != nil { + return BindingResult{}, err + } + + project.Bindings.AI = nil + wranglerCfg.AI = nil + + if err := s.store.SaveProject(options.Dir, project); err != nil { + return BindingResult{}, err + } + if err := s.store.SaveWrangler(options.Dir, project.WranglerConfig, wranglerCfg); err != nil { + return BindingResult{}, err + } + + return BindingResult{Cleared: true}, nil +} + +func loadProjectAndWrangler(dir string, store *configstore.Store) (config.Project, config.WranglerConfig, error) { + project, err := store.LoadProject(dir) + if err != nil { + return config.Project{}, config.WranglerConfig{}, err + } + wranglerCfg, err := store.LoadWrangler(dir, project.WranglerConfig) + if err != nil { + return config.Project{}, config.WranglerConfig{}, err + } + return project, wranglerCfg, nil +} diff --git a/internal/service/ai/service_test.go b/internal/service/ai/service_test.go new file mode 100644 index 0000000..8871624 --- /dev/null +++ b/internal/service/ai/service_test.go @@ -0,0 +1,85 @@ +package ai + +import ( + "context" + "path/filepath" + "testing" + + "github.com/paolo/flare-edge-cli/internal/domain/config" + "github.com/paolo/flare-edge-cli/internal/infra/configstore" + "github.com/paolo/flare-edge-cli/internal/support/fs" +) + +func TestSetAndClearBinding(t *testing.T) { + t.Parallel() + + filesystem := fs.New() + store := configstore.New(filesystem) + service := NewService(store, filesystem) + dir := t.TempDir() + + project := config.DefaultProject("ai-worker", "github.com/example/ai-worker", "main", "edge-http", config.DefaultCompatibilityDate, "") + wranglerCfg := config.WranglerConfig{ + Name: project.WorkerName, + Main: filepath.ToSlash(filepath.Join(project.OutDir, project.ShimFile)), + CompatibilityDate: project.CompatibilityDate, + } + if err := store.SaveProject(dir, project); err != nil { + t.Fatalf("save project: %v", err) + } + if err := store.SaveWrangler(dir, project.WranglerConfig, wranglerCfg); err != nil { + t.Fatalf("save wrangler: %v", err) + } + + result, err := service.SetBinding(context.Background(), SetBindingOptions{ + Dir: dir, + Binding: "AI", + Remote: true, + }) + if err != nil { + t.Fatalf("set binding: %v", err) + } + if result.Binding != "AI" || !result.Remote { + t.Fatalf("unexpected set result: %+v", result) + } + + project, err = store.LoadProject(dir) + if err != nil { + t.Fatalf("load project after set: %v", err) + } + if project.Bindings.AI == nil || project.Bindings.AI.Binding != "AI" || !project.Bindings.AI.Remote { + t.Fatalf("project ai binding not persisted: %+v", project.Bindings.AI) + } + + wranglerCfg, err = store.LoadWrangler(dir, project.WranglerConfig) + if err != nil { + t.Fatalf("load wrangler after set: %v", err) + } + if wranglerCfg.AI == nil || wranglerCfg.AI.Binding != "AI" || !wranglerCfg.AI.Remote { + t.Fatalf("wrangler ai binding not persisted: %+v", wranglerCfg.AI) + } + + clearResult, err := service.ClearBinding(context.Background(), ClearBindingOptions{Dir: dir}) + if err != nil { + t.Fatalf("clear binding: %v", err) + } + if !clearResult.Cleared { + t.Fatalf("expected cleared result, got %+v", clearResult) + } + + project, err = store.LoadProject(dir) + if err != nil { + t.Fatalf("load project after clear: %v", err) + } + if project.Bindings.AI != nil { + t.Fatalf("expected project ai binding to be cleared") + } + + wranglerCfg, err = store.LoadWrangler(dir, project.WranglerConfig) + if err != nil { + t.Fatalf("load wrangler after clear: %v", err) + } + if wranglerCfg.AI != nil { + t.Fatalf("expected wrangler ai binding to be cleared") + } +} diff --git a/internal/service/doctor/service.go b/internal/service/doctor/service.go index 20bcee6..26b711f 100644 --- a/internal/service/doctor/service.go +++ b/internal/service/doctor/service.go @@ -81,6 +81,13 @@ func (s *Service) Run(ctx context.Context, options Options) (Result, error) { } else { checks = append(checks, Check{Name: "compatibility-date", Status: "ok", Details: project.CompatibilityDate}) } + if project.Bindings.AI != nil { + if project.Bindings.AI.Remote { + checks = append(checks, Check{Name: "ai-binding", Status: "ok", Details: project.Bindings.AI.Binding + " (remote)"}) + } else { + checks = append(checks, Check{Name: "ai-binding", Status: "warning", Details: "Workers AI should use remote=true for development"}) + } + } if _, err := s.build.Wasm(ctx, buildsvc.WasmOptions{Path: options.Dir, NoShim: true, OutDir: filepath.Join(options.Dir, ".doctor-dist"), Clean: true}); err != nil { checks = append(checks, Check{Name: "wasm-build", Status: "error", Details: err.Error()}) } else { diff --git a/internal/service/project/service.go b/internal/service/project/service.go index c43cf8e..4d70be8 100644 --- a/internal/service/project/service.go +++ b/internal/service/project/service.go @@ -87,6 +87,12 @@ func (s *Service) Init(_ context.Context, options InitOptions) (InitResult, erro CompatibilityDate: project.CompatibilityDate, Observability: &config.WranglerObservability{Enabled: true}, } + if project.Bindings.AI != nil { + wrangler.AI = &config.WranglerAIBinding{ + Binding: project.Bindings.AI.Binding, + Remote: project.Bindings.AI.Remote, + } + } files := scaffoldFiles(project) for path, content := range files { diff --git a/internal/service/project/service_test.go b/internal/service/project/service_test.go new file mode 100644 index 0000000..86edd5c --- /dev/null +++ b/internal/service/project/service_test.go @@ -0,0 +1,85 @@ +package project + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/paolo/flare-edge-cli/internal/infra/configstore" + "github.com/paolo/flare-edge-cli/internal/support/fs" +) + +func TestInitAITemplate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + template string + model string + marker string + }{ + {name: "text", template: "ai-text", model: `defaultAIModel = "@cf/moonshotai/kimi-k2.5"`, marker: `messages.Call("push", user)`}, + {name: "chat", template: "ai-chat", model: `defaultAIModel = "@cf/moonshotai/kimi-k2.5"`, marker: `messages.Call("push", user)`}, + {name: "vision", template: "ai-vision", model: `defaultAIModel = "@cf/moonshotai/kimi-k2.5"`, marker: `image_url query parameter is required`}, + {name: "stt", template: "ai-stt", model: `defaultAIModel = "@cf/deepgram/nova-3"`, marker: `send audio bytes in the request body`}, + {name: "tts", template: "ai-tts", model: `defaultAIModel = "@cf/deepgram/aura-2-en"`, marker: `input.Set("speaker", speaker)`}, + {name: "image", template: "ai-image", model: `defaultAIModel = "@cf/black-forest-labs/flux-2-klein-9b"`, marker: `form.Call("append", "prompt", prompt)`}, + {name: "embeddings", template: "ai-embeddings", model: `defaultAIModel = "@cf/qwen/qwen3-embedding-0.6b"`, marker: `input.Set("text", text)`}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + filesystem := fs.New() + store := configstore.New(filesystem) + service := NewService(store, filesystem) + root := t.TempDir() + + result, err := service.Init(context.Background(), InitOptions{ + Dir: root, + Name: "ai-worker", + ModulePath: "github.com/example/ai-worker", + Template: tt.template, + WithGit: true, + }) + if err != nil { + t.Fatalf("init project: %v", err) + } + + project, err := store.LoadProject(result.ProjectDir) + if err != nil { + t.Fatalf("load project: %v", err) + } + if project.Bindings.AI == nil || project.Bindings.AI.Binding != "AI" || !project.Bindings.AI.Remote { + t.Fatalf("expected project ai binding to be scaffolded, got %+v", project.Bindings.AI) + } + + wranglerCfg, err := store.LoadWrangler(result.ProjectDir, project.WranglerConfig) + if err != nil { + t.Fatalf("load wrangler: %v", err) + } + if wranglerCfg.AI == nil || wranglerCfg.AI.Binding != "AI" || !wranglerCfg.AI.Remote { + t.Fatalf("expected wrangler ai binding to be scaffolded, got %+v", wranglerCfg.AI) + } + + workerMainPath := filepath.Join(result.ProjectDir, "cmd/worker/main.go") + workerMain, err := filesystem.ReadFile(workerMainPath) + if err != nil { + t.Fatalf("read worker main: %v", err) + } + content := string(workerMain) + if !strings.Contains(content, tt.model) { + t.Fatalf("expected ai worker template to include default model %s, got %s", tt.model, content) + } + if !strings.Contains(content, `env.Get("AI")`) { + t.Fatalf("expected ai worker template to read env.AI") + } + if !strings.Contains(content, tt.marker) { + t.Fatalf("expected ai worker template to include marker %s", tt.marker) + } + }) + } +} diff --git a/internal/service/project/template_ai_audio.go b/internal/service/project/template_ai_audio.go new file mode 100644 index 0000000..98ff1ad --- /dev/null +++ b/internal/service/project/template_ai_audio.go @@ -0,0 +1,143 @@ +package project + +func aiSTTWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/deepgram/nova-3", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + if request.Get("body").IsUndefined() || request.Get("body").IsNull() { + return jsonTextResponse(400, "{\"message\":\"send audio bytes in the request body\"}") + } + + url := js.Global().Get("URL").New(request.Get("url")) + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + contentType := request.Get("headers").Call("get", "content-type") + if contentType.IsUndefined() || contentType.IsNull() || contentType.String() == "" { + contentType = js.ValueOf("audio/mpeg") + } + + audio := js.Global().Get("Object").New() + audio.Set("body", request.Get("body")) + audio.Set("contentType", contentType) + + input := js.Global().Get("Object").New() + input.Set("audio", audio) + input.Set("detect_language", true) + + options := js.Global().Get("Object").New() + options.Set("returnRawResponse", true) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + if len(args) > 0 { + resolve.Invoke(args[0]) + return nil + } + resolve.Invoke(jsonTextResponse(502, "{\"message\":\"Workers AI returned no response\"}")) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input, options).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} + +func aiTTSWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/deepgram/aura-2-en", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + text := searchParam(url, "text") + if text == "" { + text = "Hello from a Go-based Cloudflare text to speech worker." + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + speaker := searchParam(url, "speaker") + if speaker == "" { + speaker = "luna" + } + + input := js.Global().Get("Object").New() + input.Set("text", text) + input.Set("speaker", speaker) + + options := js.Global().Get("Object").New() + options.Set("returnRawResponse", true) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + if len(args) > 0 { + resolve.Invoke(args[0]) + return nil + } + resolve.Invoke(jsonTextResponse(502, "{\"message\":\"Workers AI returned no response\"}")) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input, options).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} diff --git a/internal/service/project/template_ai_chat.go b/internal/service/project/template_ai_chat.go new file mode 100644 index 0000000..250db81 --- /dev/null +++ b/internal/service/project/template_ai_chat.go @@ -0,0 +1,69 @@ +package project + +func aiChatWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/moonshotai/kimi-k2.5", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + prompt := searchParam(url, "prompt") + if prompt == "" { + prompt = "Say hello from a Go-based Cloudflare AI Worker." + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + input := js.Global().Get("Object").New() + messages := js.Global().Get("Array").New() + user := js.Global().Get("Object").New() + user.Set("role", "user") + user.Set("content", prompt) + messages.Call("push", user) + input.Set("messages", messages) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} diff --git a/internal/service/project/template_ai_common.go b/internal/service/project/template_ai_common.go new file mode 100644 index 0000000..773ebb2 --- /dev/null +++ b/internal/service/project/template_ai_common.go @@ -0,0 +1,125 @@ +package project + +import ( + "strconv" + "strings" +) + +func aiWorkerTemplate(defaultModel, handleRequest string) string { + parts := []string{ + "package main\n\n", + "import \"syscall/js\"\n\n", + "const defaultAIModel = " + strconv.Quote(defaultModel) + "\n\n", + "func main() {\n", + "\tjs.Global().Set(\"handleRequest\", js.FuncOf(handleRequest))\n", + "\tjs.Global().Set(\"handleScheduled\", js.FuncOf(func(this js.Value, args []js.Value) any {\n", + "\t\treturn nil\n", + "\t}))\n", + "\tselect {}\n", + "}\n\n", + handleRequest, + "\n\n", + aiWorkerCommonRuntime, + } + return strings.Join(parts, "") +} + +const aiWorkerCommonRuntime = `func promise(fn func(resolve js.Value, reject js.Value)) js.Value { + executor := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) >= 2 { + fn(args[0], args[1]) + } + return nil + }) + defer executor.Release() + + return js.Global().Get("Promise").New(executor) +} + +func stringify(value js.Value) string { + output := js.Global().Get("JSON").Call("stringify", value) + if output.IsUndefined() || output.IsNull() { + return "null" + } + return output.String() +} + +func errorPayload(value js.Value) string { + payload := js.Global().Get("Object").New() + payload.Set("name", value.Get("name")) + payload.Set("message", value.Get("message")) + payload.Set("code", firstDefined(value, "code", "status")) + payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) + + cause := value.Get("cause") + if !cause.IsUndefined() && !cause.IsNull() { + causePayload := js.Global().Get("Object").New() + causePayload.Set("name", cause.Get("name")) + causePayload.Set("message", cause.Get("message")) + causePayload.Set("code", firstDefined(cause, "code", "status")) + causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) + payload.Set("cause", causePayload) + } + + text := stringify(payload) + if text == "{}" || text == "null" { + return "{\"message\":\"Workers AI request failed\"}" + } + return text +} + +func errorStatus(value js.Value) int { + for _, key := range []string{"status", "statusCode", "code"} { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { + return field.Int() + } + } + return 0 +} + +func firstDefined(value js.Value, keys ...string) js.Value { + for _, key := range keys { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() { + return field + } + } + return js.Null() +} + +func searchParam(url js.Value, key string) string { + value := url.Get("searchParams").Call("get", key) + if value.IsUndefined() || value.IsNull() { + return "" + } + return value.String() +} + +func jsonResponse(body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", 200) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func jsonTextResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func textResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "text/plain; charset=utf-8") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} +` diff --git a/internal/service/project/template_ai_embeddings.go b/internal/service/project/template_ai_embeddings.go new file mode 100644 index 0000000..420a1f9 --- /dev/null +++ b/internal/service/project/template_ai_embeddings.go @@ -0,0 +1,62 @@ +package project + +func aiEmbeddingsWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/qwen/qwen3-embedding-0.6b", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + text := searchParam(url, "text") + if text == "" { + text = "Cloudflare Workers AI embeddings from Go." + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + input := js.Global().Get("Object").New() + input.Set("text", text) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} diff --git a/internal/service/project/template_ai_image.go b/internal/service/project/template_ai_image.go new file mode 100644 index 0000000..95031af --- /dev/null +++ b/internal/service/project/template_ai_image.go @@ -0,0 +1,72 @@ +package project + +func aiImageWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/black-forest-labs/flux-2-klein-9b", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + prompt := searchParam(url, "prompt") + if prompt == "" { + prompt = "A dramatic cloudscape over the Alps at sunset." + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + form := js.Global().Get("FormData").New() + form.Call("append", "prompt", prompt) + form.Call("append", "width", "1024") + form.Call("append", "height", "1024") + + formResponse := js.Global().Get("Response").New(form) + multipart := js.Global().Get("Object").New() + multipart.Set("body", formResponse.Get("body")) + multipart.Set("contentType", formResponse.Get("headers").Call("get", "content-type")) + + input := js.Global().Get("Object").New() + input.Set("multipart", multipart) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} diff --git a/internal/service/project/template_ai_vision.go b/internal/service/project/template_ai_vision.go new file mode 100644 index 0000000..7e337d3 --- /dev/null +++ b/internal/service/project/template_ai_vision.go @@ -0,0 +1,88 @@ +package project + +func aiVisionWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/moonshotai/kimi-k2.5", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + prompt := searchParam(url, "prompt") + if prompt == "" { + prompt = "Describe this image." + } + imageURL := searchParam(url, "image_url") + if imageURL == "" { + return jsonTextResponse(400, "{\"message\":\"image_url query parameter is required\"}") + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + content := js.Global().Get("Array").New() + + textPart := js.Global().Get("Object").New() + textPart.Set("type", "text") + textPart.Set("text", prompt) + content.Call("push", textPart) + + imagePart := js.Global().Get("Object").New() + imagePart.Set("type", "image_url") + imageURLPayload := js.Global().Get("Object").New() + imageURLPayload.Set("url", imageURL) + imagePart.Set("image_url", imageURLPayload) + content.Call("push", imagePart) + + messages := js.Global().Get("Array").New() + user := js.Global().Get("Object").New() + user.Set("role", "user") + user.Set("content", content) + messages.Call("push", user) + + input := js.Global().Get("Object").New() + input.Set("messages", messages) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} diff --git a/internal/service/project/templates.go b/internal/service/project/templates.go index 1bdae33..dba39c0 100644 --- a/internal/service/project/templates.go +++ b/internal/service/project/templates.go @@ -23,20 +23,35 @@ func readmeTemplate(project config.Project) string { } func workerMainTemplate(project config.Project) string { - message := "hello from flare-edge-cli" switch project.Template { + case "ai-text", "ai-chat": + return aiChatWorkerMainTemplate() + case "ai-vision": + return aiVisionWorkerMainTemplate() + case "ai-stt": + return aiSTTWorkerMainTemplate() + case "ai-tts": + return aiTTSWorkerMainTemplate() + case "ai-image": + return aiImageWorkerMainTemplate() + case "ai-embeddings": + return aiEmbeddingsWorkerMainTemplate() case "edge-json": - message = "{\"ok\":true}" + return basicWorkerMainTemplate("{\"ok\":true}") case "scheduled": - message = "scheduled handler ready" + return basicWorkerMainTemplate("scheduled handler ready") case "kv-api": - message = "kv binding ready" + return basicWorkerMainTemplate("kv binding ready") case "d1-api": - message = "d1 binding ready" + return basicWorkerMainTemplate("d1 binding ready") case "r2-api": - message = "r2 binding ready" + return basicWorkerMainTemplate("r2 binding ready") + default: + return basicWorkerMainTemplate("hello from flare-edge-cli") } +} +func basicWorkerMainTemplate(message string) string { return fmt.Sprintf(`package main import "syscall/js" diff --git a/internal/service/teardown/service.go b/internal/service/teardown/service.go index 390fb5c..2af0c62 100644 --- a/internal/service/teardown/service.go +++ b/internal/service/teardown/service.go @@ -247,9 +247,11 @@ func resetLocalState(project config.Project, wranglerCfg config.WranglerConfig, if keepBindings { return project, wranglerCfg } + project.Bindings.AI = nil project.Bindings.KV = nil project.Bindings.D1 = nil project.Bindings.R2 = nil + wranglerCfg.AI = nil wranglerCfg.KVNamespaces = nil wranglerCfg.D1Databases = nil wranglerCfg.R2Buckets = nil diff --git a/internal/service/teardown/service_test.go b/internal/service/teardown/service_test.go index 7bae275..a79ec54 100644 --- a/internal/service/teardown/service_test.go +++ b/internal/service/teardown/service_test.go @@ -14,12 +14,14 @@ func TestResetLocalState(t *testing.T) { Bindings: config.ProjectBindings{ Vars: map[string]string{"KEEP": "1"}, Secrets: []string{"SECRET"}, + AI: &config.AIBinding{Binding: "AI", Remote: true}, KV: []config.KVBinding{{Binding: "KV", ID: "kv-id"}}, D1: []config.D1Binding{{Binding: "DB", DatabaseName: "db"}}, R2: []config.R2Binding{{Binding: "BUCKET", BucketName: "bucket"}}, }, } wrangler := config.WranglerConfig{ + AI: &config.WranglerAIBinding{Binding: "AI", Remote: true}, Routes: []config.WranglerRoute{{Pattern: "example.com/*"}}, KVNamespaces: []config.WranglerKVNamespace{{Binding: "KV", ID: "kv-id"}}, D1Databases: []config.WranglerD1Database{{Binding: "DB", DatabaseName: "db"}}, @@ -33,10 +35,13 @@ func TestResetLocalState(t *testing.T) { if len(cleanProject.Bindings.Secrets) != 0 { t.Fatalf("expected secrets to be cleared") } + if cleanProject.Bindings.AI != nil { + t.Fatalf("expected ai binding to be cleared") + } if got := cleanProject.Bindings.Vars["KEEP"]; got != "1" { t.Fatalf("expected vars to be preserved, got %q", got) } - if len(cleanWrangler.Routes) != 0 || len(cleanWrangler.KVNamespaces) != 0 || len(cleanWrangler.D1Databases) != 0 || len(cleanWrangler.R2Buckets) != 0 { + if cleanWrangler.AI != nil || len(cleanWrangler.Routes) != 0 || len(cleanWrangler.KVNamespaces) != 0 || len(cleanWrangler.D1Databases) != 0 || len(cleanWrangler.R2Buckets) != 0 { t.Fatalf("expected wrangler resources to be cleared") } @@ -47,9 +52,15 @@ func TestResetLocalState(t *testing.T) { if len(keptProject.Bindings.Secrets) != 0 { t.Fatalf("expected secrets to be cleared even when keeping bindings") } + if keptProject.Bindings.AI == nil { + t.Fatalf("expected ai binding to be preserved when keep-bindings is true") + } if len(keptWrangler.Routes) != 0 { t.Fatalf("expected routes to be cleared") } + if keptWrangler.AI == nil { + t.Fatalf("expected ai binding to be preserved when keep-bindings is true") + } if len(keptWrangler.KVNamespaces) != 1 || len(keptWrangler.D1Databases) != 1 || len(keptWrangler.R2Buckets) != 1 { t.Fatalf("expected wrangler bindings to be preserved when keep-bindings is true") }