fix(extensions): generate provider catalogs from Go registry (#1212)

* fix(frontend): generate provider presets from Go registry

* fix(frontend): regenerate provider presets after main sync

* fix(ci): compare provider presets with Go registry

* fix(gen): generate Kotlin names and reject duplicate models

* docs: tighten provider generation guidance

* docs: keep provider generation instructions in AGENTS.md

* docs: Apply batched suggestions from code review

Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>

* fix(gen): validate generated import and package declarations

---------

Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>
Co-authored-by: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com>
This commit is contained in:
祈愿Qiii 2026-09-29 11:46:45 +08:00 • committed by GitHub
parent ebb69835a3
commit f93ff155dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1651 additions and 184 deletions

View file

@ -34,6 +34,9 @@ jobs:
- name: Trust workspace
run: git config --global --replace-all safe.directory '*'
- name: Verify generated provider presets
run: go run ./internal/llm/gen -check -output extensions/frontend/src/shared/providers.generated.ts -kotlin-output extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ProviderNames.generated.kt
- name: Verify license headers
run: bash scripts/verify-license.sh

View file

@ -5,14 +5,15 @@ name: IDEA Extension
# toolchain; the webview bundle itself is covered by frontend-ext.yml.
# The full IntelliJ Plugin Verifier (verifyPlugin) downloads IDE builds and
# runs for minutes, so it stays a release-grade check and out of the PR path.
# Scoped with paths: so it only runs when the plugin or this workflow itself
# changes.
# Also runs for shared frontend changes, which the host/frontend consistency
# tests read directly.
on:
pull_request:
branches: [main]
paths:
- 'extensions/idea/**'
- 'extensions/frontend/**'
- '.github/workflows/idea-ext.yml'
permissions:

View file

@ -42,6 +42,16 @@ open-code-review (`ocr`) is an AI-powered code review CLI tool written in Go (mo
- **Translated prose has its own homes, none of them scanned.** `docs/i18n/README.<locale>.md` and `docs/i18n/CONTRIBUTING.<locale>.md` (`zh-CN`, `ja-JP`, `ko-KR`, `ru-RU`); the doc pages under `pages/src/content/docs/<locale>/` (`en`, `zh`, `ja`, `ru`, Markdown throughout); and the UI copy tables in `pages/src/i18n/<locale>.ts`. Markdown is out of scope by extension, so translations go there freely. The i18n tables are `.ts` and would be scanned, so they are exempt by prefix instead — translated UI strings belong in those tables rather than inline in a component.
- **Two escape hatches for the exceptional case, narrower one preferred.** Append an `allow-non-english: <reason>` marker comment to the offending line — the right choice for a handful of lines, such as an encoding fixture or a language-switcher label, and it leaves the rest of the file protected. Only for a whole tree that is inherently non-English, add a prefix to `allowedPrefixes` in `scripts/verify-english-only.go`; that list records each exemption's reason and the removal conditions for temporary translation exemptions.
## Provider Presets
When changing built-in provider metadata or model lists in
`internal/llm/providers.go`, run `go generate ./internal/llm` from the
repository root. Commit both generated files with the registry change;
do not edit them by hand:
- `extensions/frontend/src/shared/providers.generated.ts`
- `extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ProviderNames.generated.kt`
## Testing
- Run unit tests with `make test`, not `go test` directly.

View file

@ -0,0 +1,73 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
import { PROVIDER_PRESETS as generatedPresets } from '../providers.generated';
import { isPresetProvider, lookupPreset, mergeModelLists, PROVIDER_PRESETS, usesAmbientAuth } from '../providers';
import { buildOfficialSaveEntries, detectInitialTab, isConfigReady } from '../configUtils';
import { OcrConfig } from '../types';
describe('generated provider presets', () => {
it('keeps providers sorted by name with the existing initial provider', () => {
const names = PROVIDER_PRESETS.map((preset) => preset.name);
expect(names).toEqual([...names].sort());
expect(PROVIDER_PRESETS[0].name).toBe('anthropic');
});
it.each(generatedPresets)('preserves metadata, model order and the default for $name', (preset) => {
const found = lookupPreset(` ${preset.name.toUpperCase()} `);
expect(found).toEqual(preset);
expect(isPresetProvider(` ${preset.name.toUpperCase()} `)).toBe(true);
const userModels = preset.models.length > 0 ? ['user-model', preset.models[0]] : ['user-model'];
const models = mergeModelLists(found?.models ?? [], userModels);
expect(models).toEqual([...preset.models, 'user-model']);
if (preset.models.length > 0) {
expect(models[0]).toBe(preset.models[0]);
} else {
expect(models).toEqual(['user-model']);
}
});
it.each(['bedrock', 'openai-responses'])('uses the built-in configuration path for %s', (name) => {
const preset = lookupPreset(name);
expect(preset).toBeDefined();
const model = preset?.models[0] ?? '';
const config: OcrConfig = {
provider: name,
model: '',
providers: { [name]: { model } },
customProviders: {},
llm: { url: '', authToken: '', model: '', useAnthropic: false },
language: 'English',
};
expect(detectInitialTab(config)).toBe('official');
expect(isConfigReady(config)).toBe(true);
expect(buildOfficialSaveEntries(name, model, '', false)).toEqual([
{ key: 'provider', value: name },
{ key: `providers.${name}.model`, value: model },
]);
});
it('preserves the Bedrock authentication and Responses protocol metadata', () => {
expect(lookupPreset('bedrock')).toMatchObject({
protocol: 'anthropic-bedrock', ambientAuth: true, envVar: '', baseUrl: '',
});
expect(lookupPreset('openai-responses')?.protocol).toBe('openai-responses');
expect(lookupPreset('anthropic')?.authHeader).toBe('x-api-key');
});
it('derives ambient authentication from the effective protocol', () => {
const bedrock = lookupPreset('bedrock');
const openai = lookupPreset('openai');
expect(bedrock).toBeDefined();
expect(openai).toBeDefined();
expect(usesAmbientAuth(bedrock!)).toBe(true);
expect(usesAmbientAuth(bedrock!, 'openai')).toBe(false);
expect(usesAmbientAuth(openai!, 'anthropic-bedrock')).toBe(true);
expect(usesAmbientAuth(openai!, ' OPENAI ')).toBe(false);
});
it('keeps unknown providers outside the built-in preset path', () => {
expect(lookupPreset('my-custom-provider')).toBeUndefined();
expect(isPresetProvider('my-custom-provider')).toBe(false);
});
});

View file

@ -0,0 +1,456 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
// Code generated by go generate ./internal/llm; DO NOT EDIT.
// Source: internal/llm/providers.go. Providers are sorted by name; model order is preserved.
import type { OcrProviderPreset } from './providers';
export const PROVIDER_PRESETS: OcrProviderPreset[] = [
{
"name": "anthropic",
"displayName": "Anthropic Claude API",
"protocol": "anthropic",
"baseUrl": "https://api.anthropic.com",
"authHeader": "x-api-key",
"envVar": "ANTHROPIC_API_KEY",
"models": [
"claude-opus-5",
"claude-sonnet-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6"
]
},
{
"name": "baidu-qianfan",
"displayName": "Baidu Qianfan API",
"protocol": "openai",
"baseUrl": "https://qianfan.baidubce.com/v2",
"envVar": "QIANFAN_API_KEY",
"models": [
"ernie-5.1",
"ernie-5.0",
"ernie-x1.1",
"ernie-x1-turbo-32k-preview",
"deepseek-v4-pro",
"deepseek-v4-flash",
"glm-5.2",
"glm-5.1",
"glm-5",
"kimi-k2.6"
]
},
{
"name": "bedrock",
"displayName": "AWS Bedrock (Anthropic models)",
"protocol": "anthropic-bedrock",
"baseUrl": "",
"envVar": "",
"ambientAuth": true,
"models": [
"us.anthropic.claude-opus-5",
"us.anthropic.claude-sonnet-5",
"us.anthropic.claude-opus-4-8",
"us.anthropic.claude-opus-4-7",
"us.anthropic.claude-sonnet-4-6",
"global.anthropic.claude-opus-5",
"global.anthropic.claude-sonnet-5",
"global.anthropic.claude-opus-4-8"
]
},
{
"name": "dashscope",
"displayName": "Alibaba DashScope API",
"protocol": "openai",
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"envVar": "DASHSCOPE_API_KEY",
"models": [
"qwen3.8-max",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash",
"kimi-k2.7-code",
"glm-5.2",
"MiniMax-M2.5"
]
},
{
"name": "dashscope-tokenplan",
"displayName": "Alibaba DashScope Token Plan API",
"protocol": "openai",
"baseUrl": "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
"envVar": "DASHSCOPE_TOKENPLAN_KEY",
"models": [
"qwen3.8-max",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash",
"kimi-k2.7-code",
"kimi-k2.6",
"kimi-k2.5",
"glm-5.2",
"glm-5.1",
"glm-5",
"MiniMax-M2.5"
]
},
{
"name": "deepseek",
"displayName": "DeepSeek API",
"protocol": "openai",
"baseUrl": "https://api.deepseek.com",
"envVar": "DEEPSEEK_API_KEY",
"models": [
"deepseek-v4-pro",
"deepseek-flash"
]
},
{
"name": "edenai",
"displayName": "Eden AI",
"protocol": "openai",
"baseUrl": "https://api.edenai.run/v3",
"envVar": "EDENAI_API_KEY",
"models": [
"anthropic/claude-opus-4-5",
"anthropic/claude-sonnet-4-5",
"anthropic/claude-haiku-4-5",
"openai/gpt-5.1",
"openai/gpt-5.1-codex",
"google/gemini-3.1-pro-preview",
"mistral/devstral-medium-latest",
"mistral/codestral-latest",
"deepseek/deepseek-v4-pro",
"xai/grok-4"
]
},
{
"name": "gemini",
"displayName": "Google Gemini API",
"protocol": "openai",
"baseUrl": "https://generativelanguage.googleapis.com/v1beta/openai",
"envVar": "GEMINI_API_KEY",
"models": [
"gemini-3-flash-preview",
"gemini-3.1-flash-lite",
"gemini-3.1-pro",
"gemini-3.5-flash-lite",
"gemini-3.5-flash",
"gemini-3.6-flash"
]
},
{
"name": "hy-tokenplan",
"displayName": "Tencent Hunyuan Token Plan API",
"protocol": "openai",
"baseUrl": "https://api.lkeap.cloud.tencent.com/plan/v3",
"envVar": "TENCENT_HUNYUAN_TOKENPLAN_KEY",
"models": [
"hy3"
]
},
{
"name": "iflytek",
"displayName": "iFlytek Spark API",
"protocol": "openai",
"baseUrl": "https://spark-api-open.xf-yun.com/v1",
"envVar": "SPARK_API_KEY",
"models": [
"4.0Ultra",
"generalv3.5",
"max-32k",
"generalv3",
"pro-128k",
"lite"
]
},
{
"name": "kimi",
"displayName": "Kimi Moonshot API",
"protocol": "openai",
"baseUrl": "https://api.moonshot.cn/v1",
"envVar": "MOONSHOT_API_KEY",
"models": [
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.7-code-highspeed",
"kimi-k2.6",
"kimi-k2.5"
]
},
{
"name": "kimi-global",
"displayName": "Kimi Moonshot API (Global)",
"protocol": "openai",
"baseUrl": "https://api.moonshot.ai/v1",
"envVar": "MOONSHOT_GLOBAL_API_KEY",
"models": [
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.7-code-highspeed",
"kimi-k2.6",
"kimi-k2.5"
]
},
{
"name": "litellm",
"displayName": "LiteLLM AI Gateway",
"protocol": "openai",
"baseUrl": "http://localhost:4000/v1",
"envVar": "LITELLM_API_KEY",
"models": [
"anthropic/claude-sonnet-4-6",
"anthropic/claude-opus-4-6",
"anthropic/claude-haiku-4-5",
"openai/gpt-4o",
"openai/gpt-5.4",
"openai/o3",
"vertex_ai/gemini-2.5-flash",
"vertex_ai/gemini-2.5-pro",
"bedrock/anthropic.claude-sonnet-4-6-v1",
"groq/llama-4-scout-17b-16e-instruct",
"mistral/mistral-large-latest",
"deepseek/deepseek-chat"
]
},
{
"name": "mimo",
"displayName": "Xiaomi MiMo API",
"protocol": "openai",
"baseUrl": "https://api.xiaomimimo.com/v1",
"envVar": "MIMO_API_KEY",
"models": [
"mimo-v2.5-pro",
"mimo-v2.5"
]
},
{
"name": "minimax",
"displayName": "MiniMax API",
"protocol": "openai",
"baseUrl": "https://api.minimax.io/v1",
"envVar": "MINIMAX_GLOBAL_API_KEY",
"models": [
"MiniMax-M3",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed"
]
},
{
"name": "minimax-cn",
"displayName": "MiniMax CN API",
"protocol": "openai",
"baseUrl": "https://api.minimaxi.com/v1",
"envVar": "MINIMAX_API_KEY",
"models": [
"MiniMax-M3",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed"
]
},
{
"name": "mistral",
"displayName": "Mistral AI",
"protocol": "openai",
"baseUrl": "https://api.mistral.ai/v1",
"envVar": "MISTRAL_API_KEY",
"models": [
"codestral-latest",
"mistral-large-latest",
"mistral-small-latest"
]
},
{
"name": "novita",
"displayName": "Novita API",
"protocol": "openai",
"baseUrl": "https://api.novita.ai/openai",
"envVar": "NOVITA_API_KEY",
"models": [
"moonshotai/kimi-k3",
"zai-org/glm-5.2",
"deepseek/deepseek-v4-flash-0731"
]
},
{
"name": "ollama-cloud",
"displayName": "Ollama Cloud API",
"protocol": "openai",
"baseUrl": "https://ollama.com/v1",
"envVar": "OLLAMA_API_KEY",
"models": [
"deepseek-v4-flash",
"deepseek-v4-pro",
"gemma4:31b",
"glm-5.1",
"glm-5.2",
"gpt-oss:120b",
"gpt-oss:20b",
"kimi-k2.5",
"kimi-k2.6",
"kimi-k2.7-code",
"minimax-m2.5",
"minimax-m2.7",
"minimax-m3",
"mistral-large-3:675b",
"nemotron-3-nano:30b",
"nemotron-3-super",
"nemotron-3-ultra",
"qwen3.5:397b"
]
},
{
"name": "openai",
"displayName": "OpenAI API",
"protocol": "openai",
"baseUrl": "https://api.openai.com/v1",
"envVar": "OPENAI_API_KEY",
"models": [
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini"
]
},
{
"name": "openai-responses",
"displayName": "OpenAI Responses API",
"protocol": "openai-responses",
"baseUrl": "https://api.openai.com/v1",
"envVar": "OPENAI_RESPONSES_API_KEY",
"models": [
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna"
]
},
{
"name": "openrouter",
"displayName": "OpenRouter",
"protocol": "openai",
"baseUrl": "https://openrouter.ai/api/v1",
"envVar": "OPENROUTER_API_KEY",
"models": [
"anthropic/claude-fable-5.1",
"openai/gpt-5.6-sol",
"anthropic/claude-opus-5",
"openai/gpt-6-astra",
"moonshotai/kimi-k3"
]
},
{
"name": "siliconflow",
"displayName": "SiliconFlow API",
"protocol": "openai",
"baseUrl": "https://api.siliconflow.com/v1",
"envVar": "SILICONFLOW_GLOBAL_API_KEY",
"models": [
"deepseek-ai/DeepSeek-V4-Pro",
"deepseek-ai/DeepSeek-V4-Flash",
"Qwen/Qwen3.6-27B",
"moonshotai/Kimi-K2.7-Code",
"zai-org/GLM-5.2"
]
},
{
"name": "siliconflow-cn",
"displayName": "SiliconFlow CN API",
"protocol": "openai",
"baseUrl": "https://api.siliconflow.cn/v1",
"envVar": "SILICONFLOW_API_KEY",
"models": [
"deepseek-ai/DeepSeek-V4-Pro",
"deepseek-ai/DeepSeek-V4-Flash",
"Qwen/Qwen3.6-27B",
"moonshotai/Kimi-K2.7-Code",
"zai-org/GLM-5.2"
]
},
{
"name": "tencent-tokenhub",
"displayName": "Tencent TokenHub API",
"protocol": "openai",
"baseUrl": "https://tokenhub.tencentmaas.com/v1",
"envVar": "TENCENT_TOKENHUB_API_KEY",
"models": [
"deepseek-v4-pro",
"deepseek-v4-flash",
"glm-5.2",
"glm-5.1",
"glm-5",
"glm-5-turbo",
"kimi-k2.7-code",
"kimi-k2.7-code-highspeed",
"kimi-k2.6",
"minimax-m3",
"minimax-m2.7"
]
},
{
"name": "volcengine",
"displayName": "Volcano Engine Ark API",
"protocol": "openai",
"baseUrl": "https://ark.cn-beijing.volces.com/api/v3",
"envVar": "ARK_API_KEY",
"models": [
"doubao-seed-evolving",
"doubao-seed-2-1-pro-260628",
"doubao-seed-2-1-turbo-260628",
"doubao-seed-2-0-lite-260428",
"doubao-seed-2-0-mini-260428",
"doubao-seed-2-0-pro-260215"
]
},
{
"name": "xai",
"displayName": "xAI Grok API",
"protocol": "openai",
"baseUrl": "https://api.x.ai/v1",
"envVar": "XAI_API_KEY",
"models": [
"grok-4.6",
"grok-4.5",
"grok-4.3"
]
},
{
"name": "z-ai",
"displayName": "Z.AI API",
"protocol": "openai",
"baseUrl": "https://open.bigmodel.cn/api/paas/v4",
"envVar": "Z_AI_API_KEY",
"models": [
"glm-5.3",
"glm-5.2",
"glm-5.1",
"glm-5",
"glm-5-turbo",
"glm-4.7"
]
},
{
"name": "z-ai-coding",
"displayName": "Z.AI Coding Plan API",
"protocol": "openai",
"baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4",
"envVar": "Z_AI_CODING_API_KEY",
"models": [
"glm-5.3",
"glm-5.2",
"glm-5.1",
"glm-5-turbo",
"glm-4.7"
]
}
];

View file

@ -1,145 +1,22 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
import { PROVIDER_PRESETS } from './providers.generated';
// Update internal/llm/providers.go and run go generate ./internal/llm to change presets.
export { PROVIDER_PRESETS };
export interface OcrProviderPreset {
name: string;
displayName: string;
protocol: 'anthropic' | 'openai';
protocol: 'anthropic' | 'openai' | 'openai-responses' | 'anthropic-bedrock';
baseUrl: string;
authHeader?: string;
envVar: string;
ambientAuth?: boolean;
models: string[];
}
/** Aligned with the built-in registry in internal/llm/providers.go */
export const PROVIDER_PRESETS: OcrProviderPreset[] = [
{
name: 'anthropic',
displayName: 'Anthropic Claude API',
protocol: 'anthropic',
baseUrl: 'https://api.anthropic.com',
authHeader: 'x-api-key',
envVar: 'ANTHROPIC_API_KEY',
models: ['claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-sonnet-4-6'],
},
{
name: 'openai',
displayName: 'OpenAI API',
protocol: 'openai',
baseUrl: 'https://api.openai.com/v1',
envVar: 'OPENAI_API_KEY',
models: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini'],
},
{
name: 'dashscope',
displayName: 'Alibaba DashScope API',
protocol: 'openai',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
envVar: 'DASHSCOPE_API_KEY',
models: ['qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.6-flash'],
},
{
name: 'dashscope-tokenplan',
displayName: 'Alibaba DashScope Token Plan API',
protocol: 'openai',
baseUrl: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
envVar: 'DASHSCOPE_TOKENPLAN_KEY',
models: [
'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.6-flash',
'deepseek-v4-pro', 'deepseek-v4-flash', 'kimi-k2.6', 'kimi-k2.5',
'glm-5.2', 'glm-5.1', 'glm-5', 'MiniMax-M2.5',
],
},
{
name: 'volcengine',
displayName: 'Volcano Engine Ark API',
protocol: 'openai',
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
envVar: 'ARK_API_KEY',
models: ['doubao-seed-2-0-lite-260428', 'doubao-seed-2-0-mini-260428', 'doubao-seed-2-0-pro-260215'],
},
{
name: 'deepseek',
displayName: 'DeepSeek API',
protocol: 'openai',
baseUrl: 'https://api.deepseek.com',
envVar: 'DEEPSEEK_API_KEY',
models: ['deepseek-v4-pro', 'deepseek-flash'],
},
{
name: 'tencent-tokenhub',
displayName: 'Tencent TokenHub API',
protocol: 'openai',
baseUrl: 'https://tokenhub.tencentmaas.com/v1',
envVar: 'TENCENT_TOKENHUB_API_KEY',
models: ['hy3-preview'],
},
{
name: 'hy-tokenplan',
displayName: 'Tencent Hunyuan Token Plan API',
protocol: 'openai',
baseUrl: 'https://api.lkeap.cloud.tencent.com/plan/v3',
envVar: 'TENCENT_HUNYUAN_TOKENPLAN_KEY',
models: ['hy3-preview'],
},
{
name: 'kimi',
displayName: 'Kimi Moonshot API',
protocol: 'openai',
baseUrl: 'https://api.moonshot.cn/v1',
envVar: 'MOONSHOT_API_KEY',
models: ['kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5'],
},
{
name: 'z-ai',
displayName: 'Z.AI API',
protocol: 'openai',
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
envVar: 'Z_AI_API_KEY',
models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'],
},
{
name: 'z-ai-coding',
displayName: 'Z.AI Coding Plan API',
protocol: 'openai',
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
envVar: 'Z_AI_CODING_API_KEY',
models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'],
},
{
name: 'mimo',
displayName: 'Xiaomi MiMo API',
protocol: 'openai',
baseUrl: 'https://api.xiaomimimo.com/v1',
envVar: 'MIMO_API_KEY',
models: ['mimo-v2.5-pro', 'mimo-v2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'],
},
{
name: 'minimax',
displayName: 'MiniMax API',
protocol: 'openai',
baseUrl: 'https://api.minimax.io/v1',
envVar: 'MINIMAX_GLOBAL_API_KEY',
models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'],
},
{
name: 'minimax-cn',
displayName: 'MiniMax CN API',
protocol: 'openai',
baseUrl: 'https://api.minimaxi.com/v1',
envVar: 'MINIMAX_API_KEY',
models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'],
},
{
name: 'baidu-qianfan',
displayName: 'Baidu Qianfan API',
protocol: 'openai',
baseUrl: 'https://qianfan.baidubce.com/v2',
envVar: 'QIANFAN_API_KEY',
models: ['ernie-5.1', 'ernie-5.0', 'ernie-x1.1', 'ernie-x1-turbo-32k-preview', 'deepseek-v4-pro', 'deepseek-v4-flash'],
},
];
const presetMap = new Map(PROVIDER_PRESETS.map((p) => [p.name.toLowerCase(), p]));
export function lookupPreset(name: string): OcrProviderPreset | undefined {
@ -150,6 +27,12 @@ export function isPresetProvider(name: string): boolean {
return presetMap.has(name.trim().toLowerCase());
}
export function usesAmbientAuth(preset: OcrProviderPreset, protocolOverride?: string): boolean {
const protocol = protocolOverride?.trim().toLowerCase();
if (protocol) return protocol === 'anthropic-bedrock';
return preset.protocol === 'anthropic-bedrock' || preset.ambientAuth === true;
}
export function mergeModelLists(...lists: string[][]): string[] {
const seen = new Set<string>();
const out: string[] = [];

View file

@ -4,7 +4,7 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { ConfigEntry, ConfigPanelFocus, ProviderTab, buildCustomCreateSaveEntries, buildCustomUpdateSaveEntries, buildOfficialSaveEntries, describeActiveProvider, detectInitialTab, isConfigReady, listCustomProviderNames } from '../../shared/configUtils';
import { mergeModelLists, PROVIDER_PRESETS } from '../../shared/providers';
import { mergeModelLists, PROVIDER_PRESETS, usesAmbientAuth } from '../../shared/providers';
import { EnvCheckResult, LogLine, OcrConfig } from '../../shared/types';
import { CliStatus, ConnTest } from '../configStore';
import { CustomProviderManager } from '../components/CustomProviderManager';
@ -37,6 +37,9 @@ interface Props {
const CUSTOM_NEW = '__new__';
const MODEL_CUSTOM = '__custom__';
const SORTED_PROVIDER_OPTIONS = PROVIDER_PRESETS
.map((p) => ({ value: p.name, label: p.displayName }))
.sort((a, b) => a.label.localeCompare(b.label));
function resolvePanelState(config: OcrConfig | null, panelFocus?: ConfigPanelFocus | null) {
const tab = panelFocus?.tab ?? detectInitialTab(config);
@ -331,6 +334,7 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr
const [providerName, setProviderName] = useState(initialProvider);
const preset = PROVIDER_PRESETS.find((p) => p.name === providerName) ?? PROVIDER_PRESETS[0];
const savedEntry = config?.providers[providerName];
const ambientAuth = usesAmbientAuth(preset, savedEntry?.protocol);
const modelOptions = useMemo(
() => mergeModelLists(preset.models, savedEntry?.models ?? []),
@ -384,7 +388,7 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr
setApiKey('');
setApiKeyTouched(false);
}}
options={PROVIDER_PRESETS.map((p) => ({ value: p.name, label: p.displayName }))}
options={SORTED_PROVIDER_OPTIONS}
/>
</FormItem>
@ -407,16 +411,18 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr
)}
</FormItem>
<FormItem
label={t('view.config.apiKey')}
hint={`${t('view.config.apiKeyEnvHint')} ${preset.envVar}`}
>
<PasswordInput
value={apiKey}
onInput={(v) => { setApiKey(v); setApiKeyTouched(true); }}
placeholder={hasStoredKey && !apiKeyTouched ? t('view.config.apiKeySaved') : 'sk-...'}
/>
</FormItem>
{!ambientAuth && (
<FormItem
label={t('view.config.apiKey')}
hint={`${t('view.config.apiKeyEnvHint')} ${preset.envVar}`}
>
<PasswordInput
value={apiKey}
onInput={(v) => { setApiKey(v); setApiKeyTouched(true); }}
placeholder={hasStoredKey && !apiKeyTouched ? t('view.config.apiKeySaved') : 'sk-...'}
/>
</FormItem>
)}
<ConnActions wide={wide} connTest={connTest} canSave={canSave} onBack={onBack} onTest={test} onSave={save} />
</FormSection>
@ -569,4 +575,4 @@ function ConnActions({ wide, connTest, canSave, onBack, onTest, onSave }: {
</div>
</div>
);
}
}

View file

@ -0,0 +1,39 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
// Code generated by go generate ./internal/llm; DO NOT EDIT.
// Source: internal/llm/providers.go. Providers are sorted by name.
package com.alibaba.opencodereview.idea.services
internal fun generatedPresetProviderNames(): Set<String> = setOf(
"anthropic",
"baidu-qianfan",
"bedrock",
"dashscope",
"dashscope-tokenplan",
"deepseek",
"edenai",
"gemini",
"hy-tokenplan",
"iflytek",
"kimi",
"kimi-global",
"litellm",
"mimo",
"minimax",
"minimax-cn",
"mistral",
"novita",
"ollama-cloud",
"openai",
"openai-responses",
"openrouter",
"siliconflow",
"siliconflow-cn",
"tencent-tokenhub",
"volcengine",
"xai",
"z-ai",
"z-ai-coding",
)

View file

@ -5,35 +5,12 @@ package com.alibaba.opencodereview.idea.services
import java.util.Locale
/**
* Names of the built-in providers.
* Only the names are copied, not the baseUrl/models/protocol tables: the host side only needs
* [isPresetProvider] (to decide whether a value is written to `providers` or `custom_providers`),
* and duplicating the whole tables would create a second data source that drifts apart on the two sides.
* Drift is caught by `ProvidersTest`, which regex-extracts the `name:` fields from the shared
* frontend source and compares them.
*/
private val PRESET_PROVIDER_NAMES: Set<String> = setOf(
"anthropic",
"openai",
"dashscope",
"dashscope-tokenplan",
"volcengine",
"deepseek",
"tencent-tokenhub",
"hy-tokenplan",
"kimi",
"z-ai",
"z-ai-coding",
"mimo",
"minimax",
"minimax-cn",
"baidu-qianfan",
)
// The host needs only names to route configuration to providers or custom_providers.
private val PRESET_PROVIDER_NAMES: Set<String> = generatedPresetProviderNames()
/** Compares after trim + lowercase; Locale.ROOT avoids surprises such as the Turkish-locale 'I' lowercasing to a dotless i. */
/** Uses Locale.ROOT so provider-name normalization is independent of the system locale. */
fun isPresetProvider(name: String): Boolean =
PRESET_PROVIDER_NAMES.contains(name.trim().lowercase(Locale.ROOT))
/** Read-only view for tests and diagnostics. Returns a defensive copy so callers cannot mutate the backing set. */
/** Returns a defensive copy for tests and diagnostics. */
fun presetProviderNames(): Set<String> = PRESET_PROVIDER_NAMES.toSet()

View file

@ -101,6 +101,16 @@ class ConfigDraftTest {
// ------------------------------------------------------------ providers.<name>.<field>
@Test
fun `new generated presets keep configuration in the built-in provider bucket`() {
for (name in listOf("bedrock", "openai-responses", "xai", "gemini", "ollama-cloud")) {
val d = draft("{}", "provider" to name, "providers.$name.model" to "saved-model")
assertEquals(name, d.str("provider"))
assertEquals("saved-model", d.str("providers", name, "model"), name)
assertNull(d.obj("custom_providers"), name)
}
}
@Test
fun `the providers prefix routes by name to built-in or custom containers`() {
val preset = draft("{}", "providers.anthropic.api_key" to "sk-1")

View file

@ -4,6 +4,10 @@
package com.alibaba.opencodereview.idea.services
import com.alibaba.opencodereview.idea.FrontendSources
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@ -13,22 +17,23 @@ import org.junit.Test
* Consistency checks for the preset provider list.
*
* The host needs only names to route providers to `providers` or `custom_providers`,
* while the frontend maintains the full preset table. Both name sets must match:
* if the frontend adds a built-in provider without a host update, it is treated as custom,
* while the frontend uses the full preset table. Both are generated from Go and must match:
* if a built-in provider is missing on the host, it is treated as custom,
* written to the wrong container, and silently ignored by the CLI, which reports no configured model during review.
*/
class ProvidersTest {
/** Each frontend preset has the form `name: 'xxx',`. */
private val nameRegex = Regex("""\bname:\s*'([^']+)'""")
@Test
fun `preset provider names exactly match the frontend provider table`() {
val ts = FrontendSources.file("src/shared/providers.ts").readText()
val fromFrontend = nameRegex.findAll(ts).map { it.groupValues[1] }.toSortedSet()
val ts = FrontendSources.file("src/shared/providers.generated.ts").readText()
val declaration = "export const PROVIDER_PRESETS: OcrProviderPreset[] = "
assertTrue("Missing generated provider declaration", ts.contains(declaration))
val json = ts.substringAfter(declaration).trim().removeSuffix(";")
val fromFrontend = Json.parseToJsonElement(json).jsonArray
.map { it.jsonObject.getValue("name").jsonPrimitive.content }.toSortedSet()
assertTrue(
"No names matched in providers.ts; upstream syntax changed and this test needs updating",
"Expected the generated catalog to contain the built-in providers",
fromFrontend.size >= 10,
)
assertEquals(
@ -42,10 +47,11 @@ class ProvidersTest {
@Test
fun `isPresetProvider ignores case and surrounding whitespace`() {
val any = presetProviderNames().first()
assertTrue(isPresetProvider(any))
assertTrue(isPresetProvider(any.uppercase()))
assertTrue(isPresetProvider(" $any "))
for (name in presetProviderNames()) {
assertTrue(isPresetProvider(name))
assertTrue(isPresetProvider(name.uppercase(java.util.Locale.ROOT)))
assertTrue(isPresetProvider(" $name "))
}
assertFalse(isPresetProvider("my-own-llm"))
assertFalse(isPresetProvider(""))
}

210
internal/llm/gen/check.go Normal file
View file

@ -0,0 +1,210 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"regexp"
"slices"
"github.com/alibaba/open-code-review/internal/llm"
)
const presetDeclaration = "export const PROVIDER_PRESETS: OcrProviderPreset[] ="
const typeScriptWhitespace = `[\s\v\x{2028}\x{2029}\x{feff}\p{Zs}]`
var presetPreamble = preambleSyntax{
declaration: regexp.MustCompile(`^import` + typeScriptWhitespace + `+type` + typeScriptWhitespace + `*\{` +
typeScriptWhitespace + `*OcrProviderPreset` + typeScriptWhitespace + `*\}` +
typeScriptWhitespace + `+from` + typeScriptWhitespace + `*(?:'\./providers'|"\./providers")`),
whitespace: regexp.MustCompile(`^` + typeScriptWhitespace + `+`),
lineTerminators: "\r\n\u2028\u2029",
}
func check(output, kotlinOutput string) error {
if output == "" || kotlinOutput == "" {
return fmt.Errorf("-output and -kotlin-output paths are required for -check")
}
data, err := os.ReadFile(output)
if err != nil {
return fmt.Errorf("read provider presets: %w", err)
}
providers := llm.ListProviders()
if err := checkPresets(data, providers); err != nil {
return fmt.Errorf("provider presets are inconsistent: %w; run go generate ./internal/llm from the repository root and commit the generated files", err)
}
kotlinData, err := os.ReadFile(kotlinOutput)
if err != nil {
return fmt.Errorf("read Kotlin provider names: %w", err)
}
if err := checkKotlinNames(kotlinData, providers); err != nil {
return fmt.Errorf("Kotlin provider names are inconsistent: %w; run go generate ./internal/llm from the repository root and commit the generated files", err)
}
return nil
}
func checkPresets(data []byte, providers []llm.Provider) error {
if err := validateModelLists(providers); err != nil {
return err
}
presets, err := decodePresets(data)
if err != nil {
return err
}
if len(presets) != len(providers) {
return fmt.Errorf("provider count is %d, want %d", len(presets), len(providers))
}
for i, provider := range providers {
actual := presets[i]
if actual.Name != provider.Name {
return fmt.Errorf("provider at index %d is %q, want %q", i, actual.Name, provider.Name)
}
if !slices.Equal(actual.Models, provider.Models) {
return fmt.Errorf("models for provider %q are %v, want %v (including order)", provider.Name, actual.Models, provider.Models)
}
for _, field := range []struct{ name, actual, want string }{
{"displayName", actual.DisplayName, provider.DisplayName},
{"protocol", actual.Protocol, provider.Protocol},
{"baseUrl", actual.BaseURL, provider.BaseURL},
{"authHeader", actual.AuthHeader, provider.AuthHeader},
{"envVar", actual.EnvVar, provider.EnvVar},
} {
if field.actual != field.want {
return fmt.Errorf("%s for provider %q is %q, want %q", field.name, provider.Name, field.actual, field.want)
}
}
if actual.AmbientAuth != provider.AmbientAuth {
return fmt.Errorf("ambientAuth for provider %q is %t, want %t", provider.Name, actual.AmbientAuth, provider.AmbientAuth)
}
}
return nil
}
func decodePresets(data []byte) ([]preset, error) {
preamble, payload, found := bytes.Cut(data, []byte(presetDeclaration))
if !found {
return nil, fmt.Errorf("missing PROVIDER_PRESETS declaration")
}
if !presetPreamble.matches(preamble) {
return nil, fmt.Errorf("expected an OcrProviderPreset import from './providers' before PROVIDER_PRESETS")
}
payload = bytes.TrimSuffix(bytes.TrimSpace(payload), []byte(";"))
decoder := json.NewDecoder(bytes.NewReader(payload))
var records []map[string]json.RawMessage
if err := decoder.Decode(&records); err != nil {
return nil, fmt.Errorf("decode provider presets: %w", err)
}
if err := decoder.Decode(new(any)); err != io.EOF {
return nil, fmt.Errorf("provider presets must contain exactly one JSON array")
}
if records == nil {
return nil, fmt.Errorf("provider presets must be an array")
}
presets := make([]preset, len(records))
for i, record := range records {
actual := &presets[i]
var models []*string
for _, field := range []struct {
name string
required bool
destination any
}{
{"name", true, &actual.Name},
{"displayName", true, &actual.DisplayName},
{"protocol", true, &actual.Protocol},
{"baseUrl", true, &actual.BaseURL},
{"envVar", true, &actual.EnvVar},
{"models", true, &models},
{"authHeader", false, &actual.AuthHeader},
{"ambientAuth", false, &actual.AmbientAuth},
} {
value, exists := record[field.name]
if !exists {
if field.required {
return nil, fmt.Errorf("provider at index %d is missing required field %q", i, field.name)
}
continue
}
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return nil, fmt.Errorf("field %q for provider at index %d must not be null", field.name, i)
}
if err := json.Unmarshal(value, field.destination); err != nil {
return nil, fmt.Errorf("decode field %q for provider at index %d: %w", field.name, i, err)
}
delete(record, field.name)
}
for name := range record {
return nil, fmt.Errorf("unknown field %q for provider at index %d", name, i)
}
actual.Models = make([]string, len(models))
for j, model := range models {
if model == nil {
return nil, fmt.Errorf("model at index %d for provider %q must be a string", j, actual.Name)
}
actual.Models[j] = *model
}
}
return presets, nil
}
type preambleSyntax struct {
declaration *regexp.Regexp
whitespace *regexp.Regexp
lineTerminators string
nestedBlockComments bool
}
// Matching the whole preamble prevents comments from satisfying a missing declaration.
func (s preambleSyntax) matches(data []byte) bool {
data = s.trimTrivia(data)
match := s.declaration.FindIndex(data)
if match == nil || match[0] != 0 {
return false
}
trailing := data[match[1]:]
rest := s.trimTrivia(trailing)
if bytes.HasPrefix(rest, []byte(";")) {
return len(s.trimTrivia(rest[1:])) == 0
}
return len(rest) == 0 && bytes.ContainsAny(trailing, s.lineTerminators)
}
func (s preambleSyntax) trimTrivia(data []byte) []byte {
for {
data = data[len(s.whitespace.Find(data)):]
switch {
case bytes.HasPrefix(data, []byte("//")):
end := bytes.IndexAny(data, s.lineTerminators)
if end < 0 {
return data
}
data = data[end:]
case bytes.HasPrefix(data, []byte("/*")):
depth, end := 1, 2
for depth > 0 && end+1 < len(data) {
switch {
case data[end] == '*' && data[end+1] == '/':
depth--
end += 2
case s.nestedBlockComments && data[end] == '/' && data[end+1] == '*':
depth++
end += 2
default:
end++
}
}
if depth != 0 {
return data
}
data = data[end:]
default:
return data
}
}
}

View file

@ -0,0 +1,283 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/alibaba/open-code-review/internal/llm"
)
func TestCheckPresets(t *testing.T) {
providers := []llm.Provider{
{
Name: "alpha", DisplayName: "Alpha", Protocol: llm.ProtocolOpenAIChatCompletions,
BaseURL: "https://example.com/v1", AuthHeader: "Authorization",
EnvVar: "ALPHA_API_KEY", Models: []string{"default", "other"},
},
{
Name: "beta", DisplayName: "Beta", Protocol: llm.ProtocolAnthropicBedrock,
AmbientAuth: true,
},
}
const alpha = `{"name":"alpha","displayName":"Alpha","protocol":"openai","baseUrl":"https://example.com/v1","authHeader":"Authorization","envVar":"ALPHA_API_KEY","models":["default","other"]}`
const beta = `{"name":"beta","displayName":"Beta","protocol":"anthropic-bedrock","baseUrl":"","envVar":"","ambientAuth":true,"models":[]}`
valid := "[" + alpha + "," + beta + "]"
for _, tc := range []struct {
name, payload, wantErr string
}{
{"matching values", valid, ""},
{"different whitespace", "[\n " + alpha + ",\n " + beta + "\n]", ""},
{"missing provider", "[" + alpha + "]", "provider count"},
{"extra provider", "[" + alpha + "," + beta + "," + beta + "]", "provider count"},
{"renamed provider", strings.Replace(valid, `"name":"alpha"`, `"name":"other"`, 1), "provider at index"},
{"duplicate provider", "[" + alpha + "," + alpha + "]", "provider at index"},
{"reordered providers", "[" + beta + "," + alpha + "]", "provider at index"},
{"changed model", strings.Replace(valid, `"other"`, `"changed"`, 1), `models for provider "alpha"`},
{"reordered models", strings.Replace(valid, `["default","other"]`, `["other","default"]`, 1), `models for provider "alpha"`},
{"null models", strings.Replace(valid, `"models":[]`, `"models":null`, 1), "must not be null"},
{"missing models", strings.Replace(valid, `,"models":[]`, "", 1), `missing required field "models"`},
{"null model entry", strings.Replace(valid, `["default","other"]`, `["default",null]`, 1), "must be a string"},
{"wrong model type", strings.Replace(valid, `["default","other"]`, `["default",42]`, 1), `decode field "models"`},
{"models object", strings.Replace(valid, `"models":[]`, `"models":{}`, 1), `decode field "models"`},
{"changed display name", strings.Replace(valid, `"displayName":"Alpha"`, `"displayName":"Changed"`, 1), "displayName"},
{"changed protocol", strings.Replace(valid, `"protocol":"openai"`, `"protocol":"anthropic"`, 1), "protocol"},
{"changed URL", strings.Replace(valid, `"baseUrl":"https://example.com/v1"`, `"baseUrl":"https://other.example/v1"`, 1), "baseUrl"},
{"changed auth header", strings.Replace(valid, `"authHeader":"Authorization"`, `"authHeader":"x-api-key"`, 1), "authHeader"},
{"changed environment variable", strings.Replace(valid, `"envVar":"ALPHA_API_KEY"`, `"envVar":"OTHER_API_KEY"`, 1), "envVar"},
{"changed ambient authentication", strings.Replace(valid, `"ambientAuth":true`, `"ambientAuth":false`, 1), "ambientAuth"},
{"unknown field", strings.Replace(valid, `"name":"alpha"`, `"name":"alpha","unknown":true`, 1), "unknown field"},
{"wrong case field", strings.Replace(valid, `"name":"alpha"`, `"Name":"alpha"`, 1), `missing required field "name"`},
{"wrong case extra field", strings.Replace(valid, `"name":"alpha"`, `"name":"alpha","Name":"alpha"`, 1), "unknown field"},
{"missing empty URL", strings.Replace(valid, `,"baseUrl":""`, "", 1), `missing required field "baseUrl"`},
{"null empty URL", strings.Replace(valid, `"baseUrl":""`, `"baseUrl":null`, 1), "must not be null"},
{"missing empty environment variable", strings.Replace(valid, `,"envVar":""`, "", 1), `missing required field "envVar"`},
{"null empty environment variable", strings.Replace(valid, `"envVar":""`, `"envVar":null`, 1), "must not be null"},
{"null optional auth header", strings.Replace(valid, `"name":"beta"`, `"name":"beta","authHeader":null`, 1), "must not be null"},
{"null optional ambient authentication", strings.Replace(valid, `"name":"alpha"`, `"name":"alpha","ambientAuth":null`, 1), "must not be null"},
{"wrong scalar type", strings.Replace(valid, `"displayName":"Alpha"`, `"displayName":42`, 1), `decode field "displayName"`},
{"null provider", "[" + alpha + ",null]", `missing required field "name"`},
{"invalid JSON", "[", "decode provider presets"},
{"null catalog", "null", "must be an array"},
{"trailing JSON", valid + " []", "exactly one JSON array"},
{"trailing invalid content", valid + " invalid", "exactly one JSON array"},
} {
t.Run(tc.name, func(t *testing.T) {
data := []byte("// Different header formatting is allowed.\nimport type { OcrProviderPreset } from './providers';\n" + presetDeclaration + "\n" + tc.payload + ";\n")
err := checkPresets(data, providers)
if tc.wantErr == "" {
if err != nil {
t.Fatal(err)
}
} else if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want a diagnostic containing %q", err, tc.wantErr)
}
})
}
if err := checkPresets([]byte("import type { OcrProviderPreset } from './providers';\n"+presetDeclaration+" [];\n"), nil); err != nil {
t.Fatalf("an empty array must match an empty registry: %v", err)
}
if err := checkPresets([]byte("export const OTHER = [];\n"), nil); err == nil || !strings.Contains(err.Error(), "missing PROVIDER_PRESETS declaration") {
t.Fatalf("missing declaration error = %v", err)
}
}
func TestCheckPresetsImport(t *testing.T) {
const validImport = "import type { OcrProviderPreset } from './providers';"
for _, tc := range []struct {
name, preamble string
valid bool
}{
{"matching", validImport, true},
{"double quotes and whitespace", "\nimport type {\n OcrProviderPreset\n} from \"./providers\";\n", true},
{"no semicolon", "import type { OcrProviderPreset } from './providers'", true},
{"no semicolon with comment", "import type { OcrProviderPreset } from './providers' // Type import", true},
{"trailing block comment", "import type { OcrProviderPreset } from './providers' /* Type import */", true},
{"block comment before semicolon", "import type { OcrProviderPreset } from './providers' /* Type import */;", true},
{"header comments", "// License\n/* Generated file */\n" + validImport + " // Type import\n", true},
{"missing import", "", false},
{"wrong module", "import type { OcrProviderPreset } from './missing-providers';", false},
{"wrong binding", "import type { OtherPreset } from './providers';", false},
{"commented import", "// " + validImport + "\n", false},
{"block-commented import", "/* " + validImport + " */", false},
{"commented correct import before wrong module", "// " + validImport + "\nimport type { OcrProviderPreset } from './missing-providers';", false},
{"wrong import between comments", "/* Header */\nimport type { OcrProviderPreset } from './missing-providers';\n/* Header */\n" + validImport, false},
{"block comments do not nest", "/* Outer /* Inner */\nimport type { OcrProviderPreset } from './missing-providers';\n// */\n" + validImport, false},
{"duplicate import", validImport + "\n" + validImport, false},
{"unterminated comment", "/* Header\n" + validImport, false},
{"unterminated trailing comment", strings.TrimSuffix(validImport, ";") + " /* Type import", false},
{"overlapping comment delimiters", "/*/\n" + validImport, false},
} {
t.Run(tc.name, func(t *testing.T) {
data := []byte(tc.preamble + "\n" + presetDeclaration + " [];\n")
err := checkPresets(data, nil)
if tc.valid {
if err != nil {
t.Fatal(err)
}
} else if err == nil || !strings.Contains(err.Error(), "OcrProviderPreset import") || !strings.Contains(err.Error(), "./providers") {
t.Fatalf("expected an import diagnostic, got %v", err)
}
})
}
if err := checkPresets([]byte(validImport+"\n// "+presetDeclaration+" [];\n"), nil); err == nil || !strings.Contains(err.Error(), "OcrProviderPreset import") {
t.Fatalf("a commented catalog declaration must be rejected, got %v", err)
}
}
func TestCheckPresetsImportWhitespace(t *testing.T) {
const validImport = "import type { OcrProviderPreset } from './providers';"
for _, tc := range []struct {
name, space string
valid bool
}{
{"tab", "\t", true},
{"line feed", "\n", true},
{"vertical tab", "\v", true},
{"form feed", "\f", true},
{"carriage return", "\r", true},
{"nonbreaking space", "\u00a0", true},
{"thin space", "\u2009", true},
{"ideographic space", "\u3000", true},
{"byte order mark", "\ufeff", true},
{"line separator", "\u2028", true},
{"paragraph separator", "\u2029", true},
{"next line is not whitespace", "\u0085", false},
{"zero width space is not whitespace", "\u200b", false},
} {
t.Run(tc.name, func(t *testing.T) {
data := []byte(tc.space + strings.ReplaceAll(validImport, " ", tc.space) + tc.space + "\n" + presetDeclaration + " [];\n")
if err := checkPresets(data, nil); (err == nil) != tc.valid {
t.Fatalf("valid = %t, error = %v", tc.valid, err)
}
})
}
}
func TestCheckPresetsImportLineTerminators(t *testing.T) {
const validImport = "import type { OcrProviderPreset } from './providers';"
const wrongImport = "import type { OcrProviderPreset } from './missing-providers';"
for _, line := range []struct{ name, separator string }{
{"LF", "\n"}, {"CRLF", "\r\n"}, {"CR", "\r"}, {"LS", "\u2028"}, {"PS", "\u2029"},
} {
t.Run(line.name, func(t *testing.T) {
for _, tc := range []struct {
name, preamble string
valid bool
}{
{"header comment", "// Header" + line.separator + validImport, true},
{"no semicolon with comment", strings.TrimSuffix(validImport, ";") + " // Type import" + line.separator, true},
{"wrong import after comment", validImport + "\n// Header" + line.separator + wrongImport, false},
{"wrong import after trailing comment", strings.TrimSuffix(validImport, ";") + " // Type import" + line.separator + wrongImport, false},
} {
t.Run(tc.name, func(t *testing.T) {
data := []byte(tc.preamble + "\n" + presetDeclaration + " [];\n")
err := checkPresets(data, nil)
if (err == nil) != tc.valid {
t.Fatalf("valid = %t, error = %v", tc.valid, err)
}
})
}
})
}
}
func TestCheckRequiresExistingFile(t *testing.T) {
for _, paths := range [][2]string{{"", "providers.kt"}, {"providers.ts", ""}} {
if err := check(paths[0], paths[1]); err == nil || !strings.Contains(err.Error(), "-output") || !strings.Contains(err.Error(), "-kotlin-output") {
t.Fatalf("missing output path error = %v", err)
}
}
dir := t.TempDir()
path := filepath.Join(dir, "missing.ts")
kotlinPath := filepath.Join(dir, "missing.kt")
if err := check(path, kotlinPath); err == nil || !strings.Contains(err.Error(), "read provider presets") {
t.Fatalf("missing artifact error = %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("check must not create a missing artifact: %v", err)
}
data, err := render(llm.ListProviders())
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
if err := check(path, kotlinPath); err == nil || !strings.Contains(err.Error(), "read Kotlin provider names") {
t.Fatalf("missing Kotlin artifact error = %v", err)
}
if _, err := os.Stat(kotlinPath); !os.IsNotExist(err) {
t.Fatalf("check must not create a missing Kotlin artifact: %v", err)
}
}
func TestCheckRejectsDuplicateRegistryModels(t *testing.T) {
providers := []llm.Provider{{Name: "example", Models: []string{"duplicate", "duplicate"}}}
if err := checkPresets(nil, providers); err == nil || !strings.Contains(err.Error(), `provider "example" has duplicate model "duplicate"`) {
t.Fatalf("invalid source registry must be rejected before comparison: %v", err)
}
}
func TestCheckDoesNotRewriteArtifact(t *testing.T) {
valid, err := render(llm.ListProviders())
if err != nil {
t.Fatal(err)
}
validKotlin := renderKotlin(llm.ListProviders())
for _, tc := range []struct {
name string
data, kotlin []byte
inconsistent bool
}{
{"matching", valid, validKotlin, false},
{"inconsistent frontend", []byte("import type { OcrProviderPreset } from './providers';\n" + presetDeclaration + " [];\n"), validKotlin, true},
{"inconsistent Kotlin", valid, []byte("package com.alibaba.opencodereview.idea.services\n" + kotlinDeclaration + ")\n"), true},
{"wrong import path", bytes.Replace(valid, []byte("from './providers'"), []byte("from './missing-providers'"), 1), validKotlin, true},
{"missing import", bytes.Replace(valid, []byte("import type { OcrProviderPreset } from './providers';"), nil, 1), validKotlin, true},
{"wrong Kotlin package", valid, bytes.Replace(validKotlin, []byte("package com.alibaba.opencodereview.idea.services"), []byte("package incorrect.services"), 1), true},
{"missing Kotlin package", valid, bytes.Replace(validKotlin, []byte("package com.alibaba.opencodereview.idea.services"), nil, 1), true},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "providers.ts")
kotlinPath := filepath.Join(dir, "providers.kt")
modified := time.Unix(1600000000, 0)
artifacts := map[string][]byte{path: tc.data, kotlinPath: tc.kotlin}
for artifact, data := range artifacts {
if err := os.WriteFile(artifact, data, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(artifact, modified, modified); err != nil {
t.Fatal(err)
}
}
err := check(path, kotlinPath)
if !tc.inconsistent && err != nil {
t.Fatal(err)
}
if tc.inconsistent && (err == nil || !strings.Contains(err.Error(), "go generate ./internal/llm")) {
t.Fatalf("mismatch must include the regeneration command: %v", err)
}
for artifact, data := range artifacts {
got, err := os.ReadFile(artifact)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(artifact)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, data) || !info.ModTime().Equal(modified) {
t.Fatalf("check must not rewrite %s, even when it is inconsistent", artifact)
}
}
})
}
}

View file

@ -0,0 +1,96 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package main
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"strings"
"unicode/utf16"
"github.com/alibaba/open-code-review/internal/llm"
)
const kotlinDeclaration = "internal fun generatedPresetProviderNames(): Set<String> = setOf("
var kotlinPreamble = preambleSyntax{
declaration: regexp.MustCompile(`^package[ \t\f]+com\.alibaba\.opencodereview\.idea\.services`),
whitespace: regexp.MustCompile(`^[ \t\r\n\f]+`),
lineTerminators: "\r\n",
nestedBlockComments: true,
}
const kotlinHeader = `// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
// Code generated by go generate ./internal/llm; DO NOT EDIT.
// Source: internal/llm/providers.go. Providers are sorted by name.
package com.alibaba.opencodereview.idea.services
`
func renderKotlin(providers []llm.Provider) []byte {
var out strings.Builder
out.WriteString(kotlinHeader + kotlinDeclaration + "\n")
for _, provider := range providers {
out.WriteString(" " + quoteKotlinString(provider.Name) + ",\n")
}
out.WriteString(")\n")
return []byte(out.String())
}
func quoteKotlinString(value string) string {
var out strings.Builder
out.WriteByte('"')
for _, r := range value {
switch {
case r == '"' || r == '\\':
out.WriteByte('\\')
out.WriteRune(r)
case r < 0x20 || r > 0x7e || r == '$':
// JSON-compatible escapes also prevent Kotlin string interpolation.
for _, codeUnit := range utf16.Encode([]rune{r}) {
fmt.Fprintf(&out, "\\u%04x", codeUnit)
}
default:
out.WriteRune(r)
}
}
out.WriteByte('"')
return out.String()
}
func checkKotlinNames(data []byte, providers []llm.Provider) error {
preamble, payload, found := bytes.Cut(data, []byte(kotlinDeclaration))
if !found {
return fmt.Errorf("missing generatedPresetProviderNames declaration")
}
if !kotlinPreamble.matches(preamble) {
return fmt.Errorf("expected Kotlin package com.alibaba.opencodereview.idea.services before generatedPresetProviderNames")
}
payload = bytes.TrimSpace(payload)
if !bytes.HasSuffix(payload, []byte(")")) {
return fmt.Errorf("Kotlin provider names must end with a closing parenthesis")
}
payload = bytes.TrimSuffix(bytes.TrimSpace(bytes.TrimSuffix(payload, []byte(")"))), []byte(","))
var names []*string
if err := json.Unmarshal([]byte("["+string(payload)+"]"), &names); err != nil {
return fmt.Errorf("decode Kotlin provider names: %w", err)
}
if len(names) != len(providers) {
return fmt.Errorf("Kotlin provider count is %d, want %d", len(names), len(providers))
}
for i, provider := range providers {
if names[i] == nil {
return fmt.Errorf("Kotlin provider at index %d must be a string", i)
}
if *names[i] != provider.Name {
return fmt.Errorf("Kotlin provider at index %d is %q, want %q", i, *names[i], provider.Name)
}
}
return nil
}

View file

@ -0,0 +1,130 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package main
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/alibaba/open-code-review/internal/llm"
)
func TestQuoteKotlinString(t *testing.T) {
for _, tc := range []struct{ input, want string }{
{"alpha-beta", `"alpha-beta"`},
{"quote\"slash\\", `"quote\"slash\\"`},
{"$name ${value}", `"\u0024name \u0024{value}"`},
{"\x00\b\t\n\f\r", `"\u0000\u0008\u0009\u000a\u000c\u000d"`},
{"\u00e9\U0001f600", `"\u00e9\ud83d\ude00"`},
} {
got := quoteKotlinString(tc.input)
if got != tc.want {
t.Errorf("quoteKotlinString(%q) = %s, want %s", tc.input, got, tc.want)
}
var decoded string
if err := json.Unmarshal([]byte(got), &decoded); err != nil || decoded != tc.input {
t.Errorf("quoted Kotlin name must retain its value as JSON: %q, %v", decoded, err)
}
}
}
func TestRenderKotlin(t *testing.T) {
providers := []llm.Provider{{Name: "alpha"}, {Name: "beta"}}
data := renderKotlin(providers)
if !bytes.Contains(data, []byte("package com.alibaba.opencodereview.idea.services\n")) {
t.Fatal("generated Kotlin must be in the host services package")
}
if !bytes.HasSuffix(data, []byte(" \"alpha\",\n \"beta\",\n)\n")) {
t.Fatalf("generated Kotlin did not retain provider order: %s", data)
}
if bytes.Contains(data, []byte("\r")) {
t.Fatal("generated Kotlin must use LF line endings")
}
if err := checkKotlinNames(renderKotlin(nil), nil); err != nil {
t.Fatalf("empty registry must produce a valid empty Kotlin set: %v", err)
}
}
func TestCheckKotlinNames(t *testing.T) {
providers := []llm.Provider{{Name: "alpha"}, {Name: "beta"}}
for _, tc := range []struct{ name, payload, wantErr string }{
{"matching", `"alpha", "beta",)`, ""},
{"no trailing comma", `"alpha", "beta")`, ""},
{"missing provider", `"alpha",)`, "provider count"},
{"extra provider", `"alpha", "beta", "gamma",)`, "provider count"},
{"reordered providers", `"beta", "alpha",)`, "provider at index"},
{"duplicate provider", `"alpha", "alpha",)`, "provider at index"},
{"renamed provider", `"alpha", "changed",)`, "provider at index"},
{"null name", `"alpha", null,)`, "must be a string"},
{"invalid literal", `"alpha", 42,)`, "decode Kotlin provider names"},
{"unterminated set", `"alpha", "beta",`, "closing parenthesis"},
{"trailing code", `"alpha", "beta",); other()`, "decode Kotlin provider names"},
} {
t.Run(tc.name, func(t *testing.T) {
data := []byte("package com.alibaba.opencodereview.idea.services\n" + kotlinDeclaration + "\n" + tc.payload + "\n")
err := checkKotlinNames(data, providers)
if tc.wantErr == "" {
if err != nil {
t.Fatal(err)
}
} else if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want a diagnostic containing %q", err, tc.wantErr)
}
})
}
if err := checkKotlinNames([]byte("setOf()"), nil); err == nil || !strings.Contains(err.Error(), "missing generatedPresetProviderNames") {
t.Fatalf("missing declaration error = %v", err)
}
}
func TestCheckKotlinPackage(t *testing.T) {
const validPackage = "package com.alibaba.opencodereview.idea.services"
for _, tc := range []struct {
name, preamble string
valid bool
}{
{"matching", validPackage, true},
{"whitespace and semicolon", "\npackage\tcom.alibaba.opencodereview.idea.services;\n", true},
{"form feed whitespace", "\fpackage\fcom.alibaba.opencodereview.idea.services\f;\n", true},
{"trailing comment", validPackage + " // Host package", true},
{"trailing block comment", validPackage + " /* Host package */", true},
{"nested trailing block comment", validPackage + " /* Outer /* Inner */ End */", true},
{"block comment before semicolon", validPackage + " /* Host package */;", true},
{"header comments", "// License\n/* Generated file */\n" + validPackage + "\n// Provider names\n", true},
{"nested header comments", "/* Outer /* Inner */ End */\n" + validPackage, true},
{"CR header comment", "// Header\r" + validPackage, true},
{"CR trailing comment", validPackage + " // Host package\r", true},
{"missing package", "", false},
{"wrong package", "package incorrect.services", false},
{"package suffix", validPackage + ".incorrect", false},
{"commented package", "// " + validPackage + "\n", false},
{"block-commented package", "/* " + validPackage + " */", false},
{"package inside nested comment", "/* Outer /* Inner */\n" + validPackage + "\n// */", false},
{"commented correct package before wrong package", "// " + validPackage + "\npackage incorrect.services", false},
{"wrong package after CR comment", validPackage + "\n// Header\rpackage incorrect.services", false},
{"wrong package after CR trailing comment", validPackage + " // Host package\rpackage incorrect.services", false},
{"wrong package between comments", "/* Header */\npackage incorrect.services\n/* Header */\n" + validPackage, false},
{"duplicate package", validPackage + "\n" + validPackage, false},
{"unterminated comment", "/* Header\n" + validPackage, false},
{"unterminated trailing comment", validPackage + " /* Host package", false},
{"overlapping comment delimiters", "/*/\n" + validPackage, false},
} {
t.Run(tc.name, func(t *testing.T) {
data := []byte(tc.preamble + "\n" + kotlinDeclaration + ")\n")
err := checkKotlinNames(data, nil)
if tc.valid {
if err != nil {
t.Fatal(err)
}
} else if err == nil || !strings.Contains(err.Error(), "Kotlin package") || !strings.Contains(err.Error(), "com.alibaba.opencodereview.idea.services") {
t.Fatalf("expected a package diagnostic, got %v", err)
}
})
}
if err := checkKotlinNames([]byte(validPackage+"\n// "+kotlinDeclaration+")\n"), nil); err == nil || !strings.Contains(err.Error(), "Kotlin package") {
t.Fatalf("a commented provider declaration must be rejected, got %v", err)
}
}

