feat: implement OpenRouterProvider with chat completions and OpenRouter integration
Some checks are pending
Build and Verify / build (push) Waiting to run

This commit is contained in:
katR 2026-09-20 01:29:49 +05:30
parent c5dba5d65d
commit 4981270918
5 changed files with 612 additions and 13 deletions

View file

@ -11,7 +11,7 @@ public class JevPlayerConfigScreen extends Screen {
private final Screen parentScreen; private final Screen parentScreen;
private final JevPlayerConfig config; private final JevPlayerConfig config;
private boolean isTypeSafe; private String provider;
private boolean fairMode; private boolean fairMode;
private EditBox apiKeyEdit; private EditBox apiKeyEdit;
@ -30,12 +30,23 @@ public class JevPlayerConfigScreen extends Screen {
super(Component.literal("JevPlayer Configuration")); super(Component.literal("JevPlayer Configuration"));
this.parentScreen = parentScreen; this.parentScreen = parentScreen;
this.config = JevPlayerClientMod.getInstance().getConfig(); this.config = JevPlayerClientMod.getInstance().getConfig();
this.isTypeSafe = "typesafe".equalsIgnoreCase(config.provider); String current = config.provider != null ? config.provider.trim().toLowerCase() : "";
if ("openrouter".equals(current) || "typesafe".equals(current) || "mock".equals(current)) {
this.provider = current;
} else if (config.resolveApiKey().startsWith("sk-or-")) {
this.provider = "openrouter";
} else {
this.provider = "mock";
}
this.fairMode = config.fairMode; this.fairMode = config.fairMode;
} }
private Component getProviderComponent() { private Component getProviderComponent() {
return Component.literal("Provider: " + (isTypeSafe ? "§bTypeSafe AI (Live)" : "§eMock (Simulated)")); return switch (provider) {
case "openrouter" -> Component.literal("Provider: §dOpenRouter (Live LLM)");
case "typesafe" -> Component.literal("Provider: §bTypeSafe AI (Live)");
default -> Component.literal("Provider: §eMock (Simulated)");
};
} }
private Component getFairModeComponent() { private Component getFairModeComponent() {
@ -49,11 +60,32 @@ public class JevPlayerConfigScreen extends Screen {
int leftX = centerX - fieldW / 2; int leftX = centerX - fieldW / 2;
int startY = 32; int startY = 32;
// Provider Button // Provider Button (cycles: OpenRouter -> TypeSafe AI -> Mock)
this.providerButton = addRenderableWidget( this.providerButton = addRenderableWidget(
Button.builder(getProviderComponent(), btn -> { Button.builder(getProviderComponent(), btn -> {
isTypeSafe = !isTypeSafe; if ("openrouter".equalsIgnoreCase(provider)) {
provider = "typesafe";
} else if ("typesafe".equalsIgnoreCase(provider)) {
provider = "mock";
} else {
provider = "openrouter";
}
btn.setMessage(getProviderComponent()); btn.setMessage(getProviderComponent());
if ("openrouter".equalsIgnoreCase(provider)) {
if (baseUrlEdit != null && (baseUrlEdit.getValue().isEmpty() || baseUrlEdit.getValue().contains("typesafe.ai"))) {
baseUrlEdit.setValue("https://openrouter.ai/api/v1");
}
if (modelEdit != null && (modelEdit.getValue().isEmpty() || modelEdit.getValue().equalsIgnoreCase("jev-latest"))) {
modelEdit.setValue("google/gemini-2.5-flash");
}
} else if ("typesafe".equalsIgnoreCase(provider)) {
if (baseUrlEdit != null && (baseUrlEdit.getValue().isEmpty() || baseUrlEdit.getValue().contains("openrouter.ai"))) {
baseUrlEdit.setValue("https://api.typesafe.ai/v1");
}
if (modelEdit != null && (modelEdit.getValue().isEmpty() || modelEdit.getValue().contains("/"))) {
modelEdit.setValue("jev-latest");
}
}
}).bounds(leftX, startY, fieldW, 20).build() }).bounds(leftX, startY, fieldW, 20).build()
); );
@ -119,7 +151,7 @@ public class JevPlayerConfigScreen extends Screen {
extractor.centeredText(this.font, Component.literal("§6§lJevPlayer Settings"), centerX, 12, 0xFFFFFFFF); extractor.centeredText(this.font, Component.literal("§6§lJevPlayer Settings"), centerX, 12, 0xFFFFFFFF);
extractor.text(this.font, Component.literal("§7API Key (or env TYPESAFE_AI_API_KEY):"), leftX, startY + 25, 0xFFAAAAAA); extractor.text(this.font, Component.literal("§7API Key (OpenRouter or TypeSafe AI):"), leftX, startY + 25, 0xFFAAAAAA);
extractor.text(this.font, Component.literal("§7Model:"), leftX, startY + 61, 0xFFAAAAAA); extractor.text(this.font, Component.literal("§7Model:"), leftX, startY + 61, 0xFFAAAAAA);
extractor.text(this.font, Component.literal("§7Base URL:"), leftX, startY + 97, 0xFFAAAAAA); extractor.text(this.font, Component.literal("§7Base URL:"), leftX, startY + 97, 0xFFAAAAAA);
extractor.text(this.font, Component.literal("§7Min Interval (ms):"), leftX, startY + 169, 0xFFAAAAAA); extractor.text(this.font, Component.literal("§7Min Interval (ms):"), leftX, startY + 169, 0xFFAAAAAA);
@ -139,7 +171,7 @@ public class JevPlayerConfigScreen extends Screen {
private void saveAndClose() { private void saveAndClose() {
try { try {
config.provider = isTypeSafe ? "typesafe" : "mock"; config.provider = provider;
config.apiKey = apiKeyEdit.getValue().trim(); config.apiKey = apiKeyEdit.getValue().trim();
config.model = modelEdit.getValue().trim(); config.model = modelEdit.getValue().trim();
config.baseUrl = baseUrlEdit.getValue().trim(); config.baseUrl = baseUrlEdit.getValue().trim();
@ -160,7 +192,7 @@ public class JevPlayerConfigScreen extends Screen {
if (this.minecraft != null && this.minecraft.player != null) { if (this.minecraft != null && this.minecraft.player != null) {
this.minecraft.player.sendSystemMessage( this.minecraft.player.sendSystemMessage(
Component.literal("§a[JevPlayer] Settings saved! Active Provider: §f" + config.provider + Component.literal("§a[JevPlayer] Settings saved! Active Provider: §f" + config.provider +
(isTypeSafe ? " §7(Key: §e" + config.getRedactedApiKey() + "§7)" : "")) (!"mock".equalsIgnoreCase(config.provider) ? " §7(Key: §e" + config.getRedactedApiKey() + "§7)" : ""))
); );
} }
onClose(); onClose();

View file

@ -64,7 +64,7 @@ public final class JevPlayerConfig {
if (maxSpendUsd < 0) maxSpendUsd = 0.50; if (maxSpendUsd < 0) maxSpendUsd = 0.50;
if (episodeMinutes <= 0) episodeMinutes = 60; if (episodeMinutes <= 0) episodeMinutes = 60;
if (safeDefaultAction == null || safeDefaultAction.isBlank()) safeDefaultAction = "explore"; if (safeDefaultAction == null || safeDefaultAction.isBlank()) safeDefaultAction = "explore";
if (provider == null || (!provider.equalsIgnoreCase("typesafe") && !provider.equalsIgnoreCase("mock"))) { if (provider == null || (!provider.equalsIgnoreCase("typesafe") && !provider.equalsIgnoreCase("openrouter") && !provider.equalsIgnoreCase("mock"))) {
provider = "mock"; provider = "mock";
} }
if (confidenceThresholds == null) { if (confidenceThresholds == null) {
@ -90,10 +90,20 @@ public final class JevPlayerConfig {
if (apiKey != null && !apiKey.isBlank()) { if (apiKey != null && !apiKey.isBlank()) {
return apiKey.trim(); return apiKey.trim();
} }
String envKey = System.getenv(apiKeyEnvVar != null ? apiKeyEnvVar : "TYPESAFE_AI_API_KEY"); if (apiKeyEnvVar != null && !apiKeyEnvVar.isBlank()) {
String envKey = System.getenv(apiKeyEnvVar.trim());
if (envKey != null && !envKey.isBlank()) { if (envKey != null && !envKey.isBlank()) {
return envKey.trim(); return envKey.trim();
} }
}
String orKey = System.getenv("OPENROUTER_API_KEY");
if (orKey != null && !orKey.isBlank()) {
return orKey.trim();
}
String tsKey = System.getenv("TYPESAFE_AI_API_KEY");
if (tsKey != null && !tsKey.isBlank()) {
return tsKey.trim();
}
return ""; return "";
} }

View file

@ -0,0 +1,418 @@
package com.typesafe.jevplayer.core.decision;
import com.google.gson.*;
import com.typesafe.jevplayer.core.config.JevPlayerConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
public final class OpenRouterProvider implements DecisionProvider {
private static final Gson GSON = new Gson();
private final JevPlayerConfig config;
private final String apiKey;
private final HttpClient httpClient;
private final MockProvider fallbackProvider = new MockProvider();
private final LatencyTracker latencyTracker = new LatencyTracker();
private double totalSpendUsd = 0.0;
private boolean spendCapReached = false;
private volatile boolean warmedUp = false;
public OpenRouterProvider(JevPlayerConfig config) {
this.config = config;
this.apiKey = config.resolveApiKey();
this.httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofMillis(Math.max(config.requestTimeoutMs, 3000)))
.build();
}
@Override
public String getName() {
return "openrouter";
}
public String getEffectiveEndpoint() {
String base = config.baseUrl != null && !config.baseUrl.isBlank()
? config.baseUrl.trim()
: "https://openrouter.ai/api/v1";
base = base.replaceAll("/+$", "");
if (base.endsWith("/chat/completions")) {
return base;
}
if (!base.endsWith("/v1")) {
base += "/v1";
}
return base + "/chat/completions";
}
public String getEffectiveModel() {
if (config.model != null && !config.model.isBlank() && !config.model.equalsIgnoreCase("jev-latest")) {
return config.model.trim();
}
return "google/gemini-2.5-flash";
}
@Override
public void warmUp() {
if (warmedUp || apiKey.isEmpty()) return;
warmedUp = true;
Thread.ofVirtual().start(() -> {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(getEffectiveEndpoint()))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("HTTP-Referer", "https://github.com/oiupoyt/JevPlays")
.header("X-Title", "JevPlayer")
.header("User-Agent", "JevPlayer/1.0.0")
.timeout(Duration.ofMillis(Math.max(config.requestTimeoutMs, 3000)))
.POST(HttpRequest.BodyPublishers.ofString(
"{\"model\":\"" + getEffectiveModel() + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1}",
StandardCharsets.UTF_8))
.build();
httpClient.send(request, HttpResponse.BodyHandlers.discarding());
} catch (Exception ignored) {
// Warm up is best-effort
}
});
}
@Override
public DecisionResult decide(DecisionRequest request) {
if (apiKey.isEmpty() || isSpendCapReached()) {
DecisionResult mockResult = fallbackProvider.decide(request);
return new DecisionResult(
mockResult.requestId(),
mockResult.requestTimestampMs(),
mockResult.stateHash(),
mockResult.actionChoice(),
mockResult.inDanger(),
mockResult.progressOk(),
mockResult.urgency(),
mockResult.latencyMs(),
mockResult.gated(),
mockResult.originalActionBeforeGating(),
mockResult.providerName() + (isSpendCapReached() ? "(spend_cap)" : "(no_key)"),
0.0,
mockResult.inputTokens(),
true
);
}
long startTime = System.currentTimeMillis();
String payload = buildChatCompletionPayload(request.state().toPromptText(), request.legalActionCriteria());
int estimatedInputTokens = Math.max(1, payload.length() / 4);
int maxRetries = 1;
int attempt = 0;
while (attempt <= maxRetries) {
try {
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create(getEffectiveEndpoint()))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("HTTP-Referer", "https://github.com/oiupoyt/JevPlays")
.header("X-Title", "JevPlayer")
.header("User-Agent", "JevPlayer/1.0.0")
.timeout(Duration.ofMillis(Math.max(config.requestTimeoutMs, 3000)))
.POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
int status = response.statusCode();
if (status == 200) {
long latency = System.currentTimeMillis() - startTime;
latencyTracker.recordLatency(latency);
DecisionResult parsed = parseResponse(request, response.body(), latency, estimatedInputTokens);
recordSpend(parsed.estimatedCostUsd());
return parsed;
} else if (status == 429 || status >= 500) {
if (attempt < maxRetries) {
Thread.sleep(200);
attempt++;
continue;
}
latencyTracker.recordError();
break;
} else {
latencyTracker.recordError();
break;
}
} catch (Exception e) {
if (attempt < maxRetries) {
attempt++;
try {
Thread.sleep(100);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
} else {
latencyTracker.recordError();
break;
}
}
}
// Fallback on network/API failure
DecisionResult fallbackResult = fallbackProvider.decide(request);
return new DecisionResult(
fallbackResult.requestId(),
fallbackResult.requestTimestampMs(),
fallbackResult.stateHash(),
fallbackResult.actionChoice(),
fallbackResult.inDanger(),
fallbackResult.progressOk(),
fallbackResult.urgency(),
System.currentTimeMillis() - startTime,
fallbackResult.gated(),
fallbackResult.originalActionBeforeGating(),
"openrouter(fallback)",
0.0,
estimatedInputTokens,
true
);
}
public String buildChatCompletionPayload(String stateText, Map<String, String> criteria) {
JsonObject root = new JsonObject();
root.addProperty("model", getEffectiveModel());
root.addProperty("temperature", 0.2);
root.addProperty("max_tokens", 150);
JsonObject responseFormat = new JsonObject();
responseFormat.addProperty("type", "json_object");
root.add("response_format", responseFormat);
JsonArray messages = new JsonArray();
// System message
JsonObject sysMsg = new JsonObject();
sysMsg.addProperty("role", "system");
sysMsg.addProperty("content",
"You are JevPlayer, an autonomous AI player controlling Minecraft. " +
"Analyze the player's vitals, inventory, surroundings, and active goal, then select the best action from the provided LEGAL ACTIONS list. " +
"You must respond with valid JSON matching this schema:\n" +
"{\n" +
" \"action\": \"<one of the legal action IDs>\",\n" +
" \"confidence\": <float 0.0 to 1.0>,\n" +
" \"in_danger\": <float 0.0 to 1.0 (1.0 = immediate death risk, 0.0 = safe)>,\n" +
" \"progress_ok\": <float 0.0 to 1.0 (1.0 = progressing towards goal, 0.0 = stuck/idling)>,\n" +
" \"urgency\": <integer 0 (idle fine), 1 (soon), or 2 (immediate crisis)>,\n" +
" \"reasoning\": \"<short explanation>\"\n" +
"}"
);
messages.add(sysMsg);
// User message
JsonObject userMsg = new JsonObject();
userMsg.addProperty("role", "user");
StringBuilder userContent = new StringBuilder();
userContent.append("GAME STATE:\n").append(stateText).append("\n\n");
userContent.append("LEGAL ACTIONS (choose exactly one):\n");
for (Map.Entry<String, String> entry : criteria.entrySet()) {
userContent.append("- ").append(entry.getKey()).append(": ").append(entry.getValue()).append("\n");
}
userContent.append("\nReturn JSON only.");
userMsg.addProperty("content", userContent.toString());
messages.add(userMsg);
root.add("messages", messages);
return GSON.toJson(root);
}
public DecisionResult parseResponse(DecisionRequest request, String responseBody, long latencyMs, int fallbackInputTokens) {
JsonObject json = JsonParser.parseString(responseBody).getAsJsonObject();
String content = "";
if (json.has("choices") && json.get("choices").isJsonArray()) {
JsonArray choices = json.getAsJsonArray("choices");
if (!choices.isEmpty() && choices.get(0).isJsonObject()) {
JsonObject choice0 = choices.get(0).getAsJsonObject();
if (choice0.has("message") && choice0.get("message").isJsonObject()) {
JsonObject msg = choice0.getAsJsonObject("message");
if (msg.has("content") && !msg.get("content").isJsonNull()) {
content = msg.get("content").getAsString();
}
}
}
}
JsonObject parsed = new JsonObject();
try {
parsed = JsonParser.parseString(cleanJsonText(content)).getAsJsonObject();
} catch (Exception ignored) {
// Handle malformed JSON
}
// 1. Action
String rawAction = parsed.has("action") && !parsed.get("action").isJsonNull()
? parsed.get("action").getAsString().trim().toLowerCase()
: "hold";
Map<String, String> legal = request.legalActionCriteria();
String selectedAction = "hold";
if (legal.containsKey(rawAction)) {
selectedAction = rawAction;
} else {
for (String key : legal.keySet()) {
if (key.equalsIgnoreCase(rawAction) || rawAction.contains(key) || key.contains(rawAction)) {
selectedAction = key;
break;
}
}
if (!legal.containsKey(selectedAction)) {
selectedAction = legal.containsKey("explore") ? "explore"
: (legal.isEmpty() ? "hold" : legal.keySet().iterator().next());
}
}
// Confidence
double confidence = 0.85;
if (parsed.has("confidence") && !parsed.get("confidence").isJsonNull()) {
try {
confidence = parsed.get("confidence").getAsDouble();
} catch (Exception ignored) {}
}
confidence = Math.max(0.0, Math.min(1.0, confidence));
Map<String, Double> probs = new HashMap<>();
probs.put(selectedAction, confidence);
ChoiceAnswer choiceAnswer = new ChoiceAnswer(selectedAction, probs, confidence);
// 2. In danger
double dangerVal = 0.0;
if (parsed.has("in_danger") && !parsed.get("in_danger").isJsonNull()) {
JsonElement dElem = parsed.get("in_danger");
if (dElem.isJsonPrimitive() && dElem.getAsJsonPrimitive().isBoolean()) {
dangerVal = dElem.getAsBoolean() ? 0.9 : 0.1;
} else {
try {
dangerVal = dElem.getAsDouble();
} catch (Exception ignored) {}
}
}
dangerVal = Math.max(0.0, Math.min(1.0, dangerVal));
NoulAnswer inDanger = new NoulAnswer(dangerVal, 0.85);
// 3. Progress ok
double progressVal = 0.8;
if (parsed.has("progress_ok") && !parsed.get("progress_ok").isJsonNull()) {
JsonElement pElem = parsed.get("progress_ok");
if (pElem.isJsonPrimitive() && pElem.getAsJsonPrimitive().isBoolean()) {
progressVal = pElem.getAsBoolean() ? 0.9 : 0.2;
} else {
try {
progressVal = pElem.getAsDouble();
} catch (Exception ignored) {}
}
}
progressVal = Math.max(0.0, Math.min(1.0, progressVal));
NoulAnswer progressOk = new NoulAnswer(progressVal, 0.85);
// 4. Urgency
int urgencyVal = 0;
if (parsed.has("urgency") && !parsed.get("urgency").isJsonNull()) {
try {
urgencyVal = parsed.get("urgency").getAsInt();
} catch (Exception ignored) {}
}
urgencyVal = Math.max(0, Math.min(2, urgencyVal));
ScoreAnswer urgency = ScoreAnswer.of(urgencyVal);
// 5. Usage & cost
int inputTokens = fallbackInputTokens;
double costUsd = 0.0;
if (json.has("usage") && json.get("usage").isJsonObject()) {
JsonObject usage = json.getAsJsonObject("usage");
if (usage.has("prompt_tokens") && !usage.get("prompt_tokens").isJsonNull()) {
inputTokens = usage.get("prompt_tokens").getAsInt();
}
if (usage.has("cost") && !usage.get("cost").isJsonNull()) {
costUsd = usage.get("cost").getAsDouble();
} else if (usage.has("cost_details") && usage.get("cost_details").isJsonObject()) {
JsonObject cd = usage.getAsJsonObject("cost_details");
if (cd.has("upstream_inference_cost") && !cd.get("upstream_inference_cost").isJsonNull()) {
costUsd = cd.get("upstream_inference_cost").getAsDouble();
}
}
}
if (costUsd == 0.0) {
costUsd = (inputTokens / 1_000_000.0) * 0.075;
}
return new DecisionResult(
request.requestId(),
request.timestampMs(),
request.stateHash(),
choiceAnswer,
inDanger,
progressOk,
urgency,
latencyMs,
false,
selectedAction,
"openrouter",
costUsd,
inputTokens,
false
);
}
public static String cleanJsonText(String raw) {
if (raw == null) return "{}";
String trimmed = raw.trim();
if (trimmed.startsWith("```")) {
int firstNewline = trimmed.indexOf('\n');
if (firstNewline != -1) {
trimmed = trimmed.substring(firstNewline + 1);
}
if (trimmed.endsWith("```")) {
trimmed = trimmed.substring(0, trimmed.length() - 3);
}
trimmed = trimmed.trim();
}
return trimmed;
}
private synchronized void recordSpend(double costUsd) {
totalSpendUsd += costUsd;
if (totalSpendUsd >= config.maxSpendUsd) {
spendCapReached = true;
}
}
@Override
public synchronized double getTotalSpendUsd() {
return totalSpendUsd;
}
@Override
public synchronized boolean isSpendCapReached() {
return spendCapReached;
}
@Override
public long getRollingP50LatencyMs() {
return latencyTracker.getP50();
}
@Override
public long getRollingP95LatencyMs() {
return latencyTracker.getP95();
}
@Override
public double getErrorRate() {
return latencyTracker.getErrorRate();
}
}

View file

@ -93,7 +93,14 @@ public final class JevPlayerEngine implements AutoCloseable {
} }
private void initProvider() { private void initProvider() {
if ("typesafe".equalsIgnoreCase(config.provider) && !config.resolveApiKey().isEmpty()) { String effectiveKey = config.resolveApiKey();
boolean isOpenRouter = "openrouter".equalsIgnoreCase(config.provider)
|| effectiveKey.startsWith("sk-or-")
|| (config.baseUrl != null && config.baseUrl.contains("openrouter.ai"));
if (isOpenRouter && !effectiveKey.isEmpty()) {
this.decisionProvider = new OpenRouterProvider(config);
} else if ("typesafe".equalsIgnoreCase(config.provider) && !effectiveKey.isEmpty()) {
this.decisionProvider = new TypeSafeProvider(config); this.decisionProvider = new TypeSafeProvider(config);
} else { } else {
this.decisionProvider = new MockProvider(); this.decisionProvider = new MockProvider();

View file

@ -0,0 +1,132 @@
package com.typesafe.jevplayer.core;
import com.typesafe.jevplayer.core.config.JevPlayerConfig;
import com.typesafe.jevplayer.core.decision.DecisionRequest;
import com.typesafe.jevplayer.core.decision.DecisionResult;
import com.typesafe.jevplayer.core.decision.OpenRouterProvider;
import com.typesafe.jevplayer.core.state.StateSnapshot;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
public class OpenRouterParsingTest {
@Test
public void testOpenRouterPayloadBuilding() {
JevPlayerConfig config = new JevPlayerConfig();
config.provider = "openrouter";
config.apiKey = "sk-or-v1-testkey";
config.model = "google/gemini-2.5-flash";
OpenRouterProvider provider = new OpenRouterProvider(config);
Map<String, String> criteria = Map.of(
"gather_wood", "Chop trees for logs",
"explore", "Look around"
);
String payload = provider.buildChatCompletionPayload("health=20\nhunger=20", criteria);
assertTrue(payload.contains("\"model\":\"google/gemini-2.5-flash\""));
assertTrue(payload.contains("\"type\":\"json_object\""));
assertTrue(payload.contains("gather_wood: Chop trees for logs"));
assertTrue(payload.contains("LEGAL ACTIONS"));
}
@Test
public void testOpenRouterResponseParsingClean() {
JevPlayerConfig config = new JevPlayerConfig();
config.provider = "openrouter";
config.apiKey = "sk-or-v1-testkey";
OpenRouterProvider provider = new OpenRouterProvider(config);
String openRouterResponse = """
{
"id": "gen-12345",
"model": "google/gemini-2.5-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "{\\"action\\": \\"gather_wood\\", \\"confidence\\": 0.92, \\"in_danger\\": 0.0, \\"progress_ok\\": 0.85, \\"urgency\\": 0, \\"reasoning\\": \\"Need wood for tools\\"}"
}
}
],
"usage": {
"prompt_tokens": 120,
"completion_tokens": 35,
"total_tokens": 155,
"cost": 0.000015
}
}
""";
DecisionRequest req = new DecisionRequest(1, 1000, 42, StateSnapshot.initial(1000), Map.of("gather_wood", "Chop trees"));
DecisionResult result = provider.parseResponse(req, openRouterResponse, 250, 120);
assertNotNull(result);
assertEquals("gather_wood", result.getChosenActionId());
assertEquals(0.92, result.getConfidence(), 0.001);
assertNotNull(result.inDanger());
assertEquals(0.0, result.inDanger().noul(), 0.001);
assertFalse(result.inDanger().isAffirmative());
assertNotNull(result.progressOk());
assertEquals(0.85, result.progressOk().noul(), 0.001);
assertTrue(result.progressOk().isAffirmative());
assertNotNull(result.urgency());
assertEquals(0, result.urgency().score());
assertEquals("openrouter", result.providerName());
assertEquals(120, result.inputTokens());
assertEquals(0.000015, result.estimatedCostUsd(), 0.000001);
assertFalse(result.fallback());
}
@Test
public void testOpenRouterResponseParsingWithMarkdownFences() {
JevPlayerConfig config = new JevPlayerConfig();
OpenRouterProvider provider = new OpenRouterProvider(config);
String markdownResponse = """
{
"id": "gen-67890",
"choices": [
{
"message": {
"role": "assistant",
"content": "```json\\n{\\"action\\": \\"explore\\", \\"confidence\\": 0.88, \\"in_danger\\": false, \\"progress_ok\\": true, \\"urgency\\": 1}\\n```"
}
}
]
}
""";
DecisionRequest req = new DecisionRequest(2, 2000, 84, StateSnapshot.initial(2000), Map.of("explore", "Wander"));
DecisionResult result = provider.parseResponse(req, markdownResponse, 180, 100);
assertNotNull(result);
assertEquals("explore", result.getChosenActionId());
assertEquals(0.88, result.getConfidence(), 0.001);
assertEquals(0.1, result.inDanger().noul(), 0.001);
assertEquals(0.9, result.progressOk().noul(), 0.001);
assertEquals(1, result.urgency().score());
}
@Test
public void testEffectiveEndpoint() {
JevPlayerConfig config = new JevPlayerConfig();
config.baseUrl = "https://openrouter.ai/api/v1";
OpenRouterProvider p1 = new OpenRouterProvider(config);
assertEquals("https://openrouter.ai/api/v1/chat/completions", p1.getEffectiveEndpoint());
config.baseUrl = "https://openrouter.ai/api/";
OpenRouterProvider p2 = new OpenRouterProvider(config);
assertEquals("https://openrouter.ai/api/v1/chat/completions", p2.getEffectiveEndpoint());
config.baseUrl = "https://openrouter.ai/api/v1/chat/completions";
OpenRouterProvider p3 = new OpenRouterProvider(config);
assertEquals("https://openrouter.ai/api/v1/chat/completions", p3.getEffectiveEndpoint());
}
}