Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions internal/providers/openrouter/openrouter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand All @@ -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":
Expand All @@ -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 {
Expand Down
90 changes: 88 additions & 2 deletions internal/providers/openrouter/openrouter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
Expand Down
Loading