package llamaswap import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "reflect" "testing" "gitea.stevedudenhoeffer.com/steve/majordomo/audio" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" ) func TestSpeak(t *testing.T) { var gotBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/audio/speech" { t.Errorf("path = %q", r.URL.Path) } if r.Header.Get("Authorization") != "Bearer tok" { t.Errorf("auth = %q", r.Header.Get("Authorization")) } _ = json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "audio/mpeg") _, _ = w.Write([]byte("MP3BYTES")) })) defer srv.Close() p := New(WithBaseURL(srv.URL), WithToken("tok"), WithHTTPClient(srv.Client())) sm, err := p.SpeechModel("kokoro") if err != nil { t.Fatalf("SpeechModel: %v", err) } res, err := sm.Speak(context.Background(), audio.SpeechRequest{Input: "hello world"}, audio.WithVoice("af_heart"), audio.WithFormat("mp3"), audio.WithSpeed(1.2), ) if err != nil { t.Fatalf("Speak: %v", err) } if string(res.Audio) != "MP3BYTES" || res.MIME != "audio/mpeg" { t.Errorf("result = %q %q", res.Audio, res.MIME) } want := map[string]any{"model": "kokoro", "input": "hello world", "voice": "af_heart", "response_format": "mp3", "speed": 1.2} for k, w := range want { if gotBody[k] != w { t.Errorf("%s = %v, want %v", k, gotBody[k], w) } } } func TestSpeakDefaultsOmitted(t *testing.T) { var gotBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&gotBody) _, _ = w.Write([]byte("x")) })) defer srv.Close() p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client())) sm, _ := p.SpeechModel("kokoro") res, err := sm.Speak(context.Background(), audio.SpeechRequest{Input: "hi"}) if err != nil { t.Fatalf("Speak: %v", err) } for _, k := range []string{"voice", "response_format", "speed"} { if v, ok := gotBody[k]; ok { t.Errorf("unset request sent %q = %v, want omitted", k, v) } } // No usable Content-Type and no requested format → mp3 default. if res.MIME != "audio/mpeg" { t.Errorf("MIME = %q, want audio/mpeg fallback", res.MIME) } } func TestSpeakEmptyInput(t *testing.T) { p := New(WithBaseURL("http://example.invalid")) sm, _ := p.SpeechModel("kokoro") if _, err := sm.Speak(context.Background(), audio.SpeechRequest{Input: " "}); !errors.Is(err, llm.ErrUnsupported) { t.Errorf("err = %v, want ErrUnsupported", err) } } func TestSpeechMIME(t *testing.T) { cases := []struct { contentType, format, want string }{ {"audio/ogg; codecs=opus", "mp3", "audio/ogg"}, // concrete header wins {"application/octet-stream", "wav", "audio/wav"}, {"", "", "audio/mpeg"}, {"", "opus", "audio/ogg"}, {"text/plain", "flac", "audio/flac"}, } for _, tc := range cases { if got := speechMIME(tc.contentType, tc.format); got != tc.want { t.Errorf("speechMIME(%q, %q) = %q, want %q", tc.contentType, tc.format, got, tc.want) } } } func TestTranscribe(t *testing.T) { var gotFields map[string]string var gotFile []byte var gotFilename string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/audio/transcriptions" { t.Errorf("path = %q", r.URL.Path) } if err := r.ParseMultipartForm(1 << 20); err != nil { t.Fatalf("parse multipart: %v", err) } gotFields = map[string]string{} for k, v := range r.MultipartForm.Value { gotFields[k] = v[0] } f, hdr, err := r.FormFile("file") if err != nil { t.Fatalf("form file: %v", err) } defer f.Close() gotFile, _ = io.ReadAll(f) gotFilename = hdr.Filename _, _ = w.Write([]byte(`{"text":"hello there"}`)) })) defer srv.Close() p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client())) tm, err := p.TranscriptionModel("whisper") if err != nil { t.Fatalf("TranscriptionModel: %v", err) } res, err := tm.Transcribe(context.Background(), audio.TranscriptionRequest{Audio: []byte("AUDIO"), MIME: "audio/mpeg"}, audio.WithLanguage("en"), audio.WithPrompt("robot names"), ) if err != nil { t.Fatalf("Transcribe: %v", err) } if res.Text != "hello there" { t.Errorf("text = %q", res.Text) } if string(gotFile) != "AUDIO" || gotFilename != "audio.mp3" { t.Errorf("file = %q name = %q", gotFile, gotFilename) } want := map[string]string{"model": "whisper", "language": "en", "prompt": "robot names", "response_format": "json"} for k, w := range want { if gotFields[k] != w { t.Errorf("%s = %q, want %q", k, gotFields[k], w) } } } func TestTranscribeEmptyAudio(t *testing.T) { p := New(WithBaseURL("http://example.invalid")) tm, _ := p.TranscriptionModel("whisper") if _, err := tm.Transcribe(context.Background(), audio.TranscriptionRequest{}); !errors.Is(err, llm.ErrUnsupported) { t.Errorf("err = %v, want ErrUnsupported", err) } } func TestListVoices(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/audio/voices" { t.Errorf("path = %q", r.URL.Path) } if got := r.URL.Query().Get("model"); got != "kokoro" { t.Errorf("model = %q", got) } _, _ = w.Write([]byte(`{"voices":["af_heart","af_bella"]}`)) })) defer srv.Close() p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client())) voices, err := p.ListVoices(context.Background(), "kokoro") if err != nil { t.Fatalf("ListVoices: %v", err) } if !reflect.DeepEqual(voices, []string{"af_heart", "af_bella"}) { t.Errorf("voices = %v", voices) } if _, err := p.ListVoices(context.Background(), ""); err == nil { t.Error("empty model: want error") } } func TestParseVoicesShapes(t *testing.T) { cases := []struct { name string raw string want []string }{ {"envelope strings", `{"voices":["a","b"]}`, []string{"a", "b"}}, {"bare array", `["a","b"]`, []string{"a", "b"}}, {"objects by id", `{"voices":[{"id":"a"},{"id":"b"}]}`, []string{"a", "b"}}, {"objects by name", `{"data":[{"name":"a"},{"voice_id":"b"}]}`, []string{"a", "b"}}, } for _, tc := range cases { got, err := parseVoices([]byte(tc.raw)) if err != nil { t.Errorf("%s: %v", tc.name, err) continue } if !reflect.DeepEqual(got, tc.want) { t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) } } if _, err := parseVoices([]byte(`"just a string"`)); err == nil { t.Error("unparseable shape: want error") } } func TestAudioAPIError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"error":{"message":"model is loading"}}`)) })) defer srv.Close() p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client())) sm, _ := p.SpeechModel("kokoro") _, err := sm.Speak(context.Background(), audio.SpeechRequest{Input: "x"}) var apiErr *llm.APIError if !errors.As(err, &apiErr) { t.Fatalf("err = %T %v, want *llm.APIError", err, err) } if apiErr.Status != http.StatusServiceUnavailable || apiErr.Message != "model is loading" || apiErr.Model != "kokoro" { t.Errorf("apiErr = %+v", apiErr) } } func TestAudioNoBaseURL(t *testing.T) { p := New() if _, err := p.SpeechModel("kokoro"); err == nil { t.Error("SpeechModel: want error without base URL") } if _, err := p.TranscriptionModel("whisper"); err == nil { t.Error("TranscriptionModel: want error without base URL") } if _, err := p.ListVoices(context.Background(), "kokoro"); err == nil { t.Error("ListVoices: want error without base URL") } }