mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
feat:add --model flag to override LLM model per review (#122)
This commit is contained in:
parent
ded7c6a1e0
commit
c673c9475a
15 changed files with 688 additions and 90 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
20
cmd/opencodereview/flags_test.go
Normal file
20
cmd/opencodereview/flags_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue