diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61f6f22..924ee12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/idea-ext.yml b/.github/workflows/idea-ext.yml index 4951691..284dfc9 100644 --- a/.github/workflows/idea-ext.yml +++ b/.github/workflows/idea-ext.yml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index cca9aa3..3059434 100644 --- a/AGENTS.md +++ b/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..md` and `docs/i18n/CONTRIBUTING..md` (`zh-CN`, `ja-JP`, `ko-KR`, `ru-RU`); the doc pages under `pages/src/content/docs//` (`en`, `zh`, `ja`, `ru`, Markdown throughout); and the UI copy tables in `pages/src/i18n/.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: ` 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. diff --git a/extensions/frontend/src/shared/__tests__/providers.test.ts b/extensions/frontend/src/shared/__tests__/providers.test.ts new file mode 100644 index 0000000..5546d4b --- /dev/null +++ b/extensions/frontend/src/shared/__tests__/providers.test.ts @@ -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); + }); +}); diff --git a/extensions/frontend/src/shared/providers.generated.ts b/extensions/frontend/src/shared/providers.generated.ts new file mode 100644 index 0000000..08d3082 --- /dev/null +++ b/extensions/frontend/src/shared/providers.generated.ts @@ -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" + ] + } +]; diff --git a/extensions/frontend/src/shared/providers.ts b/extensions/frontend/src/shared/providers.ts index c57a25c..f39955e 100644 --- a/extensions/frontend/src/shared/providers.ts +++ b/extensions/frontend/src/shared/providers.ts @@ -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(); const out: string[] = []; diff --git a/extensions/frontend/src/webview/views/ConfigView.tsx b/extensions/frontend/src/webview/views/ConfigView.tsx index c812e7e..8d2a85a 100644 --- a/extensions/frontend/src/webview/views/ConfigView.tsx +++ b/extensions/frontend/src/webview/views/ConfigView.tsx @@ -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} /> @@ -407,16 +411,18 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr )} - - { setApiKey(v); setApiKeyTouched(true); }} - placeholder={hasStoredKey && !apiKeyTouched ? t('view.config.apiKeySaved') : 'sk-...'} - /> - + {!ambientAuth && ( + + { setApiKey(v); setApiKeyTouched(true); }} + placeholder={hasStoredKey && !apiKeyTouched ? t('view.config.apiKeySaved') : 'sk-...'} + /> + + )} @@ -569,4 +575,4 @@ function ConnActions({ wide, connTest, canSave, onBack, onTest, onSave }: { ); -} \ No newline at end of file +} diff --git a/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ProviderNames.generated.kt b/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ProviderNames.generated.kt new file mode 100644 index 0000000..881a949 --- /dev/null +++ b/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ProviderNames.generated.kt @@ -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 = 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", +) diff --git a/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/Providers.kt b/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/Providers.kt index d9827fa..334cad0 100644 --- a/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/Providers.kt +++ b/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/Providers.kt @@ -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 = 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 = 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 = PRESET_PROVIDER_NAMES.toSet() diff --git a/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraftTest.kt b/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraftTest.kt index d50e38c..a3249df 100644 --- a/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraftTest.kt +++ b/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraftTest.kt @@ -101,6 +101,16 @@ class ConfigDraftTest { // ------------------------------------------------------------ providers.. + @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") diff --git a/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ProvidersTest.kt b/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ProvidersTest.kt index 8f254c9..ebd577f 100644 --- a/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ProvidersTest.kt +++ b/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ProvidersTest.kt @@ -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("")) } diff --git a/internal/llm/gen/check.go b/internal/llm/gen/check.go new file mode 100644 index 0000000..6cdef66 --- /dev/null +++ b/internal/llm/gen/check.go @@ -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 + } + } +} diff --git a/internal/llm/gen/check_test.go b/internal/llm/gen/check_test.go new file mode 100644 index 0000000..8ac64db --- /dev/null +++ b/internal/llm/gen/check_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/llm/gen/kotlin.go b/internal/llm/gen/kotlin.go new file mode 100644 index 0000000..5068f34 --- /dev/null +++ b/internal/llm/gen/kotlin.go @@ -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 = 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 +} diff --git a/internal/llm/gen/kotlin_test.go b/internal/llm/gen/kotlin_test.go new file mode 100644 index 0000000..591e421 --- /dev/null +++ b/internal/llm/gen/kotlin_test.go @@ -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) + } +} diff --git a/internal/llm/gen/main.go b/internal/llm/gen/main.go new file mode 100644 index 0000000..a286c7f --- /dev/null +++ b/internal/llm/gen/main.go @@ -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 +} diff --git a/internal/llm/gen/main_test.go b/internal/llm/gen/main_test.go new file mode 100644 index 0000000..1a7312f --- /dev/null +++ b/internal/llm/gen/main_test.go @@ -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\" & 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\" & 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) + } +} diff --git a/internal/llm/providers.go b/internal/llm/providers.go index dc798f6..b90f435 100644 --- a/internal/llm/providers.go +++ b/internal/llm/providers.go @@ -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",