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/admin/certificates-validity-checker-1.0.groovy b/admin/certificates-validity-checker-1.1.groovy
similarity index 64%
rename from admin/certificates-validity-checker-1.0.groovy
rename to admin/certificates-validity-checker-1.1.groovy
index 841efdf..dfa6dc7 100644
--- a/admin/certificates-validity-checker-1.0.groovy
+++ b/admin/certificates-validity-checker-1.1.groovy
@@ -27,13 +27,13 @@
 import com.google.gerrit.server.logging.Metadata
 import com.google.inject.Inject
 import com.google.inject.Singleton
-import sun.security.x509.GeneralNameInterface
 
 import javax.net.ssl.SSLSocket
 import javax.net.ssl.SSLSocketFactory
 import java.security.cert.Certificate
 import java.security.cert.X509Certificate
 import java.time.Duration
+import java.time.Instant
 import java.util.concurrent.ScheduledFuture
 
 import static java.util.concurrent.TimeUnit.HOURS
@@ -43,7 +43,9 @@
 @Singleton
 @Listen
 class CertificatesValidityChecker implements LifecycleListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass()
   private static final int DEFAULT_CHECK_INTERVAL_HOURS = 24
+  private static final int DEFAULT_CHECK_TIMEOUT_MSEC = 1000
   private final WorkQueue queue
   private final PluginConfigFactory config
   private final String pluginName
@@ -51,7 +53,7 @@
 
   private ScheduledFuture<?> certificatesValidityChecksTask
   private List<String> endpoints
-  private Long checkIntervalInMillis
+  private boolean started
 
   @Inject
   CertificatesValidityChecker(WorkQueue queue, PluginConfigFactory cfg,
@@ -65,14 +67,24 @@
 
   @Override
   void start() {
+    if (started) {
+      logger.atWarning().log("Plugin already started: ignoring duplicate start")
+      return
+    }
+
     endpoints = getEndpointsList(config, pluginName)
-    checkIntervalInMillis = getCheckIntervalMillis(config, pluginName)
+    long checkIntervalInHours = getCheckIntervalHours(config, pluginName)
+    int timeout = config.getGlobalPluginConfig(pluginName).getInt("validation",null,"timeout", DEFAULT_CHECK_TIMEOUT_MSEC)
+    logger.atInfo().log("Checking certificates expiry date every %d hours (timeout=%d msec) for %s",
+      checkIntervalInHours, timeout, endpoints)
+
     certificatesValidityChecksTask = queue.getDefaultQueue()
         .scheduleAtFixedRate(
-            new CheckCertificatesValidityTask(metrics, endpoints),
+            new CheckCertificatesValidityTask(metrics, endpoints, timeout),
             SECONDS.toMillis(1),
-            checkIntervalInMillis,
+            HOURS.toMillis(checkIntervalInHours),
             MILLISECONDS)
+    started = true
   }
 
   @Override
@@ -81,12 +93,13 @@
       certificatesValidityChecksTask.cancel(true)
       certificatesValidityChecksTask = null
     }
+    started = false
   }
 
