opencode-ruby 0.0.1.alpha2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +18 -0
- data/README.md +162 -0
- data/examples/conversation_recipe.rb +153 -0
- data/lib/opencode/client.rb +564 -0
- data/lib/opencode/error.rb +28 -0
- data/lib/opencode/instrumentation.rb +76 -0
- data/lib/opencode/part_source.rb +62 -0
- data/lib/opencode/prompts.rb +87 -0
- data/lib/opencode/reply.rb +549 -0
- data/lib/opencode/reply_observer.rb +101 -0
- data/lib/opencode/response_parser.rb +169 -0
- data/lib/opencode/todo.rb +43 -0
- data/lib/opencode/tool_part.rb +152 -0
- data/lib/opencode/tracer.rb +50 -0
- data/lib/opencode/version.rb +5 -0
- data/lib/opencode-ruby.rb +26 -0
- data/opencode-ruby.gemspec +45 -0
- metadata +129 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "base64"
|
|
6
|
+
|
|
7
|
+
module Opencode
|
|
8
|
+
# HTTP client for OpenCode REST API.
|
|
9
|
+
# Thread safety: Each instance creates its own Net::HTTP connection.
|
|
10
|
+
# Do NOT share instances across threads. Create per-job.
|
|
11
|
+
class Client
|
|
12
|
+
attr_reader :directory
|
|
13
|
+
|
|
14
|
+
def initialize(
|
|
15
|
+
base_url: ENV["OPENCODE_BASE_URL"] || "http://localhost:4096",
|
|
16
|
+
password: ENV["OPENCODE_SERVER_PASSWORD"],
|
|
17
|
+
timeout: (ENV["OPENCODE_TIMEOUT"] || 120).to_i,
|
|
18
|
+
directory: nil,
|
|
19
|
+
workspace: nil
|
|
20
|
+
)
|
|
21
|
+
@uri = URI.parse(base_url)
|
|
22
|
+
@password = password
|
|
23
|
+
@timeout = timeout || 120
|
|
24
|
+
@directory = directory
|
|
25
|
+
@workspace = workspace
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_session(title: nil, permissions: nil)
|
|
29
|
+
body = { title: title, permission: permissions }.compact
|
|
30
|
+
post("/session", body)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def send_message(
|
|
34
|
+
session_id, text,
|
|
35
|
+
parts: nil,
|
|
36
|
+
model: nil,
|
|
37
|
+
agent: nil,
|
|
38
|
+
system: nil,
|
|
39
|
+
message_id: nil,
|
|
40
|
+
no_reply: nil,
|
|
41
|
+
tools: nil,
|
|
42
|
+
format: nil,
|
|
43
|
+
variant: nil
|
|
44
|
+
)
|
|
45
|
+
body = prompt_payload(
|
|
46
|
+
text,
|
|
47
|
+
parts: parts,
|
|
48
|
+
model: model,
|
|
49
|
+
agent: agent,
|
|
50
|
+
system: system,
|
|
51
|
+
message_id: message_id,
|
|
52
|
+
no_reply: no_reply,
|
|
53
|
+
tools: tools,
|
|
54
|
+
format: format,
|
|
55
|
+
variant: variant
|
|
56
|
+
)
|
|
57
|
+
post("/session/#{session_id}/message", body)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def send_message_async(
|
|
61
|
+
session_id, text,
|
|
62
|
+
parts: nil,
|
|
63
|
+
model: nil,
|
|
64
|
+
agent: nil,
|
|
65
|
+
system: nil,
|
|
66
|
+
message_id: nil,
|
|
67
|
+
no_reply: nil,
|
|
68
|
+
tools: nil,
|
|
69
|
+
format: nil,
|
|
70
|
+
variant: nil
|
|
71
|
+
)
|
|
72
|
+
body = prompt_payload(
|
|
73
|
+
text,
|
|
74
|
+
parts: parts,
|
|
75
|
+
model: model,
|
|
76
|
+
agent: agent,
|
|
77
|
+
system: system,
|
|
78
|
+
message_id: message_id,
|
|
79
|
+
no_reply: no_reply,
|
|
80
|
+
tools: tools,
|
|
81
|
+
format: format,
|
|
82
|
+
variant: variant
|
|
83
|
+
)
|
|
84
|
+
post("/session/#{session_id}/prompt_async", body)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Block-form streaming — the headline API for callers who want the
|
|
88
|
+
# full async-prompt + SSE-loop + final-exchange-merge flow in one
|
|
89
|
+
# call. Returns the final Opencode::Reply::Result value object once
|
|
90
|
+
# the agent finishes.
|
|
91
|
+
#
|
|
92
|
+
# reply = client.stream(session_id, "Explain monads") do |part|
|
|
93
|
+
# print part["content"] if part["type"] == "text"
|
|
94
|
+
# end
|
|
95
|
+
# reply.full_text # => the final accumulated text
|
|
96
|
+
# reply.tool_parts # => array of terminal tool parts
|
|
97
|
+
#
|
|
98
|
+
# The block is invoked every time a part is added, grows, finalizes,
|
|
99
|
+
# or (for tool parts) advances state — i.e., whenever a user-visible
|
|
100
|
+
# change happens. The block receives the current `part` hash (string
|
|
101
|
+
# keys: "type", "content", "tool", "status", "input", ...).
|
|
102
|
+
#
|
|
103
|
+
# If you need raw events (every server.* tick, todo.updated, prompt
|
|
104
|
+
# asked/replied, etc.), use #stream_events instead.
|
|
105
|
+
#
|
|
106
|
+
# Optional kwargs are forwarded to send_message_async — model, agent,
|
|
107
|
+
# system prompt override, and the SSE pacing knobs supported by
|
|
108
|
+
# stream_events.
|
|
109
|
+
def stream(
|
|
110
|
+
session_id, text,
|
|
111
|
+
model: nil, agent: nil, system: nil, message_id: nil,
|
|
112
|
+
stream_timeout: 600,
|
|
113
|
+
first_event_timeout: 120,
|
|
114
|
+
idle_stream_timeout: nil,
|
|
115
|
+
on_activity_tick: nil,
|
|
116
|
+
&block
|
|
117
|
+
)
|
|
118
|
+
send_message_async(
|
|
119
|
+
session_id, text,
|
|
120
|
+
model: model, agent: agent, system: system, message_id: message_id
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
reply = Opencode::Reply.new
|
|
124
|
+
reply.add_observer(StreamBlockObserver.new(&block)) if block_given?
|
|
125
|
+
|
|
126
|
+
stream_events(
|
|
127
|
+
session_id: session_id,
|
|
128
|
+
timeout: stream_timeout,
|
|
129
|
+
first_event_timeout: first_event_timeout,
|
|
130
|
+
idle_stream_timeout: idle_stream_timeout,
|
|
131
|
+
reply: reply,
|
|
132
|
+
on_activity_tick: on_activity_tick
|
|
133
|
+
) do |event|
|
|
134
|
+
reply.apply(event)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
merge_final_exchange(session_id, reply)
|
|
138
|
+
reply.result
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def list_sessions
|
|
142
|
+
uri = build_uri("/session")
|
|
143
|
+
request = Net::HTTP::Get.new(uri)
|
|
144
|
+
execute(request)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def children(session_id)
|
|
148
|
+
uri = build_uri("/session/#{session_id}/children")
|
|
149
|
+
request = Net::HTTP::Get.new(uri)
|
|
150
|
+
execute(request)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def delete_session(session_id)
|
|
154
|
+
uri = build_uri("/session/#{session_id}")
|
|
155
|
+
request = Net::HTTP::Delete.new(uri)
|
|
156
|
+
execute(request)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def session_status
|
|
160
|
+
uri = build_uri("/session/status")
|
|
161
|
+
request = Net::HTTP::Get.new(uri)
|
|
162
|
+
execute(request)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def get_messages(session_id)
|
|
166
|
+
uri = build_uri("/session/#{session_id}/message")
|
|
167
|
+
request = Net::HTTP::Get.new(uri)
|
|
168
|
+
execute(request)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def abort_session(session_id)
|
|
172
|
+
post("/session/#{session_id}/abort", {})
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def reply_question(request_id:, answers:)
|
|
176
|
+
post("/question/#{request_id}/reply", { answers: answers })
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def reject_question(request_id:)
|
|
180
|
+
post("/question/#{request_id}/reject", {})
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def reply_permission(request_id:, reply:, message: nil)
|
|
184
|
+
body = { reply: reply }
|
|
185
|
+
body[:message] = message if message.present?
|
|
186
|
+
post("/permission/#{request_id}/reply", body)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Returns pending question requests as an Array of Hashes with
|
|
190
|
+
# SYMBOL keys, consistent with every other endpoint that flows
|
|
191
|
+
# through handle_response (e.g., health, list_sessions, get_messages).
|
|
192
|
+
# Callers that compare against persisted JSON column data should
|
|
193
|
+
# symbolize their side, not desymbolize this side.
|
|
194
|
+
def list_questions
|
|
195
|
+
uri = build_uri("/question")
|
|
196
|
+
request = Net::HTTP::Get.new(uri)
|
|
197
|
+
add_auth_header(request)
|
|
198
|
+
|
|
199
|
+
response = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.path) do
|
|
200
|
+
http_client.request(request)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
unless response.code.to_i.between?(200, 299)
|
|
204
|
+
raise ServerError, "list_questions failed: HTTP #{response.code} — #{response.body.to_s[0, 200]}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
return [] if response.body.blank?
|
|
208
|
+
JSON.parse(response.body, symbolize_names: true)
|
|
209
|
+
rescue JSON::ParserError => e
|
|
210
|
+
raise ServerError, "list_questions returned invalid JSON: #{e.message}"
|
|
211
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
|
|
212
|
+
raise TimeoutError, "OpenCode timeout after #{@timeout}s: #{e.message}"
|
|
213
|
+
rescue Errno::ECONNREFUSED, SocketError => e
|
|
214
|
+
raise ConnectionError, "OpenCode unreachable: #{e.message}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def health
|
|
218
|
+
uri = build_uri("/global/health", scoped: false)
|
|
219
|
+
request = Net::HTTP::Get.new(uri)
|
|
220
|
+
execute(request)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
MAX_SSE_BUFFER = 1_048_576 # 1 MB — safety valve against pathological server responses
|
|
224
|
+
SSE_RECONNECT_DELAY = 0.1
|
|
225
|
+
TRANSIENT_SSE_ERRORS = [
|
|
226
|
+
EOFError,
|
|
227
|
+
IOError,
|
|
228
|
+
Net::OpenTimeout,
|
|
229
|
+
Net::ReadTimeout,
|
|
230
|
+
Errno::ECONNREFUSED,
|
|
231
|
+
Errno::ECONNRESET,
|
|
232
|
+
Errno::EPIPE
|
|
233
|
+
].freeze
|
|
234
|
+
|
|
235
|
+
# Opens SSE connection to GET /event, yields parsed events filtered by session_id.
|
|
236
|
+
# Blocks until session goes idle or timeout, reconnecting across dropped
|
|
237
|
+
# event-stream connections.
|
|
238
|
+
#
|
|
239
|
+
# first_event_timeout: seconds to wait for a session-specific event before
|
|
240
|
+
# declaring the session stale. Server heartbeats don't count — they're global
|
|
241
|
+
# keep-alives that flow regardless of session state.
|
|
242
|
+
#
|
|
243
|
+
# Default 120s rather than the more aggressive 30s used originally:
|
|
244
|
+
# slow-thinking reasoning models (Kimi K2, GPT-5 with extended thinking,
|
|
245
|
+
# etc.) routinely spend 30-90s of pure reasoning before emitting their
|
|
246
|
+
# first `message.part.*` event, especially on cold sessions with long
|
|
247
|
+
# system prompts. 30s false-positive trips on legitimate first turns
|
|
248
|
+
# and converts them to `StaleSessionError`; 120s catches genuine zombies
|
|
249
|
+
# without nuking real reasoning. Callers that know their agent is
|
|
250
|
+
# short-prompt + fast can pass a lower value.
|
|
251
|
+
#
|
|
252
|
+
# idle_stream_timeout: seconds to wait BETWEEN meaningful events once
|
|
253
|
+
# the session has started producing them. Default nil = no check
|
|
254
|
+
# (preserves the overall `timeout` ceiling behavior). Opt-in heartbeat
|
|
255
|
+
# watchdog for callers whose user-facing surface needs to fail fast
|
|
256
|
+
# rather than sit forever when an upstream LLM stream wedges mid-turn.
|
|
257
|
+
# Distinct from first_event_timeout (which only protects cold-start)
|
|
258
|
+
# and from the overall `timeout` ceiling of 600s (which is forgiving
|
|
259
|
+
# — a hung stream holding a thread for 10 minutes is already a bad
|
|
260
|
+
# UX). When the window is exceeded the call raises
|
|
261
|
+
# Opencode::IdleStreamError, which the caller is expected to catch and
|
|
262
|
+
# translate into a user-visible error / retry affordance.
|
|
263
|
+
def stream_events(session_id:, timeout: 600, first_event_timeout: 120,
|
|
264
|
+
idle_stream_timeout: nil,
|
|
265
|
+
reply: nil, on_activity_tick: nil, &block)
|
|
266
|
+
uri = build_uri("/event")
|
|
267
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
268
|
+
first_event_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + first_event_timeout
|
|
269
|
+
received_session_event = false
|
|
270
|
+
last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
271
|
+
|
|
272
|
+
loop do
|
|
273
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
274
|
+
deadline = check_deadline_or_suspend(now, deadline, timeout, reply)
|
|
275
|
+
|
|
276
|
+
# NOTE: first_event_deadline is *not* suspension-eligible. If the agent
|
|
277
|
+
# never gets started we want to fail fast — a session that's blocked on
|
|
278
|
+
# a prompt has, by definition, already produced events.
|
|
279
|
+
if !received_session_event && now > first_event_deadline
|
|
280
|
+
raise StaleSessionError, "No events for session #{session_id} within #{first_event_timeout}s"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
if idle_stream_timeout && received_session_event &&
|
|
284
|
+
(now - last_meaningful_event_at) > idle_stream_timeout
|
|
285
|
+
raise IdleStreamError,
|
|
286
|
+
"No meaningful events for session #{session_id} within #{idle_stream_timeout}s " \
|
|
287
|
+
"(SSE heartbeats still arriving — upstream likely wedged mid-turn)"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
request = Net::HTTP::Get.new(uri)
|
|
291
|
+
request["Accept"] = "text/event-stream"
|
|
292
|
+
request["Cache-Control"] = "no-cache"
|
|
293
|
+
add_auth_header(request)
|
|
294
|
+
|
|
295
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
296
|
+
http.use_ssl = @uri.scheme == "https"
|
|
297
|
+
http.open_timeout = 10
|
|
298
|
+
http.read_timeout = 30
|
|
299
|
+
|
|
300
|
+
begin
|
|
301
|
+
buffer = String.new
|
|
302
|
+
|
|
303
|
+
http.request(request) do |response|
|
|
304
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
305
|
+
raise ServerError, "SSE connection failed: HTTP #{response.code}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
response.read_body do |chunk|
|
|
309
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
310
|
+
deadline = check_deadline_or_suspend(now, deadline, timeout, reply)
|
|
311
|
+
|
|
312
|
+
if !received_session_event && now > first_event_deadline
|
|
313
|
+
raise StaleSessionError, "No events for session #{session_id} within #{first_event_timeout}s"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
if idle_stream_timeout && received_session_event &&
|
|
317
|
+
(now - last_meaningful_event_at) > idle_stream_timeout
|
|
318
|
+
raise IdleStreamError,
|
|
319
|
+
"No meaningful events for session #{session_id} within #{idle_stream_timeout}s " \
|
|
320
|
+
"(SSE heartbeats still arriving — upstream likely wedged mid-turn)"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
buffer << chunk
|
|
324
|
+
if buffer.bytesize > MAX_SSE_BUFFER
|
|
325
|
+
raise ServerError, "SSE buffer exceeded #{MAX_SSE_BUFFER} bytes"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
while (idx = buffer.index("\n\n"))
|
|
329
|
+
raw_event = buffer.slice!(0, idx + 2)
|
|
330
|
+
event = parse_sse_event(raw_event, session_id)
|
|
331
|
+
next unless event
|
|
332
|
+
|
|
333
|
+
unless event[:type]&.start_with?("server.")
|
|
334
|
+
received_session_event = true
|
|
335
|
+
last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Tick activity on EVERY event, including server.heartbeat —
|
|
339
|
+
# that's the whole point: a healthy long wait (user thinking
|
|
340
|
+
# for 30 minutes) keeps the container warm via heartbeats so
|
|
341
|
+
# the reaper doesn't kill it mid-wait.
|
|
342
|
+
on_activity_tick&.call(event)
|
|
343
|
+
block.call(event)
|
|
344
|
+
return if event[:type] == "session.idle"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
rescue *TRANSIENT_SSE_ERRORS
|
|
349
|
+
# Treat transport-level SSE disconnects like clean EOF: reconnect
|
|
350
|
+
# until session.idle, the overall timeout, or first-event timeout.
|
|
351
|
+
ensure
|
|
352
|
+
begin
|
|
353
|
+
http&.finish if http&.started?
|
|
354
|
+
rescue IOError
|
|
355
|
+
# Connection already closed — network partition or server shutdown
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
cutoff = received_session_event ? deadline : first_event_deadline
|
|
360
|
+
sleep_for = [ SSE_RECONNECT_DELAY, cutoff - Process.clock_gettime(Process::CLOCK_MONOTONIC) ].min
|
|
361
|
+
if sleep_for.positive?
|
|
362
|
+
sleep sleep_for
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def close
|
|
368
|
+
@http&.finish if @http&.started?
|
|
369
|
+
rescue IOError
|
|
370
|
+
# already closed
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
private
|
|
374
|
+
|
|
375
|
+
# Best-effort merge of the polled message exchange into the live
|
|
376
|
+
# reply. Catches the stream-only / poll-only asymmetry — todo.updated
|
|
377
|
+
# is poll-only on some opencode versions; pure-streaming would miss
|
|
378
|
+
# the terminal todo state otherwise. If the session API is also down
|
|
379
|
+
# at this point (network partition, container teardown mid-call), we
|
|
380
|
+
# silently keep whatever the stream accumulated rather than raising;
|
|
381
|
+
# the caller's reply is still a usable Result either way.
|
|
382
|
+
def merge_final_exchange(session_id, reply)
|
|
383
|
+
exchange = get_messages(session_id)
|
|
384
|
+
last_assistant = Array(exchange).reverse_each.find do |message|
|
|
385
|
+
message.dig(:info, :role) == "assistant"
|
|
386
|
+
end
|
|
387
|
+
return unless last_assistant
|
|
388
|
+
|
|
389
|
+
polled = Opencode::ResponseParser.extract_interleaved_parts(last_assistant)
|
|
390
|
+
reply.sync_recovered_parts(polled) if polled.any?
|
|
391
|
+
rescue Opencode::Error
|
|
392
|
+
# Stream's result is still complete; the merge was a polish, not a
|
|
393
|
+
# requirement.
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Healthy wait: opencode is suspended on a question/permission deferred
|
|
397
|
+
# and heartbeats are keeping the connection alive. Reset the deadline
|
|
398
|
+
# to "from now" so the full stuck-stream protection is restored once
|
|
399
|
+
# the prompt resolves. Otherwise apply the normal deadline check.
|
|
400
|
+
def check_deadline_or_suspend(now, deadline, timeout, reply)
|
|
401
|
+
return now + timeout if reply&.prompt_blocked?
|
|
402
|
+
raise TimeoutError, "SSE stream timed out after #{timeout}s" if now > deadline
|
|
403
|
+
|
|
404
|
+
deadline
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def prompt_payload(text, parts:, model:, agent:, system:, message_id:, no_reply:, tools:, format:, variant:)
|
|
408
|
+
message_parts = parts || [ { type: "text", text: text } ]
|
|
409
|
+
{
|
|
410
|
+
messageID: message_id,
|
|
411
|
+
parts: message_parts,
|
|
412
|
+
model: format_model(model),
|
|
413
|
+
agent: agent,
|
|
414
|
+
noReply: no_reply,
|
|
415
|
+
tools: tools,
|
|
416
|
+
format: format,
|
|
417
|
+
system: system,
|
|
418
|
+
variant: variant
|
|
419
|
+
}.compact
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def format_model(model)
|
|
423
|
+
return nil unless model
|
|
424
|
+
return model if model.is_a?(Hash)
|
|
425
|
+
|
|
426
|
+
provider, model_id = model.split("/", 2)
|
|
427
|
+
{ providerID: provider, modelID: model_id }
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def post(path, body)
|
|
431
|
+
uri = build_uri(path)
|
|
432
|
+
request = Net::HTTP::Post.new(uri)
|
|
433
|
+
request.body = body.to_json
|
|
434
|
+
execute(request)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def build_uri(path, scoped: true)
|
|
438
|
+
uri = @uri.dup
|
|
439
|
+
uri.path = path
|
|
440
|
+
|
|
441
|
+
if scoped
|
|
442
|
+
query = URI.decode_www_form(uri.query.to_s)
|
|
443
|
+
query << [ "directory", @directory ] if @directory.present?
|
|
444
|
+
query << [ "workspace", @workspace ] if @workspace.present?
|
|
445
|
+
uri.query = query.any? ? URI.encode_www_form(query) : nil
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
uri
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def add_auth_header(request)
|
|
452
|
+
request["Content-Type"] = "application/json"
|
|
453
|
+
if @password.present?
|
|
454
|
+
request["Authorization"] = "Basic #{Base64.strict_encode64("opencode:#{@password}")}"
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def execute(request)
|
|
459
|
+
add_auth_header(request)
|
|
460
|
+
|
|
461
|
+
response = nil
|
|
462
|
+
result = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.path) do
|
|
463
|
+
response = http_client.request(request)
|
|
464
|
+
handle_response(response)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
result
|
|
468
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
|
|
469
|
+
raise TimeoutError, "OpenCode timeout after #{@timeout}s: #{e.message}"
|
|
470
|
+
rescue Errno::ECONNREFUSED, SocketError => e
|
|
471
|
+
raise ConnectionError, "OpenCode unreachable: #{e.message}"
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def http_client
|
|
475
|
+
@http ||= Net::HTTP.new(@uri.host, @uri.port).tap do |http|
|
|
476
|
+
http.use_ssl = @uri.scheme == "https"
|
|
477
|
+
http.open_timeout = 10
|
|
478
|
+
http.read_timeout = @timeout
|
|
479
|
+
http.write_timeout = 30
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def parse_sse_event(raw, session_id)
|
|
484
|
+
data_line = raw.lines.find { |l| l.start_with?("data: ") }
|
|
485
|
+
return nil unless data_line
|
|
486
|
+
|
|
487
|
+
json = JSON.parse(data_line.sub("data: ", "").strip, symbolize_names: true)
|
|
488
|
+
|
|
489
|
+
event_session = json.dig(:properties, :sessionID) ||
|
|
490
|
+
json.dig(:properties, :info, :sessionID) ||
|
|
491
|
+
json.dig(:properties, :part, :sessionID)
|
|
492
|
+
|
|
493
|
+
return json if json[:type] == "server.heartbeat"
|
|
494
|
+
return json if json[:type] == "server.connected"
|
|
495
|
+
return nil unless event_session == session_id
|
|
496
|
+
|
|
497
|
+
json
|
|
498
|
+
rescue JSON::ParserError
|
|
499
|
+
nil
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def handle_response(response)
|
|
503
|
+
return {} if response.code.to_i == 204
|
|
504
|
+
|
|
505
|
+
body = if response.body.present?
|
|
506
|
+
JSON.parse(response.body, symbolize_names: true)
|
|
507
|
+
else
|
|
508
|
+
{}
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
case response.code.to_i
|
|
512
|
+
when 200..299 then body
|
|
513
|
+
when 400 then raise BadRequestError.new(error_message(body, "Bad request"), response: body)
|
|
514
|
+
when 404 then raise SessionNotFoundError.new(error_message(body, "Session not found"), response: body)
|
|
515
|
+
when 500..599 then raise ServerError.new(error_message(body, "Server error"), response: body)
|
|
516
|
+
else raise Error.new("Unexpected response: #{response.code}", response: body)
|
|
517
|
+
end
|
|
518
|
+
rescue JSON::ParserError
|
|
519
|
+
raise ServerError.new("Invalid JSON from OpenCode (HTTP #{response.code}): #{response.body&.truncate(200)}")
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# OpenCode HTTP error bodies use a wrapped shape: { name:, data: { message:, kind?: } }.
|
|
523
|
+
# v1.14.51 stopped exposing internal defect details from the HTTP API, so
|
|
524
|
+
# `body[:message]` is no longer populated for errors — only `body[:data][:message]`.
|
|
525
|
+
# We read both to keep older mock servers working in tests.
|
|
526
|
+
def error_message(body, fallback)
|
|
527
|
+
body.dig(:data, :message) || body[:message] || fallback
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Internal Reply observer that bridges Reply's multi-callback protocol
|
|
532
|
+
# to a single user-supplied block for Client#stream. Each part-level
|
|
533
|
+
# callback (part_added, part_changed, part_finalized, tool_progressed)
|
|
534
|
+
# forwards the current part to the user's block.
|
|
535
|
+
#
|
|
536
|
+
# Non-part-level callbacks (step_finished, session_*, message_updated,
|
|
537
|
+
# todos_changed, question_*, permission_*) are intentionally NOT
|
|
538
|
+
# forwarded — they're either telemetry the gem owns internally, or
|
|
539
|
+
# interactive-protocol concerns that callers route through
|
|
540
|
+
# #stream_events directly when they need them.
|
|
541
|
+
class StreamBlockObserver
|
|
542
|
+
include Opencode::ReplyObserver
|
|
543
|
+
|
|
544
|
+
def initialize(&block)
|
|
545
|
+
@block = block
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def part_added(part:, **)
|
|
549
|
+
@block.call(part)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def part_changed(part:, **)
|
|
553
|
+
@block.call(part)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def part_finalized(part:, **)
|
|
557
|
+
@block.call(part)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def tool_progressed(part:, **)
|
|
561
|
+
@block.call(part)
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencode
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :response
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, response: nil)
|
|
8
|
+
@response = response
|
|
9
|
+
super(message)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class ConnectionError < Error; end
|
|
14
|
+
class TimeoutError < Error; end
|
|
15
|
+
class SessionNotFoundError < Error; end
|
|
16
|
+
class StaleSessionError < Error; end
|
|
17
|
+
# Raised by stream_events when meaningful (non-`server.*`) events stop
|
|
18
|
+
# arriving for longer than the caller's `idle_stream_timeout` window,
|
|
19
|
+
# even though the SSE socket itself is still alive (heartbeats are
|
|
20
|
+
# still flowing). Distinct from StaleSessionError, which fires when
|
|
21
|
+
# the session never produced any events in the first place. This one
|
|
22
|
+
# fires when the session WAS producing events and then went silent —
|
|
23
|
+
# the classic "OpenAI stream wedged mid-turn while the SSE keep-
|
|
24
|
+
# alive ticks on" failure mode.
|
|
25
|
+
class IdleStreamError < Error; end
|
|
26
|
+
class ServerError < Error; end
|
|
27
|
+
class BadRequestError < Error; end
|
|
28
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencode
|
|
4
|
+
# Pluggable instrumentation adapter. opencode-ruby ships zero
|
|
5
|
+
# dependencies on Rails or any specific instrumentation library. Users
|
|
6
|
+
# plug in their own emitter:
|
|
7
|
+
#
|
|
8
|
+
# # ActiveSupport::Notifications (Rails apps):
|
|
9
|
+
# Opencode::Instrumentation.adapter = ->(name, payload, &block) {
|
|
10
|
+
# ActiveSupport::Notifications.instrument(name, payload, &block)
|
|
11
|
+
# }
|
|
12
|
+
#
|
|
13
|
+
# # stdout (debugging, non-Rails scripts):
|
|
14
|
+
# Opencode::Instrumentation.adapter = ->(name, payload, &block) {
|
|
15
|
+
# puts "[#{name}] #{payload.inspect}"
|
|
16
|
+
# block.call
|
|
17
|
+
# }
|
|
18
|
+
#
|
|
19
|
+
# When no adapter is set (default), instrumentation is a no-op pass-
|
|
20
|
+
# through that yields the block and returns its value. The Client emits
|
|
21
|
+
# events for HTTP requests, SSE stream lifecycle, and recovery paths.
|
|
22
|
+
#
|
|
23
|
+
# Event names the Client emits:
|
|
24
|
+
#
|
|
25
|
+
# - opencode.request — every HTTP request to OpenCode server
|
|
26
|
+
#
|
|
27
|
+
# If you wire a real adapter, the payload hash carries `:method` and
|
|
28
|
+
# `:path` for opencode.request. Other events may add fields in future
|
|
29
|
+
# versions; treat the payload as forward-compatible.
|
|
30
|
+
#
|
|
31
|
+
# Two emission shapes:
|
|
32
|
+
#
|
|
33
|
+
# .instrument(name, payload) { ... } — wrap a block; the duration
|
|
34
|
+
# of the block becomes part
|
|
35
|
+
# of the event (when the
|
|
36
|
+
# adapter is ActiveSupport::
|
|
37
|
+
# Notifications-shaped).
|
|
38
|
+
#
|
|
39
|
+
# .notify(name, payload) — fire-and-forget; no block,
|
|
40
|
+
# no duration. Use for
|
|
41
|
+
# point-in-time observations
|
|
42
|
+
# (e.g. "this artifact was
|
|
43
|
+
# dropped").
|
|
44
|
+
module Instrumentation
|
|
45
|
+
class << self
|
|
46
|
+
attr_accessor :adapter
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Yields the block, optionally routed through the adapter if one is
|
|
50
|
+
# set. Always returns the block's return value (so call sites can
|
|
51
|
+
# wrap their work transparently).
|
|
52
|
+
def self.instrument(name, payload = {})
|
|
53
|
+
return yield unless adapter
|
|
54
|
+
|
|
55
|
+
adapter.call(name, payload) { yield }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Fire-and-forget event. No block, no return value (the adapter's
|
|
59
|
+
# return is ignored). Use for point-in-time observations where
|
|
60
|
+
# duration doesn't apply — apply_patch.artifacts_dropped,
|
|
61
|
+
# session.recreated, etc.
|
|
62
|
+
#
|
|
63
|
+
# Implementation: invokes the same adapter as #instrument but with
|
|
64
|
+
# an empty block. Hosts that adapt to ActiveSupport::Notifications
|
|
65
|
+
# will see a zero-duration event; hosts that adapt to a structured-
|
|
66
|
+
# event API (Rails.event.notify, OpenTelemetry span events) can
|
|
67
|
+
# detect the empty-block convention if they need to. Most hosts
|
|
68
|
+
# don't need to care.
|
|
69
|
+
def self.notify(name, payload = {})
|
|
70
|
+
return unless adapter
|
|
71
|
+
|
|
72
|
+
adapter.call(name, payload) { }
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|