diff --git a/internal/providers/openrouter/openrouter.go b/internal/providers/openrouter/openrouter.go index c592b64d4..04bd904c0 100644 --- a/internal/providers/openrouter/openrouter.go +++ b/internal/providers/openrouter/openrouter.go @@ -33,6 +33,13 @@ type Provider struct { appName string } +// OpenRouter's /audio/speech and /audio/transcriptions endpoints are +// OpenAI-shaped, so the embedded CompatibleProvider implementation serves them +// as-is; this assertion keeps the audio surface (and the catalog's audio-only +// models, which the registry hides for providers without it) from silently +// disappearing if the embedding changes. +var _ core.AudioProvider = (*Provider)(nil) + func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { baseURL := providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL) p := &Provider{ @@ -96,10 +103,10 @@ type openrouterModel struct { // operator config still override the stamp. // // output_modalities=all is required: the endpoint defaults to text-output -// models only, which would hide OpenRouter's embedding models from the -// catalog. Models whose every modality maps outside the gateway's OpenRouter -// surface (rerank-only, video-only, speech/transcription-only) are skipped so -// the catalog never advertises a model that can only fail. +// models only, which would hide OpenRouter's embedding and audio models from +// the catalog. Models whose every modality maps outside the gateway's +// OpenRouter surface (rerank-only, video-only) are skipped so the catalog +// never advertises a model that can only fail. func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { var upstream struct { Data []openrouterModel `json:"data"` @@ -128,13 +135,16 @@ func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) } // servableOpenRouterModalities are output modalities the gateway can reach on -// OpenRouter: text and image generation flow through chat completions, and -// embeddings through /embeddings. A model listing none of these (rerank-only, -// video, speech, transcription) has no working endpoint here. +// OpenRouter: text and image generation flow through chat completions, +// embeddings through /embeddings, and speech/transcription through the +// /audio endpoints. A model listing none of these (rerank-only, video) has no +// working endpoint here. var servableOpenRouterModalities = map[string]struct{}{ - "text": {}, - "image": {}, - "embeddings": {}, + "text": {}, + "image": {}, + "embeddings": {}, + "speech": {}, + "transcription": {}, } func openrouterServable(m openrouterModel) bool { @@ -156,6 +166,9 @@ func openrouterServable(m openrouterModel) bool { // ID inference. func openrouterMetadata(m openrouterModel) *core.ModelMetadata { modes := make([]string, 0, 2) + // "rerank" is deliberately not mapped: the gateway has no rerank surface + // on OpenRouter, and the rerank mode would sort the model into the + // Embeddings category despite being unreachable here. for _, modality := range m.Architecture.OutputModalities { switch strings.ToLower(strings.TrimSpace(modality)) { case "text": @@ -164,9 +177,10 @@ func openrouterMetadata(m openrouterModel) *core.ModelMetadata { modes = append(modes, "image_generation") case "embeddings": modes = append(modes, "embedding") - // "rerank" is deliberately not mapped: the gateway has no rerank - // surface on OpenRouter, and the rerank mode would sort the model - // into the Embeddings category despite being unreachable here. + case "speech": + modes = append(modes, "audio_speech") + case "transcription": + modes = append(modes, "audio_transcription") } } if len(modes) == 0 && m.ContextLength <= 0 { diff --git a/internal/providers/openrouter/openrouter_test.go b/internal/providers/openrouter/openrouter_test.go index e70e7e1be..de6550b0a 100644 --- a/internal/providers/openrouter/openrouter_test.go +++ b/internal/providers/openrouter/openrouter_test.go @@ -33,6 +33,10 @@ func TestListModels_StampsArchitectureModalities(t *testing.T) { "architecture":{"input_modalities":["text"],"output_modalities":["image"]}}, {"id":"voyageai/voyage-4-lite","created":1721260800, "architecture":{"input_modalities":["text"],"output_modalities":["embeddings"]}}, + {"id":"fish-audio/s1","created":1721260800, + "architecture":{"input_modalities":["text"],"output_modalities":["speech"]}}, + {"id":"mistralai/voxtral-mini-3b-2507","created":1721260800, + "architecture":{"input_modalities":["audio"],"output_modalities":["transcription"]}}, {"id":"cohere/rerank-only","created":1721260800, "architecture":{"input_modalities":["text"],"output_modalities":["rerank"]}}, {"id":"acme/video-only","created":1721260800, @@ -49,8 +53,8 @@ func TestListModels_StampsArchitectureModalities(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(resp.Data) != 4 { - t.Fatalf("len(Data) = %d, want 4 (rerank-only and video-only skipped): %+v", len(resp.Data), resp.Data) + if len(resp.Data) != 6 { + t.Fatalf("len(Data) = %d, want 6 (rerank-only and video-only skipped): %+v", len(resp.Data), resp.Data) } byID := map[string]core.Model{} for _, m := range resp.Data { @@ -78,6 +82,17 @@ func TestListModels_StampsArchitectureModalities(t *testing.T) { if embed.Metadata == nil || len(embed.Metadata.Categories) != 1 || embed.Metadata.Categories[0] != core.CategoryEmbedding { t.Errorf("voyage-4-lite categories = %+v, want [embedding]", embed.Metadata) } + speech := byID["fish-audio/s1"] + if speech.Metadata == nil || len(speech.Metadata.Modes) != 1 || speech.Metadata.Modes[0] != "audio_speech" { + t.Errorf("speech model metadata = %+v, want audio_speech modes", speech.Metadata) + } + if speech.Metadata == nil || len(speech.Metadata.Categories) != 1 || speech.Metadata.Categories[0] != core.CategoryAudio { + t.Errorf("speech model categories = %+v, want [audio]", speech.Metadata) + } + stt := byID["mistralai/voxtral-mini-3b-2507"] + if stt.Metadata == nil || len(stt.Metadata.Modes) != 1 || stt.Metadata.Modes[0] != "audio_transcription" { + t.Errorf("transcription model metadata = %+v, want audio_transcription modes", stt.Metadata) + } if _, ok := byID["cohere/rerank-only"]; ok { t.Error("rerank-only model must be skipped: no gateway surface reaches it on OpenRouter") } @@ -114,6 +129,77 @@ func TestListModels_UpstreamErrorPropagates(t *testing.T) { } } +// Audio flows through the embedded OpenAI-compatible implementation; +// OpenRouter-specific request mutation (attribution headers) must still apply +// on that path so audio traffic is attributed like every other call. +func TestAudio_UsesOpenAISurfaceWithAttributionHeaders(t *testing.T) { + type seen struct { + path string + referer string + title string + } + requests := make(chan seen, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- seen{ + path: r.URL.Path, + referer: r.Header.Get("HTTP-Referer"), + title: r.Header.Get("X-OpenRouter-Title"), + } + switch r.URL.Path { + case "/audio/speech": + w.Header().Set("Content-Type", "audio/mpeg") + _, _ = w.Write([]byte("mp3-bytes")) + case "/audio/transcriptions": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"text":"hello"}`)) + default: + t.Errorf("unexpected path %q", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + speech, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{ + Model: "fish-audio/s1", + Input: "hello world", + Voice: "alloy", + }) + if err != nil { + t.Fatalf("CreateSpeech() error = %v", err) + } + if speech.ContentType != "audio/mpeg" { + t.Errorf("speech ContentType = %q, want audio/mpeg", speech.ContentType) + } + + transcription, err := provider.CreateTranscription(context.Background(), &core.AudioTranscriptionRequest{ + Model: "mistralai/voxtral-mini-3b-2507", + File: []byte("wav-bytes"), + Filename: "clip.wav", + }) + if err != nil { + t.Fatalf("CreateTranscription() error = %v", err) + } + if !strings.Contains(string(transcription.Data), "hello") { + t.Errorf("transcription Data = %q, want to contain hello", transcription.Data) + } + + for _, want := range []string{"/audio/speech", "/audio/transcriptions"} { + got := <-requests + if got.path != want { + t.Errorf("path = %q, want %q", got.path, want) + } + if got.referer != defaultSiteURL { + t.Errorf("HTTP-Referer on %s = %q, want %q", want, got.referer, defaultSiteURL) + } + if got.title != defaultAppName { + t.Errorf("X-OpenRouter-Title on %s = %q, want %q", want, got.title, defaultAppName) + } + } +} + func TestChatCompletion_AddsDefaultAttributionHeaders(t *testing.T) { var gotReferer string var gotTitle string