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() @Volatile private var cachedProviders: CachedEntry? = null @Volatile private var cachedAgents: CachedEntry>? = null @Volatile 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) { 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 = withRetry { // Send an idempotency key so the server can safely deduplicate if a transient // network failure causes this request to be retried. The key is unique per call // so a genuine re-send (e.g. retry-failed) still creates a new message. val idempotencyKey = java.util.UUID.randomUUID().toString() 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) { 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 = cacheMutex.withLock { val now = System.currentTimeMillis() val cached = cachedProviders if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) { cached.value } else { val value = withRetry { client.get("config/providers").body() } cachedProviders = CachedEntry(value, now) value } } suspend fun agents(): List = cacheMutex.withLock { val now = System.currentTimeMillis() val cached = cachedAgents if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) { cached.value } else { val value = withRetry { client.get("agent").body>() } cachedAgents = CachedEntry(value, now) value } } suspend fun commands(): List = cacheMutex.withLock { val now = System.currentTimeMillis() val cached = cachedCommands if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) { cached.value } else { val value = withRetry { client.get("command").body>() } cachedCommands = CachedEntry(value, now) value } } /** Invalidate the catalog cache (e.g. on server switch). */ fun invalidateCache() { 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) { val baseDelay = initialDelayMs * (1 shl (attempt - 1)) // 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")