Skip to content

Commit 870659c

Browse files
committed
refactor(api): 重构API包结构并升级HTTP客户端实现
- 将model包重命名为api.model,mapper包重命名为api.mapper - 将http包重命名为api,统一API相关组件位置 - 升级HTTP客户端从Unirest到OkHttp,支持外部传入客户端实例 - 更新OpenCodeClient构造函数,传入ObjectMapper和OkHttpClient参数 - 修改ChatMessageMapper导入路径,使用新的包结构 - 更新事件处理逻辑,使用新的Event模型类 - 优化异常处理,添加OkHttp请求失败的具体错误信息 - 调整JSON序列化配置,使用Jackson ObjectMapper进行类型转换 - 修改SSE客户端实现,适配新的API包结构
1 parent a8995e1 commit 870659c

23 files changed

Lines changed: 935 additions & 596 deletions

pom.xml

Lines changed: 444 additions & 120 deletions
Large diffs are not rendered by default.

src/main/java/io/github/hiwepy/opencode/OpenCodeClient.java

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package io.github.hiwepy.opencode;
22

3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import io.github.hiwepy.opencode.api.mapper.ChatMessageMapper;
5+
import io.github.hiwepy.opencode.api.model.*;
36
import io.github.hiwepy.opencode.cli.OpenCodeCli;
47
import io.github.hiwepy.opencode.cli.OpenCodeCliExecutor;
5-
import io.github.hiwepy.opencode.http.OpenCodeHttpClient;
6-
import io.github.hiwepy.opencode.http.OpenCodeSseClient;
7-
import io.github.hiwepy.opencode.model.*;
8+
import io.github.hiwepy.opencode.api.OpenCodeHttpClient;
9+
import io.github.hiwepy.opencode.api.OpenCodeSseClient;
10+
import okhttp3.OkHttpClient;
811

