From b3520d930e1582c757afd88d8a9a2c1b77bdf8fc Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Thu, 30 Apr 2026 18:10:19 +0800
Subject: [PATCH] fix(provider,web,asr): normalize elevenlabs configs and gate
default chat models
---
pkg/audio/asr/README.md | 7 +-
pkg/audio/asr/README.zh.md | 7 +-
pkg/audio/asr/asr.go | 16 +-
pkg/audio/asr/asr_test.go | 15 ++
pkg/providers/factory_provider_test.go | 10 +
pkg/providers/provider_catalog.go | 96 ++++++---
web/backend/api/gateway.go | 3 +
web/backend/api/gateway_test.go | 38 ++++
web/backend/api/models.go | 192 ++++++++----------
web/backend/api/models_test.go | 124 +++++++++--
web/frontend/src/api/models.ts | 2 +
.../src/components/models/add-model-sheet.tsx | 13 +-
.../components/models/edit-model-sheet.tsx | 19 +-
.../src/components/models/model-card.tsx | 8 +-
.../src/components/models/provider-icon.tsx | 2 +
.../src/components/models/provider-label.ts | 68 ++++---
web/frontend/src/i18n/locales/en.json | 7 +-
web/frontend/src/i18n/locales/zh.json | 7 +-
18 files changed, 424 insertions(+), 210 deletions(-)
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": "添加模型",