feat: add interactive provider setup with TUI and documentation

This commit is contained in:
kite 2026-06-14 10:41:35 +08:00
parent f577742c04
commit 72aad2c77c
23 changed files with 3407 additions and 76 deletions

View file

@ -113,18 +113,22 @@ sudo cp dist/opencodereview /usr/local/bin/ocr
**コードレビューの前に必ずLLMを設定する必要があります。**
**オプションA: 対話的セットアップ(推奨)**
```bash
ocr config provider # ビルトインプロバイダーを選択またはカスタムプロバイダーを追加
ocr config model # アクティブなプロバイダーのモデルを選択
```
![Provider setup](imgs/providers.jpg)
**オプションB: 手動設定**
```bash
# オプションA: 対話的な設定
ocr config set llm.url https://api.anthropic.com/v1/messages
ocr config set llm.auth_token your-api-key-here
ocr config set llm.model claude-opus-4-6
ocr config set llm.use_anthropic true
# オプションB: 環境変数(最優先)
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
export OCR_LLM_TOKEN=your-api-key-here
export OCR_LLM_MODEL=claude-opus-4-6
export OCR_USE_ANTHROPIC=true
```
設定は`~/.opencodereview/config.json`に保存されます。
@ -133,12 +137,19 @@ export OCR_USE_ANTHROPIC=true
```bash
ocr config set llm.auth_header x-api-key
# または
export OCR_LLM_AUTH_HEADER=x-api-key
```
サポートされる値:`x-api-key``authorization`(エイリアス:`bearer`)。それ以外の値はエラーになります。
**オプションC: 環境変数(最優先)**
```bash
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
export OCR_LLM_TOKEN=your-api-key-here
export OCR_LLM_MODEL=claude-opus-4-6
export OCR_USE_ANTHROPIC=true
```
また、Claude Codeの環境変数`ANTHROPIC_BASE_URL``ANTHROPIC_AUTH_TOKEN``ANTHROPIC_MODEL`)とも互換性があり、`~/.zshrc` / `~/.bashrc`からこれらのexportをパースします。
> **CC-Switchユーザー向けの注意**: [CC-Switch](https://github.com/farion1231/cc-switch)を[ルーティングサービス](https://www.ccswitch.io/en/docs?section=proxy&item=service)有効で使用している場合、追加設定なしで`llm.url`をCC-Switchのプロキシアドレスに向けることができます
@ -278,8 +289,11 @@ ocr review \
|---------|-------|-------------|
| `ocr review` | `ocr r` | コードレビューを開始 |
| `ocr rules check <file>` | — | ファイルパスに適用されるレビュールールをプレビュー |
| `ocr config provider` | — | 対話的プロバイダーセットアップ(ビルトイン、カスタム、手動) |
| `ocr config model` | — | アクティブなプロバイダーの対話的モデル選択 |
| `ocr config set <key> <value>` | — | 設定値をセット |
| `ocr llm test` | — | LLMの疎通テスト |
| `ocr llm providers` | — | ビルトインLLMプロバイダーを一覧表示 |
| `ocr viewer` | `ocr v` | `localhost:5483`でWebUIセッションビューアーを起動 |
| `ocr version` | — | バージョン情報を表示 |
@ -305,6 +319,11 @@ ocr review \
## 例
```bash
# 対話的プロバイダーとモデルのセットアップ
ocr config provider
ocr config model
ocr llm providers
# レビュー対象ファイルをプレビューLLM呼び出しなし
ocr review --preview
ocr review -c abc123 -p
@ -425,6 +444,13 @@ OCRは4層の優先度チェーンを使ってレビュールールを解決し
| キー | 型 | 例 |
|-----|------|---------|
| `provider` | string | `anthropic` \| `openai` \| `dashscope` \| `deepseek` \| `z-ai` |
| `providers.<name>.api_key` | string | プロバイダー固有のAPIキー |
| `providers.<name>.url` | string | プロバイダーのベースURLオーバーライド |
| `providers.<name>.protocol` | string | `anthropic` \| `openai` |
| `providers.<name>.model` | string | プロバイダーのモデル名 |
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
| `custom_providers.<name>.*` | — | `providers.<name>.*`と同じフィールド |
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
| `llm.auth_token` | string | `sk-xxxxxxx` |
| `llm.auth_header` | string | Anthropicのみ`x-api-key` \| `authorization` |

View file

@ -113,18 +113,22 @@ sudo cp dist/opencodereview /usr/local/bin/ocr
**코드 리뷰를 실행하기 전에 반드시 LLM을 설정해야 합니다.**
**Option A: 대화형 설정 (권장)**
```bash
ocr config provider # built-in provider 선택 또는 custom provider 추가
ocr config model # 활성 provider의 model 선택
```
![Provider setup](imgs/providers.jpg)
**Option B: 수동 설정**
```bash
# Option A: 대화형 config
ocr config set llm.url https://api.anthropic.com/v1/messages
ocr config set llm.auth_token your-api-key-here
ocr config set llm.model claude-opus-4-6
ocr config set llm.use_anthropic true
# Option B: 환경 변수(가장 높은 우선순위)
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
export OCR_LLM_TOKEN=your-api-key-here
export OCR_LLM_MODEL=claude-opus-4-6
export OCR_USE_ANTHROPIC=true
```
config는 `~/.opencodereview/config.json`에 저장됩니다.
@ -133,12 +137,19 @@ config는 `~/.opencodereview/config.json`에 저장됩니다.
```bash
ocr config set llm.auth_header x-api-key
# 또는
export OCR_LLM_AUTH_HEADER=x-api-key
```
지원되는 값: `x-api-key`, `authorization` (별칭: `bearer`). 그 외 값은 오류로 처리됩니다.
**Option C: 환경 변수(가장 높은 우선순위)**
```bash
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
export OCR_LLM_TOKEN=your-api-key-here
export OCR_LLM_MODEL=claude-opus-4-6
export OCR_USE_ANTHROPIC=true
```
Claude Code 환경 변수(`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`)와도 호환되며, `~/.zshrc` / `~/.bashrc`의 export도 파싱합니다.
> **CC-Switch 사용자 참고**: [CC-Switch](https://github.com/farion1231/cc-switch)를 [routing service](https://www.ccswitch.io/en/docs?section=proxy&item=service)와 함께 사용한다면, 추가 설정 없이 `llm.url`을 CC-Switch proxy 주소로 지정할 수 있습니다.
@ -278,8 +289,11 @@ ocr review \
|---------|-------|-------------|
| `ocr review` | `ocr r` | 코드 리뷰 시작 |
| `ocr rules check <file>` | - | 파일 경로에 적용될 리뷰 rule 미리보기 |
| `ocr config provider` | - | 대화형 provider 설정 (built-in, custom, 수동) |
| `ocr config model` | - | 활성 provider의 대화형 model 선택 |
| `ocr config set <key> <value>` | - | config 값 설정 |
| `ocr llm test` | - | LLM 연결 테스트 |
| `ocr llm providers` | - | built-in LLM provider 목록 표시 |
| `ocr viewer` | `ocr v` | `localhost:5483`에서 WebUI session viewer 실행 |
| `ocr version` | - | version 정보 표시 |
@ -305,6 +319,11 @@ ocr review \
## Examples
```bash
# 대화형 provider 및 model 설정
ocr config provider
ocr config model
ocr llm providers
# 리뷰 대상 파일 미리보기(LLM call 없음)
ocr review --preview
ocr review -c abc123 -p
@ -383,6 +402,13 @@ Config file: `~/.opencodereview/config.json`
| Key | Type | Example |
|-----|------|---------|
| `provider` | string | `anthropic` \| `openai` \| `dashscope` \| `deepseek` \| `z-ai` |
| `providers.<name>.api_key` | string | Provider별 API key |
| `providers.<name>.url` | string | Provider base URL override |
| `providers.<name>.protocol` | string | `anthropic` \| `openai` |
| `providers.<name>.model` | string | Provider의 model 이름 |
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
| `custom_providers.<name>.*` | — | `providers.<name>.*`과 동일한 필드 |
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
| `llm.auth_token` | string | `sk-xxxxxxx` |
| `llm.auth_header` | string | Anthropic only: `x-api-key` \| `authorization` |

View file

@ -113,18 +113,22 @@ sudo cp dist/opencodereview /usr/local/bin/ocr
**You must configure an LLM before reviewing code.**
**Option A: Interactive setup (Recommended)**
```bash
ocr config provider # Select a built-in provider or add a custom one
ocr config model # Pick a model for the active provider
```
![Provider setup](imgs/providers.jpg)
**Option B: Manual config**
```bash
# Option A: Interactive config
ocr config set llm.url https://api.anthropic.com/v1/messages
ocr config set llm.auth_token your-api-key-here
ocr config set llm.model claude-opus-4-6
ocr config set llm.use_anthropic true
# Option B: Environment variables (highest priority)
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
export OCR_LLM_TOKEN=your-api-key-here
export OCR_LLM_MODEL=claude-opus-4-6
export OCR_USE_ANTHROPIC=true
```
Config is stored in `~/.opencodereview/config.json`.
@ -133,12 +137,19 @@ Config is stored in `~/.opencodereview/config.json`.
```bash
ocr config set llm.auth_header x-api-key
# or
export OCR_LLM_AUTH_HEADER=x-api-key
```
Supported values: `x-api-key`, `authorization` (alias: `bearer`). Other values are rejected with an error.
**Option C: Environment variables (highest priority)**
```bash
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
export OCR_LLM_TOKEN=your-api-key-here
export OCR_LLM_MODEL=claude-opus-4-6
export OCR_USE_ANTHROPIC=true
```
It is also compatible with Claude Code environment variables (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`) and parses `~/.zshrc` / `~/.bashrc` for those exports.
> **Note for CC-Switch Users**: If you are using [CC-Switch](https://github.com/farion1231/cc-switch) with [routing service](https://www.ccswitch.io/en/docs?section=proxy&item=service) enabled, you can point `llm.url` to the CC-Switch proxy address without additional configuration:
@ -280,8 +291,11 @@ See the [`examples/`](./examples/) directory for integration examples:
|---------|-------|-------------|
| `ocr review` | `ocr r` | Start a code review |
| `ocr rules check <file>` | — | Preview which review rule applies to a file path |
| `ocr config provider` | — | Interactive provider setup (built-in, custom, or manual) |
| `ocr config model` | — | Interactive model selection for the active provider |
| `ocr config set <key> <value>` | — | Set configuration values |
| `ocr llm test` | — | Test LLM connectivity |
| `ocr llm providers` | — | List built-in LLM providers |
| `ocr viewer` | `ocr v` | Launch WebUI session viewer on `localhost:5483` |
| `ocr version` | — | Show version info |
@ -307,6 +321,11 @@ See the [`examples/`](./examples/) directory for integration examples:
## Examples
```bash
# Interactive provider and model setup
ocr config provider
ocr config model
ocr llm providers
# Preview which files will be reviewed (no LLM calls)
ocr review --preview
ocr review -c abc123 -p
@ -427,6 +446,13 @@ Config file: `~/.opencodereview/config.json`
| Key | Type | Example |
|-----|------|---------|
| `provider` | string | `anthropic` \| `openai` \| `dashscope` \| `deepseek` \| `z-ai` |
| `providers.<name>.api_key` | string | Provider-specific API key |
| `providers.<name>.url` | string | Provider base URL override |
| `providers.<name>.protocol` | string | `anthropic` \| `openai` |
| `providers.<name>.model` | string | Model name for the provider |
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
| `custom_providers.<name>.*` | — | Same fields as `providers.<name>.*` |
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
| `llm.auth_token` | string | `sk-xxxxxxx` |
| `llm.auth_header` | string | Anthropic only: `x-api-key` \| `authorization` |

View file

@ -113,18 +113,22 @@ sudo cp dist/opencodereview /usr/local/bin/ocr
**在审查代码之前,必须先配置 LLM。**
**方式 A交互式设置推荐**
```bash
ocr config provider # 选择内置供应商或添加自定义供应商
ocr config model # 为当前供应商选择模型
```
![Provider setup](imgs/providers.jpg)
**方式 B手动配置**
```bash
# 方式 A交互式配置
ocr config set llm.url https://api.anthropic.com/v1/messages
ocr config set llm.auth_token your-api-key-here
ocr config set llm.model claude-opus-4-6
ocr config set llm.use_anthropic true
# 方式 B环境变量优先级最高
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
export OCR_LLM_TOKEN=your-api-key-here
export OCR_LLM_MODEL=claude-opus-4-6
export OCR_USE_ANTHROPIC=true
```
配置存储于 `~/.opencodereview/config.json`
@ -133,12 +137,19 @@ export OCR_USE_ANTHROPIC=true
```bash
ocr config set llm.auth_header x-api-key
# 或
export OCR_LLM_AUTH_HEADER=x-api-key
```
支持的值:`x-api-key``authorization`(别名:`bearer`)。其他值会直接报错。
**方式 C环境变量优先级最高**
```bash
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
export OCR_LLM_TOKEN=your-api-key-here
export OCR_LLM_MODEL=claude-opus-4-6
export OCR_USE_ANTHROPIC=true
```
同时兼容了 Claude Code 环境变量(`ANTHROPIC_BASE_URL``ANTHROPIC_AUTH_TOKEN``ANTHROPIC_MODEL`),并解析 `~/.zshrc` / `~/.bashrc` 中的相关导出。
> **CC-Switch 用户特别提醒**:如果你使用 [CC-Switch](https://github.com/farion1231/cc-switch) 并开启了[路由服务](https://www.ccswitch.io/zh/docs?section=proxy&item=service),可以将 `llm.url` 配置成 CC-Switch 启动的代理地址,无需额外配置:
@ -278,8 +289,11 @@ ocr review \
|------|------|------|
| `ocr review` | `ocr r` | 开始代码审查 |
| `ocr rules check <file>` | — | 预览某个文件路径生效的审查规则 |
| `ocr config provider` | — | 交互式供应商设置(内置、自定义或手动) |
| `ocr config model` | — | 为当前供应商交互式选择模型 |
| `ocr config set <key> <value>` | — | 设置配置项 |
| `ocr llm test` | — | 测试 LLM 连通性 |
| `ocr llm providers` | — | 列出内置 LLM 供应商 |
| `ocr viewer` | `ocr v` | 启动 WebUI 会话查看器,地址 `localhost:5483` |
| `ocr version` | — | 显示版本信息 |
@ -305,6 +319,11 @@ ocr review \
## 示例
```bash
# 交互式供应商和模型设置
ocr config provider
ocr config model
ocr llm providers
# 预览将被审查的文件(不调用 LLM
ocr review --preview
ocr review -c abc123 -p
@ -415,6 +434,13 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则
| 键 | 类型 | 示例 |
|----|------|------|
| `provider` | string | `anthropic` \| `openai` \| `dashscope` \| `deepseek` \| `z-ai` |
| `providers.<name>.api_key` | string | 供应商 API 密钥 |
| `providers.<name>.url` | string | 供应商 Base URL 覆盖 |
| `providers.<name>.protocol` | string | `anthropic` \| `openai` |
| `providers.<name>.model` | string | 供应商模型名称 |
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
| `custom_providers.<name>.*` | — | 与 `providers.<name>.*` 相同的字段 |
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
| `llm.auth_token` | string | `sk-xxxxxxx` |
| `llm.auth_header` | string | 仅 Anthropic`x-api-key` \| `authorization` |

View file

@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/open-code-review/open-code-review/internal/llm"
)
@ -25,6 +26,19 @@ func runConfig(args []string) error {
return nil
}
switch args[0] {
case "provider":
if len(args) != 1 {
return fmt.Errorf("config provider does not accept arguments; use 'ocr config set provider <name>' for non-interactive setup")
}
return runConfigProvider()
case "model":
if len(args) != 1 {
return fmt.Errorf("config model does not accept arguments; use 'ocr config set model <name>' for non-interactive setup")
}
return runConfigModel()
}
action, err := parseConfigArgs(args)
if err != nil {
return err
@ -53,29 +67,38 @@ func runConfigSet(key, value string) error {
return err
}
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
if err := saveConfig(configPath, cfg); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
displayValue := value
normalizedKey := strings.ToLower(strings.ReplaceAll(key, "_", ""))
if strings.HasSuffix(normalizedKey, "apikey") || strings.HasSuffix(normalizedKey, "authtoken") {
displayValue = maskKey(value)
}
if err := os.WriteFile(configPath, data, 0o644); err != nil {
return fmt.Errorf("write config: %w", err)
}
fmt.Printf("Set %s = %s\n", key, value)
fmt.Printf("Set %s = %s\n", key, displayValue)
return nil
}
// ProviderEntry holds per-provider configuration in the providers map.
type ProviderEntry struct {
APIKey string `json:"api_key,omitempty"`
URL string `json:"url,omitempty"`
Protocol string `json:"protocol,omitempty"`
Model string `json:"model,omitempty"`
AuthHeader string `json:"auth_header,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"`
}
// Config represents the user-level configuration file (~/.opencodereview/config.json).
type Config struct {
Llm LlmConfig `json:"llm,omitempty"`
Language string `json:"language,omitempty"` // Output language, defaults to Chinese when empty
Telemetry *TelemetryConfig `json:"telemetry,omitempty"` // Telemetry/observability settings
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Providers map[string]ProviderEntry `json:"providers,omitempty"`
CustomProviders map[string]ProviderEntry `json:"custom_providers,omitempty"`
Llm LlmConfig `json:"llm,omitempty"`
Language string `json:"language,omitempty"`
Telemetry *TelemetryConfig `json:"telemetry,omitempty"`
}
type LlmConfig struct {
@ -127,7 +150,55 @@ func LoadAppConfig(path string) (*Config, error) {
}
func setConfigValue(cfg *Config, key, value string) error {
// Handle providers.<name>.<field> paths.
if strings.HasPrefix(key, "providers.") {
return setProviderValue(cfg, key, value)
}
if strings.HasPrefix(key, "custom_providers.") {
return setCustomProviderValue(cfg, key, value)
}
switch key {
case "provider":
if cfg.Provider != value {
cfg.Model = ""
}
cfg.Provider = value
if _, isPreset := llm.LookupProvider(value); isPreset {
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
if _, exists := cfg.Providers[value]; !exists {
cfg.Providers[value] = ProviderEntry{}
}
} else {
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
if _, exists := cfg.CustomProviders[value]; !exists {
cfg.CustomProviders[value] = ProviderEntry{}
}
}
case "model":
if cfg.Provider != "" {
if _, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[cfg.Provider]
entry.Model = value
cfg.Providers[cfg.Provider] = entry
} else {
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[cfg.Provider]
entry.Model = value
cfg.CustomProviders[cfg.Provider] = entry
}
} else {
cfg.Model = value
}
case "llm.url", "llm.URL":
cfg.Llm.URL = value
case "llm.auth_token", "llm.AuthToken":
@ -175,11 +246,74 @@ func setConfigValue(cfg *Config, key, value string) error {
}
cfg.Llm.ExtraBody = m
default:
return fmt.Errorf("unknown config key: %s\nSupported keys: llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging", key)
return fmt.Errorf("unknown config key: %s\nSupported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging", key)
}
return nil
}
func applyProviderField(entry *ProviderEntry, field, key, value string) error {
switch field {
case "api_key":
entry.APIKey = value
case "url":
entry.URL = value
case "protocol":
if value != "anthropic" && value != "openai" {
return fmt.Errorf("invalid protocol %q: must be \"anthropic\" or \"openai\"", value)
}
entry.Protocol = value
case "model":
entry.Model = value
case "auth_header":
normalized, err := llm.NormalizeAuthHeader(value)
if err != nil {
return err
}
entry.AuthHeader = normalized
case "extra_body":
var m map[string]any
if err := json.Unmarshal([]byte(value), &m); err != nil {
return fmt.Errorf("invalid JSON for %s: %w", key, err)
}
entry.ExtraBody = m
default:
return fmt.Errorf("unknown provider field %q: supported fields are api_key, url, protocol, model, auth_header, extra_body", field)
}
return nil
}
func setProviderValue(cfg *Config, key, value string) error {
parts := strings.SplitN(key, ".", 3)
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
return fmt.Errorf("invalid provider key %q: expected providers.<name>.<field>", key)
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[parts[1]]
if err := applyProviderField(&entry, parts[2], key, value); err != nil {
return err
}
cfg.Providers[parts[1]] = entry
return nil
}
func setCustomProviderValue(cfg *Config, key, value string) error {
parts := strings.SplitN(key, ".", 3)
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
return fmt.Errorf("invalid custom provider key %q: expected custom_providers.<name>.<field>", key)
}
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[parts[1]]
if err := applyProviderField(&entry, parts[2], key, value); err != nil {
return err
}
cfg.CustomProviders[parts[1]] = entry
return nil
}
func (c *Config) ensureTelemetry() {
if c.Telemetry == nil {
c.Telemetry = &TelemetryConfig{}

View file

@ -21,3 +21,126 @@ func TestSetConfigValueAuthHeaderRejectsCustomHeader(t *testing.T) {
t.Fatal("expected error for unsupported auth_header, got nil")
}
}
func TestSetConfigValueProvider(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "provider", "anthropic"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Provider != "anthropic" {
t.Errorf("Provider = %q, want %q", cfg.Provider, "anthropic")
}
}
func TestSetConfigValueModel(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "model", "claude-opus-4-6"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Model != "claude-opus-4-6" {
t.Errorf("Model = %q, want %q", cfg.Model, "claude-opus-4-6")
}
}
func TestSetConfigValueModelWithProvider(t *testing.T) {
cfg := &Config{
Provider: "anthropic",
Providers: map[string]ProviderEntry{
"anthropic": {APIKey: "sk-test"},
},
}
if err := setConfigValue(cfg, "model", "claude-opus-4-6"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Providers["anthropic"].Model != "claude-opus-4-6" {
t.Errorf("entry Model = %q, want %q", cfg.Providers["anthropic"].Model, "claude-opus-4-6")
}
if cfg.Model != "" {
t.Errorf("top-level Model = %q, want empty (should write to provider entry)", cfg.Model)
}
}
func TestSetConfigValueProviderEntry(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.anthropic.api_key", "sk-ant-test"); err != nil {
t.Fatalf("setConfigValue api_key: %v", err)
}
if cfg.Providers["anthropic"].APIKey != "sk-ant-test" {
t.Errorf("api_key = %q, want %q", cfg.Providers["anthropic"].APIKey, "sk-ant-test")
}
if err := setConfigValue(cfg, "providers.anthropic.model", "claude-opus-4-6"); err != nil {
t.Fatalf("setConfigValue model: %v", err)
}
if cfg.Providers["anthropic"].Model != "claude-opus-4-6" {
t.Errorf("model = %q, want %q", cfg.Providers["anthropic"].Model, "claude-opus-4-6")
}
}
func TestSetConfigValueProviderEntryProtocol(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "openai"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.CustomProviders["custom"].Protocol != "openai" {
t.Errorf("protocol = %q, want %q", cfg.CustomProviders["custom"].Protocol, "openai")
}
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "invalid"); err == nil {
t.Fatal("expected error for invalid protocol")
}
}
func TestSetConfigValueProviderEntryInvalidKey(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.anthropic.unknown_field", "value"); err == nil {
t.Fatal("expected error for unknown provider field")
}
}
func TestSetConfigValueProviderEntryInvalidPath(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.anthropic", "value"); err == nil {
t.Fatal("expected error for incomplete provider path")
}
}
func TestSetConfigValueProviderEntryExtraBody(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.anthropic.extra_body", `{"thinking":{"type":"disabled"}}`); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Providers["anthropic"].ExtraBody == nil {
t.Fatal("extra_body should not be nil")
}
if _, ok := cfg.Providers["anthropic"].ExtraBody["thinking"]; !ok {
t.Error("extra_body missing 'thinking' key")
}
}
func TestSetConfigValueModelWithCustomProvider(t *testing.T) {
cfg := &Config{
Provider: "my-gateway",
CustomProviders: map[string]ProviderEntry{
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai"},
},
}
if err := setConfigValue(cfg, "model", "llama-3-70b"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.CustomProviders["my-gateway"].Model != "llama-3-70b" {
t.Errorf("entry Model = %q, want %q", cfg.CustomProviders["my-gateway"].Model, "llama-3-70b")
}
if cfg.Model != "" {
t.Errorf("top-level Model = %q, want empty (should write to custom provider entry)", cfg.Model)
}
}

View file

@ -249,7 +249,7 @@ func parseConfigArgs(args []string) (configAction, error) {
value: args[2],
}, nil
default:
return configAction{}, fmt.Errorf("unknown config sub-command: %s\nAvailable: set", subCmd)
return configAction{}, fmt.Errorf("unknown config sub-command: %s\nAvailable: set, provider, model", subCmd)
}
}
@ -258,8 +258,29 @@ func printConfigUsage() {
Usage:
ocr config set <key> <value>
ocr config provider Interactive provider setup
ocr config model Interactive model selection
Examples:
# Provider setup (interactive)
ocr config provider
ocr config model
# Provider setup (non-interactive)
ocr config set provider anthropic
ocr config set model claude-opus-4-6
# Set API key via environment variable (recommended) or config:
# export ANTHROPIC_API_KEY=sk-ant-xxx
ocr config set providers.anthropic.api_key "$ANTHROPIC_API_KEY"
# Custom provider
ocr config set provider my-gateway
ocr config set providers.my-gateway.url https://gateway.internal.com/v1
ocr config set providers.my-gateway.protocol openai
ocr config set providers.my-gateway.model llama-3-70b
ocr config set providers.my-gateway.api_key "$MY_API_KEY"
# Legacy endpoint configuration
ocr config set llm.url https://xx/v1/openai/chat/completions
ocr config set llm.auth_token xxxxxxxxxx
ocr config set llm.auth_header x-api-key
@ -268,5 +289,5 @@ Examples:
ocr config set language English
ocr config set telemetry.enabled true
Supported keys: llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging`)
Supported keys: provider, model, providers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging`)
}

View file

@ -5,7 +5,6 @@ import (
"fmt"
"time"
"github.com/open-code-review/open-code-review/internal/config/template"
"github.com/open-code-review/open-code-review/internal/config/testconnection"
"github.com/open-code-review/open-code-review/internal/llm"
)
@ -19,6 +18,9 @@ func runLLM(args []string) error {
switch args[0] {
case "test":
return runLLMTest()
case "providers":
runLLMProviders()
return nil
default:
return fmt.Errorf("unknown llm sub-command: %s\nRun 'ocr llm' for usage", args[0])
}
@ -53,11 +55,6 @@ func runLLMTest() error {
timeout = time.Duration(task.Timeout) * time.Second
}
tpl, err := template.LoadDefault()
if err != nil {
return fmt.Errorf("load default template: %w", err)
}
llmClient := llm.NewLLMClient(ep)
messages := make([]llm.Message, 0, len(task.Messages))
@ -71,7 +68,7 @@ func runLLMTest() error {
return llmClient.CompletionsWithCtx(ctx, llm.ChatRequest{
Model: ep.Model,
Messages: messages,
MaxTokens: tpl.MaxTokens,
MaxTokens: 256,
})
}()
if err != nil {
@ -89,6 +86,18 @@ func runLLMTest() error {
return nil
}
func runLLMProviders() {
providers := llm.ListProviders()
fmt.Println("\nBuilt-in providers:")
fmt.Printf(" %-14s %-10s %s\n", "NAME", "PROTOCOL", "BASE URL")
fmt.Printf(" %-14s %-10s %s\n", "----", "--------", "--------")
for _, p := range providers {
fmt.Printf(" %-14s %-10s %s\n", p.Name, p.Protocol, p.BaseURL)
}
fmt.Println("\nUse 'ocr config provider' to configure a provider interactively.")
fmt.Println("Use 'ocr config set provider <name>' to switch providers non-interactively.")
}
func printLLMUsage() {
fmt.Println(`LLM utility commands.
@ -97,7 +106,9 @@ Usage:
Sub-commands:
test Send a test conversation to the configured LLM model
providers List all built-in LLM providers
Examples:
ocr llm test Verify LLM connectivity and configuration`)
ocr llm test Verify LLM connectivity and configuration
ocr llm providers List available built-in providers`)
}

