* docs: design + plan for shared AI core, metadata translation, Whisper ASR Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(ai): shared LLM client, segment translator, and job runner packages internal/ai/llm: OpenAI-compatible chat client moved out of subtitles/ai, plus /v1/audio/transcriptions (verbose_json) for the ASR work; one shared retry/backoff loop for both. internal/ai/translate: the batched indexed-JSON translation protocol generalized to text segments. internal/ai/jobrunner: dispatch/heartbeat/reaper/cancel lifecycle extracted behind a minimal store interface, with a semaphore shareable across job services. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(subtitles): consume shared AI core LLMTranslator becomes a thin cue<->segment adapter over aitranslate; the service delegates dispatch/heartbeat/reaper/cancel to jobrunner; the local OpenAI client is gone in favor of internal/ai/llm. Behavior (prompts, wire protocol, job rows, recovery semantics) is unchanged. NewService now takes the dispatch semaphore so all AI job services can share one bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(config): shared ai.* settings, metadata translation job table, localization provenance columns ai.* connection keys (chat + optional separate ASR endpoint) load with a fallback to the legacy subtitle_ai.* rows — those are never renamed in SQL because encrypted values are GCM-bound to their setting key. New toggles: subtitle_ai.transcribe_enabled, metadata_ai.enabled. Migration adds metadata_translation_jobs, per-field provenance (provider|ai|manual) on the localization tables, and media_folders.auto_translate_metadata. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(catalog): localization field provenance with provider/ai/manual precedence Provider upserts keep manual values and never blank a field with an empty incoming value; new UpsertAITranslation/UpsertAIOverview methods write AI fields only over empty or ai-sourced values (force adds provider, never manual) — all enforced in single-statement SQL. Serving now merges only non-empty localized fields onto the base item, since localization rows are legitimately partial (AI rows carry no titles/artwork). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(metadata): AI translation service, refresh auto-fallback, and admin API internal/metadata/translation: job service over the shared AI core that expands an item to its season/episode overviews, skips already-localized fields (zero model calls on repeat runs), batches paragraphs through the generic translator, and persists per batch with provenance-aware upserts. MetadataService gains an AutoTranslator seam invoked after each refresh for libraries with auto_translate_metadata. Admin endpoints under the metadata curation guard: enqueue, list (poll), cancel; plus a status probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs New WhisperTranscriber: one ffmpeg pass extracts the audio track to 10-min 16kHz mono WAV chunks (temp dir cleaned on every exit path), each chunk goes to the OpenAI-compatible /v1/audio/transcriptions endpoint (verbose_json, per-request timeout sized to 3x chunk duration), segment timestamps are offset and built into wrapped cues. Chunks process playhead-first and stream live to the requesting session. The transcript is stored as an ordinary downloaded subtitle (provider 'transcribed'); transcribe_translate chains the existing translator and stores the translated track as the job result. Enqueue accepts an optional kind; status reports transcribe_enabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): AI services settings, metadata translate action, library auto-translate, generate-from-audio New AI Services admin page hosts the shared endpoint config (reads fall back to legacy subtitle_ai.* values, writes target ai.*) and the three feature toggles; the AI card moves out of Subtitles settings. The metadata editor gains a Translate-with-AI panel with job polling and force/re-translate. The library form gains the auto-translate toggle (threaded through the libraries API). The player translate modal gains a From-audio mode that lists audio tracks and submits transcribe / transcribe_translate jobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: gofmt import grouping in router and translation tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(catalog): per-profile metadata language and viewer-triggered description translation user_profiles.preferred_metadata_language threads through the access scope into catalog serving: presentation language now resolves explicit param -> profile preference -> library metadata language (native API and jellycompat). ItemDetail gains pending_translation_language when the viewer's language is missing a localized overview. New metadata_ai.on_view setting (off|button| auto) gates POST /items/{id}/translate-description: any profile with item access may request its language, with in-flight dedup and a 15-minute failure cooldown so page views never hammer a broken endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): on-view description translation with per-profile metadata language Profile playback settings gain a Metadata language picker (library default inherit). Detail pages: when the server reports pending_translation_language and metadata_ai.on_view is 'auto', the description translates on view with a pulse animation until the refetched detail comes back localized (45s timeout); in 'button' mode a small Translate chip triggers the same flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): expose metadata_ai.on_view in AI Services settings The on-view translation mode had no UI control, so it could only ever be 'off' — viewers got neither the auto translation nor the fallback button. Adds the off/button/auto selector to the Features card, and the config loader now warns and falls back to 'off' on a bad row instead of refusing to start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ai): clear configuration hint when the transcription endpoint is chat-only A blank Transcription base URL falls back to the chat endpoint; chat-only gateways reject the multipart upload with an opaque 400 that reads like a pipeline bug. 400/404/405 transcription failures now carry a hint to set a Whisper-compatible endpoint in AI Services. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(subtitles): wrap ASR cue text by rune count, not bytes Arabic/Cyrillic/Greek text is 2+ bytes per character in UTF-8, so byte-based wrapping broke lines at roughly half the intended visual width. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(web): steer transcription base URL hint away from chat-only gateways Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ai): block chat-only gateways for transcription, add endpoint presets llm.IsChatOnlyGateway (OpenRouter et al — no timestamped transcription API) is enforced in three layers: the settings API rejects ai.asr_base_url values pointing at one, the router disables ASR with a warning when the blank-URL fallback would land on one, and llm.Transcribe refuses outright. The AI Services page gains one-click transcription presets (Groq turbo/accurate, OpenAI, self-hosted speaches) plus the mirrored client-side check, and the settings API now also validates metadata_ai.on_view. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(subtitles): tighten ASR subtitle sync Three systematic timing-error sources addressed: cue offsets now use the segment muxer's exact per-chunk start times (segment_list CSV) instead of assuming index*chunk_seconds; the audio stream's start delay relative to the container timeline (common in TS remuxes) is probed via ffprobe and added to every cue; and the chunk length is now operator-tunable via subtitle_ai.asr_chunk_seconds (60-600s, default 600) since shorter chunks bound Whisper's within-chunk timestamp drift. Playhead-first ordering now pivots on real chunk starts, and a beyond-end playhead starts at the final chunk instead of restarting from zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ai): tolerate base URLs that already include the /v1 segment Providers like DeepInfra expose their OpenAI-compatible API under a base that contains the version segment (api.deepinfra.com/v1/openai); always appending /v1/... mangled those. endpointURL now appends bare paths when the base already carries /v1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): prefer self-hosted transcription in presets and hints Preset order becomes self-hosted (recommended) -> Groq turbo -> Groq large-v3 -> OpenAI, and the settings hint plus the job-error hint lead with the self-hosted option. The self-hosted preset now fills the turbo CT2 model to match the recommended speaches setup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(subtitles): request VAD and word timestamps for ASR cue accuracy Without vad_filter, faster-whisper servers report wall-to-wall segment times: cues linger on screen through silence (verified up to 91s) and paragraph-length segments become single 400+ char cues. Request vad_filter=true (skipped for hosted providers that reject non-OpenAI fields and run VAD server-side) plus timestamp_granularities word+segment, and rebuild cues from word timings: split at speech pauses, sentence ends, text capacity, and a 7s max duration; cap word-less segments instead of trusting their reported end; stretch sub-second cues to a readable minimum. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
351 lines
12 KiB
Go
351 lines
12 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func chatConfig(baseURL string) Config {
|
|
return Config{BaseURL: baseURL, APIKey: "test-key", ChatModel: "test-model"}
|
|
}
|
|
|
|
const chatOK = `{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`
|
|
|
|
func TestChatSuccessSendsAuthAndModel(t *testing.T) {
|
|
var gotAuth, gotBody string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
buf := make([]byte, 4096)
|
|
n, _ := r.Body.Read(buf)
|
|
gotBody = string(buf[:n])
|
|
w.Write([]byte(chatOK))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(chatConfig(srv.URL))
|
|
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, true)
|
|
if err != nil {
|
|
t.Fatalf("Chat: %v", err)
|
|
}
|
|
if out != "hello" {
|
|
t.Errorf("content = %q, want hello", out)
|
|
}
|
|
if gotAuth != "Bearer test-key" {
|
|
t.Errorf("auth = %q", gotAuth)
|
|
}
|
|
if !strings.Contains(gotBody, `"model":"test-model"`) || !strings.Contains(gotBody, `"json_object"`) {
|
|
t.Errorf("request body missing model/response_format: %s", gotBody)
|
|
}
|
|
}
|
|
|
|
func TestChatRetriesOn429HonoringRetryAfter(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if calls.Add(1) == 1 {
|
|
w.Header().Set("Retry-After", "1")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
w.Write([]byte(chatOK))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
start := time.Now()
|
|
c := NewClient(chatConfig(srv.URL))
|
|
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err != nil {
|
|
t.Fatalf("Chat: %v", err)
|
|
}
|
|
if calls.Load() != 2 {
|
|
t.Errorf("calls = %d, want 2", calls.Load())
|
|
}
|
|
if elapsed := time.Since(start); elapsed < time.Second {
|
|
t.Errorf("did not honor Retry-After: elapsed %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestChatRetriesOn5xxAndEmbeddedErrorAndEmptyChoices(t *testing.T) {
|
|
responses := []func(w http.ResponseWriter){
|
|
func(w http.ResponseWriter) { w.WriteHeader(http.StatusBadGateway) },
|
|
func(w http.ResponseWriter) { w.Write([]byte(`{"error":{"message":"upstream sad"}}`)) },
|
|
func(w http.ResponseWriter) { w.Write([]byte(`{"choices":[]}`)) },
|
|
func(w http.ResponseWriter) { w.Write([]byte(chatOK)) },
|
|
}
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
responses[calls.Add(1)-1](w)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(chatConfig(srv.URL))
|
|
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false)
|
|
if err != nil {
|
|
t.Fatalf("Chat: %v", err)
|
|
}
|
|
if out != "hello" || calls.Load() != 4 {
|
|
t.Errorf("out=%q calls=%d, want hello/4", out, calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestChatFailsFastOnNon429ClientError(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(chatConfig(srv.URL))
|
|
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if calls.Load() != 1 {
|
|
t.Errorf("calls = %d, want 1 (no retry on 401)", calls.Load())
|
|
}
|
|
}
|
|
|
|
const verboseJSON = `{"language":"english","text":"hi there","segments":[{"start":0.0,"end":1.5,"text":" hi"},{"start":1.5,"end":3.0,"text":" there"}]}`
|
|
|
|
func TestTranscribeParsesSegmentsAndMultipart(t *testing.T) {
|
|
var gotModel, gotFormat, gotLang, gotAuth, gotVAD string
|
|
var gotGranularities []string
|
|
var gotFile []byte
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
t.Errorf("parse multipart: %v", err)
|
|
}
|
|
gotModel = r.FormValue("model")
|
|
gotFormat = r.FormValue("response_format")
|
|
gotLang = r.FormValue("language")
|
|
gotVAD = r.FormValue("vad_filter")
|
|
gotGranularities = r.MultipartForm.Value["timestamp_granularities[]"]
|
|
f, _, err := r.FormFile("file")
|
|
if err == nil {
|
|
buf := make([]byte, 64)
|
|
n, _ := f.Read(buf)
|
|
gotFile = buf[:n]
|
|
f.Close()
|
|
}
|
|
w.Write([]byte(verboseJSON))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := chatConfig(srv.URL)
|
|
cfg.ASRModel = "whisper-test"
|
|
c := NewClient(cfg)
|
|
tr, err := c.Transcribe(context.Background(), TranscribeRequest{
|
|
Filename: "chunk.wav", Audio: []byte("RIFFfake"), Language: "ja",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Transcribe: %v", err)
|
|
}
|
|
if gotModel != "whisper-test" || gotFormat != "verbose_json" || gotLang != "ja" {
|
|
t.Errorf("fields model=%q format=%q lang=%q", gotModel, gotFormat, gotLang)
|
|
}
|
|
// Local (non-hosted) endpoint: faster-whisper VAD must be requested or
|
|
// segment timestamps stretch wall-to-wall across silence.
|
|
if gotVAD != "true" {
|
|
t.Errorf("vad_filter = %q, want true for a self-hosted endpoint", gotVAD)
|
|
}
|
|
if fmt.Sprint(gotGranularities) != "[segment word]" {
|
|
t.Errorf("timestamp_granularities[] = %v, want [segment word]", gotGranularities)
|
|
}
|
|
if gotAuth != "Bearer test-key" {
|
|
t.Errorf("auth = %q (should fall back to chat key)", gotAuth)
|
|
}
|
|
if string(gotFile) != "RIFFfake" {
|
|
t.Errorf("file payload = %q", gotFile)
|
|
}
|
|
if tr.Language != "english" || len(tr.Segments) != 2 || tr.Segments[1].Text != " there" || tr.Segments[1].End != 3.0 {
|
|
t.Errorf("unexpected transcription: %+v", tr)
|
|
}
|
|
}
|
|
|
|
func TestTranscribeOmitsVADFilterForStrictHostedEndpoints(t *testing.T) {
|
|
for url, wantVAD := range map[string]bool{
|
|
"https://api.openai.com": false,
|
|
"https://myorg.openai.azure.com": false,
|
|
"https://api.groq.com/openai": false,
|
|
"http://192.168.1.10:8000": true,
|
|
"https://whisper.example.com": true,
|
|
"https://api.deepinfra.com/v1/openai": true,
|
|
} {
|
|
got := !hostMatchesAny(url, strictHostedASRHosts)
|
|
if got != wantVAD {
|
|
t.Errorf("vad_filter for %s = %v, want %v", url, got, wantVAD)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTranscribeParsesPerSegmentWords(t *testing.T) {
|
|
// speaches/faster-whisper shape: words nested inside each segment.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(`{"language":"en","text":"hi there","segments":[
|
|
{"start":0.0,"end":3.0,"text":" hi there","words":[
|
|
{"start":0.2,"end":0.5,"word":" hi"},{"start":0.6,"end":1.0,"word":" there"}]}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := chatConfig(srv.URL)
|
|
cfg.ASRModel = "whisper-test"
|
|
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
|
if err != nil {
|
|
t.Fatalf("Transcribe: %v", err)
|
|
}
|
|
words := tr.Segments[0].Words
|
|
if len(words) != 2 || words[1].Text != " there" || words[1].Start != 0.6 || words[1].End != 1.0 {
|
|
t.Errorf("segment words = %+v", words)
|
|
}
|
|
}
|
|
|
|
func TestTranscribeAttachesTopLevelWordsBySegmentTime(t *testing.T) {
|
|
// OpenAI shape: words in a top-level array, segments without words.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(`{"language":"en","text":"hi there friend","segments":[
|
|
{"start":0.0,"end":1.5,"text":" hi there"},{"start":1.5,"end":3.0,"text":" friend"}],
|
|
"words":[{"start":0.2,"end":0.5,"word":"hi"},{"start":0.6,"end":1.0,"word":"there"},
|
|
{"start":1.8,"end":2.2,"word":"friend"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := chatConfig(srv.URL)
|
|
cfg.ASRModel = "whisper-test"
|
|
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
|
if err != nil {
|
|
t.Fatalf("Transcribe: %v", err)
|
|
}
|
|
if len(tr.Segments[0].Words) != 2 || len(tr.Segments[1].Words) != 1 {
|
|
t.Errorf("word distribution = %d/%d, want 2/1", len(tr.Segments[0].Words), len(tr.Segments[1].Words))
|
|
}
|
|
if tr.Segments[1].Words[0].Text != "friend" {
|
|
t.Errorf("segment 1 word = %+v", tr.Segments[1].Words[0])
|
|
}
|
|
}
|
|
|
|
func TestTranscribeEmptySegmentsIsNotAnError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(`{"language":"english","text":"","segments":[]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := chatConfig(srv.URL)
|
|
cfg.ASRModel = "whisper-test"
|
|
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
|
if err != nil {
|
|
t.Fatalf("Transcribe: %v", err)
|
|
}
|
|
if len(tr.Segments) != 0 {
|
|
t.Errorf("segments = %v, want empty", tr.Segments)
|
|
}
|
|
}
|
|
|
|
func TestTranscribeMissingSegmentsFieldFailsFast(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
w.Write([]byte(`{"text":"plain response without segments"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := chatConfig(srv.URL)
|
|
cfg.ASRModel = "whisper-test"
|
|
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
|
if err == nil || !strings.Contains(err.Error(), "verbose_json") {
|
|
t.Fatalf("err = %v, want verbose_json complaint", err)
|
|
}
|
|
if calls.Load() != 1 {
|
|
t.Errorf("calls = %d, want 1 (permanent error must not retry)", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestTranscribeUsesASROverrides(t *testing.T) {
|
|
var gotAuth atomic.Value
|
|
asrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth.Store(r.Header.Get("Authorization"))
|
|
w.Write([]byte(verboseJSON))
|
|
}))
|
|
defer asrSrv.Close()
|
|
|
|
cfg := Config{
|
|
BaseURL: "http://chat.invalid", APIKey: "chat-key", ChatModel: "m",
|
|
ASRBaseURL: asrSrv.URL, ASRAPIKey: "asr-key", ASRModel: "whisper-test",
|
|
}
|
|
if _, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err != nil {
|
|
t.Fatalf("Transcribe: %v", err)
|
|
}
|
|
if gotAuth.Load() != "Bearer asr-key" {
|
|
t.Errorf("auth = %q, want asr-key", gotAuth.Load())
|
|
}
|
|
}
|
|
|
|
func TestTranscribeRequiresConfig(t *testing.T) {
|
|
c := NewClient(Config{BaseURL: "http://x", ChatModel: "m"}) // no ASR model
|
|
if _, err := c.Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err == nil {
|
|
t.Fatal("expected not-configured error")
|
|
}
|
|
}
|
|
|
|
func TestTranscribeChatOnlyGatewayGetsConfigHint(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte(`{"error":{"message":"invalid content-type: multipart/form-data","code":400}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := chatConfig(srv.URL)
|
|
cfg.ASRModel = "whisper-test"
|
|
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
|
if err == nil || !strings.Contains(err.Error(), "Whisper-compatible Transcription base URL") {
|
|
t.Fatalf("err = %v, want configuration hint", err)
|
|
}
|
|
}
|
|
|
|
func TestIsChatOnlyGateway(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"https://openrouter.ai/api": true,
|
|
"https://openrouter.ai": true,
|
|
"openrouter.ai/api/v1": true,
|
|
"https://gateway.openrouter.ai": true,
|
|
"https://api.groq.com/openai": false,
|
|
"https://api.openai.com": false,
|
|
"http://localhost:8000": false,
|
|
"https://my-openrouter.ai.example.io": false,
|
|
"": false,
|
|
}
|
|
for in, want := range cases {
|
|
if got := IsChatOnlyGateway(in); got != want {
|
|
t.Errorf("IsChatOnlyGateway(%q) = %v, want %v", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTranscribeRefusesChatOnlyGateway(t *testing.T) {
|
|
cfg := Config{BaseURL: "https://openrouter.ai/api", ChatModel: "m", ASRModel: "whisper-1"}
|
|
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
|
if err == nil || !strings.Contains(err.Error(), "cannot produce timestamped transcriptions") {
|
|
t.Fatalf("err = %v, want chat-only refusal", err)
|
|
}
|
|
}
|
|
|
|
func TestEndpointURLToleratesVersionedBases(t *testing.T) {
|
|
cases := map[string]string{
|
|
"https://api.openai.com": "https://api.openai.com/v1/chat/completions",
|
|
"https://api.groq.com/openai": "https://api.groq.com/openai/v1/chat/completions",
|
|
"https://api.deepinfra.com/v1/openai": "https://api.deepinfra.com/v1/openai/chat/completions",
|
|
"http://localhost:8969": "http://localhost:8969/v1/chat/completions",
|
|
"http://localhost:8969/v1": "http://localhost:8969/v1/chat/completions",
|
|
"https://api.openai.com/": "https://api.openai.com/v1/chat/completions",
|
|
}
|
|
for base, want := range cases {
|
|
if got := endpointURL(base, "chat/completions"); got != want {
|
|
t.Errorf("endpointURL(%q) = %q, want %q", base, got, want)
|
|
}
|
|
}
|
|
}
|