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