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.plugins.ResponseException
23import io.ktor.client.plugins.ServerResponseException
24import io.ktor.client.request.delete
25import io.ktor.client.request.get
26import io.ktor.client.request.header
27import io.ktor.client.request.parameter
28import io.ktor.client.request.patch
29import io.ktor.client.request.post
30import io.ktor.client.request.setBody
31import io.ktor.http.ContentType
32import io.ktor.http.contentType
33import kotlinx.coroutines.delay
34import kotlinx.coroutines.sync.Mutex
35import kotlinx.coroutines.sync.withLock
36import kotlin.random.Random
37import java.net.URLEncoder
40 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
41 * SSE stream lives in [EventStreamClient]; everything else is here.
43open class OpencodeApiClient private constructor(
44 private val client: HttpClient?,
45 @Suppress("unused") private val testMode: Boolean,
47 constructor(client: HttpClient) : this(client, false)
48 protected constructor() : this(null, true)
50 // --- Catalog cache (providers/agents/commands) ---
51 // These change rarely, but the ChatViewModel re-fetches them on every creation.
52 // A short-lived in-memory cache avoids redundant network calls when navigating
53 // between sessions on the same server.
54 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
55 // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
56 // The lock is held during the fetch to prevent a thundering-herd stampede when
57 // multiple coroutines miss the cache simultaneously.
58 private val providersMutex = Mutex()
59 private val agentsMutex = Mutex()
60 private val commandsMutex = Mutex()
61 private var cachedProviders: CachedEntry<ProvidersResponse>? = null
62 private var cachedAgents: CachedEntry<List<Agent>>? = null
63 private var cachedCommands: CachedEntry<List<Command>>? = null
65 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
66 open suspend fun ping() {
67 withRetry { client!!.get("global/health").body<String>() }
70 open suspend fun listSessions(): List<Session> = withRetry {
71 client!!.get("session").body()
74 open suspend fun createSession(title: String? = null): Session = withRetry {
75 client!!.post("session") {
76 contentType(ContentType.Application.Json)
77 setBody(CreateSessionRequest(title = title))
81 open suspend fun updateSession(id: String, title: String): Session = withRetry {
82 client!!.patch("session/${encode(id)}") {
83 contentType(ContentType.Application.Json)
84 setBody(UpdateSessionRequest(title = title))
88 open suspend fun deleteSession(id: String) {
89 withRetry { client!!.delete("session/${encode(id)}").body<String>() }
92 open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
93 client!!.get("session/${encode(sessionId)}/message").body()
96 open suspend fun sendPrompt(
99 model: ModelRef? = null,
100 agent: String? = null,
101 ): MessageWithParts {
102 // Generate the idempotency key *before* entering withRetry so all retry
103 // attempts share the same key. If the key were generated inside withRetry,
104 // each attempt would get a fresh UUID and the server couldn't deduplicate a
105 // request that reached it but whose response was lost (e.g. timeout).
106 val idempotencyKey = java.util.UUID.randomUUID().toString()
108 client!!.post("session/${encode(sessionId)}/message") {
109 contentType(ContentType.Application.Json)
110 header("Idempotency-Key", idempotencyKey)
113 parts = listOf(PromptPart(text = text)),
122 open suspend fun abort(sessionId: String) {
123 withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
126 /** Invoke a slash-command by name via `POST /session/:id/command`. */
127 open suspend fun runCommand(
130 arguments: String = "",
131 agent: String? = null,
132 ): MessageWithParts {
133 // Generate the idempotency key before entering withRetry so all retry
134 // attempts share the same key, preventing duplicate command execution if
135 // the server processed the request but the response was lost.
136 val idempotencyKey = java.util.UUID.randomUUID().toString()
138 client!!.post("session/${encode(sessionId)}/command") {
139 contentType(ContentType.Application.Json)
140 header("Idempotency-Key", idempotencyKey)
141 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
146 open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
147 val now = System.currentTimeMillis()
148 val cached = cachedProviders
149 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
150 return@withLock cached.value
152 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
153 cachedProviders = CachedEntry(value, System.currentTimeMillis())
157 open suspend fun agents(): List<Agent> = agentsMutex.withLock {
158 val now = System.currentTimeMillis()
159 val cached = cachedAgents
160 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
161 return@withLock cached.value
163 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
164 cachedAgents = CachedEntry(value, System.currentTimeMillis())
168 open suspend fun commands(): List<Command> = commandsMutex.withLock {
169 val now = System.currentTimeMillis()
170 val cached = cachedCommands
171 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
172 return@withLock cached.value
174 val value = withRetry { client!!.get("command").body<List<Command>>() }
175 cachedCommands = CachedEntry(value, System.currentTimeMillis())
179 /** Invalidate the catalog cache (e.g. on server switch). */
180 open suspend fun invalidateCache() {
181 providersMutex.withLock { cachedProviders = null }
182 agentsMutex.withLock { cachedAgents = null }
183 commandsMutex.withLock { cachedCommands = null }
186 open suspend fun invalidateProvidersCache() {
187 providersMutex.withLock { cachedProviders = null }
190 open suspend fun invalidateAgentsCache() {
191 agentsMutex.withLock { cachedAgents = null }
194 open suspend fun invalidateCommandsCache() {
195 commandsMutex.withLock { cachedCommands = null }
198 /** Respond to a permission request so a paused tool run can proceed. */
199 open suspend fun respondPermission(
201 permissionId: String,
202 response: PermissionResponse,
204 client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
205 contentType(ContentType.Application.Json)
206 setBody(PermissionReplyBody(response.wire))
212 open suspend fun findFiles(query: String): List<String> = withRetry {
213 client!!.get("find/file") { parameter("query", query) }.body()
216 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
217 client!!.get("file") { parameter("path", path) }.body()
220 open suspend fun readFile(path: String): FileContent = withRetry {
221 client!!.get("file/content") { parameter("path", path) }.body()
224 open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
225 client!!.get("file/status").body()
229private suspend fun <T> withRetry(
230 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
231 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
232 block: suspend () -> T,
233): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
236 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
237 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
238 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
239 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
240 * server errors are retried. Exposed as `internal` so the retry/skip rules are
241 * unit-testable without an HTTP server.
243internal suspend fun <T> withRetryInternal(
244 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
245 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
246 jitterFactor: Double = NetworkConfig.retryJitterFactor,
247 block: suspend () -> T,
249 var lastError: Throwable? = null
250 for (attempt in 1..maxAttempts) {
253 } catch (c: kotlinx.coroutines.CancellationException) {
255 } catch (t: ClientRequestException) {
256 // 429 (Too Many Requests) is transient — retry with backoff.
257 // All other 4xx are non-retryable.
258 if (t.response.status.value != 429) throw t
260 if (attempt < maxAttempts) {
261 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
263 } catch (t: ServerResponseException) {
264 // 5xx server errors are retryable with backoff.
266 if (attempt < maxAttempts) {
267 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
269 } catch (t: ResponseException) {
270 // 3xx (redirect limit exceeded) is non-retryable.
272 } catch (t: Exception) {
274 if (attempt < maxAttempts) {
275 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
279 throw lastError ?: IllegalStateException("withRetry failed without error")
282/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
283 * so the backoff formula is unit-testable without an HTTP server. */
284internal fun jitteredBackoff(
286 initialDelayMs: Long,
287 jitterFactor: Double,
289 // Guard against Int overflow from the shift and Long overflow from
290 // the multiplication at high attempt counts.
291 val shift = (attempt - 1).coerceAtMost(30)
292 val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
293 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
294 // shorter, sometimes longer — spreads concurrent client retries and
295 // prevents thundering-herd reconnection storms.
296 val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
297 return (baseDelay + jitter).coerceAtLeast(0)
300/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
301private fun encode(segment: String): String =
302 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