diff --git a/docs/providers.md b/docs/providers.md index 48e42ba..2488d41 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -7,7 +7,7 @@ not statically link the other vendors' SDKs: |---|---|---|---| | OpenAI | `pkg/llm/openai` | `openai.New(key, model)` | gpt-4o, gpt-4o-mini, o1, ... | | Anthropic | `pkg/llm/anthropic` | `anthropic.New(key, model)` | claude-sonnet, claude-opus, ... | -| Google Gemini | `pkg/llm/gemini` | `gemini.New(key, model)` | gemini-2.5-flash, gemini-2.5-pro, ... | +| Google Gemini | `pkg/llm/gemini` | `gemini.New(key, model)` | gemini-3.8-flash (default), gemini-2.5-pro, ... | | Vertex AI (Gemini) | `pkg/llm/gemini` | `gemini.NewVertex(project, location, model)` | Vertex-hosted Gemini via ADC | | OpenAI-compatible | `pkg/llm/openai` | `openai.NewCompat(key, model, baseURL)` | OpenRouter, Ollama, Groq, vLLM, Together, ... | diff --git a/examples/demo/.env.example b/examples/demo/.env.example index a7d4ab3..11e86d4 100644 --- a/examples/demo/.env.example +++ b/examples/demo/.env.example @@ -10,7 +10,7 @@ ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-sonnet-4-5 GEMINI_API_KEY=AIza... -GEMINI_MODEL=gemini-2.5-flash +GEMINI_MODEL=gemini-3.8-flash # ── Tools ──────────────────────────────────────────────────────────────────── TAVILY_API_KEY=tvly-... diff --git a/pkg/audio/audio.go b/pkg/audio/audio.go index bb7ff9d..31c1fb8 100644 --- a/pkg/audio/audio.go +++ b/pkg/audio/audio.go @@ -140,6 +140,9 @@ var extByMIME = map[string]string{ "audio/opus": "ogg", "audio/flac": "flac", "audio/x-flac": "flac", + "audio/aac": "aac", + "audio/x-aac": "aac", + "audio/amr": "amr", } // Ext returns the file extension for a media type, or "" when the type is not diff --git a/pkg/audio/audio_test.go b/pkg/audio/audio_test.go index e820b46..8b5ac72 100644 --- a/pkg/audio/audio_test.go +++ b/pkg/audio/audio_test.go @@ -20,6 +20,9 @@ func TestExtStripsParametersAndCase(t *testing.T) { {"audio/x-m4a", "m4a"}, {"audio/flac", "flac"}, {"audio/opus", "ogg"}, + {"audio/aac", "aac"}, + {"audio/x-aac", "aac"}, + {"audio/amr", "amr"}, {"text/plain", ""}, {"", ""}, } { diff --git a/pkg/llm/gemini/default_model_test.go b/pkg/llm/gemini/default_model_test.go new file mode 100644 index 0000000..92039c7 --- /dev/null +++ b/pkg/llm/gemini/default_model_test.go @@ -0,0 +1,28 @@ +package gemini + +import "testing" + +// A retired default fails every fresh signup on its first call while old keys +// keep passing, so pin the value and check every constructor honors it. +func TestConstructorsUseDefaultModel(t *testing.T) { + if defaultModel != "gemini-3.8-flash" { + t.Fatalf("defaultModel = %q, want gemini-3.8-flash", defaultModel) + } + p, err := New("test-key", "") + if err != nil { + t.Fatalf("New: %v", err) + } + tr, err := NewTranscriber("test-key", "") + if err != nil { + t.Fatalf("NewTranscriber: %v", err) + } + ma, err := NewMediaAnalyzer("test-key", "") + if err != nil { + t.Fatalf("NewMediaAnalyzer: %v", err) + } + for name, got := range map[string]string{"New": p.model, "NewTranscriber": tr.model, "NewMediaAnalyzer": ma.model} { + if got != defaultModel { + t.Fatalf("%s model = %q, want %q", name, got, defaultModel) + } + } +} diff --git a/pkg/llm/gemini/gemini.go b/pkg/llm/gemini/gemini.go index 0b24a08..b7ee938 100644 --- a/pkg/llm/gemini/gemini.go +++ b/pkg/llm/gemini/gemini.go @@ -56,7 +56,13 @@ func WithSeed(n int64) Option { return func(p *Provider) { p.seed = &n } } -// New creates a wrapper over Google Gemini's API. +// defaultModel is the model every constructor in this package uses when +// given "". gemini-2.5-flash is rejected with a 404 for API keys created after +// its retirement, so a fresh signup would fail on its first call. +const defaultModel = "gemini-3.8-flash" + +// New creates a wrapper over Google Gemini's API. Empty model defaults to +// "gemini-3.8-flash". func New(apiKey string, model string, opts ...Option) (*Provider, error) { if apiKey == "" { apiKey = os.Getenv("GEMINI_API_KEY") @@ -65,7 +71,7 @@ func New(apiKey string, model string, opts ...Option) (*Provider, error) { return nil, errors.New("GEMINI_API_KEY is not set in environment") } if model == "" { - model = "gemini-2.5-flash" + model = defaultModel } client, err := genai.NewClient(context.Background(), &genai.ClientConfig{ diff --git a/pkg/llm/gemini/gemini_vision.go b/pkg/llm/gemini/gemini_vision.go index cbdb25d..97d206e 100644 --- a/pkg/llm/gemini/gemini_vision.go +++ b/pkg/llm/gemini/gemini_vision.go @@ -20,7 +20,7 @@ type MediaAnalyzer struct { } // NewMediaAnalyzer builds an analyzer. apiKey defaults to GEMINI_API_KEY; -// model defaults to "gemini-2.5-flash". +// model defaults to "gemini-3.8-flash". func NewMediaAnalyzer(apiKey, model string) (*MediaAnalyzer, error) { if apiKey == "" { apiKey = os.Getenv("GEMINI_API_KEY") @@ -29,7 +29,7 @@ func NewMediaAnalyzer(apiKey, model string) (*MediaAnalyzer, error) { return nil, fmt.Errorf("gemini: GEMINI_API_KEY not set") } if model == "" { - model = "gemini-2.5-flash" + model = defaultModel } client, err := genai.NewClient(context.Background(), &genai.ClientConfig{ APIKey: apiKey, diff --git a/pkg/llm/gemini/transcriber.go b/pkg/llm/gemini/transcriber.go index 036345e..b9b1467 100644 --- a/pkg/llm/gemini/transcriber.go +++ b/pkg/llm/gemini/transcriber.go @@ -47,7 +47,7 @@ type Transcriber struct { var _ audio.Transcriber = (*Transcriber)(nil) // NewTranscriber builds a transcriber. apiKey defaults to GEMINI_API_KEY; -// model defaults to "gemini-2.5-flash". +// model defaults to "gemini-3.8-flash". func NewTranscriber(apiKey, model string) (*Transcriber, error) { if apiKey == "" { apiKey = os.Getenv("GEMINI_API_KEY") @@ -56,7 +56,7 @@ func NewTranscriber(apiKey, model string) (*Transcriber, error) { return nil, fmt.Errorf("gemini: NewTranscriber: GEMINI_API_KEY not set") } if model == "" { - model = "gemini-2.5-flash" + model = defaultModel } client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: apiKey}) if err != nil { diff --git a/pkg/llm/gemini/vertex.go b/pkg/llm/gemini/vertex.go index f478802..337ff04 100644 --- a/pkg/llm/gemini/vertex.go +++ b/pkg/llm/gemini/vertex.go @@ -17,7 +17,7 @@ import ( // // Empty projectID / location fall back to GOOGLE_CLOUD_PROJECT and // GOOGLE_CLOUD_LOCATION respectively. Empty model defaults to -// "gemini-2.5-flash". +// "gemini-3.8-flash". // // The returned *Provider reuses the same GenerateStream implementation // as the public-API backend — only the transport differs. @@ -35,7 +35,7 @@ func NewVertex(projectID, location, model string, opts ...Option) (*Provider, er location = "us-central1" } if model == "" { - model = "gemini-2.5-flash" + model = defaultModel } client, err := genai.NewClient(context.Background(), &genai.ClientConfig{ diff --git a/pkg/llm/openai/transcriber.go b/pkg/llm/openai/transcriber.go index 466bfbe..6470b5f 100644 --- a/pkg/llm/openai/transcriber.go +++ b/pkg/llm/openai/transcriber.go @@ -16,6 +16,11 @@ import ( // instead of after transferring the payload. const maxClipBytes = 25 << 20 +// unsupportedExts are containers pkg/audio accepts (Gemini decodes them) but +// the OpenAI audio endpoint does not list. Rejecting them here fails fast with +// ErrUnsupportedFormat instead of after a full upload. +var unsupportedExts = map[string]bool{"aac": true, "amr": true} + // Transcriber implements audio.Transcriber against OpenAI's audio // transcription endpoint. // @@ -52,6 +57,10 @@ func (t *Transcriber) Transcribe(ctx context.Context, clip audio.Clip, opts audi if err := clip.Validate(); err != nil { return audio.Transcript{}, fmt.Errorf("openai: Transcriber: %w", err) } + if ext := audio.Ext(clip.MIME); unsupportedExts[ext] { + return audio.Transcript{}, fmt.Errorf("openai: Transcriber: %w: %s is not accepted by the OpenAI audio endpoint", + audio.ErrUnsupportedFormat, ext) + } if len(clip.Data) > maxClipBytes { return audio.Transcript{}, fmt.Errorf("openai: Transcriber: %w: %d bytes exceeds %d", audio.ErrTooLarge, len(clip.Data), maxClipBytes) diff --git a/pkg/llm/openai/transcriber_test.go b/pkg/llm/openai/transcriber_test.go index d5c3d8b..7995b1a 100644 --- a/pkg/llm/openai/transcriber_test.go +++ b/pkg/llm/openai/transcriber_test.go @@ -151,6 +151,8 @@ func TestTranscribeRejectsInvalidClip(t *testing.T) { }{ {"empty", audio.Clip{MIME: "audio/wav"}, audio.ErrNoAudio}, {"unsupported", audio.Clip{MIME: "application/pdf", Data: []byte("x")}, audio.ErrUnsupportedFormat}, + {"aac not accepted by endpoint", audio.Clip{MIME: "audio/aac", Data: []byte("x")}, audio.ErrUnsupportedFormat}, + {"amr not accepted by endpoint", audio.Clip{MIME: "audio/amr", Data: []byte("x")}, audio.ErrUnsupportedFormat}, } { t.Run(tc.name, func(t *testing.T) { if _, err := tr.Transcribe(context.Background(), tc.clip, audio.Options{}); !errors.Is(err, tc.want) {