GeminiClient.java
/*******************************************************************************
* Copyright (c) 2025 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Carsten Hammer
*******************************************************************************/
package org.sandbox.jdt.triggerpattern.llm;
import java.io.IOException;
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.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
/**
* REST client for the Google Gemini API.
*
* <p>Sends prompts to the Gemini API and parses the response into
* {@link CommitEvaluation} objects. Includes rate limiting (15s delay)
* and exponential backoff on HTTP 429 responses.</p>
*/
public class GeminiClient implements LlmClient {
private static final String DEFAULT_MODEL = "gemini-2.5-flash";
private static final String API_URL_TEMPLATE =
"https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s";
private static final int RATE_LIMIT_DELAY_MS = 15000;
/** Maximum number of API requests allowed per day (leaves buffer from 20 RPD limit). */
public static final int MAX_DAILY_REQUESTS = 18;
private static final int MAX_RETRIES = 5;
private static final int INITIAL_BACKOFF_MS = 5000;
private static final int POST_FAILURE_COOLDOWN_MS = 60000;
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60);
/** Default maximum duration (in seconds) with no successful API call before aborting. */
public static final int DEFAULT_MAX_FAILURE_DURATION_SECONDS = 300;
private final String apiKey;
private final String model;
private final HttpClient httpClient;
private final Gson gson;
private long lastRequestTime;
private int dailyRequestCount;
private Instant lastSuccessfulCall;
private Duration maxFailureDuration;
private int rateLimitDelayMs = RATE_LIMIT_DELAY_MS;
private int consecutive429Batches;
private boolean lastResponseTruncated;
/**
* Creates a client reading the API key from the GEMINI_API_KEY environment variable.
*/
public GeminiClient() {
this(System.getenv("GEMINI_API_KEY"));
}
/**
* Creates a client with the given API key.
*
* @param apiKey the Gemini API key
*/
public GeminiClient(String apiKey) {
this(apiKey, HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build());
}
/**
* Creates a client with the given API key and HTTP client (for testing).
*
* @param apiKey the Gemini API key
* @param httpClient the HTTP client to use
*/
public GeminiClient(String apiKey, HttpClient httpClient) {
this(apiKey, httpClient, resolveModel());
}
/**
* Creates a client with the given API key, HTTP client, and model (for testing).
*
* @param apiKey the Gemini API key
* @param httpClient the HTTP client to use
* @param model the Gemini model name to use
*/
public GeminiClient(String apiKey, HttpClient httpClient, String model) {
this.apiKey = apiKey;
this.model = model;
this.httpClient = httpClient;
this.gson = new GsonBuilder().create();
this.lastSuccessfulCall = Instant.now();
this.maxFailureDuration = Duration.ofSeconds(DEFAULT_MAX_FAILURE_DURATION_SECONDS);
String debug = System.getenv("GEMINI_DEBUG");
if ("true".equalsIgnoreCase(debug)) {
System.out.println("Gemini model: " + this.model);
}
}
private static String resolveModel() {
String envModel = System.getenv("GEMINI_MODEL");
if (envModel != null) {
envModel = envModel.trim();
}
return (envModel != null && !envModel.isBlank()) ? envModel : DEFAULT_MODEL;
}
/**
* Returns the Gemini model name being used.
*
* @return the model name
*/
@Override
public String getModel() {
return model;
}
/**
* Returns the number of API requests used in this session.
*
* @return the daily request count
*/
@Override
public int getDailyRequestCount() {
return dailyRequestCount;
}
/**
* Returns true if the daily API quota has not yet been exhausted.
*
* @return true if more requests can be made today
*/
@Override
public boolean hasRemainingQuota() {
return dailyRequestCount < MAX_DAILY_REQUESTS;
}
/**
* Returns true if no successful API call has been made for longer than
* the configured maximum failure duration.
*
* @return true if the API should be considered unavailable
*/
@Override
public boolean isApiUnavailable() {
return Duration.between(lastSuccessfulCall, Instant.now()).compareTo(maxFailureDuration) > 0;
}
/**
* Sets the maximum duration without a successful API call before the client
* is considered unavailable.
*
* @param maxFailureDuration the maximum failure duration
*/
@Override
public void setMaxFailureDuration(Duration maxFailureDuration) {
this.maxFailureDuration = maxFailureDuration;
}
/**
* Returns the configured maximum failure duration.
*
* @return the maximum failure duration
*/
@Override
public Duration getMaxFailureDuration() {
return maxFailureDuration;
}
/**
* Returns true if the last API response was truncated (finishReason=MAX_TOKENS).
*
* @return true if truncation was detected
*/
@Override
public boolean wasLastResponseTruncated() {
return lastResponseTruncated;
}
/**
* Evaluates a commit by sending a prompt to the Gemini API.
*
* @param prompt the constructed prompt
* @param commitHash the commit hash
* @param commitMessage the commit message
* @param repoUrl the repository URL
* @return the evaluation result, or null if the API call fails
* @throws IOException if an I/O error occurs
*/
@Override
public CommitEvaluation evaluate(String prompt, String commitHash,
String commitMessage, String repoUrl) throws IOException {
if (apiKey == null || apiKey.isBlank()) {
System.err.println("GEMINI_API_KEY not set, skipping evaluation");
return null;
}
rateLimit();
String requestBody = buildRequestBody(prompt);
String responseBody = sendWithRetry(requestBody);
if (responseBody == null) {
return null;
}
dailyRequestCount++;
return parseResponse(responseBody, commitHash, commitMessage, repoUrl);
}
/**
* Evaluates a batch of commits in a single API call.
*
* @param prompt the batch prompt
* @param commitHashes the commit hashes (in order)
* @param commitMessages the commit messages (in order)
* @param repoUrl the repository URL
* @return list of evaluations (one per commit, in order), never null
* @throws IOException if an I/O error occurs
*/
@Override
public List<CommitEvaluation> evaluateBatch(String prompt, List<String> commitHashes,
List<String> commitMessages, String repoUrl) throws IOException {
List<CommitEvaluation> results = new ArrayList<>();
if (apiKey == null || apiKey.isBlank()) {
System.err.println("GEMINI_API_KEY not set, skipping batch evaluation");
return results;
}
rateLimit();
String requestBody = buildRequestBody(prompt);
String responseBody = sendWithRetry(requestBody);
if (responseBody == null) {
return results;
}
dailyRequestCount++;
return parseBatchResponse(responseBody, commitHashes, commitMessages, repoUrl);
}
/**
* Parses a batch response JSON array into a list of CommitEvaluations.
* Each element in the array is matched by position to the corresponding commit.
*
* @param responseBody the raw API response
* @param commitHashes the commit hashes (in order)
* @param commitMessages the commit messages (in order)
* @param repoUrl the repository URL
* @return list of evaluations (one per commit, in order)
*/
public List<CommitEvaluation> parseBatchResponse(String responseBody, List<String> commitHashes,
List<String> commitMessages, String repoUrl) {
List<CommitEvaluation> results = new ArrayList<>();
lastResponseTruncated = false;
try {
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
JsonArray candidates = root.getAsJsonArray("candidates");
if (candidates == null || candidates.isEmpty()) {
return results;
}
JsonObject firstCandidate = candidates.get(0).getAsJsonObject();
// Check finishReason before parsing content
String finishReason = getStringOrNull(firstCandidate, "finishReason"); //$NON-NLS-1$
if ("MAX_TOKENS".equals(finishReason)) { //$NON-NLS-1$
System.err.println("Warning: Gemini response truncated (finishReason=MAX_TOKENS)"); //$NON-NLS-1$
lastResponseTruncated = true;
}
if ("SAFETY".equals(finishReason)) { //$NON-NLS-1$
System.err.println("Warning: Gemini refused (finishReason=SAFETY), skipping batch"); //$NON-NLS-1$
return results;
}
JsonObject content = firstCandidate.getAsJsonObject("content");
if (content == null) {
return results;
}
JsonArray parts = content.getAsJsonArray("parts");
if (parts == null || parts.isEmpty()) {
return results;
}
String text = parts.get(0).getAsJsonObject().get("text").getAsString();
String json = extractJson(text);
JsonArray evalArray;
try {
evalArray = JsonParser.parseString(json).getAsJsonArray();
} catch (Exception parseEx) {
// Try repair for truncated responses
String repaired = repairTruncatedJson(json);
try {
evalArray = JsonParser.parseString(repaired).getAsJsonArray();
System.err.println("Recovered partial batch response after JSON repair"); //$NON-NLS-1$
} catch (Exception e2) {
throw parseEx; // Rethrow original
}
}
int evalCount = evalArray.size();
int commitCount = Math.min(commitHashes.size(), commitMessages.size());
if (evalCount != commitCount) {
System.err.println("Warning: Gemini batch response count (" + evalCount //$NON-NLS-1$
+ ") does not match commit count (" + commitCount + "). Processing min of both."); //$NON-NLS-1$
}
int limit = Math.min(evalCount, commitCount);
for (int i = 0; i < limit; i++) {
String commitHash = commitHashes.get(i);
String commitMessage = commitMessages.get(i);
try {
JsonObject eval = evalArray.get(i).getAsJsonObject();
results.add(createEvaluation(eval, commitHash, commitMessage, repoUrl));
} catch (Exception e) {
System.err.println("Failed to parse batch evaluation at index " + i + ": " + e.getMessage());
}
}
} catch (Exception e) {
System.err.println("Failed to parse batch Gemini response: " + e.getMessage());
if (responseBody != null && Boolean.parseBoolean(System.getenv("GEMINI_DEBUG"))) { //$NON-NLS-1$
System.err.println("Raw response (first 500 chars): "
+ responseBody.substring(0, Math.min(500, responseBody.length())));
}
}
return results;
}
/**
* Builds the JSON request body for the Gemini API.
*
* @param prompt the prompt text
* @return the JSON request body
*/
public String buildRequestBody(String prompt) {
JsonObject content = new JsonObject();
JsonArray parts = new JsonArray();
JsonObject textPart = new JsonObject();
textPart.addProperty("text", prompt);
parts.add(textPart);
content.add("parts", parts);
JsonArray contents = new JsonArray();
contents.add(content);
JsonObject request = new JsonObject();
request.add("contents", contents);
return gson.toJson(request);
}
private String sendWithRetry(String requestBody) throws IOException {
int backoffMs = INITIAL_BACKOFF_MS;
boolean allAttempts429 = true;
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(String.format(API_URL_TEMPLATE, model, apiKey)))
.header("Content-Type", "application/json")
.timeout(REQUEST_TIMEOUT)
.POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() == 200) {
lastSuccessfulCall = Instant.now();
consecutive429Batches = 0;
// Gradually reduce delay after success
if (rateLimitDelayMs > RATE_LIMIT_DELAY_MS) {
rateLimitDelayMs = Math.max(RATE_LIMIT_DELAY_MS, rateLimitDelayMs / 2);
}
return response.body();
}
if (response.statusCode() == 429) {
String retryAfter = response.headers().firstValue("Retry-After").orElse(null); //$NON-NLS-1$
long waitMs = backoffMs;
boolean usedRetryAfter = false;
if (retryAfter != null) {
try {
long seconds = Long.parseLong(retryAfter.trim());
if (seconds >= 0) {
waitMs = seconds * 1000;
usedRetryAfter = true;
}
} catch (NumberFormatException nfe) {
System.err.println("Invalid Retry-After header value '" + retryAfter + "', using exponential backoff instead."); //$NON-NLS-1$ //$NON-NLS-2$
}
}
System.err.println("Rate limited (429), attempt " + (attempt + 1) + "/" + MAX_RETRIES //$NON-NLS-1$ //$NON-NLS-2$
+ ", waiting " + waitMs + "ms" //$NON-NLS-1$ //$NON-NLS-2$
+ (usedRetryAfter ? " (from Retry-After header)" : " (exponential backoff)")); //$NON-NLS-1$ //$NON-NLS-2$
Thread.sleep(waitMs);
backoffMs *= 2;
continue;
}
allAttempts429 = false;
System.err.println("Gemini API error: " + response.statusCode()
+ " - " + response.body());
return null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted during Gemini API call", e);
}
}
// Track consecutive 429 batches for faster abort
if (allAttempts429) {
consecutive429Batches++;
if (consecutive429Batches >= 2) {
System.err.println("Two consecutive batches entirely rate-limited (429). " //$NON-NLS-1$
+ "Marking API as unavailable."); //$NON-NLS-1$
// Force unavailable by backdating lastSuccessfulCall
lastSuccessfulCall = Instant.now().minus(maxFailureDuration.plusSeconds(1));
}
// Increase rate limit delay after 429 cascade
rateLimitDelayMs = Math.min(rateLimitDelayMs * 2, 120000);
}
System.err.println("Max retries exceeded for Gemini API call, entering post-failure cooldown of "
+ POST_FAILURE_COOLDOWN_MS + "ms");
try {
Thread.sleep(POST_FAILURE_COOLDOWN_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return null;
}
/**
* Parses the Gemini API response JSON into a CommitEvaluation.
*/
public CommitEvaluation parseResponse(String responseBody, String commitHash,
String commitMessage, String repoUrl) {
lastResponseTruncated = false;
try {
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
JsonArray candidates = root.getAsJsonArray("candidates");
if (candidates == null || candidates.isEmpty()) {
return null;
}
JsonObject firstCandidate = candidates.get(0).getAsJsonObject();
// Check finishReason before parsing content
String finishReason = getStringOrNull(firstCandidate, "finishReason"); //$NON-NLS-1$
if ("MAX_TOKENS".equals(finishReason)) { //$NON-NLS-1$
System.err.println("Warning: Gemini response truncated (finishReason=MAX_TOKENS)"); //$NON-NLS-1$
lastResponseTruncated = true;
}
if ("SAFETY".equals(finishReason)) { //$NON-NLS-1$
System.err.println("Warning: Gemini refused (finishReason=SAFETY), skipping commit"); //$NON-NLS-1$
return null;
}
JsonObject content = firstCandidate.getAsJsonObject("content");
if (content == null) {
return null;
}
JsonArray parts = content.getAsJsonArray("parts");
if (parts == null || parts.isEmpty()) {
return null;
}
String text = parts.get(0).getAsJsonObject().get("text").getAsString();
// Extract JSON from text (may be wrapped in markdown code blocks)
String json = extractJson(text);
JsonObject eval = JsonParser.parseString(json).getAsJsonObject();
return createEvaluation(eval, commitHash, commitMessage, repoUrl);
} catch (Exception e) {
System.err.println("Failed to parse Gemini response: " + e.getMessage());
if (responseBody != null && Boolean.parseBoolean(System.getenv("GEMINI_DEBUG"))) {
System.err.println("Raw response (first 500 chars): "
+ responseBody.substring(0, Math.min(500, responseBody.length())));
}
return null;
}
}
public static String extractJson(String text) {
// Try to extract JSON from markdown code blocks
int jsonStart = text.indexOf("```json");
if (jsonStart >= 0) {
int contentStart = text.indexOf('\n', jsonStart) + 1;
int contentEnd = text.indexOf("```", contentStart);
if (contentEnd > contentStart) {
return text.substring(contentStart, contentEnd).trim();
}
// Truncated code block (no closing ```): take rest of text
return repairTruncatedJson(text.substring(contentStart).trim());
}
// Try plain code blocks
jsonStart = text.indexOf("```");
if (jsonStart >= 0) {
int contentStart = text.indexOf('\n', jsonStart) + 1;
int contentEnd = text.indexOf("```", contentStart);
if (contentEnd > contentStart) {
return text.substring(contentStart, contentEnd).trim();
}
// Truncated code block (no closing ```): take rest of text
return repairTruncatedJson(text.substring(contentStart).trim());
}
// Assume the text itself is JSON; attempt repair if truncated
return repairTruncatedJson(text.trim());
}
/**
* Attempts to repair truncated JSON by closing unclosed brackets, braces,
* and strings. This handles the common case where Gemini's response is
* cut off mid-JSON due to token limits.
*
* @param json the potentially truncated JSON string
* @return the repaired JSON string
*/
public static String repairTruncatedJson(String json) {
if (json == null || json.isEmpty()) {
return json;
}
// Try parsing first; if it works, no repair needed
try {
JsonParser.parseString(json);
return json;
} catch (Exception e) {
// Fall through to repair
}
StringBuilder sb = new StringBuilder(json);
// Remove trailing comma if present (common in truncated arrays/objects)
String trimmed = sb.toString().trim();
if (trimmed.endsWith(",")) { //$NON-NLS-1$
sb = new StringBuilder(trimmed.substring(0, trimmed.length() - 1));
}
// Close unclosed strings, objects, and arrays
boolean inString = false;
int openBraces = 0;
int openBrackets = 0;
for (int i = 0; i < sb.length(); i++) {
char ch = sb.charAt(i);
if (ch == '\\' && inString && i + 1 < sb.length()) {
i++; // skip escaped character
continue;
}
if (ch == '"') {
inString = !inString;
} else if (!inString) {
if (ch == '{') {
openBraces++;
} else if (ch == '}') {
openBraces--;
} else if (ch == '[') {
openBrackets++;
} else if (ch == ']') {
openBrackets--;
}
}
}
if (inString) {
sb.append('"');
}
// Remove trailing comma after closing string
trimmed = sb.toString().trim();
if (trimmed.endsWith(",")) { //$NON-NLS-1$
sb = new StringBuilder(trimmed.substring(0, trimmed.length() - 1));
}
for (int i = 0; i < openBraces; i++) {
sb.append('}');
}
for (int i = 0; i < openBrackets; i++) {
sb.append(']');
}
return sb.toString();
}
private static CommitEvaluation.TrafficLight parseTrafficLight(String value) {
if (value == null) {
return CommitEvaluation.TrafficLight.NOT_APPLICABLE;
}
try {
return CommitEvaluation.TrafficLight.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
return CommitEvaluation.TrafficLight.NOT_APPLICABLE;
}
}
/**
* Creates a CommitEvaluation from a parsed JSON object with null-safe field handling.
*/
private static CommitEvaluation createEvaluation(JsonObject eval, String commitHash,
String commitMessage, String repoUrl) {
boolean relevant = getBooleanOrDefault(eval, "relevant", false); //$NON-NLS-1$
String category = getStringOrNull(eval, "category"); //$NON-NLS-1$
// Default category for relevant commits without one
if (relevant && (category == null || category.isBlank())) {
System.err.println("Warning: relevant commit " + commitHash //$NON-NLS-1$
+ " has no category, defaulting to 'Uncategorized'"); //$NON-NLS-1$
category = "Uncategorized"; //$NON-NLS-1$
}
String dslRule = sanitizeDslRule(getStringOrNull(eval, "dslRule")); //$NON-NLS-1$
String dslRuleAfterChange = sanitizeDslRule(getStringOrNull(eval, "dslRuleAfterChange")); //$NON-NLS-1$
return new CommitEvaluation(
commitHash,
commitMessage,
repoUrl,
Instant.now(),
null,
relevant,
getStringOrNull(eval, "irrelevantReason"), //$NON-NLS-1$
getBooleanOrDefault(eval, "isDuplicate", false), //$NON-NLS-1$
getStringOrNull(eval, "duplicateOf"), //$NON-NLS-1$
getIntOrDefault(eval, "reusability", 0), //$NON-NLS-1$
getIntOrDefault(eval, "codeImprovement", 0), //$NON-NLS-1$
getIntOrDefault(eval, "implementationEffort", 0), //$NON-NLS-1$
parseTrafficLight(getStringOrNull(eval, "trafficLight")), //$NON-NLS-1$
category,
getBooleanOrDefault(eval, "isNewCategory", false), //$NON-NLS-1$
getStringOrNull(eval, "categoryReason"), //$NON-NLS-1$
getBooleanOrDefault(eval, "canImplementInCurrentDsl", false), //$NON-NLS-1$
dslRule,
getStringOrNull(eval, "targetHintFile"), //$NON-NLS-1$
getStringOrNull(eval, "languageChangeNeeded"), //$NON-NLS-1$
dslRuleAfterChange,
getStringOrNull(eval, "summary"), //$NON-NLS-1$
null);
}
/**
* Strips XML tag hallucinations from DSL rule strings.
* LLMs sometimes wrap rules in {@code <trigger>}, {@code <import>},
* or {@code <pattern>} tags despite being told not to.
*
* @param dslRule the raw DSL rule string, or {@code null}
* @return the sanitized DSL rule, or {@code null} if the input was {@code null}
* or if the sanitized result is empty after stripping tags and trimming
*/
static String sanitizeDslRule(String dslRule) {
if (dslRule == null) {
return null;
}
String result = dslRule;
result = result.replaceAll("</?trigger>", "").trim(); //$NON-NLS-1$ //$NON-NLS-2$
result = result.replaceAll("</?pattern>", "").trim(); //$NON-NLS-1$ //$NON-NLS-2$
result = result.replaceAll("<import>[^<]*</import>", "").trim(); //$NON-NLS-1$ //$NON-NLS-2$
return result.isEmpty() ? null : result;
}
private static String getStringOrNull(JsonObject obj, String key) {
JsonElement element = obj.get(key);
return (element != null && !element.isJsonNull()) ? element.getAsString() : null;
}
private static boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultValue) {
JsonElement element = obj.get(key);
return (element != null && !element.isJsonNull()) ? element.getAsBoolean() : defaultValue;
}
private static int getIntOrDefault(JsonObject obj, String key, int defaultValue) {
JsonElement element = obj.get(key);
return (element != null && !element.isJsonNull()) ? element.getAsInt() : defaultValue;
}
private void rateLimit() {
long now = System.currentTimeMillis();
long elapsed = now - lastRequestTime;
if (elapsed < rateLimitDelayMs && lastRequestTime > 0) {
try {
Thread.sleep(rateLimitDelayMs - elapsed);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
lastRequestTime = System.currentTimeMillis();
}
@Override
public void close() {
httpClient.close();
}
}