View file

@ -79,8 +79,11 @@ Commands:
Examples:
ocr review --from master --to dev Review diff range
ocr review --commit abc123 Review a single commit
ocr config provider Interactive provider setup
ocr config model Interactive model selection
ocr config set llm.model opus-4-6 Set a config value
ocr llm test Test LLM connectivity
ocr llm providers List built-in providers
ocr version Show version info
Use "ocr review -h" for more information about review.

View file

@ -0,0 +1,272 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
tea "charm.land/bubbletea/v2"
"github.com/open-code-review/open-code-review/internal/llm"
)
func runConfigProvider() error {
configPath, err := defaultConfigPath()
if err != nil {
return err
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
m := newProviderTUI(cfg)
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("TUI error: %w", err)
}
final := finalModel.(providerTUIModel)
if !final.confirmed {
fmt.Println("Cancelled.")
return nil
}
result := final.result()
if result.isManual {
return applyManualConfig(configPath, cfg, result)
}
if result.isCustom {
return applyCustomProviderConfig(configPath, cfg, result)
}
return applyOfficialProviderConfig(configPath, cfg, result)
}
func applyManualConfig(configPath string, cfg *Config, result providerTUIResult) error {
if result.url == "" {
return fmt.Errorf("URL is required for manual configuration")
}
if result.model == "" {
return fmt.Errorf("model is required for manual configuration")
}
cfg.Provider = ""
cfg.Model = ""
cfg.Llm.URL = result.url
cfg.Llm.Model = result.model
cfg.Llm.AuthToken = result.apiKey
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Println("\nManual configuration saved.")
fmt.Printf("URL: %s\n", result.url)
fmt.Printf("Model: %s\n", result.model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
fmt.Fprintln(os.Stderr, "Configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
return nil
}
return nil
}
func applyCustomProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
if result.provider == "" {
return fmt.Errorf("provider name is required")
}
if result.model == "" {
return fmt.Errorf("model is required")
}
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[result.provider]
entry.Model = result.model
if result.url != "" {
entry.URL = result.url
}
if result.protocol != "" {
entry.Protocol = result.protocol
}
if result.authHeader != "" {
entry.AuthHeader = result.authHeader
}
if result.apiKey != "" {
entry.APIKey = result.apiKey
}
cfg.CustomProviders[result.provider] = entry
if cfg.Provider != result.provider {
cfg.Model = ""
}
cfg.Provider = result.provider
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("\nProvider set to: %s (custom)\n", result.provider)
fmt.Printf("Model: %s\n", result.model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
fmt.Fprintln(os.Stderr, "Provider configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
return nil
}
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
return nil
}
func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
if result.provider == "" || result.model == "" {
return fmt.Errorf("provider and model are required")
}
preset, isPreset := llm.LookupProvider(result.provider)
if result.apiKey == "" {
if isPreset && preset.EnvVar != "" {
if os.Getenv(preset.EnvVar) == "" {
return fmt.Errorf("API key is required for provider %s (configure it or set $%s)", result.provider, preset.EnvVar)
}
} else {
return fmt.Errorf("API key is required for provider %s", result.provider)
}
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[result.provider]
entry.Model = result.model
if result.apiKey != "" {
entry.APIKey = result.apiKey
}
cfg.Providers[result.provider] = entry
if cfg.Provider != result.provider {
cfg.Model = ""
}
cfg.Provider = result.provider
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("\nProvider set to: %s\n", result.provider)
fmt.Printf("Model: %s\n", result.model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
fmt.Fprintln(os.Stderr, "Provider configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
return nil
}
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
return nil
}
func runConfigModel() error {
configPath, err := defaultConfigPath()
if err != nil {
return err
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.Provider == "" {
return fmt.Errorf("no provider configured. Run 'ocr config provider' first")
}
preset, isPreset := llm.LookupProvider(cfg.Provider)
if !isPreset {
return fmt.Errorf("provider %q is not a preset provider; use 'ocr config set providers.%s.model <name>' instead", cfg.Provider, cfg.Provider)
}
currentModel := ""
if entry, ok := cfg.Providers[cfg.Provider]; ok {
currentModel = entry.Model
}
if currentModel == "" {
currentModel = cfg.Model
}
m := newModelTUI(preset, currentModel)
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("TUI error: %w", err)
}
final := finalModel.(modelTUIModel)
if final.cancelled {
fmt.Println("Cancelled.")
return nil
}
selectedModel := final.selectedModel()
if selectedModel == "" {
return fmt.Errorf("model name cannot be empty")
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[cfg.Provider]
entry.Model = selectedModel
cfg.Providers[cfg.Provider] = entry
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("\nModel set to: %s\n", selectedModel)
return nil
}
func saveConfig(path string, cfg *Config) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write config: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("chmod config: %w", err)
}
return nil
}
func maskKey(key string) string {
if key == "" {
return "(not set)"
}
if len(key) <= 8 {
return "***"
}
return key[:4] + "***" + key[len(key)-4:]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,514 @@
package main
import (
"testing"
tea "charm.land/bubbletea/v2"
)
func escKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyEscape}
}
func enterKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyEnter}
}
func leftKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyLeft}
}
func rightKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyRight}
}
func downKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyDown}
}
func tabKeyMsg() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyTab}
}
func charKey(c rune) tea.KeyPressMsg {
return tea.KeyPressMsg{Code: c}
}
// --- Tab switching tests ---
func TestProviderTUI_TabSwitchRight(t *testing.T) {
m := newProviderTUI(&Config{})
if m.activeTab != tabOfficial {
t.Fatalf("initial tab = %d, want %d", m.activeTab, tabOfficial)
}
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
if m2.activeTab != tabCustom {
t.Errorf("after right, tab = %d, want %d", m2.activeTab, tabCustom)
}
result, _ = m2.Update(rightKey())
m3 := result.(providerTUIModel)
if m3.activeTab != tabManual {
t.Errorf("after 2x right, tab = %d, want %d", m3.activeTab, tabManual)
}
// Should not go past last tab
result, _ = m3.Update(rightKey())
m4 := result.(providerTUIModel)
if m4.activeTab != tabManual {
t.Errorf("after 3x right, tab = %d, want %d (should clamp)", m4.activeTab, tabManual)
}
}
func TestProviderTUI_TabSwitchLeft(t *testing.T) {
m := newProviderTUI(&Config{})
// Go to manual tab first
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(rightKey())
m3 := result.(providerTUIModel)
if m3.activeTab != tabManual {
t.Fatalf("setup: tab = %d, want %d", m3.activeTab, tabManual)
}
result, _ = m3.Update(leftKey())
m4 := result.(providerTUIModel)
if m4.activeTab != tabCustom {
t.Errorf("after left, tab = %d, want %d", m4.activeTab, tabCustom)
}
result, _ = m4.Update(leftKey())
m5 := result.(providerTUIModel)
if m5.activeTab != tabOfficial {
t.Errorf("after 2x left, tab = %d, want %d", m5.activeTab, tabOfficial)
}
// Should not go past first tab
result, _ = m5.Update(leftKey())
m6 := result.(providerTUIModel)
if m6.activeTab != tabOfficial {
t.Errorf("after 3x left, tab = %d, want %d (should clamp)", m6.activeTab, tabOfficial)
}
}
func TestProviderTUI_TabKeyCycles(t *testing.T) {
m := newProviderTUI(&Config{})
result, _ := m.Update(tabKeyMsg())
m2 := result.(providerTUIModel)
if m2.activeTab != tabCustom {
t.Errorf("after tab, tab = %d, want %d", m2.activeTab, tabCustom)
}
result, _ = m2.Update(tabKeyMsg())
m3 := result.(providerTUIModel)
if m3.activeTab != tabManual {
t.Errorf("after 2x tab, tab = %d, want %d", m3.activeTab, tabManual)
}
result, _ = m3.Update(tabKeyMsg())
m4 := result.(providerTUIModel)
if m4.activeTab != tabOfficial {
t.Errorf("after 3x tab, tab = %d, want %d (should wrap)", m4.activeTab, tabOfficial)
}
}
func TestProviderTUI_TabSwitchOnlyOnStepProvider(t *testing.T) {
m := newProviderTUI(&Config{})
// Advance to stepModel
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.step != stepModel {
t.Fatalf("step = %d, want %d", m2.step, stepModel)
}
// Tab keys should not change tab
result, _ = m2.Update(rightKey())
m3 := result.(providerTUIModel)
if m3.activeTab != tabOfficial {
t.Errorf("right on stepModel should not change tab: got %d", m3.activeTab)
}
}
// --- Official tab tests (updated from original) ---
func TestProviderTUI_EscFromModelGoesBackToProvider(t *testing.T) {
m := newProviderTUI(&Config{})
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.step != stepModel {
t.Fatalf("after Enter, step = %d, want %d (stepModel)", m2.step, stepModel)
}
result, _ = m2.Update(escKey())
m3 := result.(providerTUIModel)
if m3.step != stepProvider {
t.Errorf("after Esc on stepModel, step = %d, want %d (stepProvider)", m3.step, stepProvider)
}
if m3.cancelled {
t.Error("should not be cancelled when going back from stepModel")
}
}
func TestProviderTUI_EscFromAPIKeyGoesBackToModel(t *testing.T) {
m := newProviderTUI(&Config{})
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
if m3.step != stepAPIKey {
t.Fatalf("after 2x Enter, step = %d, want %d (stepAPIKey)", m3.step, stepAPIKey)
}
result, _ = m3.Update(escKey())
m4 := result.(providerTUIModel)
if m4.step != stepModel {
t.Errorf("after Esc on stepAPIKey, step = %d, want %d (stepModel)", m4.step, stepModel)
}
}
func TestProviderTUI_EscFromProviderCancels(t *testing.T) {
m := newProviderTUI(&Config{})
result, cmd := m.Update(escKey())
m2 := result.(providerTUIModel)
if !m2.cancelled {
t.Error("Esc on stepProvider should set cancelled = true")
}
if cmd == nil {
t.Error("Esc on stepProvider should return tea.Quit")
}
}
func TestProviderTUI_EscKeyString(t *testing.T) {
esc := escKey()
if s := esc.String(); s != "esc" {
t.Errorf("escape key String() = %q, want %q", s, "esc")
}
}
// --- Manual tab tests ---
func TestProviderTUI_ManualTabEnterStartsForm(t *testing.T) {
m := newProviderTUI(&Config{})
// Switch to manual tab
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(rightKey())
m3 := result.(providerTUIModel)
if m3.activeTab != tabManual {
t.Fatalf("tab = %d, want %d", m3.activeTab, tabManual)
}
// Press Enter to start form
result, _ = m3.Update(enterKey())
m4 := result.(providerTUIModel)
if !m4.inManualForm {
t.Error("Enter on manual tab should set inManualForm = true")
}
if m4.manualStep != manualStepURL {
t.Errorf("manualStep = %d, want %d", m4.manualStep, manualStepURL)
}
}
func TestProviderTUI_ManualFormEscFromURLExitsForm(t *testing.T) {
m := newProviderTUI(&Config{})
// Switch to manual tab and enter form
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(rightKey())
m3 := result.(providerTUIModel)
result, _ = m3.Update(enterKey())
m4 := result.(providerTUIModel)
if !m4.inManualForm {
t.Fatalf("should be in manual form")
}
// Esc should exit form, not cancel
result, _ = m4.Update(escKey())
m5 := result.(providerTUIModel)
if m5.inManualForm {
t.Error("Esc from URL step should exit form")
}
if m5.cancelled {
t.Error("should not be cancelled when exiting form")
}
}
func TestProviderTUI_ManualFormEscRestoresOriginalValues(t *testing.T) {
cfg := &Config{
Llm: LlmConfig{
URL: "https://example.com/v1",
Model: "test-model",
AuthToken: "token-123",
},
}
m := newProviderTUI(cfg)
// Enter the form
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if !m2.inManualForm {
t.Fatalf("should be in manual form")
}
// Simulate editing by directly modifying the input value
m2.manualURLInput.SetValue("https://modified.example.com")
// Esc should restore original values
result, _ = m2.Update(escKey())
m3 := result.(providerTUIModel)
if m3.inManualForm {
t.Error("should have exited form")
}
if m3.manualURLInput.Value() != "https://example.com/v1" {
t.Errorf("URL not restored: got %q, want %q", m3.manualURLInput.Value(), "https://example.com/v1")
}
if m3.manualModelInput.Value() != "test-model" {
t.Errorf("Model not restored: got %q, want %q", m3.manualModelInput.Value(), "test-model")
}
if m3.manualTokenInput.Value() != "token-123" {
t.Errorf("Token not restored: got %q, want %q", m3.manualTokenInput.Value(), "token-123")
}
}
func TestProviderTUI_ManualFormPrefilledValues(t *testing.T) {
cfg := &Config{
Llm: LlmConfig{
URL: "https://example.com/v1",
Model: "test-model",
AuthToken: "token-123",
},
}
m := newProviderTUI(cfg)
if m.activeTab != tabManual {
t.Fatalf("should auto-select manual tab when Llm.URL is set, got %d", m.activeTab)
}
if m.manualURLInput.Value() != "https://example.com/v1" {
t.Errorf("URL not prefilled: got %q", m.manualURLInput.Value())
}
if m.manualModelInput.Value() != "test-model" {
t.Errorf("Model not prefilled: got %q", m.manualModelInput.Value())
}
if m.manualTokenInput.Value() != "token-123" {
t.Errorf("Token not prefilled: got %q", m.manualTokenInput.Value())
}
}
func TestProviderTUI_ManualResult(t *testing.T) {
cfg := &Config{
Llm: LlmConfig{
URL: "https://example.com/v1",
Model: "test-model",
AuthToken: "token-123",
},
}
m := newProviderTUI(cfg)
// Enter the form
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
m2.confirmed = true
r := m2.result()
if !r.isManual {
t.Error("result should have isManual = true")
}
if r.url != "https://example.com/v1" {
t.Errorf("result url = %q, want %q", r.url, "https://example.com/v1")
}
if r.model != "test-model" {
t.Errorf("result model = %q, want %q", r.model, "test-model")
}
}
func TestProviderTUI_ManualFormPrefilledWhenProviderSet(t *testing.T) {
cfg := &Config{
Provider: "my-gateway",
CustomProviders: map[string]ProviderEntry{
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai", Model: "llama-3"},
},
Llm: LlmConfig{
URL: "https://manual.example.com/v1",
Model: "manual-model",
AuthToken: "manual-token",
},
}
m := newProviderTUI(cfg)
if m.activeTab != tabCustom {
t.Fatalf("should auto-select custom tab, got %d", m.activeTab)
}
if m.manualURLInput.Value() != "https://manual.example.com/v1" {
t.Errorf("URL not prefilled: got %q", m.manualURLInput.Value())
}
if m.manualModelInput.Value() != "manual-model" {
t.Errorf("Model not prefilled: got %q", m.manualModelInput.Value())
}
if m.manualTokenInput.Value() != "manual-token" {
t.Errorf("Token not prefilled: got %q", m.manualTokenInput.Value())
}
}
// --- Custom tab tests ---
func TestProviderTUI_CustomTabShowsAddOption(t *testing.T) {
m := newProviderTUI(&Config{})
// Switch to custom tab
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
if m2.activeTab != tabCustom {
t.Fatalf("tab = %d, want %d", m2.activeTab, tabCustom)
}
// With no custom providers, only "Add" option exists at index 0
if m2.customListCount() != 1 {
t.Errorf("customListCount() = %d, want 1 (only add option)", m2.customListCount())
}
}
func TestProviderTUI_CustomTabSelectAddStartsForm(t *testing.T) {
m := newProviderTUI(&Config{})
// Switch to custom tab
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
// Enter on "Add" option
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
if !m3.creatingCustom {
t.Error("Enter on add option should set creatingCustom = true")
}
if m3.cpStep != cpStepName {
t.Errorf("cpStep = %d, want %d", m3.cpStep, cpStepName)
}
}
func TestProviderTUI_CustomFormEscFromNameExitsForm(t *testing.T) {
m := newProviderTUI(&Config{})
// Switch to custom tab and start form
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
if !m3.creatingCustom {
t.Fatalf("should be creating custom")
}
// Esc from name step should exit form
result, _ = m3.Update(escKey())
m4 := result.(providerTUIModel)
if m4.creatingCustom {
t.Error("Esc from name step should exit custom form")
}
if m4.cancelled {
t.Error("should not be cancelled")
}
}
func TestProviderTUI_CustomProviderExistsInList(t *testing.T) {
cfg := &Config{
Provider: "my-llm",
CustomProviders: map[string]ProviderEntry{
"my-llm": {
URL: "https://custom.api/v1",
Protocol: "openai",
Model: "custom-model",
APIKey: "key-123",
},
},
}
m := newProviderTUI(cfg)
if m.activeTab != tabCustom {
t.Fatalf("should auto-select custom tab, got %d", m.activeTab)
}
if len(m.customProviders) != 1 {
t.Fatalf("expected 1 custom provider, got %d", len(m.customProviders))
}
if m.customProviders[0].name != "my-llm" {
t.Errorf("custom provider name = %q, want %q", m.customProviders[0].name, "my-llm")
}
}
func TestProviderTUI_SelectExistingCustomGoesToAPIKey(t *testing.T) {
cfg := &Config{
Provider: "my-llm",
CustomProviders: map[string]ProviderEntry{
"my-llm": {
URL: "https://custom.api/v1",
Protocol: "openai",
Model: "custom-model",
APIKey: "key-123",
},
},
}
m := newProviderTUI(cfg)
// Enter on existing custom provider should go to API key step
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.step != stepAPIKey {
t.Errorf("step = %d, want %d (stepAPIKey)", m2.step, stepAPIKey)
}
}
// --- collectCustomProviders tests ---
func TestCollectCustomProviders_NilConfig(t *testing.T) {
result := collectCustomProviders(nil)
if result != nil {
t.Errorf("expected nil, got %v", result)
}
}
func TestCollectCustomProviders_ReadsCustomProviders(t *testing.T) {
cfg := &Config{
Providers: map[string]ProviderEntry{
"anthropic": {APIKey: "key1"},
"openai": {APIKey: "key2"},
},
CustomProviders: map[string]ProviderEntry{
"my-custom": {URL: "https://example.com", Protocol: "openai"},
},
}
result := collectCustomProviders(cfg)
if len(result) != 1 {
t.Fatalf("expected 1 custom provider, got %d", len(result))
}
if result[0].name != "my-custom" {
t.Errorf("name = %q, want %q", result[0].name, "my-custom")
}
}
func TestCollectCustomProviders_SortedByName(t *testing.T) {
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"zzz-provider": {URL: "https://z.example.com"},
"aaa-provider": {URL: "https://a.example.com"},
},
}
result := collectCustomProviders(cfg)
if len(result) != 2 {
t.Fatalf("expected 2, got %d", len(result))
}
if result[0].name != "aaa-provider" {
t.Errorf("first = %q, want %q", result[0].name, "aaa-provider")
}
if result[1].name != "zzz-provider" {
t.Errorf("second = %q, want %q", result[1].name, "zzz-provider")
}
}