912
import java.util.List;
1013
import java.util.Map;
@@ -31,10 +34,10 @@ public class OpenCodeClient implements AutoCloseable {
3134
/**
3235
* 标准构造(自动创建 HTTP、SSE、CLI 客户端)。
3336
*/
34-
public OpenCodeClient(OpenCodeClientConfig config) {
37+
public OpenCodeClient(OpenCodeClientConfig config, ObjectMapper objectMapper, OkHttpClient httpClient) {
3538
this.config = Objects.requireNonNull(config, "config");
36-
this.httpClient = new OpenCodeHttpClient(config);
37-
this.sseClient = new OpenCodeSseClient(config);
39+
this.httpClient = new OpenCodeHttpClient(config, objectMapper, httpClient);
40+
this.sseClient = new OpenCodeSseClient(config, objectMapper, httpClient);
3841
this.cli = new OpenCodeCli(new OpenCodeCliExecutor(config));
3942
}
4043

@@ -164,9 +167,9 @@ public boolean chatCompletionWithSessionAsync(PromptRequest request, String sess
164167
* @return OpenAI 标准响应
165168
*/
166169
public ChatResponse chatCompletion(String sessionId, ChatRequest request) {
167-
PromptRequest promptRequest = io.github.hiwepy.opencode.mapper.ChatMessageMapper.toPromptRequest(request);
170+
PromptRequest promptRequest = ChatMessageMapper.toPromptRequest(request);
168171
PromptResult result = httpClient.prompt(sessionId, promptRequest);
169-
return io.github.hiwepy.opencode.mapper.ChatMessageMapper.toChatResponse(result);
172+
return ChatMessageMapper.toChatResponse(result);
170173
}
171174

172175
/**
@@ -178,9 +181,9 @@ public ChatResponse chatCompletion(String sessionId, ChatRequest request) {
178181
* @return OpenAI 标准响应
179182
*/
180183
public ChatResponse chatCompletionWithSession(ChatRequest request, String sessionKey) {
181-
PromptRequest promptRequest = io.github.hiwepy.opencode.mapper.ChatMessageMapper.toPromptRequest(request);
184+
PromptRequest promptRequest = ChatMessageMapper.toPromptRequest(request);
182185
PromptResult result = httpClient.chatCompletionWithSession(promptRequest, sessionKey);
183-
return io.github.hiwepy.opencode.mapper.ChatMessageMapper.toChatResponse(result);
186+
return ChatMessageMapper.toChatResponse(result);
184187
}
185188

186189
/**
@@ -199,19 +202,19 @@ public ChatResponse chatCompletionWithSession(ChatRequest request, String sessio
199202
*/
200203
public ChatStreamingResponse chatCompletionStream(ChatRequest request, String sessionKey) {
201204
String sessionId = httpClient.ensureSession(sessionKey);
202-
PromptRequest promptRequest = io.github.hiwepy.opencode.mapper.ChatMessageMapper.toPromptRequest(request);
205+
PromptRequest promptRequest = ChatMessageMapper.toPromptRequest(request);
203206

204207
ChatStreamingResponse stream = new ChatStreamingResponse();
205208

206209
// 订阅全局 SSE,按 sessionId 过滤事件
207-
java.util.concurrent.BlockingQueue<io.github.hiwepy.opencode.model.Event> queue = sseClient.subscribeQueue();
210+
java.util.concurrent.BlockingQueue<Event> queue = sseClient.subscribeQueue();
208211

209212
// 异步消费事件
210213
java.util.concurrent.CompletableFuture.runAsync(() -> {
211214
try {
212215
long deadline = System.currentTimeMillis() + (config.getLocalTimeoutSeconds() * 1000L);
213216
while (!stream.isDone() && System.currentTimeMillis() < deadline) {
214-
io.github.hiwepy.opencode.model.Event event = queue.poll(3, java.util.concurrent.TimeUnit.SECONDS);
217+
Event event = queue.poll(3, java.util.concurrent.TimeUnit.SECONDS);
215218
if (event == null) {
216219
continue;
217220
}
@@ -274,7 +277,7 @@ public ChatStreamingResponse chatCompletionStream(ChatRequest request, String se
274277
/**
275278
* 从事件属性中提取增量文本。
276279
*/
277-
private static String extractDeltaText(io.github.hiwepy.opencode.model.Event event) {
280+
private static String extractDeltaText(Event event) {
278281
if (event.getProperties() == null) {
279282
return null;
280283
}
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package io.github.hiwepy.opencode.api;
2+
3+
import com.fasterxml.jackson.core.type.TypeReference;
4+
import com.fasterxml.jackson.databind.DeserializationFeature;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import io.github.hiwepy.opencode.OpenCodeClientConfig;
7+
import io.github.hiwepy.opencode.api.model.*;
8+
import io.github.hiwepy.opencode.exception.OpenCodeHttpException;
9+
import lombok.extern.slf4j.Slf4j;
10+
import okhttp3.*;
11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
13+
14+
import java.io.IOException;
15+
import java.util.List;
16+
import java.util.Map;
17+
import java.util.Objects;
18+
import java.util.Optional;
19+
import java.util.concurrent.TimeUnit;
20+
21+
/**
22+
* OpenCode Server HTTP 客户端,封装 REST API。
23+
* <p>基于 OkHttp,支持外部传入 {@link OkHttpClient}(复用别的插件实例)。</p>
24+
*
25+
* @see <a href="https://opencode.ai/docs/server/">opencode server docs</a>
26+
*/
27+
@Slf4j
28+
public class OpenCodeHttpClient implements AutoCloseable {
29+
30+
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
31+
32+
private final OpenCodeClientConfig config;
33+
private final OkHttpClient httpClient;
34+
private final ObjectMapper objectMapper;
35+
36+
public OpenCodeHttpClient(OpenCodeClientConfig config, ObjectMapper objectMapper, OkHttpClient httpClient) {
37+
this.config = Objects.requireNonNull(config, "config");
38+
this.objectMapper = Objects.isNull(objectMapper) ? new ObjectMapper()
39+
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false): objectMapper;
40+
this.httpClient = Objects.isNull(httpClient) ? buildOkHttpClient(config) : httpClient;
41+
}
42+
43+
private static OkHttpClient buildOkHttpClient(OpenCodeClientConfig config) {
44+
// 兜底创建
45+
OkHttpClient.Builder builder = new OkHttpClient.Builder()
46+
.connectTimeout(config.getConnectTimeoutMillis(), TimeUnit.MILLISECONDS)
47+
.readTimeout(config.getReadTimeoutMillis(), TimeUnit.MILLISECONDS);
48+
if (!config.isVerifySsl()) {
49+
builder.hostnameVerifier((hostname, session) -> true);
50+
}
51+
return builder.build();
52+
}
53+
54+
// ============================================================
55+
// Global
56+
// ============================================================
57+
58+
public HealthStatus health() {
59+
return get("/global/health", HealthStatus.class);
60+
}
61+
62+
// ============================================================
63+
// Session
64+
// ============================================================
65+
66+
public Session createSession(String title) {
67+
Map<String, Object> body = title != null ? Map.of("title", title) : Map.of();
68+
return post("/session", body, Session.class);
69+
}
70+
71+
public Session getSession(String sessionId) {
72+
return get("/session/" + sessionId, Session.class);
73+
}
74+
75+
public List<Session> listSessions() {
76+
return getList("/session", new TypeReference<List<Session>>() {});
77+
}
78+
79+
/**
80+
* 分页/过滤列出 sessions,对齐 Hermes {@code listSessions(limit, offset, source, includeChildren)}。
81+
*
82+
* @param search 服务端关键字过滤,为 null 则不过滤
83+
* @param limit 最大返回条数,为 null 则不限制
84+
* @param start 分页偏移量,为 null 则从 0 开始
85+
* @return 匹配的 session 列表
86+
*/
87+
public List<Session> listSessions(String search, Integer limit, Integer start) {
88+
HttpUrl.Builder urlBuilder = HttpUrl.get(url("/session")).newBuilder();
89+
if (search != null) urlBuilder.addQueryParameter("search", search);
90+
if (limit != null) urlBuilder.addQueryParameter("limit", String.valueOf(limit));
91+
if (start != null) urlBuilder.addQueryParameter("start", String.valueOf(start));
92+
Request request = authedRequest(urlBuilder.build().toString()).get().build();
93+
return executeList(request, new TypeReference<List<Session>>() {});
94+
}
95+
96+
/**
97+
* 按 title 精确查找 session。
98+
*/
99+
public Optional<Session> findSessionByTitle(String title) {
100+
if (title == null || title.isEmpty()) {
101+
return Optional.empty();
102+
}
103+
return listSessions(title, 50, null).stream()
104+
.filter(s -> Objects.equals(title, s.getTitle()))
105+
.findFirst();
106+
}
107+
108+
public boolean deleteSession(String sessionId) {
109+
Request request = new Request.Builder().url(url("/session/" + sessionId))
110+
.delete().build();
111+
try (Response response = httpClient.newCall(request).execute()) {
112+
return response.isSuccessful();
113+
} catch (IOException e) {
114+
throw new OpenCodeHttpException("DELETE failed: " + e.getMessage(), e);
115+
}
116+
}
117+
118+
// ============================================================
119+
// Message / Prompt
120+
// ============================================================
121+
122+
public PromptResult prompt(String sessionId, PromptRequest request) {
123+
return post("/session/" + sessionId + "/message", request, PromptResult.class);
124+
}
125+
126+
public PromptResult chatCompletionWithSession(PromptRequest request, String sessionKey) {
127+
String sessionId = ensureSession(sessionKey);
128+
return prompt(sessionId, request);
129+
}
130+
131+
public boolean chatCompletionWithSessionAsync(PromptRequest request, String sessionKey) {
132+
String sessionId = ensureSession(sessionKey);
133+
return promptAsync(sessionId, request);
134+
}
135+
136+
public String ensureSession(String sessionKey) {
137+
try {
138+
Optional<Session> existing = findSessionByTitle(sessionKey);
139+
if (existing.isPresent()) {
140+
return existing.get().getId();
141+
}
142+
} catch (Exception e) {
143+
log.debug("findSessionByTitle failed, sessionKey={}, error={}", sessionKey, e.getMessage());
144+
}
145+
Session session = createSession(sessionKey);
146+
return session.getId();
147+
}
148+
149+
public boolean promptAsync(String sessionId, PromptRequest request) {
150+
try {
151+
RequestBody body = RequestBody.create(objectMapper.writeValueAsBytes(request), JSON);
152+
Request httpReq = new Request.Builder().url(url("/session/" + sessionId + "/prompt_async"))
153+
.post(body).build();
154+
try (Response response = httpClient.newCall(httpReq).execute()) {
155+
return response.isSuccessful();
156+
}
157+
} catch (IOException e) {
158+
throw new OpenCodeHttpException("promptAsync failed: " + e.getMessage(), e);
159+
}
160+
}
161+
162+
public List<PromptResult> getMessages(String sessionId) {
163+
return getList("/session/" + sessionId + "/message", new TypeReference<List<PromptResult>>() {});
164+
}
165+
166+
public boolean abortSession(String sessionId) {
167+
Request request = new Request.Builder().url(url("/session/" + sessionId + "/abort"))
168+
.post(RequestBody.create(new byte[0], null)).build();
169+
try (Response response = httpClient.newCall(request).execute()) {
170+
return response.isSuccessful();
171+
} catch (IOException e) {
172+
throw new OpenCodeHttpException("abort failed: " + e.getMessage(), e);
173+
}
174+
}
175+
176+
// ============================================================
177+
// Agent
178+
// ============================================================
179+
180+
public List<Agent> listAgents() {
181+
return getList("/agent", new TypeReference<List<Agent>>() {});
182+
}
183+
184+
/**
185+
* 暴露 OkHttpClient 供 SSE 客户端复用。
186+
*/
187+
public OkHttpClient getOkHttpClient() {
188+
return httpClient;
189+
}
190+
191+
/**
192+
* 暴露 ObjectMapper 供转换器复用。
193+
*/
194+
public ObjectMapper getObjectMapper() {
195+
return objectMapper;
196+
}
197+
198+
// ============================================================
199+
// Internal helpers
200+
// ============================================================
201+
202+
private String url(String path) {
203+
return config.getServerUrl() + path;
204+
}
205+
206+
private Request.Builder authedRequest(String url) {
207+
Request.Builder builder = new Request.Builder().url(url);
208+
String password = config.resolvePassword();
209+
if (!password.isEmpty()) {
210+
String credential = Credentials.basic(config.getUsername(), password);
211+
builder.header("Authorization", credential);
212+
}
213+
return builder;
214+
}
215+
216+
private <T> T get(String path, Class<T> type) {
217+
Request request = authedRequest(url(path)).get().build();
218+
return execute(request, type);
219+
}
220+
221+
222+
private <T> T getList(String path, TypeReference<T> typeRef) {
223+
Request request = authedRequest(url(path)).get().build();
224+
return executeList(request, typeRef);
225+
}
226+
227+
228+
private <T> T post(String path, Object body, Class<T> type) {
229+
Request request = authedRequest(url(path))
230+
.post(RequestBody.create(toJson(body), JSON))
231+
.build();
232+
return execute(request, type);
233+
}
234+
235+
private <T> T execute(Request request, Class<T> type) {
236+
try (Response response = httpClient.newCall(request).execute()) {
237+
String respBody = response.body() != null ? response.body().string() : "";
238+
if (!response.isSuccessful()) {
239+
throw new OpenCodeHttpException(response.code(), respBody);
240+
}
241+
return objectMapper.readValue(respBody, type);
242+
} catch (IOException e) {
243+
throw new OpenCodeHttpException("HTTP request failed: " + e.getMessage(), e);
244+
}
245+
}
246+
247+
private <T> T executeList(Request request, TypeReference<T> typeRef) {
248+
try (Response response = httpClient.newCall(request).execute()) {
249+
String respBody = response.body() != null ? response.body().string() : "";
250+
if (!response.isSuccessful()) {
251+
throw new OpenCodeHttpException(response.code(), respBody);
252+
}
253+
return objectMapper.readValue(respBody, typeRef);
254+
} catch (IOException e) {
255+
throw new OpenCodeHttpException("HTTP request failed: " + e.getMessage(), e);
256+
}
257+
}
258+
259+
private String toJson(Object body) {
260+
try {
261+
return objectMapper.writeValueAsString(body);
262+
} catch (IOException e) {
263+
throw new OpenCodeHttpException("Failed to serialize request body: " + e.getMessage(), e);
264+
}
265+
}
266+
267+
@Override
268+
public void close() {
269+
// 外部传入的 OkHttpClient 不关闭;自建的也不主动关闭(OkHttpClient 内部管理连接池)
270+
}
271+
}

0 commit comments

Comments
 (0)