package soy.iko.opencode.data.network import soy.iko.opencode.data.model.Agent import soy.iko.opencode.data.model.Command import soy.iko.opencode.data.model.CommandRequest import soy.iko.opencode.data.model.CreateSessionRequest import soy.iko.opencode.data.model.FileContent import soy.iko.opencode.data.model.FileNode import soy.iko.opencode.data.model.FileStatusEntry import soy.iko.opencode.data.model.MessageWithParts import soy.iko.opencode.data.model.ModelRef import soy.iko.opencode.data.model.PermissionReplyBody import soy.iko.opencode.data.model.PermissionResponse import soy.iko.opencode.data.model.PromptPart import soy.iko.opencode.data.model.PromptRequest import soy.iko.opencode.data.model.ProvidersResponse import soy.iko.opencode.data.model.Session import soy.iko.opencode.data.model.UpdateSessionRequest import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.ClientRequestException import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.random.Random import java.net.URLEncoder /** * Request/response wrapper over the opencode REST endpoints. The long-lived `/event` * SSE stream lives in [EventStreamClient]; everything else is here. */ class OpencodeApiClient(private val client: HttpClient) { // --- Catalog cache (providers/agents/commands) --- // These change rarely, but the ChatViewModel re-fetches them on every creation. // A short-lived in-memory cache avoids redundant network calls when navigating // between sessions on the same server. private data class CachedEntry(val value: T, val fetchedAt: Long) private val cacheMutex = Mutex() private var cachedProviders: CachedEntry? = null private var cachedAgents: CachedEntry>? = null private var cachedCommands: CachedEntry>? = null /** Lightweight connectivity check. Throws on non-2xx / network failure. */ suspend fun ping() { client.get("global/health") } suspend fun listSessions(): List = withRetry { client.get("session").body() } suspend fun createSession(title: String? = null): Session = withRetry { client.post("session") { contentType(ContentType.Application.Json) setBody(CreateSessionRequest(title = title)) }.body() } suspend fun updateSession(id: String, title: String): Session = withRetry { client.patch("session/${encode(id)}") { contentType(ContentType.Application.Json) setBody(UpdateSessionRequest(title = title)) }.body() } suspend fun deleteSession(id: String) { withRetry { client.delete("session/${encode(id)}") } } suspend fun listMessages(sessionId: String): List = withRetry { client.get("session/${encode(sessionId)}/message").body() } suspend fun sendPrompt( sessionId: String, text: String, model: ModelRef? = null, agent: String? = null, ): MessageWithParts { // Generate the idempotency key *before* entering withRetry so all retry // attempts share the same key. If the key were generated inside withRetry, // each attempt would get a fresh UUID and the server couldn't deduplicate a // request that reached it but whose response was lost (e.g. timeout). val idempotencyKey = java.util.UUID.randomUUID().toString() return withRetry { client.post("session/${encode(sessionId)}/message") { contentType(ContentType.Application.Json) header("Idempotency-Key", idempotencyKey) setBody( PromptRequest( parts = listOf(PromptPart(text = text)), model = model, agent = agent, ), ) }.body() } } suspend fun abort(sessionId: String) { withRetry { client.post("session/${encode(sessionId)}/abort") } } /** Invoke a slash-command by name via `POST /session/:id/command`. */ suspend fun runCommand( sessionId: String, command: String, arguments: String = "", agent: String? = null, ): MessageWithParts = withRetry { client.post("session/${encode(sessionId)}/command") { contentType(ContentType.Application.Json) setBody(CommandRequest(command = command, arguments = arguments, agent = agent)) }.body() } suspend fun providers(): ProvidersResponse { val now = System.currentTimeMillis() cacheMutex.withLock { val cached = cachedProviders if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) { return cached.value } } val value = withRetry { client.get("config/providers").body() } cacheMutex.withLock { cachedProviders = CachedEntry(value, System.currentTimeMillis()) } return value } suspend fun agents(): List { val now = System.currentTimeMillis() cacheMutex.withLock { val cached = cachedAgents if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) { return cached.value } } val value = withRetry { client.get("agent").body>() } cacheMutex.withLock { cachedAgents = CachedEntry(value, System.currentTimeMillis()) } return value } suspend fun commands(): List { val now = System.currentTimeMillis() cacheMutex.withLock { val cached = cachedCommands if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) { return cached.value } } val value = withRetry { client.get("command").body>() } cacheMutex.withLock { cachedCommands = CachedEntry(value, System.currentTimeMillis()) } return value } /** Invalidate the catalog cache (e.g. on server switch). */ suspend fun invalidateCache() = cacheMutex.withLock { cachedProviders = null cachedAgents = null cachedCommands = null } /** Respond to a permission request so a paused tool run can proceed. */ suspend fun respondPermission( sessionId: String, permissionId: String, response: PermissionResponse, ) = withRetry { client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") { contentType(ContentType.Application.Json) setBody(PermissionReplyBody(response.wire)) } } // --- Files --- suspend fun findFiles(query: String): List = withRetry { client.get("find/file") { parameter("query", query) }.body() } suspend fun listDirectory(path: String): List = withRetry { client.get("file") { parameter("path", path) }.body() } suspend fun readFile(path: String): FileContent = withRetry { client.get("file/content") { parameter("path", path) }.body() } suspend fun fileStatus(): List = withRetry { client.get("file/status").body() } } private suspend fun withRetry( maxAttempts: Int = NetworkConfig.retryMaxAttempts, initialDelayMs: Long = NetworkConfig.retryInitialDelayMs, block: suspend () -> T, ): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block) /** * Retry a suspending block up to [maxAttempts] times with exponential backoff. * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx * server errors are retried. Exposed as `internal` so the retry/skip rules are * unit-testable without an HTTP server. */ internal suspend fun withRetryInternal( maxAttempts: Int = NetworkConfig.retryMaxAttempts, initialDelayMs: Long = NetworkConfig.retryInitialDelayMs, jitterFactor: Double = NetworkConfig.retryJitterFactor, block: suspend () -> T, ): T { var lastError: Throwable? = null for (attempt in 1..maxAttempts) { try { return block() } catch (c: kotlinx.coroutines.CancellationException) { throw c } catch (t: Exception) { lastError = t if (t is ClientRequestException) { // 429 (Too Many Requests) is transient — retry with backoff. if (t.response.status.value != 429) throw t } if (attempt < maxAttempts) { // Guard against Int overflow from the shift and Long overflow from // the multiplication at high attempt counts. val shift = (attempt - 1).coerceAtMost(30) val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs) // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes // shorter, sometimes longer — spreads concurrent client retries and // prevents thundering-herd reconnection storms. val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong() delay((baseDelay + jitter).coerceAtLeast(0)) } } } throw lastError ?: IllegalStateException("withRetry failed without error") } /** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */ private fun encode(segment: String): String = URLEncoder.encode(segment, "UTF-8").replace("+", "%20")