From 87237b6462b9dfd379b22e69712e8dc516afad9d Mon Sep 17 00:00:00 2001 From: mineo Date: Fri, 16 May 2025 03:25:21 +0900 Subject: feat: support VertexAI provider (#153) * support: vertexai fix fix set default for vertexai added comment fix fix * create schema * fix README.md * fix order * added pupularity * set tools if tools is exists restore commentout * fix comment * set summarizer model --- internal/llm/models/models.go | 2 ++ internal/llm/models/vertexai.go | 38 ++++++++++++++++++++++++++++++++++++++ internal/llm/provider/gemini.go | 18 ++++++++++++------ internal/llm/provider/provider.go | 5 +++++ internal/llm/provider/vertexai.go | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 internal/llm/models/vertexai.go create mode 100644 internal/llm/provider/vertexai.go (limited to 'internal/llm') diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index 16fd406c8..e4fe6603b 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -43,6 +43,7 @@ var ProviderPopularity = map[ModelProvider]int{ ProviderOpenRouter: 5, ProviderBedrock: 6, ProviderAzure: 7, + ProviderVertexAI: 8, } var SupportedModels = map[ModelID]Model{ @@ -95,4 +96,5 @@ func init() { maps.Copy(SupportedModels, AzureModels) maps.Copy(SupportedModels, OpenRouterModels) maps.Copy(SupportedModels, XAIModels) + maps.Copy(SupportedModels, VertexAIGeminiModels) } diff --git a/internal/llm/models/vertexai.go b/internal/llm/models/vertexai.go new file mode 100644 index 000000000..d71dfc0be --- /dev/null +++ b/internal/llm/models/vertexai.go @@ -0,0 +1,38 @@ +package models + +const ( + ProviderVertexAI ModelProvider = "vertexai" + + // Models + VertexAIGemini25Flash ModelID = "vertexai.gemini-2.5-flash" + VertexAIGemini25 ModelID = "vertexai.gemini-2.5" +) + +var VertexAIGeminiModels = map[ModelID]Model{ + VertexAIGemini25Flash: { + ID: VertexAIGemini25Flash, + Name: "VertexAI: Gemini 2.5 Flash", + Provider: ProviderVertexAI, + APIModel: "gemini-2.5-flash-preview-04-17", + CostPer1MIn: GeminiModels[Gemini25Flash].CostPer1MIn, + CostPer1MInCached: GeminiModels[Gemini25Flash].CostPer1MInCached, + CostPer1MOut: GeminiModels[Gemini25Flash].CostPer1MOut, + CostPer1MOutCached: GeminiModels[Gemini25Flash].CostPer1MOutCached, + ContextWindow: GeminiModels[Gemini25Flash].ContextWindow, + DefaultMaxTokens: GeminiModels[Gemini25Flash].DefaultMaxTokens, + SupportsAttachments: true, + }, + VertexAIGemini25: { + ID: VertexAIGemini25, + Name: "VertexAI: Gemini 2.5 Pro", + Provider: ProviderVertexAI, + APIModel: "gemini-2.5-pro-preview-03-25", + CostPer1MIn: GeminiModels[Gemini25].CostPer1MIn, + CostPer1MInCached: GeminiModels[Gemini25].CostPer1MInCached, + CostPer1MOut: GeminiModels[Gemini25].CostPer1MOut, + CostPer1MOutCached: GeminiModels[Gemini25].CostPer1MOutCached, + ContextWindow: GeminiModels[Gemini25].ContextWindow, + DefaultMaxTokens: GeminiModels[Gemini25].DefaultMaxTokens, + SupportsAttachments: true, + }, +} diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go index cc97463d4..8b8e33698 100644 --- a/internal/llm/provider/gemini.go +++ b/internal/llm/provider/gemini.go @@ -176,13 +176,16 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too history := geminiMessages[:len(geminiMessages)-1] // All but last message lastMsg := geminiMessages[len(geminiMessages)-1] - chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{ + config := &genai.GenerateContentConfig{ MaxOutputTokens: int32(g.providerOptions.maxTokens), SystemInstruction: &genai.Content{ Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}}, }, - Tools: g.convertTools(tools), - }, history) + } + if len(tools) > 0 { + config.Tools = g.convertTools(tools) + } + chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history) attempts := 0 for { @@ -262,13 +265,16 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t history := geminiMessages[:len(geminiMessages)-1] // All but last message lastMsg := geminiMessages[len(geminiMessages)-1] - chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{ + config := &genai.GenerateContentConfig{ MaxOutputTokens: int32(g.providerOptions.maxTokens), SystemInstruction: &genai.Content{ Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}}, }, - Tools: g.convertTools(tools), - }, history) + } + if len(tools) > 0 { + config.Tools = g.convertTools(tools) + } + chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, config, history) attempts := 0 eventChan := make(chan ProviderEvent) diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go index 6f2a20bd9..f21e051c2 100644 --- a/internal/llm/provider/provider.go +++ b/internal/llm/provider/provider.go @@ -123,6 +123,11 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption options: clientOptions, client: newAzureClient(clientOptions), }, nil + case models.ProviderVertexAI: + return &baseProvider[VertexAIClient]{ + options: clientOptions, + client: newVertexAIClient(clientOptions), + }, nil case models.ProviderOpenRouter: clientOptions.openaiOptions = append(clientOptions.openaiOptions, WithOpenAIBaseURL("https://openrouter.ai/api/v1"), diff --git a/internal/llm/provider/vertexai.go b/internal/llm/provider/vertexai.go new file mode 100644 index 000000000..2a13a9572 --- /dev/null +++ b/internal/llm/provider/vertexai.go @@ -0,0 +1,34 @@ +package provider + +import ( + "context" + "os" + + "github.com/opencode-ai/opencode/internal/logging" + "google.golang.org/genai" +) + +type VertexAIClient ProviderClient + +func newVertexAIClient(opts providerClientOptions) VertexAIClient { + geminiOpts := geminiOptions{} + for _, o := range opts.geminiOptions { + o(&geminiOpts) + } + + client, err := genai.NewClient(context.Background(), &genai.ClientConfig{ + Project: os.Getenv("VERTEXAI_PROJECT"), + Location: os.Getenv("VERTEXAI_LOCATION"), + Backend: genai.BackendVertexAI, + }) + if err != nil { + logging.Error("Failed to create VertexAI client", "error", err) + return nil + } + + return &geminiClient{ + providerOptions: opts, + options: geminiOpts, + client: client, + } +} -- cgit v1.2.3 From b112216241ce6f17bd020d5299669c2eb0aa791d Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Thu, 15 May 2025 13:36:58 -0500 Subject: fix: build --- internal/llm/provider/vertexai.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'internal/llm') diff --git a/internal/llm/provider/vertexai.go b/internal/llm/provider/vertexai.go index 2a13a9572..328d213fe 100644 --- a/internal/llm/provider/vertexai.go +++ b/internal/llm/provider/vertexai.go @@ -2,9 +2,9 @@ package provider import ( "context" + "log/slog" "os" - "github.com/opencode-ai/opencode/internal/logging" "google.golang.org/genai" ) @@ -22,7 +22,7 @@ func newVertexAIClient(opts providerClientOptions) VertexAIClient { Backend: genai.BackendVertexAI, }) if err != nil { - logging.Error("Failed to create VertexAI client", "error", err) + slog.Error("Failed to create VertexAI client", "error", err) return nil } -- cgit v1.2.3 From 4a444e9c9b7674c6b07a1a012a6467e45c3af1ec Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 16 May 2025 14:27:28 +0300 Subject: feat: Make shell configurable via config file (#23) --- README.md | 33 +++++++++++++++++++++++++++++++++ internal/config/config.go | 7 +++++++ internal/llm/tools/shell/shell.go | 24 ++++++++++++++++++++---- 3 files changed, 60 insertions(+), 4 deletions(-) (limited to 'internal/llm') diff --git a/README.md b/README.md index 57da47289..10be21d5c 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ You can configure OpenCode using environment variables: "command": "gopls" } }, + "shell": { + "path": "/bin/zsh", + "args": ["-l"] + }, "debug": false, "debugLSP": false } @@ -411,6 +415,35 @@ You can define any of the following color keys in your `customTheme`: You don't need to define all colors. Any undefined colors will fall back to the default "opencode" theme colors. +### Shell Configuration + +OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses: +1. The shell specified in the config file (if provided) +2. The shell from the `$SHELL` environment variable (if available) +3. Falls back to `/bin/bash` if neither of the above is available + +To configure a custom shell, add a `shell` section to your `.opencode.json` configuration file: + +```json +{ + "shell": { + "path": "/bin/zsh", + "args": ["-l"] + } +} +``` + +You can specify any shell executable and custom arguments: + +```json +{ + "shell": { + "path": "/usr/bin/fish", + "args": [] + } +} +``` + ## Architecture OpenCode is built with a modular architecture: diff --git a/internal/config/config.go b/internal/config/config.go index 1d741bc91..70c05ac02 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,12 @@ type TUIConfig struct { CustomTheme map[string]any `json:"customTheme,omitempty"` } +// ShellConfig defines the configuration for the shell used by the bash tool. +type ShellConfig struct { + Path string `json:"path,omitempty"` + Args []string `json:"args,omitempty"` +} + // Config is the main configuration structure for the application. type Config struct { Data Data `json:"data"` @@ -85,6 +91,7 @@ type Config struct { DebugLSP bool `json:"debugLSP,omitempty"` ContextPaths []string `json:"contextPaths,omitempty"` TUI TUIConfig `json:"tui"` + Shell ShellConfig `json:"shell,omitempty"` } // Application constants diff --git a/internal/llm/tools/shell/shell.go b/internal/llm/tools/shell/shell.go index efbd5ddb6..a59ee4207 100644 --- a/internal/llm/tools/shell/shell.go +++ b/internal/llm/tools/shell/shell.go @@ -12,6 +12,7 @@ import ( "syscall" "time" + "github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/status" ) @@ -59,12 +60,27 @@ func GetPersistentShell(workingDir string) *PersistentShell { } func newPersistentShell(cwd string) *PersistentShell { - shellPath := os.Getenv("SHELL") - if shellPath == "" { - shellPath = "/bin/bash" + cfg := config.Get() + + // Use shell from config if specified + shellPath := "" + shellArgs := []string{"-l"} + + if cfg != nil && cfg.Shell.Path != "" { + shellPath = cfg.Shell.Path + if len(cfg.Shell.Args) > 0 { + shellArgs = cfg.Shell.Args + } + } else { + // Fall back to environment variable + shellPath = os.Getenv("SHELL") + if shellPath == "" { + // Default to bash if neither config nor environment variable is set + shellPath = "/bin/bash" + } } - cmd := exec.Command(shellPath, "-l") + cmd := exec.Command(shellPath, shellArgs...) cmd.Dir = cwd stdinPipe, err := cmd.StdinPipe() -- cgit v1.2.3 From c92f7c6630c5a4d010ea0c80380f2dbb6dd7e3e1 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 16 May 2025 18:56:39 +0300 Subject: fix: Show correct file paths in permission window (#25) * Fix paths in permission window to show relative paths * Fix paths in permission window to show actual file paths --- internal/llm/tools/edit.go | 21 +++------------------ internal/llm/tools/write.go | 8 +------- internal/tui/components/dialog/permission.go | 13 ++++++++++++- 3 files changed, 16 insertions(+), 26 deletions(-) (limited to 'internal/llm') diff --git a/internal/llm/tools/edit.go b/internal/llm/tools/edit.go index 5516db5d7..9837c018d 100644 --- a/internal/llm/tools/edit.go +++ b/internal/llm/tools/edit.go @@ -196,16 +196,11 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string) content, filePath, ) - rootDir := config.WorkingDirectory() - permissionPath := filepath.Dir(filePath) - if strings.HasPrefix(filePath, rootDir) { - permissionPath = rootDir - } p := e.permissions.Request( ctx, permission.CreatePermissionRequest{ SessionID: sessionID, - Path: permissionPath, + Path: filePath, ToolName: EditToolName, Action: "write", Description: fmt.Sprintf("Create file %s", filePath), @@ -308,16 +303,11 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string filePath, ) - rootDir := config.WorkingDirectory() - permissionPath := filepath.Dir(filePath) - if strings.HasPrefix(filePath, rootDir) { - permissionPath = rootDir - } p := e.permissions.Request( ctx, permission.CreatePermissionRequest{ SessionID: sessionID, - Path: permissionPath, + Path: filePath, ToolName: EditToolName, Action: "write", Description: fmt.Sprintf("Delete content from file %s", filePath), @@ -429,16 +419,11 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS newContent, filePath, ) - rootDir := config.WorkingDirectory() - permissionPath := filepath.Dir(filePath) - if strings.HasPrefix(filePath, rootDir) { - permissionPath = rootDir - } p := e.permissions.Request( ctx, permission.CreatePermissionRequest{ SessionID: sessionID, - Path: permissionPath, + Path: filePath, ToolName: EditToolName, Action: "write", Description: fmt.Sprintf("Replace content in file %s", filePath), diff --git a/internal/llm/tools/write.go b/internal/llm/tools/write.go index f99b3b789..caefc556f 100644 --- a/internal/llm/tools/write.go +++ b/internal/llm/tools/write.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "time" "github.com/sst/opencode/internal/config" @@ -161,16 +160,11 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error filePath, ) - rootDir := config.WorkingDirectory() - permissionPath := filepath.Dir(filePath) - if strings.HasPrefix(filePath, rootDir) { - permissionPath = rootDir - } p := w.permissions.Request( ctx, permission.CreatePermissionRequest{ SessionID: sessionID, - Path: permissionPath, + Path: filePath, ToolName: WriteToolName, Action: "write", Description: fmt.Sprintf("Create file %s", filePath), diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go index d0468d307..5e5b09e1b 100644 --- a/internal/tui/components/dialog/permission.go +++ b/internal/tui/components/dialog/permission.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/diff" "github.com/sst/opencode/internal/llm/tools" "github.com/sst/opencode/internal/permission" @@ -13,6 +14,7 @@ import ( "github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/theme" "github.com/sst/opencode/internal/tui/util" + "path/filepath" "strings" ) @@ -204,10 +206,19 @@ func (p *permissionDialogCmp) renderHeader() string { Render(fmt.Sprintf(": %s", p.permission.ToolName)) pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path") + + // Get the current working directory to display relative path + relativePath := p.permission.Path + if filepath.IsAbs(relativePath) { + if cwd, err := filepath.Rel(config.WorkingDirectory(), relativePath); err == nil { + relativePath = cwd + } + } + pathValue := baseStyle. Foreground(t.Text()). Width(p.width - lipgloss.Width(pathKey)). - Render(fmt.Sprintf(": %s", p.permission.Path)) + Render(fmt.Sprintf(": %s", relativePath)) headerParts := []string{ lipgloss.JoinHorizontal( -- cgit v1.2.3 From daf03052032e3349ff1b8236d38df43230454949 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 08:44:35 -0600 Subject: fix: split out bedrock models --- internal/llm/models/bedrock.go | 25 +++++++++++++++++++++ internal/llm/models/models.go | 50 ++---------------------------------------- 2 files changed, 27 insertions(+), 48 deletions(-) create mode 100644 internal/llm/models/bedrock.go (limited to 'internal/llm') diff --git a/internal/llm/models/bedrock.go b/internal/llm/models/bedrock.go new file mode 100644 index 000000000..06f825654 --- /dev/null +++ b/internal/llm/models/bedrock.go @@ -0,0 +1,25 @@ +package models + +const ( + ProviderBedrock ModelProvider = "bedrock" + + // Models + BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet" +) + +var BedrockModels = map[ModelID]Model{ + BedrockClaude37Sonnet: { + ID: BedrockClaude37Sonnet, + Name: "Bedrock: Claude 3.7 Sonnet", + Provider: ProviderBedrock, + APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0", + CostPer1MIn: 3.0, + CostPer1MInCached: 3.75, + CostPer1MOutCached: 0.30, + CostPer1MOut: 15.0, + ContextWindow: 200_000, + DefaultMaxTokens: 50_000, + CanReason: true, + SupportsAttachments: true, + }, +} diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index e4fe6603b..bfdd0d2d8 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -22,14 +22,7 @@ type Model struct { SupportsAttachments bool `json:"supports_attachments"` } -// Model IDs -const ( // GEMINI - // Bedrock - BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet" -) - const ( - ProviderBedrock ModelProvider = "bedrock" // ForTests ProviderMock ModelProvider = "__mock" ) @@ -46,50 +39,11 @@ var ProviderPopularity = map[ModelProvider]int{ ProviderVertexAI: 8, } -var SupportedModels = map[ModelID]Model{ - // - // // GEMINI - // GEMINI25: { - // ID: GEMINI25, - // Name: "Gemini 2.5 Pro", - // Provider: ProviderGemini, - // APIModel: "gemini-2.5-pro-exp-03-25", - // CostPer1MIn: 0, - // CostPer1MInCached: 0, - // CostPer1MOutCached: 0, - // CostPer1MOut: 0, - // }, - // - // GRMINI20Flash: { - // ID: GRMINI20Flash, - // Name: "Gemini 2.0 Flash", - // Provider: ProviderGemini, - // APIModel: "gemini-2.0-flash", - // CostPer1MIn: 0.1, - // CostPer1MInCached: 0, - // CostPer1MOutCached: 0.025, - // CostPer1MOut: 0.4, - // }, - // - // // Bedrock - BedrockClaude37Sonnet: { - ID: BedrockClaude37Sonnet, - Name: "Bedrock: Claude 3.7 Sonnet", - Provider: ProviderBedrock, - APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0", - CostPer1MIn: 3.0, - CostPer1MInCached: 3.75, - CostPer1MOutCached: 0.30, - CostPer1MOut: 15.0, - ContextWindow: 200_000, - DefaultMaxTokens: 50_000, - CanReason: true, - SupportsAttachments: true, - }, -} +var SupportedModels = map[ModelID]Model{} func init() { maps.Copy(SupportedModels, AnthropicModels) + maps.Copy(SupportedModels, BedrockModels) maps.Copy(SupportedModels, OpenAIModels) maps.Copy(SupportedModels, GeminiModels) maps.Copy(SupportedModels, GroqModels) -- cgit v1.2.3 From 2f8984fadb24ba7b369d0d09c6ad4823f7f48a63 Mon Sep 17 00:00:00 2001 From: phantomreactor Date: Sun, 18 May 2025 18:59:53 +0530 Subject: Codex mini (#34) --- go.mod | 2 +- go.sum | 9 +- internal/llm/agent/agent.go | 12 +- internal/llm/models/groq.go | 2 + internal/llm/models/openai.go | 15 ++ internal/llm/provider/openai.go | 307 +--------------------- internal/llm/provider/openai_completion.go | 317 +++++++++++++++++++++++ internal/llm/provider/openai_response.go | 393 +++++++++++++++++++++++++++++ internal/llm/provider/provider.go | 2 +- 9 files changed, 743 insertions(+), 316 deletions(-) create mode 100644 internal/llm/provider/openai_completion.go create mode 100644 internal/llm/provider/openai_response.go (limited to 'internal/llm') diff --git a/go.mod b/go.mod index ae1a7f09d..4266e719b 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.16.0 github.com/ncruces/go-sqlite3 v0.25.0 - github.com/openai/openai-go v0.1.0-beta.2 + github.com/openai/openai-go v0.1.0-beta.10 github.com/pressly/goose/v3 v3.24.2 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 github.com/spf13/cobra v1.9.1 diff --git a/go.sum b/go.sum index 9c758aefe..83184ff79 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,6 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= @@ -86,9 +84,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -182,8 +179,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= -github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894= -github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= +github.com/openai/openai-go v0.1.0-beta.10 h1:CknhGXe8aXQMRuqg255PFnWzgRY9nEryMxoNIBBM9tU= +github.com/openai/openai-go v0.1.0-beta.10/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 8be04072c..184ef9627 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -4,12 +4,11 @@ import ( "context" "errors" "fmt" + "log/slog" "strings" "sync" "time" - "log/slog" - "github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/llm/models" "github.com/sst/opencode/internal/llm/prompt" @@ -522,15 +521,6 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg assistantMsg.AddToolCall(*event.ToolCall) _, err := a.messages.Update(ctx, *assistantMsg) return err - // TODO: see how to handle this - // case provider.EventToolUseDelta: - // tm := time.Unix(assistantMsg.UpdatedAt, 0) - // assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input) - // if time.Since(tm) > 1000*time.Millisecond { - // err := a.messages.Update(ctx, *assistantMsg) - // assistantMsg.UpdatedAt = time.Now().Unix() - // return err - // } case provider.EventToolUseStop: assistantMsg.FinishToolCall(event.ToolCall.ID) _, err := a.messages.Update(ctx, *assistantMsg) diff --git a/internal/llm/models/groq.go b/internal/llm/models/groq.go index 19917f20b..0a54053de 100644 --- a/internal/llm/models/groq.go +++ b/internal/llm/models/groq.go @@ -41,6 +41,7 @@ var GroqModels = map[ModelID]Model{ CostPer1MInCached: 0, CostPer1MOutCached: 0, CostPer1MOut: 0.34, + DefaultMaxTokens: 8192, ContextWindow: 128_000, // 10M when? SupportsAttachments: true, }, @@ -54,6 +55,7 @@ var GroqModels = map[ModelID]Model{ CostPer1MInCached: 0, CostPer1MOutCached: 0, CostPer1MOut: 0.20, + DefaultMaxTokens: 8192, ContextWindow: 128_000, SupportsAttachments: true, }, diff --git a/internal/llm/models/openai.go b/internal/llm/models/openai.go index abe0e30c5..fdca5bed3 100644 --- a/internal/llm/models/openai.go +++ b/internal/llm/models/openai.go @@ -3,6 +3,7 @@ package models const ( ProviderOpenAI ModelProvider = "openai" + CodexMini ModelID = "codex-mini" GPT41 ModelID = "gpt-4.1" GPT41Mini ModelID = "gpt-4.1-mini" GPT41Nano ModelID = "gpt-4.1-nano" @@ -18,6 +19,20 @@ const ( ) var OpenAIModels = map[ModelID]Model{ + CodexMini: { + ID: CodexMini, + Name: "Codex Mini", + Provider: ProviderOpenAI, + APIModel: "codex-mini-latest", + CostPer1MIn: 1.50, + CostPer1MInCached: 0.375, + CostPer1MOutCached: 0.0, + CostPer1MOut: 6.00, + ContextWindow: 200_000, + DefaultMaxTokens: 100_000, + CanReason: true, + SupportsAttachments: true, + }, GPT41: { ID: GPT41, Name: "GPT 4.1", diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index 3e79edde8..db77a3844 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -2,21 +2,14 @@ package provider import ( "context" - "encoding/json" "errors" "fmt" - "io" - "time" - + "log/slog" "github.com/openai/openai-go" "github.com/openai/openai-go/option" - "github.com/openai/openai-go/shared" - "github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/llm/models" "github.com/sst/opencode/internal/llm/tools" "github.com/sst/opencode/internal/message" - "github.com/sst/opencode/internal/status" - "log/slog" ) type openaiOptions struct { @@ -66,87 +59,21 @@ func newOpenAIClient(opts providerClientOptions) OpenAIClient { } } -func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) { - // Add system message first - openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage)) - - for _, msg := range messages { - switch msg.Role { - case message.User: - var content []openai.ChatCompletionContentPartUnionParam - textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()} - content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock}) - for _, binaryContent := range msg.BinaryContent() { - imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)} - imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL} - - content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock}) - } - - openaiMessages = append(openaiMessages, openai.UserMessage(content)) - - case message.Assistant: - assistantMsg := openai.ChatCompletionAssistantMessageParam{ - Role: "assistant", - } - - if msg.Content().String() != "" { - assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{ - OfString: openai.String(msg.Content().String()), - } - } - - if len(msg.ToolCalls()) > 0 { - assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls())) - for i, call := range msg.ToolCalls() { - assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{ - ID: call.ID, - Type: "function", - Function: openai.ChatCompletionMessageToolCallFunctionParam{ - Name: call.Name, - Arguments: call.Input, - }, - } - } - } - - openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{ - OfAssistant: &assistantMsg, - }) - - case message.Tool: - for _, result := range msg.ToolResults() { - openaiMessages = append(openaiMessages, - openai.ToolMessage(result.Content, result.ToolCallID), - ) - } - } +func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) { + if o.providerOptions.model.ID == models.OpenAIModels[models.CodexMini].ID || o.providerOptions.model.ID == models.OpenAIModels[models.O1Pro].ID { + return o.sendResponseMessages(ctx, messages, tools) } - - return + return o.sendChatcompletionMessage(ctx, messages, tools) } -func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam { - openaiTools := make([]openai.ChatCompletionToolParam, len(tools)) - - for i, tool := range tools { - info := tool.Info() - openaiTools[i] = openai.ChatCompletionToolParam{ - Function: openai.FunctionDefinitionParam{ - Name: info.Name, - Description: openai.String(info.Description), - Parameters: openai.FunctionParameters{ - "type": "object", - "properties": info.Parameters, - "required": info.Required, - }, - }, - } +func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { + if o.providerOptions.model.ID == models.OpenAIModels[models.CodexMini].ID || o.providerOptions.model.ID == models.OpenAIModels[models.O1Pro].ID { + return o.streamResponseMessages(ctx, messages, tools) } - - return openaiTools + return o.streamChatCompletionMessages(ctx, messages, tools) } + func (o *openaiClient) finishReason(reason string) message.FinishReason { switch reason { case "stop": @@ -160,190 +87,6 @@ func (o *openaiClient) finishReason(reason string) message.FinishReason { } } -func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams { - params := openai.ChatCompletionNewParams{ - Model: openai.ChatModel(o.providerOptions.model.APIModel), - Messages: messages, - Tools: tools, - } - - if o.providerOptions.model.CanReason == true { - params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens) - switch o.options.reasoningEffort { - case "low": - params.ReasoningEffort = shared.ReasoningEffortLow - case "medium": - params.ReasoningEffort = shared.ReasoningEffortMedium - case "high": - params.ReasoningEffort = shared.ReasoningEffortHigh - default: - params.ReasoningEffort = shared.ReasoningEffortMedium - } - } else { - params.MaxTokens = openai.Int(o.providerOptions.maxTokens) - } - - if o.providerOptions.model.Provider == models.ProviderOpenRouter { - params.WithExtraFields(map[string]any{ - "provider": map[string]any{ - "require_parameters": true, - }, - }) - } - - return params -} - -func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) { - params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools)) - cfg := config.Get() - if cfg.Debug { - jsonData, _ := json.Marshal(params) - slog.Debug("Prepared messages", "messages", string(jsonData)) - } - attempts := 0 - for { - attempts++ - openaiResponse, err := o.client.Chat.Completions.New( - ctx, - params, - ) - // If there is an error we are going to see if we can retry the call - if err != nil { - retry, after, retryErr := o.shouldRetry(attempts, err) - duration := time.Duration(after) * time.Millisecond - if retryErr != nil { - return nil, retryErr - } - if retry { - status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration)) - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(duration): - continue - } - } - return nil, retryErr - } - - content := "" - if openaiResponse.Choices[0].Message.Content != "" { - content = openaiResponse.Choices[0].Message.Content - } - - toolCalls := o.toolCalls(*openaiResponse) - finishReason := o.finishReason(string(openaiResponse.Choices[0].FinishReason)) - - if len(toolCalls) > 0 { - finishReason = message.FinishReasonToolUse - } - - return &ProviderResponse{ - Content: content, - ToolCalls: toolCalls, - Usage: o.usage(*openaiResponse), - FinishReason: finishReason, - }, nil - } -} - -func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { - params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools)) - params.StreamOptions = openai.ChatCompletionStreamOptionsParam{ - IncludeUsage: openai.Bool(true), - } - - cfg := config.Get() - if cfg.Debug { - jsonData, _ := json.Marshal(params) - slog.Debug("Prepared messages", "messages", string(jsonData)) - } - - attempts := 0 - eventChan := make(chan ProviderEvent) - - go func() { - for { - attempts++ - openaiStream := o.client.Chat.Completions.NewStreaming( - ctx, - params, - ) - - acc := openai.ChatCompletionAccumulator{} - currentContent := "" - toolCalls := make([]message.ToolCall, 0) - - for openaiStream.Next() { - chunk := openaiStream.Current() - acc.AddChunk(chunk) - - for _, choice := range chunk.Choices { - if choice.Delta.Content != "" { - eventChan <- ProviderEvent{ - Type: EventContentDelta, - Content: choice.Delta.Content, - } - currentContent += choice.Delta.Content - } - } - } - - err := openaiStream.Err() - if err == nil || errors.Is(err, io.EOF) { - // Stream completed successfully - finishReason := o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason)) - if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 { - toolCalls = append(toolCalls, o.toolCalls(acc.ChatCompletion)...) - } - if len(toolCalls) > 0 { - finishReason = message.FinishReasonToolUse - } - - eventChan <- ProviderEvent{ - Type: EventComplete, - Response: &ProviderResponse{ - Content: currentContent, - ToolCalls: toolCalls, - Usage: o.usage(acc.ChatCompletion), - FinishReason: finishReason, - }, - } - close(eventChan) - return - } - - // If there is an error we are going to see if we can retry the call - retry, after, retryErr := o.shouldRetry(attempts, err) - duration := time.Duration(after) * time.Millisecond - if retryErr != nil { - eventChan <- ProviderEvent{Type: EventError, Error: retryErr} - close(eventChan) - return - } - if retry { - status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration)) - select { - case <-ctx.Done(): - // context cancelled - if ctx.Err() == nil { - eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()} - } - close(eventChan) - return - case <-time.After(duration): - continue - } - } - eventChan <- ProviderEvent{Type: EventError, Error: retryErr} - close(eventChan) - return - } - }() - - return eventChan -} func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) { var apierr *openai.Error @@ -373,36 +116,6 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) return true, int64(retryMs), nil } -func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall { - var toolCalls []message.ToolCall - - if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 { - for _, call := range completion.Choices[0].Message.ToolCalls { - toolCall := message.ToolCall{ - ID: call.ID, - Name: call.Function.Name, - Input: call.Function.Arguments, - Type: "function", - Finished: true, - } - toolCalls = append(toolCalls, toolCall) - } - } - - return toolCalls -} - -func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage { - cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens - inputTokens := completion.Usage.PromptTokens - cachedTokens - - return TokenUsage{ - InputTokens: inputTokens, - OutputTokens: completion.Usage.CompletionTokens, - CacheCreationTokens: 0, // OpenAI doesn't provide this directly - CacheReadTokens: cachedTokens, - } -} func WithOpenAIBaseURL(baseURL string) OpenAIOption { return func(options *openaiOptions) { diff --git a/internal/llm/provider/openai_completion.go b/internal/llm/provider/openai_completion.go new file mode 100644 index 000000000..e3b837231 --- /dev/null +++ b/internal/llm/provider/openai_completion.go @@ -0,0 +1,317 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "time" + + "github.com/openai/openai-go" + "github.com/openai/openai-go/shared" + "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/llm/models" + "github.com/sst/opencode/internal/llm/tools" + "github.com/sst/opencode/internal/message" + "github.com/sst/opencode/internal/status" +) + +func (o *openaiClient) convertMessagesToChatCompletionMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) { + // Add system message first + openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage)) + + for _, msg := range messages { + switch msg.Role { + case message.User: + var content []openai.ChatCompletionContentPartUnionParam + textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()} + content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock}) + for _, binaryContent := range msg.BinaryContent() { + imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)} + imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL} + + content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock}) + } + + openaiMessages = append(openaiMessages, openai.UserMessage(content)) + + case message.Assistant: + assistantMsg := openai.ChatCompletionAssistantMessageParam{ + Role: "assistant", + } + + if msg.Content().String() != "" { + assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{ + OfString: openai.String(msg.Content().String()), + } + } + + if len(msg.ToolCalls()) > 0 { + assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls())) + for i, call := range msg.ToolCalls() { + assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{ + ID: call.ID, + Type: "function", + Function: openai.ChatCompletionMessageToolCallFunctionParam{ + Name: call.Name, + Arguments: call.Input, + }, + } + } + } + + openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{ + OfAssistant: &assistantMsg, + }) + + case message.Tool: + for _, result := range msg.ToolResults() { + openaiMessages = append(openaiMessages, + openai.ToolMessage(result.Content, result.ToolCallID), + ) + } + } + } + + return +} + +func (o *openaiClient) convertToChatCompletionTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam { + openaiTools := make([]openai.ChatCompletionToolParam, len(tools)) + + for i, tool := range tools { + info := tool.Info() + openaiTools[i] = openai.ChatCompletionToolParam{ + Function: openai.FunctionDefinitionParam{ + Name: info.Name, + Description: openai.String(info.Description), + Parameters: openai.FunctionParameters{ + "type": "object", + "properties": info.Parameters, + "required": info.Required, + }, + }, + } + } + + return openaiTools +} + +func (o *openaiClient) preparedChatCompletionParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams { + params := openai.ChatCompletionNewParams{ + Model: openai.ChatModel(o.providerOptions.model.APIModel), + Messages: messages, + Tools: tools, + } + if o.providerOptions.model.CanReason == true { + params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens) + switch o.options.reasoningEffort { + case "low": + params.ReasoningEffort = shared.ReasoningEffortLow + case "medium": + params.ReasoningEffort = shared.ReasoningEffortMedium + case "high": + params.ReasoningEffort = shared.ReasoningEffortHigh + default: + params.ReasoningEffort = shared.ReasoningEffortMedium + } + } else { + params.MaxTokens = openai.Int(o.providerOptions.maxTokens) + } + + if o.providerOptions.model.Provider == models.ProviderOpenRouter { + params.WithExtraFields(map[string]any{ + "provider": map[string]any{ + "require_parameters": true, + }, + }) + } + + return params +} + +func (o *openaiClient) sendChatcompletionMessage(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) { + params := o.preparedChatCompletionParams(o.convertMessagesToChatCompletionMessages(messages), o.convertToChatCompletionTools(tools)) + cfg := config.Get() + if cfg.Debug { + jsonData, _ := json.Marshal(params) + slog.Debug("Prepared messages", "messages", string(jsonData)) + } + attempts := 0 + for { + attempts++ + openaiResponse, err := o.client.Chat.Completions.New( + ctx, + params, + ) + // If there is an error we are going to see if we can retry the call + if err != nil { + retry, after, retryErr := o.shouldRetry(attempts, err) + duration := time.Duration(after) * time.Millisecond + if retryErr != nil { + return nil, retryErr + } + if retry { + status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration)) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(duration): + continue + } + } + return nil, retryErr + } + + content := "" + if openaiResponse.Choices[0].Message.Content != "" { + content = openaiResponse.Choices[0].Message.Content + } + + toolCalls := o.chatCompletionToolCalls(*openaiResponse) + finishReason := o.finishReason(string(openaiResponse.Choices[0].FinishReason)) + + if len(toolCalls) > 0 { + finishReason = message.FinishReasonToolUse + } + + return &ProviderResponse{ + Content: content, + ToolCalls: toolCalls, + Usage: o.usage(*openaiResponse), + FinishReason: finishReason, + }, nil + } +} + +func (o *openaiClient) streamChatCompletionMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { + params := o.preparedChatCompletionParams(o.convertMessagesToChatCompletionMessages(messages), o.convertToChatCompletionTools(tools)) + params.StreamOptions = openai.ChatCompletionStreamOptionsParam{ + IncludeUsage: openai.Bool(true), + } + + cfg := config.Get() + if cfg.Debug { + jsonData, _ := json.Marshal(params) + slog.Debug("Prepared messages", "messages", string(jsonData)) + } + + attempts := 0 + eventChan := make(chan ProviderEvent) + + go func() { + for { + attempts++ + openaiStream := o.client.Chat.Completions.NewStreaming( + ctx, + params, + ) + + acc := openai.ChatCompletionAccumulator{} + currentContent := "" + toolCalls := make([]message.ToolCall, 0) + + for openaiStream.Next() { + chunk := openaiStream.Current() + acc.AddChunk(chunk) + + for _, choice := range chunk.Choices { + if choice.Delta.Content != "" { + eventChan <- ProviderEvent{ + Type: EventContentDelta, + Content: choice.Delta.Content, + } + currentContent += choice.Delta.Content + } + } + } + + err := openaiStream.Err() + if err == nil || errors.Is(err, io.EOF) { + // Stream completed successfully + finishReason := o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason)) + if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 { + toolCalls = append(toolCalls, o.chatCompletionToolCalls(acc.ChatCompletion)...) + } + if len(toolCalls) > 0 { + finishReason = message.FinishReasonToolUse + } + + eventChan <- ProviderEvent{ + Type: EventComplete, + Response: &ProviderResponse{ + Content: currentContent, + ToolCalls: toolCalls, + Usage: o.usage(acc.ChatCompletion), + FinishReason: finishReason, + }, + } + close(eventChan) + return + } + + // If there is an error we are going to see if we can retry the call + retry, after, retryErr := o.shouldRetry(attempts, err) + duration := time.Duration(after) * time.Millisecond + if retryErr != nil { + eventChan <- ProviderEvent{Type: EventError, Error: retryErr} + close(eventChan) + return + } + if retry { + status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration)) + select { + case <-ctx.Done(): + // context cancelled + if ctx.Err() == nil { + eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()} + } + close(eventChan) + return + case <-time.After(duration): + continue + } + } + eventChan <- ProviderEvent{Type: EventError, Error: retryErr} + close(eventChan) + return + } + }() + + return eventChan +} + + +func (o *openaiClient) chatCompletionToolCalls(completion openai.ChatCompletion) []message.ToolCall { + var toolCalls []message.ToolCall + + if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 { + for _, call := range completion.Choices[0].Message.ToolCalls { + toolCall := message.ToolCall{ + ID: call.ID, + Name: call.Function.Name, + Input: call.Function.Arguments, + Type: "function", + Finished: true, + } + toolCalls = append(toolCalls, toolCall) + } + } + + return toolCalls +} + +func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage { + cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens + inputTokens := completion.Usage.PromptTokens - cachedTokens + + return TokenUsage{ + InputTokens: inputTokens, + OutputTokens: completion.Usage.CompletionTokens, + CacheCreationTokens: 0, // OpenAI doesn't provide this directly + CacheReadTokens: cachedTokens, + } +} + diff --git a/internal/llm/provider/openai_response.go b/internal/llm/provider/openai_response.go new file mode 100644 index 000000000..96a61c4db --- /dev/null +++ b/internal/llm/provider/openai_response.go @@ -0,0 +1,393 @@ +package provider + + +import ( + "github.com/openai/openai-go" + "github.com/openai/openai-go/responses" + "github.com/sst/opencode/internal/llm/models" + "github.com/sst/opencode/internal/llm/tools" + "github.com/sst/opencode/internal/message" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "log/slog" + + "github.com/openai/openai-go/shared" + "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/status" +) + +func (o *openaiClient) convertMessagesToResponseParams(messages []message.Message) responses.ResponseInputParam { + inputItems := responses.ResponseInputParam{} + + inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ + OfMessage: &responses.EasyInputMessageParam{ + Content: responses.EasyInputMessageContentUnionParam{OfString: openai.String(o.providerOptions.systemMessage)}, + Role: responses.EasyInputMessageRoleSystem, + }, + }) + + for _, msg := range messages { + switch msg.Role { + case message.User: + inputItemContentList := responses.ResponseInputMessageContentListParam{ + responses.ResponseInputContentUnionParam{ + OfInputText: &responses.ResponseInputTextParam{ + Text: msg.Content().String(), + }, + }, + } + + for _, binaryContent := range msg.BinaryContent() { + inputItemContentList = append(inputItemContentList, responses.ResponseInputContentUnionParam{ + OfInputImage: &responses.ResponseInputImageParam{ + ImageURL: openai.String(binaryContent.String(models.ProviderOpenAI)), + }, + }) + } + + userMsg := responses.ResponseInputItemUnionParam{ + OfInputMessage: &responses.ResponseInputItemMessageParam{ + Content: inputItemContentList, + Role: string(responses.ResponseInputMessageItemRoleUser), + }, + } + inputItems = append(inputItems, userMsg) + + case message.Assistant: + if msg.Content().String() != "" { + assistantMsg := responses.ResponseInputItemUnionParam{ + OfOutputMessage: &responses.ResponseOutputMessageParam{ + Content: []responses.ResponseOutputMessageContentUnionParam{{ + OfOutputText: &responses.ResponseOutputTextParam{ + Text: msg.Content().String(), + }, + }}, + }, + } + inputItems = append(inputItems, assistantMsg) + } + + if len(msg.ToolCalls()) > 0 { + for _, call := range msg.ToolCalls() { + toolMsg := responses.ResponseInputItemUnionParam{ + OfFunctionCall: &responses.ResponseFunctionToolCallParam{ + CallID: call.ID, + Name: call.Name, + Arguments: call.Input, + }, + } + inputItems = append(inputItems, toolMsg) + } + } + + case message.Tool: + for _, result := range msg.ToolResults() { + toolMsg := responses.ResponseInputItemUnionParam{ + OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ + Output: result.Content, + CallID: result.ToolCallID, + }, + } + inputItems = append(inputItems, toolMsg) + } + } + } + + return inputItems +} + +func (o *openaiClient) convertToResponseTools(tools []tools.BaseTool) []responses.ToolUnionParam { + outputTools := make([]responses.ToolUnionParam, len(tools)) + + for i, tool := range tools { + info := tool.Info() + outputTools[i] = responses.ToolUnionParam{ + OfFunction: &responses.FunctionToolParam{ + Name: info.Name, + Description: openai.String(info.Description), + Parameters: map[string]any{ + "type": "object", + "properties": info.Parameters, + "required": info.Required, + }, + }, + } + } + + return outputTools +} + + +func (o *openaiClient) preparedResponseParams(input responses.ResponseInputParam, tools []responses.ToolUnionParam) responses.ResponseNewParams { + params := responses.ResponseNewParams{ + Model: shared.ResponsesModel(o.providerOptions.model.APIModel), + Input: responses.ResponseNewParamsInputUnion{OfInputItemList: input}, + Tools: tools, + } + + params.MaxOutputTokens = openai.Int(o.providerOptions.maxTokens) + + if o.providerOptions.model.CanReason == true { + switch o.options.reasoningEffort { + case "low": + params.Reasoning.Effort = shared.ReasoningEffortLow + case "medium": + params.Reasoning.Effort = shared.ReasoningEffortMedium + case "high": + params.Reasoning.Effort = shared.ReasoningEffortHigh + default: + params.Reasoning.Effort = shared.ReasoningEffortMedium + } + } + + if o.providerOptions.model.Provider == models.ProviderOpenRouter { + params.WithExtraFields(map[string]any{ + "provider": map[string]any{ + "require_parameters": true, + }, + }) + } + + return params +} + +func (o *openaiClient) sendResponseMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) { + params := o.preparedResponseParams(o.convertMessagesToResponseParams(messages), o.convertToResponseTools(tools)) + cfg := config.Get() + if cfg.Debug { + jsonData, _ := json.Marshal(params) + slog.Debug("Prepared messages", "messages", string(jsonData)) + } + attempts := 0 + for { + attempts++ + openaiResponse, err := o.client.Responses.New( + ctx, + params, + ) + // If there is an error we are going to see if we can retry the call + if err != nil { + retry, after, retryErr := o.shouldRetry(attempts, err) + duration := time.Duration(after) * time.Millisecond + if retryErr != nil { + return nil, retryErr + } + if retry { + status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration)) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(duration): + continue + } + } + return nil, retryErr + } + + content := "" + if openaiResponse.OutputText() != "" { + content = openaiResponse.OutputText() + } + + toolCalls := o.responseToolCalls(*openaiResponse) + finishReason := o.finishReason("stop") + + if len(toolCalls) > 0 { + finishReason = message.FinishReasonToolUse + } + + return &ProviderResponse{ + Content: content, + ToolCalls: toolCalls, + Usage: o.responseUsage(*openaiResponse), + FinishReason: finishReason, + }, nil + } +} + +func (o *openaiClient) streamResponseMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { + eventChan := make(chan ProviderEvent) + + params := o.preparedResponseParams(o.convertMessagesToResponseParams(messages), o.convertToResponseTools(tools)) + + cfg := config.Get() + if cfg.Debug { + jsonData, _ := json.Marshal(params) + slog.Debug("Prepared messages", "messages", string(jsonData)) + } + + attempts := 0 + + go func() { + for { + attempts++ + stream := o.client.Responses.NewStreaming(ctx, params) + outputText := "" + currentToolCallID := "" + for stream.Next() { + event := stream.Current() + + switch event := event.AsAny().(type) { + case responses.ResponseCompletedEvent: + toolCalls := o.responseToolCalls(event.Response) + finishReason := o.finishReason("stop") + + if len(toolCalls) > 0 { + finishReason = message.FinishReasonToolUse + } + + eventChan <- ProviderEvent{ + Type: EventComplete, + Response: &ProviderResponse{ + Content: outputText, + ToolCalls: toolCalls, + Usage: o.responseUsage(event.Response), + FinishReason: finishReason, + }, + } + close(eventChan) + return + + case responses.ResponseTextDeltaEvent: + outputText += event.Delta + eventChan <- ProviderEvent{ + Type: EventContentDelta, + Content: event.Delta, + } + + case responses.ResponseTextDoneEvent: + eventChan <- ProviderEvent{ + Type: EventContentStop, + Content: outputText, + } + close(eventChan) + return + + case responses.ResponseOutputItemAddedEvent: + if event.Item.Type == "function_call" { + currentToolCallID = event.Item.ID + eventChan <- ProviderEvent{ + Type: EventToolUseStart, + ToolCall: &message.ToolCall{ + ID: event.Item.ID, + Name: event.Item.Name, + Finished: false, + }, + } + } + + case responses.ResponseFunctionCallArgumentsDeltaEvent: + if event.ItemID == currentToolCallID { + eventChan <- ProviderEvent{ + Type: EventToolUseDelta, + ToolCall: &message.ToolCall{ + ID: currentToolCallID, + Finished: false, + Input: event.Delta, + }, + } + } + + case responses.ResponseFunctionCallArgumentsDoneEvent: + if event.ItemID == currentToolCallID { + eventChan <- ProviderEvent{ + Type: EventToolUseStop, + ToolCall: &message.ToolCall{ + ID: currentToolCallID, + Input: event.Arguments, + }, + } + currentToolCallID = "" + } + + case responses.ResponseOutputItemDoneEvent: + if event.Item.Type == "function_call" { + eventChan <- ProviderEvent{ + Type: EventToolUseStop, + ToolCall: &message.ToolCall{ + ID: event.Item.ID, + Name: event.Item.Name, + Input: event.Item.Arguments, + Finished: true, + }, + } + currentToolCallID = "" + } + + } + } + + err := stream.Err() + if err == nil || errors.Is(err, io.EOF) { + close(eventChan) + return + } + + // If there is an error we are going to see if we can retry the call + retry, after, retryErr := o.shouldRetry(attempts, err) + duration := time.Duration(after) * time.Millisecond + if retryErr != nil { + eventChan <- ProviderEvent{Type: EventError, Error: retryErr} + close(eventChan) + return + } + if retry { + status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), status.WithDuration(duration)) + select { + case <-ctx.Done(): + // context cancelled + if ctx.Err() == nil { + eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()} + } + close(eventChan) + return + case <-time.After(duration): + continue + } + } + eventChan <- ProviderEvent{Type: EventError, Error: retryErr} + close(eventChan) + return + } + }() + + return eventChan +} + + +func (o *openaiClient) responseToolCalls(response responses.Response) []message.ToolCall { + var toolCalls []message.ToolCall + + for _, output := range response.Output { + if output.Type == "function_call" { + call := output.AsFunctionCall() + toolCall := message.ToolCall{ + ID: call.ID, + Name: call.Name, + Input: call.Arguments, + Type: "function", + Finished: true, + } + toolCalls = append(toolCalls, toolCall) + } + } + + return toolCalls +} + +func (o *openaiClient) responseUsage(response responses.Response) TokenUsage { + cachedTokens := response.Usage.InputTokensDetails.CachedTokens + inputTokens := response.Usage.InputTokens - cachedTokens + + return TokenUsage{ + InputTokens: inputTokens, + OutputTokens: response.Usage.OutputTokens, + CacheCreationTokens: 0, // OpenAI doesn't provide this directly + CacheReadTokens: cachedTokens, + } +} diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go index f21e051c2..adcbfdbf7 100644 --- a/internal/llm/provider/provider.go +++ b/internal/llm/provider/provider.go @@ -3,11 +3,11 @@ package provider import ( "context" "fmt" + "log/slog" "github.com/sst/opencode/internal/llm/models" "github.com/sst/opencode/internal/llm/tools" "github.com/sst/opencode/internal/message" - "log/slog" ) type EventType string -- cgit v1.2.3