feat: MiniMax client with forced tool call for wiki term selection

Term selection is a forced tool call rather than free text: measured 5/5
against 2/7 for a free-text extraction call, because a tool argument is
structured output and survives the hidden reasoning eating the budget.
With tool_choice auto the model skipped the search on exactly the
questions it was most likely to get wrong.

Failures are reported through an optional warn consumer, as Wiki does,
and the endpoint is injectable for MiniMax's regional hosts.

Warnings are redacted of the key: a key with an embedded newline makes
the JDK throw invalid header value: "Bearer sk-...", quoting the whole
value back, and that lands in the generic call-failure path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jSvSTrG4qsniLSr6TpC6
This commit is contained in:
marcos
2026-08-05 16:00:46 +00:00
parent 62497556a5
commit 6e0167cff1
2 changed files with 537 additions and 0 deletions
@@ -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.
*
* <p>Two calls per grounded question: {@link #searchTerm} makes the model name
* a wiki article, then {@link #answer} answers with that article in context.
*
* <p>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<String> 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<String> warn) {
this.fetcher = fetcher;
this.url = url == null || url.isBlank() ? URL : url;
this.warn = warn;
}
/**
* Asks the model which wiki article to read.
*
* <p>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.
*
* <p>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<Msg> 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<Msg> 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.
*
* <p>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;
}
}
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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"));
}
}