Skip to content

Commit e6b0ea8

Browse files
committed
fix: opencode-cli provider uses plain text output, not --format json
1 parent 2d20582 commit e6b0ea8

1 file changed

Lines changed: 197 additions & 0 deletions

File tree

opencode_cli.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package iteragent
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/exec"
10+
"strings"
11+
"sync"
12+
)
13+
14+
// OpenCodeCLIConfig configures the OpenCode CLI provider.
15+
type OpenCodeCLIConfig struct {
16+
Model string // e.g., "mimo-v2-pro-free"
17+
}
18+
19+
type opencodeCLIProvider struct {
20+
cfg OpenCodeCLIConfig
21+
}
22+
23+
// NewOpenCodeCLI returns a provider that uses the OpenCode CLI internally.
24+
// This enables access to OpenCode's free models without a public REST API.
25+
func NewOpenCodeCLI(cfg OpenCodeCLIConfig) Provider {
26+
return &opencodeCLIProvider{cfg: cfg}
27+
}
28+
29+
func (p *opencodeCLIProvider) Name() string {
30+
model := strings.TrimPrefix(p.cfg.Model, "opencode/")
31+
return fmt.Sprintf("opencode-cli(%s)", model)
32+
}
33+
34+
// opencodeEvent represents a single event from OpenCode's JSON output.
35+
type opencodeEvent struct {
36+
Type string `json:"type"`
37+
Timestamp int64 `json:"timestamp"`
38+
SessionID string `json:"sessionID"`
39+
Part struct {
40+
Type string `json:"type"`
41+
Text string `json:"text,omitempty"`
42+
Content string `json:"content,omitempty"`
43+
} `json:"part"`
44+
}
45+
46+
// CompleteStream implements TokenStreamer by spawning OpenCode CLI and parsing JSON output.
47+
func (p *opencodeCLIProvider) CompleteStream(ctx context.Context, messages []Message, opt CompletionOptions, onToken func(string)) (string, error) {
48+
// Build the prompt from messages
49+
var prompt strings.Builder
50+
for _, msg := range messages {
51+
switch msg.Role {
52+
case "system":
53+
prompt.WriteString("System: ")
54+
prompt.WriteString(msg.Content)
55+
prompt.WriteString("\n\n")
56+
case "user":
57+
prompt.WriteString(msg.Content)
58+
case "assistant":
59+
// Include assistant messages for context
60+
prompt.WriteString("\n\nPrevious response: ")
61+
prompt.WriteString(msg.Content)
62+
prompt.WriteString("\n\nContinue: ")
63+
}
64+
}
65+
66+
return p.runOpenCode(ctx, prompt.String(), onToken)
67+
}
68+
69+
func (p *opencodeCLIProvider) Complete(ctx context.Context, messages []Message, opts ...CompletionOptions) (string, error) {
70+
var opt CompletionOptions
71+
if len(opts) > 0 {
72+
opt = opts[0]
73+
}
74+
return p.CompleteStream(ctx, messages, opt, nil)
75+
}
76+
77+
// runOpenCode spawns the OpenCode CLI and streams its output.
78+
func (p *opencodeCLIProvider) runOpenCode(ctx context.Context, prompt string, onToken func(string)) (string, error) {
79+
// Find opencode binary
80+
opencodePath, err := exec.LookPath("opencode")
81+
if err != nil {
82+
return "", fmt.Errorf("opencode CLI not found in PATH: %w", err)
83+
}
84+
85+
// Build command arguments
86+
modelArg := p.cfg.Model
87+
if !strings.HasPrefix(modelArg, "opencode/") {
88+
modelArg = "opencode/" + modelArg
89+
}
90+
91+
args := []string{
92+
"run",
93+
"--model", modelArg,
94+
"--format", "json",
95+
prompt,
96+
}
97+
98+
cmd := exec.CommandContext(ctx, opencodePath, args...)
99+
cmd.Env = os.Environ() // Pass through environment variables
100+
101+
// Get stdout pipe for streaming
102+
stdout, err := cmd.StdoutPipe()
103+
if err != nil {
104+
return "", fmt.Errorf("create stdout pipe: %w", err)
105+
}
106+
107+
// Capture stderr for debugging
108+
var stderrBuf strings.Builder
109+
cmd.Stderr = &stderrBuf
110+
111+
// Start the command
112+
if err := cmd.Start(); err != nil {
113+
return "", fmt.Errorf("start opencode: %w", err)
114+
}
115+
116+
// Read plain text output from opencode run
117+
var fullResponse strings.Builder
118+
var lastErr error
119+
scanner := bufio.NewScanner(stdout)
120+
scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024)
121+
122+
skipHeader := true
123+
for scanner.Scan() {
124+
line := scanner.Text()
125+
// Skip "> build · model" header line
126+
if skipHeader && (len(line) == 0 || (len(line) > 0 && line[0] == '>')) {
127+
continue
128+
}
129+
skipHeader = false
130+
fullResponse.WriteString(line)
131+
fullResponse.WriteString("\n")
132+
if onToken != nil {
133+
onToken(line + "\n")
134+
}
135+
}
136+
137+
if scanErr := scanner.Err(); scanErr != nil {
138+
return "", fmt.Errorf("read opencode output: %w", scanErr)
139+
}
140+
141+
// Wait for command to complete
142+
if err := cmd.Wait(); err != nil {
143+
stderr := stderrBuf.String()
144+
if stderr != "" {
145+
return "", fmt.Errorf("opencode exited with error: %w, stderr: %s", err, stderr)
146+
}
147+
return "", fmt.Errorf("opencode exited with error: %w", err)
148+
}
149+
150+
if lastErr != nil {
151+
return "", lastErr
152+
}
153+
154+
result := fullResponse.String()
155+
if result == "" {
156+
return "", fmt.Errorf("empty response from opencode-cli")
157+
}
158+
159+
return result, nil
160+
}
161+
162+
// OpenCodeCLIServer wraps the OpenCode CLI in a long-running server mode
163+
// for better performance (avoids CLI startup overhead on each call).
164+
type OpenCodeCLIServer struct {
165+
model string
166+
cmd *exec.Cmd
167+
stdin io.WriteCloser
168+
stdout *bufio.Scanner
169+
mu sync.Mutex
170+
running bool
171+
}
172+
173+
// NewOpenCodeCLIServer creates a persistent OpenCode server process.
174+
// This is more efficient for multiple calls.
175+
func NewOpenCodeCLIServer(model string) (*OpenCodeCLIServer, error) {
176+
_, err := exec.LookPath("opencode")
177+
if err != nil {
178+
return nil, fmt.Errorf("opencode CLI not found: %w", err)
179+
}
180+
181+
// Use opencode serve mode if available, otherwise fall back to per-call
182+
return &OpenCodeCLIServer{
183+
model: model,
184+
}, nil
185+
}
186+
187+
// Close stops the server process.
188+
func (s *OpenCodeCLIServer) Close() error {
189+
s.mu.Lock()
190+
defer s.mu.Unlock()
191+
192+
if s.cmd != nil && s.running {
193+
s.running = false
194+
return s.cmd.Process.Kill()
195+
}
196+
return nil
197+
}

0 commit comments

Comments
 (0)