19
go.mod
View file

@ -3,6 +3,9 @@ module github.com/open-code-review/open-code-review
go 1.25.0
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.3
github.com/anthropics/anthropic-sdk-go v1.47.0
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/openai/openai-go/v3 v3.39.0
@ -19,29 +22,43 @@ require (
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/invopop/jsonschema v0.14.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect

44
go.sum
View file

@ -1,5 +1,15 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
github.com/anthropics/anthropic-sdk-go v1.47.0 h1:p1F48S/5UAGK3h2NzvZP8rqKnZqB7RkyYvOEM8dOEaQ=
github.com/anthropics/anthropic-sdk-go v1.47.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
@ -10,6 +20,24 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
@ -31,6 +59,12 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/openai/openai-go/v3 v3.39.0 h1:WgLGgMOOdQDkZyo8YIhzUNXRXlEc+OJfU4EKP5Qp6AA=
github.com/openai/openai-go/v3 v3.39.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
@ -39,6 +73,8 @@ github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/Q
github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@ -53,6 +89,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
@ -81,12 +119,14 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=

BIN
imgs/providers.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

116
internal/llm/providers.go Normal file
View file

@ -0,0 +1,116 @@
package llm
import "strings"
// Provider holds the preset configuration for a known LLM provider.
type Provider struct {
Name string
DisplayName string
Protocol string // "anthropic" or "openai"
BaseURL string
AuthHeader string // Anthropic-only; empty for OpenAI-compatible
EnvVar string // environment variable name for API key fallback
Models []string
}
var registry = []Provider{
{
Name: "anthropic",
DisplayName: "Anthropic Claude API",
Protocol: "anthropic",
BaseURL: "https://api.anthropic.com",
AuthHeader: "x-api-key",
EnvVar: "ANTHROPIC_API_KEY",
Models: []string{
"claude-opus-4-8",
"claude-sonnet-4-8",
"claude-opus-4-7",
"claude-sonnet-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: []string{
"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: []string{
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-flash",
},
},
{
Name: "deepseek",
DisplayName: "DeepSeek API",
Protocol: "openai",
BaseURL: "https://api.deepseek.com",
EnvVar: "DEEPSEEK_API_KEY",
Models: []string{
"deepseek-v4-pro",
"deepseek-v4-flash",
},
},
{
Name: "z-ai",
DisplayName: "Z.AI API",
Protocol: "openai",
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
EnvVar: "Z_AI_API_KEY",
Models: []string{
"glm-5.1",
"glm-5-turbo",
"glm-4.7",
},
},
}
var registryMap map[string]Provider
func init() {
registryMap = make(map[string]Provider, len(registry))
for _, p := range registry {
registryMap[strings.ToLower(p.Name)] = p
}
}
// LookupProvider returns the preset provider by name.
// The returned Provider has its own copy of the Models slice.
func LookupProvider(name string) (Provider, bool) {
p, ok := registryMap[strings.ToLower(strings.TrimSpace(name))]
if ok && p.Models != nil {
models := make([]string, len(p.Models))
copy(models, p.Models)
p.Models = models
}
return p, ok
}
// ListProviders returns all built-in providers in registration order.
// Each returned Provider has its own copy of the Models slice.
func ListProviders() []Provider {
out := make([]Provider, len(registry))
for i, p := range registry {
if p.Models != nil {
models := make([]string, len(p.Models))
copy(models, p.Models)
p.Models = models
}
out[i] = p
}
return out
}

View file

@ -0,0 +1,97 @@
package llm
import (
"testing"
)
func TestLookupProvider_KnownProviders(t *testing.T) {
names := []string{"anthropic", "openai", "dashscope"}
for _, name := range names {
p, ok := LookupProvider(name)
if !ok {
t.Errorf("LookupProvider(%q) returned false, want true", name)
continue
}
if p.Name != name {
t.Errorf("LookupProvider(%q).Name = %q", name, p.Name)
}
if p.Protocol == "" {
t.Errorf("LookupProvider(%q).Protocol is empty", name)
}
if p.BaseURL == "" {
t.Errorf("LookupProvider(%q).BaseURL is empty", name)
}
if len(p.Models) == 0 {
t.Errorf("LookupProvider(%q).Models is empty", name)
}
}
}
func TestLookupProvider_Unknown(t *testing.T) {
_, ok := LookupProvider("nonexistent-provider")
if ok {
t.Error("LookupProvider(nonexistent) returned true, want false")
}
}
func TestListProviders_Order(t *testing.T) {
providers := ListProviders()
if len(providers) < 3 {
t.Fatalf("expected at least 3 providers, got %d", len(providers))
}
expected := []string{"anthropic", "openai", "dashscope"}
for i, name := range expected {
if providers[i].Name != name {
t.Errorf("providers[%d].Name = %q, want %q", i, providers[i].Name, name)
}
}
}
func TestListProviders_ReturnsCopy(t *testing.T) {
p1 := ListProviders()
p1[0].Name = "mutated"
p2 := ListProviders()
if p2[0].Name == "mutated" {
t.Error("ListProviders returns a reference to the registry, should return a copy")
}
}
func TestLookupProvider_ReturnsCopyOfModels(t *testing.T) {
p1, _ := LookupProvider("anthropic")
p1.Models[0] = "mutated"
p2, _ := LookupProvider("anthropic")
if p2.Models[0] == "mutated" {
t.Error("LookupProvider returns a reference to Models slice, should return a copy")
}
}
func TestLookupProvider_AnthropicDetails(t *testing.T) {
p, ok := LookupProvider("anthropic")
if !ok {
t.Fatal("anthropic not found")
}
if p.Protocol != "anthropic" {
t.Errorf("Protocol = %q, want %q", p.Protocol, "anthropic")
}
if p.AuthHeader != "x-api-key" {
t.Errorf("AuthHeader = %q, want %q", p.AuthHeader, "x-api-key")
}
if p.EnvVar != "ANTHROPIC_API_KEY" {
t.Errorf("EnvVar = %q, want %q", p.EnvVar, "ANTHROPIC_API_KEY")
}
}
func TestLookupProvider_OpenAIDetails(t *testing.T) {
p, ok := LookupProvider("openai")
if !ok {
t.Fatal("openai not found")
}
if p.Protocol != "openai" {
t.Errorf("Protocol = %q, want %q", p.Protocol, "openai")
}
if p.AuthHeader != "" {
t.Errorf("AuthHeader = %q, want empty", p.AuthHeader)
}
}

View file

@ -56,7 +56,9 @@ func ResolveEndpoint(configPath string) (ResolvedEndpoint, error) {
return ResolvedEndpoint{}, fmt.Errorf("resolve %s: %w", s.name, err)
}
if ok && ep.URL != "" && ep.Token != "" && ep.Model != "" {
ep.Source = s.name
if ep.Source == "" {
ep.Source = s.name
}
ep.Model = stripModelSuffix(ep.Model)
return ep, nil
}
@ -110,8 +112,22 @@ type llmFileConfig struct {
ExtraBody map[string]any `json:"extra_body,omitempty"`
}
// providerEntryConfig represents a single provider entry in config.json.
type providerEntryConfig struct {
APIKey string `json:"api_key,omitempty"`
URL string `json:"url,omitempty"`
Protocol string `json:"protocol,omitempty"`
Model string `json:"model,omitempty"`
AuthHeader string `json:"auth_header,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"`
}
type configFile struct {
Llm llmFileConfig `json:"llm,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Providers map[string]providerEntryConfig `json:"providers,omitempty"`
CustomProviders map[string]providerEntryConfig `json:"custom_providers,omitempty"`
Llm llmFileConfig `json:"llm,omitempty"`
}
// tryOCRConfig reads the OCR config file.
@ -129,6 +145,116 @@ func tryOCRConfig(path string) (ResolvedEndpoint, bool, error) {
return ResolvedEndpoint{}, false, fmt.Errorf("parse config: %w", err)
}
if cfg.Provider != "" {
return tryProviderConfig(cfg)
}
return tryLegacyLlmConfig(cfg)
}
// tryProviderConfig resolves an endpoint from the provider-based configuration.
func tryProviderConfig(cfg configFile) (ResolvedEndpoint, bool, error) {
preset, isPreset := LookupProvider(cfg.Provider)
var entry providerEntryConfig
var ok bool
if isPreset {
entry, ok = cfg.Providers[cfg.Provider]
} else {
entry, ok = cfg.CustomProviders[cfg.Provider]
}
if !ok {
section := "providers"
if !isPreset {
section = "custom_providers"
}
return ResolvedEndpoint{}, false, fmt.Errorf("provider %q is set but not configured in %s section", cfg.Provider, section)
}
apiKey := entry.APIKey
if apiKey == "" {
if isPreset && preset.EnvVar != "" {
apiKey = os.Getenv(preset.EnvVar)
}
}
if apiKey == "" {
return ResolvedEndpoint{}, false, fmt.Errorf("provider %q has no api_key configured and no environment variable fallback found", cfg.Provider)
}
var url, protocol, authHeader, model string
var extraBody map[string]any
if isPreset {
url = preset.BaseURL
protocol = preset.Protocol
authHeader = preset.AuthHeader
if entry.URL != "" {
url = entry.URL
}
if entry.Protocol != "" {
protocol = strings.ToLower(entry.Protocol)
}
} else {
// Custom provider: url and protocol are required; model can come from cfg.Model.
if entry.URL == "" || entry.Protocol == "" {
return ResolvedEndpoint{}, false, fmt.Errorf("custom provider %q requires url and protocol fields", cfg.Provider)
}
if !strings.EqualFold(entry.Protocol, "anthropic") && !strings.EqualFold(entry.Protocol, "openai") {
return ResolvedEndpoint{}, false, fmt.Errorf("custom provider %q has invalid protocol %q: must be \"anthropic\" or \"openai\"", cfg.Provider, entry.Protocol)
}
url = entry.URL
protocol = strings.ToLower(entry.Protocol)
}
if cfg.Model != "" {
model = cfg.Model
}
if entry.Model != "" {
model = entry.Model
}
if model == "" {
return ResolvedEndpoint{}, false, fmt.Errorf("provider %q has no model configured; run 'ocr config model' to select one", cfg.Provider)
}
if protocol == "anthropic" {
var err error
ah := "authorization"
if isPreset && authHeader != "" {
ah = authHeader
}
if entry.AuthHeader != "" {
ah = entry.AuthHeader
}
authHeader, err = NormalizeAuthHeader(ah)
if err != nil {
return ResolvedEndpoint{}, false, fmt.Errorf("provider %q: %w", cfg.Provider, err)
}
if authHeader == "" {
authHeader = defaultAuthHeader(protocol)
}
} else {
authHeader = ""
}
extraBody = entry.ExtraBody
if protocol == "anthropic" {
url = ensureMessagesSuffix(url)
}
return ResolvedEndpoint{
URL: url,
Token: apiKey,
Model: model,
Protocol: protocol,
AuthHeader: authHeader,
Source: "provider:" + cfg.Provider,
ExtraBody: extraBody,
}, true, nil
}
// tryLegacyLlmConfig resolves an endpoint from the legacy llm config block.
func tryLegacyLlmConfig(cfg configFile) (ResolvedEndpoint, bool, error) {
if cfg.Llm.URL == "" || cfg.Llm.AuthToken == "" || cfg.Llm.Model == "" {
return ResolvedEndpoint{}, false, nil
}

View file

@ -244,3 +244,369 @@ func TestResolveEndpoint_OCREnvOpenAIIgnoresAuthHeader(t *testing.T) {
t.Errorf("expected empty auth header for OpenAI protocol, got %q", ep.AuthHeader)
}
}
// --- Provider-based resolution tests ---
func clearAllEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"OCR_LLM_URL", "OCR_LLM_TOKEN", "OCR_LLM_MODEL", "OCR_LLM_AUTH_HEADER", "OCR_USE_ANTHROPIC",
"ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL",
"ANTHROPIC_API_KEY", "OPENAI_API_KEY",
} {
t.Setenv(k, "")
}
}
func TestResolveEndpoint_ProviderAnthropic(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "anthropic",
Providers: map[string]providerEntryConfig{
"anthropic": {APIKey: "sk-ant-test", Model: "claude-sonnet-4-6"},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Protocol != "anthropic" {
t.Errorf("Protocol = %q, want %q", ep.Protocol, "anthropic")
}
if ep.AuthHeader != "x-api-key" {
t.Errorf("AuthHeader = %q, want %q", ep.AuthHeader, "x-api-key")
}
if ep.Token != "sk-ant-test" {
t.Errorf("Token = %q, want %q", ep.Token, "sk-ant-test")
}
if ep.Model != "claude-sonnet-4-6" {
t.Errorf("Model = %q, want %q", ep.Model, "claude-sonnet-4-6")
}
if ep.Source != "provider:anthropic" {
t.Errorf("Source = %q, want %q", ep.Source, "provider:anthropic")
}
}
func TestResolveEndpoint_ProviderOpenAI(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "openai",
Providers: map[string]providerEntryConfig{
"openai": {APIKey: "sk-openai-test", Model: "gpt-4o"},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Protocol != "openai" {
t.Errorf("Protocol = %q, want %q", ep.Protocol, "openai")
}
if ep.AuthHeader != "" {
t.Errorf("AuthHeader = %q, want empty", ep.AuthHeader)
}
if ep.Model != "gpt-4o" {
t.Errorf("Model = %q, want %q", ep.Model, "gpt-4o")
}
}
func TestResolveEndpoint_ProviderModelOverride(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "anthropic",
Model: "claude-opus-4-6",
Providers: map[string]providerEntryConfig{
"anthropic": {APIKey: "sk-ant-test", Model: "claude-haiku-4-5"},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Model != "claude-haiku-4-5" {
t.Errorf("Model = %q, want %q (entry model should override top-level model)", ep.Model, "claude-haiku-4-5")
}
}
func TestResolveEndpoint_ProviderEntryModelOverridesDefault(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "anthropic",
Providers: map[string]providerEntryConfig{
"anthropic": {APIKey: "sk-ant-test", Model: "claude-haiku-4-5"},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Model != "claude-haiku-4-5" {
t.Errorf("Model = %q, want %q", ep.Model, "claude-haiku-4-5")
}
}
func TestResolveEndpoint_ProviderAPIKeyEnvFallback(t *testing.T) {
clearAllEnv(t)
t.Setenv("ANTHROPIC_API_KEY", "env-api-key")
cfg := configFile{
Provider: "anthropic",
Providers: map[string]providerEntryConfig{
"anthropic": {Model: "claude-sonnet-4-6"},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Token != "env-api-key" {
t.Errorf("Token = %q, want %q (should fall back to env var)", ep.Token, "env-api-key")
}
}
func TestResolveEndpoint_ProviderMissingAPIKey(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "anthropic",
Providers: map[string]providerEntryConfig{
"anthropic": {},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
_, err := ResolveEndpoint(cfgPath)
if err == nil {
t.Fatal("expected error for missing API key")
}
}
func TestResolveEndpoint_ProviderNotConfigured(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "anthropic",
Providers: map[string]providerEntryConfig{},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
_, err := ResolveEndpoint(cfgPath)
if err == nil {
t.Fatal("expected error for unconfigured provider")
}
}
func TestResolveEndpoint_CustomProvider(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "my-gateway",
CustomProviders: map[string]providerEntryConfig{
"my-gateway": {
APIKey: "custom-token",
URL: "https://gateway.internal.com/v1",
Protocol: "openai",
Model: "llama-3-70b",
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Protocol != "openai" {
t.Errorf("Protocol = %q, want %q", ep.Protocol, "openai")
}
if ep.URL != "https://gateway.internal.com/v1" {
t.Errorf("URL = %q", ep.URL)
}
if ep.Model != "llama-3-70b" {
t.Errorf("Model = %q, want %q", ep.Model, "llama-3-70b")
}
if ep.Source != "provider:my-gateway" {
t.Errorf("Source = %q, want %q", ep.Source, "provider:my-gateway")
}
}
func TestResolveEndpoint_CustomProviderInvalidProtocol(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "my-gateway",
CustomProviders: map[string]providerEntryConfig{
"my-gateway": {
APIKey: "token",
URL: "https://gateway.internal.com/v1",
Protocol: "grpc",
Model: "some-model",
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
_, err := ResolveEndpoint(cfgPath)
if err == nil {
t.Fatal("expected error for custom provider with invalid protocol")
}
}
func TestResolveEndpoint_CustomProviderMissingFields(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "my-gateway",
CustomProviders: map[string]providerEntryConfig{
"my-gateway": {
APIKey: "token",
URL: "https://gateway.internal.com/v1",
// Missing protocol and model.
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
_, err := ResolveEndpoint(cfgPath)
if err == nil {
t.Fatal("expected error for custom provider missing required fields")
}
}
func TestResolveEndpoint_CustomProviderModelFromTopLevel(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "my-gateway",
Model: "top-level-model",
CustomProviders: map[string]providerEntryConfig{
"my-gateway": {
APIKey: "token",
URL: "https://gateway.internal.com/v1",
Protocol: "openai",
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Model != "top-level-model" {
t.Errorf("Model = %q, want %q", ep.Model, "top-level-model")
}
}
func TestResolveEndpoint_LegacyLlmStillWorks(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Llm: llmFileConfig{
URL: "https://api.example.com/v1/messages",
AuthToken: "legacy-token",
Model: "claude-opus-4-6",
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Source != "OCR config file" {
t.Errorf("Source = %q, want %q", ep.Source, "OCR config file")
}
if ep.Token != "legacy-token" {
t.Errorf("Token = %q, want %q", ep.Token, "legacy-token")
}
}
func TestResolveEndpoint_ProviderAnthropicURLHasMessagesSuffix(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "anthropic",
Providers: map[string]providerEntryConfig{
"anthropic": {APIKey: "sk-ant-test", Model: "claude-sonnet-4-6"},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.URL != "https://api.anthropic.com/v1/messages" {
t.Errorf("URL = %q, want %q", ep.URL, "https://api.anthropic.com/v1/messages")
}
}
func TestResolveEndpoint_ProviderExtraBody(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "anthropic",
Providers: map[string]providerEntryConfig{
"anthropic": {
APIKey: "sk-ant-test",
Model: "claude-sonnet-4-6",
ExtraBody: map[string]any{"thinking": map[string]any{"type": "disabled"}},
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpoint(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.ExtraBody == nil {
t.Fatal("ExtraBody should not be nil")
}
if _, ok := ep.ExtraBody["thinking"]; !ok {
t.Error("ExtraBody missing 'thinking' key")
}
}

View file

@ -50,9 +50,12 @@ const QuickStartSection: React.FC = () => {
codeBlocks: [
{
label: t('quickstart.step2Label1'),
code: `ocr config provider`
},
{
label: t('quickstart.step2Label2'),
code: `ocr config set llm.url https://api.anthropic.com \\\n && ocr config set llm.auth_token {{your-api-key}} \\\n && ocr config set llm.model claude-opus-4-6 \\\n && ocr config set llm.use_anthropic true`
},
{ label: t('quickstart.step2Label2'), code: `ocr config set language Chinese` },
{ label: t('quickstart.step2Label3'), code: `ocr llm test` }
]
},

