Repository navigation
Expand file tree
/
Copy pathopencode.go
More file actions
276 lines (251 loc) · 8.48 KB
/
Copy pathopencode.go
File metadata and controls
276 lines (251 loc) · 8.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
package harness
import (
"encoding/json"
"io"
"path"
"path/filepath"
"strings"
)
// OpencodeHarness drives OpenCode in headless run mode. OpenCode is
// provider-neutral, so its environment and egress list cover Anthropic,
// OpenAI, and OpenCode's model registry by default.
type OpencodeHarness struct{}
func (OpencodeHarness) Binary() string { return "opencode" }
// Args builds opencode run argv. OpenCode discovers SKILL.md but does not
// invoke a named skill itself, so Prompt points at the staged file. --auto
// suppresses interactive permission prompts and --format json selects JSONL.
// The caller remains responsible for process isolation.
func (OpencodeHarness) Args(j Job) []string {
args := []string{
"run",
"--format", "json",
"--auto",
}
if j.Model != "" {
args = append(args, "--model", j.Model)
}
if id := safeSessionID(j.ResumeSessionID); id != "" {
args = append(args, "--session", id)
}
return append(args, "--", safePrompt(OpencodeHarness{}.Prompt(j)))
}
func (OpencodeHarness) Prompt(j Job) string {
return explicitSkillPrompt(j, "./.opencode/skill/"+j.SkillName)
}
func (OpencodeHarness) ParseStream(r io.Reader, emit func(Event)) {
scanJSONL(r, emit, parseOpencodeLine)
}
// opencodeLine is the subset of opencode run --format json used here. Each
// event has a type and sessionID, with its payload nested under part.
type opencodeLine struct {
Type string `json:"type"`
SessionID string `json:"sessionID"`
Part *opencodePart `json:"part"`
Error json.RawMessage `json:"error"`
}
type opencodePart struct {
Type string `json:"type"`
Text string `json:"text"`
Tool string `json:"tool"`
Name string `json:"name"`
State opencodeToolState `json:"state"`
// step_finish carries per-step cost and tokens here rather than at the
// event top level.
Cost float64 `json:"cost"`
Tokens *opencodeTokens `json:"tokens"`
}
type opencodeToolState struct {
Input json.RawMessage `json:"input"`
}
type opencodeTokens struct {
Input int `json:"input"`
Output int `json:"output"`
Reasoning int `json:"reasoning"`
Cache struct {
Read int `json:"read"`
Write int `json:"write"`
} `json:"cache"`
}
func parseOpencodeLine(raw []byte, emit func(Event)) {
line := strings.TrimSpace(string(raw))
if line == "" {
return
}
var event opencodeLine
if err := json.Unmarshal(raw, &event); err != nil {
emit(Event{Kind: KindText, Text: line})
return
}
switch {
case event.Type == "step_start" && event.SessionID != "":
emit(Event{Kind: KindSession, SessionID: event.SessionID})
case isOpencodeToolEvent(event):
name := event.Part.Tool
if name == "" {
name = event.Part.Name
}
emit(Event{Kind: KindTool, Tool: name, Text: summariseInput(name, event.Part.State.Input)})
case event.Type == wireTypeError || len(event.Error) > 0:
emit(Event{Kind: KindError, Text: opencodeErrorText(event.Error, line)})
case isOpencodeReasoningEvent(event):
emit(Event{Kind: KindThinking, Text: event.Part.Text})
case isOpencodeTextEvent(event):
emit(Event{Kind: KindText, Text: event.Part.Text})
case event.Type == "step_finish" && event.Part != nil:
usage := opencodeUsage(event.Part.Tokens)
if usage != (Usage{}) || event.Part.Cost != 0 {
emit(Event{Kind: KindUsage, CostUSD: event.Part.Cost, Usage: usage})
}
emit(Event{
Kind: KindResult,
CostUSD: event.Part.Cost,
Turns: 1,
Usage: opencodeUsage(event.Part.Tokens),
})
case event.Type == "step_finish":
// A marker with no part has no cost, usage, or text to record.
default:
// New event types stay visible until the parser gains a specific
// mapping, which prevents CLI upgrades from silently dropping output.
emit(Event{Kind: KindText, Text: line})
}
}
func opencodeUsage(tokens *opencodeTokens) Usage {
if tokens == nil {
return Usage{}
}
// OpenCode reports reasoning separately. The shared Usage type has no
// reasoning field, so include it in output for complete token totals.
return Usage{
InputTokens: tokens.Input,
OutputTokens: tokens.Output + tokens.Reasoning,
CacheReadTokens: tokens.Cache.Read,
CacheWriteTokens: tokens.Cache.Write,
}
}
func isOpencodeToolEvent(event opencodeLine) bool {
if event.Part == nil {
return false
}
return event.Type == wireTypeTool || event.Part.Type == wireTypeTool ||
event.Part.Tool != "" || event.Part.Name != ""
}
func isOpencodeReasoningEvent(event opencodeLine) bool {
if event.Part == nil || event.Part.Text == "" {
return false
}
return event.Type == "reasoning" || event.Part.Type == "reasoning"
}
func isOpencodeTextEvent(event opencodeLine) bool {
if event.Part == nil || event.Part.Text == "" {
return false
}
return event.Type == wireTypeText || event.Part.Type == wireTypeText
}
func opencodeErrorText(raw json.RawMessage, fallback string) string {
if len(raw) == 0 {
return fallback
}
var text string
if json.Unmarshal(raw, &text) == nil {
return text
}
var value struct {
Message string `json:"message"`
Name string `json:"name"`
Code string `json:"code"`
// Typed provider errors put the provider's message in data. That is
// where useful rate-limit and quota wording normally appears.
Data struct {
Message string `json:"message"`
} `json:"data"`
}
if json.Unmarshal(raw, &value) == nil {
for _, candidate := range []string{value.Data.Message, value.Message, value.Code, value.Name} {
if candidate != "" {
return candidate
}
}
}
return strings.TrimSpace(string(raw))
}
func (OpencodeHarness) SkillDir(workspace, name string) string {
return filepath.Join(workspace, ".opencode", "skill", name)
}
func (OpencodeHarness) GuideFilename() string { return "AGENTS.md" }
func (OpencodeHarness) SystemPromptViaArgs() bool { return false }
func (OpencodeHarness) EgressHosts() []string {
// Operators using another provider, such as Bedrock or Azure, must extend
// the caller's allowlist with that provider's hosts.
return []string{"models.dev", "api.openai.com", "*.anthropic.com"}
}
// Env ignores baseURL because OpenCode has no single provider endpoint. A
// caller can configure each provider through OPENCODE_CONFIG_CONTENT.
func (OpencodeHarness) Env(_ string) []string {
// --auto grants tool permissions. OPENCODE_PERMISSION is deliberately
// absent because OpenCode parses it as a JSON value and has no scalar
// "allow all" setting.
env := []string{
"OPENCODE_DISABLE_AUTOUPDATE=true",
"OPENCODE_DISABLE_MODELS_FETCH=true",
"OPENCODE_DISABLE_SHARE=true",
// OpenCode's server catch-all replaces uncaught defects with
// "Unexpected server error. Check server logs for details." and writes
// the cause via Effect.logError, which by default goes only to
// <XDG_DATA_HOME>/opencode/log/opencode.log. OPENCODE_PRINT_LOGS=1
// mirrors that logger to stderr so ParseStream (and the caller's scan
// log) receive the underlying error as text lines. The default level is
// Info, which would flood every successful run with startup and session
// bookkeeping; restricting to error keeps only the failure causes.
"OPENCODE_PRINT_LOGS=1",
"OPENCODE_LOG_LEVEL=error",
}
// OpenCode reads provider credentials from its auth config or the
// provider's environment. Pass through whichever form the caller set.
return append(env, passthroughEnv(
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENCODE_CONFIG_CONTENT",
"OPENCODE_AUTH_CONTENT",
)...)
}
func (OpencodeHarness) StateEnv(dir string) []string {
return []string{
"OPENCODE_CONFIG_DIR=" + dir,
"OPENCODE_DB=" + path.Join(dir, "opencode.db"),
}
}
func (OpencodeHarness) DefaultModels() []ModelDefault {
// With ANTHROPIC_API_KEY and no extra provider config, OpenCode selects
// Anthropic. The provider prefix is required by --model and is removed by
// normalizeModelID for price lookup.
claude := ClaudeHarness{}.DefaultModels()
models := make([]ModelDefault, len(claude))
for i, model := range claude {
models[i] = ModelDefault{
Name: model.Name,
ID: "anthropic/" + model.ID,
Tier: model.Tier,
}
}
return models
}
// opencodeAccountPhrases cover the common provider failures surfaced through
// OpenCode's nested error message.
var opencodeAccountPhrases = []string{
accountPhraseRateLimit,
accountCodeRateLimit,
accountPhraseTooManyRequests,
"429",
"usage limit",
"quota",
"insufficient_quota",
"invalid_api_key",
"incorrect api key",
"invalid x-api-key",
"credit balance",
"billing",
}
func (OpencodeHarness) AccountErrorText(s string) string {
return matchAccountPhrase(s, opencodeAccountPhrases)
}