mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
feat: add interactive provider setup with TUI and documentation
This commit is contained in:
parent
f577742c04
commit
72aad2c77c
23 changed files with 3407 additions and 76 deletions
|
|
@ -113,18 +113,22 @@ sudo cp dist/opencodereview /usr/local/bin/ocr
|
|||
|
||||
**コードレビューの前に必ずLLMを設定する必要があります。**
|
||||
|
||||
**オプションA: 対話的セットアップ(推奨)**
|
||||
|
||||
```bash
|
||||
ocr config provider # ビルトインプロバイダーを選択またはカスタムプロバイダーを追加
|
||||
ocr config model # アクティブなプロバイダーのモデルを選択
|
||||
```
|
||||
|
||||

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

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

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

|
||||
|
||||
**方式 B:手动配置**
|
||||
|
||||
```bash
|
||||
# 方式 A:交互式配置
|
||||
ocr config set llm.url https://api.anthropic.com/v1/messages
|
||||
ocr config set llm.auth_token your-api-key-here
|
||||
ocr config set llm.model claude-opus-4-6
|
||||
ocr config set llm.use_anthropic true
|
||||
|
||||
# 方式 B:环境变量(优先级最高)
|
||||
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
|
||||
export OCR_LLM_TOKEN=your-api-key-here
|
||||
export OCR_LLM_MODEL=claude-opus-4-6
|
||||
export OCR_USE_ANTHROPIC=true
|
||||
```
|
||||
|
||||
配置存储于 `~/.opencodereview/config.json`。
|
||||
|
|
@ -133,12 +137,19 @@ export OCR_USE_ANTHROPIC=true
|
|||
|
||||
```bash
|
||||
ocr config set llm.auth_header x-api-key
|
||||
# 或
|
||||
export OCR_LLM_AUTH_HEADER=x-api-key
|
||||
```
|
||||
|
||||
支持的值:`x-api-key`、`authorization`(别名:`bearer`)。其他值会直接报错。
|
||||
|
||||
**方式 C:环境变量(优先级最高)**
|
||||
|
||||
```bash
|
||||
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
|
||||
export OCR_LLM_TOKEN=your-api-key-here
|
||||
export OCR_LLM_MODEL=claude-opus-4-6
|
||||
export OCR_USE_ANTHROPIC=true
|
||||
```
|
||||
|
||||
同时兼容了 Claude Code 环境变量(`ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN`、`ANTHROPIC_MODEL`),并解析 `~/.zshrc` / `~/.bashrc` 中的相关导出。
|
||||
|
||||
> **CC-Switch 用户特别提醒**:如果你使用 [CC-Switch](https://github.com/farion1231/cc-switch) 并开启了[路由服务](https://www.ccswitch.io/zh/docs?section=proxy&item=service),可以将 `llm.url` 配置成 CC-Switch 启动的代理地址,无需额外配置:
|
||||
|
|
@ -278,8 +289,11 @@ ocr review \
|
|||
|------|------|------|
|
||||
| `ocr review` | `ocr r` | 开始代码审查 |
|
||||
| `ocr rules check <file>` | — | 预览某个文件路径生效的审查规则 |
|
||||
| `ocr config provider` | — | 交互式供应商设置(内置、自定义或手动) |
|
||||
| `ocr config model` | — | 为当前供应商交互式选择模型 |
|
||||
| `ocr config set <key> <value>` | — | 设置配置项 |
|
||||
| `ocr llm test` | — | 测试 LLM 连通性 |
|
||||
| `ocr llm providers` | — | 列出内置 LLM 供应商 |
|
||||
| `ocr viewer` | `ocr v` | 启动 WebUI 会话查看器,地址 `localhost:5483` |
|
||||
| `ocr version` | — | 显示版本信息 |
|
||||
|
||||
|
|
@ -305,6 +319,11 @@ ocr review \
|
|||
## 示例
|
||||
|
||||
```bash
|
||||
# 交互式供应商和模型设置
|
||||
ocr config provider
|
||||
ocr config model
|
||||
ocr llm providers
|
||||
|
||||
# 预览将被审查的文件(不调用 LLM)
|
||||
ocr review --preview
|
||||
ocr review -c abc123 -p
|
||||
|
|
@ -415,6 +434,13 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则
|
|||
|
||||
| 键 | 类型 | 示例 |
|
||||
|----|------|------|
|
||||
| `provider` | string | `anthropic` \| `openai` \| `dashscope` \| `deepseek` \| `z-ai` |
|
||||
| `providers.<name>.api_key` | string | 供应商 API 密钥 |
|
||||
| `providers.<name>.url` | string | 供应商 Base URL 覆盖 |
|
||||
| `providers.<name>.protocol` | string | `anthropic` \| `openai` |
|
||||
| `providers.<name>.model` | string | 供应商模型名称 |
|
||||
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
|
||||
| `custom_providers.<name>.*` | — | 与 `providers.<name>.*` 相同的字段 |
|
||||
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
|
||||
| `llm.auth_token` | string | `sk-xxxxxxx` |
|
||||
| `llm.auth_header` | string | 仅 Anthropic:`x-api-key` \| `authorization` |
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/llm"
|
||||
)
|
||||
|
|
@ -25,6 +26,19 @@ func runConfig(args []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "provider":
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("config provider does not accept arguments; use 'ocr config set provider <name>' for non-interactive setup")
|
||||
}
|
||||
return runConfigProvider()
|
||||
case "model":
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("config model does not accept arguments; use 'ocr config set model <name>' for non-interactive setup")
|
||||
}
|
||||
return runConfigModel()
|
||||
}
|
||||
|
||||
action, err := parseConfigArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -53,29 +67,38 @@ func runConfigSet(key, value string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
displayValue := value
|
||||
normalizedKey := strings.ToLower(strings.ReplaceAll(key, "_", ""))
|
||||
if strings.HasSuffix(normalizedKey, "apikey") || strings.HasSuffix(normalizedKey, "authtoken") {
|
||||
displayValue = maskKey(value)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Set %s = %s\n", key, value)
|
||||
fmt.Printf("Set %s = %s\n", key, displayValue)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderEntry holds per-provider configuration in the providers map.
|
||||
type ProviderEntry struct {
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
AuthHeader string `json:"auth_header,omitempty"`
|
||||
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
||||
}
|
||||
|
||||
// Config represents the user-level configuration file (~/.opencodereview/config.json).
|
||||
type Config struct {
|
||||
Llm LlmConfig `json:"llm,omitempty"`
|
||||
Language string `json:"language,omitempty"` // Output language, defaults to Chinese when empty
|
||||
Telemetry *TelemetryConfig `json:"telemetry,omitempty"` // Telemetry/observability settings
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Providers map[string]ProviderEntry `json:"providers,omitempty"`
|
||||
CustomProviders map[string]ProviderEntry `json:"custom_providers,omitempty"`
|
||||
Llm LlmConfig `json:"llm,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Telemetry *TelemetryConfig `json:"telemetry,omitempty"`
|
||||
}
|
||||
|
||||
type LlmConfig struct {
|
||||
|
|
@ -127,7 +150,55 @@ func LoadAppConfig(path string) (*Config, error) {
|
|||
}
|
||||
|
||||
func setConfigValue(cfg *Config, key, value string) error {
|
||||
// Handle providers.<name>.<field> paths.
|
||||
if strings.HasPrefix(key, "providers.") {
|
||||
return setProviderValue(cfg, key, value)
|
||||
}
|
||||
if strings.HasPrefix(key, "custom_providers.") {
|
||||
return setCustomProviderValue(cfg, key, value)
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "provider":
|
||||
if cfg.Provider != value {
|
||||
cfg.Model = ""
|
||||
}
|
||||
cfg.Provider = value
|
||||
if _, isPreset := llm.LookupProvider(value); isPreset {
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = make(map[string]ProviderEntry)
|
||||
}
|
||||
if _, exists := cfg.Providers[value]; !exists {
|
||||
cfg.Providers[value] = ProviderEntry{}
|
||||
}
|
||||
} else {
|
||||
if cfg.CustomProviders == nil {
|
||||
cfg.CustomProviders = make(map[string]ProviderEntry)
|
||||
}
|
||||
if _, exists := cfg.CustomProviders[value]; !exists {
|
||||
cfg.CustomProviders[value] = ProviderEntry{}
|
||||
}
|
||||
}
|
||||
case "model":
|
||||
if cfg.Provider != "" {
|
||||
if _, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = make(map[string]ProviderEntry)
|
||||
}
|
||||
entry := cfg.Providers[cfg.Provider]
|
||||
entry.Model = value
|
||||
cfg.Providers[cfg.Provider] = entry
|
||||
} else {
|
||||
if cfg.CustomProviders == nil {
|
||||
cfg.CustomProviders = make(map[string]ProviderEntry)
|
||||
}
|
||||
entry := cfg.CustomProviders[cfg.Provider]
|
||||
entry.Model = value
|
||||
cfg.CustomProviders[cfg.Provider] = entry
|
||||
}
|
||||
} else {
|
||||
cfg.Model = value
|
||||
}
|
||||
case "llm.url", "llm.URL":
|
||||
cfg.Llm.URL = value
|
||||
case "llm.auth_token", "llm.AuthToken":
|
||||
|
|
@ -175,11 +246,74 @@ func setConfigValue(cfg *Config, key, value string) error {
|
|||
}
|
||||
cfg.Llm.ExtraBody = m
|
||||
default:
|
||||
return fmt.Errorf("unknown config key: %s\nSupported keys: llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging", key)
|
||||
return fmt.Errorf("unknown config key: %s\nSupported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyProviderField(entry *ProviderEntry, field, key, value string) error {
|
||||
switch field {
|
||||
case "api_key":
|
||||
entry.APIKey = value
|
||||
case "url":
|
||||
entry.URL = value
|
||||
case "protocol":
|
||||
if value != "anthropic" && value != "openai" {
|
||||
return fmt.Errorf("invalid protocol %q: must be \"anthropic\" or \"openai\"", value)
|
||||
}
|
||||
entry.Protocol = value
|
||||
case "model":
|
||||
entry.Model = value
|
||||
case "auth_header":
|
||||
normalized, err := llm.NormalizeAuthHeader(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.AuthHeader = normalized
|
||||
case "extra_body":
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(value), &m); err != nil {
|
||||
return fmt.Errorf("invalid JSON for %s: %w", key, err)
|
||||
}
|
||||
entry.ExtraBody = m
|
||||
default:
|
||||
return fmt.Errorf("unknown provider field %q: supported fields are api_key, url, protocol, model, auth_header, extra_body", field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setProviderValue(cfg *Config, key, value string) error {
|
||||
parts := strings.SplitN(key, ".", 3)
|
||||
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
|
||||
return fmt.Errorf("invalid provider key %q: expected providers.<name>.<field>", key)
|
||||
}
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = make(map[string]ProviderEntry)
|
||||
}
|
||||
entry := cfg.Providers[parts[1]]
|
||||
if err := applyProviderField(&entry, parts[2], key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Providers[parts[1]] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func setCustomProviderValue(cfg *Config, key, value string) error {
|
||||
parts := strings.SplitN(key, ".", 3)
|
||||
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
|
||||
return fmt.Errorf("invalid custom provider key %q: expected custom_providers.<name>.<field>", key)
|
||||
}
|
||||
if cfg.CustomProviders == nil {
|
||||
cfg.CustomProviders = make(map[string]ProviderEntry)
|
||||
}
|
||||
entry := cfg.CustomProviders[parts[1]]
|
||||
if err := applyProviderField(&entry, parts[2], key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.CustomProviders[parts[1]] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) ensureTelemetry() {
|
||||
if c.Telemetry == nil {
|
||||
c.Telemetry = &TelemetryConfig{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ func parseConfigArgs(args []string) (configAction, error) {
|
|||
value: args[2],
|
||||
}, nil
|
||||
default:
|
||||
return configAction{}, fmt.Errorf("unknown config sub-command: %s\nAvailable: set", subCmd)
|
||||
return configAction{}, fmt.Errorf("unknown config sub-command: %s\nAvailable: set, provider, model", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,8 +258,29 @@ func printConfigUsage() {
|
|||
|
||||
Usage:
|
||||
ocr config set <key> <value>
|
||||
ocr config provider Interactive provider setup
|
||||
ocr config model Interactive model selection
|
||||
|
||||
Examples:
|
||||
# Provider setup (interactive)
|
||||
ocr config provider
|
||||
ocr config model
|
||||
|
||||
# Provider setup (non-interactive)
|
||||
ocr config set provider anthropic
|
||||
ocr config set model claude-opus-4-6
|
||||
# Set API key via environment variable (recommended) or config:
|
||||
# export ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
ocr config set providers.anthropic.api_key "$ANTHROPIC_API_KEY"
|
||||
|
||||
# Custom provider
|
||||
ocr config set provider my-gateway
|
||||
ocr config set providers.my-gateway.url https://gateway.internal.com/v1
|
||||
ocr config set providers.my-gateway.protocol openai
|
||||
ocr config set providers.my-gateway.model llama-3-70b
|
||||
ocr config set providers.my-gateway.api_key "$MY_API_KEY"
|
||||
|
||||
# Legacy endpoint configuration
|
||||
ocr config set llm.url https://xx/v1/openai/chat/completions
|
||||
ocr config set llm.auth_token xxxxxxxxxx
|
||||
ocr config set llm.auth_header x-api-key
|
||||
|
|
@ -268,5 +289,5 @@ Examples:
|
|||
ocr config set language English
|
||||
ocr config set telemetry.enabled true
|
||||
|
||||
Supported keys: llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging`)
|
||||
Supported keys: provider, model, providers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/config/template"
|
||||
"github.com/open-code-review/open-code-review/internal/config/testconnection"
|
||||
"github.com/open-code-review/open-code-review/internal/llm"
|
||||
)
|
||||
|
|
@ -19,6 +18,9 @@ func runLLM(args []string) error {
|
|||
switch args[0] {
|
||||
case "test":
|
||||
return runLLMTest()
|
||||
case "providers":
|
||||
runLLMProviders()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown llm sub-command: %s\nRun 'ocr llm' for usage", args[0])
|
||||
}
|
||||
|
|
@ -53,11 +55,6 @@ func runLLMTest() error {
|
|||
timeout = time.Duration(task.Timeout) * time.Second
|
||||
}
|
||||
|
||||
tpl, err := template.LoadDefault()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load default template: %w", err)
|
||||
}
|
||||
|
||||
llmClient := llm.NewLLMClient(ep)
|
||||
|
||||
messages := make([]llm.Message, 0, len(task.Messages))
|
||||
|
|
@ -71,7 +68,7 @@ func runLLMTest() error {
|
|||
return llmClient.CompletionsWithCtx(ctx, llm.ChatRequest{
|
||||
Model: ep.Model,
|
||||
Messages: messages,
|
||||
MaxTokens: tpl.MaxTokens,
|
||||
MaxTokens: 256,
|
||||
})
|
||||
}()
|
||||
if err != nil {
|
||||
|
|
@ -89,6 +86,18 @@ func runLLMTest() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func runLLMProviders() {
|
||||
providers := llm.ListProviders()
|
||||
fmt.Println("\nBuilt-in providers:")
|
||||
fmt.Printf(" %-14s %-10s %s\n", "NAME", "PROTOCOL", "BASE URL")
|
||||
fmt.Printf(" %-14s %-10s %s\n", "----", "--------", "--------")
|
||||
for _, p := range providers {
|
||||
fmt.Printf(" %-14s %-10s %s\n", p.Name, p.Protocol, p.BaseURL)
|
||||
}
|
||||
fmt.Println("\nUse 'ocr config provider' to configure a provider interactively.")
|
||||
fmt.Println("Use 'ocr config set provider <name>' to switch providers non-interactively.")
|
||||
}
|
||||
|
||||
func printLLMUsage() {
|
||||
fmt.Println(`LLM utility commands.
|
||||
|
||||
|
|
@ -97,7 +106,9 @@ Usage:
|
|||
|
||||
Sub-commands:
|
||||
test Send a test conversation to the configured LLM model
|
||||
providers List all built-in LLM providers
|
||||
|
||||
Examples:
|
||||
ocr llm test Verify LLM connectivity and configuration`)
|
||||
ocr llm test Verify LLM connectivity and configuration
|
||||
ocr llm providers List available built-in providers`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
272
cmd/opencodereview/provider_cmd.go
Normal file
272
cmd/opencodereview/provider_cmd.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/llm"
|
||||
)
|
||||
|
||||
func runConfigProvider() error {
|
||||
configPath, err := defaultConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := loadOrCreateConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
m := newProviderTUI(cfg)
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("TUI error: %w", err)
|
||||
}
|
||||
|
||||
final := finalModel.(providerTUIModel)
|
||||
if !final.confirmed {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
result := final.result()
|
||||
|
||||
if result.isManual {
|
||||
return applyManualConfig(configPath, cfg, result)
|
||||
}
|
||||
|
||||
if result.isCustom {
|
||||
return applyCustomProviderConfig(configPath, cfg, result)
|
||||
}
|
||||
|
||||
return applyOfficialProviderConfig(configPath, cfg, result)
|
||||
}
|
||||
|
||||
func applyManualConfig(configPath string, cfg *Config, result providerTUIResult) error {
|
||||
if result.url == "" {
|
||||
return fmt.Errorf("URL is required for manual configuration")
|
||||
}
|
||||
if result.model == "" {
|
||||
return fmt.Errorf("model is required for manual configuration")
|
||||
}
|
||||
|
||||
cfg.Provider = ""
|
||||
cfg.Model = ""
|
||||
cfg.Llm.URL = result.url
|
||||
cfg.Llm.Model = result.model
|
||||
cfg.Llm.AuthToken = result.apiKey
|
||||
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\nManual configuration saved.")
|
||||
fmt.Printf("URL: %s\n", result.url)
|
||||
fmt.Printf("Model: %s\n", result.model)
|
||||
|
||||
fmt.Println("\nTesting connection...")
|
||||
if err := runLLMTest(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "Configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyCustomProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
|
||||
if result.provider == "" {
|
||||
return fmt.Errorf("provider name is required")
|
||||
}
|
||||
if result.model == "" {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
|
||||
if cfg.CustomProviders == nil {
|
||||
cfg.CustomProviders = make(map[string]ProviderEntry)
|
||||
}
|
||||
|
||||
entry := cfg.CustomProviders[result.provider]
|
||||
entry.Model = result.model
|
||||
if result.url != "" {
|
||||
entry.URL = result.url
|
||||
}
|
||||
if result.protocol != "" {
|
||||
entry.Protocol = result.protocol
|
||||
}
|
||||
if result.authHeader != "" {
|
||||
entry.AuthHeader = result.authHeader
|
||||
}
|
||||
if result.apiKey != "" {
|
||||
entry.APIKey = result.apiKey
|
||||
}
|
||||
cfg.CustomProviders[result.provider] = entry
|
||||
|
||||
if cfg.Provider != result.provider {
|
||||
cfg.Model = ""
|
||||
}
|
||||
cfg.Provider = result.provider
|
||||
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\nProvider set to: %s (custom)\n", result.provider)
|
||||
fmt.Printf("Model: %s\n", result.model)
|
||||
|
||||
fmt.Println("\nTesting connection...")
|
||||
if err := runLLMTest(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "Provider configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
|
||||
if result.provider == "" || result.model == "" {
|
||||
return fmt.Errorf("provider and model are required")
|
||||
}
|
||||
|
||||
preset, isPreset := llm.LookupProvider(result.provider)
|
||||
|
||||
if result.apiKey == "" {
|
||||
if isPreset && preset.EnvVar != "" {
|
||||
if os.Getenv(preset.EnvVar) == "" {
|
||||
return fmt.Errorf("API key is required for provider %s (configure it or set $%s)", result.provider, preset.EnvVar)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("API key is required for provider %s", result.provider)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = make(map[string]ProviderEntry)
|
||||
}
|
||||
|
||||
entry := cfg.Providers[result.provider]
|
||||
entry.Model = result.model
|
||||
if result.apiKey != "" {
|
||||
entry.APIKey = result.apiKey
|
||||
}
|
||||
cfg.Providers[result.provider] = entry
|
||||
|
||||
if cfg.Provider != result.provider {
|
||||
cfg.Model = ""
|
||||
}
|
||||
cfg.Provider = result.provider
|
||||
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\nProvider set to: %s\n", result.provider)
|
||||
fmt.Printf("Model: %s\n", result.model)
|
||||
|
||||
fmt.Println("\nTesting connection...")
|
||||
if err := runLLMTest(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "Provider configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigModel() error {
|
||||
configPath, err := defaultConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := loadOrCreateConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Provider == "" {
|
||||
return fmt.Errorf("no provider configured. Run 'ocr config provider' first")
|
||||
}
|
||||
|
||||
preset, isPreset := llm.LookupProvider(cfg.Provider)
|
||||
if !isPreset {
|
||||
return fmt.Errorf("provider %q is not a preset provider; use 'ocr config set providers.%s.model <name>' instead", cfg.Provider, cfg.Provider)
|
||||
}
|
||||
|
||||
currentModel := ""
|
||||
if entry, ok := cfg.Providers[cfg.Provider]; ok {
|
||||
currentModel = entry.Model
|
||||
}
|
||||
if currentModel == "" {
|
||||
currentModel = cfg.Model
|
||||
}
|
||||
|
||||
m := newModelTUI(preset, currentModel)
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("TUI error: %w", err)
|
||||
}
|
||||
|
||||
final := finalModel.(modelTUIModel)
|
||||
if final.cancelled {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
selectedModel := final.selectedModel()
|
||||
if selectedModel == "" {
|
||||
return fmt.Errorf("model name cannot be empty")
|
||||
}
|
||||
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = make(map[string]ProviderEntry)
|
||||
}
|
||||
entry := cfg.Providers[cfg.Provider]
|
||||
entry.Model = selectedModel
|
||||
cfg.Providers[cfg.Provider] = entry
|
||||
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\nModel set to: %s\n", selectedModel)
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveConfig(path string, cfg *Config) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return fmt.Errorf("chmod config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maskKey(key string) string {
|
||||
if key == "" {
|
||||
return "(not set)"
|
||||
}
|
||||
if len(key) <= 8 {
|
||||
return "***"
|
||||
}
|
||||
return key[:4] + "***" + key[len(key)-4:]
|
||||
}
|
||||
1337
cmd/opencodereview/provider_tui.go
Normal file
1337
cmd/opencodereview/provider_tui.go
Normal file
File diff suppressed because it is too large
Load diff
514
cmd/opencodereview/provider_tui_test.go
Normal file
514
cmd/opencodereview/provider_tui_test.go
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
func escKey() tea.KeyPressMsg {
|
||||
return tea.KeyPressMsg{Code: tea.KeyEscape}
|
||||
}
|
||||
|
||||
func enterKey() tea.KeyPressMsg {
|
||||
return tea.KeyPressMsg{Code: tea.KeyEnter}
|
||||
}
|
||||
|
||||
func leftKey() tea.KeyPressMsg {
|
||||
return tea.KeyPressMsg{Code: tea.KeyLeft}
|
||||
}
|
||||
|
||||
func rightKey() tea.KeyPressMsg {
|
||||
return tea.KeyPressMsg{Code: tea.KeyRight}
|
||||
}
|
||||
|
||||
func downKey() tea.KeyPressMsg {
|
||||
return tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
}
|
||||
|
||||
func tabKeyMsg() tea.KeyPressMsg {
|
||||
return tea.KeyPressMsg{Code: tea.KeyTab}
|
||||
}
|
||||
|
||||
func charKey(c rune) tea.KeyPressMsg {
|
||||
return tea.KeyPressMsg{Code: c}
|
||||
}
|
||||
|
||||
// --- Tab switching tests ---
|
||||
|
||||
func TestProviderTUI_TabSwitchRight(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
if m.activeTab != tabOfficial {
|
||||
t.Fatalf("initial tab = %d, want %d", m.activeTab, tabOfficial)
|
||||
}
|
||||
|
||||
result, _ := m.Update(rightKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
if m2.activeTab != tabCustom {
|
||||
t.Errorf("after right, tab = %d, want %d", m2.activeTab, tabCustom)
|
||||
}
|
||||
|
||||
result, _ = m2.Update(rightKey())
|
||||
m3 := result.(providerTUIModel)
|
||||
if m3.activeTab != tabManual {
|
||||
t.Errorf("after 2x right, tab = %d, want %d", m3.activeTab, tabManual)
|
||||
}
|
||||
|
||||
// Should not go past last tab
|
||||
result, _ = m3.Update(rightKey())
|
||||
m4 := result.(providerTUIModel)
|
||||
if m4.activeTab != tabManual {
|
||||
t.Errorf("after 3x right, tab = %d, want %d (should clamp)", m4.activeTab, tabManual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_TabSwitchLeft(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
|
||||
// Go to manual tab first
|
||||
result, _ := m.Update(rightKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
result, _ = m2.Update(rightKey())
|
||||
m3 := result.(providerTUIModel)
|
||||
if m3.activeTab != tabManual {
|
||||
t.Fatalf("setup: tab = %d, want %d", m3.activeTab, tabManual)
|
||||
}
|
||||
|
||||
result, _ = m3.Update(leftKey())
|
||||
m4 := result.(providerTUIModel)
|
||||
if m4.activeTab != tabCustom {
|
||||
t.Errorf("after left, tab = %d, want %d", m4.activeTab, tabCustom)
|
||||
}
|
||||
|
||||
result, _ = m4.Update(leftKey())
|
||||
m5 := result.(providerTUIModel)
|
||||
if m5.activeTab != tabOfficial {
|
||||
t.Errorf("after 2x left, tab = %d, want %d", m5.activeTab, tabOfficial)
|
||||
}
|
||||
|
||||
// Should not go past first tab
|
||||
result, _ = m5.Update(leftKey())
|
||||
m6 := result.(providerTUIModel)
|
||||
if m6.activeTab != tabOfficial {
|
||||
t.Errorf("after 3x left, tab = %d, want %d (should clamp)", m6.activeTab, tabOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_TabKeyCycles(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
|
||||
result, _ := m.Update(tabKeyMsg())
|
||||
m2 := result.(providerTUIModel)
|
||||
if m2.activeTab != tabCustom {
|
||||
t.Errorf("after tab, tab = %d, want %d", m2.activeTab, tabCustom)
|
||||
}
|
||||
|
||||
result, _ = m2.Update(tabKeyMsg())
|
||||
m3 := result.(providerTUIModel)
|
||||
if m3.activeTab != tabManual {
|
||||
t.Errorf("after 2x tab, tab = %d, want %d", m3.activeTab, tabManual)
|
||||
}
|
||||
|
||||
result, _ = m3.Update(tabKeyMsg())
|
||||
m4 := result.(providerTUIModel)
|
||||
if m4.activeTab != tabOfficial {
|
||||
t.Errorf("after 3x tab, tab = %d, want %d (should wrap)", m4.activeTab, tabOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_TabSwitchOnlyOnStepProvider(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
|
||||
// Advance to stepModel
|
||||
result, _ := m.Update(enterKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
if m2.step != stepModel {
|
||||
t.Fatalf("step = %d, want %d", m2.step, stepModel)
|
||||
}
|
||||
|
||||
// Tab keys should not change tab
|
||||
result, _ = m2.Update(rightKey())
|
||||
m3 := result.(providerTUIModel)
|
||||
if m3.activeTab != tabOfficial {
|
||||
t.Errorf("right on stepModel should not change tab: got %d", m3.activeTab)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Official tab tests (updated from original) ---
|
||||
|
||||
func TestProviderTUI_EscFromModelGoesBackToProvider(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
|
||||
result, _ := m.Update(enterKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
if m2.step != stepModel {
|
||||
t.Fatalf("after Enter, step = %d, want %d (stepModel)", m2.step, stepModel)
|
||||
}
|
||||
|
||||
result, _ = m2.Update(escKey())
|
||||
m3 := result.(providerTUIModel)
|
||||
if m3.step != stepProvider {
|
||||
t.Errorf("after Esc on stepModel, step = %d, want %d (stepProvider)", m3.step, stepProvider)
|
||||
}
|
||||
if m3.cancelled {
|
||||
t.Error("should not be cancelled when going back from stepModel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_EscFromAPIKeyGoesBackToModel(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
|
||||
result, _ := m.Update(enterKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
|
||||
result, _ = m2.Update(enterKey())
|
||||
m3 := result.(providerTUIModel)
|
||||
if m3.step != stepAPIKey {
|
||||
t.Fatalf("after 2x Enter, step = %d, want %d (stepAPIKey)", m3.step, stepAPIKey)
|
||||
}
|
||||
|
||||
result, _ = m3.Update(escKey())
|
||||
m4 := result.(providerTUIModel)
|
||||
if m4.step != stepModel {
|
||||
t.Errorf("after Esc on stepAPIKey, step = %d, want %d (stepModel)", m4.step, stepModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_EscFromProviderCancels(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
|
||||
result, cmd := m.Update(escKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
if !m2.cancelled {
|
||||
t.Error("Esc on stepProvider should set cancelled = true")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("Esc on stepProvider should return tea.Quit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_EscKeyString(t *testing.T) {
|
||||
esc := escKey()
|
||||
if s := esc.String(); s != "esc" {
|
||||
t.Errorf("escape key String() = %q, want %q", s, "esc")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Manual tab tests ---
|
||||
|
||||
func TestProviderTUI_ManualTabEnterStartsForm(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
|
||||
// Switch to manual tab
|
||||
result, _ := m.Update(rightKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
result, _ = m2.Update(rightKey())
|
||||
m3 := result.(providerTUIModel)
|
||||
if m3.activeTab != tabManual {
|
||||
t.Fatalf("tab = %d, want %d", m3.activeTab, tabManual)
|
||||
}
|
||||
|
||||
// Press Enter to start form
|
||||
result, _ = m3.Update(enterKey())
|
||||
m4 := result.(providerTUIModel)
|
||||
if !m4.inManualForm {
|
||||
t.Error("Enter on manual tab should set inManualForm = true")
|
||||
}
|
||||
if m4.manualStep != manualStepURL {
|
||||
t.Errorf("manualStep = %d, want %d", m4.manualStep, manualStepURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_ManualFormEscFromURLExitsForm(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
|
||||
// Switch to manual tab and enter form
|
||||
result, _ := m.Update(rightKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
result, _ = m2.Update(rightKey())
|
||||
m3 := result.(providerTUIModel)
|
||||
result, _ = m3.Update(enterKey())
|
||||
m4 := result.(providerTUIModel)
|
||||
if !m4.inManualForm {
|
||||
t.Fatalf("should be in manual form")
|
||||
}
|
||||
|
||||
// Esc should exit form, not cancel
|
||||
result, _ = m4.Update(escKey())
|
||||
m5 := result.(providerTUIModel)
|
||||
if m5.inManualForm {
|
||||
t.Error("Esc from URL step should exit form")
|
||||
}
|
||||
if m5.cancelled {
|
||||
t.Error("should not be cancelled when exiting form")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_ManualFormEscRestoresOriginalValues(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Llm: LlmConfig{
|
||||
URL: "https://example.com/v1",
|
||||
Model: "test-model",
|
||||
AuthToken: "token-123",
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
|
||||
// Enter the form
|
||||
result, _ := m.Update(enterKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
if !m2.inManualForm {
|
||||
t.Fatalf("should be in manual form")
|
||||
}
|
||||
|
||||
// Simulate editing by directly modifying the input value
|
||||
m2.manualURLInput.SetValue("https://modified.example.com")
|
||||
|
||||
// Esc should restore original values
|
||||
result, _ = m2.Update(escKey())
|
||||
m3 := result.(providerTUIModel)
|
||||
if m3.inManualForm {
|
||||
t.Error("should have exited form")
|
||||
}
|
||||
if m3.manualURLInput.Value() != "https://example.com/v1" {
|
||||
t.Errorf("URL not restored: got %q, want %q", m3.manualURLInput.Value(), "https://example.com/v1")
|
||||
}
|
||||
if m3.manualModelInput.Value() != "test-model" {
|
||||
t.Errorf("Model not restored: got %q, want %q", m3.manualModelInput.Value(), "test-model")
|
||||
}
|
||||
if m3.manualTokenInput.Value() != "token-123" {
|
||||
t.Errorf("Token not restored: got %q, want %q", m3.manualTokenInput.Value(), "token-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_ManualFormPrefilledValues(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Llm: LlmConfig{
|
||||
URL: "https://example.com/v1",
|
||||
Model: "test-model",
|
||||
AuthToken: "token-123",
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
|
||||
if m.activeTab != tabManual {
|
||||
t.Fatalf("should auto-select manual tab when Llm.URL is set, got %d", m.activeTab)
|
||||
}
|
||||
if m.manualURLInput.Value() != "https://example.com/v1" {
|
||||
t.Errorf("URL not prefilled: got %q", m.manualURLInput.Value())
|
||||
}
|
||||
if m.manualModelInput.Value() != "test-model" {
|
||||
t.Errorf("Model not prefilled: got %q", m.manualModelInput.Value())
|
||||
}
|
||||
if m.manualTokenInput.Value() != "token-123" {
|
||||
t.Errorf("Token not prefilled: got %q", m.manualTokenInput.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_ManualResult(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Llm: LlmConfig{
|
||||
URL: "https://example.com/v1",
|
||||
Model: "test-model",
|
||||
AuthToken: "token-123",
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
|
||||
// Enter the form
|
||||
result, _ := m.Update(enterKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
m2.confirmed = true
|
||||
|
||||
r := m2.result()
|
||||
if !r.isManual {
|
||||
t.Error("result should have isManual = true")
|
||||
}
|
||||
if r.url != "https://example.com/v1" {
|
||||
t.Errorf("result url = %q, want %q", r.url, "https://example.com/v1")
|
||||
}
|
||||
if r.model != "test-model" {
|
||||
t.Errorf("result model = %q, want %q", r.model, "test-model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_ManualFormPrefilledWhenProviderSet(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Provider: "my-gateway",
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai", Model: "llama-3"},
|
||||
},
|
||||
Llm: LlmConfig{
|
||||
URL: "https://manual.example.com/v1",
|
||||
Model: "manual-model",
|
||||
AuthToken: "manual-token",
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
|
||||
if m.activeTab != tabCustom {
|
||||
t.Fatalf("should auto-select custom tab, got %d", m.activeTab)
|
||||
}
|
||||
if m.manualURLInput.Value() != "https://manual.example.com/v1" {
|
||||
t.Errorf("URL not prefilled: got %q", m.manualURLInput.Value())
|
||||
}
|
||||
if m.manualModelInput.Value() != "manual-model" {
|
||||
t.Errorf("Model not prefilled: got %q", m.manualModelInput.Value())
|
||||
}
|
||||
if m.manualTokenInput.Value() != "manual-token" {
|
||||
t.Errorf("Token not prefilled: got %q", m.manualTokenInput.Value())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Custom tab tests ---
|
||||
|
||||
func TestProviderTUI_CustomTabShowsAddOption(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
|
||||
// Switch to custom tab
|
||||
result, _ := m.Update(rightKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
if m2.activeTab != tabCustom {
|
||||
t.Fatalf("tab = %d, want %d", m2.activeTab, tabCustom)
|
||||
}
|
||||
|
||||
// With no custom providers, only "Add" option exists at index 0
|
||||
if m2.customListCount() != 1 {
|
||||
t.Errorf("customListCount() = %d, want 1 (only add option)", m2.customListCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_CustomTabSelectAddStartsForm(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
|
||||
// Switch to custom tab
|
||||
result, _ := m.Update(rightKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
|
||||
// Enter on "Add" option
|
||||
result, _ = m2.Update(enterKey())
|
||||
m3 := result.(providerTUIModel)
|
||||
if !m3.creatingCustom {
|
||||
t.Error("Enter on add option should set creatingCustom = true")
|
||||
}
|
||||
if m3.cpStep != cpStepName {
|
||||
t.Errorf("cpStep = %d, want %d", m3.cpStep, cpStepName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_CustomFormEscFromNameExitsForm(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
|
||||
// Switch to custom tab and start form
|
||||
result, _ := m.Update(rightKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
result, _ = m2.Update(enterKey())
|
||||
m3 := result.(providerTUIModel)
|
||||
if !m3.creatingCustom {
|
||||
t.Fatalf("should be creating custom")
|
||||
}
|
||||
|
||||
// Esc from name step should exit form
|
||||
result, _ = m3.Update(escKey())
|
||||
m4 := result.(providerTUIModel)
|
||||
if m4.creatingCustom {
|
||||
t.Error("Esc from name step should exit custom form")
|
||||
}
|
||||
if m4.cancelled {
|
||||
t.Error("should not be cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_CustomProviderExistsInList(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Provider: "my-llm",
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"my-llm": {
|
||||
URL: "https://custom.api/v1",
|
||||
Protocol: "openai",
|
||||
Model: "custom-model",
|
||||
APIKey: "key-123",
|
||||
},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
|
||||
if m.activeTab != tabCustom {
|
||||
t.Fatalf("should auto-select custom tab, got %d", m.activeTab)
|
||||
}
|
||||
if len(m.customProviders) != 1 {
|
||||
t.Fatalf("expected 1 custom provider, got %d", len(m.customProviders))
|
||||
}
|
||||
if m.customProviders[0].name != "my-llm" {
|
||||
t.Errorf("custom provider name = %q, want %q", m.customProviders[0].name, "my-llm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_SelectExistingCustomGoesToAPIKey(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Provider: "my-llm",
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"my-llm": {
|
||||
URL: "https://custom.api/v1",
|
||||
Protocol: "openai",
|
||||
Model: "custom-model",
|
||||
APIKey: "key-123",
|
||||
},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
|
||||
// Enter on existing custom provider should go to API key step
|
||||
result, _ := m.Update(enterKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
if m2.step != stepAPIKey {
|
||||
t.Errorf("step = %d, want %d (stepAPIKey)", m2.step, stepAPIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// --- collectCustomProviders tests ---
|
||||
|
||||
func TestCollectCustomProviders_NilConfig(t *testing.T) {
|
||||
result := collectCustomProviders(nil)
|
||||
if result != nil {
|
||||
t.Errorf("expected nil, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectCustomProviders_ReadsCustomProviders(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: map[string]ProviderEntry{
|
||||
"anthropic": {APIKey: "key1"},
|
||||
"openai": {APIKey: "key2"},
|
||||
},
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"my-custom": {URL: "https://example.com", Protocol: "openai"},
|
||||
},
|
||||
}
|
||||
result := collectCustomProviders(cfg)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 custom provider, got %d", len(result))
|
||||
}
|
||||
if result[0].name != "my-custom" {
|
||||
t.Errorf("name = %q, want %q", result[0].name, "my-custom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectCustomProviders_SortedByName(t *testing.T) {
|
||||
cfg := &Config{
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"zzz-provider": {URL: "https://z.example.com"},
|
||||
"aaa-provider": {URL: "https://a.example.com"},
|
||||
},
|
||||
}
|
||||
result := collectCustomProviders(cfg)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(result))
|
||||
}
|
||||
if result[0].name != "aaa-provider" {
|
||||
t.Errorf("first = %q, want %q", result[0].name, "aaa-provider")
|
||||
}
|
||||
if result[1].name != "zzz-provider" {
|
||||
t.Errorf("second = %q, want %q", result[1].name, "zzz-provider")
|
||||
}
|
||||
}
|
||||
19
go.mod
19
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
|
||||
|
|
|
|||
44
go.sum
44
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=
|
||||
|
|
|
|||
BIN
imgs/providers.jpg
Normal file
BIN
imgs/providers.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
116
internal/llm/providers.go
Normal file
116
internal/llm/providers.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package llm
|
||||
|
||||
import "strings"
|
||||
|
||||
// Provider holds the preset configuration for a known LLM provider.
|
||||
type Provider struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
Protocol string // "anthropic" or "openai"
|
||||
BaseURL string
|
||||
AuthHeader string // Anthropic-only; empty for OpenAI-compatible
|
||||
EnvVar string // environment variable name for API key fallback
|
||||
Models []string
|
||||
}
|
||||
|
||||
var registry = []Provider{
|
||||
{
|
||||
Name: "anthropic",
|
||||
DisplayName: "Anthropic Claude API",
|
||||
Protocol: "anthropic",
|
||||
BaseURL: "https://api.anthropic.com",
|
||||
AuthHeader: "x-api-key",
|
||||
EnvVar: "ANTHROPIC_API_KEY",
|
||||
Models: []string{
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-8",
|
||||
"claude-opus-4-7",
|
||||
"claude-sonnet-4-7",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "openai",
|
||||
DisplayName: "OpenAI API",
|
||||
Protocol: "openai",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
EnvVar: "OPENAI_API_KEY",
|
||||
Models: []string{
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "dashscope",
|
||||
DisplayName: "Alibaba DashScope API",
|
||||
Protocol: "openai",
|
||||
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
EnvVar: "DASHSCOPE_API_KEY",
|
||||
Models: []string{
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.6-flash",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "deepseek",
|
||||
DisplayName: "DeepSeek API",
|
||||
Protocol: "openai",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
EnvVar: "DEEPSEEK_API_KEY",
|
||||
Models: []string{
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "z-ai",
|
||||
DisplayName: "Z.AI API",
|
||||
Protocol: "openai",
|
||||
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
EnvVar: "Z_AI_API_KEY",
|
||||
Models: []string{
|
||||
"glm-5.1",
|
||||
"glm-5-turbo",
|
||||
"glm-4.7",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var registryMap map[string]Provider
|
||||
|
||||
func init() {
|
||||
registryMap = make(map[string]Provider, len(registry))
|
||||
for _, p := range registry {
|
||||
registryMap[strings.ToLower(p.Name)] = p
|
||||
}
|
||||
}
|
||||
|
||||
// LookupProvider returns the preset provider by name.
|
||||
// The returned Provider has its own copy of the Models slice.
|
||||
func LookupProvider(name string) (Provider, bool) {
|
||||
p, ok := registryMap[strings.ToLower(strings.TrimSpace(name))]
|
||||
if ok && p.Models != nil {
|
||||
models := make([]string, len(p.Models))
|
||||
copy(models, p.Models)
|
||||
p.Models = models
|
||||
}
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// ListProviders returns all built-in providers in registration order.
|
||||
// Each returned Provider has its own copy of the Models slice.
|
||||
func ListProviders() []Provider {
|
||||
out := make([]Provider, len(registry))
|
||||
for i, p := range registry {
|
||||
if p.Models != nil {
|
||||
models := make([]string, len(p.Models))
|
||||
copy(models, p.Models)
|
||||
p.Models = models
|
||||
}
|
||||
out[i] = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
97
internal/llm/providers_test.go
Normal file
97
internal/llm/providers_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package llm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLookupProvider_KnownProviders(t *testing.T) {
|
||||
names := []string{"anthropic", "openai", "dashscope"}
|
||||
for _, name := range names {
|
||||
p, ok := LookupProvider(name)
|
||||
if !ok {
|
||||
t.Errorf("LookupProvider(%q) returned false, want true", name)
|
||||
continue
|
||||
}
|
||||
if p.Name != name {
|
||||
t.Errorf("LookupProvider(%q).Name = %q", name, p.Name)
|
||||
}
|
||||
if p.Protocol == "" {
|
||||
t.Errorf("LookupProvider(%q).Protocol is empty", name)
|
||||
}
|
||||
if p.BaseURL == "" {
|
||||
t.Errorf("LookupProvider(%q).BaseURL is empty", name)
|
||||
}
|
||||
if len(p.Models) == 0 {
|
||||
t.Errorf("LookupProvider(%q).Models is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupProvider_Unknown(t *testing.T) {
|
||||
_, ok := LookupProvider("nonexistent-provider")
|
||||
if ok {
|
||||
t.Error("LookupProvider(nonexistent) returned true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProviders_Order(t *testing.T) {
|
||||
providers := ListProviders()
|
||||
if len(providers) < 3 {
|
||||
t.Fatalf("expected at least 3 providers, got %d", len(providers))
|
||||
}
|
||||
expected := []string{"anthropic", "openai", "dashscope"}
|
||||
for i, name := range expected {
|
||||
if providers[i].Name != name {
|
||||
t.Errorf("providers[%d].Name = %q, want %q", i, providers[i].Name, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProviders_ReturnsCopy(t *testing.T) {
|
||||
p1 := ListProviders()
|
||||
p1[0].Name = "mutated"
|
||||
|
||||
p2 := ListProviders()
|
||||
if p2[0].Name == "mutated" {
|
||||
t.Error("ListProviders returns a reference to the registry, should return a copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupProvider_ReturnsCopyOfModels(t *testing.T) {
|
||||
p1, _ := LookupProvider("anthropic")
|
||||
p1.Models[0] = "mutated"
|
||||
|
||||
p2, _ := LookupProvider("anthropic")
|
||||
if p2.Models[0] == "mutated" {
|
||||
t.Error("LookupProvider returns a reference to Models slice, should return a copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupProvider_AnthropicDetails(t *testing.T) {
|
||||
p, ok := LookupProvider("anthropic")
|
||||
if !ok {
|
||||
t.Fatal("anthropic not found")
|
||||
}
|
||||
if p.Protocol != "anthropic" {
|
||||
t.Errorf("Protocol = %q, want %q", p.Protocol, "anthropic")
|
||||
}
|
||||
if p.AuthHeader != "x-api-key" {
|
||||
t.Errorf("AuthHeader = %q, want %q", p.AuthHeader, "x-api-key")
|
||||
}
|
||||
if p.EnvVar != "ANTHROPIC_API_KEY" {
|
||||
t.Errorf("EnvVar = %q, want %q", p.EnvVar, "ANTHROPIC_API_KEY")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupProvider_OpenAIDetails(t *testing.T) {
|
||||
p, ok := LookupProvider("openai")
|
||||
if !ok {
|
||||
t.Fatal("openai not found")
|
||||
}
|
||||
if p.Protocol != "openai" {
|
||||
t.Errorf("Protocol = %q, want %q", p.Protocol, "openai")
|
||||
}
|
||||
if p.AuthHeader != "" {
|
||||
t.Errorf("AuthHeader = %q, want empty", p.AuthHeader)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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` }
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -82,9 +82,9 @@ export const en: TranslationKeys = {
|
|||
'quickstart.step1Label1': 'Install',
|
||||
'quickstart.step1Label2': 'Verify Installation',
|
||||
'quickstart.step2Title': 'Configure',
|
||||
'quickstart.step2Desc': 'Configure your LLM info. Supports Anthropic and OpenAI-compatible protocols. Config file at ~/.opencodereview/config.json.',
|
||||
'quickstart.step2Label1': 'Configure LLM',
|
||||
'quickstart.step2Label2': 'Configure Language (default English)',
|
||||
'quickstart.step2Desc': 'Set up your LLM provider interactively, or configure manually. Built-in support for Anthropic, OpenAI, DashScope, DeepSeek, and Z.AI.',
|
||||
'quickstart.step2Label1': 'Interactive Setup (Recommended)',
|
||||
'quickstart.step2Label2': 'Manual Config (Alternative)',
|
||||
'quickstart.step2Label3': 'Verify Configuration',
|
||||
'quickstart.step3Title': 'Run Review',
|
||||
'quickstart.step3Desc': 'Start your first code review.',
|
||||
|
|
@ -117,6 +117,13 @@ export const en: TranslationKeys = {
|
|||
|
||||
'docs.configTitle': 'Configuration & Verification',
|
||||
'docs.configDesc': 'Manage CLI configuration settings, stored in <code>~/.opencodereview/config.json</code>.',
|
||||
'docs.configInteractive': 'Interactive Setup (Recommended)',
|
||||
'docs.configInteractiveDesc': 'The easiest way to configure Open Code Review. Select from built-in providers or add a custom one.',
|
||||
'docs.configModelSelect': 'Model Selection',
|
||||
'docs.configModelSelectDesc': 'After choosing a provider, pick the model to use.',
|
||||
'docs.configListProviders': 'List Built-in Providers',
|
||||
'docs.configListProvidersDesc': 'View all available built-in LLM providers.',
|
||||
'docs.configManual': 'Manual Configuration',
|
||||
'docs.configCommand': 'Command',
|
||||
'docs.configExample': 'Example',
|
||||
'docs.configKeys': 'Supported Config Keys',
|
||||
|
|
|
|||
|
|
@ -82,9 +82,9 @@ export const zh: TranslationKeys = {
|
|||
'quickstart.step1Label1': '安装',
|
||||
'quickstart.step1Label2': '验证安装',
|
||||
'quickstart.step2Title': '配置',
|
||||
'quickstart.step2Desc': '配置您的 LLM 信息,支持 Anthropic 和 OpenAI 兼容协议。配置文件位于 ~/.opencodereview/config.json。',
|
||||
'quickstart.step2Label1': '配置 LLM',
|
||||
'quickstart.step2Label2': '配置语言(默认英文)',
|
||||
'quickstart.step2Desc': '通过交互式向导设置 LLM 供应商,或手动配置。内置支持 Anthropic、OpenAI、DashScope、DeepSeek 和 Z.AI。',
|
||||
'quickstart.step2Label1': '交互式设置(推荐)',
|
||||
'quickstart.step2Label2': '手动配置(备选)',
|
||||
'quickstart.step2Label3': '验证配置',
|
||||
'quickstart.step3Title': '运行评审',
|
||||
'quickstart.step3Desc': '开始你的第一次代码评审。',
|
||||
|
|
@ -117,6 +117,13 @@ export const zh: TranslationKeys = {
|
|||
|
||||
'docs.configTitle': '配置与验证',
|
||||
'docs.configDesc': '管理 CLI 的配置设置,存储在 <code>~/.opencodereview/config.json</code>。',
|
||||
'docs.configInteractive': '交互式设置(推荐)',
|
||||
'docs.configInteractiveDesc': '最简单的配置方式。从内置供应商中选择,或添加自定义供应商。',
|
||||
'docs.configModelSelect': '模型选择',
|
||||
'docs.configModelSelectDesc': '选择供应商后,选择要使用的模型。',
|
||||
'docs.configListProviders': '查看内置供应商',
|
||||
'docs.configListProvidersDesc': '查看所有可用的内置 LLM 供应商。',
|
||||
'docs.configManual': '手动配置',
|
||||
'docs.configCommand': '命令',
|
||||
'docs.configExample': '示例',
|
||||
'docs.configKeys': '支持的配置 Key',
|
||||
|
|
|
|||
|
|
@ -227,10 +227,43 @@ const DocsPage: React.FC = () => {
|
|||
<DocSection id="config" title={t('docs.configTitle')}>
|
||||
<p className="text-slate-300 leading-relaxed mb-6" dangerouslySetInnerHTML={{ __html: t('docs.configDesc') }} />
|
||||
|
||||
<h3 className="text-lg font-semibold text-white mb-3">{t('docs.configCommand')}</h3>
|
||||
<h3 className="text-lg font-semibold text-white mb-3">{t('docs.configInteractive')}</h3>
|
||||
<p className="text-slate-400 text-sm mb-3">{t('docs.configInteractiveDesc')}</p>
|
||||
<div className="space-y-3 mb-6">
|
||||
<CodeBlock
|
||||
code="ocr config provider"
|
||||
copied={copiedIndex === 'config-provider'}
|
||||
onCopy={() => handleCopy('ocr config provider', 'config-provider')}
|
||||
copyLabel={t('docs.copy')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3 mb-6">
|
||||
<p className="text-slate-500 text-xs font-mono mb-2 ml-1">{t('docs.configModelSelect')}</p>
|
||||
<p className="text-slate-400 text-sm mb-3">{t('docs.configModelSelectDesc')}</p>
|
||||
<CodeBlock
|
||||
code="ocr config model"
|
||||
copied={copiedIndex === 'config-model'}
|
||||
onCopy={() => handleCopy('ocr config model', 'config-model')}
|
||||
copyLabel={t('docs.copy')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3 mb-8">
|
||||
<p className="text-slate-500 text-xs font-mono mb-2 ml-1">{t('docs.configListProviders')}</p>
|
||||
<p className="text-slate-400 text-sm mb-3">{t('docs.configListProvidersDesc')}</p>
|
||||
<CodeBlock
|
||||
code="ocr llm providers"
|
||||
copied={copiedIndex === 'config-providers-list'}
|
||||
onCopy={() => handleCopy('ocr llm providers', 'config-providers-list')}
|
||||
copyLabel={t('docs.copy')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-white mb-3">{t('docs.configManual')}</h3>
|
||||
|
||||
<h4 className="text-base font-medium text-slate-300 mb-3">{t('docs.configCommand')}</h4>
|
||||
<CodeBlock code="ocr config set <key> <value>" />
|
||||
|
||||
<h3 className="text-lg font-semibold text-white mb-3 mt-8">{t('docs.configExample')}</h3>
|
||||
<h4 className="text-base font-medium text-slate-300 mb-3 mt-6">{t('docs.configExample')}</h4>
|
||||
<div className="space-y-3 mb-8">
|
||||
<CodeBlock
|
||||
code={`ocr config set llm.url https://api.anthropic.com \\\n && ocr config set llm.auth_token {{your-api-key}} \\\n && ocr config set llm.model claude-opus-4-6 \\\n && ocr config set llm.use_anthropic true \\\n && ocr config set language Chinese`}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue