diff --git a/adapter-26_2/src/main/java/com/typesafe/jevplayer/adapter26_2/JevPlayerConfigScreen.java b/adapter-26_2/src/main/java/com/typesafe/jevplayer/adapter26_2/JevPlayerConfigScreen.java index 6122f40..39ae574 100644 --- a/adapter-26_2/src/main/java/com/typesafe/jevplayer/adapter26_2/JevPlayerConfigScreen.java +++ b/adapter-26_2/src/main/java/com/typesafe/jevplayer/adapter26_2/JevPlayerConfigScreen.java @@ -11,7 +11,7 @@ public class JevPlayerConfigScreen extends Screen { private final Screen parentScreen; private final JevPlayerConfig config; - private boolean isTypeSafe; + private String provider; private boolean fairMode; private EditBox apiKeyEdit; @@ -30,12 +30,23 @@ public class JevPlayerConfigScreen extends Screen { super(Component.literal("JevPlayer Configuration")); this.parentScreen = parentScreen; 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; } 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() { @@ -49,11 +60,32 @@ public class JevPlayerConfigScreen extends Screen { int leftX = centerX - fieldW / 2; int startY = 32; - // Provider Button + // Provider Button (cycles: OpenRouter -> TypeSafe AI -> Mock) this.providerButton = addRenderableWidget( 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()); + 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() ); @@ -119,7 +151,7 @@ public class JevPlayerConfigScreen extends Screen { 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("§7Base URL:"), leftX, startY + 97, 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() { try { - config.provider = isTypeSafe ? "typesafe" : "mock"; + config.provider = provider; config.apiKey = apiKeyEdit.getValue().trim(); config.model = modelEdit.getValue().trim(); config.baseUrl = baseUrlEdit.getValue().trim(); @@ -160,7 +192,7 @@ public class JevPlayerConfigScreen extends Screen { if (this.minecraft != null && this.minecraft.player != null) { this.minecraft.player.sendSystemMessage( 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(); diff --git a/core/src/main/java/com/typesafe/jevplayer/core/config/JevPlayerConfig.java b/core/src/main/java/com/typesafe/jevplayer/core/config/JevPlayerConfig.java index c4980f3..d0c0257 100644 --- a/core/src/main/java/com/typesafe/jevplayer/core/config/JevPlayerConfig.java +++ b/core/src/main/java/com/typesafe/jevplayer/core/config/JevPlayerConfig.java @@ -64,7 +64,7 @@ public final class JevPlayerConfig { if (maxSpendUsd < 0) maxSpendUsd = 0.50; if (episodeMinutes <= 0) episodeMinutes = 60; 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"; } if (confidenceThresholds == null) { @@ -90,9 +90,19 @@ public final class JevPlayerConfig { if (apiKey != null && !apiKey.isBlank()) { return apiKey.trim(); } - String envKey = System.getenv(apiKeyEnvVar != null ? apiKeyEnvVar : "TYPESAFE_AI_API_KEY"); - if (envKey != null && !envKey.isBlank()) { - return envKey.trim(); + if (apiKeyEnvVar != null && !apiKeyEnvVar.isBlank()) { + String envKey = System.getenv(apiKeyEnvVar.trim()); + if (envKey != null && !envKey.isBlank()) { + 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 ""; } diff --git a/core/src/main/java/com/typesafe/jevplayer/core/decision/OpenRouterProvider.java b/core/src/main/java/com/typesafe/jevplayer/core/decision/OpenRouterProvider.java new file mode 100644 index 0000000..64dbb9e --- /dev/null +++ b/core/src/main/java/com/typesafe/jevplayer/core/decision/OpenRouterProvider.java @@ -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 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 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\": \"\",\n" + + " \"confidence\": ,\n" + + " \"in_danger\": ,\n" + + " \"progress_ok\": ,\n" + + " \"urgency\": ,\n" + + " \"reasoning\": \"\"\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 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 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 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(); + } +} diff --git a/core/src/main/java/com/typesafe/jevplayer/core/engine/JevPlayerEngine.java b/core/src/main/java/com/typesafe/jevplayer/core/engine/JevPlayerEngine.java index 7bda4c9..6c89f5a 100644 --- a/core/src/main/java/com/typesafe/jevplayer/core/engine/JevPlayerEngine.java +++ b/core/src/main/java/com/typesafe/jevplayer/core/engine/JevPlayerEngine.java @@ -93,7 +93,14 @@ public final class JevPlayerEngine implements AutoCloseable { } 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); } else { this.decisionProvider = new MockProvider(); diff --git a/core/src/test/java/com/typesafe/jevplayer/core/OpenRouterParsingTest.java b/core/src/test/java/com/typesafe/jevplayer/core/OpenRouterParsingTest.java new file mode 100644 index 0000000..f1dfc6b --- /dev/null +++ b/core/src/test/java/com/typesafe/jevplayer/core/OpenRouterParsingTest.java @@ -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 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()); + } +}