git.iko.soy/opencode-android
on fb38d12 help sign in
290 lines 12 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 = withRetry {
75 client!!.post("session") {
76 contentType(ContentType.Application.Json)
77 setBody(CreateSessionRequest(title = title))
78 }.body()
79 }
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))
85 }.body()
86 }
88 open suspend fun deleteSession(id: String) {
89 withRetry { client!!.delete("session/${encode(id)}").body<String>() }
90 }
92 open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
93 client!!.get("session/${encode(sessionId)}/message").body()
94 }
96 open suspend fun sendPrompt(
97 sessionId: String,
98 text: String,
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()
107 return withRetry {
108 client!!.post("session/${encode(sessionId)}/message") {
109 contentType(ContentType.Application.Json)
110 header("Idempotency-Key", idempotencyKey)
111 setBody(
112 PromptRequest(
113 parts = listOf(PromptPart(text = text)),
114 model = model,
115 agent = agent,
116 ),
117 )
118 }.body()
119 }
120 }
122 open suspend fun abort(sessionId: String) {
123 withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
124 }
126 /** Invoke a slash-command by name via `POST /session/:id/command`. */
127 open suspend fun runCommand(
128 sessionId: String,
129 command: String,
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()
137 return withRetry {
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))
142 }.body()
143 }
144 }
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
151 }
152 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
153 cachedProviders = CachedEntry(value, System.currentTimeMillis())
154 value
155 }
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
162 }
163 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
164 cachedAgents = CachedEntry(value, System.currentTimeMillis())
165 value
166 }
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
173 }
174 val value = withRetry { client!!.get("command").body<List<Command>>() }
175 cachedCommands = CachedEntry(value, System.currentTimeMillis())
176 value
177 }
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 }
184 }
186 /** Respond to a permission request so a paused tool run can proceed. */
187 open suspend fun respondPermission(
188 sessionId: String,
189 permissionId: String,
190 response: PermissionResponse,
191 ) = withRetry {
192 client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
193 contentType(ContentType.Application.Json)
194 setBody(PermissionReplyBody(response.wire))
195 }.body<String>()
196 }
198 // --- Files ---
200 open suspend fun findFiles(query: String): List<String> = withRetry {
201 client!!.get("find/file") { parameter("query", query) }.body()
202 }
204 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
205 client!!.get("file") { parameter("path", path) }.body()
206 }
208 open suspend fun readFile(path: String): FileContent = withRetry {
209 client!!.get("file/content") { parameter("path", path) }.body()
210 }
212 open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
213 client!!.get("file/status").body()
214 }
217private suspend fun <T> withRetry(
218 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
219 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
220 block: suspend () -> T,
221): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
223/**
224 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
225 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
226 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
227 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
228 * server errors are retried. Exposed as `internal` so the retry/skip rules are
229 * unit-testable without an HTTP server.
230 */
231internal suspend fun <T> withRetryInternal(
232 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
233 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
234 jitterFactor: Double = NetworkConfig.retryJitterFactor,
235 block: suspend () -> T,
236): T {
237 var lastError: Throwable? = null
238 for (attempt in 1..maxAttempts) {
239 try {
240 return block()
241 } catch (c: kotlinx.coroutines.CancellationException) {
242 throw c
243 } catch (t: ClientRequestException) {
244 // 429 (Too Many Requests) is transient — retry with backoff.
245 // All other 4xx are non-retryable.
246 if (t.response.status.value != 429) throw t
247 lastError = t
248 if (attempt < maxAttempts) {
249 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
250 }
251 } catch (t: ServerResponseException) {
252 // 5xx server errors are retryable with backoff.
253 lastError = t
254 if (attempt < maxAttempts) {
255 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
256 }
257 } catch (t: ResponseException) {
258 // 3xx (redirect limit exceeded) is non-retryable.
259 throw t
260 } catch (t: Exception) {
261 lastError = t
262 if (attempt < maxAttempts) {
263 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
264 }
265 }
266 }
267 throw lastError ?: IllegalStateException("withRetry failed without error")
270/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
271 * so the backoff formula is unit-testable without an HTTP server. */
272internal fun jitteredBackoff(
273 attempt: Int,
274 initialDelayMs: Long,
275 jitterFactor: Double,
276): Long {
277 // Guard against Int overflow from the shift and Long overflow from
278 // the multiplication at high attempt counts.
279 val shift = (attempt - 1).coerceAtMost(30)
280 val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
281 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
282 // shorter, sometimes longer — spreads concurrent client retries and
283 // prevents thundering-herd reconnection storms.
284 val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
285 return (baseDelay + jitter).coerceAtLeast(0)
288/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
289private fun encode(segment: String): String =
290 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")