mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-10-19 10:14:00 +00:00
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:
parent
ebb69835a3
commit
f93ff155dd
18 changed files with 1651 additions and 184 deletions
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
5
.github/workflows/idea-ext.yml
vendored
5
.github/workflows/idea-ext.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
10
AGENTS.md
10
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
73
extensions/frontend/src/shared/__tests__/providers.test.ts
Normal file
73
extensions/frontend/src/shared/__tests__/providers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
456
extensions/frontend/src/shared/providers.generated.ts
Normal file
456
extensions/frontend/src/shared/providers.generated.ts
Normal 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"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
210
internal/llm/gen/check.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
283
internal/llm/gen/check_test.go
Normal file
283
internal/llm/gen/check_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
96
internal/llm/gen/kotlin.go
Normal file
96
internal/llm/gen/kotlin.go
Normal 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
|
||||
}
|
||||
130
internal/llm/gen/kotlin_test.go
Normal file
130
internal/llm/gen/kotlin_test.go
Normal 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
141
internal/llm/gen/main.go
Normal 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
|
||||
}
|
||||
139
internal/llm/gen/main_test.go
Normal file
139
internal/llm/gen/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue