git.iko.soy/opencode-android
on 8bc2cce help sign in
325 lines 14 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.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
39/**
40 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
41 * SSE stream lives in [EventStreamClient]; everything else is here.
42 */
43open class OpencodeApiClient private constructor(
44 private val client: HttpClient?,
45 @Suppress("unused") private val testMode: Boolean,
46) {
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>() }
68 }
70 open suspend fun listSessions(): List<Session> = withRetry {
71 client!!.get("session").body()
72 }
74 open suspend fun createSession(title: String? = null): Session {
75 // Generate the idempotency key before entering withRetry so every retry attempt
76 // shares it. Without this, a POST that reached the server but whose response was
77 // lost (reset/timeout) would be retried and create a second orphan session.
78 val idempotencyKey = java.util.UUID.randomUUID().toString()
79 return withRetry {
80 client!!.post("session") {
81 contentType(ContentType.Application.Json)
82 header("Idempotency-Key", idempotencyKey)
83 setBody(CreateSessionRequest(title = title))
84 }.body()
85 }
86 }
88 open suspend fun updateSession(id: String, title: String): Session = withRetry {
89 client!!.patch("session/${encode(id)}") {
90 contentType(ContentType.Application.Json)
91 setBody(UpdateSessionRequest(title = title))
92 }.body()
93 }
95 open suspend fun deleteSession(id: String) {
96 try {
97 withRetry { client!!.delete("session/${encode(id)}").body<String>() }
98 } catch (t: ClientRequestException) {
99 // A DELETE that succeeded but whose response was lost gets retried; the retry
100 // hits an already-gone session and returns 404. Deleting a non-existent session
101 // is effectively success, so swallow 404 rather than surfacing a spurious failure.
102 if (t.response.status.value != 404) throw t
103 }
104 }
106 open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
107 client!!.get("session/${encode(sessionId)}/message").body()
108 }
110 open suspend fun sendPrompt(
111 sessionId: String,
112 text: String,
113 model: ModelRef? = null,
114 agent: String? = null,
115 ): MessageWithParts {
116 // Generate the idempotency key *before* entering withRetry so all retry
117 // attempts share the same key. If the key were generated inside withRetry,
118 // each attempt would get a fresh UUID and the server couldn't deduplicate a
119 // request that reached it but whose response was lost (e.g. timeout).
120 val idempotencyKey = java.util.UUID.randomUUID().toString()
121 return withRetry {
122 client!!.post("session/${encode(sessionId)}/message") {
123 contentType(ContentType.Application.Json)
124 header("Idempotency-Key", idempotencyKey)
125 setBody(
126 PromptRequest(
127 parts = listOf(PromptPart(text = text)),
128 model = model,
129 agent = agent,
130 ),
131 )
132 }.body()
133 }
134 }
136 open suspend fun abort(sessionId: String) {
137 withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
138 }
140 /** Invoke a slash-command by name via `POST /session/:id/command`. */
141 open suspend fun runCommand(
142 sessionId: String,
143 command: String,
144 arguments: String = "",
145 agent: String? = null,
146 ): MessageWithParts {
147 // Generate the idempotency key before entering withRetry so all retry
148 // attempts share the same key, preventing duplicate command execution if
149 // the server processed the request but the response was lost.
150 val idempotencyKey = java.util.UUID.randomUUID().toString()
151 return withRetry {
152 client!!.post("session/${encode(sessionId)}/command") {
153 contentType(ContentType.Application.Json)
154 header("Idempotency-Key", idempotencyKey)
155 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
156 }.body()
157 }
158 }
160 open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
161 val now = System.currentTimeMillis()
162 val cached = cachedProviders
163 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
164 return@withLock cached.value
165 }
166 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
167 cachedProviders = CachedEntry(value, System.currentTimeMillis())
168 value
169 }
171 open suspend fun agents(): List<Agent> = agentsMutex.withLock {
172 val now = System.currentTimeMillis()
173 val cached = cachedAgents
174 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
175 return@withLock cached.value
176 }
177 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
178 cachedAgents = CachedEntry(value, System.currentTimeMillis())
179 value
180 }
182 open suspend fun commands(): List<Command> = commandsMutex.withLock {
183 val now = System.currentTimeMillis()
184 val cached = cachedCommands
185 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
186 return@withLock cached.value
187 }
188 val value = withRetry { client!!.get("command").body<List<Command>>() }
189 cachedCommands = CachedEntry(value, System.currentTimeMillis())
190 value
191 }
193 /** Invalidate the catalog cache (e.g. on server switch). */
194 open suspend fun invalidateCache() {
195 providersMutex.withLock { cachedProviders = null }
196 agentsMutex.withLock { cachedAgents = null }
197 commandsMutex.withLock { cachedCommands = null }
198 }
200 open suspend fun invalidateProvidersCache() {
201 providersMutex.withLock { cachedProviders = null }
202 }
204 open suspend fun invalidateAgentsCache() {
205 agentsMutex.withLock { cachedAgents = null }
206 }
208 open suspend fun invalidateCommandsCache() {
209 commandsMutex.withLock { cachedCommands = null }
210 }
212 /** Respond to a permission request so a paused tool run can proceed. */
213 open suspend fun respondPermission(
214 sessionId: String,
215 permissionId: String,
216 response: PermissionResponse,
217 ): String = try {
218 withRetry {
219 client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
220 contentType(ContentType.Application.Json)
221 setBody(PermissionReplyBody(response.wire))
222 }.body<String>()
223 }
224 } catch (t: ClientRequestException) {
225 // A response that reached the server but whose reply was lost gets retried; the
226 // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
227 // the answer landed, so treat them as success instead of a spurious failure.
228 val code = t.response.status.value
229 if (code != 404 && code != 409) throw t
230 ""
231 }
233 // --- Files ---
235 open suspend fun findFiles(query: String): List<String> = withRetry {
236 client!!.get("find/file") { parameter("query", query) }.body()
237 }
239 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
240 client!!.get("file") { parameter("path", path) }.body()
241 }
243 open suspend fun readFile(path: String): FileContent = withRetry {
244 client!!.get("file/content") { parameter("path", path) }.body()
245 }
247 open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
248 client!!.get("file/status").body()
249 }
252private suspend fun <T> withRetry(
253 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
254 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
255 block: suspend () -> T,
256): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
258/**
259 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
260 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
261 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
262 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
263 * server errors are retried. Exposed as `internal` so the retry/skip rules are
264 * unit-testable without an HTTP server.
265 */
266internal suspend fun <T> withRetryInternal(
267 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
268 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
269 jitterFactor: Double = NetworkConfig.retryJitterFactor,
270 block: suspend () -> T,
271): T {
272 var lastError: Throwable? = null
273 for (attempt in 1..maxAttempts) {
274 try {
275 return block()
276 } catch (c: kotlinx.coroutines.CancellationException) {
277 throw c
278 } catch (t: ClientRequestException) {
279 // 429 (Too Many Requests) is transient — retry with backoff.
280 // All other 4xx are non-retryable.
281 if (t.response.status.value != 429) throw t
282 lastError = t
283 if (attempt < maxAttempts) {
284 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
285 }
286 } catch (t: ServerResponseException) {
287 // 5xx server errors are retryable with backoff.
288 lastError = t
289 if (attempt < maxAttempts) {
290 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
291 }
292 } catch (t: ResponseException) {
293 // 3xx (redirect limit exceeded) is non-retryable.
294 throw t
295 } catch (t: Exception) {
296 lastError = t
297 if (attempt < maxAttempts) {
298 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
299 }
300 }
301 }
302 throw lastError ?: IllegalStateException("withRetry failed without error")
305/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
306 * so the backoff formula is unit-testable without an HTTP server. */
307internal fun jitteredBackoff(
308 attempt: Int,
309 initialDelayMs: Long,
310 jitterFactor: Double,
311): Long {
312 // Guard against Int overflow from the shift and Long overflow from
313 // the multiplication at high attempt counts.
314 val shift = (attempt - 1).coerceAtMost(30)
315 val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
316 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
317 // shorter, sometimes longer — spreads concurrent client retries and
318 // prevents thundering-herd reconnection storms.
319 val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
320 return (baseDelay + jitter).coerceAtLeast(0)
323/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
324private fun encode(segment: String): String =
325 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")