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 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();
|
||||
|
|
|
|||
|
|
@ -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,10 +90,20 @@ public final class JevPlayerConfig {
|
|||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
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()) {
|
||||
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 "";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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