From 81a050555d8f6b960e8f2c1df69e1daf75c2b856 Mon Sep 17 00:00:00 2001 From: LC Date: Wed, 6 May 2026 16:06:49 +0800 Subject: [PATCH] feat(provider,web,asr): enhance model management with explicit provider metadata (#2701) * feat(provider,web): enhance model management with provider options * fix(asr): enhance compatibility for ElevenLabs transcription model * fix(provider,web): align provider availability predicates and add flow gating * fix(web,asr): preserve legacy elevenlabs transcription configs * fix(provider,web,asr): normalize elevenlabs configs and gate default chat models * fix: tighten provider catalog and elevenlabs compatibility --- pkg/audio/asr/README.md | 7 +- pkg/audio/asr/README.zh.md | 7 +- pkg/audio/asr/asr.go | 27 +- pkg/audio/asr/asr_test.go | 15 + pkg/audio/asr/elevenlabs_transcriber.go | 9 +- pkg/audio/asr/elevenlabs_transcriber_test.go | 85 +- pkg/providers/factory_provider.go | 33 +- pkg/providers/factory_provider_test.go | 132 ++- pkg/providers/model_ref.go | 25 +- pkg/providers/model_ref_test.go | 44 + pkg/providers/provider_catalog.go | 181 +++ web/backend/api/gateway.go | 3 + web/backend/api/gateway_test.go | 38 + web/backend/api/model_status.go | 65 +- web/backend/api/models.go | 234 +++- web/backend/api/models_test.go | 1040 ++++++++++++++++- web/frontend/src/api/models.ts | 12 + .../src/components/models/add-model-sheet.tsx | 160 ++- .../components/models/edit-model-sheet.tsx | 172 ++- .../src/components/models/model-card.tsx | 8 +- .../src/components/models/models-page.tsx | 59 +- .../src/components/models/provider-icon.tsx | 2 + .../src/components/models/provider-label.ts | 96 ++ web/frontend/src/hooks/use-chat-models.ts | 50 +- web/frontend/src/i18n/locales/en.json | 15 +- web/frontend/src/i18n/locales/zh.json | 15 +- 26 files changed, 2341 insertions(+), 193 deletions(-) create mode 100644 pkg/providers/provider_catalog.go diff --git a/pkg/audio/asr/README.md b/pkg/audio/asr/README.md index 0477276dd..99d2a8c90 100644 --- a/pkg/audio/asr/README.md +++ b/pkg/audio/asr/README.md @@ -82,7 +82,8 @@ Notes: "model_list": [ { "model_name": "elevenlabs-asr", - "model": "elevenlabs/scribe_v1" + "provider": "elevenlabs", + "model": "scribe_v1" } ] } @@ -130,7 +131,7 @@ PicoClaw currently supports three main ASR routes: | Route | Example models | Behavior | | --- | --- | --- | -| ElevenLabs ASR | `elevenlabs/scribe_v1` | Uses the ElevenLabs transcription API. | +| ElevenLabs ASR | `provider: elevenlabs`, `model: scribe_v1` | Uses the ElevenLabs transcription API. | | Whisper endpoint models | `openai/whisper-1`, `groq/whisper-large-v3` | Uses an OpenAI-compatible `/audio/transcriptions` endpoint. | | Audio-capable chat models **(Under construction)** | `openai/gpt-4o-audio-preview`, `gemini/gemini-2.5-flash` | Sends audio to a multimodal chat model and asks it to transcribe. | @@ -142,7 +143,7 @@ If you are unsure which one to pick, choose Groq Whisper or ElevenLabs first. 1. **Preferred path**: resolve `voice.model_name` against `model_list`. 2. If that resolved model is: - - `elevenlabs/...`, PicoClaw uses the ElevenLabs transcriber. + - an `elevenlabs` provider model, PicoClaw uses the ElevenLabs transcriber. - an OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber. - an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`. 3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries. diff --git a/pkg/audio/asr/README.zh.md b/pkg/audio/asr/README.zh.md index 104116080..670698cb8 100644 --- a/pkg/audio/asr/README.zh.md +++ b/pkg/audio/asr/README.zh.md @@ -82,7 +82,8 @@ model_list: "model_list": [ { "model_name": "elevenlabs-asr", - "model": "elevenlabs/scribe_v1" + "provider": "elevenlabs", + "model": "scribe_v1" } ] } @@ -130,7 +131,7 @@ PicoClaw 目前主要支持三种 ASR 路径: | 路径 | 示例模型 | 行为说明 | | --- | --- | --- | -| ElevenLabs ASR | `elevenlabs/scribe_v1` | 使用 ElevenLabs 的语音转录接口。 | +| ElevenLabs ASR | `provider: elevenlabs`,`model: scribe_v1` | 使用 ElevenLabs 的语音转录接口。 | | Whisper 接口模型 | `openai/whisper-1`、`groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 | | 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 | @@ -142,7 +143,7 @@ PicoClaw 目前主要支持三种 ASR 路径: 1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。 2. 如果找到的模型属于以下类型: - - `elevenlabs/...`,则使用 ElevenLabs transcriber。 + - `provider=elevenlabs` 的模型,则使用 ElevenLabs transcriber。 - OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。 - 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。 3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。 diff --git a/pkg/audio/asr/asr.go b/pkg/audio/asr/asr.go index 1482f40bb..a7c93e578 100644 --- a/pkg/audio/asr/asr.go +++ b/pkg/audio/asr/asr.go @@ -8,6 +8,12 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +const elevenLabsSupportedModelID = "scribe_v1" + +func ElevenLabsSupportedModelID() string { + return elevenLabsSupportedModelID +} + type Transcriber interface { Name() string Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) @@ -72,14 +78,23 @@ func whisperModelID(modelCfg *config.ModelConfig) string { return "" } +func isElevenLabsTranscriptionModel(modelCfg *config.ModelConfig) bool { + if modelCfg == nil || modelCfg.APIKey() == "" { + return false + } + + protocol, _ := providers.ExtractProtocol(modelCfg) + return protocol == "elevenlabs" +} + func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber { if modelCfg == nil { return nil } - protocol, _ := providers.ExtractProtocol(modelCfg) - if protocol == "elevenlabs" && modelCfg.APIKey() != "" { - return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) + if isElevenLabsTranscriptionModel(modelCfg) { + _, modelID := providers.ExtractProtocol(modelCfg) + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID) } if modelID := whisperModelID(modelCfg); modelID != "" { return NewWhisperTranscriber(modelCfg) @@ -95,9 +110,9 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe return nil } - protocol, _ := providers.ExtractProtocol(modelCfg) - if protocol == "elevenlabs" && modelCfg.APIKey() != "" { - return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) + if isElevenLabsTranscriptionModel(modelCfg) { + _, modelID := providers.ExtractProtocol(modelCfg) + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID) } if modelID := whisperModelID(modelCfg); modelID != "" { return NewWhisperTranscriber(modelCfg) diff --git a/pkg/audio/asr/asr_test.go b/pkg/audio/asr/asr_test.go index 0970d69f4..f877b1198 100644 --- a/pkg/audio/asr/asr_test.go +++ b/pkg/audio/asr/asr_test.go @@ -46,6 +46,21 @@ func TestDetectTranscriber(t *testing.T) { }, wantName: "elevenlabs", }, + { + name: "explicit elevenlabs provider selects elevenlabs transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + }, + }, + wantName: "elevenlabs", + }, { name: "voice model name alias selects whisper transcriber for groq", cfg: &config.Config{ diff --git a/pkg/audio/asr/elevenlabs_transcriber.go b/pkg/audio/asr/elevenlabs_transcriber.go index 452b9512d..a89d62848 100644 --- a/pkg/audio/asr/elevenlabs_transcriber.go +++ b/pkg/audio/asr/elevenlabs_transcriber.go @@ -20,19 +20,24 @@ import ( type ElevenLabsTranscriber struct { apiKey string apiBase string + modelID string httpClient *http.Client } -func NewElevenLabsTranscriber(apiKey, apiBase string) *ElevenLabsTranscriber { +func NewElevenLabsTranscriber(apiKey, apiBase, modelID string) *ElevenLabsTranscriber { logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""}) if apiBase == "" { apiBase = "https://api.elevenlabs.io" } + if modelID == "" || modelID != ElevenLabsSupportedModelID() { + modelID = ElevenLabsSupportedModelID() + } return &ElevenLabsTranscriber{ apiKey: apiKey, apiBase: apiBase, + modelID: modelID, httpClient: &http.Client{ Timeout: 120 * time.Second, }, @@ -74,7 +79,7 @@ func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath st return nil, fmt.Errorf("failed to copy file content: %w", err) } - if err = writer.WriteField("model_id", "scribe_v1"); err != nil { + if err = writer.WriteField("model_id", t.modelID); err != nil { return nil, fmt.Errorf("failed to write model_id field: %w", err) } diff --git a/pkg/audio/asr/elevenlabs_transcriber_test.go b/pkg/audio/asr/elevenlabs_transcriber_test.go index fa80110be..bbc827578 100644 --- a/pkg/audio/asr/elevenlabs_transcriber_test.go +++ b/pkg/audio/asr/elevenlabs_transcriber_test.go @@ -3,10 +3,14 @@ package asr import ( "context" "encoding/json" + "io" + "mime" + "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -14,7 +18,7 @@ import ( var _ Transcriber = (*ElevenLabsTranscriber)(nil) func TestElevenLabsTranscriberName(t *testing.T) { - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") if got := tr.Name(); got != "elevenlabs" { t.Errorf("Name() = %q, want %q", got, "elevenlabs") } @@ -35,6 +39,35 @@ func TestElevenLabsTranscribe(t *testing.T) { if r.Header.Get("Xi-Api-Key") != "sk_test" { t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key")) } + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType() error = %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content-type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(r.Body, params["boundary"]) + var gotModelID string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error = %v", err) + } + if part.FormName() != "model_id" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll(part) error = %v", err) + } + gotModelID = strings.TrimSpace(string(body)) + } + if gotModelID != "scribe_v1" { + t.Fatalf("model_id = %q, want %q", gotModelID, "scribe_v1") + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(TranscriptionResponse{ Text: "hello from elevenlabs", @@ -43,7 +76,7 @@ func TestElevenLabsTranscribe(t *testing.T) { })) defer srv.Close() - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") tr.apiBase = srv.URL resp, err := tr.Transcribe(context.Background(), audioPath) @@ -64,7 +97,7 @@ func TestElevenLabsTranscribe(t *testing.T) { })) defer srv.Close() - tr := NewElevenLabsTranscriber("sk_bad", "") + tr := NewElevenLabsTranscriber("sk_bad", "", "scribe_v1") tr.apiBase = srv.URL _, err := tr.Transcribe(context.Background(), audioPath) @@ -74,10 +107,54 @@ func TestElevenLabsTranscribe(t *testing.T) { }) t.Run("missing file", func(t *testing.T) { - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) if err == nil { t.Fatal("expected error for missing file, got nil") } }) + + t.Run("unsupported model falls back to scribe_v1", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType() error = %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content-type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(r.Body, params["boundary"]) + var gotModelID string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error = %v", err) + } + if part.FormName() != "model_id" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll(part) error = %v", err) + } + gotModelID = strings.TrimSpace(string(body)) + } + if gotModelID != "scribe_v1" { + t.Fatalf("model_id = %q, want runtime fallback to %q", gotModelID, "scribe_v1") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(TranscriptionResponse{Text: "ok"}) + })) + defer srv.Close() + + tr := NewElevenLabsTranscriber("sk_test", "", "unsupported-model") + tr.apiBase = srv.URL + + if _, err := tr.Transcribe(context.Background(), audioPath); err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + }) } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index a59e2de25..aa99d6d38 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -110,19 +110,7 @@ func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) { if provider := strings.TrimSpace(cfg.Provider); provider != "" { return NormalizeProvider(provider), model } - if model == "" { - return "", "" - } - - protocol, rest, found := strings.Cut(model, "/") - if !found { - return "openai", model - } - protocol = strings.TrimSpace(protocol) - if protocol == "" { - return "", strings.TrimSpace(rest) - } - return NormalizeProvider(protocol), strings.TrimSpace(rest) + return SplitModelProviderAndID(model, "openai") } // ResolveAPIBase returns the configured API base, or the protocol default when @@ -154,6 +142,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } protocol, modelID := ExtractProtocol(cfg) + authMethod := strings.ToLower(strings.TrimSpace(cfg.AuthMethod)) userAgent := cfg.UserAgent if userAgent == "" { @@ -163,7 +152,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err switch protocol { case "openai": // OpenAI with OAuth/token auth (Codex-style) - if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + if authMethod == "oauth" || authMethod == "token" { provider, err := createCodexAuthProvider() if err != nil { return nil, "", err @@ -320,7 +309,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return finalizeProviderFromConfig(provider, modelID, cfg) case "anthropic": - if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + if authMethod == "oauth" || authMethod == "token" { // Use OAuth credentials from auth store provider, err := createClaudeAuthProvider() if err != nil { @@ -431,7 +420,7 @@ func finalizeProviderFromConfig( } func isEmptyAPIKeyAllowed(protocol string) bool { - meta, ok := protocolMetaByName[protocol] + meta, ok := protocolMetaForName(protocol) return ok && meta.emptyAPIKeyAllowed } @@ -451,9 +440,19 @@ func DefaultAPIBaseForProtocol(protocol string) string { // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { - meta, ok := protocolMetaByName[protocol] + meta, ok := protocolMetaForName(protocol) if !ok { return "" } return meta.defaultAPIBase } + +func protocolMetaForName(protocol string) (protocolMeta, bool) { + if meta, ok := protocolMetaByName[protocol]; ok { + return meta, true + } + if meta, ok := attachedModelProviderMetaByName[protocol]; ok { + return meta.protocolMeta, true + } + return protocolMeta{}, false +} diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 3d3c30ce0..eb9b3d600 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) @@ -101,6 +102,12 @@ func TestExtractProtocol(t *testing.T) { wantProtocol: "", wantModelID: "gpt-4o", }, + { + name: "unknown prefix falls back to openai", + config: &config.ModelConfig{Model: "meta-llama/Llama-3.1-8B-Instruct"}, + wantProtocol: "openai", + wantModelID: "meta-llama/Llama-3.1-8B-Instruct", + }, { name: "nil config", wantProtocol: "", @@ -605,6 +612,41 @@ func TestCreateProviderFromConfig_CodexCLI(t *testing.T) { } } +func TestCreateProviderFromConfig_OpenAIMixedCaseAuthMethodUsesOAuthBranch(t *testing.T) { + origGetCredential := getCredential + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "openai" { + t.Fatalf("provider = %q, want %q", provider, "openai") + } + return &auth.AuthCredential{ + AccessToken: "test-token", + AccountID: "acct-test", + Provider: "openai", + AuthMethod: "oauth", + }, nil + } + t.Cleanup(func() { + getCredential = origGetCredential + }) + + cfg := &config.ModelConfig{ + ModelName: "test-openai-oauth", + Model: "openai/gpt-5.4", + AuthMethod: "OAuth", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "gpt-5.4" { + t.Errorf("modelID = %q, want %q", modelID, "gpt-5.4") + } +} + func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-no-key", @@ -619,8 +661,9 @@ func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { cfg := &config.ModelConfig{ - ModelName: "test-unknown", - Model: "unknown-protocol/model", + ModelName: "test-unknown-provider", + Provider: "unknown-protocol", + Model: "model", } cfg.SetAPIKey("test-key") @@ -630,6 +673,26 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { } } +func TestCreateProviderFromConfig_UnknownModelPrefixDefaultsToOpenAI(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-unknown-model-prefix", + Model: "meta-llama/Llama-3.1-8B-Instruct", + APIBase: "https://api.example.com/v1", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("modelID = %q, want full model ID", modelID) + } +} + func TestCreateProviderFromConfig_NilConfig(t *testing.T) { _, _, err := CreateProviderFromConfig(nil) if err == nil { @@ -889,6 +952,71 @@ func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { } } +func TestModelProviderOptions(t *testing.T) { + options := ModelProviderOptions() + if len(options) == 0 { + t.Fatal("ModelProviderOptions() returned no options") + } + + seen := make(map[string]ModelProviderOption, len(options)) + for _, option := range options { + seen[option.ID] = option + } + + if _, ok := seen["openai"]; !ok { + t.Fatal("openai option missing") + } + if option, ok := seen["openai"]; ok && !option.CreateAllowed { + t.Fatal("openai should be creatable") + } + if option, ok := seen["lmstudio"]; !ok { + t.Fatal("lmstudio option missing") + } else if !option.EmptyAPIKeyAllowed { + t.Fatal("lmstudio should allow empty API keys") + } + if option, ok := seen["anthropic"]; !ok { + t.Fatal("anthropic option missing") + } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { + t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1") + } + if _, ok := seen["azure"]; !ok { + t.Fatal("azure option missing") + } + if option, ok := seen["bedrock"]; !ok { + t.Fatal("bedrock option missing") + } else if !option.CreateAllowed { + t.Fatal("bedrock should be creatable and defer credential/build errors to runtime") + } + if option, ok := seen["elevenlabs"]; !ok { + t.Fatal("elevenlabs option missing") + } else { + if option.DefaultAPIBase != "https://api.elevenlabs.io" { + t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io") + } + if option.DefaultModelAllowed { + t.Fatal("elevenlabs should be ASR-only and therefore not allowed as a default chat model") + } + } + if option, ok := seen["antigravity"]; !ok { + t.Fatal("antigravity option missing") + } else { + if !option.CreateAllowed { + t.Fatal("antigravity should be creatable") + } + if option.DefaultAuthMethod != "oauth" { + t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth") + } + if !option.AuthMethodLocked { + t.Fatal("antigravity auth method should be locked") + } + } + if option, ok := seen["github-copilot"]; !ok { + t.Fatal("github-copilot option missing") + } else if option.DefaultAPIBase != "localhost:4321" { + t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } +} + func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) { var requestBody map[string]any diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go index be9f63bc6..48e3fb4cb 100644 --- a/pkg/providers/model_ref.go +++ b/pkg/providers/model_ref.go @@ -17,18 +17,13 @@ func ParseModelRef(raw string, defaultProvider string) *ModelRef { return nil } - if idx := strings.Index(raw, "/"); idx > 0 { - provider := NormalizeProvider(raw[:idx]) - model := strings.TrimSpace(raw[idx+1:]) - if model == "" { - return nil - } - return &ModelRef{Provider: provider, Model: model} + provider, model := SplitModelProviderAndID(raw, defaultProvider) + if model == "" { + return nil } - return &ModelRef{ - Provider: NormalizeProvider(defaultProvider), - Model: raw, + Provider: provider, + Model: model, } } @@ -53,6 +48,8 @@ func NormalizeProvider(provider string) string { return "zhipu" case "google": return "gemini" + case "google-antigravity": + return "antigravity" case "alibaba-coding", "qwen-coding": return "coding-plan" case "alibaba-coding-anthropic": @@ -61,6 +58,14 @@ func NormalizeProvider(provider string) string { return "qwen-intl" case "dashscope-us": return "qwen-us" + case "azure-openai": + return "azure" + case "claudecli": + return "claude-cli" + case "codexcli": + return "codex-cli" + case "copilot": + return "github-copilot" } return p diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go index 040c511ba..9a164bf48 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -72,7 +72,12 @@ func TestNormalizeProvider(t *testing.T) { {"claude", "anthropic"}, {"glm", "zhipu"}, {"google", "gemini"}, + {"google-antigravity", "antigravity"}, {"groq", "groq"}, + {"azure-openai", "azure"}, + {"claudecli", "claude-cli"}, + {"codexcli", "codex-cli"}, + {"copilot", "github-copilot"}, // Alibaba Coding Plan aliases {"alibaba-coding", "coding-plan"}, {"qwen-coding", "coding-plan"}, @@ -131,3 +136,42 @@ func TestParseModelRef_DefaultProviderNormalization(t *testing.T) { t.Errorf("provider = %q, want openai (normalized from GPT)", ref.Provider) } } + +func TestParseModelRef_UnknownPrefixFallsBackToDefaultProvider(t *testing.T) { + ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "openai" { + t.Fatalf("provider = %q, want openai", ref.Provider) + } + if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("model = %q, want full original model ID", ref.Model) + } +} + +func TestParseModelRef_UnknownPrefixPreservesEmptyDefaultProvider(t *testing.T) { + ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "" { + t.Fatalf("provider = %q, want empty", ref.Provider) + } + if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("model = %q, want full original model ID", ref.Model) + } +} + +func TestParseModelRef_KnownNonSelectableProvider(t *testing.T) { + ref := ParseModelRef("bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "bedrock" { + t.Fatalf("provider = %q, want bedrock", ref.Provider) + } + if ref.Model != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want preserved bedrock model ID", ref.Model) + } +} diff --git a/pkg/providers/provider_catalog.go b/pkg/providers/provider_catalog.go new file mode 100644 index 000000000..a9178cb81 --- /dev/null +++ b/pkg/providers/provider_catalog.go @@ -0,0 +1,181 @@ +package providers + +import ( + "sort" + "strings" +) + +// ModelProviderOption describes a canonical provider entry exposed to the Web UI. +type ModelProviderOption struct { + ID string `json:"id"` + DefaultAPIBase string `json:"default_api_base"` + EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"` + CreateAllowed bool `json:"create_allowed"` + DefaultModelAllowed bool `json:"default_model_allowed"` + DefaultAuthMethod string `json:"default_auth_method,omitempty"` + AuthMethodLocked bool `json:"auth_method_locked,omitempty"` +} + +type attachedModelProviderMeta struct { + protocolMeta + createAllowed bool + defaultModelAllowed bool + defaultAuthMethod string + authMethodLocked bool +} + +// attachedModelProviderMetaByName augments protocolMetaByName for provider +// families that are implemented in CreateProviderFromConfig but intentionally +// kept out of the core HTTP metadata map because they have special auth/runtime +// semantics. +var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{ + "azure": {createAllowed: true, defaultModelAllowed: true}, + "anthropic": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, + createAllowed: true, + defaultModelAllowed: true, + }, + "anthropic-messages": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, + createAllowed: true, + defaultModelAllowed: true, + }, + "bedrock": {createAllowed: true, defaultModelAllowed: true}, + "antigravity": { + createAllowed: true, + defaultModelAllowed: true, + defaultAuthMethod: "oauth", + authMethodLocked: true, + }, + "claude-cli": {createAllowed: true, defaultModelAllowed: true}, + "codex-cli": {createAllowed: true, defaultModelAllowed: true}, + "github-copilot": { + protocolMeta: protocolMeta{defaultAPIBase: "localhost:4321"}, + createAllowed: true, + defaultModelAllowed: true, + }, + // ElevenLabs is intentionally exposed only as an ASR-capable provider. It + // belongs in the shared model catalog because ASR is configured via + // model_list, but it must not be selectable as the default chat model. + "elevenlabs": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.elevenlabs.io"}, + createAllowed: true, + defaultModelAllowed: false, + }, +} + +// ModelProviderOptions returns the canonical provider catalog exposed to the Web UI. +func ModelProviderOptions() []ModelProviderOption { + optionsByID := make(map[string]ModelProviderOption, len(protocolMetaByName)+len(attachedModelProviderMetaByName)) + for provider := range protocolMetaByName { + if NormalizeProvider(provider) != provider { + continue + } + optionsByID[provider] = ModelProviderOption{ + ID: provider, + DefaultAPIBase: DefaultAPIBaseForProtocol(provider), + EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider), + CreateAllowed: true, + DefaultModelAllowed: true, + } + } + for provider, meta := range attachedModelProviderMetaByName { + if NormalizeProvider(provider) != provider { + continue + } + optionsByID[provider] = ModelProviderOption{ + ID: provider, + DefaultAPIBase: meta.defaultAPIBase, + EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed, + CreateAllowed: meta.createAllowed, + DefaultModelAllowed: meta.defaultModelAllowed, + DefaultAuthMethod: meta.defaultAuthMethod, + AuthMethodLocked: meta.authMethodLocked, + } + } + + options := make([]ModelProviderOption, 0, len(optionsByID)) + for _, option := range optionsByID { + options = append(options, option) + } + sort.Slice(options, func(i, j int) bool { + return options[i].ID < options[j].ID + }) + return options +} + +// IsSupportedModelProvider reports whether provider resolves to a provider ID +// returned by ModelProviderOptions. +func IsSupportedModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + _, ok := attachedModelProviderMetaByName[normalized] + return ok +} + +// IsCreatableModelProvider reports whether provider can be selected for a new +// model entry from the Web UI. +func IsCreatableModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + meta, ok := attachedModelProviderMetaByName[normalized] + return ok && meta.createAllowed +} + +// IsDefaultModelProvider reports whether provider can be used as the default +// chat model. Some providers such as ASR-only entries are intentionally +// exposed in model_list management but cannot drive the gateway default model. +func IsDefaultModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + meta, ok := attachedModelProviderMetaByName[normalized] + return ok && meta.defaultModelAllowed +} + +// SplitModelProviderAndID separates a legacy "provider/model" string into its +// effective provider and canonical model ID. Unknown prefixes are treated as +// part of the model ID and fall back to defaultProvider. +func SplitModelProviderAndID(model, defaultProvider string) (provider, modelID string) { + model = strings.TrimSpace(model) + if model == "" { + return "", "" + } + + provider, modelID = splitKnownProviderModel(model) + if provider != "" || modelID != "" { + return provider, modelID + } + + return NormalizeProvider(defaultProvider), model +} + +func splitKnownProviderModel(model string) (provider, modelID string) { + provider, modelID, found := strings.Cut(strings.TrimSpace(model), "/") + if !found { + return "", "" + } + provider = strings.TrimSpace(provider) + modelID = strings.TrimSpace(modelID) + if provider == "" { + return "", modelID + } + if !IsSupportedModelProvider(provider) { + return "", "" + } + return NormalizeProvider(provider), modelID +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 67b055236..45f7e6912 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -382,6 +382,9 @@ func (h *Handler) gatewayStartReady() (bool, string, error) { if modelCfg == nil { return false, fmt.Sprintf("default model %q is invalid", modelName), nil } + if !defaultModelAllowedForModelConfig(modelCfg) { + return false, fmt.Sprintf("default model %q is not usable for chat", modelName), nil + } if !hasModelConfiguration(modelCfg) { return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 1d9352972..f383089a6 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -357,6 +357,44 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) { } } +func TestGatewayStartReady_RejectsASROnlyDefaultModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + cfg.Agents.Defaults.ModelName = "elevenlabs-asr" + + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatal("gatewayStartReady() ready = true, want false") + } + if reason != `default model "elevenlabs-asr" is not usable for chat` { + t.Fatalf( + "gatewayStartReady() reason = %q, want %q", + reason, + `default model "elevenlabs-asr" is not usable for chat`, + ) + } +} + func TestLooksLikeGatewayCommandLine(t *testing.T) { cases := []struct { name string diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index d262cf124..302231d80 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/url" + "os/exec" "strconv" "strings" "sync" @@ -47,6 +48,7 @@ var ( probeTCPServiceFunc = probeTCPService probeOllamaModelFunc = probeOllamaModel probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel + probeCommandAvailableFunc = probeCommandAvailable modelProbeNowFunc = time.Now modelProbeState = newModelProbeCacheState() ) @@ -83,17 +85,23 @@ func (s *modelProbeCacheState) resetForTest() { } func hasModelConfiguration(m *config.ModelConfig) bool { + protocol := modelProtocol(m) authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) apiKey := strings.TrimSpace(m.APIKey()) if authMethod == "oauth" || authMethod == "token" { - if provider, ok := oauthProviderForModel(m); ok { - cred, err := oauthGetCredential(provider) - if err != nil || cred == nil { - return false - } - return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "" + if configured, checked := hasStoredOAuthCredential(m); checked { + return configured } + } + + if authMethod == "" && providerUsesImplicitOAuth(protocol) { + if configured, checked := hasStoredOAuthCredential(m); checked { + return configured + } + } + + if providerUsesAmbientCredentials(protocol) { return true } @@ -104,6 +112,40 @@ func hasModelConfiguration(m *config.ModelConfig) bool { return apiKey != "" } +func hasStoredOAuthCredential(m *config.ModelConfig) (bool, bool) { + provider, ok := oauthProviderForModel(m) + if !ok { + return false, false + } + cred, err := oauthGetCredential(provider) + if err != nil || cred == nil { + return false, true + } + return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "", true +} + +func providerUsesImplicitOAuth(protocol string) bool { + switch protocol { + case "antigravity", "google-antigravity": + return true + default: + return false + } +} + +func providerUsesAmbientCredentials(protocol string) bool { + switch protocol { + case "bedrock": + // Bedrock relies on the AWS SDK credential chain instead of an explicit + // API key stored in ModelConfig. We cannot reliably preflight every AWS + // credential source here, so avoid misclassifying valid environments as + // "unconfigured" and defer concrete credential failures to runtime. + return true + default: + return false + } +} + func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary { if !hasModelConfiguration(m) { return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured} @@ -180,8 +222,10 @@ func runLocalModelProbe(m *config.ModelConfig) bool { return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) case "github-copilot", "copilot": return probeTCPServiceFunc(apiBase) - case "claude-cli", "claudecli", "codex-cli", "codexcli": - return true + case "claude-cli", "claudecli": + return probeCommandAvailableFunc("claude") + case "codex-cli", "codexcli": + return probeCommandAvailableFunc("codex") default: if hasLocalAPIBase(apiBase) { return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) @@ -190,6 +234,11 @@ func runLocalModelProbe(m *config.ModelConfig) bool { } } +func probeCommandAvailable(command string) bool { + _, err := exec.LookPath(command) + return err == nil +} + func modelProbeCacheKey(m *config.ModelConfig) string { protocol, modelID := splitModel(m) diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 61eb235cb..8a66918f9 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -9,6 +9,7 @@ import ( "strings" "sync" + "github.com/sipeed/picoclaw/pkg/audio/asr" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -45,11 +46,184 @@ type modelResponse struct { ExtraBody map[string]any `json:"extra_body,omitempty"` CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Meta - Enabled bool `json:"enabled"` - Available bool `json:"available"` - Status string `json:"status"` - IsDefault bool `json:"is_default"` - IsVirtual bool `json:"is_virtual"` + Enabled bool `json:"enabled"` + Available bool `json:"available"` + Status string `json:"status"` + IsDefault bool `json:"is_default"` + IsVirtual bool `json:"is_virtual"` + DefaultModelAllowed bool `json:"default_model_allowed"` +} + +func normalizeStoredModelConfig(mc *config.ModelConfig) bool { + if mc == nil { + return false + } + + changed := false + model := strings.TrimSpace(mc.Model) + if model != mc.Model { + mc.Model = model + changed = true + } + provider := strings.TrimSpace(mc.Provider) + if provider != mc.Provider { + mc.Provider = provider + changed = true + } + authMethod := strings.ToLower(strings.TrimSpace(mc.AuthMethod)) + if authMethod != mc.AuthMethod { + mc.AuthMethod = authMethod + changed = true + } + + if provider != "" { + normalizedProvider := providers.NormalizeProvider(provider) + if providers.IsSupportedModelProvider(normalizedProvider) && normalizedProvider != provider { + mc.Provider = normalizedProvider + changed = true + } + if mc.Provider == "elevenlabs" { + if _, strippedModel, found := strings.Cut( + model, + "/", + ); found && + providers.NormalizeProvider(strings.TrimSpace(provider)) == "elevenlabs" { + strippedModel = strings.TrimSpace(strippedModel) + if strippedModel != "" && strippedModel != mc.Model { + mc.Model = strippedModel + changed = true + } + } + if strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() { + mc.Model = asr.ElevenLabsSupportedModelID() + changed = true + } + } + return changed + } + + effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai") + if effectiveProvider == "" { + return changed + } + if mc.Provider != effectiveProvider { + mc.Provider = effectiveProvider + changed = true + } + if mc.Model != modelID { + mc.Model = modelID + changed = true + } + return changed +} + +func normalizeIncomingModelConfig(mc *config.ModelConfig) { + if mc == nil { + return + } + + mc.Model = strings.TrimSpace(mc.Model) + mc.Provider = strings.TrimSpace(mc.Provider) + mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod)) + if mc.Provider == "" { + mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai") + } else { + mc.Provider = providers.NormalizeProvider(mc.Provider) + if mc.Provider == "elevenlabs" { + if _, strippedModel, found := strings.Cut(mc.Model, "/"); found { + strippedModel = strings.TrimSpace(strippedModel) + if strippedModel != "" { + mc.Model = strippedModel + } + } + } + } + if mc.Provider == "antigravity" && mc.AuthMethod == "" { + mc.AuthMethod = "oauth" + } +} + +func createAllowedForProvider(provider string) bool { + normalized := providers.NormalizeProvider(provider) + switch normalized { + case "bedrock": + // Bedrock currently authenticates through the AWS SDK credential chain + // (env vars, shared profiles, IAM roles, etc.), and this Web layer does + // not yet have a reliable preflight check for those credential sources. + // Keep it creatable in the catalog and let provider construction/runtime + // return the concrete AWS error when the environment is incomplete. + return true + case "claude-cli", "codex-cli": + return cliProviderCreateAllowedFromCurrentStatus(normalized) + default: + return providers.IsCreatableModelProvider(normalized) + } +} + +// cliProviderCreateAllowedFromCurrentStatus intentionally reuses the existing +// local model status pipeline so provider catalog gating follows the same CLI +// executable probe used by launcher readiness. +func cliProviderCreateAllowedFromCurrentStatus(provider string) bool { + status := modelConfigurationStatus(&config.ModelConfig{ + Provider: provider, + Model: provider, + }) + return status.Available +} + +func modelProviderOptionsForResponse() []providers.ModelProviderOption { + options := providers.ModelProviderOptions() + for i := range options { + options[i].CreateAllowed = createAllowedForProvider(options[i].ID) + } + return options +} + +func defaultModelAllowedForModelConfig(mc *config.ModelConfig) bool { + provider, _ := providers.ExtractProtocol(mc) + return providers.IsDefaultModelProvider(provider) +} + +func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelConfig) error { + if mc == nil { + return fmt.Errorf("model config is required") + } + if err := mc.Validate(); err != nil { + return err + } + if strings.TrimSpace(mc.Provider) == "" { + return fmt.Errorf("provider is required") + } + if !providers.IsSupportedModelProvider(mc.Provider) { + return fmt.Errorf("provider %q is not supported", mc.Provider) + } + if mc.Provider == "elevenlabs" && strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() { + return fmt.Errorf("provider %q only supports model %q", mc.Provider, asr.ElevenLabsSupportedModelID()) + } + if !createAllowedForProvider(mc.Provider) { + if existing == nil { + return fmt.Errorf("provider %q is not available for new models", mc.Provider) + } + existingProvider, _ := providers.ExtractProtocol(existing) + if providers.NormalizeProvider(existingProvider) != mc.Provider { + return fmt.Errorf("provider %q is not available for selection", mc.Provider) + } + } + return nil +} + +func normalizeStoredModelProviders(cfg *config.Config) bool { + if cfg == nil { + return false + } + + changed := false + for _, model := range cfg.ModelList { + if normalizeStoredModelConfig(model) { + changed = true + } + } + return changed } // handleListModels returns all model_list entries with masked API keys. @@ -62,6 +236,10 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { return } + // Normalize legacy provider/model storage in memory so GET can round-trip + // through the current API shape without mutating the on-disk config. + normalizeStoredModelProviders(cfg) + defaultModel := cfg.Agents.Defaults.GetModelName() modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList)) @@ -101,14 +279,16 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { Status: modelStatuses[i].Status, IsDefault: m.ModelName == defaultModel, IsVirtual: m.IsVirtual(), + DefaultModelAllowed: defaultModelAllowedForModelConfig(m), }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "models": models, - "total": len(models), - "default_model": defaultModel, + "models": models, + "total": len(models), + "default_model": defaultModel, + "provider_options": modelProviderOptionsForResponse(), }) } @@ -134,7 +314,9 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { return } - if err = mc.Validate(); err != nil { + normalizeIncomingModelConfig(&mc.ModelConfig) + + if err = validateIncomingModelConfig(&mc.ModelConfig, nil); err != nil { http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) return } @@ -150,6 +332,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { } cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig) + normalizeStoredModelProviders(cfg) if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) @@ -200,11 +383,6 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { return } - if err = mc.Validate(); err != nil { - http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) - return - } - cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) @@ -253,9 +431,9 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { // This keeps provider-omitted updates backward-compatible even when an // older client edits the visible model ID. if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" { - existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model) incomingModel := strings.TrimSpace(mc.Model) + existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" { if incomingModel == existingModelID { mc.Model = existingRawModel @@ -272,7 +450,20 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } } + normalizeIncomingModelConfig(&mc.ModelConfig) + if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil { + http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) + return + } + if cfg.Agents.Defaults.ModelName == cfg.ModelList[idx].ModelName && + !defaultModelAllowedForModelConfig(&mc.ModelConfig) { + // Allow users to recover from legacy/invalid defaults by saving the model + // and clearing the default chat model reference in the same write. + cfg.Agents.Defaults.ModelName = "" + } + cfg.ModelList[idx] = &mc.ModelConfig + normalizeStoredModelProviders(cfg) logger.Debugf("update model config: %#v", mc.ModelConfig) @@ -372,6 +563,19 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest) return } + for _, m := range cfg.ModelList { + if m.ModelName == req.ModelName { + if !defaultModelAllowedForModelConfig(m) { + http.Error( + w, + fmt.Sprintf("Model %q cannot be used as the default chat model", req.ModelName), + http.StatusBadRequest, + ) + return + } + break + } + } cfg.Agents.Defaults.ModelName = req.ModelName diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index dd5ff6a54..0b1f04848 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -12,6 +12,7 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" ) func resetModelProbeHooks(t *testing.T) { @@ -20,17 +21,46 @@ func resetModelProbeHooks(t *testing.T) { origTCPProbe := probeTCPServiceFunc origOllamaProbe := probeOllamaModelFunc origOpenAIProbe := probeOpenAICompatibleModelFunc + origCommandProbe := probeCommandAvailableFunc origNow := modelProbeNowFunc resetModelProbeCache() t.Cleanup(func() { probeTCPServiceFunc = origTCPProbe probeOllamaModelFunc = origOllamaProbe probeOpenAICompatibleModelFunc = origOpenAIProbe + probeCommandAvailableFunc = origCommandProbe modelProbeNowFunc = origNow resetModelProbeCache() }) } +func addModelAndLoadLatest(t *testing.T, configPath string, body string) *config.ModelConfig { + t.Helper() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(cfg.ModelList) == 0 { + t.Fatal("model_list should contain the newly added model") + } + + return cfg.ModelList[len(cfg.ModelList)-1] +} + func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -94,7 +124,8 @@ func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing }, } cfg.Agents.Defaults.ModelName = "openai-oauth" - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -113,7 +144,8 @@ func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { t.Fatalf("Unmarshal() error = %v", err) } @@ -181,14 +213,91 @@ func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) AuthMethod: "oauth", }} cfg.Agents.Defaults.ModelName = "claude-oauth" - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } - if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ + if setCredentialErr := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ AccessToken: "anthropic-token", Provider: oauthProviderAnthropic, AuthMethod: "oauth", + }); setCredentialErr != nil { + t.Fatalf("SetCredential() error = %v", setCredentialErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatalf("oauth model available = false, want true with stored credential") + } +} + +func TestHasModelConfiguration_OAuthWithoutMappedCredentialFallsBackToAPIKey(t *testing.T) { + noKey := &config.ModelConfig{ + Provider: "gemini", + Model: "gemini-2.5-flash", + AuthMethod: "oauth", + } + if hasModelConfiguration(noKey) { + t.Fatal("oauth model without credential mapping and api key should be unconfigured") + } + + withKey := &config.ModelConfig{ + Provider: "gemini", + Model: "gemini-2.5-flash", + AuthMethod: "oauth", + APIKeys: config.SimpleSecureStrings("gemini-key"), + } + if !hasModelConfiguration(withKey) { + t.Fatal("oauth model without credential mapping should fall back to api key configuration") + } +} + +func TestHandleListModels_AntigravityImplicitOAuthAvailability(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "gemini-flash", + Provider: "antigravity", + Model: "gemini-3-flash", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if err := auth.SetCredential(oauthProviderGoogleAntigravity, &auth.AuthCredential{ + AccessToken: "antigravity-token", + Provider: oauthProviderGoogleAntigravity, + AuthMethod: "oauth", }); err != nil { t.Fatalf("SetCredential() error = %v", err) } @@ -208,14 +317,158 @@ func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if !resp.Models[0].Available { - t.Fatalf("oauth model available = false, want true with stored credential") + t.Fatal("antigravity model available = false, want true with stored credential even without auth_method") + } +} + +func TestHandleListModels_BedrockUsesAmbientCredentialStatus(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Provider: "bedrock", + Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", + }} + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatal("bedrock model available = false, want true because Bedrock uses ambient AWS credentials") + } + if resp.Models[0].Status != modelStatusAvailable { + t.Fatalf("bedrock model status = %q, want %q", resp.Models[0].Status, modelStatusAvailable) + } +} + +func TestHandleListModels_CLIProvidersRequireInstalledCommands(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeCommandAvailableFunc = func(command string) bool { + switch command { + case "claude": + return false + case "codex": + return true + default: + return false + } + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "claude-cli-model", + Provider: "claude-cli", + Model: "claude-cli", + }, + { + ModelName: "codex-cli-model", + Provider: "codex-cli", + Model: "codex-cli", + }, + } + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + + modelsByName := make(map[string]modelResponse, len(resp.Models)) + for _, model := range resp.Models { + modelsByName[model.ModelName] = model + } + if model := modelsByName["claude-cli-model"]; model.Available || model.Status != modelStatusUnreachable { + t.Fatalf( + "claude-cli status = (%t, %q), want (%t, %q)", + model.Available, + model.Status, + false, + modelStatusUnreachable, + ) + } + if model := modelsByName["codex-cli-model"]; !model.Available || model.Status != modelStatusAvailable { + t.Fatalf( + "codex-cli status = (%t, %q), want (%t, %q)", + model.Available, + model.Status, + true, + modelStatusAvailable, + ) + } + + optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) + for _, option := range resp.ProviderOptions { + optionsByID[option.ID] = option + } + if option, ok := optionsByID["claude-cli"]; !ok { + t.Fatal("claude-cli provider option missing") + } else if option.CreateAllowed { + t.Fatal("claude-cli should not be creatable when the claude command is missing") + } + if option, ok := optionsByID["codex-cli"]; !ok { + t.Fatal("codex-cli provider option missing") + } else if !option.CreateAllowed { + t.Fatal("codex-cli should be creatable when the codex command is available") } } @@ -321,8 +574,8 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) @@ -508,6 +761,223 @@ func TestHandleAddModel_PersistsProvider(t *testing.T) { } } +func TestHandleAddModel_RejectsUnsupportedProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"bad-provider", + "provider":"not-supported", + "model":"gpt-4o-mini" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "not-supported" is not supported`) { + t.Fatalf("body = %q, want unsupported provider error", rec.Body.String()) + } +} + +func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"bedrock-claude", + "provider":"bedrock", + "model":"us.anthropic.claude-sonnet-4-20250514-v1:0" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + added := cfg.ModelList[len(cfg.ModelList)-1] + if got := added.Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := added.Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want bedrock model ID", got) + } +} + +func TestHandleAddModel_NormalizesLegacyElevenLabsASRConfig(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "provider":"openai", + "model":"gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(updated.ModelList) != 2 { + t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList)) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") + } +} + +func TestHandleAddModel_NormalizesExplicitElevenLabsUnsupportedModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v2", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "provider":"openai", + "model":"gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") + } +} + +func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeCommandAvailableFunc = func(command string) bool { + return false + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"claude-cli-model", + "provider":"claude-cli", + "model":"claude-cli" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "claude-cli" is not available for new models`) { + t.Fatalf("body = %q, want missing cli command error", rec.Body.String()) + } +} + +func TestHandleAddModel_DefaultsAntigravityToOAuth(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + added := addModelAndLoadLatest(t, configPath, `{ + "model_name":"gemini-flash", + "provider":"antigravity", + "model":"gemini-3-flash" + }`) + if got := added.AuthMethod; got != "oauth" { + t.Fatalf("auth_method = %q, want %q", got, "oauth") + } +} + +func TestHandleAddModel_NormalizesMixedCaseAuthMethod(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + added := addModelAndLoadLatest(t, configPath, `{ + "model_name":"openai-oauth", + "provider":"openai", + "model":"gpt-5.4", + "auth_method":"OAuth" + }`) + if got := added.AuthMethod; got != "oauth" { + t.Fatalf("auth_method = %q, want %q", got, "oauth") + } +} + func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -845,7 +1315,8 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { Provider: "openrouter", Model: "openrouter/auto", }} - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -864,7 +1335,8 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { @@ -878,6 +1350,55 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { } } +func TestHandleListModels_ExposesElevenLabsASRProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := resp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if resp.Models[0].DefaultModelAllowed { + t.Fatal("elevenlabs ASR model should not be allowed as the default chat model") + } +} + func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -940,11 +1461,230 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *test if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := updated.ModelList[0].Provider; got != "" { - t.Fatalf("provider = %q, want empty", got) + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") } - if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" { - t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.4") + if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } +} + +func TestHandleUpdateModel_MigratesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := listResp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { + t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") + } +} + +func TestHandleUpdateModel_RoundTripsExplicitLegacyElevenLabsModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v2", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := listResp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after GET normalization", got, "scribe_v1") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { + t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") + } +} + +func TestHandleUpdateModel_ClearsDefaultWhenSavingASROnlyModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + cfg.Agents.Defaults.ModelName = "elevenlabs-asr" + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.Agents.Defaults.ModelName; got != "" { + t.Fatalf("default model = %q, want cleared default", got) + } +} + +func TestHandleAddModel_RejectsUnsupportedElevenLabsModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v2" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "elevenlabs" only supports model "scribe_v1"`) { + t.Fatalf("body = %q, want elevenlabs model validation error", rec.Body.String()) } } @@ -984,11 +1724,125 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModel if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := updated.ModelList[0].Provider; got != "" { - t.Fatalf("provider = %q, want empty", got) + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") } - if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.5" { - t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.5") + if got := updated.ModelList[0].Model; got != "openai/gpt-5.5" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.5") + } +} + +func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "legacy-openrouter", + Model: "openrouter/openai/gpt-5.4", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } + if got := resp.Models[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } + + optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) + for _, option := range resp.ProviderOptions { + optionsByID[option.ID] = option + } + if len(optionsByID) == 0 { + t.Fatal("provider_options should not be empty") + } + if option, ok := optionsByID["openai"]; !ok { + t.Fatal("openai provider option missing") + } else if option.DefaultAPIBase != "https://api.openai.com/v1" { + t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1") + } + if option, ok := optionsByID["anthropic"]; !ok { + t.Fatal("anthropic provider option missing") + } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { + t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1") + } + if _, ok := optionsByID["azure"]; !ok { + t.Fatal("azure provider option missing") + } + if option, ok := optionsByID["github-copilot"]; !ok { + t.Fatal("github-copilot provider option missing") + } else if option.DefaultAPIBase != "localhost:4321" { + t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } + if option, ok := optionsByID["elevenlabs"]; !ok { + t.Fatal("elevenlabs provider option missing") + } else { + if option.DefaultAPIBase != "https://api.elevenlabs.io" { + t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io") + } + if option.DefaultModelAllowed { + t.Fatal("elevenlabs should be marked as not allowed for default chat model selection") + } + } + if option, ok := optionsByID["lmstudio"]; !ok { + t.Fatal("lmstudio provider option missing") + } else if !option.EmptyAPIKeyAllowed { + t.Fatal("lmstudio should allow empty api keys") + } + if option, ok := optionsByID["bedrock"]; !ok { + t.Fatal("bedrock provider option missing") + } else if !option.CreateAllowed { + t.Fatal("bedrock should stay creatable and defer AWS credential failures to runtime") + } + if option, ok := optionsByID["antigravity"]; !ok { + t.Fatal("antigravity provider option missing") + } else { + if option.DefaultAuthMethod != "oauth" { + t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth") + } + if !option.AuthMethodLocked { + t.Fatal("antigravity auth method should be locked") + } + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "" { + t.Fatalf("persisted provider = %q, want unchanged empty provider", got) + } + if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" { + t.Fatalf("persisted model = %q, want unchanged legacy model", got) } } @@ -1036,6 +1890,115 @@ func TestHandleListModels_ReturnsProviderField(t *testing.T) { } } +func TestHandleListModels_PreservesKnownProviderInCatalog(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := resp.Models[0].Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want %q", got, "us.anthropic.claude-sonnet-4-20250514-v1:0") + } + foundBedrock := false + for _, option := range resp.ProviderOptions { + if option.ID == "bedrock" { + foundBedrock = true + if !option.CreateAllowed { + t.Fatal("bedrock should stay creatable in provider_options") + } + } + } + if !foundBedrock { + t.Fatal("bedrock should be included in provider_options for compatibility") + } +} + +func TestHandleUpdateModel_AllowsExistingBedrockProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Provider: "bedrock", + Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", + APIBase: "us-west-2", + }} + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"bedrock-claude", + "provider":"bedrock", + "model":"us.anthropic.claude-3-7-sonnet-20250219-v1:0", + "api_base":"us-east-1" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := updated.ModelList[0].Model; got != "us.anthropic.claude-3-7-sonnet-20250219-v1:0" { + t.Fatalf("model = %q, want updated bedrock model", got) + } + if got := updated.ModelList[0].APIBase; got != "us-east-1" { + t.Fatalf("api_base = %q, want %q", got, "us-east-1") + } +} + func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -1147,6 +2110,45 @@ func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) { } } +func TestHandleSetDefaultModel_RejectsElevenLabsASRProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{ + "model_name": "elevenlabs-asr" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "cannot be used as the default chat model") { + t.Fatalf("body = %q, want default chat model rejection", rec.Body.String()) + } +} + func TestMaskAPIKey(t *testing.T) { tests := []struct { name string diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 926bf8a0a..5bb275fde 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -27,12 +27,24 @@ export interface ModelInfo { status: "available" | "unconfigured" | "unreachable" is_default: boolean is_virtual: boolean + default_model_allowed?: boolean +} + +export interface ModelProviderOption { + id: string + default_api_base: string + empty_api_key_allowed: boolean + create_allowed: boolean + default_model_allowed: boolean + default_auth_method?: string + auth_method_locked?: boolean } interface ModelsListResponse { models: ModelInfo[] total: number default_model: string + provider_options: ModelProviderOption[] } interface ModelActionResponse { diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index 376c42263..e0f51596a 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -1,8 +1,12 @@ import { IconLoader2 } from "@tabler/icons-react" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { addModel, setDefaultModel } from "@/api/models" +import { + type ModelProviderOption, + addModel, + setDefaultModel, +} from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { @@ -13,6 +17,13 @@ import { } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Sheet, SheetContent, @@ -25,6 +36,15 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" +import { + findProviderOption, + getProviderDefaultAPIBase, + getProviderDefaultAuthMethod, + getProviderLabel, + getSortedProviderOptions, + isProviderAuthMethodLocked, +} from "./provider-label" + interface AddForm { modelName: string provider: string @@ -46,7 +66,7 @@ interface AddForm { const EMPTY_ADD_FORM: AddForm = { modelName: "", - provider: "", + provider: "openai", model: "", apiBase: "", apiKey: "", @@ -68,6 +88,7 @@ interface AddModelSheetProps { onClose: () => void onSaved: () => void existingModelNames: string[] + providerOptions: ModelProviderOption[] } export function AddModelSheet({ @@ -75,6 +96,7 @@ export function AddModelSheet({ onClose, onSaved, existingModelNames, + providerOptions, }: AddModelSheetProps) { const { t } = useTranslation() const [form, setForm] = useState(EMPTY_ADD_FORM) @@ -88,6 +110,37 @@ export function AddModelSheet({ form.apiKey, t("models.field.apiKeyPlaceholder"), ) + const sortedProviderOptions = useMemo( + () => getSortedProviderOptions(providerOptions), + [providerOptions], + ) + const creatableProviderOptions = useMemo( + () => sortedProviderOptions.filter((option) => option.create_allowed), + [sortedProviderOptions], + ) + const selectedProviderOption = findProviderOption( + form.provider, + providerOptions, + ) + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + const defaultAuthMethod = getProviderDefaultAuthMethod( + form.provider, + providerOptions, + ) + const effectiveAuthMethod = ( + authMethodLocked ? defaultAuthMethod : form.authMethod + ) + .trim() + .toLowerCase() + const isOAuth = effectiveAuthMethod === "oauth" + const defaultModelAllowed = + selectedProviderOption?.default_model_allowed !== false + const apiBasePlaceholder = + getProviderDefaultAPIBase(form.provider, providerOptions) || + "https://api.example.com/v1" const isDirty = JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault @@ -108,6 +161,9 @@ export function AddModelSheet({ } else if (existingModelNames.some((name) => name.trim() === modelName)) { errors.modelName = t("models.add.errorDuplicateModelName") } + if (!selectedProviderOption) { + errors.provider = t("models.field.providerInvalid") + } if (!form.model.trim()) errors.model = t("models.add.errorRequired") setFieldErrors(errors) return Object.keys(errors).length === 0 @@ -122,22 +178,47 @@ export function AddModelSheet({ } } + const setProvider = (value: string) => { + setForm((f) => { + const previousOption = findProviderOption(f.provider, providerOptions) + const nextOption = findProviderOption(value, providerOptions) + let authMethod = f.authMethod + if (nextOption?.auth_method_locked) { + authMethod = nextOption.default_auth_method ?? "" + } else if ( + previousOption?.auth_method_locked && + f.authMethod === (previousOption.default_auth_method ?? "") + ) { + authMethod = "" + } + return { ...f, provider: value, authMethod } + }) + const nextOption = findProviderOption(value, providerOptions) + if (nextOption?.default_model_allowed === false) { + setSetAsDefault(false) + } + if (fieldErrors.provider) { + setFieldErrors((prev) => ({ ...prev, provider: undefined })) + } + } + const handleSave = async () => { if (!validate()) return setSaving(true) setServerError("") try { const modelName = form.modelName.trim() - const provider = form.provider.trim() const modelId = form.model.trim() await addModel({ model_name: modelName, - provider: provider || undefined, + provider: form.provider.trim(), model: modelId, api_base: form.apiBase.trim() || undefined, api_key: form.apiKey.trim() || undefined, proxy: form.proxy.trim() || undefined, - auth_method: form.authMethod.trim() || undefined, + auth_method: authMethodLocked + ? defaultAuthMethod || undefined + : form.authMethod.trim() || undefined, connect_mode: form.connectMode.trim() || undefined, workspace: form.workspace.trim() || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -208,12 +289,29 @@ export function AddModelSheet({ - + - - setForm((f) => ({ ...f, apiKey: v }))} - placeholder={apiKeyPlaceholder} - /> - + {!isOAuth && ( + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={apiKeyPlaceholder} + /> + + )} - + @@ -269,12 +378,17 @@ export function AddModelSheet({ diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index d0810e6d6..82d3cf97f 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -1,8 +1,13 @@ import { IconLoader2 } from "@tabler/icons-react" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models" +import { + type ModelInfo, + type ModelProviderOption, + setDefaultModel, + updateModel, +} from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { @@ -13,6 +18,13 @@ import { } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Sheet, SheetContent, @@ -25,6 +37,15 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" +import { + findProviderOption, + getProviderDefaultAPIBase, + getProviderDefaultAuthMethod, + getProviderLabel, + getSortedProviderOptions, + isProviderAuthMethodLocked, +} from "./provider-label" + interface EditForm { provider: string modelId: string @@ -45,6 +66,7 @@ interface EditForm { interface EditModelSheetProps { model: ModelInfo | null + providerOptions: ModelProviderOption[] open: boolean onClose: () => void onSaved: () => void @@ -76,6 +98,7 @@ function buildInitialEditForm(model: ModelInfo): EditForm { export function EditModelSheet({ model, + providerOptions, open, onClose, onSaved, @@ -102,26 +125,99 @@ export function EditModelSheet({ const [setAsDefault, setSetAsDefault] = useState(false) const [error, setError] = useState("") const initialForm = model ? buildInitialEditForm(model) : null + const sortedProviderOptions = useMemo( + () => getSortedProviderOptions(providerOptions), + [providerOptions], + ) + const currentProviderID = model + ? (findProviderOption(model.provider, providerOptions)?.id ?? + model.provider?.trim().toLowerCase() ?? + "") + : "" + const selectedProviderOption = findProviderOption( + form.provider, + providerOptions, + ) + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + const defaultAuthMethod = getProviderDefaultAuthMethod( + form.provider, + providerOptions, + ) + const effectiveAuthMethod = ( + authMethodLocked ? defaultAuthMethod : form.authMethod + ) + .trim() + .toLowerCase() + const providerError = selectedProviderOption + ? "" + : t("models.field.providerInvalid") + const defaultModelAllowed = + selectedProviderOption?.default_model_allowed !== false + const willClearDefaultOnSave = + model?.is_default === true && defaultModelAllowed === false + const apiBasePlaceholder = + getProviderDefaultAPIBase(form.provider, providerOptions) || + "https://api.example.com/v1" const isDirty = model != null && (JSON.stringify(form) !== JSON.stringify(initialForm) || setAsDefault !== model.is_default) useEffect(() => { - if (model) { - setForm(buildInitialEditForm(model)) - setSetAsDefault(model.is_default) - setError("") + if (model) { + const initialForm = buildInitialEditForm(model) + const option = findProviderOption(initialForm.provider, providerOptions) + if (option?.auth_method_locked && !initialForm.authMethod) { + initialForm.authMethod = option.default_auth_method ?? "" } - }, [model]) + setForm(initialForm) + setSetAsDefault(model.is_default && model.default_model_allowed !== false) + setError("") + } + }, [model, providerOptions]) const setField = (key: keyof EditForm) => - (e: React.ChangeEvent) => + (e: React.ChangeEvent) => { + if (error) { + setError("") + } setForm((f) => ({ ...f, [key]: e.target.value })) + } + + const setProvider = (value: string) => { + if (error) { + setError("") + } + setForm((f) => { + const previousOption = findProviderOption(f.provider, providerOptions) + const nextOption = findProviderOption(value, providerOptions) + let authMethod = f.authMethod + if (nextOption?.auth_method_locked) { + authMethod = nextOption.default_auth_method ?? "" + } else if ( + previousOption?.auth_method_locked && + f.authMethod === (previousOption.default_auth_method ?? "") + ) { + authMethod = "" + } + return { ...f, provider: value, authMethod } + }) + const nextOption = findProviderOption(value, providerOptions) + if (nextOption?.default_model_allowed === false) { + setSetAsDefault(false) + } + } const handleSave = async () => { if (!model) return + if (!selectedProviderOption) { + setError(providerError) + return + } if (!form.modelId.trim()) { setError(t("models.add.errorRequired")) return @@ -136,7 +232,9 @@ export function EditModelSheet({ api_base: form.apiBase || undefined, api_key: form.apiKey || undefined, proxy: form.proxy || undefined, - auth_method: form.authMethod || undefined, + auth_method: authMethodLocked + ? defaultAuthMethod || undefined + : form.authMethod || undefined, connect_mode: form.connectMode || undefined, workspace: form.workspace || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -172,7 +270,7 @@ export function EditModelSheet({ } } - const isOAuth = model?.auth_method === "oauth" + const isOAuth = effectiveAuthMethod === "oauth" const hasSavedAPIKey = Boolean(model?.api_key) const apiKeyPlaceholder = hasSavedAPIKey ? maskedSecretPlaceholder( @@ -201,12 +299,36 @@ export function EditModelSheet({ - + @@ -267,12 +396,17 @@ export function EditModelSheet({ diff --git a/web/frontend/src/components/models/model-card.tsx b/web/frontend/src/components/models/model-card.tsx index 44730bb57..e53fcdeca 100644 --- a/web/frontend/src/components/models/model-card.tsx +++ b/web/frontend/src/components/models/model-card.tsx @@ -36,7 +36,10 @@ export function ModelCard({ const status = model.status const statusLabel = t(`models.status.${status}`) const canSetDefault = - model.available && !model.is_default && !model.is_virtual + model.available && + !model.is_default && + !model.is_virtual && + model.default_model_allowed !== false const setDefaultLabel = t("models.action.setDefault") const setDefaultDisabledReason = (() => { @@ -45,6 +48,9 @@ export function ModelCard({ return t("models.action.setDefaultDisabled.unavailable") if (model.is_default) return t("models.action.setDefaultDisabled.isDefault") if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual") + if (model.default_model_allowed === false) { + return t("models.action.setDefaultDisabled.unsupportedProvider") + } return setDefaultLabel })() diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index 152c47585..df372b6b1 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -3,7 +3,12 @@ import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" -import { type ModelInfo, getModels, setDefaultModel } from "@/api/models" +import { + type ModelInfo, + type ModelProviderOption, + getModels, + setDefaultModel, +} from "@/api/models" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" @@ -12,41 +17,13 @@ import { refreshGatewayState } from "@/store/gateway" import { AddModelSheet } from "./add-model-sheet" import { DeleteModelDialog } from "./delete-model-dialog" import { EditModelSheet } from "./edit-model-sheet" -import { getProviderKey, getProviderLabel } from "./provider-label" +import { + PROVIDER_PRIORITY, + getProviderKey, + getProviderLabel, +} from "./provider-label" import { ProviderSection } from "./provider-section" -const PROVIDER_PRIORITY: Record = { - volcengine: 0, - openai: 1, - gemini: 2, - anthropic: 3, - zhipu: 4, - deepseek: 5, - openrouter: 6, - "qwen-portal": 7, - "qwen-intl": 8, - moonshot: 9, - groq: 10, - "github-copilot": 11, - antigravity: 12, - nvidia: 13, - cerebras: 14, - shengsuanyun: 15, - venice: 16, - vivgrid: 17, - minimax: 18, - longcat: 19, - modelscope: 20, - mistral: 21, - avian: 22, - azure: 23, - ollama: 24, - vllm: 25, - lmstudio: 26, - zai: 27, - mimo: 28, -} - interface ProviderGroup { key: string label: string @@ -58,6 +35,9 @@ interface ProviderGroup { export function ModelsPage() { const { t } = useTranslation() const [models, setModels] = useState([]) + const [providerOptions, setProviderOptions] = useState( + [], + ) const [loading, setLoading] = useState(true) const [fetchError, setFetchError] = useState("") @@ -67,6 +47,7 @@ export function ModelsPage() { const [settingDefaultIndex, setSettingDefaultIndex] = useState( null, ) + const addDisabled = loading || providerOptions.length === 0 const fetchModels = useCallback(async () => { try { @@ -79,6 +60,7 @@ export function ModelsPage() { return a.model_name.localeCompare(b.model_name) }) setModels(sorted) + setProviderOptions(data.provider_options ?? []) setFetchError("") } catch (e) { setFetchError(e instanceof Error ? e.message : t("models.loadError")) @@ -160,7 +142,12 @@ export function ModelsPage() {
- @@ -213,6 +200,7 @@ export function ModelsPage() { setEditingModel(null)} onSaved={fetchModels} @@ -220,6 +208,7 @@ export function ModelsPage() { setAddOpen(false)} onSaved={fetchModels} existingModelNames={models.map((model) => model.model_name)} diff --git a/web/frontend/src/components/models/provider-icon.tsx b/web/frontend/src/components/models/provider-icon.tsx index 8d1cfe2c9..2ac728e76 100644 --- a/web/frontend/src/components/models/provider-icon.tsx +++ b/web/frontend/src/components/models/provider-icon.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react" const PROVIDER_ICON_SLUGS: Record = { openai: "openai", + elevenlabs: "elevenlabs", anthropic: "anthropic", azure: "microsoftazure", gemini: "googlegemini", @@ -21,6 +22,7 @@ const PROVIDER_ICON_SLUGS: Record = { const PROVIDER_DOMAINS: Record = { openai: "openai.com", + elevenlabs: "elevenlabs.io", anthropic: "anthropic.com", azure: "azure.com", gemini: "gemini.google.com", diff --git a/web/frontend/src/components/models/provider-label.ts b/web/frontend/src/components/models/provider-label.ts index 123640fe5..75eb81e53 100644 --- a/web/frontend/src/components/models/provider-label.ts +++ b/web/frontend/src/components/models/provider-label.ts @@ -1,11 +1,19 @@ +import type { ModelProviderOption } from "@/api/models" + const PROVIDER_LABELS: Record = { openai: "OpenAI", + bedrock: "AWS Bedrock", + elevenlabs: "ElevenLabs ASR", anthropic: "Anthropic", + "anthropic-messages": "Anthropic Messages", azure: "Azure OpenAI", gemini: "Google Gemini", deepseek: "DeepSeek", + "coding-plan": "Alibaba Coding Plan", + "coding-plan-anthropic": "Alibaba Coding Plan (Anthropic)", "qwen-portal": "Qwen (阿里云)", "qwen-intl": "Qwen International", + "qwen-us": "Qwen US", moonshot: "Moonshot (月之暗面)", groq: "Groq", openrouter: "OpenRouter", @@ -15,8 +23,11 @@ const PROVIDER_LABELS: Record = { shengsuanyun: "ShengsuanYun (神算云)", antigravity: "Google Code Assist", "github-copilot": "GitHub Copilot", + "claude-cli": "Claude CLI (local)", + "codex-cli": "Codex CLI (local)", ollama: "Ollama (local)", lmstudio: "LM Studio (local)", + litellm: "LiteLLM", mistral: "Mistral AI", avian: "Avian", vllm: "VLLM (local)", @@ -28,6 +39,7 @@ const PROVIDER_LABELS: Record = { minimax: "MiniMax", longcat: "LongCat", modelscope: "ModelScope (魔搭社区)", + novita: "Novita AI", } const PROVIDER_ALIASES: Record = { @@ -40,6 +52,48 @@ const PROVIDER_ALIASES: Record = { "google-antigravity": "antigravity", } +export const PROVIDER_PRIORITY: Record = { + volcengine: 0, + openai: 1, + gemini: 2, + anthropic: 3, + bedrock: 4, + elevenlabs: 5, + "anthropic-messages": 6, + zhipu: 7, + deepseek: 8, + openrouter: 9, + "qwen-portal": 10, + "qwen-intl": 11, + "qwen-us": 12, + moonshot: 13, + groq: 14, + "coding-plan": 15, + "coding-plan-anthropic": 16, + "github-copilot": 17, + antigravity: 18, + nvidia: 19, + cerebras: 20, + shengsuanyun: 21, + venice: 22, + vivgrid: 23, + minimax: 24, + longcat: 25, + modelscope: 26, + mistral: 27, + avian: 28, + novita: 29, + azure: 30, + litellm: 31, + ollama: 32, + vllm: 33, + lmstudio: 34, + "claude-cli": 35, + "codex-cli": 36, + zai: 37, + mimo: 38, +} + export function getProviderKey(provider?: string): string { const normalized = provider?.trim().toLowerCase() if (!normalized) return "openai" @@ -50,3 +104,45 @@ export function getProviderLabel(provider?: string): string { const prefix = getProviderKey(provider) return PROVIDER_LABELS[prefix] ?? prefix } + +export function findProviderOption( + provider: string | undefined, + options: ModelProviderOption[], +): ModelProviderOption | undefined { + const providerKey = getProviderKey(provider) + return options.find((option) => option.id === providerKey) +} + +export function getProviderDefaultAPIBase( + provider: string | undefined, + options: ModelProviderOption[], +): string { + return findProviderOption(provider, options)?.default_api_base ?? "" +} + +export function getSortedProviderOptions( + options: ModelProviderOption[], +): ModelProviderOption[] { + return [...options].sort((a, b) => { + const aPriority = PROVIDER_PRIORITY[a.id] ?? Number.MAX_SAFE_INTEGER + const bPriority = PROVIDER_PRIORITY[b.id] ?? Number.MAX_SAFE_INTEGER + if (aPriority !== bPriority) { + return aPriority - bPriority + } + return getProviderLabel(a.id).localeCompare(getProviderLabel(b.id)) + }) +} + +export function getProviderDefaultAuthMethod( + provider: string | undefined, + options: ModelProviderOption[], +): string { + return findProviderOption(provider, options)?.default_auth_method ?? "" +} + +export function isProviderAuthMethodLocked( + provider: string | undefined, + options: ModelProviderOption[], +): boolean { + return findProviderOption(provider, options)?.auth_method_locked === true +} diff --git a/web/frontend/src/hooks/use-chat-models.ts b/web/frontend/src/hooks/use-chat-models.ts index 337bea8db..98566f70f 100644 --- a/web/frontend/src/hooks/use-chat-models.ts +++ b/web/frontend/src/hooks/use-chat-models.ts @@ -27,17 +27,26 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { const [defaultModelName, setDefaultModelName] = useState("") const setDefaultRequestIdRef = useRef(0) + const syncDefaultModelName = useCallback( + (models: ModelInfo[], defaultModel: string) => { + if (models.some((m) => m.model_name === defaultModel)) { + setDefaultModelName(defaultModel) + return + } + setDefaultModelName("") + }, + [], + ) + const loadModels = useCallback(async () => { try { const data = await getModels() setModelList(data.models) - if (data.models.some((m) => m.model_name === data.default_model)) { - setDefaultModelName(data.default_model) - } + syncDefaultModelName(data.models, data.default_model) } catch { // silently fail } - }, []) + }, [syncDefaultModelName]) useEffect(() => { const timerId = setTimeout(() => { @@ -60,9 +69,7 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { } setModelList(data.models) - if (data.models.some((m) => m.model_name === data.default_model)) { - setDefaultModelName(data.default_model) - } + syncDefaultModelName(data.models, data.default_model) const gateway = await refreshGatewayState({ force: true }) showSaveSuccessOrRestartToast( t, @@ -75,30 +82,41 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { toast.error(err instanceof Error ? err.message : t("models.loadError")) } }, - [defaultModelName, t], + [defaultModelName, syncDefaultModelName, t], + ) + + const defaultSelectableModels = useMemo( + () => + modelList.filter( + (m) => m.default_model_allowed !== false && m.is_virtual !== true, + ), + [modelList], ) const hasAvailableModels = useMemo( - () => modelList.some((m) => m.available), - [modelList], + () => defaultSelectableModels.some((m) => m.available), + [defaultSelectableModels], ) const oauthModels = useMemo( - () => modelList.filter((m) => m.available && m.auth_method === "oauth"), - [modelList], + () => + defaultSelectableModels.filter( + (m) => m.available && m.auth_method === "oauth", + ), + [defaultSelectableModels], ) const localModels = useMemo( - () => modelList.filter((m) => m.available && isLocalModel(m)), - [modelList], + () => defaultSelectableModels.filter((m) => m.available && isLocalModel(m)), + [defaultSelectableModels], ) const apiKeyModels = useMemo( () => - modelList.filter( + defaultSelectableModels.filter( (m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m), ), - [modelList], + [defaultSelectableModels], ) return { diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 634e509a2..029691aba 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -236,7 +236,8 @@ "setting": "Setting as default...", "unavailable": "Cannot set unavailable model as default", "isDefault": "Already the default model", - "isVirtual": "Cannot set virtual model as default" + "isVirtual": "Cannot set virtual model as default", + "unsupportedProvider": "This provider is ASR-only and cannot be the default chat model" }, "deleteDisabled": { "isDefault": "Cannot delete the default model" @@ -244,7 +245,9 @@ }, "defaultOnSave": { "label": "Default Model", - "description": "Automatically set this model as default after saving." + "description": "Automatically set this model as default after saving.", + "unsupportedProvider": "This provider can be saved in model_list, but it cannot be used as the default chat model.", + "clearOnSave": "Saving this ASR-only model will clear the current default chat model selection." }, "add": { "button": "Add Model", @@ -255,7 +258,7 @@ "modelNameHint": "A short name used to identify this model in conversations.", "modelId": "Model Identifier", "modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o", - "modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.", + "modelIdHint": "This field is sent as the canonical model ID for the selected Provider. If the model ID itself contains slashes, such as openai/gpt-5.4, it is preserved as-is instead of being split again.", "errorRequired": "This field is required.", "errorDuplicateModelName": "Model alias already exists. Please use a different name.", "saveError": "Failed to add model", @@ -272,8 +275,9 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "e.g. openai", - "providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.", + "providerPlaceholder": "Select a provider", + "providerHint": "Choose a Provider from the backend catalog. The Model Identifier field is interpreted as that Provider's canonical model ID.", + "providerInvalid": "The current Provider is invalid. Select a supported Provider.", "apiBase": "API Base URL", "apiKey": "API Key", "apiKeyPlaceholder": "Enter your API key", @@ -282,6 +286,7 @@ "proxyHint": "Optional. e.g. http://127.0.0.1:7890", "authMethod": "Auth Method", "authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.", + "authMethodManagedHint": "This Provider manages its authentication mode automatically.", "connectMode": "Connect Mode", "connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.", "workspace": "Workspace Path", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 3cd6f6c54..c2076135e 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -236,7 +236,8 @@ "setting": "正在设为默认...", "unavailable": "无法将不可用的模型设为默认", "isDefault": "该模型已是默认模型", - "isVirtual": "无法将虚拟模型设为默认" + "isVirtual": "无法将虚拟模型设为默认", + "unsupportedProvider": "该 Provider 仅用于 ASR,不能设为默认聊天模型" }, "deleteDisabled": { "isDefault": "无法删除默认模型" @@ -244,7 +245,9 @@ }, "defaultOnSave": { "label": "默认模型", - "description": "保存后自动将该模型设置为默认模型。" + "description": "保存后自动将该模型设置为默认模型。", + "unsupportedProvider": "该 Provider 可以保存在 model_list 中,但不能作为默认聊天模型使用。", + "clearOnSave": "保存这个仅用于 ASR 的模型后,会清除当前的默认聊天模型设置。" }, "add": { "button": "添加模型", @@ -255,7 +258,7 @@ "modelNameHint": "用于在对话中识别此模型的简短名称。", "modelId": "模型标识符", "modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o", - "modelIdHint": "未指定 Provider 时,诸如 openai/gpt-4o 的值将按 provider/model 格式解析。已指定 Provider 时,此字段将作为规范模型 ID 使用,不再解析其中的 provider 前缀。", + "modelIdHint": "此字段将作为所选 Provider 的规范模型 ID 使用。若模型标识符本身包含斜杠(如 openai/gpt-5.4),将作为完整 ID 保留,不会再次拆分 Provider。", "errorRequired": "此字段为必填项。", "errorDuplicateModelName": "模型别名已存在,请使用其他名称。", "saveError": "添加模型失败", @@ -272,8 +275,9 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "例如 openai", - "providerHint": "可选。指定后,将以该值作为最终 provider,并将“模型标识符”字段解释为规范模型 ID。", + "providerPlaceholder": "请选择 Provider", + "providerHint": "请选择一个由后端 catalog 提供的 Provider;“模型标识符”字段会按该 Provider 的规范模型 ID 解释。", + "providerInvalid": "当前 Provider 无效,请重新选择一个受支持的 Provider。", "apiBase": "API Base URL", "apiKey": "API Key", "apiKeyPlaceholder": "请输入 API Key", @@ -282,6 +286,7 @@ "proxyHint": "可选。例如 http://127.0.0.1:7890", "authMethod": "认证方式", "authMethodHint": "认证方式:oauth、token。留空表示使用 API Key 认证。", + "authMethodManagedHint": "该 Provider 的认证方式由系统自动管理。", "connectMode": "连接模式", "connectModeHint": "CLI 型服务商的连接模式:stdio 或 grpc。", "workspace": "工作目录",