-  private Long getCheckIntervalMillis(PluginConfigFactory cfg, String pluginName) {
+  private Long getCheckIntervalHours(PluginConfigFactory cfg, String pluginName) {
     String fromConfig =
         Strings.nullToEmpty(cfg.getGlobalPluginConfig(pluginName).getString("validation",null,"checkInterval"))
-    return HOURS.toMillis(ConfigUtil.getTimeUnit(fromConfig, DEFAULT_CHECK_INTERVAL_HOURS, HOURS))
+    return ConfigUtil.getTimeUnit(fromConfig, DEFAULT_CHECK_INTERVAL_HOURS, HOURS)
   }
 
   private List<String> getEndpointsList(PluginConfigFactory cfg, String pluginName) {
@@ -117,36 +130,57 @@
 
   private static class CheckCertificatesValidityTask implements Runnable {
     private static final FluentLogger logger = FluentLogger.forEnclosingClass()
+    private static final int DNS_TYPE = 2 // The GeneralNameInterface.NAME_DNS value, inaccessible because of being an internal package
+
     private final CertificatesCheckMetrics metrics
     private final List<String> endpoints
+    private final int timeout
 
-    CheckCertificatesValidityTask(CertificatesCheckMetrics metrics, List<String> endpoints) {
+    CheckCertificatesValidityTask(CertificatesCheckMetrics metrics, List<String> endpoints, int timeout) {
       this.endpoints = endpoints
       this.metrics = metrics
+      this.timeout = timeout
     }
 
     @Override
     void run() {
       for (String endpoint : endpoints) {
-        logger.atInfo().log("Checking certificate expiry date for %s endpoint", endpoint)
+        logger.atFine().log("Checking certificate expiry date for %s endpoint", endpoint)
         SSLSocket conn
         try {
           def (hostname, port) = parseEndpoint(endpoint)
           conn = openConnection(hostname as String, port as int)
-          conn.startHandshake();
-          Certificate[] certs = conn.getSession().getPeerCertificates();
-          for (Certificate cert : certs) {
-            if (cert instanceof X509Certificate &&
-              cert.getSubjectAlternativeNames().findAll{it[0] == GeneralNameInterface.NAME_DNS}
-                  .any {isHostnameMatching(hostname as String, it.get(1) as String) }) {
-              def numberOfDaysToExpire = Duration
-                  .between(new Date().toInstant(), cert.notAfter.toInstant()).toDays()
-              metrics
-                  .setMetric(
-                      hostname as String,
-                      numberOfDaysToExpire.intValue())
+          conn.setSoTimeout(timeout)
+          conn.startHandshake()
+          X509Certificate[] certs = conn.getSession().getPeerCertificates().findAll {it instanceof X509Certificate}
+
+          for (X509Certificate cert : certs) {
+            def certsAlternativeNames = cert.subjectAlternativeNames
+            def certsDnsNames = certsAlternativeNames.findAll{it[0] == DNS_TYPE}.collect{it[1]}
+            if (certsDnsNames.empty) {
+              logger.atFine().log("[%s] Skipping X.509 Certificate %s because there are no subjectAlternativeNames of DNS type", endpoint, cert)
+              continue
+            }
+
+            logger.atFine().log("[%s] Checking X.509 DNS names %s against %s:%d", endpoint, certsDnsNames, hostname, port)
+            def anyCertIsMatchingDnsNames = certsDnsNames.any{isHostnameMatching(hostname as String, it as String)}
+            if (!anyCertIsMatchingDnsNames) {
+              logger.atWarning().log("[%s] Skipping X.509 Certificate %s because none of the certificate DNS names %s are matching the hostname %s", endpoint, cert, certsDnsNames, hostname)
+              continue
+            }
+
+            logger.atFine().log("[%s] X.509 Certificate %s has expiry date %s", endpoint, cert.subjectDN, cert.notAfter)
+            def numberOfDaysToExpire = Duration
+                .between(Instant.now(), cert.notAfter.toInstant()).toDays()
+            metrics
+                .setMetric(
+                    hostname as String,
+                    numberOfDaysToExpire.intValue())
+
+            if (numberOfDaysToExpire >= 0) {
+              logger.atInfo().log("[%s] X.509 Certificate %s is valid and has %d days left", endpoint, cert.subjectDN, numberOfDaysToExpire)
             } else {
-              logger.atFine().log("Certificate type %s is not a valid X.509 certificate for the specified endpoint: %s. Skipping!", cert.getType(), endpoint)
+              logger.atWarning().log("[%s] X.509 Certificate for %s **EXPIRED**", endpoint, cert.subjectDN)
             }
           }
         } catch(e) {
@@ -175,10 +209,6 @@
     }
 
     private SSLSocket openConnection(String hostname, int port) {
-      logger
-          .atInfo()
-          .log("Opening connection for %s endpoint successful",
-              hostname)
       (SSLSocket) SSLSocketFactory.getDefault()
           .createSocket(hostname, port);
     }
diff --git a/ai/ai-review-agent-openai-compatible-1.0.groovy b/ai/ai-review-agent-openai-compatible-1.0.groovy
new file mode 100644
index 0000000..3eff412
--- /dev/null
+++ b/ai/ai-review-agent-openai-compatible-1.0.groovy
@@ -0,0 +1,150 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import com.gerritforge.gerrit.plugins.ai.provider.api.*
+
+import com.google.common.flogger.FluentLogger
+import com.google.gerrit.extensions.annotations.PluginName
+import com.google.gerrit.extensions.registration.DynamicSet
+import com.google.gerrit.server.config.PluginConfig
+import com.google.gerrit.server.config.PluginConfigFactory
+import com.google.inject.*
+
+import org.apache.http.*
+import org.apache.http.message.*
+import org.apache.http.entity.StringEntity
+
+import java.nio.charset.StandardCharsets
+
+import groovy.json.*
+
+@Singleton
+class AiOpenAiCompatibleReviewProvider implements AiReviewProvider {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass()
+  private static final String DEFAULT_URL = 'http://localhost:3000/api/v1'
+  private static final int MAX_ERROR_LEN = 500
+
+  final String displayName = 'OpenAI Compatible'
+
+  private final String baseUrl
+  private final AiHttpClient http
+
+  @Inject
+  AiOpenAiCompatibleReviewProvider(PluginConfigFactory configFactory, @PluginName String pluginName, AiHttpClient http) {
+    def config = configFactory.getFromGerritConfig(pluginName)
+    this.baseUrl = config.getString('baseUrl', DEFAULT_URL)
+    this.http = http
+  }
+
+  @Override
+  Set<String> getModels(String apiKey) {
+    List<Map> catalog = http.get(
+        baseUrl + '/models',
+        [
+            http.acceptApplicationJson(),
+            new BasicHeader('Authorization', "Bearer ${apiKey}"),
+        ] as Header[],
+        { extractErrorMessage(it) },
+        { extractCatalog(it) }) as List<Map>
+
+    def ids = new LinkedHashSet<>(catalog.collect {it.id as String})
+    logger.atInfo().log('Open AI Compatible catalog refreshed (%d models cached)', ids.size())
+    return ids
+
+  }
+
+  @Override
+  String review(String apiKey, String model, String prompt) {
+    Header[] headers = [
+        http.contentTypeApplicationJson(),
+        new BasicHeader('Authorization', "Bearer ${apiKey}"),
+    ] as Header[]
+    def entity = new StringEntity(
+        new JsonBuilder([
+            model   : model,
+            messages: [[role: 'user', content: prompt]],
+        ]).toString(),
+        StandardCharsets.UTF_8)
+
+    try {
+      return http.post(
+          baseUrl + '/chat/completions',
+          headers,
+          entity,
+          { extractErrorMessage(it) },
+          { extractResponseText(it) }) as String
+    } catch (JsonException | IOException e) {
+      logger.atWarning().withCause(e).log('Failed to call Open AI Compatible API (model=%s)', model)
+      throw new IllegalStateException('Failed to call Open AI Compatible API', e)
+    }
+  }
+
+  private static String extractResponseText(String body) {
+    def json = new JsonSlurper().parseText(body)
+
+    def choice = json.choices?.find()
+    if (!choice) {
+      throw new IOException('Open AI Compatible API returned no choices')
+    }
+
+    def content = choice.message?.content
+    if (!content) {
+      def reason = choice.finish_reason ? choice.finish_reason : 'unknown'
+      throw new IOException("Open AI Compatible API choice has no content, finish_reason=$reason")
+    }
+
+    // Prefix \n so the reply sits below the "Gathering ..." placeholder.
+    String text = unwrapOuterMarkdownFence(content as String)
+    return text ? "\n${text}" : text
+  }
+
+  private static List<Map> extractCatalog(String body) {
+    def json = new JsonSlurper().parseText(body)
+    return (json?.data ?: []) as List<Map>
+  }
+
+  // Strip outer ```lang ... ``` wrap (gpt-oss-* habit) so the chat panel
+  // renders it as markdown, not a literal code block.
+  private static String unwrapOuterMarkdownFence(String text) {
+    if (!text) return text
+    String trimmed = text.trim()
+    if (!trimmed.startsWith('```') || !trimmed.endsWith('```')) return text
+    int firstNewline = trimmed.indexOf('\n')
+    if (firstNewline < 0) return text
+    String openFence = trimmed.substring(0, firstNewline).trim()
+    if (!(openFence ==~ /```[A-Za-z0-9_+\-]*/)) return text
+    String inner = trimmed.substring(firstNewline + 1, trimmed.length() - 3)
+    if (inner.contains('```')) return text
+    return inner.trim()
+  }
+
+  private static String extractErrorMessage(String body) {
+    try {
+      def json = new JsonSlurper().parseText(body)
+      if (json?.error) return "[${json.error.code}] ${json.error.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
+  }
+}
+
+class AiOpenAiCompatibleModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), AiReviewProvider).to(AiOpenAiCompatibleReviewProvider)
+  }
+}
+
+module = AiOpenAiCompatibleModule
diff --git a/ai/ai-review-agent-openai-compatible.md b/ai/ai-review-agent-openai-compatible.md
new file mode 100644
index 0000000..ebbb1e9
--- /dev/null
+++ b/ai/ai-review-agent-openai-compatible.md
@@ -0,0 +1,46 @@
+# Gerrit AI Review Agent for OpenAI Compatible provider
+
+Implementation of the Gerrit's AI Code Review Agent API on top of an OpenAI Compatible provider.
+
+[Install](#install-in-gerrit) this plugin and enable the Gerrit AI chat to enjoy
+a side-by-side collaboration with OpenAI Compatible provider on the Change screen.
+
+This plugin has been validated against [Open WebUI](https://openwebui.com/).
+
+## License
+
+This script is licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+## How to use
+
+### Prerequisites
+
+Gerrit v3.14 or later with the following additional plugins:
+
+- [Groovy scripting provider](https://gerrit.googlesource.com/plugins/scripting/groovy-provider/)
+- [GerritForge's AI Review Agent Provider](https://github.com/GerritForge/ai-review-agent-provider)
+
+### [Install in Gerrit](#install-in-gerrit)
+
+Copy the `ai-review-agent-openai-compatible-1.0.groovy` script into your Gerrit site (`$GERRIT_SITE`)
+plugins' directory.
+
+```bash
+cp ai-review-agent-openai-compatible-1.0.groovy "$GERRIT_SITE/plugins/"
+```
+
+### Configuration
+
+Configure the OpenAI Compatible provider API URL in the `gerrit.config` like this:
+
+```ini
+[plugin "ai-review-agent-openai-compatible"]
+   baseUrl = http://openai-compatible-server:3000/api/v1
+```