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 @Volatile private var cachedProviders: CachedEntry<ProvidersResponse>? = null
50 @Volatile private var cachedAgents: CachedEntry<List<Agent>>? = null
51 @Volatile 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 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,
89 ): MessageWithParts = withRetry {
90 // Send an idempotency key so the server can safely deduplicate if a transient
91 // network failure causes this request to be retried. The key is unique per call
92 // so a genuine re-send (e.g. retry-failed) still creates a new message.
93 val idempotencyKey = java.util.UUID.randomUUID().toString()
94 client.post("session/${encode(sessionId)}/message") {
95 contentType(ContentType.Application.Json)
96 header("Idempotency-Key", idempotencyKey)
99 parts = listOf(PromptPart(text = text)),
107 suspend fun abort(sessionId: String) {
108 client.post("session/${encode(sessionId)}/abort")
111 /** Invoke a slash-command by name via `POST /session/:id/command`. */
112 suspend fun runCommand(
115 arguments: String = "",
116 agent: String? = null,
117 ): MessageWithParts = withRetry {
118 client.post("session/${encode(sessionId)}/command") {
119 contentType(ContentType.Application.Json)
120 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
124 suspend fun providers(): ProvidersResponse = cacheMutex.withLock {
125 val now = System.currentTimeMillis()
126 val cached = cachedProviders
127 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
130 val value = withRetry { client.get("config/providers").body<ProvidersResponse>() }
131 cachedProviders = CachedEntry(value, now)
136 suspend fun agents(): List<Agent> = cacheMutex.withLock {
137 val now = System.currentTimeMillis()
138 val cached = cachedAgents
139 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
142 val value = withRetry { client.get("agent").body<List<Agent>>() }
143 cachedAgents = CachedEntry(value, now)
148 suspend fun commands(): List<Command> = cacheMutex.withLock {
149 val now = System.currentTimeMillis()
150 val cached = cachedCommands
151 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
154 val value = withRetry { client.get("command").body<List<Command>>() }
155 cachedCommands = CachedEntry(value, now)
160 /** Invalidate the catalog cache (e.g. on server switch). */
161 fun invalidateCache() {
162 cachedProviders = null
164 cachedCommands = null
167 /** Respond to a permission request so a paused tool run can proceed. */
168 suspend fun respondPermission(
170 permissionId: String,
171 response: PermissionResponse,
173 client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
174 contentType(ContentType.Application.Json)
175 setBody(PermissionReplyBody(response.wire))
181 suspend fun findFiles(query: String): List<String> = withRetry {
182 client.get("find/file") { parameter("query", query) }.body()
185 suspend fun listDirectory(path: String): List<FileNode> = withRetry {
186 client.get("file") { parameter("path", path) }.body()
189 suspend fun readFile(path: String): FileContent = withRetry {
190 client.get("file/content") { parameter("path", path) }.body()
193 suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
194 client.get("file/status").body()
198private suspend fun <T> withRetry(
199 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
200 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
201 block: suspend () -> T,
202): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
205 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
206 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
207 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
208 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
209 * server errors are retried. Exposed as `internal` so the retry/skip rules are
210 * unit-testable without an HTTP server.
212internal suspend fun <T> withRetryInternal(
213 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
214 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
215 jitterFactor: Double = NetworkConfig.retryJitterFactor,
216 block: suspend () -> T,
218 var lastError: Throwable? = null
219 for (attempt in 1..maxAttempts) {
222 } catch (c: kotlinx.coroutines.CancellationException) {
224 } catch (t: Exception) {
226 if (t is ClientRequestException) {
227 // 429 (Too Many Requests) is transient — retry with backoff.
228 if (t.response.status.value != 429) throw t
230 if (attempt < maxAttempts) {
231 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
232 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
233 // shorter, sometimes longer — spreads concurrent client retries and
234 // prevents thundering-herd reconnection storms.
235 val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
236 delay((baseDelay + jitter).coerceAtLeast(0))
240 throw lastError ?: IllegalStateException("withRetry failed without error")
243/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
244private fun encode(segment: String): String =
245 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