141
internal/llm/gen/main.go Normal file
View file

@ -0,0 +1,141 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
// Command gen generates frontend presets and IDEA provider names from the Go registry.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"github.com/alibaba/open-code-review/internal/llm"
)
// preset contains only built-in metadata, never resolved user configuration or
// credentials. Keep the JSON field names aligned with OcrProviderPreset.
type preset struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Protocol string `json:"protocol"`
BaseURL string `json:"baseUrl"`
AuthHeader string `json:"authHeader,omitempty"`
EnvVar string `json:"envVar"`
AmbientAuth bool `json:"ambientAuth,omitempty"`
Models []string `json:"models"`
}
const header = `// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
// Code generated by go generate ./internal/llm; DO NOT EDIT.
// Source: internal/llm/providers.go. Providers are sorted by name; model order is preserved.
import type { OcrProviderPreset } from './providers';
export const PROVIDER_PRESETS: OcrProviderPreset[] = `
func main() {
output := flag.String("output", "", "path to the generated TypeScript file")
kotlinOutput := flag.String("kotlin-output", "", "path to the generated Kotlin file")
checkOnly := flag.Bool("check", false, "verify the provider presets without writing files")
flag.Parse()
var err error
if *checkOnly {
err = check(*output, *kotlinOutput)
} else {
err = generate(*output, *kotlinOutput)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func generate(output, kotlinOutput string) error {
if output == "" || kotlinOutput == "" {
return fmt.Errorf("-output and -kotlin-output paths are required; run go generate ./internal/llm from the repository root")
}
providers := llm.ListProviders()
data, err := render(providers)
if err != nil {
return err
}
if err := writeFileAtomically(output, data, 0o644); err != nil {
return fmt.Errorf("write provider presets: %w", err)
}
if err := writeFileAtomically(kotlinOutput, renderKotlin(providers), 0o644); err != nil {
return fmt.Errorf("write Kotlin provider names: %w", err)
}
return nil
}
func writeFileAtomically(output string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(output)
tmp, err := os.CreateTemp(dir, "."+filepath.Base(output)+".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, output); err != nil {
return err
}
return nil
}
func render(providers []llm.Provider) ([]byte, error) {
if err := validateModelLists(providers); err != nil {
return nil, err
}
presets := make([]preset, 0, len(providers))
for _, p := range providers {
models := append([]string{}, p.Models...)
presets = append(presets, preset{
Name: p.Name,
DisplayName: p.DisplayName,
Protocol: p.Protocol,
BaseURL: p.BaseURL,
AuthHeader: p.AuthHeader,
EnvVar: p.EnvVar,
AmbientAuth: p.AmbientAuth,
Models: models,
})
}
data, err := json.MarshalIndent(presets, "", " ")
if err != nil {
return nil, fmt.Errorf("encode provider presets: %w", err)
}
return []byte(header + string(data) + ";\n"), nil
}
func validateModelLists(providers []llm.Provider) error {
for _, provider := range providers {
seen := make(map[string]bool, len(provider.Models))
for _, model := range provider.Models {
if seen[model] {
return fmt.Errorf("provider %q has duplicate model %q", provider.Name, model)
}
seen[model] = true
}
}
return nil
}

View file

@ -0,0 +1,139 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package main
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/alibaba/open-code-review/internal/llm"
)
func TestGeneratedProvidersUpToDate(t *testing.T) {
path := filepath.Join("..", "..", "..", "extensions", "frontend", "src", "shared", "providers.generated.ts")
kotlinPath := filepath.Join("..", "..", "..", "extensions", "idea", "src", "main", "kotlin", "com", "alibaba", "opencodereview", "idea", "services", "ProviderNames.generated.kt")
if err := check(path, kotlinPath); err != nil {
t.Fatal(err)
}
}
func TestRenderPreservesMetadataAndModelOrder(t *testing.T) {
providers := []llm.Provider{
{
Name: "example", DisplayName: "Quoted \"name\" <with> & characters",
Protocol: llm.ProtocolAnthropic, BaseURL: "https://example.com/v1",
AuthHeader: "x-api-key", EnvVar: "EXAMPLE_API_KEY",
Models: []string{"z-default", "a-model", "quote\"slash\\newline\n"},
},
{
Name: "ambient", DisplayName: "Ambient credentials",
Protocol: llm.ProtocolAnthropicBedrock, AmbientAuth: true,
},
{
Name: "responses", Protocol: llm.ProtocolOpenAIResponses,
Models: []string{"responses-model"},
},
}
data, err := render(providers)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(data, []byte("\r")) {
t.Fatal("generated output must use LF line endings")
}
var got []map[string]any
if err := json.Unmarshal(bytes.TrimSuffix(bytes.TrimPrefix(data, []byte(header)), []byte(";\n")), &got); err != nil {
t.Fatalf("generated payload must be valid JSON: %v", err)
}
want := []map[string]any{
{
"name": "example", "displayName": "Quoted \"name\" <with> & characters",
"protocol": "anthropic", "baseUrl": "https://example.com/v1",
"authHeader": "x-api-key", "envVar": "EXAMPLE_API_KEY",
"models": []any{"z-default", "a-model", "quote\"slash\\newline\n"},
},
{
"name": "ambient", "displayName": "Ambient credentials",
"protocol": "anthropic-bedrock", "baseUrl": "", "envVar": "",
"ambientAuth": true, "models": []any{},
},
{
"name": "responses", "displayName": "", "protocol": "openai-responses",
"baseUrl": "", "envVar": "", "models": []any{"responses-model"},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("generated metadata = %#v, want %#v", got, want)
}
}
func TestGenerateRequiresOutput(t *testing.T) {
for _, paths := range [][2]string{{"", "providers.kt"}, {"providers.ts", ""}} {
err := generate(paths[0], paths[1])
if err == nil || !strings.Contains(err.Error(), "-output") || !strings.Contains(err.Error(), "-kotlin-output") || !strings.Contains(err.Error(), "go generate ./internal/llm") {
t.Fatalf("expected the required flags and regeneration command in the error, got %v", err)
}
}
}
func TestRenderRejectsDuplicateModels(t *testing.T) {
for _, models := range [][]string{{"duplicate", "duplicate"}, {"duplicate", "other", "duplicate"}} {
data, err := render([]llm.Provider{{Name: "example", Models: models}})
if err == nil || !strings.Contains(err.Error(), `provider "example" has duplicate model "duplicate"`) {
t.Fatalf("expected provider and duplicate model in the error, got %v", err)
}
if data != nil {
t.Fatal("invalid registry must not produce an artifact")
}
}
if _, err := render([]llm.Provider{
{Name: "first", Models: []string{"shared-model"}},
{Name: "second", Models: []string{"shared-model"}},
}); err != nil {
t.Fatalf("different providers may offer the same model: %v", err)
}
}
func TestGenerate(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "providers.ts")
kotlinPath := filepath.Join(dir, "providers.kt")
if err := generate(path, kotlinPath); err != nil {
t.Fatal(err)
}
first := make(map[string][]byte)
for _, artifact := range []string{path, kotlinPath} {
data, err := os.ReadFile(artifact)
if err != nil {
t.Fatal(err)
}
first[artifact] = data
}
if err := generate(path, kotlinPath); err != nil {
t.Fatal(err)
}
for artifact, data := range first {
second, err := os.ReadFile(artifact)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, second) {
t.Fatalf("generating twice must produce identical bytes for %s", artifact)
}
}
if err := check(path, kotlinPath); err != nil {
t.Fatal(err)
}
if err := generate(t.TempDir(), kotlinPath); err == nil || !strings.Contains(err.Error(), "write provider presets") {
t.Fatalf("expected a contextual write error, got %v", err)
}
if err := generate(path, t.TempDir()); err == nil || !strings.Contains(err.Error(), "write Kotlin provider names") {
t.Fatalf("expected a contextual Kotlin write error, got %v", err)
}
}

View file

@ -3,6 +3,8 @@
package llm
//go:generate go run ./gen -output ../../extensions/frontend/src/shared/providers.generated.ts -kotlin-output ../../extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ProviderNames.generated.kt
import (
"sort"
"strings"
@ -35,6 +37,8 @@ type Provider struct {
AmbientAuth bool
}
// After modifying the built-in provider registry, run `go generate ./internal/llm`
// and commit both generated catalogs. See the root AGENTS.md for paths and verification.
var registry = []Provider{
{
Name: "anthropic",