Ether Framework
Unified API docs for Ether modules
Loading...
Searching...
No Matches
DeepSeekChatModel.java
Go to the documentation of this file.
1package dev.rafex.ether.ai.deepseek.chat;
2
3/*-
4 * #%L
5 * ether-ai-deepseek
6 * %%
7 * Copyright (C) 2025 - 2026 Raúl Eduardo González Argote
8 * %%
9 * Permission is hereby granted, free of charge, to any person obtaining a copy
10 * of this software and associated documentation files (the "Software"), to deal
11 * in the Software without restriction, including without limitation the rights
12 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 * copies of the Software, and to permit persons to whom the Software is
14 * furnished to do so, subject to the following conditions:
15 *
16 * The above copyright notice and this permission notice shall be included in
17 * all copies or substantial portions of the Software.
18 *
19 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25 * THE SOFTWARE.
26 * #L%
27 */
28
29import java.io.IOException;
30import java.net.http.HttpClient;
31import java.net.http.HttpRequest;
32import java.net.http.HttpResponse;
33import java.nio.charset.StandardCharsets;
34import java.util.LinkedHashMap;
35import java.util.Map;
36import java.util.Objects;
37
38import com.fasterxml.jackson.databind.JsonNode;
39
40import dev.rafex.ether.ai.core.chat.AiChatModel;
41import dev.rafex.ether.ai.core.chat.AiChatRequest;
42import dev.rafex.ether.ai.core.chat.AiChatResponse;
43import dev.rafex.ether.ai.core.error.AiHttpException;
44import dev.rafex.ether.ai.core.message.AiMessage;
45import dev.rafex.ether.ai.core.message.AiMessageRole;
46import dev.rafex.ether.ai.core.usage.AiUsage;
47import dev.rafex.ether.ai.deepseek.config.DeepSeekConfig;
48import dev.rafex.ether.json.JsonCodec;
49import dev.rafex.ether.json.JsonUtils;
50
51/**
52 * Chat model adapter for the DeepSeek API.
53 *
54 * <p>Implements {@link AiChatModel} by sending HTTP requests to the
55 * DeepSeek chat completions endpoint and mapping JSON responses back to
56 * Ether AI domain objects.
57 */
58public final class DeepSeekChatModel implements AiChatModel {
59
60 /** Configuration holding API key, base URI, timeout and default headers. */
61 private final DeepSeekConfig config;
62
63 /** HTTP client used to send requests to the DeepSeek API. */
64 private final HttpClient httpClient;
65
66 /** JSON codec used to serialize payloads and deserialize responses. */
67 private final JsonCodec jsonCodec;
68
69 /**
70 * Creates a new model with a default HTTP client and JSON codec.
71 *
72 * @param config DeepSeek connection configuration
73 */
74 public DeepSeekChatModel(final DeepSeekConfig config) {
75 this(config, HttpClient.newBuilder().connectTimeout(config.timeout()).build(), JsonUtils.codec());
76 }
77
78 /**
79 * Creates a new model with a custom HTTP client and JSON codec.
80 *
81 * @param config DeepSeek connection configuration
82 * @param httpClient pre-configured HTTP client to use for requests
83 * @param jsonCodec JSON codec for serialization; falls back to the default codec when {@code null}
84 */
85 public DeepSeekChatModel(final DeepSeekConfig config, final HttpClient httpClient, final JsonCodec jsonCodec) {
86 this.config = Objects.requireNonNull(config, "config");
87 this.httpClient = Objects.requireNonNull(httpClient, "httpClient");
88 this.jsonCodec = jsonCodec == null ? JsonUtils.codec() : jsonCodec;
89 }
90
91 /**
92 * Sends a chat completion request to the DeepSeek API and returns the parsed response.
93 *
94 * @param request the chat request containing model, messages and generation parameters
95 * @return the parsed response including the assistant message, model name and token usage
96 * @throws IOException if an I/O error occurs during the HTTP call or response parsing
97 * @throws InterruptedException if the current thread is interrupted while waiting for the HTTP response
98 * @throws AiHttpException if the DeepSeek API returns a non-2xx HTTP status code
99 */
100 @Override
101 public AiChatResponse generate(final AiChatRequest request) throws IOException, InterruptedException {
102 final byte[] payload = jsonCodec.toJsonBytes(toPayload(request));
103 final var builder = HttpRequest.newBuilder(config.chatCompletionsUri()).timeout(config.timeout())
104 .header("Authorization", "Bearer " + config.apiKey()).header("Content-Type", "application/json")
105 .POST(HttpRequest.BodyPublishers.ofByteArray(payload));
106 config.defaultHeaders().forEach(builder::header);
107
108 final var response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
109 if (response.statusCode() < 200 || response.statusCode() >= 300) {
110 throw new AiHttpException("DeepSeek request failed with HTTP " + response.statusCode(),
111 response.statusCode(), new String(response.body(), StandardCharsets.UTF_8));
112 }
113
114 final JsonNode root = jsonCodec.readTree(response.body());
115 final JsonNode choice = root.path("choices").path(0);
116 final JsonNode messageNode = choice.path("message");
117 final var message = new AiMessage(AiMessageRole.fromWireValue(text(messageNode, "role")),
118 text(messageNode, "content"));
119 return new AiChatResponse(text(root, "id"), text(root, "model"), message, text(choice, "finish_reason"),
120 usage(root.path("usage")));
121 }
122
123 /**
124 * Converts an {@link AiChatRequest} into a JSON-serializable payload map
125 * compatible with the DeepSeek chat completions API.
126 */
127 private static Map<String, Object> toPayload(final AiChatRequest request) {
128 final var payload = new LinkedHashMap<String, Object>();
129 payload.put("model", request.model());
130 payload.put("messages", request.messages().stream()
131 .map(message -> Map.of("role", message.role().wireValue(), "content", message.content())).toList());
132 if (request.temperature() != null) {
133 payload.put("temperature", request.temperature());
134 }
135 if (request.maxOutputTokens() != null) {
136 payload.put("max_tokens", request.maxOutputTokens());
137 }
138 return payload;
139 }
140
141 /**
142 * Extracts a text value from a JSON node by field name.
143 *
144 * @throws IOException if the field is missing or {@code null} in the JSON tree
145 */
146 private static String text(final JsonNode node, final String fieldName) throws IOException {
147 final JsonNode field = node.path(fieldName);
148 if (field.isMissingNode() || field.isNull()) {
149 throw new IOException("Missing JSON field: " + fieldName);
150 }
151 return field.asText();
152 }
153
154 /**
155 * Parses token usage information from a JSON node, returning an empty
156 * usage object when the node is absent or {@code null}.
157 */
158 private static AiUsage usage(final JsonNode node) {
159 if (node == null || node.isMissingNode() || node.isNull()) {
160 return AiUsage.empty();
161 }
162 return new AiUsage(node.path("prompt_tokens").asInt(0), node.path("completion_tokens").asInt(0),
163 node.path("total_tokens").asInt(0));
164 }
165}
AiChatResponse generate(final AiChatRequest request)
Sends a chat completion request to the DeepSeek API and returns the parsed response.
DeepSeekChatModel(final DeepSeekConfig config, final HttpClient httpClient, final JsonCodec jsonCodec)
Creates a new model with a custom HTTP client and JSON codec.
DeepSeekChatModel(final DeepSeekConfig config)
Creates a new model with a default HTTP client and JSON codec.
static AiMessageRole fromWireValue(final String value)