feat: implement OpenRouterProvider with chat completions and OpenRouter integration
Some checks are pending
Build and Verify / build (push) Waiting to run
Some checks are pending
Build and Verify / build (push) Waiting to run
This commit is contained in:
parent
c5dba5d65d
commit
4981270918
5 changed files with 612 additions and 13 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,9 +90,19 @@ 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()) {
|
||||||
if (envKey != null && !envKey.isBlank()) {
|
String envKey = System.getenv(apiKeyEnvVar.trim());
|
||||||
return envKey.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 "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in a new issue