fix(web,asr): preserve legacy elevenlabs transcription configs

This commit is contained in:
lc6464 2026-04-29 00:18:23 +08:00
parent e76527d932
commit 6a096a0d46
No known key found for this signature in database
GPG key ID: 53C61B42FEC71D6D
2 changed files with 249 additions and 13 deletions

View file

@ -51,6 +51,48 @@ type modelResponse struct {
IsVirtual bool `json:"is_virtual"` 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 { func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
if mc == nil { if mc == nil {
return false return false
@ -81,6 +123,9 @@ func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
} }
return changed return changed
} }
if isLegacyUnsupportedASRModelConfig(mc) {
return changed
}
effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai") effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai")
if effectiveProvider == "" { if effectiveProvider == "" {
@ -106,6 +151,9 @@ 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)
@ -159,6 +207,9 @@ 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) {
@ -215,7 +266,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 := providers.ExtractProtocol(m) provider, modelID := responseProviderAndModel(m)
models = append(models, modelResponse{ models = append(models, modelResponse{
Index: i, Index: i,
ModelName: m.ModelName, 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 // This keeps provider-omitted updates backward-compatible even when an
// older client edits the visible model ID. // older client edits the visible model ID.
if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" { if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
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 existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" { if legacyProvider, legacyModelID, ok := legacyUnsupportedASRProviderAndModel(existingRawModel); ok {
if incomingModel == existingModelID { if incomingModel != "" {
mc.Model = existingRawModel if incomingModel == legacyModelID {
} else if strings.Contains(incomingModel, "/") && !strings.Contains(existingModelID, "/") { mc.Model = existingRawModel
// Older clients never saw the hidden provider prefix for simple } else if strings.Contains(incomingModel, "/") && !strings.Contains(legacyModelID, "/") {
// legacy entries such as "openai/gpt-4o". If they now send an // Older clients only saw the visible legacy ASR model ID
// explicit provider/model string, treat it as the caller's full // (for example "scribe_v1"). If they now send an explicit
// intent instead of re-applying the old hidden prefix. // provider/model string, keep that full intent instead of
mc.Model = incomingModel // silently re-applying the hidden ElevenLabs prefix.
} else if !strings.HasPrefix(incomingModel, existingProtocol+"/") { mc.Model = incomingModel
mc.Model = existingProtocol + "/" + 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
}
} }
} }
} }

View file

@ -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) { func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() 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) { func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() 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) { func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()