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 8edb8159b..66bea3ef1 100644 --- a/pkg/audio/asr/asr.go +++ b/pkg/audio/asr/asr.go @@ -78,21 +78,7 @@ func isElevenLabsTranscriptionModel(modelCfg *config.ModelConfig) bool { } protocol, _ := providers.ExtractProtocol(modelCfg) - if protocol == "elevenlabs" { - return true - } - if strings.TrimSpace(modelCfg.Provider) != "" { - return false - } - - legacyProvider, legacyModel, found := strings.Cut(strings.TrimSpace(modelCfg.Model), "/") - if !found || strings.TrimSpace(legacyModel) == "" { - return false - } - - // Keep legacy elevenlabs/... ASR configs working even though the shared - // provider catalog no longer treats elevenlabs as a general model provider. - return providers.NormalizeProvider(legacyProvider) == "elevenlabs" + return protocol == "elevenlabs" } func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber { 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/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 889b08e98..4a8d19e9a 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -987,6 +987,16 @@ func TestModelProviderOptions(t *testing.T) { } 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 { diff --git a/pkg/providers/provider_catalog.go b/pkg/providers/provider_catalog.go index 077c28944..a9178cb81 100644 --- a/pkg/providers/provider_catalog.go +++ b/pkg/providers/provider_catalog.go @@ -7,19 +7,21 @@ import ( // 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"` - DefaultAuthMethod string `json:"default_auth_method,omitempty"` - AuthMethodLocked bool `json:"auth_method_locked,omitempty"` + 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 - defaultAuthMethod string - authMethodLocked bool + createAllowed bool + defaultModelAllowed bool + defaultAuthMethod string + authMethodLocked bool } // attachedModelProviderMetaByName augments protocolMetaByName for provider @@ -27,20 +29,39 @@ type attachedModelProviderMeta struct { // kept out of the core HTTP metadata map because they have special auth/runtime // semantics. var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{ - "azure": {createAllowed: true}, + "azure": {createAllowed: true, defaultModelAllowed: true}, "anthropic": { - protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, - createAllowed: true, + protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, + createAllowed: true, + defaultModelAllowed: true, }, "anthropic-messages": { - protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, - createAllowed: true, + 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, }, - "bedrock": {createAllowed: true}, - "antigravity": {createAllowed: true, defaultAuthMethod: "oauth", authMethodLocked: true}, - "claude-cli": {createAllowed: true}, - "codex-cli": {createAllowed: true}, - "github-copilot": {protocolMeta: protocolMeta{defaultAPIBase: "localhost:4321"}, createAllowed: true}, } // ModelProviderOptions returns the canonical provider catalog exposed to the Web UI. @@ -51,10 +72,11 @@ func ModelProviderOptions() []ModelProviderOption { continue } optionsByID[provider] = ModelProviderOption{ - ID: provider, - DefaultAPIBase: DefaultAPIBaseForProtocol(provider), - EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider), - CreateAllowed: true, + ID: provider, + DefaultAPIBase: DefaultAPIBaseForProtocol(provider), + EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider), + CreateAllowed: true, + DefaultModelAllowed: true, } } for provider, meta := range attachedModelProviderMetaByName { @@ -62,12 +84,13 @@ func ModelProviderOptions() []ModelProviderOption { continue } optionsByID[provider] = ModelProviderOption{ - ID: provider, - DefaultAPIBase: meta.defaultAPIBase, - EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed, - CreateAllowed: meta.createAllowed, - DefaultAuthMethod: meta.defaultAuthMethod, - AuthMethodLocked: meta.authMethodLocked, + ID: provider, + DefaultAPIBase: meta.defaultAPIBase, + EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed, + CreateAllowed: meta.createAllowed, + DefaultModelAllowed: meta.defaultModelAllowed, + DefaultAuthMethod: meta.defaultAuthMethod, + AuthMethodLocked: meta.authMethodLocked, } } @@ -109,6 +132,21 @@ func IsCreatableModelProvider(provider string) bool { 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. 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/models.go b/web/backend/api/models.go index a889e7020..7994fdb77 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -44,53 +44,12 @@ 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"` -} - -func legacyUnsupportedASRProviderAndModel(rawModel string) (provider, modelID string, ok bool) { - provider, modelID, found := strings.Cut(strings.TrimSpace(rawModel), "/") - if !found { - return "", "", false - } - - provider = providers.NormalizeProvider(provider) - modelID = strings.TrimSpace(modelID) - if modelID == "" { - return "", "", false - } - - switch provider { - case "elevenlabs": - // Keep the documented legacy ASR-only form elevenlabs/scribe_v1 stable - // even though elevenlabs is not part of the general model provider - // catalog exposed by the Web model-management UI. - return provider, modelID, true - default: - return "", "", false - } -} - -func isLegacyUnsupportedASRModelConfig(mc *config.ModelConfig) bool { - if mc == nil || strings.TrimSpace(mc.Provider) != "" { - return false - } - - _, _, ok := legacyUnsupportedASRProviderAndModel(mc.Model) - return ok -} - -func responseProviderAndModel(mc *config.ModelConfig) (provider, modelID string) { - if strings.TrimSpace(mc.Provider) == "" { - if legacyProvider, legacyModelID, ok := legacyUnsupportedASRProviderAndModel(mc.Model); ok { - return legacyProvider, legacyModelID - } - } - - return providers.ExtractProtocol(mc) + 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 { @@ -121,9 +80,19 @@ func normalizeStoredModelConfig(mc *config.ModelConfig) bool { mc.Provider = normalizedProvider changed = true } - return changed - } - if isLegacyUnsupportedASRModelConfig(mc) { + 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 + } + } + } return changed } @@ -151,12 +120,17 @@ func normalizeIncomingModelConfig(mc *config.ModelConfig) { mc.Provider = strings.TrimSpace(mc.Provider) mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod)) if mc.Provider == "" { - if isLegacyUnsupportedASRModelConfig(mc) { - return - } 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" @@ -199,6 +173,11 @@ func modelProviderOptionsForResponse() []providers.ModelProviderOption { 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") @@ -207,9 +186,6 @@ func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelC return err } if strings.TrimSpace(mc.Provider) == "" { - if existing != nil && isLegacyUnsupportedASRModelConfig(existing) && isLegacyUnsupportedASRModelConfig(mc) { - return nil - } return fmt.Errorf("provider is required") } if !providers.IsSupportedModelProvider(mc.Provider) { @@ -266,29 +242,30 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { models := make([]modelResponse, 0, len(cfg.ModelList)) for i, m := range cfg.ModelList { - provider, modelID := responseProviderAndModel(m) + provider, modelID := providers.ExtractProtocol(m) models = append(models, modelResponse{ - Index: i, - ModelName: m.ModelName, - Provider: provider, - Model: modelID, - APIBase: m.APIBase, - APIKey: maskAPIKey(m.APIKey()), - Proxy: m.Proxy, - AuthMethod: m.AuthMethod, - ConnectMode: m.ConnectMode, - Workspace: m.Workspace, - RPM: m.RPM, - MaxTokensField: m.MaxTokensField, - RequestTimeout: m.RequestTimeout, - ThinkingLevel: m.ThinkingLevel, - ExtraBody: m.ExtraBody, - CustomHeaders: m.CustomHeaders, - Enabled: m.Enabled, - Available: modelStatuses[i].Available, - Status: modelStatuses[i].Status, - IsDefault: m.ModelName == defaultModel, - IsVirtual: m.IsVirtual(), + Index: i, + ModelName: m.ModelName, + Provider: provider, + Model: modelID, + APIBase: m.APIBase, + APIKey: maskAPIKey(m.APIKey()), + Proxy: m.Proxy, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + ExtraBody: m.ExtraBody, + CustomHeaders: m.CustomHeaders, + Enabled: m.Enabled, + Available: modelStatuses[i].Available, + Status: modelStatuses[i].Status, + IsDefault: m.ModelName == defaultModel, + IsVirtual: m.IsVirtual(), + DefaultModelAllowed: defaultModelAllowedForModelConfig(m), }) } @@ -439,34 +416,18 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" { existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model) incomingModel := strings.TrimSpace(mc.Model) - if legacyProvider, legacyModelID, ok := legacyUnsupportedASRProviderAndModel(existingRawModel); ok { - if incomingModel != "" { - if incomingModel == legacyModelID { - mc.Model = existingRawModel - } else if strings.Contains(incomingModel, "/") && !strings.Contains(legacyModelID, "/") { - // Older clients only saw the visible legacy ASR model ID - // (for example "scribe_v1"). If they now send an explicit - // provider/model string, keep that full intent instead of - // silently re-applying the hidden ElevenLabs prefix. - mc.Model = incomingModel - } else if !strings.HasPrefix(incomingModel, legacyProvider+"/") { - mc.Model = legacyProvider + "/" + incomingModel - } - } - } else { - existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) - if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" { - if incomingModel == existingModelID { - mc.Model = existingRawModel - } else if strings.Contains(incomingModel, "/") && !strings.Contains(existingModelID, "/") { - // Older clients never saw the hidden provider prefix for simple - // legacy entries such as "openai/gpt-4o". If they now send an - // explicit provider/model string, treat it as the caller's full - // intent instead of re-applying the old hidden prefix. - mc.Model = incomingModel - } else if !strings.HasPrefix(incomingModel, existingProtocol+"/") { - mc.Model = existingProtocol + "/" + incomingModel - } + existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) + if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" { + if incomingModel == existingModelID { + mc.Model = existingRawModel + } else if strings.Contains(incomingModel, "/") && !strings.Contains(existingModelID, "/") { + // Older clients never saw the hidden provider prefix for simple + // legacy entries such as "openai/gpt-4o". If they now send an + // explicit provider/model string, treat it as the caller's full + // intent instead of re-applying the old hidden prefix. + mc.Model = incomingModel + } else if !strings.HasPrefix(incomingModel, existingProtocol+"/") { + mc.Model = existingProtocol + "/" + incomingModel } } } @@ -477,6 +438,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { 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) @@ -579,6 +546,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 3cc7e36cc..8463f0ee1 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -820,7 +820,7 @@ func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) { } } -func TestHandleAddModel_PreservesLegacyElevenLabsASRConfig(t *testing.T) { +func TestHandleAddModel_NormalizesLegacyElevenLabsASRConfig(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -862,11 +862,11 @@ func TestHandleAddModel_PreservesLegacyElevenLabsASRConfig(t *testing.T) { if len(updated.ModelList) != 2 { t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList)) } - if got := updated.ModelList[0].Provider; got != "" { - t.Fatalf("provider = %q, want preserved empty provider for legacy ElevenLabs ASR config", got) + 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 != "elevenlabs/scribe_v1" { - t.Fatalf("model = %q, want preserved legacy ElevenLabs model ref", got) + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") } } @@ -1208,7 +1208,7 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { } } -func TestHandleListModels_ExposesLegacyElevenLabsASRProvider(t *testing.T) { +func TestHandleListModels_ExposesElevenLabsASRProvider(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -1247,10 +1247,13 @@ func TestHandleListModels_ExposesLegacyElevenLabsASRProvider(t *testing.T) { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if got := resp.Models[0].Provider; got != "elevenlabs" { - t.Fatalf("provider = %q, want %q for legacy unsupported ASR entry", 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 for legacy unsupported ASR entry", 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") } } @@ -1324,7 +1327,7 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *test } } -func TestHandleUpdateModel_PreservesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) { +func TestHandleUpdateModel_MigratesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -1386,17 +1389,63 @@ func TestHandleUpdateModel_PreservesLegacyElevenLabsASRWhenProviderOmitted(t *te if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := updated.ModelList[0].Provider; got != "" { - t.Fatalf("provider = %q, want preserved empty provider", got) + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") } - if got := updated.ModelList[0].Model; got != "elevenlabs/scribe_v1" { - t.Fatalf("model = %q, want preserved legacy model ref", got) + 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 TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -1512,6 +1561,16 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration } 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 { @@ -1809,6 +1868,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 c334e02ea..3077afa6a 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -26,6 +26,7 @@ export interface ModelInfo { status: "available" | "unconfigured" | "unreachable" is_default: boolean is_virtual: boolean + default_model_allowed?: boolean } export interface ModelProviderOption { @@ -33,6 +34,7 @@ export interface ModelProviderOption { default_api_base: string empty_api_key_allowed: boolean create_allowed: boolean + default_model_allowed: boolean default_auth_method?: string auth_method_locked?: boolean } diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index b84614076..ff194d9bd 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -134,6 +134,8 @@ export function AddModelSheet({ .trim() .toLowerCase() const isOAuth = effectiveAuthMethod === "oauth" + const defaultModelAllowed = + selectedProviderOption?.default_model_allowed !== false const apiBasePlaceholder = getProviderDefaultAPIBase(form.provider, providerOptions) || "https://api.example.com/v1" @@ -189,6 +191,10 @@ export function AddModelSheet({ } 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 })) } @@ -345,9 +351,14 @@ 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 9919e2674..a3456c3c8 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -151,6 +151,10 @@ export function EditModelSheet({ 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" @@ -167,7 +171,7 @@ export function EditModelSheet({ initialForm.authMethod = option.default_auth_method ?? "" } setForm(initialForm) - setSetAsDefault(model.is_default) + setSetAsDefault(model.is_default && model.default_model_allowed !== false) setError("") } }, [model, providerOptions]) @@ -199,6 +203,10 @@ export function EditModelSheet({ } return { ...f, provider: value, authMethod } }) + const nextOption = findProviderOption(value, providerOptions) + if (nextOption?.default_model_allowed === false) { + setSetAsDefault(false) + } } const handleSave = async () => { @@ -358,9 +366,16 @@ 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/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 2aaa394e5..75eb81e53 100644 --- a/web/frontend/src/components/models/provider-label.ts +++ b/web/frontend/src/components/models/provider-label.ts @@ -3,6 +3,7 @@ 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", @@ -57,39 +58,40 @@ export const PROVIDER_PRIORITY: Record = { gemini: 2, anthropic: 3, bedrock: 4, - "anthropic-messages": 5, - zhipu: 6, - deepseek: 7, - openrouter: 8, - "qwen-portal": 9, - "qwen-intl": 10, - "qwen-us": 11, - moonshot: 12, - groq: 13, - "coding-plan": 14, - "coding-plan-anthropic": 15, - "github-copilot": 16, - antigravity: 17, - nvidia: 18, - cerebras: 19, - shengsuanyun: 20, - venice: 21, - vivgrid: 22, - minimax: 23, - longcat: 24, - modelscope: 25, - mistral: 26, - avian: 27, - novita: 28, - azure: 29, - litellm: 30, - ollama: 31, - vllm: 32, - lmstudio: 33, - "claude-cli": 34, - "codex-cli": 35, - zai: 36, - mimo: 37, + 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 { diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 3fdb8c100..d7d1979f1 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", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index c73732f9e..08c530db4 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": "添加模型",