diff --git a/src/main/java/dev/marcospaulo/canalhandia/MiniMax.java b/src/main/java/dev/marcospaulo/canalhandia/MiniMax.java new file mode 100644 index 0000000..9215daa --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/MiniMax.java @@ -0,0 +1,238 @@ +package dev.marcospaulo.canalhandia; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.List; +import java.util.function.Consumer; + +/** + * MiniMax chat-completions client. + * + *
Two calls per grounded question: {@link #searchTerm} makes the model name + * a wiki article, then {@link #answer} answers with that article in context. + * + *
The API key is a parameter rather than a field so it is never held longer
+ * than a call, and it leaves this class by exactly one route: the {@code bearer}
+ * argument of {@link Fetcher#postJson}, which {@link HttpFetcher} turns into an
+ * {@code Authorization} header. It is never put in the request body, never
+ * concatenated into a message, and never passed to {@link #warn}. The failure
+ * paths below log the response or the exception, and neither carries it.
+ */
+final class MiniMax {
+
+ static final String URL = "https://api.minimax.io/v1/text/chatcompletion_v2";
+
+ /**
+ * The tool the model is forced to call. Written as JSON rather than built
+ * with {@code JsonObject} calls because it is a fixed schema that never
+ * varies at runtime, and this way it can be read against the API docs — and
+ * against the request that was measured to work — line for line.
+ */
+ private static final String TOOLS = """
+ [{"type":"function","function":{
+ "name":"buscar_wiki",
+ "description":"Busca um artigo na Minecraft Wiki em português.",
+ "parameters":{"type":"object",
+ "properties":{"termo":{"type":"string",
+ "description":"Termo curto do jogo, ex: Camelo, Creeper"}},
+ "required":["termo"]}}}]
+ """;
+
+ private static final String TOOL_CHOICE = """
+ {"type":"function","function":{"name":"buscar_wiki"}}
+ """;
+
+ /**
+ * Enough for a tool call and the reasoning that precedes it. Answers get far
+ * more; see {@link #answer}.
+ */
+ private static final int TERM_TOKENS = 500;
+
+ /**
+ * Term selection is deterministic on purpose: the same question must pick
+ * the same article every time, or the wiki cache thrashes and two players
+ * asking the same thing get differently grounded answers. Only
+ * {@link #answer} takes its temperature from config.
+ */
+ private static final double TERM_TEMPERATURE = 0.0;
+
+ /** One message in the request. */
+ record Msg(String role, String content) {
+ }
+
+ private final Fetcher fetcher;
+ private final String url;
+ private final Consumer The call is forced with {@code tool_choice} rather than left to
+ * {@code auto}. Given the choice the model skipped the search on exactly
+ * the questions it was most likely to get wrong. A tool argument is also
+ * structured output, so unlike a free-text reply it survives the model's
+ * hidden reasoning eating the token budget: measured over the same
+ * questions, a free-text extraction call answered 2 of 7 while this
+ * answered 5 of 5.
+ */
+ String searchTerm(String key, String model, String question) {
+ JsonObject body = base(model, List.of(
+ new Msg("system", "Você escolhe qual artigo da Minecraft Wiki consultar."),
+ new Msg("user", question)), TERM_TOKENS, TERM_TEMPERATURE);
+ body.add("tools", JsonParser.parseString(TOOLS).getAsJsonArray());
+ body.add("tool_choice", JsonParser.parseString(TOOL_CHOICE).getAsJsonObject());
+
+ JsonObject message = message(post(key, body));
+ if (message == null) {
+ return null;
+ }
+ JsonElement calls = message.get("tool_calls");
+ // No tool_calls at all is a plain no-result, not a malformed response:
+ // the model answered in prose instead. Nothing to warn about.
+ if (calls == null || !calls.isJsonArray() || calls.getAsJsonArray().isEmpty()) {
+ return null;
+ }
+ try {
+ // The arguments are a JSON *string* that has to be parsed again.
+ String arguments = calls.getAsJsonArray().get(0).getAsJsonObject()
+ .getAsJsonObject("function").get("arguments").getAsString();
+ JsonObject parsed = JsonParser.parseString(arguments).getAsJsonObject();
+ if (!parsed.has("termo")) {
+ warn.accept("IA: tool call sem \"termo\": " + AiText.forLog(arguments));
+ return null;
+ }
+ String term = parsed.get("termo").getAsString().trim();
+ return term.isEmpty() ? null : term;
+ } catch (RuntimeException e) {
+ // Nothing guarantees the shape of a tool call, and an unchecked
+ // throw from here would surface as a bare stack trace in the
+ // async worker rather than as a lost bit of grounding.
+ warn.accept("IA: tool call malformada: " + e);
+ return null;
+ }
+ }
+
+ /**
+ * Answers a question. Returns null on any failure, including empty content.
+ *
+ * Empty content is a failure and not a short answer: the model's hidden
+ * reasoning is charged against {@code max_tokens}, so too small a budget
+ * spends the whole allowance thinking and returns nothing at all. Measured
+ * twice at 400 tokens, which is why answers are given 1200.
+ */
+ String answer(String key, String model, List This is the one place an exception could carry it. A key with a stray
+ * newline in it — two lines pasted into {@code minimax.key}, which
+ * {@code trim()} does not fix — makes the JDK reject the Authorization
+ * header with {@code invalid header value: "Bearer sk-…"}, quoting the
+ * whole value back. That throws before the request leaves, so it arrives
+ * here as a plain call failure and would otherwise be logged verbatim.
+ */
+ private void redacted(String key, String message) {
+ warn.accept(key == null || key.isEmpty() ? message : message.replace(key, "***"));
+ }
+
+ /** The first choice's message, or null if the response reported a failure. */
+ private JsonObject message(JsonObject root) {
+ if (root == null) {
+ return null;
+ }
+ // HTTP 200 does not mean success here: MiniMax reports application
+ // errors — bad key, no balance, rate limit — with a 200 and a non-zero
+ // status_code, so checking the HTTP status alone misses all of them.
+ if (root.has("base_resp")) {
+ JsonObject base = root.getAsJsonObject("base_resp");
+ if (base.has("status_code") && base.get("status_code").getAsInt() != 0) {
+ warn.accept("IA: recusada, base_resp " + AiText.forLog(base.toString()));
+ return null;
+ }
+ }
+ JsonElement choices = root.get("choices");
+ if (choices == null || !choices.isJsonArray() || choices.getAsJsonArray().isEmpty()) {
+ warn.accept("IA: resposta sem \"choices\": " + AiText.forLog(root.toString()));
+ return null;
+ }
+ JsonObject first = choices.getAsJsonArray().get(0).getAsJsonObject();
+ return first.has("message") ? first.getAsJsonObject("message") : null;
+ }
+}
diff --git a/src/test/java/dev/marcospaulo/canalhandia/MiniMaxTest.java b/src/test/java/dev/marcospaulo/canalhandia/MiniMaxTest.java
new file mode 100644
index 0000000..a361901
--- /dev/null
+++ b/src/test/java/dev/marcospaulo/canalhandia/MiniMaxTest.java
@@ -0,0 +1,299 @@
+package dev.marcospaulo.canalhandia;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class MiniMaxTest {
+
+ private static Fetcher replying(String body) {
+ return new Fetcher() {
+ @Override
+ public String get(String url) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String postJson(String url, String json, String bearer) {
+ return body;
+ }
+ };
+ }
+
+ @Test
+ void readsAnswerContent() {
+ MiniMax api = new MiniMax(replying(
+ "{\"choices\":[{\"message\":{\"content\":\"pólvora\"}}]}"));
+ assertEquals("pólvora", api.answer("k", "m", List.of(), 1200, 0.3));
+ }
+
+ // MiniMax reports application errors with HTTP 200 and a non-zero base_resp.
+ @Test
+ void treatsNonZeroBaseRespAsFailure() {
+ MiniMax api = new MiniMax(replying(
+ "{\"base_resp\":{\"status_code\":1004,\"status_msg\":\"bad key\"},\"choices\":[]}"));
+ assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
+ }
+
+ // Hidden reasoning can consume the whole budget, leaving content empty.
+ @Test
+ void emptyContentIsNullNotBlank() {
+ MiniMax api = new MiniMax(replying(
+ "{\"choices\":[{\"message\":{\"content\":\"\"}}]}"));
+ assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
+ }
+
+ @Test
+ void readsForcedToolArgument() {
+ MiniMax api = new MiniMax(replying(
+ "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"1\",\"function\":"
+ + "{\"name\":\"buscar_wiki\",\"arguments\":\"{\\\"termo\\\":\\\"Camelo\\\"}\"}}]}}]}"));
+ assertEquals("Camelo", api.searchTerm("k", "m", "como pego um camelo?"));
+ }
+
+ @Test
+ void missingToolCallYieldsNull() {
+ MiniMax api = new MiniMax(replying(
+ "{\"choices\":[{\"message\":{\"content\":\"sei lá\"}}]}"));
+ assertNull(api.searchTerm("k", "m", "oi"));
+ }
+
+ // --- request shape ------------------------------------------------------
+
+ /** Captures what was sent, so the request shape itself can be asserted. */
+ private static final class Recorder implements Fetcher {
+ String url;
+ String json;
+ String bearer;
+ private final String reply;
+
+ Recorder(String reply) {
+ this.reply = reply;
+ }
+
+ @Override
+ public String get(String u) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String postJson(String u, String body, String token) {
+ this.url = u;
+ this.json = body;
+ this.bearer = token;
+ return reply;
+ }
+ }
+
+ @Test
+ void forcesTheBuscarWikiFunctionAtTemperatureZero() {
+ Recorder recorder = new Recorder("{\"choices\":[]}");
+ new MiniMax(recorder).searchTerm("k", "MiniMax-M2.7", "onde acho diamante?");
+
+ JsonObject body = JsonParser.parseString(recorder.json).getAsJsonObject();
+ assertEquals(0.0, body.get("temperature").getAsDouble(),
+ "term selection must be deterministic");
+ JsonObject function = body.getAsJsonArray("tools").get(0).getAsJsonObject()
+ .getAsJsonObject("function");
+ assertEquals("buscar_wiki", function.get("name").getAsString());
+ JsonObject parameters = function.getAsJsonObject("parameters");
+ assertEquals("object", parameters.get("type").getAsString());
+ assertTrue(parameters.getAsJsonObject("properties").has("termo"));
+ assertEquals("string", parameters.getAsJsonObject("properties")
+ .getAsJsonObject("termo").get("type").getAsString());
+ assertEquals("termo", parameters.getAsJsonArray("required").get(0).getAsString());
+ // "auto" let the model skip the search on exactly the questions it was
+ // most likely to get wrong, so the function is named explicitly.
+ JsonObject choice = body.getAsJsonObject("tool_choice");
+ assertEquals("function", choice.get("type").getAsString());
+ assertEquals("buscar_wiki", choice.getAsJsonObject("function").get("name").getAsString());
+ }
+
+ @Test
+ void answerSendsNoToolsAndCarriesItsOwnBudget() {
+ Recorder recorder = new Recorder("{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}");
+ new MiniMax(recorder).answer("k", "MiniMax-M2.7",
+ List.of(new MiniMax.Msg("system", "regras"), new MiniMax.Msg("user", "oi")),
+ 1200, 0.3);
+
+ JsonObject body = JsonParser.parseString(recorder.json).getAsJsonObject();
+ assertFalse(body.has("tools"), "the answering call must give the model nothing to call");
+ assertFalse(body.has("tool_choice"));
+ assertEquals("MiniMax-M2.7", body.get("model").getAsString());
+ assertEquals(1200, body.get("max_tokens").getAsInt());
+ assertEquals(0.3, body.get("temperature").getAsDouble());
+ assertEquals(2, body.getAsJsonArray("messages").size());
+ assertEquals("system", body.getAsJsonArray("messages").get(0).getAsJsonObject()
+ .get("role").getAsString());
+ }
+
+ @Test
+ void postsToTheDefaultEndpointUnlessOneIsGiven() {
+ Recorder standard = new Recorder("{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}");
+ new MiniMax(standard).answer("k", "m", List.of(), 1200, 0.3);
+ assertEquals(MiniMax.URL, standard.url);
+
+ Recorder regional = new Recorder("{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}");
+ new MiniMax(regional, "https://api.minimaxi.com/v1/text/chatcompletion_v2", warn -> {
+ }).answer("k", "m", List.of(), 1200, 0.3);
+ assertEquals("https://api.minimaxi.com/v1/text/chatcompletion_v2", regional.url);
+ }
+
+ // --- failures are reported, not swallowed -------------------------------
+
+ @Test
+ void reportsTransportFailureRatherThanFailingSilently() {
+ List