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.PermissionReplyBody
13import soy.iko.opencode.data.model.PermissionResponse
14import soy.iko.opencode.data.model.PromptPart
15import soy.iko.opencode.data.model.PromptRequest
16import soy.iko.opencode.data.model.ProvidersResponse
17import soy.iko.opencode.data.model.Session
18import soy.iko.opencode.data.model.UpdateSessionRequest
19import io.ktor.client.HttpClient
20import io.ktor.client.call.body
21import io.ktor.client.plugins.ClientRequestException
22import io.ktor.client.request.delete
23import io.ktor.client.request.get
24import io.ktor.client.request.header
25import io.ktor.client.request.parameter
26import io.ktor.client.request.patch
27import io.ktor.client.request.post
28import io.ktor.client.request.setBody
29import io.ktor.http.ContentType
30import io.ktor.http.contentType
31import kotlinx.coroutines.delay
32import kotlinx.coroutines.sync.Mutex
33import kotlinx.coroutines.sync.withLock
34import kotlin.random.Random
35import java.net.URLEncoder
38 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
39 * SSE stream lives in [EventStreamClient]; everything else is here.
41class OpencodeApiClient(private val client: HttpClient) {
43 // --- Catalog cache (providers/agents/commands) ---
44 // These change rarely, but the ChatViewModel re-fetches them on every creation.
45 // A short-lived in-memory cache avoids redundant network calls when navigating
46 // between sessions on the same server.
47 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
48 private val cacheMutex = Mutex()
49 private var cachedProviders: CachedEntry<ProvidersResponse>? = null
50 private var cachedAgents: CachedEntry<List<Agent>>? = null
51 private var cachedCommands: CachedEntry<List<Command>>? = null
53 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
55 client.get("global/health")
58 suspend fun listSessions(): List<Session> = withRetry {
59 client.get("session").body()
62 suspend fun createSession(title: String? = null): Session = withRetry {
63 client.post("session") {
64 contentType(ContentType.Application.Json)
65 setBody(CreateSessionRequest(title = title))
69 suspend fun updateSession(id: String, title: String): Session = withRetry {
70 client.patch("session/${encode(id)}") {
71 contentType(ContentType.Application.Json)
72 setBody(UpdateSessionRequest(title = title))
76 suspend fun deleteSession(id: String) {
77 withRetry { client.delete("session/${encode(id)}") }
80 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
81 client.get("session/${encode(sessionId)}/message").body()
84 suspend fun sendPrompt(
87 model: ModelRef? = null,
88 agent: String? = null,
90 // Generate the idempotency key *before* entering withRetry so all retry
91 // attempts share the same key. If the key were generated inside withRetry,
92 // each attempt would get a fresh UUID and the server couldn't deduplicate a
93 // request that reached it but whose response was lost (e.g. timeout).
94 val idempotencyKey = java.util.UUID.randomUUID().toString()
96 client.post("session/${encode(sessionId)}/message") {
97 contentType(ContentType.Application.Json)
98 header("Idempotency-Key", idempotencyKey)
101 parts = listOf(PromptPart(text = text)),
110 suspend fun abort(sessionId: String) {
111 withRetry { client.post("session/${encode(sessionId)}/abort") }
114 /** Invoke a slash-command by name via `POST /session/:id/command`. */
115 suspend fun runCommand(
118 arguments: String = "",
119 agent: String? = null,
120 ): MessageWithParts = withRetry {
121 client.post("session/${encode(sessionId)}/command") {
122 contentType(ContentType.Application.Json)
123 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
127 suspend fun providers(): ProvidersResponse {
128 val now = System.currentTimeMillis()
129 cacheMutex.withLock {
130 val cached = cachedProviders
131 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
135 val value = withRetry { client.get("config/providers").body<ProvidersResponse>() }
136 cacheMutex.withLock { cachedProviders = CachedEntry(value, System.currentTimeMillis()) }
140 suspend fun agents(): List<Agent> {
141 val now = System.currentTimeMillis()
142 cacheMutex.withLock {
143 val cached = cachedAgents
144 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
148 val value = withRetry { client.get("agent").body<List<Agent>>() }
149 cacheMutex.withLock { cachedAgents = CachedEntry(value, System.currentTimeMillis()) }
153 suspend fun commands(): List<Command> {
154 val now = System.currentTimeMillis()
155 cacheMutex.withLock {
156 val cached = cachedCommands
157 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
161 val value = withRetry { client.get("command").body<List<Command>>() }
162 cacheMutex.withLock { cachedCommands = CachedEntry(value, System.currentTimeMillis()) }
166 /** Invalidate the catalog cache (e.g. on server switch). */
167 suspend fun invalidateCache() = cacheMutex.withLock {
168 cachedProviders = null
170 cachedCommands = null
173 /** Respond to a permission request so a paused tool run can proceed. */
174 suspend fun respondPermission(
176 permissionId: String,
177 response: PermissionResponse,
179 client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
180 contentType(ContentType.Application.Json)
181 setBody(PermissionReplyBody(response.wire))
187 suspend fun findFiles(query: String): List<String> = withRetry {
188 client.get("find/file") { parameter("query", query) }.body()
191 suspend fun listDirectory(path: String): List<FileNode> = withRetry {
192 client.get("file") { parameter("path", path) }.body()
195 suspend fun readFile(path: String): FileContent = withRetry {
196 client.get("file/content") { parameter("path", path) }.body()
199 suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
200 client.get("file/status").body()
204private suspend fun <T> withRetry(
205 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
206 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
207 block: suspend () -> T,
208): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
211 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
212 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
213 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
214 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
215 * server errors are retried. Exposed as `internal` so the retry/skip rules are
216 * unit-testable without an HTTP server.
218internal suspend fun <T> withRetryInternal(
219 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
220 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
221 jitterFactor: Double = NetworkConfig.retryJitterFactor,
222 block: suspend () -> T,
224 var lastError: Throwable? = null
225 for (attempt in 1..maxAttempts) {
228 } catch (c: kotlinx.coroutines.CancellationException) {
230 } catch (t: Exception) {
232 if (t is ClientRequestException) {
233 // 429 (Too Many Requests) is transient — retry with backoff.
234 if (t.response.status.value != 429) throw t
236 if (attempt < maxAttempts) {
237 // Guard against Int overflow from the shift and Long overflow from
238 // the multiplication at high attempt counts.
239 val shift = (attempt - 1).coerceAtMost(30)
240 val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
241 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
242 // shorter, sometimes longer — spreads concurrent client retries and
243 // prevents thundering-herd reconnection storms.
244 val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
245 delay((baseDelay + jitter).coerceAtLeast(0))
249 throw lastError ?: IllegalStateException("withRetry failed without error")
252/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
253private fun encode(segment: String): String =
254 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
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