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 warn; + + MiniMax(Fetcher fetcher) { + this(fetcher, URL, message -> { + }); + } + + /** + * @param url the chat-completions endpoint, or null for {@link #URL}. + * MiniMax has regional hosts ({@code api.minimax.io} versus + * {@code api.minimaxi.com}) that answer only for accounts registered + * against them, so the wrong one fails every call with a valid key. + * {@code Settings.aiUrl()} supplies it. + * @param warn where failures are reported. Every failure mode here returns + * null, which the caller cannot tell apart from "no answer": an expired + * key, an exhausted balance, a regional host mismatch and a network + * blip all look identical in chat. Without this the only symptom of a + * dead API key is {@code /ia} quietly getting worse. + */ + MiniMax(Fetcher fetcher, String url, Consumer warn) { + this.fetcher = fetcher; + this.url = url == null || url.isBlank() ? URL : url; + this.warn = warn; + } + + /** + * Asks the model which wiki article to read. + * + *

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 messages, int maxTokens, double temperature) { + JsonObject message = message(post(key, base(model, messages, maxTokens, temperature))); + if (message == null) { + return null; + } + JsonElement content = message.get("content"); + if (content == null || content.isJsonNull() || content.getAsString().isBlank()) { + warn.accept("IA: resposta vazia com max_tokens=" + maxTokens + + " (o raciocínio do modelo pode ter consumido o orçamento)."); + return null; + } + return content.getAsString(); + } + + private JsonObject base(String model, List messages, int maxTokens, double temperature) { + JsonArray array = new JsonArray(); + for (Msg msg : messages) { + JsonObject object = new JsonObject(); + object.addProperty("role", msg.role()); + object.addProperty("content", msg.content()); + array.add(object); + } + JsonObject body = new JsonObject(); + body.addProperty("model", model); + body.add("messages", array); + body.addProperty("max_tokens", maxTokens); + body.addProperty("temperature", temperature); + return body; + } + + /** + * POSTs and parses. The request timeout lives in {@link HttpFetcher}, which + * owns the connection; there is no retry, because {@code /ia} already runs + * on a per-player cooldown and a silent retry would double the wait a player + * sees with no way to tell why. + */ + private JsonObject post(String key, JsonObject body) { + try { + return JsonParser.parseString(fetcher.postJson(url, body.toString(), key)) + .getAsJsonObject(); + } catch (InterruptedException e) { + // Swallowing this would leave an async worker running through a + // plugin disable or reload as if nothing had happened. + Thread.currentThread().interrupt(); + warn.accept("IA: chamada interrompida."); + return null; + } catch (Exception e) { + redacted(key, "IA: falha na chamada: " + e); + return null; + } + } + + /** + * Warns with any occurrence of the key removed. + * + *

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 warnings = new ArrayList<>(); + MiniMax api = new MiniMax(new Fetcher() { + @Override + public String get(String url) { + throw new UnsupportedOperationException(); + } + + @Override + public String postJson(String url, String json, String bearer) throws IOException { + throw new IOException("HTTP 401: {\"base_resp\":{\"status_code\":1004}}"); + } + }, null, warnings::add); + + assertNull(api.answer("k", "m", List.of(), 1200, 0.3)); + assertEquals(1, warnings.size(), warnings.toString()); + assertTrue(warnings.get(0).contains("401"), warnings.get(0)); + } + + @Test + void reportsARefusalDistinctlyFromATransportFailure() { + List warnings = new ArrayList<>(); + MiniMax api = new MiniMax(replying( + "{\"base_resp\":{\"status_code\":1008,\"status_msg\":\"sem saldo\"}}"), + null, warnings::add); + + assertNull(api.answer("k", "m", List.of(), 1200, 0.3)); + assertEquals(1, warnings.size(), warnings.toString()); + assertTrue(warnings.get(0).contains("1008"), warnings.get(0)); + } + + @Test + void reportsAnEmptyAnswerBecauseThatMeansTheBudgetWasTooSmall() { + List warnings = new ArrayList<>(); + MiniMax api = new MiniMax(replying("{\"choices\":[{\"message\":{\"content\":\"\"}}]}"), + null, warnings::add); + + assertNull(api.answer("k", "m", List.of(), 400, 0.3)); + assertEquals(1, warnings.size(), warnings.toString()); + assertTrue(warnings.get(0).contains("400"), warnings.get(0)); + } + + /** + * The key travels in the Authorization header and must never reach a log + * line. Every failure path is exercised with a distinctive key and the + * warnings are searched for it. + */ + @Test + void theApiKeyNeverReachesAWarning() { + String key = "sk-canalhandia-segredo-nao-vaze"; + List warnings = new ArrayList<>(); + + Recorder recorder = new Recorder("{\"base_resp\":{\"status_code\":1004," + + "\"status_msg\":\"invalid api key\"}}"); + MiniMax refused = new MiniMax(recorder, null, warnings::add); + assertNull(refused.answer(key, "m", List.of(new MiniMax.Msg("user", "oi")), 1200, 0.3)); + assertNull(refused.searchTerm(key, "m", "oi")); + + MiniMax broken = new MiniMax(new Fetcher() { + @Override + public String get(String url) { + throw new UnsupportedOperationException(); + } + + @Override + public String postJson(String url, String json, String bearer) throws IOException { + throw new IOException("connection reset"); + } + }, null, warnings::add); + assertNull(broken.answer(key, "m", List.of(), 1200, 0.3)); + assertNull(broken.searchTerm(key, "m", "oi")); + + MiniMax garbage = new MiniMax(replying("not json at all"), null, warnings::add); + assertNull(garbage.answer(key, "m", List.of(), 1200, 0.3)); + + assertFalse(warnings.isEmpty()); + for (String warning : warnings) { + assertFalse(warning.contains(key), "key leaked into a log line: " + warning); + } + // It does reach the header, which is the only place it belongs. + assertEquals(key, recorder.bearer); + assertFalse(recorder.json.contains(key), "key leaked into the request body"); + } + + /** + * A key with a stray newline — two lines pasted into minimax.key, which + * {@code trim()} does not fix — makes the JDK reject the Authorization + * header with an {@code IllegalArgumentException} whose message quotes the + * whole header value back, key included. Measured on temurin-25: + * {@code invalid header value: "Bearer sk-..."}. Logging that exception + * verbatim would print the key. + */ + @Test + void aKeyThatBreaksTheHeaderIsRedactedFromTheWarning() { + String key = "sk-canalhandia-segredo" + (char) 10 + "linha2"; + List warnings = new ArrayList<>(); + MiniMax api = new MiniMax(new Fetcher() { + @Override + public String get(String url) { + throw new UnsupportedOperationException(); + } + + @Override + public String postJson(String url, String json, String bearer) { + throw new IllegalArgumentException("invalid header value: \"Bearer " + bearer + "\""); + } + }, null, warnings::add); + + assertNull(api.answer(key, "m", List.of(), 1200, 0.3)); + assertEquals(1, warnings.size(), warnings.toString()); + assertFalse(warnings.get(0).contains("segredo"), warnings.get(0)); + } + + // --- malformed tool calls ----------------------------------------------- + + @Test + void malformedToolArgumentsYieldNullRatherThanThrowing() { + List warnings = new ArrayList<>(); + MiniMax api = new MiniMax(replying( + "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"1\",\"function\":" + + "{\"name\":\"buscar_wiki\",\"arguments\":\"nao sou json\"}}]}}]}"), + null, warnings::add); + assertNull(api.searchTerm("k", "m", "oi")); + assertEquals(1, warnings.size(), warnings.toString()); + } + + @Test + void emptyToolCallsListYieldsNull() { + MiniMax api = new MiniMax(replying( + "{\"choices\":[{\"message\":{\"tool_calls\":[]}}]}")); + assertNull(api.searchTerm("k", "m", "oi")); + } + + @Test + void blankTermYieldsNull() { + MiniMax api = new MiniMax(replying( + "{\"choices\":[{\"message\":{\"tool_calls\":[{\"function\":" + + "{\"name\":\"buscar_wiki\",\"arguments\":\"{\\\"termo\\\":\\\" \\\"}\"}}]}}]}")); + assertNull(api.searchTerm("k", "m", "oi")); + } + + @Test + void trimsTheTerm() { + MiniMax api = new MiniMax(replying( + "{\"choices\":[{\"message\":{\"tool_calls\":[{\"function\":" + + "{\"name\":\"buscar_wiki\",\"arguments\":\"{\\\"termo\\\":\\\" Creeper \\\"}\"}}]}}]}")); + assertEquals("Creeper", api.searchTerm("k", "m", "oi")); + } +}