diff --git a/agenteval-gradle-plugin/src/test/java/com/agenteval/gradle/AgentEvalPluginTest.java b/agenteval-gradle-plugin/src/test/java/com/agenteval/gradle/AgentEvalPluginTest.java index 7685f22..c9b450d 100644 --- a/agenteval-gradle-plugin/src/test/java/com/agenteval/gradle/AgentEvalPluginTest.java +++ b/agenteval-gradle-plugin/src/test/java/com/agenteval/gradle/AgentEvalPluginTest.java @@ -31,8 +31,7 @@ void taskRegisteredWithCorrectTypeAndGroup() { Project project = ProjectBuilder.builder().build(); project.getPluginManager().apply("com.agenteval.evaluate"); - var task = project.getTasks().findByName("agentEvaluate"); - assertThat(task).isNotNull(); + var task = project.getTasks().getByName("agentEvaluate"); assertThat(task).isInstanceOf(EvaluateTask.class); assertThat(task.getGroup()).isEqualTo("verification"); } @@ -43,8 +42,7 @@ void extensionDefaultValues() { project.getPluginManager().apply("com.agenteval.evaluate"); AgentEvalExtension ext = project.getExtensions() - .findByType(AgentEvalExtension.class); - assertThat(ext).isNotNull(); + .getByType(AgentEvalExtension.class); assertThat(ext.getConfigFile().get()).isEqualTo("agenteval.yaml"); assertThat(ext.getReportFormats().get()).isEqualTo("console,json"); diff --git a/agenteval-gradle-plugin/src/test/java/com/agenteval/gradle/EvaluateTaskTest.java b/agenteval-gradle-plugin/src/test/java/com/agenteval/gradle/EvaluateTaskTest.java index ac04cca..9eb4e28 100644 --- a/agenteval-gradle-plugin/src/test/java/com/agenteval/gradle/EvaluateTaskTest.java +++ b/agenteval-gradle-plugin/src/test/java/com/agenteval/gradle/EvaluateTaskTest.java @@ -19,7 +19,7 @@ static void checkEnvironment() { try { Project project = ProjectBuilder.builder().build(); project.getPluginManager().apply("com.agenteval.evaluate"); - EvaluateTask task = (EvaluateTask) project.getTasks().findByName("agentEvaluate"); + EvaluateTask task = (EvaluateTask) project.getTasks().getByName("agentEvaluate"); gradleTaskCreationSupported = task != null; } catch (Exception e) { gradleTaskCreationSupported = false; @@ -34,8 +34,7 @@ void taskDefaultPropertyValues() { Project project = ProjectBuilder.builder().build(); project.getPluginManager().apply("com.agenteval.evaluate"); - EvaluateTask task = (EvaluateTask) project.getTasks().findByName("agentEvaluate"); - assertThat(task).isNotNull(); + EvaluateTask task = (EvaluateTask) project.getTasks().getByName("agentEvaluate"); assertThat(task.getConfigFile().get()).isEqualTo("agenteval.yaml"); assertThat(task.getReportFormats().get()).isEqualTo("console,json"); @@ -53,8 +52,7 @@ void datasetPathRequiredValidation() { Project project = ProjectBuilder.builder().build(); project.getPluginManager().apply("com.agenteval.evaluate"); - EvaluateTask task = (EvaluateTask) project.getTasks().findByName("agentEvaluate"); - assertThat(task).isNotNull(); + EvaluateTask task = (EvaluateTask) project.getTasks().getByName("agentEvaluate"); assertThat(task.getDatasetPath().isPresent()).isFalse(); } @@ -68,14 +66,12 @@ void extensionOverridesWireToTask() { project.getPluginManager().apply("com.agenteval.evaluate"); AgentEvalExtension ext = project.getExtensions() - .findByType(AgentEvalExtension.class); - assertThat(ext).isNotNull(); + .getByType(AgentEvalExtension.class); ext.getMetrics().set("Faithfulness,Correctness"); ext.getThreshold().set(0.8); - EvaluateTask task = (EvaluateTask) project.getTasks().findByName("agentEvaluate"); - assertThat(task).isNotNull(); + EvaluateTask task = (EvaluateTask) project.getTasks().getByName("agentEvaluate"); assertThat(task.getMetrics().get()).isEqualTo("Faithfulness,Correctness"); assertThat(task.getThreshold().get()).isEqualTo(0.8); } diff --git a/agenteval-judge/src/main/java/com/agenteval/judge/JudgeModels.java b/agenteval-judge/src/main/java/com/agenteval/judge/JudgeModels.java index d15d9d2..28220f9 100644 --- a/agenteval-judge/src/main/java/com/agenteval/judge/JudgeModels.java +++ b/agenteval-judge/src/main/java/com/agenteval/judge/JudgeModels.java @@ -4,6 +4,10 @@ import com.agenteval.judge.config.JudgeConfig; import com.agenteval.judge.multi.MultiModelJudge; import com.agenteval.judge.provider.AnthropicJudgeModel; +import com.agenteval.judge.provider.AzureOpenAiJudgeModel; +import com.agenteval.judge.provider.BedrockJudgeModel; +import com.agenteval.judge.provider.CustomHttpJudgeModel; +import com.agenteval.judge.provider.GoogleJudgeModel; import com.agenteval.judge.provider.OllamaJudgeModel; import com.agenteval.judge.provider.OpenAiJudgeModel; @@ -15,20 +19,24 @@ *
{@code
  * var judge = JudgeModels.openai("gpt-4o");
  * var judge = JudgeModels.anthropic("claude-sonnet-4-20250514");
+ * var judge = JudgeModels.google("gemini-1.5-pro");
+ * var judge = JudgeModels.azure(JudgeConfig.builder()
+ *     .apiKey("...").model("my-deployment").baseUrl("https://myresource.openai.azure.com").build());
+ * var judge = JudgeModels.bedrock("anthropic.claude-3-sonnet-20240229-v1:0");
+ * var judge = JudgeModels.custom(JudgeConfig.builder()
+ *     .model("my-model").baseUrl("http://localhost:8000").build());
  * var judge = JudgeModels.ollama("llama3");
- * var judge = JudgeModels.openai(JudgeConfig.builder()
- *     .apiKey("sk-...")
- *     .model("gpt-4o")
- *     .baseUrl("https://api.openai.com")
- *     .build());
  * }
*/ public final class JudgeModels { private static final String OPENAI_API_KEY_ENV = "OPENAI_API_KEY"; private static final String ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY"; + private static final String GOOGLE_API_KEY_ENV = "GOOGLE_API_KEY"; private static final String OPENAI_BASE_URL = "https://api.openai.com"; private static final String ANTHROPIC_BASE_URL = "https://api.anthropic.com"; + private static final String GOOGLE_BASE_URL = "https://generativelanguage.googleapis.com"; + private static final String BEDROCK_BASE_URL = "https://bedrock-runtime.us-east-1.amazonaws.com"; private static final String OLLAMA_BASE_URL = "http://localhost:11434"; private JudgeModels() {} @@ -71,6 +79,76 @@ public static JudgeModel anthropic(JudgeConfig config) { return new AnthropicJudgeModel(config); } + /** + * Creates a Google Gemini judge model using the given model ID. + * API key is resolved from the {@code GOOGLE_API_KEY} environment variable. + */ + public static JudgeModel google(String model) { + return google(JudgeConfig.builder() + .apiKey(resolveApiKey(GOOGLE_API_KEY_ENV, "Google")) + .model(model) + .baseUrl(GOOGLE_BASE_URL) + .build()); + } + + /** + * Creates a Google Gemini judge model with full configuration. + */ + public static JudgeModel google(JudgeConfig config) { + return new GoogleJudgeModel(config); + } + + /** + * Creates an Azure OpenAI judge model using the given deployment name. + * API key is resolved from the {@code AZURE_OPENAI_API_KEY} environment variable. + * The base URL must point to your Azure OpenAI resource + * (e.g., {@code https://myresource.openai.azure.com}). + */ + public static JudgeModel azure(JudgeConfig config) { + return new AzureOpenAiJudgeModel(config); + } + + /** + * Creates an Azure OpenAI judge model with a specific API version. + */ + public static JudgeModel azure(JudgeConfig config, String apiVersion) { + return new AzureOpenAiJudgeModel(config, apiVersion); + } + + /** + * Creates an Amazon Bedrock judge model using the given model ID. + * AWS credentials are resolved from {@code AWS_ACCESS_KEY_ID} and + * {@code AWS_SECRET_ACCESS_KEY} environment variables. + * + * @param model the Bedrock model ID (e.g., {@code anthropic.claude-3-sonnet-20240229-v1:0}) + */ + public static JudgeModel bedrock(String model) { + return bedrock(JudgeConfig.builder() + .model(model) + .baseUrl(BEDROCK_BASE_URL) + .build()); + } + + /** + * Creates an Amazon Bedrock judge model with full configuration. + * The base URL determines the AWS region + * (e.g., {@code https://bedrock-runtime.eu-west-1.amazonaws.com}). + */ + public static JudgeModel bedrock(JudgeConfig config) { + return new BedrockJudgeModel(config); + } + + /** + * Creates a custom HTTP judge model for any OpenAI-compatible endpoint. + * No API key required — if one is set in the config, it will be sent + * as a {@code Bearer} token. + * + *

Use this for vLLM, LiteLLM, LocalAI, or any OpenAI-compatible server.

+ */ + public static JudgeModel custom(JudgeConfig config) { + return new CustomHttpJudgeModel(config); + } + /** * Creates an Ollama judge model using the given model ID. * Defaults to {@code localhost:11434}. No API key required. diff --git a/agenteval-judge/src/main/java/com/agenteval/judge/provider/AzureOpenAiJudgeModel.java b/agenteval-judge/src/main/java/com/agenteval/judge/provider/AzureOpenAiJudgeModel.java new file mode 100644 index 0000000..ebdf8ce --- /dev/null +++ b/agenteval-judge/src/main/java/com/agenteval/judge/provider/AzureOpenAiJudgeModel.java @@ -0,0 +1,111 @@ +package com.agenteval.judge.provider; + +import com.agenteval.core.model.TokenUsage; +import com.agenteval.judge.JudgeException; +import com.agenteval.judge.config.JudgeConfig; +import com.agenteval.judge.http.HttpJudgeClient; +import com.agenteval.judge.http.HttpJudgeRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.Map; + +/** + * Azure OpenAI judge model provider. + * + *

Sends requests to Azure's OpenAI-compatible endpoint at + * {@code {baseUrl}/openai/deployments/{model}/chat/completions?api-version=...} + * with {@code api-key} header authentication.

+ * + *

The {@link JudgeConfig#getModel()} value is used as the deployment name. + * The API version defaults to {@code 2024-02-01} but can be customized + * by appending it to the base URL.

+ */ +public final class AzureOpenAiJudgeModel extends AbstractHttpJudgeModel { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String DEFAULT_API_VERSION = "2024-02-01"; + private static final String COMPLETIONS_PATH = + "/openai/deployments/%s/chat/completions?api-version=%s"; + private static final String SYSTEM_PROMPT = + "You are an evaluation judge. Respond ONLY with a JSON object " + + "containing \"score\" (a number between 0.0 and 1.0) " + + "and \"reason\" (a brief explanation)."; + + private final String apiVersion; + + public AzureOpenAiJudgeModel(JudgeConfig config) { + this(config, DEFAULT_API_VERSION); + } + + public AzureOpenAiJudgeModel(JudgeConfig config, String apiVersion) { + super(config); + if (config.getApiKey() == null || config.getApiKey().isBlank()) { + throw new JudgeException("Azure OpenAI requires a non-null API key"); + } + this.apiVersion = apiVersion != null ? apiVersion : DEFAULT_API_VERSION; + } + + AzureOpenAiJudgeModel(JudgeConfig config, String apiVersion, HttpJudgeClient client) { + super(config, client); + this.apiVersion = apiVersion != null ? apiVersion : DEFAULT_API_VERSION; + } + + String getApiVersion() { + return apiVersion; + } + + @Override + protected HttpJudgeRequest buildRequest(String prompt) { + try { + var body = MAPPER.createObjectNode(); + body.put("temperature", config.getTemperature()); + + var responseFormat = MAPPER.createObjectNode(); + responseFormat.put("type", "json_object"); + body.set("response_format", responseFormat); + + var messages = body.putArray("messages"); + + var systemMsg = messages.addObject(); + systemMsg.put("role", "system"); + systemMsg.put("content", SYSTEM_PROMPT); + + var userMsg = messages.addObject(); + userMsg.put("role", "user"); + userMsg.put("content", prompt); + + String url = config.getBaseUrl() + + String.format(COMPLETIONS_PATH, config.getModel(), apiVersion); + return new HttpJudgeRequest( + url, + Map.of("api-key", config.getApiKey()), + MAPPER.writeValueAsString(body)); + } catch (Exception e) { + throw new JudgeException("Failed to build Azure OpenAI request", e); + } + } + + @Override + protected String extractContent(String responseBody) { + JsonNode root = parseJson(responseBody); + JsonNode choices = root.path("choices"); + if (choices.isEmpty()) { + throw new JudgeException("No choices in Azure OpenAI response"); + } + return choices.get(0).path("message").path("content").asText(""); + } + + @Override + protected TokenUsage extractTokenUsage(String responseBody) { + JsonNode root = parseJson(responseBody); + JsonNode usage = root.path("usage"); + if (usage.isMissingNode()) { + return null; + } + return new TokenUsage( + usage.path("prompt_tokens").asInt(0), + usage.path("completion_tokens").asInt(0), + usage.path("total_tokens").asInt(0)); + } +} diff --git a/agenteval-judge/src/main/java/com/agenteval/judge/provider/BedrockJudgeModel.java b/agenteval-judge/src/main/java/com/agenteval/judge/provider/BedrockJudgeModel.java new file mode 100644 index 0000000..d86c660 --- /dev/null +++ b/agenteval-judge/src/main/java/com/agenteval/judge/provider/BedrockJudgeModel.java @@ -0,0 +1,262 @@ +package com.agenteval.judge.provider; + +import com.agenteval.core.model.TokenUsage; +import com.agenteval.judge.JudgeException; +import com.agenteval.judge.config.JudgeConfig; +import com.agenteval.judge.http.HttpJudgeClient; +import com.agenteval.judge.http.HttpJudgeRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.TreeMap; + +/** + * Amazon Bedrock judge model provider. + * + *

Sends requests to the Bedrock Runtime {@code InvokeModel} API using + * the Anthropic Messages API format (for Claude models on Bedrock). + * Authentication uses AWS Signature Version 4.

+ * + *

Required environment variables: {@code AWS_ACCESS_KEY_ID}, + * {@code AWS_SECRET_ACCESS_KEY}, and optionally {@code AWS_SESSION_TOKEN}. + * The region is extracted from the base URL + * ({@code https://bedrock-runtime.{region}.amazonaws.com}).

+ */ +public final class BedrockJudgeModel extends AbstractHttpJudgeModel { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String DEFAULT_BASE_URL = "https://bedrock-runtime.us-east-1.amazonaws.com"; + private static final String INVOKE_PATH = "/model/%s/invoke"; + private static final String SERVICE = "bedrock"; + private static final String ALGORITHM = "AWS4-HMAC-SHA256"; + private static final String SYSTEM_PROMPT = + "You are an evaluation judge. Respond ONLY with a JSON object " + + "containing \"score\" (a number between 0.0 and 1.0) " + + "and \"reason\" (a brief explanation)."; + + private static final DateTimeFormatter DATE_FORMAT = + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC); + private static final DateTimeFormatter DATETIME_FORMAT = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC); + + private final String accessKeyId; + private final String secretAccessKey; + private final String sessionToken; + private final String region; + + public BedrockJudgeModel(JudgeConfig config) { + this(config, + resolveEnv("AWS_ACCESS_KEY_ID", "Bedrock"), + resolveEnv("AWS_SECRET_ACCESS_KEY", "Bedrock"), + System.getenv("AWS_SESSION_TOKEN")); + } + + public BedrockJudgeModel(JudgeConfig config, + String accessKeyId, String secretAccessKey, + String sessionToken) { + super(config); + if (accessKeyId == null || accessKeyId.isBlank()) { + throw new JudgeException("Bedrock requires AWS_ACCESS_KEY_ID"); + } + if (secretAccessKey == null || secretAccessKey.isBlank()) { + throw new JudgeException("Bedrock requires AWS_SECRET_ACCESS_KEY"); + } + this.accessKeyId = accessKeyId; + this.secretAccessKey = secretAccessKey; + this.sessionToken = sessionToken; + this.region = extractRegion(config.getBaseUrl()); + } + + BedrockJudgeModel(JudgeConfig config, + String accessKeyId, String secretAccessKey, + String sessionToken, + HttpJudgeClient client) { + super(config, client); + this.accessKeyId = accessKeyId; + this.secretAccessKey = secretAccessKey; + this.sessionToken = sessionToken; + this.region = extractRegion(config.getBaseUrl()); + } + + static String defaultBaseUrl() { + return DEFAULT_BASE_URL; + } + + @Override + protected HttpJudgeRequest buildRequest(String prompt) { + try { + var body = MAPPER.createObjectNode(); + body.put("anthropic_version", "bedrock-2023-05-31"); + body.put("max_tokens", 1024); + body.put("temperature", config.getTemperature()); + body.put("system", SYSTEM_PROMPT); + + var messages = body.putArray("messages"); + var userMsg = messages.addObject(); + userMsg.put("role", "user"); + userMsg.put("content", prompt); + + String url = config.getBaseUrl() + + String.format(INVOKE_PATH, config.getModel()); + String payload = MAPPER.writeValueAsString(body); + + Map headers = signRequest(url, payload); + return new HttpJudgeRequest(url, headers, payload); + } catch (JudgeException e) { + throw e; + } catch (Exception e) { + throw new JudgeException("Failed to build Bedrock request", e); + } + } + + @Override + protected String extractContent(String responseBody) { + JsonNode root = parseJson(responseBody); + JsonNode content = root.path("content"); + if (content.isEmpty()) { + throw new JudgeException("No content in Bedrock response"); + } + for (JsonNode block : content) { + if ("text".equals(block.path("type").asText())) { + return block.path("text").asText(""); + } + } + throw new JudgeException("No text block in Bedrock response content"); + } + + @Override + protected TokenUsage extractTokenUsage(String responseBody) { + JsonNode root = parseJson(responseBody); + JsonNode usage = root.path("usage"); + if (usage.isMissingNode()) { + return null; + } + int input = usage.path("input_tokens").asInt(0); + int output = usage.path("output_tokens").asInt(0); + return new TokenUsage(input, output, input + output); + } + + private Map signRequest(String url, String payload) { + try { + Instant now = Instant.now(); + String dateStamp = DATE_FORMAT.format(now); + String amzDate = DATETIME_FORMAT.format(now); + + URI uri = URI.create(url); + String host = uri.getHost(); + String path = uri.getPath(); + + String payloadHash = sha256Hex(payload); + + var signedHeaders = new TreeMap(); + signedHeaders.put("host", host); + signedHeaders.put("x-amz-content-sha256", payloadHash); + signedHeaders.put("x-amz-date", amzDate); + if (sessionToken != null && !sessionToken.isBlank()) { + signedHeaders.put("x-amz-security-token", sessionToken); + } + + String signedHeaderNames = String.join(";", signedHeaders.keySet()); + + StringBuilder canonicalHeaders = new StringBuilder(); + for (var entry : signedHeaders.entrySet()) { + canonicalHeaders.append(entry.getKey()).append(':') + .append(entry.getValue()).append('\n'); + } + + String canonicalRequest = String.join("\n", + "POST", path, "", canonicalHeaders.toString(), + signedHeaderNames, payloadHash); + + String credentialScope = dateStamp + "/" + region + "/" + SERVICE + "/aws4_request"; + String stringToSign = String.join("\n", + ALGORITHM, amzDate, credentialScope, + sha256Hex(canonicalRequest)); + + byte[] signingKey = getSignatureKey(secretAccessKey, dateStamp, region, SERVICE); + String signature = hexEncode(hmacSha256(signingKey, + stringToSign.getBytes(StandardCharsets.UTF_8))); + + String authorization = ALGORITHM + " Credential=" + accessKeyId + "/" + + credentialScope + ", SignedHeaders=" + signedHeaderNames + + ", Signature=" + signature; + + var headers = new TreeMap(); + headers.put("Authorization", authorization); + headers.put("x-amz-content-sha256", payloadHash); + headers.put("x-amz-date", amzDate); + if (sessionToken != null && !sessionToken.isBlank()) { + headers.put("x-amz-security-token", sessionToken); + } + return Map.copyOf(headers); + } catch (Exception e) { + throw new JudgeException("Failed to sign Bedrock request", e); + } + } + + static String extractRegion(String baseUrl) { + // Format: https://bedrock-runtime.{region}.amazonaws.com + try { + String host = URI.create(baseUrl).getHost(); + String[] parts = host.split("\\."); + if (parts.length >= 3) { + return parts[1]; + } + } catch (Exception e) { + // fall through + } + return "us-east-1"; + } + + private static String sha256Hex(String data) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return hexEncode(digest.digest(data.getBytes(StandardCharsets.UTF_8))); + } + + private static byte[] hmacSha256(byte[] key, byte[] data) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data); + } + + private static byte[] getSignatureKey(String key, String dateStamp, + String regionName, String serviceName) + throws Exception { + byte[] kDate = hmacSha256( + ("AWS4" + key).getBytes(StandardCharsets.UTF_8), + dateStamp.getBytes(StandardCharsets.UTF_8)); + byte[] kRegion = hmacSha256(kDate, + regionName.getBytes(StandardCharsets.UTF_8)); + byte[] kService = hmacSha256(kRegion, + serviceName.getBytes(StandardCharsets.UTF_8)); + return hmacSha256(kService, + "aws4_request".getBytes(StandardCharsets.UTF_8)); + } + + private static String hexEncode(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static String resolveEnv(String envVar, String providerName) { + String value = System.getenv(envVar); + if (value == null || value.isBlank()) { + throw new JudgeException( + providerName + " requires " + envVar + + " environment variable"); + } + return value; + } +} diff --git a/agenteval-judge/src/main/java/com/agenteval/judge/provider/CustomHttpJudgeModel.java b/agenteval-judge/src/main/java/com/agenteval/judge/provider/CustomHttpJudgeModel.java new file mode 100644 index 0000000..a259f73 --- /dev/null +++ b/agenteval-judge/src/main/java/com/agenteval/judge/provider/CustomHttpJudgeModel.java @@ -0,0 +1,107 @@ +package com.agenteval.judge.provider; + +import com.agenteval.core.model.TokenUsage; +import com.agenteval.judge.JudgeException; +import com.agenteval.judge.config.JudgeConfig; +import com.agenteval.judge.http.HttpJudgeClient; +import com.agenteval.judge.http.HttpJudgeRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.Map; + +/** + * Custom HTTP judge model provider for any OpenAI-compatible endpoint. + * + *

Points to any self-hosted or third-party API that exposes + * the OpenAI chat completions format. Supports optional API key + * authentication via {@code Authorization: Bearer} header.

+ * + *

Use this provider for vLLM, LiteLLM, LocalAI, or any other + * OpenAI-compatible inference server.

+ * + *
{@code
+ * var judge = JudgeModels.custom(JudgeConfig.builder()
+ *     .model("my-model")
+ *     .baseUrl("http://localhost:8000")
+ *     .build());
+ * }
+ */ +public final class CustomHttpJudgeModel extends AbstractHttpJudgeModel { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String COMPLETIONS_PATH = "/v1/chat/completions"; + private static final String SYSTEM_PROMPT = + "You are an evaluation judge. Respond ONLY with a JSON object " + + "containing \"score\" (a number between 0.0 and 1.0) " + + "and \"reason\" (a brief explanation)."; + + public CustomHttpJudgeModel(JudgeConfig config) { + super(config); + } + + CustomHttpJudgeModel(JudgeConfig config, HttpJudgeClient client) { + super(config, client); + } + + @Override + protected HttpJudgeRequest buildRequest(String prompt) { + try { + var body = MAPPER.createObjectNode(); + body.put("model", config.getModel()); + body.put("temperature", config.getTemperature()); + + var responseFormat = MAPPER.createObjectNode(); + responseFormat.put("type", "json_object"); + body.set("response_format", responseFormat); + + var messages = body.putArray("messages"); + + var systemMsg = messages.addObject(); + systemMsg.put("role", "system"); + systemMsg.put("content", SYSTEM_PROMPT); + + var userMsg = messages.addObject(); + userMsg.put("role", "user"); + userMsg.put("content", prompt); + + String url = config.getBaseUrl() + COMPLETIONS_PATH; + Map headers = buildHeaders(); + return new HttpJudgeRequest(url, headers, + MAPPER.writeValueAsString(body)); + } catch (Exception e) { + throw new JudgeException("Failed to build custom HTTP request", e); + } + } + + @Override + protected String extractContent(String responseBody) { + JsonNode root = parseJson(responseBody); + JsonNode choices = root.path("choices"); + if (choices.isEmpty()) { + throw new JudgeException("No choices in custom HTTP response"); + } + return choices.get(0).path("message").path("content").asText(""); + } + + @Override + protected TokenUsage extractTokenUsage(String responseBody) { + JsonNode root = parseJson(responseBody); + JsonNode usage = root.path("usage"); + if (usage.isMissingNode()) { + return null; + } + return new TokenUsage( + usage.path("prompt_tokens").asInt(0), + usage.path("completion_tokens").asInt(0), + usage.path("total_tokens").asInt(0)); + } + + private Map buildHeaders() { + String apiKey = config.getApiKey(); + if (apiKey != null && !apiKey.isBlank()) { + return Map.of("Authorization", "Bearer " + apiKey); + } + return Map.of(); + } +} diff --git a/agenteval-judge/src/main/java/com/agenteval/judge/provider/GoogleJudgeModel.java b/agenteval-judge/src/main/java/com/agenteval/judge/provider/GoogleJudgeModel.java new file mode 100644 index 0000000..cd50f79 --- /dev/null +++ b/agenteval-judge/src/main/java/com/agenteval/judge/provider/GoogleJudgeModel.java @@ -0,0 +1,103 @@ +package com.agenteval.judge.provider; + +import com.agenteval.core.model.TokenUsage; +import com.agenteval.judge.JudgeException; +import com.agenteval.judge.config.JudgeConfig; +import com.agenteval.judge.http.HttpJudgeClient; +import com.agenteval.judge.http.HttpJudgeRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.Map; + +/** + * Google Gemini judge model provider. + * + *

Sends requests to the Gemini {@code generateContent} API with + * JSON response format via {@code responseMimeType}.

+ */ +public final class GoogleJudgeModel extends AbstractHttpJudgeModel { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com"; + private static final String GENERATE_PATH = "/v1beta/models/%s:generateContent"; + private static final String SYSTEM_PROMPT = + "You are an evaluation judge. Respond ONLY with a JSON object " + + "containing \"score\" (a number between 0.0 and 1.0) " + + "and \"reason\" (a brief explanation)."; + + public GoogleJudgeModel(JudgeConfig config) { + super(config); + if (config.getApiKey() == null || config.getApiKey().isBlank()) { + throw new JudgeException("Google requires a non-null API key"); + } + } + + GoogleJudgeModel(JudgeConfig config, HttpJudgeClient client) { + super(config, client); + } + + static String defaultBaseUrl() { + return DEFAULT_BASE_URL; + } + + @Override + protected HttpJudgeRequest buildRequest(String prompt) { + try { + var body = MAPPER.createObjectNode(); + + var systemInstruction = MAPPER.createObjectNode(); + var systemParts = systemInstruction.putArray("parts"); + systemParts.addObject().put("text", SYSTEM_PROMPT); + body.set("systemInstruction", systemInstruction); + + var contents = body.putArray("contents"); + var userContent = contents.addObject(); + userContent.put("role", "user"); + var parts = userContent.putArray("parts"); + parts.addObject().put("text", prompt); + + var generationConfig = MAPPER.createObjectNode(); + generationConfig.put("temperature", config.getTemperature()); + generationConfig.put("responseMimeType", "application/json"); + body.set("generationConfig", generationConfig); + + String url = config.getBaseUrl() + + String.format(GENERATE_PATH, config.getModel()); + return new HttpJudgeRequest( + url, + Map.of("x-goog-api-key", config.getApiKey()), + MAPPER.writeValueAsString(body)); + } catch (Exception e) { + throw new JudgeException("Failed to build Google request", e); + } + } + + @Override + protected String extractContent(String responseBody) { + JsonNode root = parseJson(responseBody); + JsonNode candidates = root.path("candidates"); + if (candidates.isEmpty()) { + throw new JudgeException("No candidates in Google response"); + } + return candidates.get(0) + .path("content").path("parts").path(0) + .path("text").asText(""); + } + + @Override + protected TokenUsage extractTokenUsage(String responseBody) { + JsonNode root = parseJson(responseBody); + JsonNode usage = root.path("usageMetadata"); + if (usage.isMissingNode()) { + return null; + } + int promptTokens = usage.path("promptTokenCount").asInt(0); + int candidateTokens = usage.path("candidatesTokenCount").asInt(0); + int totalTokens = usage.path("totalTokenCount").asInt(0); + if (totalTokens == 0) { + totalTokens = promptTokens + candidateTokens; + } + return new TokenUsage(promptTokens, candidateTokens, totalTokens); + } +} diff --git a/agenteval-judge/src/test/java/com/agenteval/judge/JudgeModelsTest.java b/agenteval-judge/src/test/java/com/agenteval/judge/JudgeModelsTest.java index 0e8240e..e3121df 100644 --- a/agenteval-judge/src/test/java/com/agenteval/judge/JudgeModelsTest.java +++ b/agenteval-judge/src/test/java/com/agenteval/judge/JudgeModelsTest.java @@ -1,6 +1,9 @@ package com.agenteval.judge; import com.agenteval.judge.config.JudgeConfig; +import com.agenteval.judge.provider.AzureOpenAiJudgeModel; +import com.agenteval.judge.provider.CustomHttpJudgeModel; +import com.agenteval.judge.provider.GoogleJudgeModel; import com.agenteval.judge.provider.OpenAiJudgeModel; import com.agenteval.judge.provider.AnthropicJudgeModel; import org.junit.jupiter.api.Test; @@ -38,6 +41,47 @@ void anthropicWithConfigShouldCreateAnthropicModel() { assertThat(model.modelId()).isEqualTo("claude-sonnet-4-20250514"); } + @Test + void googleWithConfigShouldCreateGoogleModel() { + var config = JudgeConfig.builder() + .apiKey("test-key") + .model("gemini-1.5-pro") + .baseUrl("https://generativelanguage.googleapis.com") + .build(); + + var model = JudgeModels.google(config); + + assertThat(model).isInstanceOf(GoogleJudgeModel.class); + assertThat(model.modelId()).isEqualTo("gemini-1.5-pro"); + } + + @Test + void azureWithConfigShouldCreateAzureModel() { + var config = JudgeConfig.builder() + .apiKey("azure-key") + .model("my-deployment") + .baseUrl("https://myresource.openai.azure.com") + .build(); + + var model = JudgeModels.azure(config); + + assertThat(model).isInstanceOf(AzureOpenAiJudgeModel.class); + assertThat(model.modelId()).isEqualTo("my-deployment"); + } + + @Test + void customWithConfigShouldCreateCustomModel() { + var config = JudgeConfig.builder() + .model("local-model") + .baseUrl("http://localhost:8000") + .build(); + + var model = JudgeModels.custom(config); + + assertThat(model).isInstanceOf(CustomHttpJudgeModel.class); + assertThat(model.modelId()).isEqualTo("local-model"); + } + @Test void openaiWithoutEnvKeyShouldThrow() { assertThatThrownBy(() -> JudgeModels.openai("gpt-4o")) @@ -51,4 +95,17 @@ void anthropicWithoutEnvKeyShouldThrow() { .isInstanceOf(JudgeException.class) .hasMessageContaining("ANTHROPIC_API_KEY"); } + + @Test + void googleWithoutEnvKeyShouldThrow() { + assertThatThrownBy(() -> JudgeModels.google("gemini-1.5-pro")) + .isInstanceOf(JudgeException.class) + .hasMessageContaining("GOOGLE_API_KEY"); + } + + @Test + void bedrockWithoutEnvKeyShouldThrow() { + assertThatThrownBy(() -> JudgeModels.bedrock("anthropic.claude-3-sonnet")) + .isInstanceOf(JudgeException.class); + } } diff --git a/agenteval-judge/src/test/java/com/agenteval/judge/provider/AzureOpenAiJudgeModelTest.java b/agenteval-judge/src/test/java/com/agenteval/judge/provider/AzureOpenAiJudgeModelTest.java new file mode 100644 index 0000000..d9a3b00 --- /dev/null +++ b/agenteval-judge/src/test/java/com/agenteval/judge/provider/AzureOpenAiJudgeModelTest.java @@ -0,0 +1,139 @@ +package com.agenteval.judge.provider; + +import com.agenteval.core.judge.JudgeResponse; +import com.agenteval.judge.JudgeException; +import com.agenteval.judge.config.JudgeConfig; +import com.agenteval.judge.http.HttpJudgeClient; +import com.agenteval.judge.http.HttpJudgeRequest; +import com.agenteval.judge.http.HttpJudgeResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AzureOpenAiJudgeModelTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private JudgeConfig config; + private HttpJudgeClient mockClient; + + @BeforeEach + void setUp() { + config = JudgeConfig.builder() + .apiKey("azure-test-key") + .model("my-gpt4-deployment") + .baseUrl("https://myresource.openai.azure.com") + .build(); + mockClient = mock(HttpJudgeClient.class); + } + + @Test + void shouldReturnModelId() { + var model = new AzureOpenAiJudgeModel(config, "2024-02-01", mockClient); + assertThat(model.modelId()).isEqualTo("my-gpt4-deployment"); + } + + @Test + void shouldBuildValidRequest() { + var model = new AzureOpenAiJudgeModel(config, "2024-02-01", mockClient); + + when(mockClient.send(any())).thenAnswer(invocation -> { + HttpJudgeRequest req = invocation.getArgument(0); + assertThat(req.url()).isEqualTo( + "https://myresource.openai.azure.com/openai/deployments/" + + "my-gpt4-deployment/chat/completions?api-version=2024-02-01"); + assertThat(req.headers()).containsEntry("api-key", "azure-test-key"); + assertThat(req.headers()).doesNotContainKey("Authorization"); + + JsonNode body = MAPPER.readTree(req.body()); + assertThat(body.has("model")).isFalse(); + assertThat(body.get("response_format").get("type").asText()).isEqualTo("json_object"); + assertThat(body.get("messages")).hasSize(2); + + return makeResponse(0.85, "Relevant", 100, 50, 150); + }); + + model.judge("Evaluate this"); + } + + @Test + void shouldParseSuccessResponse() { + var model = new AzureOpenAiJudgeModel(config, "2024-02-01", mockClient); + when(mockClient.send(any())).thenReturn( + makeResponse(0.85, "Good answer", 100, 50, 150)); + + JudgeResponse response = model.judge("test prompt"); + + assertThat(response.score()).isCloseTo(0.85, within(0.001)); + assertThat(response.reason()).isEqualTo("Good answer"); + assertThat(response.tokenUsage()).isNotNull(); + assertThat(response.tokenUsage().inputTokens()).isEqualTo(100); + assertThat(response.tokenUsage().outputTokens()).isEqualTo(50); + } + + @Test + void shouldUseDefaultApiVersion() { + var model = new AzureOpenAiJudgeModel(config, null, mockClient); + assertThat(model.getApiVersion()).isEqualTo("2024-02-01"); + } + + @Test + void shouldThrowOnEmptyChoices() { + var model = new AzureOpenAiJudgeModel(config, "2024-02-01", mockClient); + when(mockClient.send(any())).thenReturn( + new HttpJudgeResponse(200, "{\"choices\": []}", null)); + + assertThatThrownBy(() -> model.judge("test")) + .isInstanceOf(JudgeException.class) + .hasMessageContaining("No choices"); + } + + @Test + void shouldThrowOnMissingApiKey() { + var noKeyConfig = JudgeConfig.builder() + .model("my-deployment") + .baseUrl("https://myresource.openai.azure.com") + .build(); + + assertThatThrownBy(() -> new AzureOpenAiJudgeModel(noKeyConfig)) + .isInstanceOf(JudgeException.class) + .hasMessageContaining("Azure OpenAI requires"); + } + + @Test + void shouldHandleResponseWithoutUsage() { + var model = new AzureOpenAiJudgeModel(config, "2024-02-01", mockClient); + String responseBody = """ + {"choices": [{"message": {"content": "{\\"score\\": 0.7, \\"reason\\": \\"OK\\"}"}}]}"""; + when(mockClient.send(any())).thenReturn( + new HttpJudgeResponse(200, responseBody, null)); + + JudgeResponse response = model.judge("test"); + assertThat(response.score()).isCloseTo(0.7, within(0.001)); + assertThat(response.tokenUsage()).isNull(); + } + + private HttpJudgeResponse makeResponse( + double score, String reason, int promptTokens, int completionTokens, int totalTokens) { + String content = String.format( + "{\"score\": %s, \"reason\": \"%s\"}", score, reason); + String body = String.format(""" + { + "choices": [{"message": {"content": %s}}], + "usage": { + "prompt_tokens": %d, + "completion_tokens": %d, + "total_tokens": %d + } + }""", + MAPPER.valueToTree(content), promptTokens, completionTokens, totalTokens); + return new HttpJudgeResponse(200, body, null); + } +} diff --git a/agenteval-judge/src/test/java/com/agenteval/judge/provider/BedrockJudgeModelTest.java b/agenteval-judge/src/test/java/com/agenteval/judge/provider/BedrockJudgeModelTest.java new file mode 100644 index 0000000..bd517de --- /dev/null +++ b/agenteval-judge/src/test/java/com/agenteval/judge/provider/BedrockJudgeModelTest.java @@ -0,0 +1,161 @@ +package com.agenteval.judge.provider; + +import com.agenteval.core.judge.JudgeResponse; +import com.agenteval.judge.JudgeException; +import com.agenteval.judge.config.JudgeConfig; +import com.agenteval.judge.http.HttpJudgeClient; +import com.agenteval.judge.http.HttpJudgeRequest; +import com.agenteval.judge.http.HttpJudgeResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BedrockJudgeModelTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private JudgeConfig config; + private HttpJudgeClient mockClient; + + @BeforeEach + void setUp() { + config = JudgeConfig.builder() + .model("anthropic.claude-3-sonnet-20240229-v1:0") + .baseUrl("https://bedrock-runtime.us-east-1.amazonaws.com") + .build(); + mockClient = mock(HttpJudgeClient.class); + } + + @Test + void shouldReturnModelId() { + var model = new BedrockJudgeModel(config, "AKID", "secret", null, mockClient); + assertThat(model.modelId()).isEqualTo("anthropic.claude-3-sonnet-20240229-v1:0"); + } + + @Test + void shouldBuildValidRequest() { + var model = new BedrockJudgeModel(config, "AKID", "secret", null, mockClient); + + when(mockClient.send(any())).thenAnswer(invocation -> { + HttpJudgeRequest req = invocation.getArgument(0); + assertThat(req.url()).isEqualTo( + "https://bedrock-runtime.us-east-1.amazonaws.com" + + "/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke"); + assertThat(req.headers()).containsKey("Authorization"); + assertThat(req.headers().get("Authorization")).startsWith("AWS4-HMAC-SHA256"); + assertThat(req.headers()).containsKey("x-amz-date"); + assertThat(req.headers()).containsKey("x-amz-content-sha256"); + + JsonNode body = MAPPER.readTree(req.body()); + assertThat(body.get("anthropic_version").asText()).isEqualTo("bedrock-2023-05-31"); + assertThat(body.get("max_tokens").asInt()).isEqualTo(1024); + assertThat(body.has("system")).isTrue(); + assertThat(body.get("messages")).hasSize(1); + + return makeResponse(0.9, "Excellent"); + }); + + model.judge("Evaluate this"); + } + + @Test + void shouldParseSuccessResponse() { + var model = new BedrockJudgeModel(config, "AKID", "secret", null, mockClient); + when(mockClient.send(any())).thenReturn(makeResponse(0.9, "Excellent")); + + JudgeResponse response = model.judge("test prompt"); + + assertThat(response.score()).isCloseTo(0.9, within(0.001)); + assertThat(response.reason()).isEqualTo("Excellent"); + assertThat(response.tokenUsage()).isNotNull(); + assertThat(response.tokenUsage().inputTokens()).isEqualTo(150); + assertThat(response.tokenUsage().outputTokens()).isEqualTo(60); + } + + @Test + void shouldIncludeSessionToken() { + var model = new BedrockJudgeModel(config, "AKID", "secret", "token123", mockClient); + + when(mockClient.send(any())).thenAnswer(invocation -> { + HttpJudgeRequest req = invocation.getArgument(0); + assertThat(req.headers()).containsEntry("x-amz-security-token", "token123"); + return makeResponse(0.8, "OK"); + }); + + model.judge("test"); + } + + @Test + void shouldExtractRegionFromBaseUrl() { + assertThat(BedrockJudgeModel.extractRegion( + "https://bedrock-runtime.eu-west-1.amazonaws.com")) + .isEqualTo("eu-west-1"); + assertThat(BedrockJudgeModel.extractRegion( + "https://bedrock-runtime.ap-southeast-1.amazonaws.com")) + .isEqualTo("ap-southeast-1"); + } + + @Test + void shouldDefaultRegionOnInvalidUrl() { + assertThat(BedrockJudgeModel.extractRegion("http://localhost")) + .isEqualTo("us-east-1"); + } + + @Test + void shouldThrowOnEmptyContent() { + var model = new BedrockJudgeModel(config, "AKID", "secret", null, mockClient); + when(mockClient.send(any())).thenReturn( + new HttpJudgeResponse(200, "{\"content\": []}", null)); + + assertThatThrownBy(() -> model.judge("test")) + .isInstanceOf(JudgeException.class) + .hasMessageContaining("No content"); + } + + @Test + void shouldThrowOnMissingAccessKey() { + assertThatThrownBy(() -> + new BedrockJudgeModel(config, null, "secret", null)) + .isInstanceOf(JudgeException.class) + .hasMessageContaining("AWS_ACCESS_KEY_ID"); + } + + @Test + void shouldThrowOnMissingSecretKey() { + assertThatThrownBy(() -> + new BedrockJudgeModel(config, "AKID", null, null)) + .isInstanceOf(JudgeException.class) + .hasMessageContaining("AWS_SECRET_ACCESS_KEY"); + } + + @Test + void shouldHandleResponseWithoutUsage() { + var model = new BedrockJudgeModel(config, "AKID", "secret", null, mockClient); + String responseBody = """ + {"content": [{"type": "text", "text": "{\\"score\\": 0.7, \\"reason\\": \\"OK\\"}"}]}"""; + when(mockClient.send(any())).thenReturn( + new HttpJudgeResponse(200, responseBody, null)); + + JudgeResponse response = model.judge("test"); + assertThat(response.score()).isCloseTo(0.7, within(0.001)); + assertThat(response.tokenUsage()).isNull(); + } + + private HttpJudgeResponse makeResponse(double score, String reason) { + String content = String.format( + "{\"score\": %s, \"reason\": \"%s\"}", score, reason); + String body = String.format(""" + { + "content": [{"type": "text", "text": %s}], + "usage": {"input_tokens": 150, "output_tokens": 60} + }""", MAPPER.valueToTree(content)); + return new HttpJudgeResponse(200, body, null); + } +} diff --git a/agenteval-judge/src/test/java/com/agenteval/judge/provider/CustomHttpJudgeModelTest.java b/agenteval-judge/src/test/java/com/agenteval/judge/provider/CustomHttpJudgeModelTest.java new file mode 100644 index 0000000..660ec4f --- /dev/null +++ b/agenteval-judge/src/test/java/com/agenteval/judge/provider/CustomHttpJudgeModelTest.java @@ -0,0 +1,148 @@ +package com.agenteval.judge.provider; + +import com.agenteval.core.judge.JudgeResponse; +import com.agenteval.judge.JudgeException; +import com.agenteval.judge.config.JudgeConfig; +import com.agenteval.judge.http.HttpJudgeClient; +import com.agenteval.judge.http.HttpJudgeRequest; +import com.agenteval.judge.http.HttpJudgeResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CustomHttpJudgeModelTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private HttpJudgeClient mockClient; + + @BeforeEach + void setUp() { + mockClient = mock(HttpJudgeClient.class); + } + + @Test + void shouldReturnModelId() { + var config = JudgeConfig.builder() + .model("my-custom-model") + .baseUrl("http://localhost:8000") + .build(); + var model = new CustomHttpJudgeModel(config, mockClient); + assertThat(model.modelId()).isEqualTo("my-custom-model"); + } + + @Test + void shouldBuildRequestWithApiKey() { + var config = JudgeConfig.builder() + .apiKey("custom-key") + .model("my-model") + .baseUrl("http://localhost:8000") + .build(); + var model = new CustomHttpJudgeModel(config, mockClient); + + when(mockClient.send(any())).thenAnswer(invocation -> { + HttpJudgeRequest req = invocation.getArgument(0); + assertThat(req.url()).isEqualTo("http://localhost:8000/v1/chat/completions"); + assertThat(req.headers()).containsEntry("Authorization", "Bearer custom-key"); + + JsonNode body = MAPPER.readTree(req.body()); + assertThat(body.get("model").asText()).isEqualTo("my-model"); + assertThat(body.get("response_format").get("type").asText()).isEqualTo("json_object"); + assertThat(body.get("messages")).hasSize(2); + + return makeResponse(0.85, "Relevant", 100, 50, 150); + }); + + model.judge("Evaluate this"); + } + + @Test + void shouldBuildRequestWithoutApiKey() { + var config = JudgeConfig.builder() + .model("local-model") + .baseUrl("http://localhost:8000") + .build(); + var model = new CustomHttpJudgeModel(config, mockClient); + + when(mockClient.send(any())).thenAnswer(invocation -> { + HttpJudgeRequest req = invocation.getArgument(0); + assertThat(req.headers()).doesNotContainKey("Authorization"); + return makeResponse(0.8, "OK", 50, 30, 80); + }); + + model.judge("test"); + } + + @Test + void shouldParseSuccessResponse() { + var config = JudgeConfig.builder() + .model("my-model") + .baseUrl("http://localhost:8000") + .build(); + var model = new CustomHttpJudgeModel(config, mockClient); + when(mockClient.send(any())).thenReturn( + makeResponse(0.85, "Good answer", 100, 50, 150)); + + JudgeResponse response = model.judge("test prompt"); + + assertThat(response.score()).isCloseTo(0.85, within(0.001)); + assertThat(response.reason()).isEqualTo("Good answer"); + assertThat(response.tokenUsage()).isNotNull(); + } + + @Test + void shouldThrowOnEmptyChoices() { + var config = JudgeConfig.builder() + .model("my-model") + .baseUrl("http://localhost:8000") + .build(); + var model = new CustomHttpJudgeModel(config, mockClient); + when(mockClient.send(any())).thenReturn( + new HttpJudgeResponse(200, "{\"choices\": []}", null)); + + assertThatThrownBy(() -> model.judge("test")) + .isInstanceOf(JudgeException.class) + .hasMessageContaining("No choices"); + } + + @Test + void shouldHandleResponseWithoutUsage() { + var config = JudgeConfig.builder() + .model("my-model") + .baseUrl("http://localhost:8000") + .build(); + var model = new CustomHttpJudgeModel(config, mockClient); + String responseBody = """ + {"choices": [{"message": {"content": "{\\"score\\": 0.7, \\"reason\\": \\"OK\\"}"}}]}"""; + when(mockClient.send(any())).thenReturn( + new HttpJudgeResponse(200, responseBody, null)); + + JudgeResponse response = model.judge("test"); + assertThat(response.score()).isCloseTo(0.7, within(0.001)); + assertThat(response.tokenUsage()).isNull(); + } + + private HttpJudgeResponse makeResponse( + double score, String reason, int promptTokens, int completionTokens, int totalTokens) { + String content = String.format( + "{\"score\": %s, \"reason\": \"%s\"}", score, reason); + String body = String.format(""" + { + "choices": [{"message": {"content": %s}}], + "usage": { + "prompt_tokens": %d, + "completion_tokens": %d, + "total_tokens": %d + } + }""", + MAPPER.valueToTree(content), promptTokens, completionTokens, totalTokens); + return new HttpJudgeResponse(200, body, null); + } +} diff --git a/agenteval-judge/src/test/java/com/agenteval/judge/provider/GoogleJudgeModelTest.java b/agenteval-judge/src/test/java/com/agenteval/judge/provider/GoogleJudgeModelTest.java new file mode 100644 index 0000000..65239a7 --- /dev/null +++ b/agenteval-judge/src/test/java/com/agenteval/judge/provider/GoogleJudgeModelTest.java @@ -0,0 +1,131 @@ +package com.agenteval.judge.provider; + +import com.agenteval.core.judge.JudgeResponse; +import com.agenteval.judge.JudgeException; +import com.agenteval.judge.config.JudgeConfig; +import com.agenteval.judge.http.HttpJudgeClient; +import com.agenteval.judge.http.HttpJudgeRequest; +import com.agenteval.judge.http.HttpJudgeResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GoogleJudgeModelTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private JudgeConfig config; + private HttpJudgeClient mockClient; + + @BeforeEach + void setUp() { + config = JudgeConfig.builder() + .apiKey("test-google-key") + .model("gemini-1.5-pro") + .baseUrl("https://generativelanguage.googleapis.com") + .build(); + mockClient = mock(HttpJudgeClient.class); + } + + @Test + void shouldReturnModelId() { + var model = new GoogleJudgeModel(config, mockClient); + assertThat(model.modelId()).isEqualTo("gemini-1.5-pro"); + } + + @Test + void shouldBuildValidRequest() { + var model = new GoogleJudgeModel(config, mockClient); + + when(mockClient.send(any())).thenAnswer(invocation -> { + HttpJudgeRequest req = invocation.getArgument(0); + assertThat(req.url()).isEqualTo( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent"); + assertThat(req.headers()).containsEntry("x-goog-api-key", "test-google-key"); + + JsonNode body = MAPPER.readTree(req.body()); + assertThat(body.has("systemInstruction")).isTrue(); + assertThat(body.path("systemInstruction").path("parts").get(0) + .path("text").asText()).contains("evaluation judge"); + assertThat(body.path("contents").get(0).path("role").asText()).isEqualTo("user"); + assertThat(body.path("generationConfig").path("responseMimeType").asText()) + .isEqualTo("application/json"); + + return makeResponse(0.85, "Relevant"); + }); + + model.judge("Evaluate this"); + } + + @Test + void shouldParseSuccessResponse() { + var model = new GoogleJudgeModel(config, mockClient); + when(mockClient.send(any())).thenReturn(makeResponse(0.85, "Good answer")); + + JudgeResponse response = model.judge("test prompt"); + + assertThat(response.score()).isCloseTo(0.85, within(0.001)); + assertThat(response.reason()).isEqualTo("Good answer"); + assertThat(response.tokenUsage()).isNotNull(); + assertThat(response.tokenUsage().inputTokens()).isEqualTo(120); + assertThat(response.tokenUsage().outputTokens()).isEqualTo(45); + } + + @Test + void shouldHandleResponseWithoutUsage() { + var model = new GoogleJudgeModel(config, mockClient); + String responseBody = """ + {"candidates": [{"content": {"parts": [{"text": "{\\"score\\": 0.7, \\"reason\\": \\"OK\\"}"}]}}]}"""; + when(mockClient.send(any())).thenReturn( + new HttpJudgeResponse(200, responseBody, null)); + + JudgeResponse response = model.judge("test"); + assertThat(response.score()).isCloseTo(0.7, within(0.001)); + assertThat(response.tokenUsage()).isNull(); + } + + @Test + void shouldThrowOnEmptyCandidates() { + var model = new GoogleJudgeModel(config, mockClient); + when(mockClient.send(any())).thenReturn( + new HttpJudgeResponse(200, "{\"candidates\": []}", null)); + + assertThatThrownBy(() -> model.judge("test")) + .isInstanceOf(JudgeException.class) + .hasMessageContaining("No candidates"); + } + + @Test + void shouldThrowOnMissingApiKey() { + var noKeyConfig = JudgeConfig.builder() + .model("gemini-1.5-pro") + .baseUrl("https://generativelanguage.googleapis.com") + .build(); + + assertThatThrownBy(() -> new GoogleJudgeModel(noKeyConfig)) + .isInstanceOf(JudgeException.class) + .hasMessageContaining("Google requires"); + } + + private HttpJudgeResponse makeResponse(double score, String reason) { + String content = String.format( + "{\"score\": %s, \"reason\": \"%s\"}", score, reason); + String body = String.format(""" + { + "candidates": [{"content": {"parts": [{"text": %s}]}}], + "usageMetadata": { + "promptTokenCount": 120, + "candidatesTokenCount": 45, + "totalTokenCount": 165 + } + }""", MAPPER.valueToTree(content)); + return new HttpJudgeResponse(200, body, null); + } +} diff --git a/agenteval-metrics/src/main/java/com/agenteval/metrics/agent/TrajectoryOptimalityMetric.java b/agenteval-metrics/src/main/java/com/agenteval/metrics/agent/TrajectoryOptimalityMetric.java new file mode 100644 index 0000000..e694ac3 --- /dev/null +++ b/agenteval-metrics/src/main/java/com/agenteval/metrics/agent/TrajectoryOptimalityMetric.java @@ -0,0 +1,88 @@ +package com.agenteval.metrics.agent; + +import com.agenteval.core.judge.JudgeModel; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.metrics.llm.LLMJudgeMetric; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Evaluates whether the agent took the most efficient path to complete its task. + * + *

Penalizes unnecessary tool calls, redundant LLM invocations, and + * circular reasoning. When {@code maxSteps} is configured, step counts + * exceeding the limit are penalized by the judge.

+ */ +public final class TrajectoryOptimalityMetric extends LLMJudgeMetric { + + private static final String NAME = "TrajectoryOptimality"; + private static final String PROMPT_PATH = + "com/agenteval/metrics/prompts/trajectory-optimality.txt"; + private static final double DEFAULT_THRESHOLD = 0.7; + private static final int DEFAULT_MAX_STEPS = -1; + + private final int maxSteps; + + public TrajectoryOptimalityMetric(JudgeModel judge) { + this(judge, DEFAULT_THRESHOLD, DEFAULT_MAX_STEPS); + } + + public TrajectoryOptimalityMetric(JudgeModel judge, double threshold) { + this(judge, threshold, DEFAULT_MAX_STEPS); + } + + public TrajectoryOptimalityMetric(JudgeModel judge, double threshold, int maxSteps) { + super(judge, threshold, PROMPT_PATH); + this.maxSteps = maxSteps; + } + + @Override + public String name() { + return NAME; + } + + @Override + protected void validate(AgentTestCase testCase) { + if (testCase.getInput() == null || testCase.getInput().isBlank()) { + throw new IllegalArgumentException(NAME + " requires non-empty input"); + } + if (testCase.getReasoningTrace().isEmpty() && testCase.getToolCalls().isEmpty()) { + throw new IllegalArgumentException( + NAME + " requires non-empty reasoningTrace or toolCalls"); + } + } + + @Override + protected Map buildTemplateVariables(AgentTestCase testCase) { + Map vars = new HashMap<>(); + vars.put("input", testCase.getInput()); + vars.put("actualOutput", testCase.getActualOutput() != null + ? testCase.getActualOutput() : "(no output)"); + vars.put("reasoningTrace", formatReasoningTrace(testCase)); + vars.put("toolCalls", formatToolCalls(testCase)); + vars.put("totalSteps", String.valueOf(testCase.getReasoningTrace().size())); + vars.put("totalToolCalls", String.valueOf(testCase.getToolCalls().size())); + vars.put("maxSteps", maxSteps > 0 ? String.valueOf(maxSteps) : "(not set)"); + return vars; + } + + private String formatReasoningTrace(AgentTestCase testCase) { + if (testCase.getReasoningTrace().isEmpty()) { + return "(none)"; + } + return testCase.getReasoningTrace().stream() + .map(step -> "[" + step.type() + "] " + step.content()) + .collect(Collectors.joining("\n")); + } + + private String formatToolCalls(AgentTestCase testCase) { + if (testCase.getToolCalls().isEmpty()) { + return "(none)"; + } + return testCase.getToolCalls().stream() + .map(tc -> tc.name() + "(" + tc.arguments() + ")") + .collect(Collectors.joining(", ")); + } +} diff --git a/agenteval-metrics/src/main/resources/com/agenteval/metrics/prompts/trajectory-optimality.txt b/agenteval-metrics/src/main/resources/com/agenteval/metrics/prompts/trajectory-optimality.txt new file mode 100644 index 0000000..73aa48f --- /dev/null +++ b/agenteval-metrics/src/main/resources/com/agenteval/metrics/prompts/trajectory-optimality.txt @@ -0,0 +1,29 @@ +You are evaluating whether an AI agent took the most efficient path to complete its task. + +Task Input: {{input}} + +Agent's Reasoning Trace ({{totalSteps}} steps): +{{reasoningTrace}} + +Tool Calls Made ({{totalToolCalls}} calls): {{toolCalls}} + +Final Output: {{actualOutput}} + +Maximum Expected Steps: {{maxSteps}} + +Evaluation criteria: +- Did the agent take the most direct path to the solution? +- Were there any unnecessary or redundant tool calls? +- Did the agent repeat the same actions or reasoning (circular reasoning)? +- Were there unnecessary intermediate steps that could have been skipped? +- If a maximum step count is set, did the agent stay within the limit? +- Did the agent avoid calling tools whose results were not used? + +Score the trajectory optimality from 0.0 to 1.0 where: +- 1.0 = Perfectly optimal path, no unnecessary steps, all actions contributed to the result +- 0.7 = Mostly efficient with minor redundancies +- 0.5 = Some unnecessary steps or minor circular reasoning +- 0.3 = Significant inefficiencies, multiple redundant actions +- 0.0 = Extremely inefficient, circular reasoning, or far exceeded expected steps + +Respond ONLY with a JSON object: {"score": , "reason": ""} diff --git a/agenteval-metrics/src/test/java/com/agenteval/metrics/agent/TrajectoryOptimalityMetricTest.java b/agenteval-metrics/src/test/java/com/agenteval/metrics/agent/TrajectoryOptimalityMetricTest.java new file mode 100644 index 0000000..596eb49 --- /dev/null +++ b/agenteval-metrics/src/test/java/com/agenteval/metrics/agent/TrajectoryOptimalityMetricTest.java @@ -0,0 +1,162 @@ +package com.agenteval.metrics.agent; + +import com.agenteval.core.judge.JudgeModel; +import com.agenteval.core.judge.JudgeResponse; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; +import com.agenteval.core.model.ReasoningStep; +import com.agenteval.core.model.ReasoningStepType; +import com.agenteval.core.model.ToolCall; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TrajectoryOptimalityMetricTest { + + private JudgeModel judge; + + @BeforeEach + void setUp() { + judge = mock(JudgeModel.class); + } + + @Test + void shouldScoreOptimalTrajectory() { + when(judge.judge(anyString())).thenReturn( + new JudgeResponse(0.95, "Optimal path taken", null)); + + var metric = new TrajectoryOptimalityMetric(judge, 0.7); + var testCase = AgentTestCase.builder() + .input("Find the weather in Paris") + .actualOutput("The weather in Paris is 22°C and sunny.") + .reasoningTrace(List.of( + ReasoningStep.of(ReasoningStepType.THOUGHT,"I need to check the weather API"), + ReasoningStep.action("Calling weather API", + ToolCall.of("getWeather", Map.of("city", "Paris"))))) + .toolCalls(List.of( + ToolCall.of("getWeather", Map.of("city", "Paris")))) + .build(); + + EvalScore score = metric.evaluate(testCase); + + assertThat(score.value()).isCloseTo(0.95, within(0.001)); + assertThat(score.passed()).isTrue(); + } + + @Test + void shouldScoreSuboptimalTrajectory() { + when(judge.judge(anyString())).thenReturn( + new JudgeResponse(0.3, "Redundant tool calls detected", null)); + + var metric = new TrajectoryOptimalityMetric(judge, 0.7); + var testCase = AgentTestCase.builder() + .input("Find the weather in Paris") + .actualOutput("The weather is 22°C.") + .reasoningTrace(List.of( + ReasoningStep.of(ReasoningStepType.THOUGHT,"Let me search for weather"), + ReasoningStep.action("Search", + ToolCall.of("search", Map.of("q", "weather Paris"))), + ReasoningStep.of(ReasoningStepType.THOUGHT,"Let me search again"), + ReasoningStep.action("Search again", + ToolCall.of("search", Map.of("q", "Paris weather"))), + ReasoningStep.action("API call", + ToolCall.of("getWeather", Map.of("city", "Paris"))))) + .toolCalls(List.of( + ToolCall.of("search", Map.of("q", "weather Paris")), + ToolCall.of("search", Map.of("q", "Paris weather")), + ToolCall.of("getWeather", Map.of("city", "Paris")))) + .build(); + + EvalScore score = metric.evaluate(testCase); + assertThat(score.passed()).isFalse(); + } + + @Test + void shouldIncludeMaxStepsInPrompt() { + when(judge.judge(contains("5"))).thenReturn( + new JudgeResponse(0.8, "Within step limit", null)); + + var metric = new TrajectoryOptimalityMetric(judge, 0.7, 5); + var testCase = AgentTestCase.builder() + .input("Do something") + .actualOutput("Done") + .toolCalls(List.of(ToolCall.of("doIt"))) + .build(); + + metric.evaluate(testCase); + } + + @Test + void shouldHandleToolCallsOnly() { + when(judge.judge(anyString())).thenReturn( + new JudgeResponse(0.7, "Acceptable path", null)); + + var metric = new TrajectoryOptimalityMetric(judge); + var testCase = AgentTestCase.builder() + .input("Fetch data") + .actualOutput("Data fetched") + .toolCalls(List.of( + ToolCall.of("fetchData", Map.of("id", "123")))) + .build(); + + EvalScore score = metric.evaluate(testCase); + assertThat(score.value()).isCloseTo(0.7, within(0.001)); + } + + @Test + void shouldRejectMissingInputAndTrace() { + var metric = new TrajectoryOptimalityMetric(judge); + var testCase = AgentTestCase.builder() + .input("Do something") + .build(); + + assertThatThrownBy(() -> metric.evaluate(testCase)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reasoningTrace or toolCalls"); + } + + @Test + void shouldRejectEmptyInput() { + var metric = new TrajectoryOptimalityMetric(judge); + var testCase = AgentTestCase.builder() + .input("") + .toolCalls(List.of(ToolCall.of("something"))) + .build(); + + assertThatThrownBy(() -> metric.evaluate(testCase)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("input"); + } + + @Test + void shouldReturnCorrectName() { + var metric = new TrajectoryOptimalityMetric(judge); + assertThat(metric.name()).isEqualTo("TrajectoryOptimality"); + } + + @Test + void shouldUseDefaultThreshold() { + when(judge.judge(anyString())).thenReturn( + new JudgeResponse(0.7, "OK", null)); + + var metric = new TrajectoryOptimalityMetric(judge); + var testCase = AgentTestCase.builder() + .input("task") + .actualOutput("done") + .toolCalls(List.of(ToolCall.of("doIt"))) + .build(); + + EvalScore score = metric.evaluate(testCase); + assertThat(score.threshold()).isEqualTo(0.7); + } +}