Merge branch 'stable-3.14' * stable-3.14: Offer only available Gemini chat models, newest first Preserve upstream provider detail in OpenRouter errors Change-Id: I2683c7810a94ef1016a300967e0424fa705bdccc
diff --git a/ai/ai-review-agent-gemini-1.0.groovy b/ai/ai-review-agent-gemini-1.0.groovy index 0dcd157..2c89715 100644 --- a/ai/ai-review-agent-gemini-1.0.groovy +++ b/ai/ai-review-agent-gemini-1.0.groovy
@@ -19,6 +19,7 @@ import com.google.inject.* import org.apache.http.* +import org.apache.http.client.methods.HttpPost import org.apache.http.message.* import org.apache.http.entity.StringEntity @@ -29,9 +30,16 @@ @Singleton class AiGeminiReviewProvider implements AiReviewProvider { private static final FluentLogger logger = FluentLogger.forEnclosingClass() - private static final String GEMINI_API_URL_BASE = 'https://generativelanguage.googleapis.com/v1/models' + // v1beta is intended for all calls (models listing, countTokens and generateContent): + // preview models and the `-latest` aliases are not exposed on v1 and fail there with 404. + private static final String GEMINI_API_URL_BASE = 'https://generativelanguage.googleapis.com/v1beta/models' private static final String API_KEY_HEADER = 'x-goog-api-key' private static final int MAX_ERROR_LEN = 500 + // Gemini variants that are not suitable for a text-only code review chat. + private static final def NON_CHAT_MODEL = + ~/-(image|tts|transcribe|live|custom-?tools)\b|robotics|computer-use|omni|nano-banana/ + private static final def LATEST_ALIAS = ~/^gemini-(.+)-latest$/ + private static final def MODEL_VERSION = ~/^gemini-(\d+(?:\.\d+)*)-/ final String displayName = 'Gemini' @@ -40,8 +48,10 @@ @Override Set<String> getModels(String apiKey) { + Set<String> listed try { - http.get(GEMINI_API_URL_BASE, + // Default page size (50) is smaller than the v1beta catalog. + listed = http.get("${GEMINI_API_URL_BASE}?pageSize=1000", [http.acceptApplicationJson(), apiKeyHeader(apiKey)] as Header[], { extractErrorMessage(it) }, { extractModels(it) }) @@ -49,6 +59,36 @@ logger.atWarning().withCause(e).log('Failed to call Gemini API to fetch models') return [] as Set } + + def available = listed.findAll { isAvailable(apiKey, it) } + if (!available) { + logger.atWarning().log('None of the Gemini models listed for this key is available') + } + new LinkedHashSet<>(available.sort(false) { a, b -> compareModels(a, b) }) + } + + /** + * ListModels also returns models that are retired for new users and fail with HTTP 404 on + * generateContent. countTokens fails the same way but does not consume generation quota. + * Any other failure (quota, network) keeps the model listed. + */ + private boolean isAvailable(String apiKey, String model) { + def request = new HttpPost("${GEMINI_API_URL_BASE}/${model}:countTokens") + request.setHeaders([http.contentTypeApplicationJson(), apiKeyHeader(apiKey)] as Header[]) + request.setEntity(new StringEntity(new JsonBuilder([contents: [[parts: [[text: 'ping']]]]]).toString(), + StandardCharsets.UTF_8)) + try { + http.execute(request, + { it != HttpStatus.SC_NOT_FOUND } as StatusCodeHandler, + { extractErrorMessage(it) }, + { true }) + } catch (AiCodeReviewException e) { + logger.atInfo().log('Gemini model %s is not available: %s', model, e.message) + false + } catch (IOException e) { + logger.atWarning().withCause(e).log('Failed to check availability of Gemini model %s', model) + true + } } @Override @@ -76,7 +116,7 @@ def fetchedModels = json.models?.findAll { it.supportedGenerationMethods?.contains('generateContent') && it.name?.startsWith('models/gemini') - }?.collect { it.name.replace('models/', '') } as Set + }?.collect { it.name.replace('models/', '') }?.findAll { !(it =~ NON_CHAT_MODEL) } as Set if (!fetchedModels) { logger.atWarning().log("Gemini did not return any model enabled for this key") @@ -86,6 +126,41 @@ } } + /** + * `-latest` aliases first, then newest version first; within a version pro, flash, flash-lite, + * with stable models before previews. + */ + private static int compareModels(String a, String b) { + boolean aLatest = a ==~ LATEST_ALIAS + boolean bLatest = b ==~ LATEST_ALIAS + if (aLatest != bLatest) return aLatest ? -1 : 1 + int byVersion = compareVersions(versionOf(b), versionOf(a)) + if (byVersion != 0) return byVersion + int byTier = tierOf(a) <=> tierOf(b) + if (byTier != 0) return byTier + int byPreview = a.contains('-preview') <=> b.contains('-preview') + byPreview != 0 ? byPreview : a <=> b + } + + private static List<Integer> versionOf(String model) { + def matcher = model =~ MODEL_VERSION + matcher.find() ? matcher.group(1).tokenize('.').collect { it as int } : [] + } + + private static int compareVersions(List<Integer> a, List<Integer> b) { + for (int i = 0; i < Math.max(a.size(), b.size()); i++) { + int byPart = (i < a.size() ? a[i] : 0) <=> (i < b.size() ? b[i] : 0) + if (byPart != 0) return byPart + } + a.size() <=> b.size() + } + + private static int tierOf(String model) { + if (model.contains('-pro')) return 0 + if (model.contains('-flash-lite')) return 2 + model.contains('-flash') ? 1 : 3 + } + private static String extractResponseText(String body) { def json = new JsonSlurper().parseText(body)
diff --git a/ai/ai-review-agent-openrouter-1.0.groovy b/ai/ai-review-agent-openrouter-1.0.groovy index 7cf579d..4b504ed 100644 --- a/ai/ai-review-agent-openrouter-1.0.groovy +++ b/ai/ai-review-agent-openrouter-1.0.groovy
@@ -200,12 +200,26 @@ private static String extractErrorMessage(String body) { try { def json = new JsonSlurper().parseText(body) - if (json?.error) return "[${json.error.code}] ${json.error.message}" + if (json?.error) { + def message = json.error.message as String + def detail = formatErrorMetadata(json.error.metadata) + return detail ? "${message}: ${detail}" : message + } } catch (Exception e) { logger.atWarning().withCause(e).log('Failed to parse error response') } return body.length() > MAX_ERROR_LEN ? "${body.take(MAX_ERROR_LEN)}..." : body } + + // OpenRouter wraps the real upstream failure under `error.metadata`. + // Without this, the user only sees the generic "Provider returned error". + private static String formatErrorMetadata(metadata) { + if (!metadata) return null + def parts = [] + if (metadata.provider_name) parts << "provider=${metadata.provider_name}" + if (metadata.raw) parts << (metadata.raw as String) + return parts ? parts.join(', ') : null + } } class AiOpenRouterModule extends AbstractModule {