git.iko.soy/opencode-android
on 5d56632 help sign in
496 lines 24 kB history raw
1package soy.iko.opencode.data.network
3import soy.iko.opencode.data.model.Agent
4import soy.iko.opencode.data.model.Command
5import soy.iko.opencode.data.model.CommandRequest
6import soy.iko.opencode.data.model.CreateSessionRequest
7import soy.iko.opencode.data.model.FileContent
8import soy.iko.opencode.data.model.FileNode
9import soy.iko.opencode.data.model.FileStatusEntry
10import soy.iko.opencode.data.model.MessageWithParts
11import soy.iko.opencode.data.model.ModelRef
12import soy.iko.opencode.data.model.PathInfo
13import soy.iko.opencode.data.model.FilePromptPart
14import soy.iko.opencode.data.model.FindMatch
15import soy.iko.opencode.data.model.PermissionReplyBody
16import soy.iko.opencode.data.model.PermissionResponse
17import soy.iko.opencode.data.model.Project
18import soy.iko.opencode.data.model.PromptPart
19import soy.iko.opencode.data.model.PromptRequest
20import soy.iko.opencode.data.model.ProvidersResponse
21import soy.iko.opencode.data.model.RevertRequest
22import soy.iko.opencode.data.model.Session
23import soy.iko.opencode.data.model.ShellRequest
24import soy.iko.opencode.data.model.SummarizeRequest
25import soy.iko.opencode.data.model.SymbolResult
26import soy.iko.opencode.data.model.TextPromptPart
27import soy.iko.opencode.data.model.UpdateSessionRequest
28import io.ktor.client.HttpClient
29import io.ktor.client.call.body
30import io.ktor.client.plugins.ClientRequestException
31import io.ktor.client.plugins.ResponseException
32import io.ktor.client.plugins.ServerResponseException
33import io.ktor.client.request.delete
34import io.ktor.client.request.get
35import io.ktor.client.request.header
36import io.ktor.client.request.parameter
37import io.ktor.client.request.patch
38import io.ktor.client.request.post
39import io.ktor.client.request.setBody
40import io.ktor.http.ContentType
41import io.ktor.http.contentType
42import kotlinx.coroutines.delay
43import kotlinx.coroutines.sync.Mutex
44import kotlinx.coroutines.sync.withLock
45import kotlin.random.Random
46import java.net.URLEncoder
48/**
49 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
50 * SSE stream lives in [EventStreamClient]; everything else is here.
51 */
52open class OpencodeApiClient private constructor(
53 private val client: HttpClient?,
54 @Suppress("unused") private val testMode: Boolean,
55) {
56 constructor(client: HttpClient) : this(client, false)
57 protected constructor() : this(null, true)
59 // --- Catalog cache (providers/agents/commands) ---
60 // These change rarely, but the ChatViewModel re-fetches them on every creation.
61 // A short-lived in-memory cache avoids redundant network calls when navigating
62 // between sessions on the same server.
63 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
64 // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
65 // The fetch runs OUTSIDE the lock (holding it across the network call would serialize
66 // all callers and stall invalidation). To keep that correct, each catalog carries a
67 // generation counter, bumped under the mutex by its invalidateXCache(): a fetch captures
68 // the generation before it starts and, when it completes, only writes its result back if
69 // the generation is unchanged — so an invalidation that raced the in-flight fetch is not
70 // defeated by the pre-invalidation result being stored afterwards.
71 private val providersMutex = Mutex()
72 private val agentsMutex = Mutex()
73 private val commandsMutex = Mutex()
74 private var cachedProviders: CachedEntry<ProvidersResponse>? = null
75 private var cachedAgents: CachedEntry<List<Agent>>? = null
76 private var cachedCommands: CachedEntry<List<Command>>? = null
77 // Generation counters (guarded by the matching mutex above) for the invalidation-race guard.
78 private var providersGeneration = 0
79 private var agentsGeneration = 0
80 private var commandsGeneration = 0
82 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
83 open suspend fun ping() {
84 withRetry { client!!.get("global/health").body<String>() }
85 }
87 open suspend fun listSessions(): List<Session> = withRetry {
88 client!!.get("session").body()
89 }
91 /**
92 * Create a session, optionally in a specific worktree [directory]. The directory is a
93 * query parameter (not a body field): the server records it on the session and resolves
94 * it by session id thereafter, so follow-up calls (messages, commands, abort, …) don't
95 * — and shouldn't — re-send it. A null/blank [directory] lets the server fall back to
96 * its own launch cwd, preserving the pre-directory behavior.
97 */
98 open suspend fun createSession(title: String? = null, directory: String? = null): Session {
99 // Generate the idempotency key before entering withRetry so every retry attempt
100 // shares it. Without this, a POST that reached the server but whose response was
101 // lost (reset/timeout) would be retried and create a second orphan session.
102 val idempotencyKey = java.util.UUID.randomUUID().toString()
103 val dir = directory?.takeIf { it.isNotBlank() }
104 return withRetry {
105 client!!.post("session") {
106 contentType(ContentType.Application.Json)
107 header("Idempotency-Key", idempotencyKey)
108 if (dir != null) parameter("directory", dir)
109 setBody(CreateSessionRequest(title = title))
110 }.body()
111 }
112 }
114 /** List the projects (worktree roots) the server knows about, for the directory picker. */
115 open suspend fun listProjects(): List<Project> = withRetry {
116 client!!.get("project").body()
117 }
119 /** The server's current path info; its [PathInfo.directory] is the default (cwd) a
120 * session with no directory override runs in. */
121 open suspend fun currentPath(): PathInfo = withRetry {
122 client!!.get("path").body()
123 }
125 open suspend fun updateSession(id: String, title: String): Session = withRetry {
126 client!!.patch("session/${encode(id)}") {
127 contentType(ContentType.Application.Json)
128 setBody(UpdateSessionRequest(title = title))
129 }.body()
130 }
132 open suspend fun deleteSession(id: String) {
133 try {
134 withRetry { client!!.delete("session/${encode(id)}").body<String>() }
135 } catch (t: ClientRequestException) {
136 // A DELETE that succeeded but whose response was lost gets retried; the retry
137 // hits an already-gone session and returns 404. Deleting a non-existent session
138 // is effectively success, so swallow 404 rather than surfacing a spurious failure.
139 if (t.response.status.value != 404) throw t
140 }
141 }
143 open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
144 client!!.get("session/${encode(sessionId)}/message").body()
145 }
147 open suspend fun sendPrompt(
148 sessionId: String,
149 text: String,
150 attachments: List<FilePromptPart> = emptyList(),
151 model: ModelRef? = null,
152 agent: String? = null,
153 // A caller-stable idempotency key. Callers that may re-invoke sendPrompt across
154 // *separate* calls for the same logical message (the offline outbox, which re-sends
155 // a message that stayed queued after an earlier flush failed) must pass a stable key
156 // derived from the persistent message id — otherwise each attempt mints a fresh key
157 // and a POST that reached the server but whose response was lost is re-created as a
158 // duplicate run. When null, a fresh key is generated (correct for one-shot sends).
159 idempotencyKey: String? = null,
160 ): MessageWithParts {
161 // Resolve the key *before* entering withRetry so all retry attempts share it. If the
162 // key were generated inside withRetry, each attempt would get a fresh UUID and the
163 // server couldn't deduplicate a request that reached it but whose response was lost.
164 val resolvedKey = idempotencyKey ?: java.util.UUID.randomUUID().toString()
165 // A text part is only included when there's actual text, so an image-only prompt
166 // (attachments, empty text) doesn't send an empty text part. Attachments follow
167 // the text so the model reads the instruction first.
168 val parts = buildList<PromptPart> {
169 if (text.isNotEmpty()) add(TextPromptPart(text))
170 addAll(attachments)
171 }
172 return withRetry {
173 client!!.post("session/${encode(sessionId)}/message") {
174 contentType(ContentType.Application.Json)
175 header("Idempotency-Key", resolvedKey)
176 setBody(
177 PromptRequest(
178 parts = parts,
179 model = model,
180 agent = agent,
181 ),
182 )
183 }.body()
184 }
185 }
187 open suspend fun abort(sessionId: String) {
188 withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
189 }
191 /** Invoke a slash-command by name via `POST /session/:id/command`. */
192 open suspend fun runCommand(
193 sessionId: String,
194 command: String,
195 arguments: String = "",
196 agent: String? = null,
197 ): MessageWithParts {
198 // Generate the idempotency key before entering withRetry so all retry
199 // attempts share the same key, preventing duplicate command execution if
200 // the server processed the request but the response was lost.
201 val idempotencyKey = java.util.UUID.randomUUID().toString()
202 return withRetry {
203 client!!.post("session/${encode(sessionId)}/command") {
204 contentType(ContentType.Application.Json)
205 header("Idempotency-Key", idempotencyKey)
206 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
207 }.body()
208 }
209 }
211 /** Revert the session to just before [messageId] (optionally part [partId]); the
212 * server hides everything after the checkpoint. Returns the updated session. Naturally
213 * idempotent (reverting to the same point twice yields the same state). */
214 open suspend fun revert(sessionId: String, messageId: String, partId: String? = null): Session = withRetry {
215 client!!.post("session/${encode(sessionId)}/revert") {
216 contentType(ContentType.Application.Json)
217 setBody(RevertRequest(messageID = messageId, partID = partId))
218 }.body()
219 }
221 /** Undo the active revert checkpoint, restoring the hidden messages. */
222 open suspend fun unrevert(sessionId: String): Session = withRetry {
223 client!!.post("session/${encode(sessionId)}/unrevert").body()
224 }
226 /** Create a public share link for the session. Returns the session with [Session.share] set. */
227 open suspend fun shareSession(sessionId: String): Session = withRetry {
228 client!!.post("session/${encode(sessionId)}/share").body()
229 }
231 /** Revoke the session's public share link. Returns the session with share cleared. */
232 open suspend fun unshareSession(sessionId: String): Session = withRetry {
233 client!!.delete("session/${encode(sessionId)}/share").body()
234 }
236 /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
237 * summary streams back via SSE like any run; this just triggers it. */
238 open suspend fun summarize(sessionId: String, model: ModelRef) {
239 val idempotencyKey = java.util.UUID.randomUUID().toString()
240 withRetry {
241 client!!.post("session/${encode(sessionId)}/summarize") {
242 contentType(ContentType.Application.Json)
243 header("Idempotency-Key", idempotencyKey)
244 setBody(SummarizeRequest(providerID = model.providerID, modelID = model.modelID))
245 }.body<String>()
246 }
247 }
249 /** Analyze the project and (re)generate its AGENTS.md. The body is omitted so the server
250 * assigns the message id and uses its default model; the run streams back via SSE. */
251 open suspend fun initSession(sessionId: String) {
252 val idempotencyKey = java.util.UUID.randomUUID().toString()
253 withRetry {
254 client!!.post("session/${encode(sessionId)}/init") {
255 header("Idempotency-Key", idempotencyKey)
256 }.body<String>()
257 }
258 }
260 /** Run a one-off shell [command] in the session's worktree. Output streams back via SSE.
261 * [agent] scopes the run (the server requires it). */
262 open suspend fun shell(sessionId: String, command: String, agent: String, model: ModelRef? = null) {
263 val idempotencyKey = java.util.UUID.randomUUID().toString()
264 withRetry {
265 client!!.post("session/${encode(sessionId)}/shell") {
266 contentType(ContentType.Application.Json)
267 header("Idempotency-Key", idempotencyKey)
268 setBody(ShellRequest(agent = agent, command = command, model = model))
269 }.body<String>()
270 }
271 }
273 open suspend fun providers(): ProvidersResponse {
274 val generation: Int
275 providersMutex.withLock {
276 val cached = cachedProviders
277 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
278 return cached.value
279 }
280 generation = providersGeneration
281 }
282 // Fetch outside the lock so a slow/retrying request (and its backoff) can't stall other
283 // callers or cache invalidation. Concurrent cold callers may double-fetch — acceptable
284 // for a rarely-fetched catalog — and the last store wins.
285 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
286 providersMutex.withLock {
287 // FIX 15: skip the store if an invalidateProvidersCache() raced this fetch — the
288 // result predates the invalidation and would otherwise resurrect stale data.
289 if (providersGeneration == generation) cachedProviders = CachedEntry(value, System.currentTimeMillis())
290 }
291 return value
292 }
294 /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
295 * (e.g. the MCP viewer) so a schema addition on the server never breaks decoding. */
296 open suspend fun config(): kotlinx.serialization.json.JsonObject = withRetry {
297 client!!.get("config").body()
298 }
300 open suspend fun agents(): List<Agent> {
301 val generation: Int
302 agentsMutex.withLock {
303 val cached = cachedAgents
304 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
305 return cached.value
306 }
307 generation = agentsGeneration
308 }
309 // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
310 // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
311 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
312 agentsMutex.withLock {
313 // FIX 15: skip the store if an invalidateAgentsCache() raced this fetch (see providers()).
314 if (agentsGeneration == generation) cachedAgents = CachedEntry(value, System.currentTimeMillis())
315 }
316 return value
317 }
319 open suspend fun commands(): List<Command> {
320 val generation: Int
321 commandsMutex.withLock {
322 val cached = cachedCommands
323 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
324 return cached.value
325 }
326 generation = commandsGeneration
327 }
328 // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
329 // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
330 val value = withRetry { client!!.get("command").body<List<Command>>() }
331 commandsMutex.withLock {
332 // FIX 15: skip the store if an invalidateCommandsCache() raced this fetch (see providers()).
333 if (commandsGeneration == generation) cachedCommands = CachedEntry(value, System.currentTimeMillis())
334 }
335 return value
336 }
338 /** Invalidate the catalog cache (e.g. on server switch). */
339 open suspend fun invalidateCache() {
340 // FIX 15: bump each generation so an in-flight fetch drops its now-stale result.
341 providersMutex.withLock { cachedProviders = null; providersGeneration++ }
342 agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
343 commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
344 }
346 open suspend fun invalidateProvidersCache() {
347 providersMutex.withLock { cachedProviders = null; providersGeneration++ }
348 }
350 open suspend fun invalidateAgentsCache() {
351 agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
352 }
354 open suspend fun invalidateCommandsCache() {
355 commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
356 }
358 /** Respond to a permission request so a paused tool run can proceed. */
359 open suspend fun respondPermission(
360 sessionId: String,
361 permissionId: String,
362 response: PermissionResponse,
363 ): String = try {
364 withRetry {
365 client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
366 contentType(ContentType.Application.Json)
367 setBody(PermissionReplyBody(response.wire))
368 }.body<String>()
369 }
370 } catch (t: ClientRequestException) {
371 // A response that reached the server but whose reply was lost gets retried; the
372 // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
373 // the answer landed, so treat them as success instead of a spurious failure.
374 val code = t.response.status.value
375 if (code != 404 && code != 409) throw t
376 ""
377 }
379 // --- Files ---
381 open suspend fun findFiles(query: String): List<String> = withRetry {
382 client!!.get("find/file") { parameter("query", query) }.body()
383 }
385 /** Ripgrep content search across the project (`GET /find?pattern=`). */
386 open suspend fun findText(pattern: String): List<FindMatch> = withRetry {
387 client!!.get("find") { parameter("pattern", pattern) }.body()
388 }
390 /** LSP workspace symbol search (`GET /find/symbol?query=`). */
391 open suspend fun findSymbol(query: String): List<SymbolResult> = withRetry {
392 client!!.get("find/symbol") { parameter("query", query) }.body()
393 }
395 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
396 client!!.get("file") { parameter("path", path) }.body()
397 }
399 open suspend fun readFile(path: String): FileContent = withRetry {
400 client!!.get("file/content") { parameter("path", path) }.body()
401 }
403 open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
404 client!!.get("file/status").body()
405 }
408private suspend fun <T> withRetry(
409 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
410 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
411 block: suspend () -> T,
412): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
414/**
415 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
416 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
417 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
418 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
419 * server errors are retried. Exposed as `internal` so the retry/skip rules are
420 * unit-testable without an HTTP server.
421 */
422internal suspend fun <T> withRetryInternal(
423 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
424 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
425 jitterFactor: Double = NetworkConfig.retryJitterFactor,
426 block: suspend () -> T,
427): T {
428 var lastError: Throwable? = null
429 for (attempt in 1..maxAttempts) {
430 try {
431 return block()
432 } catch (c: kotlinx.coroutines.CancellationException) {
433 throw c
434 } catch (t: ClientRequestException) {
435 // 408 (Request Timeout) and 429 (Too Many Requests) are transient — retry with
436 // backoff. All other 4xx are non-retryable (a 401/404 won't succeed on retry).
437 val status = t.response.status.value
438 if (status != 408 && status != 429) throw t
439 lastError = t
440 if (attempt < maxAttempts) {
441 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
442 }
443 } catch (t: ServerResponseException) {
444 // 5xx server errors are retryable with backoff.
445 lastError = t
446 if (attempt < maxAttempts) {
447 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
448 }
449 } catch (t: ResponseException) {
450 // 3xx (redirect limit exceeded) is non-retryable.
451 throw t
452 } catch (t: io.ktor.serialization.ContentConvertException) {
453 // A malformed/unexpected response body (deserialization failure via content
454 // negotiation) is deterministic — it fails identically on every attempt — so
455 // retrying only burns the whole backoff budget re-parsing the same bytes. Fail
456 // fast instead.
457 throw t
458 } catch (t: IllegalArgumentException) {
459 // Also deterministic: a bad argument, or a raw kotlinx SerializationException
460 // (which extends IllegalArgumentException) thrown outside content negotiation.
461 throw t
462 } catch (t: Exception) {
463 lastError = t
464 if (attempt < maxAttempts) {
465 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
466 }
467 }
468 }
469 throw lastError ?: IllegalStateException("withRetry failed without error")
472/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
473 * so the backoff formula is unit-testable without an HTTP server. */
474internal fun jitteredBackoff(
475 attempt: Int,
476 initialDelayMs: Long,
477 jitterFactor: Double,
478): Long {
479 // Guard against Int overflow from the shift and Long overflow from
480 // the multiplication at high attempt counts.
481 val shift = (attempt - 1).coerceAtMost(30)
482 val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
483 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
484 // shorter, sometimes longer — spreads concurrent client retries and
485 // prevents thundering-herd reconnection storms.
486 val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
487 return (baseDelay + jitter).coerceAtLeast(0)
490/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding).
491 * URLEncoder leaves '.' unencoded, so a "." or ".." segment would otherwise survive as a real
492 * path-traversal component; percent-encode a segment that is nothing but dots. */
493private fun encode(segment: String): String {
494 val encoded = URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
495 return if (encoded.isNotEmpty() && encoded.all { it == '.' }) encoded.replace(".", "%2E") else encoded