From 6a096a0d46066ed26363fde7bf0304a6050db5e7 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:18:23 +0800 Subject: [PATCH] fix(web,asr): preserve legacy elevenlabs transcription configs --- web/backend/api/models.go | 93 +++++++++++++++--- web/backend/api/models_test.go | 169 +++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 13 deletions(-) diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 9118e6a99..a889e7020 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -51,6 +51,48 @@ type modelResponse struct { 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) +} + func normalizeStoredModelConfig(mc *config.ModelConfig) bool { if mc == nil { return false @@ -81,6 +123,9 @@ func normalizeStoredModelConfig(mc *config.ModelConfig) bool { } return changed } + if isLegacyUnsupportedASRModelConfig(mc) { + return changed + } effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai") if effectiveProvider == "" { @@ -106,6 +151,9 @@ 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) @@ -159,6 +207,9 @@ 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) { @@ -215,7 +266,7 @@ 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 := providers.ExtractProtocol(m) + provider, modelID := responseProviderAndModel(m) models = append(models, modelResponse{ Index: i, ModelName: m.ModelName, @@ -386,20 +437,36 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { // This keeps provider-omitted updates backward-compatible even when an // older client edits the visible model ID. if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" { - existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model) incomingModel := strings.TrimSpace(mc.Model) - 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 + 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 + } } } } diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 38988db6e..3cc7e36cc 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -820,6 +820,56 @@ func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) { } } +func TestHandleAddModel_PreservesLegacyElevenLabsASRConfig(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "provider":"openai", + "model":"gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(updated.ModelList) != 2 { + t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList)) + } + if got := updated.ModelList[0].Provider; got != "" { + t.Fatalf("provider = %q, want preserved empty provider for legacy ElevenLabs ASR config", got) + } + if got := updated.ModelList[0].Model; got != "elevenlabs/scribe_v1" { + t.Fatalf("model = %q, want preserved legacy ElevenLabs model ref", got) + } +} + func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -1158,6 +1208,52 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { } } +func TestHandleListModels_ExposesLegacyElevenLabsASRProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q for legacy unsupported ASR entry", 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") + } +} + func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -1228,6 +1324,79 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *test } } +func TestHandleUpdateModel_PreservesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := listResp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "" { + t.Fatalf("provider = %q, want preserved empty provider", got) + } + 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].APIBase; got != "https://api.elevenlabs.io" { + t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") + } +} + func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup()