Introduce AI review chat integration with OpenRouter Add a Groovy script implementation of AiReviewProvider that wires the Gerrit AI Code Review chat to OpenRouter (https://openrouter.ai/), a unified gateway exposing 300+ LLMs from Anthropic, OpenAI, Google, DeepSeek, Meta, Qwen, xAI and others behind a single OpenAI-compatible API. The provider builds the dropdown at runtime from a small static list of OpenRouter floating aliases (`~vendor/model-latest`, always-fresh paid options) combined with a live `/api/v1/models` query that picks the highest-versioned DeepSeek `*-pro` slug and the top five free-tier slugs by a code-review heuristic (id keywords, reasoning support, big-vendor namespace, context length). The catalog result is cached for a day via Guava's memoizeWithExpiration and invalidated when a review call returns HTTP 404 (a previously listed slug went away upstream). On catalog fetch failure the dropdown falls back to the static floating aliases so it is never empty. The script also handles a few OpenRouter-specific quirks: * Single retry on HTTP 429 with a fixed back-off, followed by a user-facing rate-limit notice rendered as the assistant reply when the retry budget is exhausted, instead of surfacing a generic HTTP 500 in the chat panel. * Strip an outer triple-backtick markdown fence when the entire reply is wrapped in one, since gr-formatted-text would otherwise render the response as a literal code block (observed with the openai/gpt-oss-* models). * Prefix every non-empty reply with a newline so the response renders on its own line, separated from the "Gathering file contents and calling AI model ..." placeholder produced by the provider plugin. Add a companion README documenting installation, the dynamic model selection scheme, and OpenRouter API key setup. Change-Id: Ieaae90717b5c56e053d31eda4f4b286d7c1a366c
diff --git a/ai/ai-review-agent-openrouter-1.0.groovy b/ai/ai-review-agent-openrouter-1.0.groovy new file mode 100644 index 0000000..30f75f3 --- /dev/null +++ b/ai/ai-review-agent-openrouter-1.0.groovy
@@ -0,0 +1,271 @@ +// 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.base.Supplier +import com.google.common.base.Suppliers +import com.google.common.flogger.FluentLogger +import com.google.gerrit.extensions.registration.DynamicSet +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 java.util.concurrent.TimeUnit + +import groovy.json.* + +@Singleton +class AiOpenRouterReviewProvider implements AiReviewProvider { + private static final FluentLogger logger = FluentLogger.forEnclosingClass() + private static final String OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions' + private static final String OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models' + private static final String ATTRIBUTION_REFERER = 'https://gerrit-review.googlesource.com/' + private static final String ATTRIBUTION_TITLE = 'Gerrit AI Review' + private static final int MAX_ERROR_LEN = 500 + + // Single flat-window retry on 429 (AiHttpClient hides Retry-After). + private static final int MAX_RETRIES = 1 + private static final long RETRY_WAIT_MS = 5_000L + + // Floating aliases that always resolve to the latest vendor version. + // DeepSeek (no `~latest` alias) is sourced dynamically below. + private static final List<String> PAID_LATEST_MODELS = [ + '~anthropic/claude-opus-latest', + '~anthropic/claude-sonnet-latest', + '~google/gemini-pro-latest', + '~openai/gpt-latest', + '~anthropic/claude-haiku-latest', + '~google/gemini-flash-latest', + '~openai/gpt-mini-latest', + ] + + private static final int FREE_PICK_COUNT = 5 + private static final long CACHE_TTL_DAYS = 1L + + final String displayName = 'OpenRouter' + + @Inject + private AiHttpClient http + + // Refreshed once per TTL, or when review() sees a 404 (slug gone upstream). + private volatile Supplier<LinkedHashSet<String>> modelsSupplier = newModelsSupplier() + + private Supplier<LinkedHashSet<String>> newModelsSupplier() { + return Suppliers.memoizeWithExpiration( + { -> fetchAndMergeModels() } as Supplier, + CACHE_TTL_DAYS, TimeUnit.DAYS) + } + + @Override + Set<String> getModels(String notUsed) { + return modelsSupplier.get() + } + + // On failure, return paid-only fallback (also memoized for the TTL). + private LinkedHashSet<String> fetchAndMergeModels() { + try { + List<Map> catalog = http.get( + OPENROUTER_MODELS_URL, + [http.acceptApplicationJson()] as Header[], + { extractErrorMessage(it) }, + { extractCatalog(it) }) as List<Map> + + // Paid first (strongest quality), then DeepSeek flagship, then free picks. + LinkedHashSet<String> merged = new LinkedHashSet<>() + merged.addAll(PAID_LATEST_MODELS) + merged.addAll(latestDeepseekProFromCatalog(catalog)) + merged.addAll(topFreeFromCatalog(catalog)) + + logger.atInfo().log('OpenRouter catalog refreshed (%d models cached)', merged.size()) + return merged + } catch (JsonException | IOException e) { + logger.atWarning().withCause(e).log( + 'Failed to fetch OpenRouter catalog; falling back to ~latest paid models only') + return PAID_LATEST_MODELS as LinkedHashSet + } + } + + @Override + String review(String apiKey, String model, String prompt) { + Header[] headers = [ + http.contentTypeApplicationJson(), + new BasicHeader('Authorization', "Bearer ${apiKey}"), + new BasicHeader('HTTP-Referer', ATTRIBUTION_REFERER), + new BasicHeader('X-Title', ATTRIBUTION_TITLE), + ] as Header[] + def entity = new StringEntity( + new JsonBuilder([ + model : model, + messages: [[role: 'user', content: prompt]], + ]).toString(), + StandardCharsets.UTF_8) + + for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return http.post( + OPENROUTER_API_URL, + headers, + entity, + { extractErrorMessage(it) }, + { extractResponseText(it) }) as String + } catch (JsonException | IOException e) { + String msg = e.message ?: '' + if (msg.contains('[429]')) { + if (attempt < MAX_RETRIES) { + logger.atInfo().log( + 'OpenRouter 429 for model=%s, retrying after %d ms', + model, RETRY_WAIT_MS) + try { + Thread.sleep(RETRY_WAIT_MS) + } catch (InterruptedException ie) { + Thread.currentThread().interrupt() + throw new IllegalStateException( + 'Interrupted while retrying OpenRouter call', ie) + } + continue + } + logger.atInfo().log('OpenRouter 429 exhausted for model=%s', model) + return rateLimitMessage(model) + } + if (msg.contains('[404]')) { + logger.atInfo().log( + 'OpenRouter 404 for model=%s; invalidating catalog cache', model) + modelsSupplier = newModelsSupplier() + } + logger.atWarning().withCause(e).log('Failed to call OpenRouter API (model=%s)', model) + throw new IllegalStateException('Failed to call OpenRouter API', e) + } + } + throw new IllegalStateException('OpenRouter API: retry budget exhausted') + } + + private static String rateLimitMessage(String model) { + return "\n⚠️ **OpenRouter rate limit**\n\n" + + "Model `$model` is temporarily rate-limited by the upstream provider. " + + "Try again shortly, or pick a different model from the list." + } + + private static String extractResponseText(String body) { + def json = new JsonSlurper().parseText(body) + + def choice = json.choices?.find() + if (!choice) { + throw new IOException('OpenRouter API returned no choices') + } + + def content = choice.message?.content + if (!content) { + def reason = choice.finish_reason ? choice.finish_reason : 'unknown' + throw new IOException("OpenRouter 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> + } + + // Empirically more reliable free-tier namespaces (fewer 404 rotations). + private static final Set<String> BIG_VENDORS = [ + 'openai', 'anthropic', 'google', 'meta-llama', + 'deepseek', 'qwen', 'z-ai', 'mistralai', 'nvidia', + ] as Set + + // Heuristic "good for code review" score (catalog payload lacks benchmarks): + // +100 id contains `coder` / `code` — code-specialized + // +60 reasoning mode (supported_parameters or `thinking` in id) + // +40 big-vendor namespace (see BIG_VENDORS) + // +ctx/10k — tie-breaker + // Re-tune if real-world picks regress. + private static long scoreFreeModel(Map entry) { + String id = (entry.id as String) ?: '' + long score = 0L + if (id.contains('coder') || id.contains('code')) score += 100L + List params = (entry.supported_parameters as List) ?: [] + if (params.contains('reasoning') || id.contains('thinking')) score += 60L + String vendor = id.contains('/') ? id.substring(0, id.indexOf('/')) : '' + if (BIG_VENDORS.contains(vendor)) score += 40L + score += ((entry.context_length ?: 0) as long) / 10_000L + return score + } + + // Top FREE_PICK_COUNT by scoreFreeModel(); id sort breaks ties stably. + private static List<String> topFreeFromCatalog(List<Map> catalog) { + return catalog + .findAll { (it.id as String)?.endsWith(':free') } + .sort { a, b -> + long sb = scoreFreeModel(b) + long sa = scoreFreeModel(a) + if (sb != sa) return Long.compare(sb, sa) + return ((a.id as String) ?: '').compareTo((b.id as String) ?: '') + } + .take(FREE_PICK_COUNT) + .collect { it.id as String } + } + + // Approximate "deepseek-latest": highest version `deepseek/deepseek-v<N>-pro`. + private static List<String> latestDeepseekProFromCatalog(List<Map> catalog) { + def versionRegex = ~/^deepseek\/deepseek-v(\d+)(?:\.\d+)?-pro$/ + def versioned = catalog.findResults { entry -> + def id = entry.id as String + if (!id) return null + def m = versionRegex.matcher(id) + m.matches() ? [id: id, version: (m.group(1) as int)] : null + } + if (versioned.isEmpty()) return [] + return [(versioned.max { it.version }).id as String] + } + + // 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 AiOpenRouterModule extends AbstractModule { + @Override + protected void configure() { + DynamicSet.bind(binder(), AiReviewProvider).to(AiOpenRouterReviewProvider) + } +} + +module = AiOpenRouterModule
diff --git a/ai/ai-review-agent-openrouter.md b/ai/ai-review-agent-openrouter.md new file mode 100644 index 0000000..ab25f96 --- /dev/null +++ b/ai/ai-review-agent-openrouter.md
@@ -0,0 +1,102 @@ +# Gerrit AI Review Agent for OpenRouter + +Implementation of the Gerrit's AI Code Review Agent API on top of +[OpenRouter](https://openrouter.ai/), a unified gateway exposing 300+ LLMs +(Anthropic, OpenAI, Google, Meta, DeepSeek, xAI and more) behind a single +OpenAI-compatible API. + +[Install](#install-in-gerrit) this plugin and enable the Gerrit AI chat to enjoy +a side-by-side collaboration with the LLM of your choice on the Change screen. + +## 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 newer with the following additional plugins: + +- [Groovy scripting provider](https://github.com/GerritForge/groovy-provider/) +- [GerritForge's AI Review Agent Provider](https://github.com/GerritForge/ai-review-agent-provider) + +An OpenRouter API key: create one at https://openrouter.ai/keys. Free-tier +models (suffix `:free`) work with any key but are rate-limited; paid models +require credits on the account. + +### Available models + +The dropdown is assembled at runtime from a small static list combined with +a live query against the OpenRouter catalog (`/api/v1/models`). The catalog +result is cached for 24 hours, and re-fetched immediately if a review call +returns HTTP 404 (i.e. a previously listed slug went away upstream). + +**Static — paid floating aliases** (always-fresh, no script update needed): + +- `~anthropic/claude-opus-latest` — auto-tracks newest Opus +- `~anthropic/claude-sonnet-latest` — auto-tracks newest Sonnet +- `~openai/gpt-latest` — auto-tracks newest GPT +- `~google/gemini-pro-latest` — auto-tracks newest Gemini Pro +- `~anthropic/claude-haiku-latest` — cheaper/faster Anthropic +- `~google/gemini-flash-latest` — cheaper/faster Google +- `~openai/gpt-mini-latest` — cheaper/faster OpenAI + +**Dynamic — DeepSeek** (latest `*-pro` slug): the highest-versioned +`deepseek/deepseek-v<N>-pro` slug from the live catalog. OpenRouter does +not mint a floating alias for DeepSeek, so we approximate by parsing the +version number from the id and picking the largest. Currently that +resolves to `deepseek/deepseek-v4-pro` (1M context, top open-vendor +SWE-bench Verified). + +**Dynamic — free tier** (top 5 by code-review heuristic, picked live +from the catalog): typically GLM, GPT-OSS, Qwen3-coder variants +depending on what OpenRouter currently exposes. Free-tier slugs rotate +frequently; dynamic selection avoids stale hard-coded lists. Qwen +entries are often rate-limited (HTTP 429) under load — the script +retries once and then surfaces a user-facing notice in the chat panel. + +The heuristic combines four signals available in the catalog payload: + +| Signal | Weight | Why | +|---|---|---| +| id contains `coder` / `code` | +100 | direct evidence of a code-specialized model | +| reasoning mode (`supported_parameters` includes `reasoning`, or id contains `thinking`) | +60 | reasoning models generally outperform plain chat on code review | +| big-vendor namespace (`openai/`, `anthropic/`, `google/`, `meta-llama/`, `deepseek/`, `qwen/`, `z-ai/`, `mistralai/`, `nvidia/`) | +40 | empirically these slugs rotate less and respond more reliably | +| `context_length / 10000` | tie-breaker | more diff fits in prompt | + +The scoring is intentionally cheap to evaluate (no extra network calls) +and uses only fields present in the catalog response. It is a +heuristic, not a benchmark; if a vendor publishes a low-quality +"code-mini" free model it would still rank highly. Re-tune the weights +in the script if real-world picks regress. + +**Ordering**: the dropdown serves the paid options first (always-fresh +`~latest` aliases, then the dynamically-selected DeepSeek flagship), +followed by the five dynamic free picks. That matches the strongest +quality guarantees at the top of the chat panel selector. + +If the catalog fetch fails (network outage, etc.) the dropdown falls +back to the seven static `~latest` aliases above so reviews are not +blocked. + +Grok-4 is deliberately not surfaced: it ranks mid-pack on code-review +benchmarks (SWE-bench / Aider polyglot) compared to the Anthropic, +OpenAI, Google and DeepSeek frontier. It remains reachable through the +OpenRouter catalog for users who explicitly want it. + +### [Install in Gerrit](#install-in-gerrit) + +Copy the `ai-review-agent-openrouter-1.0.groovy` script into your Gerrit site (`$GERRIT_SITE`) +plugins' directory. + +```bash +cp ai-review-agent-openrouter-1.0.groovy "$GERRIT_SITE/plugins/" +```