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_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.

View file

@ -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 条目。

View file

@ -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 {

View file

@ -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{

View file

@ -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 {

View file

@ -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.

View file

@ -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

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) {
cases := []struct {
name string

View file

@ -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

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)
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

View file

@ -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
}

View file

@ -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({
<SwitchCardField
label={t("models.defaultOnSave.label")}
hint={t("models.defaultOnSave.description")}
hint={
defaultModelAllowed
? t("models.defaultOnSave.description")
: t("models.defaultOnSave.unsupportedProvider")
}
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
disabled={!defaultModelAllowed}
/>
<AdvancedSection>

View file

@ -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({
<SwitchCardField
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}
onCheckedChange={setSetAsDefault}
disabled={!defaultModelAllowed}
/>
<AdvancedSection>

View file

@ -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
})()

View file

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

View file

@ -3,6 +3,7 @@ import type { ModelProviderOption } from "@/api/models"
const PROVIDER_LABELS: Record<string, string> = {
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<string, number> = {
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 {

View file

@ -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",

View file

@ -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": "添加模型",