git.iko.soy/opencode-android
on 3e723a1 help sign in
189 lines 6.9 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.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
31import kotlin.random.Random
32import java.net.URLEncoder
34/**
35 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
36 * SSE stream lives in [EventStreamClient]; everything else is here.
37 */
38class OpencodeApiClient(private val client: HttpClient) {
40 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
41 suspend fun ping() {
42 client.get("global/health")
43 }
45 suspend fun listSessions(): List<Session> = withRetry {
46 client.get("session").body()
47 }
49 suspend fun createSession(title: String? = null): Session =
50 client.post("session") {
51 contentType(ContentType.Application.Json)
52 setBody(CreateSessionRequest(title = title))
53 }.body()
55 suspend fun updateSession(id: String, title: String): Session =
56 client.patch("session/${encode(id)}") {
57 contentType(ContentType.Application.Json)
58 setBody(UpdateSessionRequest(title = title))
59 }.body()
61 suspend fun deleteSession(id: String) {
62 client.delete("session/${encode(id)}")
63 }
65 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
66 client.get("session/${encode(sessionId)}/message").body()
67 }
69 suspend fun sendPrompt(
70 sessionId: String,
71 text: String,
72 model: ModelRef? = null,
73 agent: String? = null,
74 ): MessageWithParts =
75 client.post("session/${encode(sessionId)}/message") {
76 contentType(ContentType.Application.Json)
77 setBody(
78 PromptRequest(
79 parts = listOf(PromptPart(text = text)),
80 model = model,
81 agent = agent,
82 ),
83 )
84 }.body()
86 suspend fun abort(sessionId: String) {
87 client.post("session/${encode(sessionId)}/abort")
88 }
90 /** Invoke a slash-command by name via `POST /session/:id/command`. */
91 suspend fun runCommand(
92 sessionId: String,
93 command: String,
94 arguments: String = "",
95 agent: String? = null,
96 ): MessageWithParts =
97 client.post("session/${encode(sessionId)}/command") {
98 contentType(ContentType.Application.Json)
99 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
100 }.body()
102 suspend fun providers(): ProvidersResponse = withRetry {
103 client.get("config/providers").body()
104 }
106 suspend fun agents(): List<Agent> = withRetry {
107 client.get("agent").body()
108 }
110 suspend fun commands(): List<Command> = withRetry {
111 client.get("command").body()
112 }
114 /** Respond to a permission request so a paused tool run can proceed. */
115 suspend fun respondPermission(
116 sessionId: String,
117 permissionId: String,
118 response: PermissionResponse,
119 ) = withRetry {
120 client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
121 contentType(ContentType.Application.Json)
122 setBody(PermissionReplyBody(response.wire))
123 }
124 }
126 // --- Files ---
128 suspend fun findFiles(query: String): List<String> = withRetry {
129 client.get("find/file") { parameter("query", query) }.body()
130 }
132 suspend fun listDirectory(path: String): List<FileNode> = withRetry {
133 client.get("file") { parameter("path", path) }.body()
134 }
136 suspend fun readFile(path: String): FileContent = withRetry {
137 client.get("file/content") { parameter("path", path) }.body()
138 }
140 suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
141 client.get("file/status").body()
142 }
145private suspend fun <T> withRetry(
146 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
147 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
148 block: suspend () -> T,
149): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
151/**
152 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
153 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
154 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
155 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
156 * server errors are retried. Exposed as `internal` so the retry/skip rules are
157 * unit-testable without an HTTP server.
158 */
159internal suspend fun <T> withRetryInternal(
160 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
161 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
162 jitterFactor: Double = NetworkConfig.retryJitterFactor,
163 block: suspend () -> T,
164): T {
165 var lastError: Throwable? = null
166 for (attempt in 1..maxAttempts) {
167 try {
168 return block()
169 } catch (c: kotlinx.coroutines.CancellationException) {
170 throw c
171 } catch (t: Exception) {
172 lastError = t
173 if (t is ClientRequestException) {
174 // 429 (Too Many Requests) is transient — retry with backoff.
175 if (t.response.status.value != 429) throw t
176 }
177 if (attempt < maxAttempts) {
178 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
179 val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
180 delay(baseDelay + jitter)
181 }
182 }
183 }
184 throw lastError ?: IllegalStateException("withRetry failed without error")
187/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
188private fun encode(segment: String): String =
189 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")