fix: tighten provider catalog and elevenlabs compatibility
This commit is contained in:
parent
b3520d930e
commit
e73439e40e
6 changed files with 292 additions and 24 deletions
|
|
@ -8,6 +8,12 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const elevenLabsSupportedModelID = "scribe_v1"
|
||||||
|
|
||||||
|
func ElevenLabsSupportedModelID() string {
|
||||||
|
return elevenLabsSupportedModelID
|
||||||
|
}
|
||||||
|
|
||||||
type Transcriber interface {
|
type Transcriber interface {
|
||||||
Name() string
|
Name() string
|
||||||
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
|
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
|
||||||
|
|
@ -87,7 +93,8 @@ func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
|
||||||
}
|
}
|
||||||
|
|
||||||
if isElevenLabsTranscriptionModel(modelCfg) {
|
if isElevenLabsTranscriptionModel(modelCfg) {
|
||||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
|
_, modelID := providers.ExtractProtocol(modelCfg)
|
||||||
|
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID)
|
||||||
}
|
}
|
||||||
if modelID := whisperModelID(modelCfg); modelID != "" {
|
if modelID := whisperModelID(modelCfg); modelID != "" {
|
||||||
return NewWhisperTranscriber(modelCfg)
|
return NewWhisperTranscriber(modelCfg)
|
||||||
|
|
@ -104,7 +111,8 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe
|
||||||
}
|
}
|
||||||
|
|
||||||
if isElevenLabsTranscriptionModel(modelCfg) {
|
if isElevenLabsTranscriptionModel(modelCfg) {
|
||||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
|
_, modelID := providers.ExtractProtocol(modelCfg)
|
||||||
|
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID)
|
||||||
}
|
}
|
||||||
if modelID := whisperModelID(modelCfg); modelID != "" {
|
if modelID := whisperModelID(modelCfg); modelID != "" {
|
||||||
return NewWhisperTranscriber(modelCfg)
|
return NewWhisperTranscriber(modelCfg)
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,24 @@ import (
|
||||||
type ElevenLabsTranscriber struct {
|
type ElevenLabsTranscriber struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
|
modelID string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewElevenLabsTranscriber(apiKey, apiBase string) *ElevenLabsTranscriber {
|
func NewElevenLabsTranscriber(apiKey, apiBase, modelID string) *ElevenLabsTranscriber {
|
||||||
logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""})
|
logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""})
|
||||||
|
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = "https://api.elevenlabs.io"
|
apiBase = "https://api.elevenlabs.io"
|
||||||
}
|
}
|
||||||
|
if modelID == "" || modelID != ElevenLabsSupportedModelID() {
|
||||||
|
modelID = ElevenLabsSupportedModelID()
|
||||||
|
}
|
||||||
|
|
||||||
return &ElevenLabsTranscriber{
|
return &ElevenLabsTranscriber{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: apiBase,
|
apiBase: apiBase,
|
||||||
|
modelID: modelID,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
},
|
},
|
||||||
|
|
@ -74,7 +79,7 @@ func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath st
|
||||||
return nil, fmt.Errorf("failed to copy file content: %w", err)
|
return nil, fmt.Errorf("failed to copy file content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = writer.WriteField("model_id", "scribe_v1"); err != nil {
|
if err = writer.WriteField("model_id", t.modelID); err != nil {
|
||||||
return nil, fmt.Errorf("failed to write model_id field: %w", err)
|
return nil, fmt.Errorf("failed to write model_id field: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,14 @@ package asr
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,7 +18,7 @@ import (
|
||||||
var _ Transcriber = (*ElevenLabsTranscriber)(nil)
|
var _ Transcriber = (*ElevenLabsTranscriber)(nil)
|
||||||
|
|
||||||
func TestElevenLabsTranscriberName(t *testing.T) {
|
func TestElevenLabsTranscriberName(t *testing.T) {
|
||||||
tr := NewElevenLabsTranscriber("sk_test", "")
|
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
|
||||||
if got := tr.Name(); got != "elevenlabs" {
|
if got := tr.Name(); got != "elevenlabs" {
|
||||||
t.Errorf("Name() = %q, want %q", got, "elevenlabs")
|
t.Errorf("Name() = %q, want %q", got, "elevenlabs")
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +39,35 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||||
if r.Header.Get("Xi-Api-Key") != "sk_test" {
|
if r.Header.Get("Xi-Api-Key") != "sk_test" {
|
||||||
t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key"))
|
t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key"))
|
||||||
}
|
}
|
||||||
|
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMediaType() error = %v", err)
|
||||||
|
}
|
||||||
|
if mediaType != "multipart/form-data" {
|
||||||
|
t.Fatalf("content-type = %q, want multipart/form-data", mediaType)
|
||||||
|
}
|
||||||
|
reader := multipart.NewReader(r.Body, params["boundary"])
|
||||||
|
var gotModelID string
|
||||||
|
for {
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextPart() error = %v", err)
|
||||||
|
}
|
||||||
|
if part.FormName() != "model_id" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(part)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll(part) error = %v", err)
|
||||||
|
}
|
||||||
|
gotModelID = strings.TrimSpace(string(body))
|
||||||
|
}
|
||||||
|
if gotModelID != "scribe_v1" {
|
||||||
|
t.Fatalf("model_id = %q, want %q", gotModelID, "scribe_v1")
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(TranscriptionResponse{
|
_ = json.NewEncoder(w).Encode(TranscriptionResponse{
|
||||||
Text: "hello from elevenlabs",
|
Text: "hello from elevenlabs",
|
||||||
|
|
@ -43,7 +76,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tr := NewElevenLabsTranscriber("sk_test", "")
|
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
|
||||||
tr.apiBase = srv.URL
|
tr.apiBase = srv.URL
|
||||||
|
|
||||||
resp, err := tr.Transcribe(context.Background(), audioPath)
|
resp, err := tr.Transcribe(context.Background(), audioPath)
|
||||||
|
|
@ -64,7 +97,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tr := NewElevenLabsTranscriber("sk_bad", "")
|
tr := NewElevenLabsTranscriber("sk_bad", "", "scribe_v1")
|
||||||
tr.apiBase = srv.URL
|
tr.apiBase = srv.URL
|
||||||
|
|
||||||
_, err := tr.Transcribe(context.Background(), audioPath)
|
_, err := tr.Transcribe(context.Background(), audioPath)
|
||||||
|
|
@ -74,10 +107,54 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("missing file", func(t *testing.T) {
|
t.Run("missing file", func(t *testing.T) {
|
||||||
tr := NewElevenLabsTranscriber("sk_test", "")
|
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
|
||||||
_, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
|
_, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for missing file, got nil")
|
t.Fatal("expected error for missing file, got nil")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("unsupported model falls back to scribe_v1", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMediaType() error = %v", err)
|
||||||
|
}
|
||||||
|
if mediaType != "multipart/form-data" {
|
||||||
|
t.Fatalf("content-type = %q, want multipart/form-data", mediaType)
|
||||||
|
}
|
||||||
|
reader := multipart.NewReader(r.Body, params["boundary"])
|
||||||
|
var gotModelID string
|
||||||
|
for {
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextPart() error = %v", err)
|
||||||
|
}
|
||||||
|
if part.FormName() != "model_id" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(part)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll(part) error = %v", err)
|
||||||
|
}
|
||||||
|
gotModelID = strings.TrimSpace(string(body))
|
||||||
|
}
|
||||||
|
if gotModelID != "scribe_v1" {
|
||||||
|
t.Fatalf("model_id = %q, want runtime fallback to %q", gotModelID, "scribe_v1")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(TranscriptionResponse{Text: "ok"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tr := NewElevenLabsTranscriber("sk_test", "", "unsupported-model")
|
||||||
|
tr.apiBase = srv.URL
|
||||||
|
|
||||||
|
if _, err := tr.Transcribe(context.Background(), audioPath); err != nil {
|
||||||
|
t.Fatalf("Transcribe() error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/audio/asr"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
|
@ -92,6 +93,10 @@ func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() {
|
||||||
|
mc.Model = asr.ElevenLabsSupportedModelID()
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
@ -191,6 +196,9 @@ func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelC
|
||||||
if !providers.IsSupportedModelProvider(mc.Provider) {
|
if !providers.IsSupportedModelProvider(mc.Provider) {
|
||||||
return fmt.Errorf("provider %q is not supported", mc.Provider)
|
return fmt.Errorf("provider %q is not supported", mc.Provider)
|
||||||
}
|
}
|
||||||
|
if mc.Provider == "elevenlabs" && strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() {
|
||||||
|
return fmt.Errorf("provider %q only supports model %q", mc.Provider, asr.ElevenLabsSupportedModelID())
|
||||||
|
}
|
||||||
if !createAllowedForProvider(mc.Provider) {
|
if !createAllowedForProvider(mc.Provider) {
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
return fmt.Errorf("provider %q is not available for new models", mc.Provider)
|
return fmt.Errorf("provider %q is not available for new models", mc.Provider)
|
||||||
|
|
@ -227,6 +235,10 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize legacy provider/model storage in memory so GET can round-trip
|
||||||
|
// through the current API shape without mutating the on-disk config.
|
||||||
|
normalizeStoredModelProviders(cfg)
|
||||||
|
|
||||||
defaultModel := cfg.Agents.Defaults.GetModelName()
|
defaultModel := cfg.Agents.Defaults.GetModelName()
|
||||||
modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
|
modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -870,6 +870,54 @@ func TestHandleAddModel_NormalizesLegacyElevenLabsASRConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleAddModel_NormalizesExplicitElevenLabsUnsupportedModelID(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_v2",
|
||||||
|
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 got := updated.ModelList[0].Provider; got != "elevenlabs" {
|
||||||
|
t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs")
|
||||||
|
}
|
||||||
|
if got := updated.ModelList[0].Model; got != "scribe_v1" {
|
||||||
|
t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) {
|
func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
@ -1400,6 +1448,81 @@ func TestHandleUpdateModel_MigratesLegacyElevenLabsASRWhenProviderOmitted(t *tes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleUpdateModel_RoundTripsExplicitLegacyElevenLabsModelID(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_v2",
|
||||||
|
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 after GET normalization", got, "scribe_v1")
|
||||||
|
}
|
||||||
|
|
||||||
|
recUpdate := httptest.NewRecorder()
|
||||||
|
reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
|
||||||
|
"model_name":"elevenlabs-asr",
|
||||||
|
"provider":"elevenlabs",
|
||||||
|
"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 != "elevenlabs" {
|
||||||
|
t.Fatalf("provider = %q, want %q", got, "elevenlabs")
|
||||||
|
}
|
||||||
|
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) {
|
func TestHandleUpdateModel_ClearsDefaultWhenSavingASROnlyModel(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
@ -1446,6 +1569,31 @@ func TestHandleUpdateModel_ClearsDefaultWhenSavingASROnlyModel(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleAddModel_RejectsUnsupportedElevenLabsModelID(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
|
||||||
|
"model_name":"elevenlabs-asr",
|
||||||
|
"provider":"elevenlabs",
|
||||||
|
"model":"scribe_v2"
|
||||||
|
}`))
|
||||||
|
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(), `provider "elevenlabs" only supports model "scribe_v1"`) {
|
||||||
|
t.Fatalf("body = %q, want elevenlabs model validation error", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) {
|
func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,26 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||||
const [defaultModelName, setDefaultModelName] = useState("")
|
const [defaultModelName, setDefaultModelName] = useState("")
|
||||||
const setDefaultRequestIdRef = useRef(0)
|
const setDefaultRequestIdRef = useRef(0)
|
||||||
|
|
||||||
|
const syncDefaultModelName = useCallback(
|
||||||
|
(models: ModelInfo[], defaultModel: string) => {
|
||||||
|
if (models.some((m) => m.model_name === defaultModel)) {
|
||||||
|
setDefaultModelName(defaultModel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDefaultModelName("")
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const loadModels = useCallback(async () => {
|
const loadModels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getModels()
|
const data = await getModels()
|
||||||
setModelList(data.models)
|
setModelList(data.models)
|
||||||
if (data.models.some((m) => m.model_name === data.default_model)) {
|
syncDefaultModelName(data.models, data.default_model)
|
||||||
setDefaultModelName(data.default_model)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail
|
// silently fail
|
||||||
}
|
}
|
||||||
}, [])
|
}, [syncDefaultModelName])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timerId = setTimeout(() => {
|
const timerId = setTimeout(() => {
|
||||||
|
|
@ -60,9 +69,7 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setModelList(data.models)
|
setModelList(data.models)
|
||||||
if (data.models.some((m) => m.model_name === data.default_model)) {
|
syncDefaultModelName(data.models, data.default_model)
|
||||||
setDefaultModelName(data.default_model)
|
|
||||||
}
|
|
||||||
const gateway = await refreshGatewayState({ force: true })
|
const gateway = await refreshGatewayState({ force: true })
|
||||||
showSaveSuccessOrRestartToast(
|
showSaveSuccessOrRestartToast(
|
||||||
t,
|
t,
|
||||||
|
|
@ -75,30 +82,41 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||||
toast.error(err instanceof Error ? err.message : t("models.loadError"))
|
toast.error(err instanceof Error ? err.message : t("models.loadError"))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[defaultModelName, t],
|
[defaultModelName, syncDefaultModelName, t],
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultSelectableModels = useMemo(
|
||||||
|
() =>
|
||||||
|
modelList.filter(
|
||||||
|
(m) => m.default_model_allowed !== false && m.is_virtual !== true,
|
||||||
|
),
|
||||||
|
[modelList],
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasAvailableModels = useMemo(
|
const hasAvailableModels = useMemo(
|
||||||
() => modelList.some((m) => m.available),
|
() => defaultSelectableModels.some((m) => m.available),
|
||||||
[modelList],
|
[defaultSelectableModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
const oauthModels = useMemo(
|
const oauthModels = useMemo(
|
||||||
() => modelList.filter((m) => m.available && m.auth_method === "oauth"),
|
() =>
|
||||||
[modelList],
|
defaultSelectableModels.filter(
|
||||||
|
(m) => m.available && m.auth_method === "oauth",
|
||||||
|
),
|
||||||
|
[defaultSelectableModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
const localModels = useMemo(
|
const localModels = useMemo(
|
||||||
() => modelList.filter((m) => m.available && isLocalModel(m)),
|
() => defaultSelectableModels.filter((m) => m.available && isLocalModel(m)),
|
||||||
[modelList],
|
[defaultSelectableModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
const apiKeyModels = useMemo(
|
const apiKeyModels = useMemo(
|
||||||
() =>
|
() =>
|
||||||
modelList.filter(
|
defaultSelectableModels.filter(
|
||||||
(m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m),
|
(m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m),
|
||||||
),
|
),
|
||||||
[modelList],
|
[defaultSelectableModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue