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;
}
}