diff --git a/README.ja-JP.md b/README.ja-JP.md index 159a1ff..732116c 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -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 ` | — | ファイルパスに適用されるレビュールールをプレビュー | +| `ocr config provider` | — | 対話的プロバイダーセットアップ(ビルトイン、カスタム、手動) | +| `ocr config model` | — | アクティブなプロバイダーの対話的モデル選択 | | `ocr config set ` | — | 設定値をセット | | `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..api_key` | string | プロバイダー固有のAPIキー | +| `providers..url` | string | プロバイダーのベースURLオーバーライド | +| `providers..protocol` | string | `anthropic` \| `openai` | +| `providers..model` | string | プロバイダーのモデル名 | +| `providers..auth_header` | string | `x-api-key` \| `authorization` | +| `custom_providers..*` | — | `providers..*`と同じフィールド | | `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` | diff --git a/README.ko-KR.md b/README.ko-KR.md index ef85b01..da712a1 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -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 ` | - | 파일 경로에 적용될 리뷰 rule 미리보기 | +| `ocr config provider` | - | 대화형 provider 설정 (built-in, custom, 수동) | +| `ocr config model` | - | 활성 provider의 대화형 model 선택 | | `ocr config set ` | - | 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..api_key` | string | Provider별 API key | +| `providers..url` | string | Provider base URL override | +| `providers..protocol` | string | `anthropic` \| `openai` | +| `providers..model` | string | Provider의 model 이름 | +| `providers..auth_header` | string | `x-api-key` \| `authorization` | +| `custom_providers..*` | — | `providers..*`과 동일한 필드 | | `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` | diff --git a/README.md b/README.md index 56af8e8..f639c27 100644 --- a/README.md +++ b/README.md @@ -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 ` | — | 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 ` | — | 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..api_key` | string | Provider-specific API key | +| `providers..url` | string | Provider base URL override | +| `providers..protocol` | string | `anthropic` \| `openai` | +| `providers..model` | string | Model name for the provider | +| `providers..auth_header` | string | `x-api-key` \| `authorization` | +| `custom_providers..*` | — | Same fields as `providers..*` | | `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` | diff --git a/README.zh-CN.md b/README.zh-CN.md index d82715a..ec41f69 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 ` | — | 预览某个文件路径生效的审查规则 | +| `ocr config provider` | — | 交互式供应商设置(内置、自定义或手动) | +| `ocr config model` | — | 为当前供应商交互式选择模型 | | `ocr config set ` | — | 设置配置项 | | `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..api_key` | string | 供应商 API 密钥 | +| `providers..url` | string | 供应商 Base URL 覆盖 | +| `providers..protocol` | string | `anthropic` \| `openai` | +| `providers..model` | string | 供应商模型名称 | +| `providers..auth_header` | string | `x-api-key` \| `authorization` | +| `custom_providers..*` | — | 与 `providers..*` 相同的字段 | | `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` | diff --git a/cmd/opencodereview/config_cmd.go b/cmd/opencodereview/config_cmd.go index 6cf1aca..50b16ee 100644 --- a/cmd/opencodereview/config_cmd.go +++ b/cmd/opencodereview/config_cmd.go @@ -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 ' 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 ' 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.. 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.., custom_providers.., 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..", 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..", 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{} diff --git a/cmd/opencodereview/config_cmd_test.go b/cmd/opencodereview/config_cmd_test.go index cb31424..34b4234 100644 --- a/cmd/opencodereview/config_cmd_test.go +++ b/cmd/opencodereview/config_cmd_test.go @@ -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) + } +} diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go index 3b62b5f..3e13bb5 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -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 + 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.., 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`) } diff --git a/cmd/opencodereview/llm_cmd.go b/cmd/opencodereview/llm_cmd.go index 84088c9..95cf861 100644 --- a/cmd/opencodereview/llm_cmd.go +++ b/cmd/opencodereview/llm_cmd.go @@ -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 ' 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`) } diff --git a/cmd/opencodereview/main.go b/cmd/opencodereview/main.go index d97d9c9..50c5b2c 100644 --- a/cmd/opencodereview/main.go +++ b/cmd/opencodereview/main.go @@ -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. diff --git a/cmd/opencodereview/provider_cmd.go b/cmd/opencodereview/provider_cmd.go new file mode 100644 index 0000000..e62a6b7 --- /dev/null +++ b/cmd/opencodereview/provider_cmd.go @@ -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 ' 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:] +} diff --git a/cmd/opencodereview/provider_tui.go b/cmd/opencodereview/provider_tui.go new file mode 100644 index 0000000..9bf70d2 --- /dev/null +++ b/cmd/opencodereview/provider_tui.go @@ -0,0 +1,1337 @@ +package main + +import ( + "fmt" + "os" + "sort" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/open-code-review/open-code-review/internal/llm" +) + +type tuiStep int + +const ( + stepProvider tuiStep = iota + stepModel + stepAPIKey +) + +type providerTab int + +const ( + tabOfficial providerTab = iota + tabCustom + tabManual + tabCount // sentinel — must remain last +) + +type customProviderStep int + +const ( + cpStepName customProviderStep = iota + cpStepProtocol + cpStepBaseURL + cpStepModel + cpStepAPIKey + cpStepAuthHeader +) + +type manualStep int + +const ( + manualStepURL manualStep = iota + manualStepModel + manualStepAuthToken +) + +var cpProtocols = []string{"anthropic", "openai"} + +type customProviderListItem struct { + name string + entry ProviderEntry +} + +type providerTUIResult struct { + provider string + model string + apiKey string + isCustom bool + isManual bool + url string + protocol string + authHeader string +} + +type providerTUIModel struct { + step tuiStep + width int + height int + + activeTab providerTab + + // --- tab: official --- + providers []llm.Provider + officialIdx int + + // --- tab: custom --- + customProviders []customProviderListItem + customIdx int + creatingCustom bool + cpStep customProviderStep + cpProtocolIdx int + cpNameInput textinput.Model + cpURLInput textinput.Model + cpModelInput textinput.Model + cpAuthInput textinput.Model + + // --- tab: manual --- + inManualForm bool + manualStep manualStep + manualURLInput textinput.Model + manualModelInput textinput.Model + manualTokenInput textinput.Model + + // --- shared model/api-key steps (official + existing custom) --- + modelIdx int + customModel bool + modelInput textinput.Model + + apiKeyInput textinput.Model + apiKeyMasked bool + apiKeyOriginal string + + existingCfg *Config + confirmed bool + cancelled bool +} + +func collectCustomProviders(cfg *Config) []customProviderListItem { + if cfg == nil || cfg.CustomProviders == nil { + return nil + } + var out []customProviderListItem + for name, entry := range cfg.CustomProviders { + out = append(out, customProviderListItem{name: name, entry: entry}) + } + sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name }) + return out +} + +func newProviderTUI(cfg *Config) providerTUIModel { + providers := llm.ListProviders() + + mi := textinput.New() + mi.Placeholder = "model name" + mi.SetWidth(40) + + ai := textinput.New() + ai.Placeholder = "paste your API key here" + ai.SetWidth(50) + ai.EchoMode = textinput.EchoPassword + ai.EchoCharacter = '*' + + cpName := textinput.New() + cpName.Placeholder = "provider name (e.g. my-llm)" + cpName.SetWidth(40) + + cpURL := textinput.New() + cpURL.Placeholder = "enter your API base URL" + cpURL.SetWidth(50) + + cpModel := textinput.New() + cpModel.Placeholder = "model name" + cpModel.SetWidth(40) + + cpAuth := textinput.New() + cpAuth.Placeholder = "optional, leave empty for default (Authorization)" + cpAuth.SetWidth(40) + + manualURL := textinput.New() + manualURL.Placeholder = "enter your API base URL" + manualURL.SetWidth(50) + + manualModel := textinput.New() + manualModel.Placeholder = "enter model name" + manualModel.SetWidth(40) + + manualToken := textinput.New() + manualToken.Placeholder = "enter your auth token" + manualToken.SetWidth(50) + manualToken.EchoMode = textinput.EchoPassword + manualToken.EchoCharacter = '*' + + m := providerTUIModel{ + providers: providers, + existingCfg: cfg, + modelInput: mi, + apiKeyInput: ai, + cpNameInput: cpName, + cpURLInput: cpURL, + cpModelInput: cpModel, + cpAuthInput: cpAuth, + manualURLInput: manualURL, + manualModelInput: manualModel, + manualTokenInput: manualToken, + width: 80, + height: 24, + activeTab: tabOfficial, + customProviders: collectCustomProviders(cfg), + } + + providerFound := false + if cfg.Provider != "" { + for i, p := range providers { + if p.Name == cfg.Provider { + m.officialIdx = i + providerFound = true + break + } + } + + if !providerFound { + m.activeTab = tabCustom + m.customIdx = len(m.customProviders) // default to "Add" option + for i, cp := range m.customProviders { + if cp.name == cfg.Provider { + m.customIdx = i + break + } + } + } + } + + if providerFound { + if entry, ok := cfg.Providers[cfg.Provider]; ok && entry.Model != "" { + selected := providers[m.officialIdx] + found := false + for i, model := range selected.Models { + if model == entry.Model { + m.modelIdx = i + found = true + break + } + } + if !found { + m.modelIdx = len(selected.Models) + m.modelInput.SetValue(entry.Model) + } + } + + if entry, ok := cfg.Providers[cfg.Provider]; ok && entry.APIKey != "" { + m.apiKeyOriginal = entry.APIKey + m.apiKeyMasked = true + } + } + + if cfg.Provider == "" && cfg.Llm.URL != "" { + m.activeTab = tabManual + } + + if cfg.Llm.URL != "" { + m.manualURLInput.SetValue(cfg.Llm.URL) + m.manualModelInput.SetValue(cfg.Llm.Model) + if cfg.Llm.AuthToken != "" { + m.manualTokenInput.SetValue(cfg.Llm.AuthToken) + } + } + + return m +} + +func (m providerTUIModel) Init() tea.Cmd { + return nil +} + +func (m providerTUIModel) currentProvider() llm.Provider { + if m.activeTab != tabOfficial || m.officialIdx >= len(m.providers) { + return llm.Provider{} + } + return m.providers[m.officialIdx] +} + +func (m providerTUIModel) models() []string { + return m.currentProvider().Models +} + +func (m providerTUIModel) isCustomModelItem(idx int) bool { + return idx == len(m.models()) +} + +func (m providerTUIModel) modelCount() int { + return len(m.models()) + 1 +} + +func (m providerTUIModel) customListCount() int { + return len(m.customProviders) + 1 +} + +// --- Update --- + +func (m providerTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyPressMsg: + key := msg.String() + + if m.step == stepModel && m.customModel { + return m.updateCustomModelInput(key, msg) + } + + if m.step == stepAPIKey { + return m.updateAPIKeyInput(key, msg) + } + + if m.step == stepProvider && m.creatingCustom { + return m.updateCustomProviderForm(key, msg) + } + + if m.step == stepProvider && m.inManualForm { + return m.updateManualForm(key, msg) + } + + switch key { + case "ctrl+c": + m.cancelled = true + return m, tea.Quit + + case "esc": + if m.step == stepProvider { + m.cancelled = true + return m, tea.Quit + } + m.step-- + return m, nil + + case "enter": + return m.handleEnter() + + case "up", "k": + return m.handleUp() + + case "down", "j": + return m.handleDown() + + case "left", "h": + if m.step == stepProvider { + if m.activeTab > 0 { + m.activeTab-- + } + } + return m, nil + + case "right", "l": + if m.step == stepProvider { + if m.activeTab < tabCount-1 { + m.activeTab++ + } + } + return m, nil + + case "tab": + if m.step == stepProvider { + m.activeTab = (m.activeTab + 1) % tabCount + } + return m, nil + } + + default: + if m.step == stepProvider && m.creatingCustom { + return m.passThroughCPInput(msg) + } + if m.step == stepProvider && m.inManualForm { + return m.passThroughManualInput(msg) + } + if m.step == stepAPIKey { + var cmd tea.Cmd + m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) + return m, cmd + } + if m.step == stepModel && m.customModel { + var cmd tea.Cmd + m.modelInput, cmd = m.modelInput.Update(msg) + return m, cmd + } + } + return m, nil +} + +func (m providerTUIModel) updateCustomModelInput(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch key { + case "esc": + m.customModel = false + m.modelInput.Blur() + m.modelInput.SetValue("") + return m, nil + case "enter": + if m.modelInput.Value() != "" { + m.customModel = false + m.modelInput.Blur() + m.step = stepAPIKey + m.loadExistingAPIKey() + return m, m.apiKeyInput.Focus() + } + return m, nil + default: + var cmd tea.Cmd + m.modelInput, cmd = m.modelInput.Update(msg) + return m, cmd + } +} + +func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch key { + case "esc": + m.apiKeyInput.Blur() + if m.activeTab == tabCustom { + m.step = stepProvider + } else { + m.step = stepModel + } + return m, nil + case "enter": + m.confirmed = true + return m, tea.Quit + case "ctrl+c": + m.cancelled = true + return m, tea.Quit + default: + if m.apiKeyMasked { + if len(key) == 1 { + m.apiKeyMasked = false + m.apiKeyInput.SetValue("") + } else { + return m, nil + } + } + var cmd tea.Cmd + m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) + return m, cmd + } +} + +func (m providerTUIModel) updateCustomProviderForm(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch key { + case "ctrl+c": + m.cancelled = true + return m, tea.Quit + case "esc": + if m.cpStep == cpStepName { + m.creatingCustom = false + m.cpNameInput.Blur() + return m, nil + } + m.blurCPStep() + m.cpStep-- + return m, m.focusCPStep() + case "enter": + return m.handleCustomFormEnter() + case "up", "k": + if m.cpStep == cpStepProtocol && m.cpProtocolIdx > 0 { + m.cpProtocolIdx-- + } + return m, nil + case "down", "j": + if m.cpStep == cpStepProtocol && m.cpProtocolIdx < len(cpProtocols)-1 { + m.cpProtocolIdx++ + } + return m, nil + default: + return m.passThroughCPInput(msg) + } +} + +func (m providerTUIModel) handleCustomFormEnter() (tea.Model, tea.Cmd) { + switch m.cpStep { + case cpStepName: + if m.cpNameInput.Value() == "" { + return m, nil + } + m.cpNameInput.Blur() + m.cpStep = cpStepProtocol + return m, nil + case cpStepProtocol: + m.cpStep = cpStepBaseURL + return m, m.cpURLInput.Focus() + case cpStepBaseURL: + if m.cpURLInput.Value() == "" { + return m, nil + } + m.cpURLInput.Blur() + m.cpStep = cpStepModel + return m, m.cpModelInput.Focus() + case cpStepModel: + if m.cpModelInput.Value() == "" { + return m, nil + } + m.cpModelInput.Blur() + m.cpStep = cpStepAPIKey + m.apiKeyInput.SetValue("") + m.apiKeyMasked = false + return m, m.apiKeyInput.Focus() + case cpStepAPIKey: + m.apiKeyInput.Blur() + m.cpStep = cpStepAuthHeader + return m, m.cpAuthInput.Focus() + case cpStepAuthHeader: + m.cpAuthInput.Blur() + m.confirmed = true + return m, tea.Quit + } + return m, nil +} + +func (m *providerTUIModel) blurCPStep() { + switch m.cpStep { + case cpStepName: + m.cpNameInput.Blur() + case cpStepBaseURL: + m.cpURLInput.Blur() + case cpStepModel: + m.cpModelInput.Blur() + case cpStepAPIKey: + m.apiKeyInput.Blur() + case cpStepAuthHeader: + m.cpAuthInput.Blur() + } +} + +func (m providerTUIModel) focusCPStep() tea.Cmd { + switch m.cpStep { + case cpStepName: + return m.cpNameInput.Focus() + case cpStepBaseURL: + return m.cpURLInput.Focus() + case cpStepModel: + return m.cpModelInput.Focus() + case cpStepAPIKey: + return m.apiKeyInput.Focus() + case cpStepAuthHeader: + return m.cpAuthInput.Focus() + } + return nil +} + +func (m providerTUIModel) passThroughCPInput(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch m.cpStep { + case cpStepName: + m.cpNameInput, cmd = m.cpNameInput.Update(msg) + case cpStepBaseURL: + m.cpURLInput, cmd = m.cpURLInput.Update(msg) + case cpStepModel: + m.cpModelInput, cmd = m.cpModelInput.Update(msg) + case cpStepAPIKey: + if m.apiKeyMasked { + return m, nil + } + m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) + case cpStepAuthHeader: + m.cpAuthInput, cmd = m.cpAuthInput.Update(msg) + } + return m, cmd +} + +func (m providerTUIModel) updateManualForm(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch key { + case "ctrl+c": + m.cancelled = true + return m, tea.Quit + case "esc": + if m.manualStep == manualStepURL { + m.inManualForm = false + m.manualURLInput.Blur() + if m.existingCfg != nil { + m.manualURLInput.SetValue(m.existingCfg.Llm.URL) + m.manualModelInput.SetValue(m.existingCfg.Llm.Model) + m.manualTokenInput.SetValue(m.existingCfg.Llm.AuthToken) + } else { + m.manualURLInput.SetValue("") + m.manualModelInput.SetValue("") + m.manualTokenInput.SetValue("") + } + return m, nil + } + m.blurManualStep() + m.manualStep-- + return m, m.focusManualStep() + case "enter": + return m.handleManualFormEnter() + default: + return m.passThroughManualInput(msg) + } +} + +func (m providerTUIModel) handleManualFormEnter() (tea.Model, tea.Cmd) { + switch m.manualStep { + case manualStepURL: + if m.manualURLInput.Value() == "" { + return m, nil + } + m.manualURLInput.Blur() + m.manualStep = manualStepModel + return m, m.manualModelInput.Focus() + case manualStepModel: + if m.manualModelInput.Value() == "" { + return m, nil + } + m.manualModelInput.Blur() + m.manualStep = manualStepAuthToken + return m, m.manualTokenInput.Focus() + case manualStepAuthToken: + m.manualTokenInput.Blur() + m.confirmed = true + return m, tea.Quit + } + return m, nil +} + +func (m *providerTUIModel) blurManualStep() { + switch m.manualStep { + case manualStepURL: + m.manualURLInput.Blur() + case manualStepModel: + m.manualModelInput.Blur() + case manualStepAuthToken: + m.manualTokenInput.Blur() + } +} + +func (m providerTUIModel) focusManualStep() tea.Cmd { + switch m.manualStep { + case manualStepURL: + return m.manualURLInput.Focus() + case manualStepModel: + return m.manualModelInput.Focus() + case manualStepAuthToken: + return m.manualTokenInput.Focus() + } + return nil +} + +func (m providerTUIModel) passThroughManualInput(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch m.manualStep { + case manualStepURL: + m.manualURLInput, cmd = m.manualURLInput.Update(msg) + case manualStepModel: + m.manualModelInput, cmd = m.manualModelInput.Update(msg) + case manualStepAuthToken: + m.manualTokenInput, cmd = m.manualTokenInput.Update(msg) + } + return m, cmd +} + +func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) { + switch m.step { + case stepProvider: + switch m.activeTab { + case tabOfficial: + m.step = stepModel + m.modelIdx = 0 + if m.existingCfg != nil { + if entry, ok := m.existingCfg.Providers[m.currentProvider().Name]; ok && entry.Model != "" { + found := false + for i, model := range m.models() { + if model == entry.Model { + m.modelIdx = i + found = true + break + } + } + if !found { + m.modelIdx = len(m.models()) + m.modelInput.SetValue(entry.Model) + } + } + } + return m, nil + + case tabCustom: + addIdx := len(m.customProviders) + if m.customIdx == addIdx { + m.creatingCustom = true + m.cpStep = cpStepName + m.cpProtocolIdx = 1 // default openai + m.cpNameInput.SetValue("") + m.cpURLInput.SetValue("") + m.cpModelInput.SetValue("") + m.cpAuthInput.SetValue("") + m.apiKeyInput.SetValue("") + m.apiKeyMasked = false + return m, m.cpNameInput.Focus() + } + cp := m.customProviders[m.customIdx] + m.step = stepAPIKey + m.apiKeyMasked = false + m.apiKeyOriginal = "" + m.apiKeyInput.SetValue("") + if cp.entry.APIKey != "" { + m.apiKeyOriginal = cp.entry.APIKey + m.apiKeyMasked = true + m.apiKeyInput.SetValue(strings.Repeat("*", 20)) + } + return m, m.apiKeyInput.Focus() + + case tabManual: + m.inManualForm = true + m.manualStep = manualStepURL + return m, m.manualURLInput.Focus() + } + + case stepModel: + if m.isCustomModelItem(m.modelIdx) { + m.customModel = true + return m, m.modelInput.Focus() + } + m.step = stepAPIKey + m.loadExistingAPIKey() + return m, m.apiKeyInput.Focus() + } + return m, nil +} + +func (m providerTUIModel) handleUp() (tea.Model, tea.Cmd) { + switch m.step { + case stepProvider: + switch m.activeTab { + case tabOfficial: + if m.officialIdx > 0 { + m.officialIdx-- + } + case tabCustom: + if m.customIdx > 0 { + m.customIdx-- + } + } + case stepModel: + if m.modelIdx > 0 { + m.modelIdx-- + } + } + return m, nil +} + +func (m providerTUIModel) handleDown() (tea.Model, tea.Cmd) { + switch m.step { + case stepProvider: + switch m.activeTab { + case tabOfficial: + if m.officialIdx < len(m.providers)-1 { + m.officialIdx++ + } + case tabCustom: + if m.customIdx < m.customListCount()-1 { + m.customIdx++ + } + } + case stepModel: + if m.modelIdx < m.modelCount()-1 { + m.modelIdx++ + } + } + return m, nil +} + +func (m *providerTUIModel) loadExistingAPIKey() { + p := m.currentProvider() + m.apiKeyMasked = false + m.apiKeyOriginal = "" + m.apiKeyInput.SetValue("") + if m.existingCfg != nil { + if entry, ok := m.existingCfg.Providers[p.Name]; ok && entry.APIKey != "" { + m.apiKeyOriginal = entry.APIKey + m.apiKeyMasked = true + m.apiKeyInput.SetValue(strings.Repeat("*", 20)) + } + } +} + +func (m providerTUIModel) result() providerTUIResult { + switch m.activeTab { + case tabOfficial: + p := m.currentProvider() + model := "" + if m.modelInput.Value() != "" && (m.customModel || m.isCustomModelItem(m.modelIdx)) { + model = m.modelInput.Value() + } else if m.modelIdx < len(m.models()) { + model = m.models()[m.modelIdx] + } + + apiKey := "" + if m.apiKeyMasked { + apiKey = m.apiKeyOriginal + } else { + apiKey = m.apiKeyInput.Value() + } + + return providerTUIResult{ + provider: p.Name, + model: model, + apiKey: apiKey, + } + + case tabCustom: + if m.creatingCustom { + protocol := cpProtocols[m.cpProtocolIdx] + return providerTUIResult{ + provider: m.cpNameInput.Value(), + model: m.cpModelInput.Value(), + apiKey: m.apiKeyInput.Value(), + isCustom: true, + url: m.cpURLInput.Value(), + protocol: protocol, + authHeader: m.cpAuthInput.Value(), + } + } + if m.customIdx < len(m.customProviders) { + cp := m.customProviders[m.customIdx] + apiKey := "" + if m.apiKeyMasked { + apiKey = m.apiKeyOriginal + } else { + apiKey = m.apiKeyInput.Value() + } + return providerTUIResult{ + provider: cp.name, + model: cp.entry.Model, + apiKey: apiKey, + isCustom: true, + url: cp.entry.URL, + protocol: cp.entry.Protocol, + authHeader: cp.entry.AuthHeader, + } + } + return providerTUIResult{} + + case tabManual: + return providerTUIResult{ + isManual: true, + url: m.manualURLInput.Value(), + model: m.manualModelInput.Value(), + apiKey: m.manualTokenInput.Value(), + } + } + + return providerTUIResult{} +} + +// --- View --- + +func (m providerTUIModel) View() tea.View { + var s strings.Builder + s.WriteString("\n") + + switch m.step { + case stepProvider: + m.viewProvider(&s) + case stepModel: + m.viewModel(&s) + case stepAPIKey: + m.viewAPIKey(&s) + } + + v := tea.NewView(s.String()) + v.AltScreen = true + return v +} + +func renderTabBar(active providerTab) string { + tabs := []struct { + label string + tab providerTab + }{ + {"Official", tabOfficial}, + {"Custom", tabCustom}, + {"Manual", tabManual}, + } + + var parts []string + for _, t := range tabs { + if t.tab == active { + parts = append(parts, tuiActiveTabStyle.Render("◉ "+t.label)) + } else { + parts = append(parts, tuiInactiveTabStyle.Render("○ "+t.label)) + } + } + return " " + strings.Join(parts, " ") +} + +func (m providerTUIModel) viewProvider(s *strings.Builder) { + s.WriteString(renderTabBar(m.activeTab)) + s.WriteString("\n\n") + + switch m.activeTab { + case tabOfficial: + m.viewOfficialTab(s) + case tabCustom: + m.viewCustomTab(s) + case tabManual: + m.viewManualTab(s) + } + + s.WriteString("\n") + if m.creatingCustom || m.inManualForm { + s.WriteString(tuiHelpStyle.Render(" Enter Confirm · Esc Back")) + } else { + s.WriteString(tuiHelpStyle.Render(" Enter to select · Tab/Arrow keys to navigate · Esc to cancel")) + } + s.WriteString("\n") +} + +func (m providerTUIModel) viewOfficialTab(s *strings.Builder) { + s.WriteString(tuiTitleStyle.Render(" Select a provider")) + s.WriteString("\n\n") + + for i, p := range m.providers { + cursor := " " + if i == m.officialIdx { + cursor = " " + tuiCursorStyle.Render(tuiCursor) + " " + } + name := p.DisplayName + if i == m.officialIdx { + s.WriteString(cursor + tuiSelectedItemStyle.Render(name)) + } else { + s.WriteString(cursor + tuiItemStyle.Render(name)) + } + s.WriteString("\n") + } +} + +func (m providerTUIModel) viewCustomTab(s *strings.Builder) { + if m.creatingCustom { + m.viewCustomProviderForm(s) + return + } + + s.WriteString(tuiTitleStyle.Render(" Select a provider")) + s.WriteString("\n\n") + + for i, cp := range m.customProviders { + cursor := " " + if i == m.customIdx { + cursor = " " + tuiCursorStyle.Render(tuiCursor) + " " + } + label := cp.name + if cp.entry.Model != "" { + label += " " + tuiDimStyle.Render("("+cp.entry.Model+")") + } + if i == m.customIdx { + s.WriteString(cursor + tuiSelectedItemStyle.Render(cp.name)) + if cp.entry.Model != "" { + s.WriteString(" " + tuiDimStyle.Render("("+cp.entry.Model+")")) + } + } else { + s.WriteString(cursor + label) + } + s.WriteString("\n") + } + + addIdx := len(m.customProviders) + cursor := " " + if m.customIdx == addIdx { + cursor = " " + tuiCursorStyle.Render(tuiCursor) + " " + } + addLabel := "+ Add custom provider" + if m.customIdx == addIdx { + s.WriteString(cursor + tuiSelectedItemStyle.Render(addLabel)) + } else { + s.WriteString(cursor + tuiDimStyle.Render(addLabel)) + } + s.WriteString("\n") +} + +func (m providerTUIModel) viewCustomProviderForm(s *strings.Builder) { + s.WriteString(tuiTitleStyle.Render(" Add Custom Provider")) + s.WriteString("\n\n") + + type field struct { + label string + value string + active bool + } + + fields := []field{ + {"Provider name", m.cpNameInput.Value(), m.cpStep == cpStepName}, + {"Protocol", cpProtocols[m.cpProtocolIdx], m.cpStep == cpStepProtocol}, + {"Base URL", m.cpURLInput.Value(), m.cpStep == cpStepBaseURL}, + {"Model", m.cpModelInput.Value(), m.cpStep == cpStepModel}, + {"API Key", strings.Repeat("*", len(m.apiKeyInput.Value())), m.cpStep == cpStepAPIKey}, + {"Auth Header", m.cpAuthInput.Value(), m.cpStep == cpStepAuthHeader}, + } + + for _, f := range fields { + if f.active { + s.WriteString(" " + tuiSelectedItemStyle.Render(f.label+":") + "\n") + switch m.cpStep { + case cpStepName: + s.WriteString(" " + m.cpNameInput.View() + "\n") + case cpStepProtocol: + for i, proto := range cpProtocols { + cur := " " + if i == m.cpProtocolIdx { + cur = " " + tuiCursorStyle.Render(tuiCursor) + " " + } + if i == m.cpProtocolIdx { + s.WriteString(cur + tuiSelectedItemStyle.Render(proto) + "\n") + } else { + s.WriteString(cur + tuiItemStyle.Render(proto) + "\n") + } + } + case cpStepBaseURL: + s.WriteString(" " + m.cpURLInput.View() + "\n") + case cpStepModel: + s.WriteString(" " + m.cpModelInput.View() + "\n") + case cpStepAPIKey: + s.WriteString(" " + m.apiKeyInput.View() + "\n") + case cpStepAuthHeader: + s.WriteString(" " + m.cpAuthInput.View() + "\n") + } + } else if f.value != "" { + s.WriteString(" " + tuiDimStyle.Render(f.label+": "+f.value) + "\n") + } + } +} + +func (m providerTUIModel) viewManualTab(s *strings.Builder) { + if !m.inManualForm { + s.WriteString(tuiTitleStyle.Render(" Manual Configuration")) + s.WriteString("\n\n") + s.WriteString(tuiItemStyle.Render(" Configure LLM endpoint manually.")) + s.WriteString("\n") + if m.existingCfg != nil && m.existingCfg.Llm.URL != "" { + s.WriteString("\n") + s.WriteString(tuiDimStyle.Render(fmt.Sprintf(" Current: %s (%s)", m.existingCfg.Llm.URL, m.existingCfg.Llm.Model))) + s.WriteString("\n") + } + s.WriteString("\n") + s.WriteString(tuiItemStyle.Render(" Press Enter to configure.")) + s.WriteString("\n") + return + } + + s.WriteString(tuiTitleStyle.Render(" Manual Configuration")) + s.WriteString("\n\n") + + type field struct { + label string + value string + active bool + } + + fields := []field{ + {"URL", m.manualURLInput.Value(), m.manualStep == manualStepURL}, + {"Model", m.manualModelInput.Value(), m.manualStep == manualStepModel}, + {"Auth Token", strings.Repeat("*", len(m.manualTokenInput.Value())), m.manualStep == manualStepAuthToken}, + } + + for _, f := range fields { + if f.active { + s.WriteString(" " + tuiSelectedItemStyle.Render(f.label+":") + "\n") + switch m.manualStep { + case manualStepURL: + s.WriteString(" " + m.manualURLInput.View() + "\n") + case manualStepModel: + s.WriteString(" " + m.manualModelInput.View() + "\n") + case manualStepAuthToken: + s.WriteString(" " + m.manualTokenInput.View() + "\n") + } + } else if f.value != "" { + s.WriteString(" " + tuiDimStyle.Render(f.label+": "+f.value) + "\n") + } + } +} + +func (m providerTUIModel) viewModel(s *strings.Builder) { + provider := m.currentProvider() + s.WriteString(tuiTitleStyle.Render(fmt.Sprintf(" Select a model (%s)", provider.DisplayName))) + s.WriteString("\n\n") + + models := m.models() + for i, model := range models { + cursor := " " + if i == m.modelIdx { + cursor = " " + tuiCursorStyle.Render(tuiCursor) + " " + } + if i == m.modelIdx { + s.WriteString(cursor + tuiSelectedItemStyle.Render(model)) + } else { + s.WriteString(cursor + tuiItemStyle.Render(model)) + } + s.WriteString("\n") + } + + customIdx := len(models) + cursor := " " + if m.modelIdx == customIdx { + cursor = " " + tuiCursorStyle.Render(tuiCursor) + " " + } + customLabel := "Enter custom model name..." + if m.modelIdx == customIdx { + s.WriteString(cursor + tuiSelectedItemStyle.Render(customLabel)) + } else { + s.WriteString(cursor + tuiDimStyle.Render(customLabel)) + } + s.WriteString("\n") + + if m.customModel { + s.WriteString("\n") + s.WriteString(" " + m.modelInput.View()) + s.WriteString("\n") + } + + s.WriteString("\n") + s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Back")) + s.WriteString("\n") +} + +func (m providerTUIModel) viewAPIKey(s *strings.Builder) { + var title string + if m.activeTab == tabCustom && m.customIdx < len(m.customProviders) { + title = fmt.Sprintf(" Enter API Key (%s)", m.customProviders[m.customIdx].name) + } else { + provider := m.currentProvider() + title = fmt.Sprintf(" Enter API Key (%s)", provider.DisplayName) + } + s.WriteString(tuiTitleStyle.Render(title)) + s.WriteString("\n\n") + + s.WriteString(" " + m.apiKeyInput.View()) + s.WriteString("\n") + + if m.activeTab == tabOfficial { + provider := m.currentProvider() + if envKey := os.Getenv(provider.EnvVar); envKey != "" { + s.WriteString("\n") + s.WriteString(tuiDimStyle.Render(fmt.Sprintf(" $%s is set", provider.EnvVar))) + s.WriteString("\n") + } else { + s.WriteString("\n") + s.WriteString(tuiDimStyle.Render(fmt.Sprintf(" Tip: You can also set via env var %s", provider.EnvVar))) + s.WriteString("\n") + } + } + + s.WriteString("\n") + s.WriteString(tuiHelpStyle.Render(" Enter Confirm Esc Back")) + s.WriteString("\n") +} + +// --- Styles --- + +const tuiCursor = "▸" + +var ( + tuiTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("15")) + + tuiCursorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("12")) + + tuiSelectedItemStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("12")) + + tuiItemStyle = lipgloss.NewStyle() + + tuiDimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) + + tuiHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) + + tuiActiveTabStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("12")) + + tuiInactiveTabStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) +) + +// --- Model-only TUI (for `ocr config model`) --- + +type modelTUIModel struct { + width int + height int + + provider llm.Provider + models []string + modelIdx int + customModel bool + modelInput textinput.Model + + confirmed bool + cancelled bool +} + +func newModelTUI(provider llm.Provider, currentModel string) modelTUIModel { + mi := textinput.New() + mi.Placeholder = "model name" + mi.SetWidth(40) + + m := modelTUIModel{ + provider: provider, + models: provider.Models, + width: 80, + height: 24, + modelInput: mi, + } + + if currentModel != "" { + found := false + for i, model := range provider.Models { + if model == currentModel { + m.modelIdx = i + found = true + break + } + } + if !found { + m.modelIdx = len(provider.Models) + m.modelInput.SetValue(currentModel) + } + } + + return m +} + +func (m modelTUIModel) Init() tea.Cmd { + return nil +} + +func (m modelTUIModel) isCustomItem(idx int) bool { + return idx == len(m.models) +} + +func (m modelTUIModel) itemCount() int { + return len(m.models) + 1 +} + +func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyPressMsg: + key := msg.String() + + if m.customModel { + switch key { + case "esc": + m.customModel = false + m.modelInput.Blur() + m.modelInput.SetValue("") + return m, nil + case "enter": + if m.modelInput.Value() != "" { + m.confirmed = true + return m, tea.Quit + } + return m, nil + default: + var cmd tea.Cmd + m.modelInput, cmd = m.modelInput.Update(msg) + return m, cmd + } + } + + switch key { + case "ctrl+c", "esc": + m.cancelled = true + return m, tea.Quit + case "enter": + if m.isCustomItem(m.modelIdx) { + m.customModel = true + return m, m.modelInput.Focus() + } + m.confirmed = true + return m, tea.Quit + case "up", "k": + if m.modelIdx > 0 { + m.modelIdx-- + } + return m, nil + case "down", "j": + if m.modelIdx < m.itemCount()-1 { + m.modelIdx++ + } + return m, nil + } + + default: + if m.customModel { + var cmd tea.Cmd + m.modelInput, cmd = m.modelInput.Update(msg) + return m, cmd + } + } + return m, nil +} + +func (m modelTUIModel) selectedModel() string { + if m.customModel || m.isCustomItem(m.modelIdx) { + return m.modelInput.Value() + } + if m.modelIdx < len(m.models) { + return m.models[m.modelIdx] + } + return "" +} + +func (m modelTUIModel) View() tea.View { + var s strings.Builder + s.WriteString("\n") + s.WriteString(tuiTitleStyle.Render(fmt.Sprintf(" Select a model (%s)", m.provider.DisplayName))) + s.WriteString("\n\n") + + for i, model := range m.models { + cursor := " " + if i == m.modelIdx { + cursor = " " + tuiCursorStyle.Render(tuiCursor) + " " + } + if i == m.modelIdx { + s.WriteString(cursor + tuiSelectedItemStyle.Render(model)) + } else { + s.WriteString(cursor + tuiItemStyle.Render(model)) + } + s.WriteString("\n") + } + + customIdx := len(m.models) + cursor := " " + if m.modelIdx == customIdx { + cursor = " " + tuiCursorStyle.Render(tuiCursor) + " " + } + customLabel := "Enter custom model name..." + if m.modelIdx == customIdx { + s.WriteString(cursor + tuiSelectedItemStyle.Render(customLabel)) + } else { + s.WriteString(cursor + tuiDimStyle.Render(customLabel)) + } + s.WriteString("\n") + + if m.customModel { + s.WriteString("\n") + s.WriteString(" " + m.modelInput.View()) + s.WriteString("\n") + } + + s.WriteString("\n") + s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Cancel")) + s.WriteString("\n") + + v := tea.NewView(s.String()) + v.AltScreen = true + return v +} diff --git a/cmd/opencodereview/provider_tui_test.go b/cmd/opencodereview/provider_tui_test.go new file mode 100644 index 0000000..2dc6ab4 --- /dev/null +++ b/cmd/opencodereview/provider_tui_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod index 0c9b1f3..c6dcda1 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c28e891..291ab0b 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/imgs/providers.jpg b/imgs/providers.jpg new file mode 100644 index 0000000..ed03ddb Binary files /dev/null and b/imgs/providers.jpg differ diff --git a/internal/llm/providers.go b/internal/llm/providers.go new file mode 100644 index 0000000..91208ae --- /dev/null +++ b/internal/llm/providers.go @@ -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 +} diff --git a/internal/llm/providers_test.go b/internal/llm/providers_test.go new file mode 100644 index 0000000..6d9fbef --- /dev/null +++ b/internal/llm/providers_test.go @@ -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) + } +} diff --git a/internal/llm/resolver.go b/internal/llm/resolver.go index a5075a0..6bb1d05 100644 --- a/internal/llm/resolver.go +++ b/internal/llm/resolver.go @@ -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 } diff --git a/internal/llm/resolver_test.go b/internal/llm/resolver_test.go index 919eac0..97d954c 100644 --- a/internal/llm/resolver_test.go +++ b/internal/llm/resolver_test.go @@ -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") + } +} diff --git a/pages/src/components/QuickStartSection.tsx b/pages/src/components/QuickStartSection.tsx index 2b52e08..8c6d750 100644 --- a/pages/src/components/QuickStartSection.tsx +++ b/pages/src/components/QuickStartSection.tsx @@ -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` } ] }, diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index 10176d2..d4401f4 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -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 ~/.opencodereview/config.json.', + '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', diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index 026872b..62ad27c 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -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 的配置设置,存储在 ~/.opencodereview/config.json。', + 'docs.configInteractive': '交互式设置(推荐)', + 'docs.configInteractiveDesc': '最简单的配置方式。从内置供应商中选择,或添加自定义供应商。', + 'docs.configModelSelect': '模型选择', + 'docs.configModelSelectDesc': '选择供应商后,选择要使用的模型。', + 'docs.configListProviders': '查看内置供应商', + 'docs.configListProvidersDesc': '查看所有可用的内置 LLM 供应商。', + 'docs.configManual': '手动配置', 'docs.configCommand': '命令', 'docs.configExample': '示例', 'docs.configKeys': '支持的配置 Key', diff --git a/pages/src/pages/DocsPage.tsx b/pages/src/pages/DocsPage.tsx index c52abe3..150ee4d 100644 --- a/pages/src/pages/DocsPage.tsx +++ b/pages/src/pages/DocsPage.tsx @@ -227,10 +227,43 @@ const DocsPage: React.FC = () => {

-

{t('docs.configCommand')}

+

{t('docs.configInteractive')}

+

{t('docs.configInteractiveDesc')}

+
+ handleCopy('ocr config provider', 'config-provider')} + copyLabel={t('docs.copy')} + /> +
+
+

{t('docs.configModelSelect')}

+

{t('docs.configModelSelectDesc')}

+ handleCopy('ocr config model', 'config-model')} + copyLabel={t('docs.copy')} + /> +
+
+

{t('docs.configListProviders')}

+

{t('docs.configListProvidersDesc')}

+ handleCopy('ocr llm providers', 'config-providers-list')} + copyLabel={t('docs.copy')} + /> +
+ +

{t('docs.configManual')}

+ +

{t('docs.configCommand')}

-

{t('docs.configExample')}

+

{t('docs.configExample')}