Introduce AI review chat integration with any OpenAI compatible provider Change-Id: I5ebdd2a0186d355f62bc95c85e04c6927cb9e992
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 +```