Files
canalhandia/src/main/java/dev/marcospaulo/canalhandia/MiniMax.java
T
masi dafd96a4b6 i18n: per-player EN/PT via Adventure GlobalTranslator (#1)
Foundation + commands module of the i18n spec.

- I18n registry/loader + Lang.tr facade + reloadI18n
- PT source-of-truth bundle + EN translation
- CanalhandiaCommand player-facing strings migrated; admin-tuning/help/enum-labels deferred
- I18nTest: parity + per-locale render + pt_BR fallback; 316/316 green

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-12 15:53:27 +00:00

352 lines
16 KiB
Java

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.
*
* <p>Named {@code Turn} and not {@code Msg} because the package already has
* a top-level {@link Msg}, the chat-formatting helper. A nested record of
* that name would shadow it inside this file, so a later {@code Msg.error}
* here would resolve to the record instead.
*/
record Turn(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 Turn("system", "Você escolhe qual artigo da Minecraft Wiki consultar."),
new Turn("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<Turn> 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");
// isJsonPrimitive before getAsString: on an object or array that call
// throws UnsupportedOperationException, and this runs outside post()'s
// try. Some OpenAI-compatible servers return content as an array of
// parts rather than a string.
if (content == null || !content.isJsonPrimitive() || 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();
}
/** Runs a tool the model asked for and returns its result text. */
@FunctionalInterface
interface ToolExecutor {
String run(String name, String argumentsJson);
}
/**
* Answers with tools: the model may call the given tools, whose results are
* fed back until it produces a final answer or {@code maxCalls} rounds pass.
*
* <p>The last round is deliberately sent tool-free, so a model that keeps
* asking for tools instead of answering is still forced to produce prose
* rather than looping forever. Every failure returns null, like {@link
* #answer}, so the caller cannot tell a broken loop from "no answer".
*/
String answerWithTools(String key, String model, List<Turn> initial, JsonArray tools,
ToolExecutor executor, int maxTokens, double temperature, int maxCalls) {
JsonArray messages = new JsonArray();
for (Turn turn : initial) {
JsonObject object = new JsonObject();
object.addProperty("role", turn.role());
object.addProperty("content", turn.content());
messages.add(object);
}
for (int round = 0; round <= maxCalls; round++) {
boolean lastRound = round == maxCalls;
JsonObject body = new JsonObject();
body.addProperty("model", model);
body.add("messages", messages);
body.addProperty("max_tokens", maxTokens);
body.addProperty("temperature", temperature);
if (!lastRound) {
body.add("tools", tools);
body.addProperty("tool_choice", "auto");
}
JsonObject message = message(post(key, body));
if (message == null) {
return null;
}
JsonElement calls = message.get("tool_calls");
boolean hasCalls = calls != null && calls.isJsonArray() && !calls.getAsJsonArray().isEmpty();
if (lastRound || !hasCalls) {
JsonElement content = message.get("content");
if (content != null && content.isJsonPrimitive() && !content.getAsString().isBlank()) {
return content.getAsString();
}
if (lastRound) {
warn.accept("IA: sem resposta após " + maxCalls + " rodadas de ferramenta.");
}
return null;
}
// Append the assistant turn (carrying its tool_calls) verbatim, then
// one tool result per call. Some servers reject a null content on an
// assistant turn, so an empty string stands in.
JsonObject assistant = message.deepCopy();
if (!assistant.has("content") || assistant.get("content").isJsonNull()) {
assistant.addProperty("content", "");
}
messages.add(assistant);
for (JsonElement element : calls.getAsJsonArray()) {
JsonObject call = element.getAsJsonObject();
String id = call.has("id") ? call.get("id").getAsString() : "";
JsonObject function = call.getAsJsonObject("function");
String name = function.get("name").getAsString();
String arguments = function.has("arguments")
? function.get("arguments").getAsString() : "{}";
String result;
try {
result = executor.run(name, arguments);
} catch (RuntimeException e) {
result = "erro ao executar " + name + ": " + e.getMessage();
}
JsonObject toolMessage = new JsonObject();
toolMessage.addProperty("role", "tool");
toolMessage.addProperty("tool_call_id", id);
toolMessage.addProperty("content", result == null ? "sem resultado." : result);
messages.add(toolMessage);
}
}
return null;
}
private JsonObject base(String model, List<Turn> messages, int maxTokens, double temperature) {
JsonArray array = new JsonArray();
for (Turn 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) {
// The host is named because MiniMax's regional endpoints reject
// each other's keys: without it a wrong-host misconfiguration and
// an expired key produce the same log line.
redacted(key, "IA: falha na chamada a " + url + ": " + 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.
*
* <p>Every step checks the <em>type</em> it is about to assume, not just
* the presence of the key. This is called outside {@link #post}'s try, and
* Gson's {@code getAsJsonObject}/{@code getAsInt} throw unchecked on JSON
* that parses but has the wrong shape — {@code {"base_resp":"texto"}} or
* {@code {"choices":["texto"]}} — which would escape to the async worker as
* a bare stack trace instead of the null this method promises.
*/
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.
JsonElement baseResp = root.get("base_resp");
if (baseResp != null && baseResp.isJsonObject()) {
JsonObject base = baseResp.getAsJsonObject();
JsonElement status = base.get("status_code");
if (status != null && status.isJsonPrimitive() && status.getAsJsonPrimitive().isNumber()
&& status.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;
}
JsonElement first = choices.getAsJsonArray().get(0);
JsonElement message = first.isJsonObject() ? first.getAsJsonObject().get("message") : null;
if (message == null || !message.isJsonObject()) {
warn.accept("IA: choices com formato inesperado: " + AiText.forLog(root.toString()));
return null;
}
return message.getAsJsonObject();
}
}