feat:add --model flag to override LLM model per review (#122)

This commit is contained in:
Jiale Li 2026-06-16 22:38:52 +08:00 committed by GitHub
parent ded7c6a1e0
commit c673c9475a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 688 additions and 90 deletions

View file

@ -311,6 +311,7 @@ ocr review \
| `--timeout` | — | `10` | 同時実行タスクのタイムアウト(分) |
| `--audience` | — | `human` | `human`(進捗を表示)または`agent`(サマリーのみ) |
| `--background` | `-b` | — | レビューのための任意の要件/ビジネスコンテキスト。`--commit`使用時に未指定の場合、コミットメッセージから自動取得 |
| `--model` | — | — | このレビューでLLMモデルを選択または上書き |
| `--rule` | — | — | カスタムJSONレビュールールへのパス |
| `--max-tools` | — | 組み込み値 | ファイルごとのツール呼び出しラウンドの上限。テンプレートのデフォルトより大きい場合のみ有効 |
| `--max-git-procs` | — | 組み込み値 | gitサブプロセスの最大同時実行数 |
@ -337,6 +338,10 @@ ocr review --from main --to my-feature --concurrency 4
# 特定のコミットを詳細なJSON出力でレビュー
ocr review --commit abc123 --format json --audience agent
# このレビューでモデルを選択またはオーバーライド
ocr review --model claude-opus-4-6
ocr review --commit abc123 --model claude-sonnet-4-6
# 要件コンテキストを提供してより的確なレビューを実施
ocr review --background "ログインAPIにレート制限を追加"
@ -449,8 +454,9 @@ OCRは4層の優先度チェーンを使ってレビュールールを解決し
| `providers.<name>.url` | string | プロバイダーのベースURLオーバーライド |
| `providers.<name>.protocol` | string | `anthropic` \| `openai` |
| `providers.<name>.model` | string | プロバイダーのモデル名 |
| `providers.<name>.models` | array | 対話的選択に使う任意のプロバイダーモデル一覧 |
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
| `custom_providers.<name>.*` | — | `providers.<name>.*`と同じフィールド |
| `custom_providers.<name>.*` | — | 任意の`models`を含む`providers.<name>.*`と同じフィールド |
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
| `llm.auth_token` | string | `sk-xxxxxxx` |
| `llm.auth_header` | string | Anthropicのみ`x-api-key` \| `authorization` |

View file

@ -311,6 +311,7 @@ ocr review \
| `--timeout` | - | `10` | 동시 task timeout(분) |
| `--audience` | - | `human` | `human`(progress 표시) 또는 `agent`(summary only) |
| `--background` | `-b` | - | 리뷰를 위한 선택적 요구사항/비즈니스 컨텍스트. `--commit` 사용 시 미지정이면 commit message에서 자동 추출 |
| `--model` | - | - | 이번 리뷰에서 LLM model 선택 또는 override |
| `--rule` | - | - | custom JSON review rules 경로 |
| `--max-tools` | - | built-in | 파일별 최대 tool call round. template default보다 클 때만 적용 |
| `--max-git-procs` | - | built-in | 최대 동시 git subprocess 수 |
@ -337,6 +338,10 @@ ocr review --from main --to my-feature --concurrency 4
# 특정 commit을 verbose JSON output으로 리뷰
ocr review --commit abc123 --format json --audience agent
# 이번 리뷰에서 model 선택 또는 override
ocr review --model claude-opus-4-6
ocr review --commit abc123 --model claude-sonnet-4-6
# 요구사항 컨텍스트를 제공하여 더 정확한 리뷰 수행
ocr review --background "로그인 API에 rate limiting 추가"
@ -407,8 +412,9 @@ Config file: `~/.opencodereview/config.json`
| `providers.<name>.url` | string | Provider base URL override |
| `providers.<name>.protocol` | string | `anthropic` \| `openai` |
| `providers.<name>.model` | string | Provider의 model 이름 |
| `providers.<name>.models` | array | 대화형 선택에 사용할 optional provider model 목록 |
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
| `custom_providers.<name>.*` | — | `providers.<name>.*`과 동일한 필드 |
| `custom_providers.<name>.*` | — | optional `models`를 포함한 `providers.<name>.*`과 동일한 필드 |
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
| `llm.auth_token` | string | `sk-xxxxxxx` |
| `llm.auth_header` | string | Anthropic only: `x-api-key` \| `authorization` |

View file

@ -313,6 +313,7 @@ See the [`examples/`](./examples/) directory for integration examples:
| `--timeout` | — | `10` | Concurrent task timeout in minutes |
| `--audience` | — | `human` | `human` (show progress) or `agent` (summary only) |
| `--background` | `-b` | — | Optional requirement/business context for the review; auto-filled from commit message when using `--commit` |
| `--model` | — | — | Select or override the LLM model for this review |
| `--rule` | — | — | Path to custom JSON review rules |
| `--max-tools` | — | built-in | Max tool call rounds per file; only takes effect when greater than template default |
| `--max-git-procs` | — | built-in | Max concurrent git subprocesses |
@ -339,6 +340,10 @@ ocr review --from main --to my-feature --concurrency 4
# Review a specific commit with verbose JSON output
ocr review --commit abc123 --format json --audience agent
# Select or override model for this review
ocr review --model claude-opus-4-6
ocr review --commit abc123 --model claude-sonnet-4-6
# Provide requirement context for more targeted review
ocr review --background "Adding rate limiting to the login API"
@ -451,8 +456,9 @@ Config file: `~/.opencodereview/config.json`
| `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>.models` | array | Optional provider model list for interactive selection |
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
| `custom_providers.<name>.*` | — | Same fields as `providers.<name>.*` |
| `custom_providers.<name>.*` | — | Same fields as `providers.<name>.*`, including optional `models` |
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
| `llm.auth_token` | string | `sk-xxxxxxx` |
| `llm.auth_header` | string | Anthropic only: `x-api-key` \| `authorization` |

View file

@ -313,6 +313,7 @@ ocr review \
| `--timeout` | — | `10` | Таймаут конкурентной задачи в минутах |
| `--audience` | — | `human` | `human` (показывать прогресс) или `agent` (только сводка) |
| `--background` | `-b` | — | Необязательный контекст требований/бизнес-логики для ревью; при `--commit` автоматически заполняется из сообщения коммита |
| `--model` | — | — | Выбрать или переопределить LLM-модель для этого ревью |
| `--rule` | — | — | Путь к пользовательским JSON-правилам ревью |
| `--max-tools` | — | встроенное | Максимум раундов вызова инструментов на файл; действует, только если больше значения шаблона по умолчанию |
| `--max-git-procs` | — | встроенное | Максимум одновременных git-подпроцессов |
@ -339,6 +340,10 @@ ocr review --from main --to my-feature --concurrency 4
# Ревью конкретного коммита с подробным JSON-выводом
ocr review --commit abc123 --format json --audience agent
# Выбрать или переопределить модель для этого ревью
ocr review --model claude-opus-4-6
ocr review --commit abc123 --model claude-sonnet-4-6
# Передать контекст требований для более прицельного ревью
ocr review --background "Добавляем rate limiting в API логина"
@ -446,6 +451,14 @@ 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>.models` | array | Необязательный список моделей для интерактивного выбора |
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
| `custom_providers.<name>.*` | — | Те же поля, что и `providers.<name>.*`, включая необязательное `models` |
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
| `llm.auth_token` | string | `sk-xxxxxxx` |
| `llm.auth_header` | string | Только для Anthropic: `x-api-key` \| `authorization` |

View file

@ -311,6 +311,7 @@ ocr review \
| `--timeout` | — | `10` | 并发任务超时时间(分钟) |
| `--audience` | — | `human` | `human`(显示进度)或 `agent`(仅输出摘要) |
| `--background` | `-b` | — | 可选的需求/业务背景信息;使用 `--commit` 时如未指定则自动从 commit message 中提取 |
| `--model` | — | — | 为本次审查选择或覆盖 LLM 模型 |
| `--rule` | — | — | 自定义 JSON 审查规则路径 |
| `--max-tools` | — | 内置默认 | 每个文件的最大工具调用轮次;仅在大于模板默认值时生效 |
| `--max-git-procs` | — | 内置默认 | 最大并发 git 子进程数 |
@ -337,6 +338,10 @@ ocr review --from main --to my-feature --concurrency 4
# 审查特定提交并以 JSON 格式输出详细信息
ocr review --commit abc123 --format json --audience agent
# 为本次审查选择或覆盖模型
ocr review --model claude-opus-4-6
ocr review --commit abc123 --model claude-sonnet-4-6
# 提供需求背景以获得更有针对性的审查
ocr review --background "为登录 API 添加限流"
@ -439,8 +444,9 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则
| `providers.<name>.url` | string | 供应商 Base URL 覆盖 |
| `providers.<name>.protocol` | string | `anthropic` \| `openai` |
| `providers.<name>.model` | string | 供应商模型名称 |
| `providers.<name>.models` | array | 用于交互式选择的可选供应商模型列表 |
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
| `custom_providers.<name>.*` | — | 与 `providers.<name>.*` 相同的字段 |
| `custom_providers.<name>.*` | — | 与 `providers.<name>.*` 相同的字段,包括可选的 `models` |
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
| `llm.auth_token` | string | `sk-xxxxxxx` |
| `llm.auth_header` | string | 仅 Anthropic`x-api-key` \| `authorization` |

View file

@ -86,6 +86,7 @@ type ProviderEntry struct {
URL string `json:"url,omitempty"`
Protocol string `json:"protocol,omitempty"`
Model string `json:"model,omitempty"`
Models []string `json:"models,omitempty"`
AuthHeader string `json:"auth_header,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"`
}
@ -246,7 +247,7 @@ func setConfigValue(cfg *Config, key, value string) error {
}
cfg.Llm.ExtraBody = m
default:
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 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\nProvider fields: api_key, url, protocol, model, models, auth_header, extra_body", key)
}
return nil
}
@ -264,6 +265,12 @@ func applyProviderField(entry *ProviderEntry, field, key, value string) error {
entry.Protocol = value
case "model":
entry.Model = value
case "models":
models, err := parseModelListValue(value)
if err != nil {
return fmt.Errorf("invalid model list for %s: %w", key, err)
}
entry.Models = models
case "auth_header":
normalized, err := llm.NormalizeAuthHeader(value)
if err != nil {
@ -277,16 +284,71 @@ func applyProviderField(entry *ProviderEntry, field, key, value string) error {
}
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 fmt.Errorf("unknown provider field %q: supported fields are api_key, url, protocol, model, models, auth_header, extra_body", field)
}
return nil
}
func parseModelListValue(value string) ([]string, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
if strings.HasPrefix(value, "[") {
var models []string
if err := json.Unmarshal([]byte(value), &models); err == nil {
return normalizeModelList(models), nil
}
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "["), "]"))
}
return normalizeModelList(strings.Split(value, ",")), nil
}
func normalizeModelList(models []string) []string {
out := make([]string, 0, len(models))
seen := make(map[string]struct{}, len(models))
for _, model := range models {
model = strings.TrimSpace(model)
if model == "" {
continue
}
if _, ok := seen[model]; ok {
continue
}
seen[model] = struct{}{}
out = append(out, model)
}
return out
}
func mergeModelLists(lists ...[]string) []string {
var merged []string
for _, list := range lists {
merged = append(merged, list...)
}
return normalizeModelList(merged)
}
func modelListContains(models []string, target string) bool {
target = strings.TrimSpace(target)
for _, model := range models {
if model == target {
return true
}
}
return false
}
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 _, isPreset := llm.LookupProvider(parts[1]); !isPreset {
return setCustomProviderField(cfg, parts[1], parts[2], key, value)
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
@ -303,14 +365,18 @@ func setCustomProviderValue(cfg *Config, key, value string) error {
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
return fmt.Errorf("invalid custom provider key %q: expected custom_providers.<name>.<field>", key)
}
return setCustomProviderField(cfg, parts[1], parts[2], key, value)
}
func setCustomProviderField(cfg *Config, name, field, key, value string) error {
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 {
entry := cfg.CustomProviders[name]
if err := applyProviderField(&entry, field, key, value); err != nil {
return err
}
cfg.CustomProviders[parts[1]] = entry
cfg.CustomProviders[name] = entry
return nil
}

View file

@ -81,6 +81,80 @@ func TestSetConfigValueProviderEntry(t *testing.T) {
}
}
func TestSetConfigValueProviderEntryNonPresetWritesCustomProvider(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.my-gateway.url", "https://gateway.internal.com/v1"); err != nil {
t.Fatalf("setConfigValue url: %v", err)
}
if cfg.Providers != nil {
if _, ok := cfg.Providers["my-gateway"]; ok {
t.Fatal("non-preset providers.<name> should be stored in CustomProviders, not Providers")
}
}
if cfg.CustomProviders["my-gateway"].URL != "https://gateway.internal.com/v1" {
t.Errorf("custom provider URL = %q", cfg.CustomProviders["my-gateway"].URL)
}
}
func TestSetConfigValueProviderEntryModelsJSON(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", `["llama-3-70b","llama-3-8b","llama-3-70b"]`); err != nil {
t.Fatalf("setConfigValue models: %v", err)
}
got := cfg.CustomProviders["my-gateway"].Models
want := []string{"llama-3-70b", "llama-3-8b"}
if len(got) != len(want) {
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestSetConfigValueProviderEntryModelsCommaSeparated(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", " llama-3-70b, llama-3-8b ,, llama-3-70b "); err != nil {
t.Fatalf("setConfigValue models: %v", err)
}
got := cfg.CustomProviders["my-gateway"].Models
want := []string{"llama-3-70b", "llama-3-8b"}
if len(got) != len(want) {
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestSetConfigValueProviderEntryModelsUnquotedBracketList(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", "[llama-3-70b,llama-3-8b]"); err != nil {
t.Fatalf("setConfigValue models: %v", err)
}
got := cfg.CustomProviders["my-gateway"].Models
want := []string{"llama-3-70b", "llama-3-8b"}
if len(got) != len(want) {
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestSetConfigValueProviderEntryProtocol(t *testing.T) {
cfg := &Config{}

View file

@ -104,6 +104,7 @@ type reviewOptions struct {
outputFormat string
audience string // --audience: "human" (default) or "agent"
background string // --background: optional requirement context
model string // --model: override resolved LLM model for this review
concurrency int
perFileTimeout int
maxTools int
@ -128,6 +129,7 @@ func parseReviewFlags(args []string) (reviewOptions, error) {
a.IntVar(&opts.perFileTimeout, "timeout", 10, "concurrent task timeout in minutes")
a.StringVar(&opts.audience, "audience", "human", "output audience: human (show progress) or agent (summary only)")
a.StringVarP(&opts.background, "background", "b", "", "optional requirement/business context for the review")
a.StringVar(&opts.model, "model", "", "override LLM model for this review (e.g., claude-opus-4-6)")
a.IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file (0 = template default; min 10)")
a.IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses")
a.BoolVarP(&opts.preview, "preview", "p", false, "preview which files will be reviewed without running the LLM")
@ -219,6 +221,7 @@ Flags:
--max-git-procs int max concurrent git subprocesses (default 16)
--from string source ref to start diff from (e.g., 'main')
--max-tools int max tool call rounds per file (0 = template default; min 10)
--model string override LLM model for this review (e.g., claude-opus-4-6)
-p, --preview preview which files will be reviewed without running the LLM
--repo string root directory of the git repository (default: current dir)
--rule string path to JSON file with system review rules
@ -278,10 +281,11 @@ Examples:
# 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"
ocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1
ocr config set custom_providers.my-gateway.protocol openai
ocr config set custom_providers.my-gateway.model llama-3-70b
ocr config set custom_providers.my-gateway.models '["llama-3-70b","llama-3-8b"]'
ocr config set custom_providers.my-gateway.api_key "$MY_API_KEY"
# Legacy endpoint configuration
ocr config set llm.url https://xx/v1/openai/chat/completions
@ -292,5 +296,6 @@ Examples:
ocr config set language English
ocr config set telemetry.enabled true
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`)
Supported 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
Provider fields: api_key, url, protocol, model, models, auth_header, extra_body`)
}

View file

@ -0,0 +1,20 @@
package main
import "testing"
func TestParseReviewFlagsModelOverride(t *testing.T) {
opts, err := parseReviewFlags([]string{"--model", "claude-opus-4-6"})
if err != nil {
t.Fatalf("parseReviewFlags: %v", err)
}
if opts.model != "claude-opus-4-6" {
t.Errorf("model = %q, want %q", opts.model, "claude-opus-4-6")
}
if opts.outputFormat != "text" {
t.Errorf("outputFormat = %q, want %q", opts.outputFormat, "text")
}
if opts.audience != "human" {
t.Errorf("audience = %q, want %q", opts.audience, "human")
}
}

View file

@ -94,6 +94,9 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
entry := cfg.CustomProviders[result.provider]
entry.Model = result.model
if len(result.models) > 0 {
entry.Models = mergeModelLists([]string{result.model}, result.models)
}
if result.url != "" {
entry.URL = result.url
}
@ -154,6 +157,9 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
entry := cfg.Providers[result.provider]
entry.Model = result.model
if len(result.models) > 0 {
entry.Models = mergeModelLists(entry.Models, result.models)
}
if result.apiKey != "" {
entry.APIKey = result.apiKey
}
@ -197,20 +203,32 @@ func runConfigModel() error {
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 {
provider := llm.Provider{Name: cfg.Provider, DisplayName: cfg.Provider}
isCustom := false
if preset, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
provider = preset
if entry, ok := cfg.Providers[cfg.Provider]; ok {
currentModel = entry.Model
provider.Models = mergeModelLists(provider.Models, entry.Models)
}
} else {
isCustom = true
entry, ok := cfg.CustomProviders[cfg.Provider]
if !ok {
return fmt.Errorf("provider %q is not configured in custom_providers", cfg.Provider)
}
currentModel = entry.Model
provider.DisplayName = cfg.Provider + " (custom)"
provider.Protocol = entry.Protocol
provider.BaseURL = entry.URL
provider.Models = mergeModelLists(entry.Models)
}
if currentModel == "" {
currentModel = cfg.Model
}
m := newModelTUI(preset, currentModel)
m := newModelTUI(provider, currentModel)
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
@ -228,12 +246,25 @@ func runConfigModel() error {
return fmt.Errorf("model name cannot be empty")
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
if isCustom {
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[cfg.Provider]
entry.Model = selectedModel
entry.Models = mergeModelLists([]string{selectedModel}, entry.Models)
cfg.CustomProviders[cfg.Provider] = entry
} else {
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[cfg.Provider]
entry.Model = selectedModel
if !modelListContains(provider.Models, selectedModel) {
entry.Models = mergeModelLists([]string{selectedModel}, entry.Models)
}
cfg.Providers[cfg.Provider] = entry
}
entry := cfg.Providers[cfg.Provider]
entry.Model = selectedModel
cfg.Providers[cfg.Provider] = entry
if err := saveConfig(configPath, cfg); err != nil {
return err

View file

@ -37,6 +37,7 @@ const (
cpStepProtocol
cpStepBaseURL
cpStepModel
cpStepModels
cpStepAPIKey
cpStepAuthHeader
)
@ -59,6 +60,7 @@ type customProviderListItem struct {
type providerTUIResult struct {
provider string
model string
models []string
apiKey string
isCustom bool
isManual bool
@ -87,6 +89,7 @@ type providerTUIModel struct {
cpNameInput textinput.Model
cpURLInput textinput.Model
cpModelInput textinput.Model
cpModelsInput textinput.Model
cpAuthInput textinput.Model
// --- tab: manual ---
@ -155,6 +158,10 @@ func newProviderTUI(cfg *Config) providerTUIModel {
cpModel.Placeholder = "model name"
cpModel.SetWidth(40)
cpModels := textinput.New()
cpModels.Placeholder = "optional comma-separated models"
cpModels.SetWidth(50)
cpAuth := textinput.New()
cpAuth.Placeholder = "optional, leave empty for default (Authorization)"
cpAuth.SetWidth(55)
@ -181,6 +188,7 @@ func newProviderTUI(cfg *Config) providerTUIModel {
cpNameInput: cpName,
cpURLInput: cpURL,
cpModelInput: cpModel,
cpModelsInput: cpModels,
cpAuthInput: cpAuth,
manualURLInput: manualURL,
manualModelInput: manualModel,
@ -262,8 +270,64 @@ func (m providerTUIModel) currentProvider() llm.Provider {
return m.providers[m.officialIdx]
}
func (m providerTUIModel) selectedCustomProvider() (customProviderListItem, bool) {
if m.activeTab != tabCustom || m.customIdx >= len(m.customProviders) {
return customProviderListItem{}, false
}
return m.customProviders[m.customIdx], true
}
func (m providerTUIModel) modelProviderName() string {
if m.activeTab == tabCustom {
if cp, ok := m.selectedCustomProvider(); ok {
return cp.name + " (custom)"
}
}
provider := m.currentProvider()
if provider.DisplayName != "" {
return provider.DisplayName
}
return provider.Name
}
func (m providerTUIModel) models() []string {
return m.currentProvider().Models
switch m.activeTab {
case tabOfficial:
models := m.currentProvider().Models
if m.existingCfg != nil {
provider := m.currentProvider()
if entry, ok := m.existingCfg.Providers[provider.Name]; ok {
models = mergeModelLists(models, entry.Models)
}
}
return models
case tabCustom:
if cp, ok := m.selectedCustomProvider(); ok {
return cp.entry.Models
}
}
return nil
}
func (m *providerTUIModel) prepareModelSelection(currentModel string) {
m.modelIdx = 0
m.customModel = false
m.modelInput.Blur()
m.modelInput.SetValue("")
models := m.models()
if currentModel == "" {
return
}
for i, model := range models {
if model == currentModel {
m.modelIdx = i
return
}
}
m.modelIdx = len(models)
m.modelInput.SetValue(currentModel)
}
func (m providerTUIModel) isCustomModelItem(idx int) bool {
@ -399,11 +463,7 @@ func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (te
switch key {
case "esc":
m.apiKeyInput.Blur()
if m.activeTab == tabCustom {
m.step = stepProvider
} else {
m.step = stepModel
}
m.step = stepModel
return m, nil
case "enter":
m.confirmed = true
@ -481,6 +541,10 @@ func (m providerTUIModel) handleCustomFormEnter() (tea.Model, tea.Cmd) {
return m, nil
}
m.cpModelInput.Blur()
m.cpStep = cpStepModels
return m, m.cpModelsInput.Focus()
case cpStepModels:
m.cpModelsInput.Blur()
m.cpStep = cpStepAPIKey
m.apiKeyInput.SetValue("")
m.apiKeyMasked = false
@ -505,6 +569,8 @@ func (m *providerTUIModel) blurCPStep() {
m.cpURLInput.Blur()
case cpStepModel:
m.cpModelInput.Blur()
case cpStepModels:
m.cpModelsInput.Blur()
case cpStepAPIKey:
m.apiKeyInput.Blur()
case cpStepAuthHeader:
@ -520,6 +586,8 @@ func (m providerTUIModel) focusCPStep() tea.Cmd {
return m.cpURLInput.Focus()
case cpStepModel:
return m.cpModelInput.Focus()
case cpStepModels:
return m.cpModelsInput.Focus()
case cpStepAPIKey:
return m.apiKeyInput.Focus()
case cpStepAuthHeader:
@ -537,6 +605,8 @@ func (m providerTUIModel) passThroughCPInput(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cpURLInput, cmd = m.cpURLInput.Update(msg)
case cpStepModel:
m.cpModelInput, cmd = m.cpModelInput.Update(msg)
case cpStepModels:
m.cpModelsInput, cmd = m.cpModelsInput.Update(msg)
case cpStepAPIKey:
if m.apiKeyMasked {
return m, nil
@ -644,23 +714,13 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) {
switch m.activeTab {
case tabOfficial:
m.step = stepModel
m.modelIdx = 0
currentModel := ""
if m.existingCfg != nil {
if entry, ok := m.existingCfg.Providers[m.currentProvider().Name]; ok && entry.Model != "" {
found := false
for i, model := range m.models() {
if model == entry.Model {
m.modelIdx = i
found = true
break
}
}
if !found {
m.modelIdx = len(m.models())
m.modelInput.SetValue(entry.Model)
}
currentModel = entry.Model
}
}
m.prepareModelSelection(currentModel)
return m, nil
case tabCustom:
@ -672,22 +732,16 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) {
m.cpNameInput.SetValue("")
m.cpURLInput.SetValue("")
m.cpModelInput.SetValue("")
m.cpModelsInput.SetValue("")
m.cpAuthInput.SetValue("")
m.apiKeyInput.SetValue("")
m.apiKeyMasked = false
return m, m.cpNameInput.Focus()
}
cp := m.customProviders[m.customIdx]
m.step = stepAPIKey
m.apiKeyMasked = false
m.apiKeyOriginal = ""
m.apiKeyInput.SetValue("")
if cp.entry.APIKey != "" {
m.apiKeyOriginal = cp.entry.APIKey
m.apiKeyMasked = true
m.apiKeyInput.SetValue(strings.Repeat("*", 20))
}
return m, m.apiKeyInput.Focus()
m.step = stepModel
m.prepareModelSelection(cp.entry.Model)
return m, nil
case tabManual:
m.inManualForm = true
@ -750,29 +804,44 @@ func (m providerTUIModel) handleDown() (tea.Model, tea.Cmd) {
}
func (m *providerTUIModel) loadExistingAPIKey() {
p := m.currentProvider()
m.apiKeyMasked = false
m.apiKeyOriginal = ""
m.apiKeyInput.SetValue("")
if m.existingCfg != nil {
if entry, ok := m.existingCfg.Providers[p.Name]; ok && entry.APIKey != "" {
m.apiKeyOriginal = entry.APIKey
if m.activeTab == tabCustom {
if cp, ok := m.selectedCustomProvider(); ok && cp.entry.APIKey != "" {
m.apiKeyOriginal = cp.entry.APIKey
m.apiKeyMasked = true
m.apiKeyInput.SetValue(strings.Repeat("*", 20))
}
return
}
if m.existingCfg == nil {
return
}
p := m.currentProvider()
if entry, ok := m.existingCfg.Providers[p.Name]; ok && entry.APIKey != "" {
m.apiKeyOriginal = entry.APIKey
m.apiKeyMasked = true
m.apiKeyInput.SetValue(strings.Repeat("*", 20))
}
}
func (m providerTUIModel) selectedModelFromState() string {
if m.modelInput.Value() != "" && (m.customModel || m.isCustomModelItem(m.modelIdx)) {
return m.modelInput.Value()
}
models := m.models()
if m.modelIdx < len(models) {
return models[m.modelIdx]
}
return ""
}
func (m providerTUIModel) result() providerTUIResult {
switch m.activeTab {
case tabOfficial:
p := m.currentProvider()
model := ""
if m.modelInput.Value() != "" && (m.customModel || m.isCustomModelItem(m.modelIdx)) {
model = m.modelInput.Value()
} else if m.modelIdx < len(m.models()) {
model = m.models()[m.modelIdx]
}
model := m.selectedModelFromState()
apiKey := ""
if m.apiKeyMasked {
@ -790,9 +859,14 @@ func (m providerTUIModel) result() providerTUIResult {
case tabCustom:
if m.creatingCustom {
protocol := cpProtocols[m.cpProtocolIdx]
models := mergeModelLists(
[]string{m.cpModelInput.Value()},
strings.Split(m.cpModelsInput.Value(), ","),
)
return providerTUIResult{
provider: m.cpNameInput.Value(),
model: m.cpModelInput.Value(),
models: models,
apiKey: m.apiKeyInput.Value(),
isCustom: true,
url: m.cpURLInput.Value(),
@ -802,6 +876,10 @@ func (m providerTUIModel) result() providerTUIResult {
}
if m.customIdx < len(m.customProviders) {
cp := m.customProviders[m.customIdx]
model := m.selectedModelFromState()
if model == "" {
model = cp.entry.Model
}
apiKey := ""
if m.apiKeyMasked {
apiKey = m.apiKeyOriginal
@ -810,7 +888,8 @@ func (m providerTUIModel) result() providerTUIResult {
}
return providerTUIResult{
provider: cp.name,
model: cp.entry.Model,
model: model,
models: mergeModelLists([]string{model}, cp.entry.Models),
apiKey: apiKey,
isCustom: true,
url: cp.entry.URL,
@ -972,6 +1051,7 @@ func (m providerTUIModel) viewCustomProviderForm(s *strings.Builder) {
{"Protocol", cpProtocols[m.cpProtocolIdx], m.cpStep == cpStepProtocol},
{"Base URL", m.cpURLInput.Value(), m.cpStep == cpStepBaseURL},
{"Model", m.cpModelInput.Value(), m.cpStep == cpStepModel},
{"Models", m.cpModelsInput.Value(), m.cpStep == cpStepModels},
{"API Key", strings.Repeat("*", len(m.apiKeyInput.Value())), m.cpStep == cpStepAPIKey},
{"Auth Header", m.cpAuthInput.Value(), m.cpStep == cpStepAuthHeader},
}
@ -998,6 +1078,8 @@ func (m providerTUIModel) viewCustomProviderForm(s *strings.Builder) {
s.WriteString(" " + m.cpURLInput.View() + "\n")
case cpStepModel:
s.WriteString(" " + m.cpModelInput.View() + "\n")
case cpStepModels:
s.WriteString(" " + m.cpModelsInput.View() + "\n")
case cpStepAPIKey:
s.WriteString(" " + m.apiKeyInput.View() + "\n")
case cpStepAuthHeader:
@ -1059,8 +1141,7 @@ func (m providerTUIModel) viewManualTab(s *strings.Builder) {
}
func (m providerTUIModel) viewModel(s *strings.Builder) {
provider := m.currentProvider()
s.WriteString(tuiTitleStyle.Render(fmt.Sprintf(" Select a model (%s)", provider.DisplayName)))
s.WriteString(tuiTitleStyle.Render(fmt.Sprintf(" Select a model (%s)", m.modelProviderName())))
s.WriteString("\n\n")
models := m.models()

View file

@ -461,7 +461,7 @@ func TestProviderTUI_CustomProviderExistsInList(t *testing.T) {
}
}
func TestProviderTUI_SelectExistingCustomGoesToAPIKey(t *testing.T) {
func TestProviderTUI_SelectExistingCustomGoesToModel(t *testing.T) {
cfg := &Config{
Provider: "my-llm",
CustomProviders: map[string]ProviderEntry{
@ -469,17 +469,21 @@ func TestProviderTUI_SelectExistingCustomGoesToAPIKey(t *testing.T) {
URL: "https://custom.api/v1",
Protocol: "openai",
Model: "custom-model",
Models: []string{"custom-model", "custom-fast"},
APIKey: "key-123",
},
},
}
m := newProviderTUI(cfg)
// Enter on existing custom provider should go to API key step
// Enter on existing custom provider should go to model selection first.
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.step != stepAPIKey {
t.Errorf("step = %d, want %d (stepAPIKey)", m2.step, stepAPIKey)
if m2.step != stepModel {
t.Errorf("step = %d, want %d (stepModel)", m2.step, stepModel)
}
if m2.models()[0] != "custom-model" {
t.Errorf("first model = %q, want %q", m2.models()[0], "custom-model")
}
}

View file

@ -88,7 +88,7 @@ func runReview(args []string) error {
tpl.ApplyLanguage(appCfg.Language)
}
ep, err := llm.ResolveEndpoint(cfgPath)
ep, err := llm.ResolveEndpointWithModelOverride(cfgPath, opts.model)
if err != nil {
return fmt.Errorf("resolve LLM endpoint: %w", err)
}

View file

@ -40,14 +40,23 @@ const (
// Each strategy requires all three fields (URL, Token, Model) to be non-empty.
// Returns the first valid strategy's result.
func ResolveEndpoint(configPath string) (ResolvedEndpoint, error) {
return ResolveEndpointWithModelOverride(configPath, "")
}
// ResolveEndpointWithModelOverride resolves an endpoint like ResolveEndpoint,
// but uses modelOverride as the request model when it is non-empty. The override
// can also supply the otherwise required model for a configured endpoint.
func ResolveEndpointWithModelOverride(configPath, modelOverride string) (ResolvedEndpoint, error) {
modelOverride = strings.TrimSpace(modelOverride)
strategies := []struct {
name string
fn func() (ResolvedEndpoint, bool, error)
}{
{"OCR config file", func() (ResolvedEndpoint, bool, error) { return tryOCRConfig(configPath) }},
{"OCR environment", tryOCREnv},
{"Claude Code environment", tryCCEnv},
{"Shell rc file", tryShellRC},
{"OCR config file", func() (ResolvedEndpoint, bool, error) { return tryOCRConfig(configPath, modelOverride) }},
{"OCR environment", func() (ResolvedEndpoint, bool, error) { return tryOCREnv(modelOverride) }},
{"Claude Code environment", func() (ResolvedEndpoint, bool, error) { return tryCCEnv(modelOverride) }},
{"Shell rc file", func() (ResolvedEndpoint, bool, error) { return tryShellRC(modelOverride) }},
}
for _, s := range strategies {
@ -68,10 +77,13 @@ func ResolveEndpoint(configPath string) (ResolvedEndpoint, error) {
}
// tryOCREnv reads OCR-specific environment variables.
func tryOCREnv() (ResolvedEndpoint, bool, error) {
func tryOCREnv(modelOverride string) (ResolvedEndpoint, bool, error) {
url := os.Getenv(envOCRLLMURL)
token := os.Getenv(envOCRLLMToken)
model := os.Getenv(envOCRLLMModel)
if modelOverride != "" {
model = modelOverride
}
if url == "" || token == "" || model == "" {
return ResolvedEndpoint{}, false, nil
}
@ -118,6 +130,7 @@ type providerEntryConfig struct {
URL string `json:"url,omitempty"`
Protocol string `json:"protocol,omitempty"`
Model string `json:"model,omitempty"`
Models []string `json:"models,omitempty"`
AuthHeader string `json:"auth_header,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"`
}
@ -131,7 +144,7 @@ type configFile struct {
}
// tryOCRConfig reads the OCR config file.
func tryOCRConfig(path string) (ResolvedEndpoint, bool, error) {
func tryOCRConfig(path, modelOverride string) (ResolvedEndpoint, bool, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
@ -146,14 +159,14 @@ func tryOCRConfig(path string) (ResolvedEndpoint, bool, error) {
}
if cfg.Provider != "" {
return tryProviderConfig(cfg)
return tryProviderConfig(cfg, modelOverride)
}
return tryLegacyLlmConfig(cfg)
return tryLegacyLlmConfig(cfg, modelOverride)
}
// tryProviderConfig resolves an endpoint from the provider-based configuration.
func tryProviderConfig(cfg configFile) (ResolvedEndpoint, bool, error) {
func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, bool, error) {
preset, isPreset := LookupProvider(cfg.Provider)
var entry providerEntryConfig
@ -212,8 +225,31 @@ func tryProviderConfig(cfg configFile) (ResolvedEndpoint, bool, error) {
if entry.Model != "" {
model = entry.Model
}
// Build available model list for validation.
var availableModels []string
if isPreset {
availableModels = append(availableModels, preset.Models...)
}
availableModels = append(availableModels, entry.Models...)
// Apply model override with validation.
if modelOverride != "" {
if len(availableModels) > 0 {
if !modelListContains(availableModels, modelOverride) {
return ResolvedEndpoint{}, false, fmt.Errorf(
"model %q is not available for provider %q; available models: %s",
modelOverride,
cfg.Provider,
strings.Join(availableModels, ", "),
)
}
}
model = modelOverride
}
if model == "" {
return ResolvedEndpoint{}, false, fmt.Errorf("provider %q has no model configured; run 'ocr config model' to select one", cfg.Provider)
return ResolvedEndpoint{}, false, fmt.Errorf("provider %q has no model configured; run 'ocr config model' to select one or pass --model", cfg.Provider)
}
if protocol == "anthropic" {
@ -254,8 +290,12 @@ func tryProviderConfig(cfg configFile) (ResolvedEndpoint, bool, error) {
}
// 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 == "" {
func tryLegacyLlmConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, bool, error) {
model := cfg.Llm.Model
if modelOverride != "" {
model = modelOverride
}
if cfg.Llm.URL == "" || cfg.Llm.AuthToken == "" || model == "" {
return ResolvedEndpoint{}, false, nil
}
@ -281,14 +321,17 @@ func tryLegacyLlmConfig(cfg configFile) (ResolvedEndpoint, bool, error) {
}
}
return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: cfg.Llm.Model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR config file", ExtraBody: cfg.Llm.ExtraBody}, true, nil
return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR config file", ExtraBody: cfg.Llm.ExtraBody}, true, nil
}
// tryCCEnv reads Claude Code environment variables.
func tryCCEnv() (ResolvedEndpoint, bool, error) {
func tryCCEnv(modelOverride string) (ResolvedEndpoint, bool, error) {
baseURL := os.Getenv(envCCBaseURL)
token := os.Getenv(envCCToken)
model := os.Getenv(envCCModel)
if modelOverride != "" {
model = modelOverride
}
if baseURL == "" || token == "" || model == "" {
return ResolvedEndpoint{}, false, nil
}
@ -300,10 +343,10 @@ func tryCCEnv() (ResolvedEndpoint, bool, error) {
}
// tryShellRC parses ~/.zshrc and ~/.bashrc for ANTHROPIC_* exports.
func tryShellRC() (ResolvedEndpoint, bool, error) {
func tryShellRC(modelOverride string) (ResolvedEndpoint, bool, error) {
files := shellRCFiles()
for _, f := range files {
ep, ok, err := parseShellRC(f)
ep, ok, err := parseShellRC(f, modelOverride)
if err != nil || ok {
return ep, ok, err
}
@ -339,7 +382,7 @@ func stripModelSuffix(model string) string {
return modelSuffixRe.ReplaceAllString(model, "")
}
func parseShellRC(path string) (ResolvedEndpoint, bool, error) {
func parseShellRC(path, modelOverride string) (ResolvedEndpoint, bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return ResolvedEndpoint{}, false, nil
@ -371,6 +414,9 @@ func parseShellRC(path string) (ResolvedEndpoint, bool, error) {
model = value
}
}
if modelOverride != "" {
model = modelOverride
}
if baseURL == "" || token == "" || model == "" {
return ResolvedEndpoint{}, false, nil
@ -390,6 +436,17 @@ func defaultAuthHeader(protocol string) string {
return ""
}
// modelListContains checks if a model exists in the available models list.
func modelListContains(models []string, target string) bool {
target = strings.TrimSpace(target)
for _, model := range models {
if strings.TrimSpace(model) == target {
return true
}
}
return false
}
// NormalizeAuthHeader normalizes an auth header value to a canonical form.
// It returns an error for unrecognized values.
func NormalizeAuthHeader(header string) (string, error) {

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
@ -365,6 +366,36 @@ func TestResolveEndpoint_ProviderEntryModelOverridesDefault(t *testing.T) {
}
}
func TestResolveEndpointWithModelOverride_CustomProviderWithoutConfiguredModel(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: "openai",
Models: []string{"llama-3-70b", "llama-3-8b"},
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpointWithModelOverride(cfgPath, "llama-3-8b")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Model != "llama-3-8b" {
t.Errorf("Model = %q, want %q", ep.Model, "llama-3-8b")
}
if ep.Source != "provider:my-gateway" {
t.Errorf("Source = %q, want %q", ep.Source, "provider:my-gateway")
}
}
func TestResolveEndpoint_ProviderAPIKeyEnvFallback(t *testing.T) {
clearAllEnv(t)
t.Setenv("ANTHROPIC_API_KEY", "env-api-key")
@ -610,3 +641,195 @@ func TestResolveEndpoint_ProviderExtraBody(t *testing.T) {
t.Error("ExtraBody missing 'thinking' key")
}
}
func TestResolveEndpointWithModelOverride_ValidModelInPresetList(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 := ResolveEndpointWithModelOverride(cfgPath, "claude-opus-4-8")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Model != "claude-opus-4-8" {
t.Errorf("Model = %q, want %q", ep.Model, "claude-opus-4-8")
}
}
func TestResolveEndpointWithModelOverride_InvalidModelInPresetList(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)
_, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opsu-4-6")
if err == nil {
t.Fatal("expected error for invalid model override")
}
if !strings.Contains(err.Error(), "not available for provider") {
t.Errorf("error message should mention model unavailability, got: %v", err)
}
if !strings.Contains(err.Error(), "available models:") {
t.Errorf("error message should list available models, got: %v", err)
}
}
func TestResolveEndpointWithModelOverride_ValidModelInCustomProviderList(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: "openai",
Models: []string{"llama-3-70b", "llama-3-8b"},
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpointWithModelOverride(cfgPath, "llama-3-8b")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep.Model != "llama-3-8b" {
t.Errorf("Model = %q, want %q", ep.Model, "llama-3-8b")
}
}
func TestResolveEndpointWithModelOverride_InvalidModelInCustomProviderList(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: "openai",
Models: []string{"llama-3-70b", "llama-3-8b"},
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
_, err := ResolveEndpointWithModelOverride(cfgPath, "gpt-4")
if err == nil {
t.Fatal("expected error for invalid model override in custom provider")
}
if !strings.Contains(err.Error(), "not available for provider") {
t.Errorf("error message should mention model unavailability, got: %v", err)
}
}
func TestResolveEndpointWithModelOverride_NoValidationWhenNoModelList(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: "openai",
// No Models list, so any model override should be accepted.
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
ep, err := ResolveEndpointWithModelOverride(cfgPath, "any-model-name")
if err != nil {
t.Fatalf("unexpected error when no model list exists: %v", err)
}
if ep.Model != "any-model-name" {
t.Errorf("Model = %q, want %q", ep.Model, "any-model-name")
}
}
func TestResolveEndpointWithModelOverride_MergesPresetAndEntryModels(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Provider: "anthropic",
Providers: map[string]providerEntryConfig{
"anthropic": {
APIKey: "sk-ant-test",
Models: []string{"custom-model-1", "custom-model-2"},
},
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
// Should accept both preset models and entry models.
ep1, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opus-4-8")
if err != nil {
t.Fatalf("unexpected error for preset model: %v", err)
}
if ep1.Model != "claude-opus-4-8" {
t.Errorf("Model = %q, want %q", ep1.Model, "claude-opus-4-8")
}
ep2, err := ResolveEndpointWithModelOverride(cfgPath, "custom-model-1")
if err != nil {
t.Fatalf("unexpected error for entry model: %v", err)
}
if ep2.Model != "custom-model-1" {
t.Errorf("Model = %q, want %q", ep2.Model, "custom-model-1")
}
// Should reject models not in either list.
_, err = ResolveEndpointWithModelOverride(cfgPath, "invalid-model")
if err == nil {
t.Fatal("expected error for model not in preset or entry lists")
}
}
func TestResolveEndpointWithModelOverride_LegacyConfigNoValidation(t *testing.T) {
clearAllEnv(t)
cfg := configFile{
Llm: llmFileConfig{
URL: "https://api.example.com/v1/messages",
AuthToken: "legacy-token",
Model: "configured-model",
},
}
data, _ := json.Marshal(cfg)
cfgPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(cfgPath, data, 0644)
// Legacy config has no model list, so any override should be accepted.
ep, err := ResolveEndpointWithModelOverride(cfgPath, "any-override-model")
if err != nil {
t.Fatalf("unexpected error for legacy config override: %v", err)
}
if ep.Model != "any-override-model" {
t.Errorf("Model = %q, want %q", ep.Model, "any-override-model")
}
}