Offer only available Gemini chat models, newest first

The Gemini provider listed every model returned by ListModels on the
v1 API in API order, so the chat dropdown started with outdated or
unsuitable entries:

* gemini-2.5-pro is still listed, but generateContent fails with
  "This model models/gemini-2.5-pro is no longer available to new
  users".
* Image models such as gemini-2.5-flash-image were offered for a
  text-only code review.
* Preview models and the gemini-*-latest aliases, including the
  replacement Google suggests for gemini-2.5-pro, exist only on v1beta
  and were never offered.

Move all Gemini API calls to v1beta: not only the models listing but
also generateContent, which serves the actual reviews, since the
preview models and -latest aliases fail with HTTP 404 on v1. Request
up to 1000 models per page, as the v1beta catalog is larger than the
default page of 50. Drop the image, tts, transcribe, live,
customtools, robotics, computer-use, omni and nano-banana variants.

ListModels carries no lifecycle information, so probe every remaining
model with countTokens, which fails with HTTP 404 for retired models
like generateContent does, without consuming generation quota. Any
other failure keeps the model listed. The provider plugin caches the
resulting list for a day, so the probes run at most once per day.

Sort the -latest aliases first, then by version, newest first; within
a version pro comes before flash and flash-lite, and stable models
before previews.

Bug: https://github.com/GerritForge/ai-review-agent-provider/issues/8
Change-Id: Ibc3cdc71de7efc4b9f5cee02cacc2dcfef961fe6
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)