View file

@ -82,9 +82,9 @@ export const en: TranslationKeys = {
'quickstart.step1Label1': 'Install',
'quickstart.step1Label2': 'Verify Installation',
'quickstart.step2Title': 'Configure',
'quickstart.step2Desc': 'Configure your LLM info. Supports Anthropic and OpenAI-compatible protocols. Config file at ~/.opencodereview/config.json.',
'quickstart.step2Label1': 'Configure LLM',
'quickstart.step2Label2': 'Configure Language (default English)',
'quickstart.step2Desc': 'Set up your LLM provider interactively, or configure manually. Built-in support for Anthropic, OpenAI, DashScope, DeepSeek, and Z.AI.',
'quickstart.step2Label1': 'Interactive Setup (Recommended)',
'quickstart.step2Label2': 'Manual Config (Alternative)',
'quickstart.step2Label3': 'Verify Configuration',
'quickstart.step3Title': 'Run Review',
'quickstart.step3Desc': 'Start your first code review.',
@ -117,6 +117,13 @@ export const en: TranslationKeys = {
'docs.configTitle': 'Configuration & Verification',
'docs.configDesc': 'Manage CLI configuration settings, stored in <code>~/.opencodereview/config.json</code>.',
'docs.configInteractive': 'Interactive Setup (Recommended)',
'docs.configInteractiveDesc': 'The easiest way to configure Open Code Review. Select from built-in providers or add a custom one.',
'docs.configModelSelect': 'Model Selection',
'docs.configModelSelectDesc': 'After choosing a provider, pick the model to use.',
'docs.configListProviders': 'List Built-in Providers',
'docs.configListProvidersDesc': 'View all available built-in LLM providers.',
'docs.configManual': 'Manual Configuration',
'docs.configCommand': 'Command',
'docs.configExample': 'Example',
'docs.configKeys': 'Supported Config Keys',

View file

@ -82,9 +82,9 @@ export const zh: TranslationKeys = {
'quickstart.step1Label1': '安装',
'quickstart.step1Label2': '验证安装',
'quickstart.step2Title': '配置',
'quickstart.step2Desc': '配置您的 LLM 信息,支持 Anthropic 和 OpenAI 兼容协议。配置文件位于 ~/.opencodereview/config.json。',
'quickstart.step2Label1': '配置 LLM',
'quickstart.step2Label2': '配置语言(默认英文',
'quickstart.step2Desc': '通过交互式向导设置 LLM 供应商,或手动配置。内置支持 Anthropic、OpenAI、DashScope、DeepSeek 和 Z.AI。',
'quickstart.step2Label1': '交互式设置(推荐)',
'quickstart.step2Label2': '手动配置(备选',
'quickstart.step2Label3': '验证配置',
'quickstart.step3Title': '运行评审',
'quickstart.step3Desc': '开始你的第一次代码评审。',
@ -117,6 +117,13 @@ export const zh: TranslationKeys = {
'docs.configTitle': '配置与验证',
'docs.configDesc': '管理 CLI 的配置设置,存储在 <code>~/.opencodereview/config.json</code>。',
'docs.configInteractive': '交互式设置(推荐)',
'docs.configInteractiveDesc': '最简单的配置方式。从内置供应商中选择,或添加自定义供应商。',
'docs.configModelSelect': '模型选择',
'docs.configModelSelectDesc': '选择供应商后,选择要使用的模型。',
'docs.configListProviders': '查看内置供应商',
'docs.configListProvidersDesc': '查看所有可用的内置 LLM 供应商。',
'docs.configManual': '手动配置',
'docs.configCommand': '命令',
'docs.configExample': '示例',
'docs.configKeys': '支持的配置 Key',

View file

@ -227,10 +227,43 @@ const DocsPage: React.FC = () => {
<DocSection id="config" title={t('docs.configTitle')}>
<p className="text-slate-300 leading-relaxed mb-6" dangerouslySetInnerHTML={{ __html: t('docs.configDesc') }} />
<h3 className="text-lg font-semibold text-white mb-3">{t('docs.configCommand')}</h3>
<h3 className="text-lg font-semibold text-white mb-3">{t('docs.configInteractive')}</h3>
<p className="text-slate-400 text-sm mb-3">{t('docs.configInteractiveDesc')}</p>
<div className="space-y-3 mb-6">
<CodeBlock
code="ocr config provider"
copied={copiedIndex === 'config-provider'}
onCopy={() => handleCopy('ocr config provider', 'config-provider')}
copyLabel={t('docs.copy')}
/>
</div>
<div className="space-y-3 mb-6">
<p className="text-slate-500 text-xs font-mono mb-2 ml-1">{t('docs.configModelSelect')}</p>
<p className="text-slate-400 text-sm mb-3">{t('docs.configModelSelectDesc')}</p>
<CodeBlock
code="ocr config model"
copied={copiedIndex === 'config-model'}
onCopy={() => handleCopy('ocr config model', 'config-model')}
copyLabel={t('docs.copy')}
/>
</div>
<div className="space-y-3 mb-8">
<p className="text-slate-500 text-xs font-mono mb-2 ml-1">{t('docs.configListProviders')}</p>
<p className="text-slate-400 text-sm mb-3">{t('docs.configListProvidersDesc')}</p>
<CodeBlock
code="ocr llm providers"
copied={copiedIndex === 'config-providers-list'}
onCopy={() => handleCopy('ocr llm providers', 'config-providers-list')}
copyLabel={t('docs.copy')}
/>
</div>
<h3 className="text-lg font-semibold text-white mb-3">{t('docs.configManual')}</h3>
<h4 className="text-base font-medium text-slate-300 mb-3">{t('docs.configCommand')}</h4>
<CodeBlock code="ocr config set &lt;key&gt; &lt;value&gt;" />
<h3 className="text-lg font-semibold text-white mb-3 mt-8">{t('docs.configExample')}</h3>
<h4 className="text-base font-medium text-slate-300 mb-3 mt-6">{t('docs.configExample')}</h4>
<div className="space-y-3 mb-8">
<CodeBlock
code={`ocr config set llm.url https://api.anthropic.com \\\n && ocr config set llm.auth_token {{your-api-key}} \\\n && ocr config set llm.model claude-opus-4-6 \\\n && ocr config set llm.use_anthropic true \\\n && ocr config set language Chinese`}