Merge "Bump certificates-validity-checker version to 1.1"
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
+```