fix(provider,web,asr): normalize elevenlabs configs and gate default chat models

This commit is contained in:
lc6464 2026-04-30 18:10:19 +08:00
parent 6a096a0d46
commit b3520d930e
No known key found for this signature in database
GPG key ID: 53C61B42FEC71D6D
18 changed files with 424 additions and 210 deletions

View file

@ -82,7 +82,8 @@ Notes:
"model_list": [ "model_list": [
{ {
"model_name": "elevenlabs-asr", "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 | | 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. | | 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. | | 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`. 1. **Preferred path**: resolve `voice.model_name` against `model_list`.
2. If that resolved model is: 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 OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber.
- an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`. - 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. 3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries.

View file

@ -82,7 +82,8 @@ model_list:
"model_list": [ "model_list": [
{ {
"model_name": "elevenlabs-asr", "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` 接口。 | | Whisper 接口模型 | `openai/whisper-1``groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 |
| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview``gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 | | 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview``gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 |
@ -142,7 +143,7 @@ PicoClaw 目前主要支持三种 ASR 路径:
1. **首选路径**:根据 `voice.model_name``model_list` 中找到对应模型。 1. **首选路径**:根据 `voice.model_name``model_list` 中找到对应模型。
2. 如果找到的模型属于以下类型: 2. 如果找到的模型属于以下类型:
- `elevenlabs/...`,则使用 ElevenLabs transcriber。 - `provider=elevenlabs` 的模型,则使用 ElevenLabs transcriber。
- OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。 - OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。
- 支持音频输入的聊天模型,则使用 `AudioModelTranscriber` - 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`
3. **回退路径**:如果没有设置 `voice.model_name`PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。 3. **回退路径**:如果没有设置 `voice.model_name`PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。

View file

@ -78,21 +78,7 @@ func isElevenLabsTranscriptionModel(modelCfg *config.ModelConfig) bool {
} }
protocol, _ := providers.ExtractProtocol(modelCfg) protocol, _ := providers.ExtractProtocol(modelCfg)
if protocol == "elevenlabs" { return 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"
} }
func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber { func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {

View file

@ -46,6 +46,21 @@ func TestDetectTranscriber(t *testing.T) {
}, },
wantName: "elevenlabs", 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", name: "voice model name alias selects whisper transcriber for groq",
cfg: &config.Config{ cfg: &config.Config{

View file

@ -987,6 +987,16 @@ func TestModelProviderOptions(t *testing.T) {
} else if !option.CreateAllowed { } else if !option.CreateAllowed {
t.Fatal("bedrock should be creatable and defer credential/build errors to runtime") 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 { if option, ok := seen["antigravity"]; !ok {
t.Fatal("antigravity option missing") t.Fatal("antigravity option missing")
} else { } else {

View file

@ -11,6 +11,7 @@ type ModelProviderOption struct {
DefaultAPIBase string `json:"default_api_base"` DefaultAPIBase string `json:"default_api_base"`
EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"` EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"`
CreateAllowed bool `json:"create_allowed"` CreateAllowed bool `json:"create_allowed"`
DefaultModelAllowed bool `json:"default_model_allowed"`
DefaultAuthMethod string `json:"default_auth_method,omitempty"` DefaultAuthMethod string `json:"default_auth_method,omitempty"`
AuthMethodLocked bool `json:"auth_method_locked,omitempty"` AuthMethodLocked bool `json:"auth_method_locked,omitempty"`
} }
@ -18,6 +19,7 @@ type ModelProviderOption struct {
type attachedModelProviderMeta struct { type attachedModelProviderMeta struct {
protocolMeta protocolMeta
createAllowed bool createAllowed bool
defaultModelAllowed bool
defaultAuthMethod string defaultAuthMethod string
authMethodLocked bool authMethodLocked bool
} }
@ -27,20 +29,39 @@ type attachedModelProviderMeta struct {
// kept out of the core HTTP metadata map because they have special auth/runtime // kept out of the core HTTP metadata map because they have special auth/runtime
// semantics. // semantics.
var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{ var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{
"azure": {createAllowed: true}, "azure": {createAllowed: true, defaultModelAllowed: true},
"anthropic": { "anthropic": {
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
createAllowed: true, createAllowed: true,
defaultModelAllowed: true,
}, },
"anthropic-messages": { "anthropic-messages": {
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
createAllowed: true, 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. // ModelProviderOptions returns the canonical provider catalog exposed to the Web UI.
@ -55,6 +76,7 @@ func ModelProviderOptions() []ModelProviderOption {
DefaultAPIBase: DefaultAPIBaseForProtocol(provider), DefaultAPIBase: DefaultAPIBaseForProtocol(provider),
EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider), EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider),
CreateAllowed: true, CreateAllowed: true,
DefaultModelAllowed: true,
} }
} }
for provider, meta := range attachedModelProviderMetaByName { for provider, meta := range attachedModelProviderMetaByName {
@ -66,6 +88,7 @@ func ModelProviderOptions() []ModelProviderOption {
DefaultAPIBase: meta.defaultAPIBase, DefaultAPIBase: meta.defaultAPIBase,
EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed, EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed,
CreateAllowed: meta.createAllowed, CreateAllowed: meta.createAllowed,
DefaultModelAllowed: meta.defaultModelAllowed,
DefaultAuthMethod: meta.defaultAuthMethod, DefaultAuthMethod: meta.defaultAuthMethod,
AuthMethodLocked: meta.authMethodLocked, AuthMethodLocked: meta.authMethodLocked,
} }
@ -109,6 +132,21 @@ func IsCreatableModelProvider(provider string) bool {
return ok && meta.createAllowed 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 // SplitModelProviderAndID separates a legacy "provider/model" string into its
// effective provider and canonical model ID. Unknown prefixes are treated as // effective provider and canonical model ID. Unknown prefixes are treated as
// part of the model ID and fall back to defaultProvider. // part of the model ID and fall back to defaultProvider.

View file

@ -382,6 +382,9 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
if modelCfg == nil { if modelCfg == nil {
return false, fmt.Sprintf("default model %q is invalid", modelName), 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) { if !hasModelConfiguration(modelCfg) {
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil

View file

@ -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) { func TestLooksLikeGatewayCommandLine(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View file

@ -49,48 +49,7 @@ type modelResponse struct {
Status string `json:"status"` Status string `json:"status"`
IsDefault bool `json:"is_default"` IsDefault bool `json:"is_default"`
IsVirtual bool `json:"is_virtual"` IsVirtual bool `json:"is_virtual"`
} DefaultModelAllowed bool `json:"default_model_allowed"`
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)
} }
func normalizeStoredModelConfig(mc *config.ModelConfig) bool { func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
@ -121,9 +80,19 @@ func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
mc.Provider = normalizedProvider mc.Provider = normalizedProvider
changed = true changed = true
} }
return changed 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 isLegacyUnsupportedASRModelConfig(mc) {
return changed return changed
} }
@ -151,12 +120,17 @@ func normalizeIncomingModelConfig(mc *config.ModelConfig) {
mc.Provider = strings.TrimSpace(mc.Provider) mc.Provider = strings.TrimSpace(mc.Provider)
mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod)) mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod))
if mc.Provider == "" { if mc.Provider == "" {
if isLegacyUnsupportedASRModelConfig(mc) {
return
}
mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai") mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai")
} else { } else {
mc.Provider = providers.NormalizeProvider(mc.Provider) 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 == "" { if mc.Provider == "antigravity" && mc.AuthMethod == "" {
mc.AuthMethod = "oauth" mc.AuthMethod = "oauth"
@ -199,6 +173,11 @@ func modelProviderOptionsForResponse() []providers.ModelProviderOption {
return options 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 { func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelConfig) error {
if mc == nil { if mc == nil {
return fmt.Errorf("model config is required") return fmt.Errorf("model config is required")
@ -207,9 +186,6 @@ func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelC
return err return err
} }
if strings.TrimSpace(mc.Provider) == "" { if strings.TrimSpace(mc.Provider) == "" {
if existing != nil && isLegacyUnsupportedASRModelConfig(existing) && isLegacyUnsupportedASRModelConfig(mc) {
return nil
}
return fmt.Errorf("provider is required") return fmt.Errorf("provider is required")
} }
if !providers.IsSupportedModelProvider(mc.Provider) { if !providers.IsSupportedModelProvider(mc.Provider) {
@ -266,7 +242,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
models := make([]modelResponse, 0, len(cfg.ModelList)) models := make([]modelResponse, 0, len(cfg.ModelList))
for i, m := range cfg.ModelList { for i, m := range cfg.ModelList {
provider, modelID := responseProviderAndModel(m) provider, modelID := providers.ExtractProtocol(m)
models = append(models, modelResponse{ models = append(models, modelResponse{
Index: i, Index: i,
ModelName: m.ModelName, ModelName: m.ModelName,
@ -289,6 +265,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
Status: modelStatuses[i].Status, Status: modelStatuses[i].Status,
IsDefault: m.ModelName == defaultModel, IsDefault: m.ModelName == defaultModel,
IsVirtual: m.IsVirtual(), IsVirtual: m.IsVirtual(),
DefaultModelAllowed: defaultModelAllowedForModelConfig(m),
}) })
} }
@ -439,21 +416,6 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" { if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model) existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
incomingModel := strings.TrimSpace(mc.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]) existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" { if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
if incomingModel == existingModelID { if incomingModel == existingModelID {
@ -470,13 +432,18 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
}
normalizeIncomingModelConfig(&mc.ModelConfig) normalizeIncomingModelConfig(&mc.ModelConfig)
if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil { if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil {
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
return 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 cfg.ModelList[idx] = &mc.ModelConfig
normalizeStoredModelProviders(cfg) 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) http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest)
return 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 cfg.Agents.Defaults.ModelName = req.ModelName

View file

@ -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) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -862,11 +862,11 @@ func TestHandleAddModel_PreservesLegacyElevenLabsASRConfig(t *testing.T) {
if len(updated.ModelList) != 2 { if len(updated.ModelList) != 2 {
t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList)) t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList))
} }
if got := updated.ModelList[0].Provider; got != "" { if got := updated.ModelList[0].Provider; got != "elevenlabs" {
t.Fatalf("provider = %q, want preserved empty provider for legacy ElevenLabs ASR config", got) t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs")
} }
if got := updated.ModelList[0].Model; got != "elevenlabs/scribe_v1" { if got := updated.ModelList[0].Model; got != "scribe_v1" {
t.Fatalf("model = %q, want preserved legacy ElevenLabs model ref", got) 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) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -1247,10 +1247,13 @@ func TestHandleListModels_ExposesLegacyElevenLabsASRProvider(t *testing.T) {
t.Fatalf("len(models) = %d, want 1", len(resp.Models)) t.Fatalf("len(models) = %d, want 1", len(resp.Models))
} }
if got := resp.Models[0].Provider; got != "elevenlabs" { 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" { 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) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -1386,17 +1389,63 @@ func TestHandleUpdateModel_PreservesLegacyElevenLabsASRWhenProviderOmitted(t *te
if err != nil { if err != nil {
t.Fatalf("LoadConfig() error = %v", err) t.Fatalf("LoadConfig() error = %v", err)
} }
if got := updated.ModelList[0].Provider; got != "" { if got := updated.ModelList[0].Provider; got != "elevenlabs" {
t.Fatalf("provider = %q, want preserved empty provider", got) t.Fatalf("provider = %q, want %q", got, "elevenlabs")
} }
if got := updated.ModelList[0].Model; got != "elevenlabs/scribe_v1" { if got := updated.ModelList[0].Model; got != "scribe_v1" {
t.Fatalf("model = %q, want preserved legacy model ref", got) t.Fatalf("model = %q, want %q", got, "scribe_v1")
} }
if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" {
t.Fatalf("api_base = %q, want %q", 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) { func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -1512,6 +1561,16 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration
} else if option.DefaultAPIBase != "localhost:4321" { } else if option.DefaultAPIBase != "localhost:4321" {
t.Fatalf("github-copilot default_api_base = %q, want %q", 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 { if option, ok := optionsByID["lmstudio"]; !ok {
t.Fatal("lmstudio provider option missing") t.Fatal("lmstudio provider option missing")
} else if !option.EmptyAPIKeyAllowed { } 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) { func TestMaskAPIKey(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -26,6 +26,7 @@ export interface ModelInfo {
status: "available" | "unconfigured" | "unreachable" status: "available" | "unconfigured" | "unreachable"
is_default: boolean is_default: boolean
is_virtual: boolean is_virtual: boolean
default_model_allowed?: boolean
} }
export interface ModelProviderOption { export interface ModelProviderOption {
@ -33,6 +34,7 @@ export interface ModelProviderOption {
default_api_base: string default_api_base: string
empty_api_key_allowed: boolean empty_api_key_allowed: boolean
create_allowed: boolean create_allowed: boolean
default_model_allowed: boolean
default_auth_method?: string default_auth_method?: string
auth_method_locked?: boolean auth_method_locked?: boolean
} }

View file

@ -134,6 +134,8 @@ export function AddModelSheet({
.trim() .trim()
.toLowerCase() .toLowerCase()
const isOAuth = effectiveAuthMethod === "oauth" const isOAuth = effectiveAuthMethod === "oauth"
const defaultModelAllowed =
selectedProviderOption?.default_model_allowed !== false
const apiBasePlaceholder = const apiBasePlaceholder =
getProviderDefaultAPIBase(form.provider, providerOptions) || getProviderDefaultAPIBase(form.provider, providerOptions) ||
"https://api.example.com/v1" "https://api.example.com/v1"
@ -189,6 +191,10 @@ export function AddModelSheet({
} }
return { ...f, provider: value, authMethod } return { ...f, provider: value, authMethod }
}) })
const nextOption = findProviderOption(value, providerOptions)
if (nextOption?.default_model_allowed === false) {
setSetAsDefault(false)
}
if (fieldErrors.provider) { if (fieldErrors.provider) {
setFieldErrors((prev) => ({ ...prev, provider: undefined })) setFieldErrors((prev) => ({ ...prev, provider: undefined }))
} }
@ -345,9 +351,14 @@ export function AddModelSheet({
<SwitchCardField <SwitchCardField
label={t("models.defaultOnSave.label")} label={t("models.defaultOnSave.label")}
hint={t("models.defaultOnSave.description")} hint={
defaultModelAllowed
? t("models.defaultOnSave.description")
: t("models.defaultOnSave.unsupportedProvider")
}
checked={setAsDefault} checked={setAsDefault}
onCheckedChange={setSetAsDefault} onCheckedChange={setSetAsDefault}
disabled={!defaultModelAllowed}
/> />
<AdvancedSection> <AdvancedSection>

View file

@ -151,6 +151,10 @@ export function EditModelSheet({
const providerError = selectedProviderOption const providerError = selectedProviderOption
? "" ? ""
: t("models.field.providerInvalid") : t("models.field.providerInvalid")
const defaultModelAllowed =
selectedProviderOption?.default_model_allowed !== false
const willClearDefaultOnSave =
model?.is_default === true && defaultModelAllowed === false
const apiBasePlaceholder = const apiBasePlaceholder =
getProviderDefaultAPIBase(form.provider, providerOptions) || getProviderDefaultAPIBase(form.provider, providerOptions) ||
"https://api.example.com/v1" "https://api.example.com/v1"
@ -167,7 +171,7 @@ export function EditModelSheet({
initialForm.authMethod = option.default_auth_method ?? "" initialForm.authMethod = option.default_auth_method ?? ""
} }
setForm(initialForm) setForm(initialForm)
setSetAsDefault(model.is_default) setSetAsDefault(model.is_default && model.default_model_allowed !== false)
setError("") setError("")
} }
}, [model, providerOptions]) }, [model, providerOptions])
@ -199,6 +203,10 @@ export function EditModelSheet({
} }
return { ...f, provider: value, authMethod } return { ...f, provider: value, authMethod }
}) })
const nextOption = findProviderOption(value, providerOptions)
if (nextOption?.default_model_allowed === false) {
setSetAsDefault(false)
}
} }
const handleSave = async () => { const handleSave = async () => {
@ -358,9 +366,16 @@ export function EditModelSheet({
<SwitchCardField <SwitchCardField
label={t("models.defaultOnSave.label")} label={t("models.defaultOnSave.label")}
hint={t("models.defaultOnSave.description")} hint={
willClearDefaultOnSave
? t("models.defaultOnSave.clearOnSave")
: defaultModelAllowed
? t("models.defaultOnSave.description")
: t("models.defaultOnSave.unsupportedProvider")
}
checked={setAsDefault} checked={setAsDefault}
onCheckedChange={setSetAsDefault} onCheckedChange={setSetAsDefault}
disabled={!defaultModelAllowed}
/> />
<AdvancedSection> <AdvancedSection>

View file

@ -36,7 +36,10 @@ export function ModelCard({
const status = model.status const status = model.status
const statusLabel = t(`models.status.${status}`) const statusLabel = t(`models.status.${status}`)
const canSetDefault = 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 setDefaultLabel = t("models.action.setDefault")
const setDefaultDisabledReason = (() => { const setDefaultDisabledReason = (() => {
@ -45,6 +48,9 @@ export function ModelCard({
return t("models.action.setDefaultDisabled.unavailable") return t("models.action.setDefaultDisabled.unavailable")
if (model.is_default) return t("models.action.setDefaultDisabled.isDefault") if (model.is_default) return t("models.action.setDefaultDisabled.isDefault")
if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual") if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual")
if (model.default_model_allowed === false) {
return t("models.action.setDefaultDisabled.unsupportedProvider")
}
return setDefaultLabel return setDefaultLabel
})() })()

View file

@ -2,6 +2,7 @@ import { useMemo, useState } from "react"
const PROVIDER_ICON_SLUGS: Record<string, string> = { const PROVIDER_ICON_SLUGS: Record<string, string> = {
openai: "openai", openai: "openai",
elevenlabs: "elevenlabs",
anthropic: "anthropic", anthropic: "anthropic",
azure: "microsoftazure", azure: "microsoftazure",
gemini: "googlegemini", gemini: "googlegemini",
@ -21,6 +22,7 @@ const PROVIDER_ICON_SLUGS: Record<string, string> = {
const PROVIDER_DOMAINS: Record<string, string> = { const PROVIDER_DOMAINS: Record<string, string> = {
openai: "openai.com", openai: "openai.com",
elevenlabs: "elevenlabs.io",
anthropic: "anthropic.com", anthropic: "anthropic.com",
azure: "azure.com", azure: "azure.com",
gemini: "gemini.google.com", gemini: "gemini.google.com",

View file

@ -3,6 +3,7 @@ import type { ModelProviderOption } from "@/api/models"
const PROVIDER_LABELS: Record<string, string> = { const PROVIDER_LABELS: Record<string, string> = {
openai: "OpenAI", openai: "OpenAI",
bedrock: "AWS Bedrock", bedrock: "AWS Bedrock",
elevenlabs: "ElevenLabs ASR",
anthropic: "Anthropic", anthropic: "Anthropic",
"anthropic-messages": "Anthropic Messages", "anthropic-messages": "Anthropic Messages",
azure: "Azure OpenAI", azure: "Azure OpenAI",
@ -57,39 +58,40 @@ export const PROVIDER_PRIORITY: Record<string, number> = {
gemini: 2, gemini: 2,
anthropic: 3, anthropic: 3,
bedrock: 4, bedrock: 4,
"anthropic-messages": 5, elevenlabs: 5,
zhipu: 6, "anthropic-messages": 6,
deepseek: 7, zhipu: 7,
openrouter: 8, deepseek: 8,
"qwen-portal": 9, openrouter: 9,
"qwen-intl": 10, "qwen-portal": 10,
"qwen-us": 11, "qwen-intl": 11,
moonshot: 12, "qwen-us": 12,
groq: 13, moonshot: 13,
"coding-plan": 14, groq: 14,
"coding-plan-anthropic": 15, "coding-plan": 15,
"github-copilot": 16, "coding-plan-anthropic": 16,
antigravity: 17, "github-copilot": 17,
nvidia: 18, antigravity: 18,
cerebras: 19, nvidia: 19,
shengsuanyun: 20, cerebras: 20,
venice: 21, shengsuanyun: 21,
vivgrid: 22, venice: 22,
minimax: 23, vivgrid: 23,
longcat: 24, minimax: 24,
modelscope: 25, longcat: 25,
mistral: 26, modelscope: 26,
avian: 27, mistral: 27,
novita: 28, avian: 28,
azure: 29, novita: 29,
litellm: 30, azure: 30,
ollama: 31, litellm: 31,
vllm: 32, ollama: 32,
lmstudio: 33, vllm: 33,
"claude-cli": 34, lmstudio: 34,
"codex-cli": 35, "claude-cli": 35,
zai: 36, "codex-cli": 36,
mimo: 37, zai: 37,
mimo: 38,
} }
export function getProviderKey(provider?: string): string { export function getProviderKey(provider?: string): string {

View file

@ -236,7 +236,8 @@
"setting": "Setting as default...", "setting": "Setting as default...",
"unavailable": "Cannot set unavailable model as default", "unavailable": "Cannot set unavailable model as default",
"isDefault": "Already the default model", "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": { "deleteDisabled": {
"isDefault": "Cannot delete the default model" "isDefault": "Cannot delete the default model"
@ -244,7 +245,9 @@
}, },
"defaultOnSave": { "defaultOnSave": {
"label": "Default Model", "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": { "add": {
"button": "Add Model", "button": "Add Model",

View file

@ -236,7 +236,8 @@
"setting": "正在设为默认...", "setting": "正在设为默认...",
"unavailable": "无法将不可用的模型设为默认", "unavailable": "无法将不可用的模型设为默认",
"isDefault": "该模型已是默认模型", "isDefault": "该模型已是默认模型",
"isVirtual": "无法将虚拟模型设为默认" "isVirtual": "无法将虚拟模型设为默认",
"unsupportedProvider": "该 Provider 仅用于 ASR不能设为默认聊天模型"
}, },
"deleteDisabled": { "deleteDisabled": {
"isDefault": "无法删除默认模型" "isDefault": "无法删除默认模型"
@ -244,7 +245,9 @@
}, },
"defaultOnSave": { "defaultOnSave": {
"label": "默认模型", "label": "默认模型",
"description": "保存后自动将该模型设置为默认模型。" "description": "保存后自动将该模型设置为默认模型。",
"unsupportedProvider": "该 Provider 可以保存在 model_list 中,但不能作为默认聊天模型使用。",
"clearOnSave": "保存这个仅用于 ASR 的模型后,会清除当前的默认聊天模型设置。"
}, },
"add": { "add": {
"button": "添加模型", "button": "添加模型",