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 +```