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
49 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
50 * SSE stream lives in [EventStreamClient]; everything else is here.
52open class OpencodeApiClient private constructor(
53 private val client: HttpClient?,
54 @Suppress("unused") private val testMode: Boolean,
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>() }
87 open suspend fun listSessions(): List<Session> = withRetry {
88 client!!.get("session").body()
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.
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() }
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))
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()
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()
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))
132 open suspend fun deleteSession(id: String) {
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
143 open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
144 client!!.get("session/${encode(sessionId)}/message").body()
147 open suspend fun sendPrompt(
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))
173 client!!.post("session/${encode(sessionId)}/message") {
174 contentType(ContentType.Application.Json)
175 header("Idempotency-Key", resolvedKey)
187 open suspend fun abort(sessionId: String) {
188 withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
191 /** Invoke a slash-command by name via `POST /session/:id/command`. */
192 open suspend fun runCommand(
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()
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))
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))
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()
226 /** Create a public share link for the session. Returns the session with [Session.share] set.
227 * Mint the Idempotency-Key *before* withRetry so a retried POST (first request reached the
228 * server but its response was lost) doesn't create a second, orphaned, still-valid share. */
229 open suspend fun shareSession(sessionId: String): Session {
230 val idempotencyKey = java.util.UUID.randomUUID().toString()
232 client!!.post("session/${encode(sessionId)}/share") {
233 header("Idempotency-Key", idempotencyKey)
238 /** Revoke the session's public share link. Returns the session with share cleared.
239 * Like [deleteSession], a retried DELETE (first reached the server but the response was
240 * lost) hits an already-unshared session and gets 404 — revoking a non-shared session is
241 * effectively success, so swallow 404 rather than surfacing a spurious failure. */
242 open suspend fun unshareSession(sessionId: String): Session {
244 withRetry { client!!.delete("session/${encode(sessionId)}/share").body() }
245 } catch (t: ClientRequestException) {
246 if (t.response.status.value != 404) throw t
247 withRetry { client!!.get("session/${encode(sessionId)}").body() }
251 /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
252 * summary streams back via SSE like any run; this just triggers it. */
253 open suspend fun summarize(sessionId: String, model: ModelRef) {
254 val idempotencyKey = java.util.UUID.randomUUID().toString()
256 client!!.post("session/${encode(sessionId)}/summarize") {
257 contentType(ContentType.Application.Json)
258 header("Idempotency-Key", idempotencyKey)
259 setBody(SummarizeRequest(providerID = model.providerID, modelID = model.modelID))
264 /** Analyze the project and (re)generate its AGENTS.md. The body is omitted so the server
265 * assigns the message id and uses its default model; the run streams back via SSE. */
266 open suspend fun initSession(sessionId: String) {
267 val idempotencyKey = java.util.UUID.randomUUID().toString()
269 client!!.post("session/${encode(sessionId)}/init") {
270 header("Idempotency-Key", idempotencyKey)
275 /** Run a one-off shell [command] in the session's worktree. Output streams back via SSE.
276 * [agent] scopes the run (the server requires it). */
277 open suspend fun shell(sessionId: String, command: String, agent: String, model: ModelRef? = null) {
278 val idempotencyKey = java.util.UUID.randomUUID().toString()
280 client!!.post("session/${encode(sessionId)}/shell") {
281 contentType(ContentType.Application.Json)
282 header("Idempotency-Key", idempotencyKey)
283 setBody(ShellRequest(agent = agent, command = command, model = model))
288 open suspend fun providers(): ProvidersResponse {
290 providersMutex.withLock {
291 val cached = cachedProviders
292 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
295 generation = providersGeneration
297 // Fetch outside the lock so a slow/retrying request (and its backoff) can't stall other
298 // callers or cache invalidation. Concurrent cold callers may double-fetch — acceptable
299 // for a rarely-fetched catalog — and the last store wins.
300 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
301 providersMutex.withLock {
302 // FIX 15: skip the store if an invalidateProvidersCache() raced this fetch — the
303 // result predates the invalidation and would otherwise resurrect stale data.
304 if (providersGeneration == generation) cachedProviders = CachedEntry(value, System.currentTimeMillis())
309 /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
310 * (e.g. the MCP viewer) so a schema addition on the server never breaks decoding. */
311 open suspend fun config(): kotlinx.serialization.json.JsonObject = withRetry {
312 client!!.get("config").body()
315 open suspend fun agents(): List<Agent> {
317 agentsMutex.withLock {
318 val cached = cachedAgents
319 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
322 generation = agentsGeneration
324 // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
325 // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
326 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
327 agentsMutex.withLock {
328 // FIX 15: skip the store if an invalidateAgentsCache() raced this fetch (see providers()).
329 if (agentsGeneration == generation) cachedAgents = CachedEntry(value, System.currentTimeMillis())
334 open suspend fun commands(): List<Command> {
336 commandsMutex.withLock {
337 val cached = cachedCommands
338 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
341 generation = commandsGeneration
343 // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
344 // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
345 val value = withRetry { client!!.get("command").body<List<Command>>() }
346 commandsMutex.withLock {
347 // FIX 15: skip the store if an invalidateCommandsCache() raced this fetch (see providers()).
348 if (commandsGeneration == generation) cachedCommands = CachedEntry(value, System.currentTimeMillis())
353 /** Invalidate the catalog cache (e.g. on server switch). */
354 open suspend fun invalidateCache() {
355 // FIX 15: bump each generation so an in-flight fetch drops its now-stale result.
356 providersMutex.withLock { cachedProviders = null; providersGeneration++ }
357 agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
358 commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
361 open suspend fun invalidateProvidersCache() {
362 providersMutex.withLock { cachedProviders = null; providersGeneration++ }
365 open suspend fun invalidateAgentsCache() {
366 agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
369 open suspend fun invalidateCommandsCache() {
370 commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
373 /** Respond to a permission request so a paused tool run can proceed. */
374 open suspend fun respondPermission(
376 permissionId: String,
377 response: PermissionResponse,
380 client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
381 contentType(ContentType.Application.Json)
382 setBody(PermissionReplyBody(response.wire))
385 } catch (t: ClientRequestException) {
386 // A response that reached the server but whose reply was lost gets retried; the
387 // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
388 // the answer landed, so treat them as success instead of a spurious failure.
389 val code = t.response.status.value
390 if (code != 404 && code != 409) throw t
396 open suspend fun findFiles(query: String): List<String> = withRetry {
397 client!!.get("find/file") { parameter("query", query) }.body()
400 /** Ripgrep content search across the project (`GET /find?pattern=`). */
401 open suspend fun findText(pattern: String): List<FindMatch> = withRetry {
402 client!!.get("find") { parameter("pattern", pattern) }.body()
405 /** LSP workspace symbol search (`GET /find/symbol?query=`). */
406 open suspend fun findSymbol(query: String): List<SymbolResult> = withRetry {
407 client!!.get("find/symbol") { parameter("query", query) }.body()
410 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
411 client!!.get("file") { parameter("path", path) }.body()
414 open suspend fun readFile(path: String): FileContent = withRetry {
415 client!!.get("file/content") { parameter("path", path) }.body()
418 open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
419 client!!.get("file/status").body()
423private suspend fun <T> withRetry(
424 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
425 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
426 block: suspend () -> T,
427): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
430 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
431 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
432 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
433 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
434 * server errors are retried. Exposed as `internal` so the retry/skip rules are
435 * unit-testable without an HTTP server.
437internal suspend fun <T> withRetryInternal(
438 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
439 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
440 jitterFactor: Double = NetworkConfig.retryJitterFactor,
441 block: suspend () -> T,
443 var lastError: Throwable? = null
444 for (attempt in 1..maxAttempts) {
447 } catch (c: kotlinx.coroutines.CancellationException) {
449 } catch (t: ClientRequestException) {
450 // 408 (Request Timeout) and 429 (Too Many Requests) are transient — retry with
451 // backoff. All other 4xx are non-retryable (a 401/404 won't succeed on retry).
452 val status = t.response.status.value
453 if (status != 408 && status != 429) throw t
455 if (attempt < maxAttempts) {
456 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
458 } catch (t: ServerResponseException) {
459 // 5xx server errors are retryable with backoff.
461 if (attempt < maxAttempts) {
462 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
464 } catch (t: ResponseException) {
465 // 3xx (redirect limit exceeded) is non-retryable.
467 } catch (t: io.ktor.serialization.ContentConvertException) {
468 // A malformed/unexpected response body (deserialization failure via content
469 // negotiation) is deterministic — it fails identically on every attempt — so
470 // retrying only burns the whole backoff budget re-parsing the same bytes. Fail
473 } catch (t: IllegalArgumentException) {
474 // Also deterministic: a bad argument, or a raw kotlinx SerializationException
475 // (which extends IllegalArgumentException) thrown outside content negotiation.
477 } catch (t: Exception) {
479 if (attempt < maxAttempts) {
480 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
484 throw lastError ?: IllegalStateException("withRetry failed without error")
487/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
488 * so the backoff formula is unit-testable without an HTTP server. */
489internal fun jitteredBackoff(
491 initialDelayMs: Long,
492 jitterFactor: Double,
494 // Guard against Int overflow from the shift and Long overflow from
495 // the multiplication at high attempt counts.
496 val shift = (attempt - 1).coerceAtMost(30)
497 val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
498 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
499 // shorter, sometimes longer — spreads concurrent client retries and
500 // prevents thundering-herd reconnection storms.
501 val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
502 return (baseDelay + jitter).coerceAtLeast(0)
505/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding).
506 * URLEncoder leaves '.' unencoded, so a "." or ".." segment would otherwise survive as a real
507 * path-traversal component; percent-encode a segment that is nothing but dots. */
508private fun encode(segment: String): String {
509 val encoded = URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
510 return if (encoded.isNotEmpty() && encoded.all { it == '.' }) encoded.replace(".", "%2E") else encoded
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list