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.request.delete
23import io.ktor.client.request.get
24import io.ktor.client.request.parameter
25import io.ktor.client.request.patch
26import io.ktor.client.request.post
27import io.ktor.client.request.setBody
28import io.ktor.http.ContentType
29import io.ktor.http.contentType
30import kotlinx.coroutines.delay
33 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
34 * SSE stream lives in [EventStreamClient]; everything else is here.
36class OpencodeApiClient(private val client: HttpClient) {
38 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
40 client.get("global/health")
43 suspend fun listSessions(): List<Session> = withRetry {
44 client.get("session").body()
47 suspend fun createSession(title: String? = null): Session =
48 client.post("session") {
49 contentType(ContentType.Application.Json)
50 setBody(CreateSessionRequest(title = title))
53 suspend fun updateSession(id: String, title: String): Session =
54 client.patch("session/$id") {
55 contentType(ContentType.Application.Json)
56 setBody(UpdateSessionRequest(title = title))
59 suspend fun deleteSession(id: String) {
60 client.delete("session/$id")
63 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
64 client.get("session/$sessionId/message").body()
67 suspend fun sendPrompt(
70 model: ModelRef? = null,
71 agent: String? = null,
73 client.post("session/$sessionId/message") {
74 contentType(ContentType.Application.Json)
77 parts = listOf(PromptPart(text = text)),
84 suspend fun abort(sessionId: String) {
85 client.post("session/$sessionId/abort")
88 /** Invoke a slash-command by name via `POST /session/:id/command`. */
89 suspend fun runCommand(
92 arguments: String = "",
93 agent: String? = null,
95 client.post("session/$sessionId/command") {
96 contentType(ContentType.Application.Json)
97 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
100 suspend fun providers(): ProvidersResponse = withRetry {
101 client.get("config/providers").body()
104 suspend fun agents(): List<Agent> = withRetry {
105 client.get("agent").body()
108 suspend fun commands(): List<Command> = withRetry {
109 client.get("command").body()
112 /** Respond to a permission request so a paused tool run can proceed. */
113 suspend fun respondPermission(
115 permissionId: String,
116 response: PermissionResponse,
118 client.post("session/$sessionId/permissions/$permissionId") {
119 contentType(ContentType.Application.Json)
120 setBody(PermissionReplyBody(response.wire))
126 suspend fun findFiles(query: String): List<String> = withRetry {
127 client.get("find/file") { parameter("query", query) }.body()
130 suspend fun listDirectory(path: String): List<FileNode> = withRetry {
131 client.get("file") { parameter("path", path) }.body()
134 suspend fun readFile(path: String): FileContent = withRetry {
135 client.get("file/content") { parameter("path", path) }.body()
138 suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
139 client.get("file/status").body()
143private suspend fun <T> withRetry(
144 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
145 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
146 block: suspend () -> T,
147): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
150 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
151 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
152 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
153 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
154 * server errors are retried. Exposed as `internal` so the retry/skip rules are
155 * unit-testable without an HTTP server.
157internal suspend fun <T> withRetryInternal(
158 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
159 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
160 jitterFactor: Double = NetworkConfig.retryJitterFactor,
161 block: suspend () -> T,
163 var lastError: Throwable? = null
164 for (attempt in 1..maxAttempts) {
167 } catch (c: kotlinx.coroutines.CancellationException) {
169 } catch (t: Throwable) {
171 if (t is ClientRequestException) throw t
172 if (attempt < maxAttempts) {
173 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
174 val jitter = (baseDelay * jitterFactor * Math.random()).toLong()
175 delay(baseDelay + jitter)
179 throw lastError ?: IllegalStateException("withRetry failed without error")
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list