git.iko.soy/opencode-android
on ea52e62 help sign in
347 lines 15 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.PathInfo
13import soy.iko.opencode.data.model.PermissionReplyBody
14import soy.iko.opencode.data.model.PermissionResponse
15import soy.iko.opencode.data.model.Project
16import soy.iko.opencode.data.model.PromptPart
17import soy.iko.opencode.data.model.PromptRequest
18import soy.iko.opencode.data.model.ProvidersResponse
19import soy.iko.opencode.data.model.Session
20import soy.iko.opencode.data.model.UpdateSessionRequest
21import io.ktor.client.HttpClient
22import io.ktor.client.call.body
23import io.ktor.client.plugins.ClientRequestException
24import io.ktor.client.plugins.ResponseException
25import io.ktor.client.plugins.ServerResponseException
26import io.ktor.client.request.delete
27import io.ktor.client.request.get
28import io.ktor.client.request.header
29import io.ktor.client.request.parameter
30import io.ktor.client.request.patch
31import io.ktor.client.request.post
32import io.ktor.client.request.setBody
33import io.ktor.http.ContentType
34import io.ktor.http.contentType
35import kotlinx.coroutines.delay
36import kotlinx.coroutines.sync.Mutex
37import kotlinx.coroutines.sync.withLock
38import kotlin.random.Random
39import java.net.URLEncoder
41/**
42 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
43 * SSE stream lives in [EventStreamClient]; everything else is here.
44 */
45open class OpencodeApiClient private constructor(
46 private val client: HttpClient?,
47 @Suppress("unused") private val testMode: Boolean,
48) {
49 constructor(client: HttpClient) : this(client, false)
50 protected constructor() : this(null, true)
52 // --- Catalog cache (providers/agents/commands) ---
53 // These change rarely, but the ChatViewModel re-fetches them on every creation.
54 // A short-lived in-memory cache avoids redundant network calls when navigating
55 // between sessions on the same server.
56 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
57 // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
58 // The lock is held during the fetch to prevent a thundering-herd stampede when
59 // multiple coroutines miss the cache simultaneously.
60 private val providersMutex = Mutex()
61 private val agentsMutex = Mutex()
62 private val commandsMutex = Mutex()
63 private var cachedProviders: CachedEntry<ProvidersResponse>? = null
64 private var cachedAgents: CachedEntry<List<Agent>>? = null
65 private var cachedCommands: CachedEntry<List<Command>>? = null
67 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
68 open suspend fun ping() {
69 withRetry { client!!.get("global/health").body<String>() }
70 }
72 open suspend fun listSessions(): List<Session> = withRetry {
73 client!!.get("session").body()
74 }
76 /**
77 * Create a session, optionally in a specific worktree [directory]. The directory is a
78 * query parameter (not a body field): the server records it on the session and resolves
79 * it by session id thereafter, so follow-up calls (messages, commands, abort, …) don't
80 * — and shouldn't — re-send it. A null/blank [directory] lets the server fall back to
81 * its own launch cwd, preserving the pre-directory behavior.
82 */
83 open suspend fun createSession(title: String? = null, directory: String? = null): Session {
84 // Generate the idempotency key before entering withRetry so every retry attempt
85 // shares it. Without this, a POST that reached the server but whose response was
86 // lost (reset/timeout) would be retried and create a second orphan session.
87 val idempotencyKey = java.util.UUID.randomUUID().toString()
88 val dir = directory?.takeIf { it.isNotBlank() }
89 return withRetry {
90 client!!.post("session") {
91 contentType(ContentType.Application.Json)
92 header("Idempotency-Key", idempotencyKey)
93 if (dir != null) parameter("directory", dir)
94 setBody(CreateSessionRequest(title = title))
95 }.body()
96 }
97 }
99 /** List the projects (worktree roots) the server knows about, for the directory picker. */
100 open suspend fun listProjects(): List<Project> = withRetry {
101 client!!.get("project").body()
102 }
104 /** The server's current path info; its [PathInfo.directory] is the default (cwd) a
105 * session with no directory override runs in. */
106 open suspend fun currentPath(): PathInfo = withRetry {
107 client!!.get("path").body()
108 }
110 open suspend fun updateSession(id: String, title: String): Session = withRetry {
111 client!!.patch("session/${encode(id)}") {
112 contentType(ContentType.Application.Json)
113 setBody(UpdateSessionRequest(title = title))
114 }.body()
115 }
117 open suspend fun deleteSession(id: String) {
118 try {
119 withRetry { client!!.delete("session/${encode(id)}").body<String>() }
120 } catch (t: ClientRequestException) {
121 // A DELETE that succeeded but whose response was lost gets retried; the retry
122 // hits an already-gone session and returns 404. Deleting a non-existent session
123 // is effectively success, so swallow 404 rather than surfacing a spurious failure.
124 if (t.response.status.value != 404) throw t
125 }
126 }
128 open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
129 client!!.get("session/${encode(sessionId)}/message").body()
130 }
132 open suspend fun sendPrompt(
133 sessionId: String,
134 text: String,
135 model: ModelRef? = null,
136 agent: String? = null,
137 ): MessageWithParts {
138 // Generate the idempotency key *before* entering withRetry so all retry
139 // attempts share the same key. If the key were generated inside withRetry,
140 // each attempt would get a fresh UUID and the server couldn't deduplicate a
141 // request that reached it but whose response was lost (e.g. timeout).
142 val idempotencyKey = java.util.UUID.randomUUID().toString()
143 return withRetry {
144 client!!.post("session/${encode(sessionId)}/message") {
145 contentType(ContentType.Application.Json)
146 header("Idempotency-Key", idempotencyKey)
147 setBody(
148 PromptRequest(
149 parts = listOf(PromptPart(text = text)),
150 model = model,
151 agent = agent,
152 ),
153 )
154 }.body()
155 }
156 }
158 open suspend fun abort(sessionId: String) {
159 withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
160 }
162 /** Invoke a slash-command by name via `POST /session/:id/command`. */
163 open suspend fun runCommand(
164 sessionId: String,
165 command: String,
166 arguments: String = "",
167 agent: String? = null,
168 ): MessageWithParts {
169 // Generate the idempotency key before entering withRetry so all retry
170 // attempts share the same key, preventing duplicate command execution if
171 // the server processed the request but the response was lost.
172 val idempotencyKey = java.util.UUID.randomUUID().toString()
173 return withRetry {
174 client!!.post("session/${encode(sessionId)}/command") {
175 contentType(ContentType.Application.Json)
176 header("Idempotency-Key", idempotencyKey)
177 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
178 }.body()
179 }
180 }
182 open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
183 val now = System.currentTimeMillis()
184 val cached = cachedProviders
185 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
186 return@withLock cached.value
187 }
188 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
189 cachedProviders = CachedEntry(value, System.currentTimeMillis())
190 value
191 }
193 open suspend fun agents(): List<Agent> = agentsMutex.withLock {
194 val now = System.currentTimeMillis()
195 val cached = cachedAgents
196 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
197 return@withLock cached.value
198 }
199 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
200 cachedAgents = CachedEntry(value, System.currentTimeMillis())
201 value
202 }
204 open suspend fun commands(): List<Command> = commandsMutex.withLock {
205 val now = System.currentTimeMillis()
206 val cached = cachedCommands
207 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
208 return@withLock cached.value
209 }
210 val value = withRetry { client!!.get("command").body<List<Command>>() }
211 cachedCommands = CachedEntry(value, System.currentTimeMillis())
212 value
213 }
215 /** Invalidate the catalog cache (e.g. on server switch). */
216 open suspend fun invalidateCache() {
217 providersMutex.withLock { cachedProviders = null }
218 agentsMutex.withLock { cachedAgents = null }
219 commandsMutex.withLock { cachedCommands = null }
220 }
222 open suspend fun invalidateProvidersCache() {
223 providersMutex.withLock { cachedProviders = null }
224 }
226 open suspend fun invalidateAgentsCache() {
227 agentsMutex.withLock { cachedAgents = null }
228 }
230 open suspend fun invalidateCommandsCache() {
231 commandsMutex.withLock { cachedCommands = null }
232 }
234 /** Respond to a permission request so a paused tool run can proceed. */
235 open suspend fun respondPermission(
236 sessionId: String,
237 permissionId: String,
238 response: PermissionResponse,
239 ): String = try {
240 withRetry {
241 client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
242 contentType(ContentType.Application.Json)
243 setBody(PermissionReplyBody(response.wire))
244 }.body<String>()
245 }
246 } catch (t: ClientRequestException) {
247 // A response that reached the server but whose reply was lost gets retried; the
248 // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
249 // the answer landed, so treat them as success instead of a spurious failure.
250 val code = t.response.status.value
251 if (code != 404 && code != 409) throw t
252 ""
253 }
255 // --- Files ---
257 open suspend fun findFiles(query: String): List<String> = withRetry {
258 client!!.get("find/file") { parameter("query", query) }.body()
259 }
261 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
262 client!!.get("file") { parameter("path", path) }.body()
263 }
265 open suspend fun readFile(path: String): FileContent = withRetry {
266 client!!.get("file/content") { parameter("path", path) }.body()
267 }
269 open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
270 client!!.get("file/status").body()
271 }
274private suspend fun <T> withRetry(
275 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
276 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
277 block: suspend () -> T,
278): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
280/**
281 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
282 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
283 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
284 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
285 * server errors are retried. Exposed as `internal` so the retry/skip rules are
286 * unit-testable without an HTTP server.
287 */
288internal suspend fun <T> withRetryInternal(
289 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
290 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
291 jitterFactor: Double = NetworkConfig.retryJitterFactor,
292 block: suspend () -> T,
293): T {
294 var lastError: Throwable? = null
295 for (attempt in 1..maxAttempts) {
296 try {
297 return block()
298 } catch (c: kotlinx.coroutines.CancellationException) {
299 throw c
300 } catch (t: ClientRequestException) {
301 // 429 (Too Many Requests) is transient — retry with backoff.
302 // All other 4xx are non-retryable.
303 if (t.response.status.value != 429) throw t
304 lastError = t
305 if (attempt < maxAttempts) {
306 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
307 }
308 } catch (t: ServerResponseException) {
309 // 5xx server errors are retryable with backoff.
310 lastError = t
311 if (attempt < maxAttempts) {
312 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
313 }
314 } catch (t: ResponseException) {
315 // 3xx (redirect limit exceeded) is non-retryable.
316 throw t
317 } catch (t: Exception) {
318 lastError = t
319 if (attempt < maxAttempts) {
320 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
321 }
322 }
323 }
324 throw lastError ?: IllegalStateException("withRetry failed without error")
327/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
328 * so the backoff formula is unit-testable without an HTTP server. */
329internal fun jitteredBackoff(
330 attempt: Int,
331 initialDelayMs: Long,
332 jitterFactor: Double,
333): Long {
334 // Guard against Int overflow from the shift and Long overflow from
335 // the multiplication at high attempt counts.
336 val shift = (attempt - 1).coerceAtMost(30)
337 val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
338 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
339 // shorter, sometimes longer — spreads concurrent client retries and
340 // prevents thundering-herd reconnection storms.
341 val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
342 return (baseDelay + jitter).coerceAtLeast(0)
345/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
346private fun encode(segment: String): String =
347 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")