git.iko.soy/opencode-android
on aeac232 help sign in
434 lines 19 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.FilePromptPart
14import soy.iko.opencode.data.model.FindMatch
15import soy.iko.opencode.data.model.PermissionReplyBody
16import soy.iko.opencode.data.model.PermissionResponse
17import soy.iko.opencode.data.model.Project
18import soy.iko.opencode.data.model.PromptPart
19import soy.iko.opencode.data.model.PromptRequest
20import soy.iko.opencode.data.model.ProvidersResponse
21import soy.iko.opencode.data.model.RevertRequest
22import soy.iko.opencode.data.model.Session
23import soy.iko.opencode.data.model.ShellRequest
24import soy.iko.opencode.data.model.SummarizeRequest
25import soy.iko.opencode.data.model.SymbolResult
26import soy.iko.opencode.data.model.TextPromptPart
27import soy.iko.opencode.data.model.UpdateSessionRequest
28import io.ktor.client.HttpClient
29import io.ktor.client.call.body
30import io.ktor.client.plugins.ClientRequestException
31import io.ktor.client.plugins.ResponseException
32import io.ktor.client.plugins.ServerResponseException
33import io.ktor.client.request.delete
34import io.ktor.client.request.get
35import io.ktor.client.request.header
36import io.ktor.client.request.parameter
37import io.ktor.client.request.patch
38import io.ktor.client.request.post
39import io.ktor.client.request.setBody
40import io.ktor.http.ContentType
41import io.ktor.http.contentType
42import kotlinx.coroutines.delay
43import kotlinx.coroutines.sync.Mutex
44import kotlinx.coroutines.sync.withLock
45import kotlin.random.Random
46import java.net.URLEncoder
48/**
49 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
50 * SSE stream lives in [EventStreamClient]; everything else is here.
51 */
52open class OpencodeApiClient private constructor(
53 private val client: HttpClient?,
54 @Suppress("unused") private val testMode: Boolean,
55) {
56 constructor(client: HttpClient) : this(client, false)
57 protected constructor() : this(null, true)
59 // --- Catalog cache (providers/agents/commands) ---
60 // These change rarely, but the ChatViewModel re-fetches them on every creation.
61 // A short-lived in-memory cache avoids redundant network calls when navigating
62 // between sessions on the same server.
63 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
64 // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
65 // The lock is held during the fetch to prevent a thundering-herd stampede when
66 // multiple coroutines miss the cache simultaneously.
67 private val providersMutex = Mutex()
68 private val agentsMutex = Mutex()
69 private val commandsMutex = Mutex()
70 private var cachedProviders: CachedEntry<ProvidersResponse>? = null
71 private var cachedAgents: CachedEntry<List<Agent>>? = null
72 private var cachedCommands: CachedEntry<List<Command>>? = null
74 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
75 open suspend fun ping() {
76 withRetry { client!!.get("global/health").body<String>() }
77 }
79 open suspend fun listSessions(): List<Session> = withRetry {
80 client!!.get("session").body()
81 }
83 /**
84 * Create a session, optionally in a specific worktree [directory]. The directory is a
85 * query parameter (not a body field): the server records it on the session and resolves
86 * it by session id thereafter, so follow-up calls (messages, commands, abort, …) don't
87 * — and shouldn't — re-send it. A null/blank [directory] lets the server fall back to
88 * its own launch cwd, preserving the pre-directory behavior.
89 */
90 open suspend fun createSession(title: String? = null, directory: String? = null): Session {
91 // Generate the idempotency key before entering withRetry so every retry attempt
92 // shares it. Without this, a POST that reached the server but whose response was
93 // lost (reset/timeout) would be retried and create a second orphan session.
94 val idempotencyKey = java.util.UUID.randomUUID().toString()
95 val dir = directory?.takeIf { it.isNotBlank() }
96 return withRetry {
97 client!!.post("session") {
98 contentType(ContentType.Application.Json)
99 header("Idempotency-Key", idempotencyKey)
100 if (dir != null) parameter("directory", dir)
101 setBody(CreateSessionRequest(title = title))
102 }.body()
103 }
104 }
106 /** List the projects (worktree roots) the server knows about, for the directory picker. */
107 open suspend fun listProjects(): List<Project> = withRetry {
108 client!!.get("project").body()
109 }
111 /** The server's current path info; its [PathInfo.directory] is the default (cwd) a
112 * session with no directory override runs in. */
113 open suspend fun currentPath(): PathInfo = withRetry {
114 client!!.get("path").body()
115 }
117 open suspend fun updateSession(id: String, title: String): Session = withRetry {
118 client!!.patch("session/${encode(id)}") {
119 contentType(ContentType.Application.Json)
120 setBody(UpdateSessionRequest(title = title))
121 }.body()
122 }
124 open suspend fun deleteSession(id: String) {
125 try {
126 withRetry { client!!.delete("session/${encode(id)}").body<String>() }
127 } catch (t: ClientRequestException) {
128 // A DELETE that succeeded but whose response was lost gets retried; the retry
129 // hits an already-gone session and returns 404. Deleting a non-existent session
130 // is effectively success, so swallow 404 rather than surfacing a spurious failure.
131 if (t.response.status.value != 404) throw t
132 }
133 }
135 open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
136 client!!.get("session/${encode(sessionId)}/message").body()
137 }
139 open suspend fun sendPrompt(
140 sessionId: String,
141 text: String,
142 attachments: List<FilePromptPart> = emptyList(),
143 model: ModelRef? = null,
144 agent: String? = null,
145 ): MessageWithParts {
146 // Generate the idempotency key *before* entering withRetry so all retry
147 // attempts share the same key. If the key were generated inside withRetry,
148 // each attempt would get a fresh UUID and the server couldn't deduplicate a
149 // request that reached it but whose response was lost (e.g. timeout).
150 val idempotencyKey = java.util.UUID.randomUUID().toString()
151 // A text part is only included when there's actual text, so an image-only prompt
152 // (attachments, empty text) doesn't send an empty text part. Attachments follow
153 // the text so the model reads the instruction first.
154 val parts = buildList<PromptPart> {
155 if (text.isNotEmpty()) add(TextPromptPart(text))
156 addAll(attachments)
157 }
158 return withRetry {
159 client!!.post("session/${encode(sessionId)}/message") {
160 contentType(ContentType.Application.Json)
161 header("Idempotency-Key", idempotencyKey)
162 setBody(
163 PromptRequest(
164 parts = parts,
165 model = model,
166 agent = agent,
167 ),
168 )
169 }.body()
170 }
171 }
173 open suspend fun abort(sessionId: String) {
174 withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
175 }
177 /** Invoke a slash-command by name via `POST /session/:id/command`. */
178 open suspend fun runCommand(
179 sessionId: String,
180 command: String,
181 arguments: String = "",
182 agent: String? = null,
183 ): MessageWithParts {
184 // Generate the idempotency key before entering withRetry so all retry
185 // attempts share the same key, preventing duplicate command execution if
186 // the server processed the request but the response was lost.
187 val idempotencyKey = java.util.UUID.randomUUID().toString()
188 return withRetry {
189 client!!.post("session/${encode(sessionId)}/command") {
190 contentType(ContentType.Application.Json)
191 header("Idempotency-Key", idempotencyKey)
192 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
193 }.body()
194 }
195 }
197 /** Revert the session to just before [messageId] (optionally part [partId]); the
198 * server hides everything after the checkpoint. Returns the updated session. Naturally
199 * idempotent (reverting to the same point twice yields the same state). */
200 open suspend fun revert(sessionId: String, messageId: String, partId: String? = null): Session = withRetry {
201 client!!.post("session/${encode(sessionId)}/revert") {
202 contentType(ContentType.Application.Json)
203 setBody(RevertRequest(messageID = messageId, partID = partId))
204 }.body()
205 }
207 /** Undo the active revert checkpoint, restoring the hidden messages. */
208 open suspend fun unrevert(sessionId: String): Session = withRetry {
209 client!!.post("session/${encode(sessionId)}/unrevert").body()
210 }
212 /** Create a public share link for the session. Returns the session with [Session.share] set. */
213 open suspend fun shareSession(sessionId: String): Session = withRetry {
214 client!!.post("session/${encode(sessionId)}/share").body()
215 }
217 /** Revoke the session's public share link. Returns the session with share cleared. */
218 open suspend fun unshareSession(sessionId: String): Session = withRetry {
219 client!!.delete("session/${encode(sessionId)}/share").body()
220 }
222 /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
223 * summary streams back via SSE like any run; this just triggers it. */
224 open suspend fun summarize(sessionId: String, model: ModelRef) {
225 val idempotencyKey = java.util.UUID.randomUUID().toString()
226 withRetry {
227 client!!.post("session/${encode(sessionId)}/summarize") {
228 contentType(ContentType.Application.Json)
229 header("Idempotency-Key", idempotencyKey)
230 setBody(SummarizeRequest(providerID = model.providerID, modelID = model.modelID))
231 }.body<String>()
232 }
233 }
235 /** Analyze the project and (re)generate its AGENTS.md. The body is omitted so the server
236 * assigns the message id and uses its default model; the run streams back via SSE. */
237 open suspend fun initSession(sessionId: String) {
238 val idempotencyKey = java.util.UUID.randomUUID().toString()
239 withRetry {
240 client!!.post("session/${encode(sessionId)}/init") {
241 header("Idempotency-Key", idempotencyKey)
242 }.body<String>()
243 }
244 }
246 /** Run a one-off shell [command] in the session's worktree. Output streams back via SSE.
247 * [agent] scopes the run (the server requires it). */
248 open suspend fun shell(sessionId: String, command: String, agent: String, model: ModelRef? = null) {
249 val idempotencyKey = java.util.UUID.randomUUID().toString()
250 withRetry {
251 client!!.post("session/${encode(sessionId)}/shell") {
252 contentType(ContentType.Application.Json)
253 header("Idempotency-Key", idempotencyKey)
254 setBody(ShellRequest(agent = agent, command = command, model = model))
255 }.body<String>()
256 }
257 }
259 open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
260 val now = System.currentTimeMillis()
261 val cached = cachedProviders
262 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
263 return@withLock cached.value
264 }
265 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
266 cachedProviders = CachedEntry(value, System.currentTimeMillis())
267 value
268 }
270 open suspend fun agents(): List<Agent> = agentsMutex.withLock {
271 val now = System.currentTimeMillis()
272 val cached = cachedAgents
273 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
274 return@withLock cached.value
275 }
276 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
277 cachedAgents = CachedEntry(value, System.currentTimeMillis())
278 value
279 }
281 open suspend fun commands(): List<Command> = commandsMutex.withLock {
282 val now = System.currentTimeMillis()
283 val cached = cachedCommands
284 if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
285 return@withLock cached.value
286 }
287 val value = withRetry { client!!.get("command").body<List<Command>>() }
288 cachedCommands = CachedEntry(value, System.currentTimeMillis())
289 value
290 }
292 /** Invalidate the catalog cache (e.g. on server switch). */
293 open suspend fun invalidateCache() {
294 providersMutex.withLock { cachedProviders = null }
295 agentsMutex.withLock { cachedAgents = null }
296 commandsMutex.withLock { cachedCommands = null }
297 }
299 open suspend fun invalidateProvidersCache() {
300 providersMutex.withLock { cachedProviders = null }
301 }
303 open suspend fun invalidateAgentsCache() {
304 agentsMutex.withLock { cachedAgents = null }
305 }
307 open suspend fun invalidateCommandsCache() {
308 commandsMutex.withLock { cachedCommands = null }
309 }
311 /** Respond to a permission request so a paused tool run can proceed. */
312 open suspend fun respondPermission(
313 sessionId: String,
314 permissionId: String,
315 response: PermissionResponse,
316 ): String = try {
317 withRetry {
318 client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
319 contentType(ContentType.Application.Json)
320 setBody(PermissionReplyBody(response.wire))
321 }.body<String>()
322 }
323 } catch (t: ClientRequestException) {
324 // A response that reached the server but whose reply was lost gets retried; the
325 // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
326 // the answer landed, so treat them as success instead of a spurious failure.
327 val code = t.response.status.value
328 if (code != 404 && code != 409) throw t
329 ""
330 }
332 // --- Files ---
334 open suspend fun findFiles(query: String): List<String> = withRetry {
335 client!!.get("find/file") { parameter("query", query) }.body()
336 }
338 /** Ripgrep content search across the project (`GET /find?pattern=`). */
339 open suspend fun findText(pattern: String): List<FindMatch> = withRetry {
340 client!!.get("find") { parameter("pattern", pattern) }.body()
341 }
343 /** LSP workspace symbol search (`GET /find/symbol?query=`). */
344 open suspend fun findSymbol(query: String): List<SymbolResult> = withRetry {
345 client!!.get("find/symbol") { parameter("query", query) }.body()
346 }
348 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
349 client!!.get("file") { parameter("path", path) }.body()
350 }
352 open suspend fun readFile(path: String): FileContent = withRetry {
353 client!!.get("file/content") { parameter("path", path) }.body()
354 }
356 open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
357 client!!.get("file/status").body()
358 }
361private suspend fun <T> withRetry(
362 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
363 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
364 block: suspend () -> T,
365): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
367/**
368 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
369 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
370 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
371 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
372 * server errors are retried. Exposed as `internal` so the retry/skip rules are
373 * unit-testable without an HTTP server.
374 */
375internal suspend fun <T> withRetryInternal(
376 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
377 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
378 jitterFactor: Double = NetworkConfig.retryJitterFactor,
379 block: suspend () -> T,
380): T {
381 var lastError: Throwable? = null
382 for (attempt in 1..maxAttempts) {
383 try {
384 return block()
385 } catch (c: kotlinx.coroutines.CancellationException) {
386 throw c
387 } catch (t: ClientRequestException) {
388 // 429 (Too Many Requests) is transient — retry with backoff.
389 // All other 4xx are non-retryable.
390 if (t.response.status.value != 429) throw t
391 lastError = t
392 if (attempt < maxAttempts) {
393 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
394 }
395 } catch (t: ServerResponseException) {
396 // 5xx server errors are retryable with backoff.
397 lastError = t
398 if (attempt < maxAttempts) {
399 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
400 }
401 } catch (t: ResponseException) {
402 // 3xx (redirect limit exceeded) is non-retryable.
403 throw t
404 } catch (t: Exception) {
405 lastError = t
406 if (attempt < maxAttempts) {
407 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
408 }
409 }
410 }
411 throw lastError ?: IllegalStateException("withRetry failed without error")
414/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
415 * so the backoff formula is unit-testable without an HTTP server. */
416internal fun jitteredBackoff(
417 attempt: Int,
418 initialDelayMs: Long,
419 jitterFactor: Double,
420): Long {
421 // Guard against Int overflow from the shift and Long overflow from
422 // the multiplication at high attempt counts.
423 val shift = (attempt - 1).coerceAtMost(30)
424 val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
425 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
426 // shorter, sometimes longer — spreads concurrent client retries and
427 // prevents thundering-herd reconnection storms.
428 val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
429 return (baseDelay + jitter).coerceAtLeast(0)
432/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
433private fun encode(segment: String): String =
434 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")