From fa5f51e72e2860b3e6f84b124788e1e7bf3cc3a0 Mon Sep 17 00:00:00 2001 From: Luan Vu Date: Wed, 11 Mar 2026 16:40:35 +0700 Subject: [PATCH] fix: allow OAuth providers in media tool chain (read_audio, read_image, etc.) (#150) ExecuteWithChain previously required all providers to implement credentialProvider (APIKey/APIBase). OAuth-based providers like CodexProvider (ChatGPT OAuth) don't expose static credentials, causing all media tools to fail with "does not expose API credentials". Make credentialProvider optional (nil when unsupported). Each callProvider gracefully falls back to the provider's Chat() API when credentials are unavailable. Generation tools (create_image, create_video, create_audio) return a clear error since they require direct API access with no Chat fallback. Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com> --- internal/tools/create_audio.go | 3 +++ internal/tools/create_image.go | 3 +++ internal/tools/create_video.go | 3 +++ internal/tools/media_provider_chain.go | 11 +++----- internal/tools/read_audio_resolve.go | 34 ++++++++++++++----------- internal/tools/read_document_resolve.go | 4 +-- internal/tools/read_video_resolve.go | 4 +-- 7 files changed, 36 insertions(+), 26 deletions(-) diff --git a/internal/tools/create_audio.go b/internal/tools/create_audio.go index 2a11187a..a9d80328 100644 --- a/internal/tools/create_audio.go +++ b/internal/tools/create_audio.go @@ -173,6 +173,9 @@ func (t *CreateAudioTool) Execute(ctx context.Context, args map[string]any) *Res // callProvider dispatches to the correct music generation implementation based on provider type. func (t *CreateAudioTool) callProvider(ctx context.Context, cp credentialProvider, providerName, model string, params map[string]any) ([]byte, *providers.Usage, error) { + if cp == nil { + return nil, nil, fmt.Errorf("provider %q does not expose API credentials required for audio generation", providerName) + } prompt := GetParamString(params, "prompt", "") slog.Info("create_audio: calling music generation API", diff --git a/internal/tools/create_image.go b/internal/tools/create_image.go index fbf34c45..67a81c64 100644 --- a/internal/tools/create_image.go +++ b/internal/tools/create_image.go @@ -129,6 +129,9 @@ func (t *CreateImageTool) Execute(ctx context.Context, args map[string]any) *Res // callProvider dispatches to the correct image generation implementation based on provider type. func (t *CreateImageTool) callProvider(ctx context.Context, cp credentialProvider, providerName, model string, params map[string]any) ([]byte, *providers.Usage, error) { + if cp == nil { + return nil, nil, fmt.Errorf("provider %q does not expose API credentials required for image generation", providerName) + } prompt := GetParamString(params, "prompt", "") aspectRatio := GetParamString(params, "aspect_ratio", "1:1") diff --git a/internal/tools/create_video.go b/internal/tools/create_video.go index 51f3d2a5..29c12d3e 100644 --- a/internal/tools/create_video.go +++ b/internal/tools/create_video.go @@ -140,6 +140,9 @@ func (t *CreateVideoTool) Execute(ctx context.Context, args map[string]any) *Res // callProvider dispatches to the correct video generation implementation based on provider type. func (t *CreateVideoTool) callProvider(ctx context.Context, cp credentialProvider, providerName, model string, params map[string]any) ([]byte, *providers.Usage, error) { + if cp == nil { + return nil, nil, fmt.Errorf("provider %q does not expose API credentials required for video generation", providerName) + } prompt := GetParamString(params, "prompt", "") duration := GetParamInt(params, "duration", 8) aspectRatio := GetParamString(params, "aspect_ratio", "16:9") diff --git a/internal/tools/media_provider_chain.go b/internal/tools/media_provider_chain.go index c782c344..7b745694 100644 --- a/internal/tools/media_provider_chain.go +++ b/internal/tools/media_provider_chain.go @@ -169,13 +169,10 @@ func ExecuteWithChain( continue } - cp, ok := p.(credentialProvider) - if !ok { - slog.Warn("media_chain: provider does not expose credentials, skipping", - "provider", entry.Provider) - lastErr = fmt.Errorf("provider %q does not expose API credentials", entry.Provider) - continue - } + // credentialProvider is optional — providers that don't expose static + // credentials (e.g. OAuth-based CodexProvider) pass nil and each + // callProvider falls back to using the provider's Chat() API. + cp, _ := p.(credentialProvider) // Retry loop for this provider for attempt := 1; attempt <= entry.MaxRetries; attempt++ { diff --git a/internal/tools/read_audio_resolve.go b/internal/tools/read_audio_resolve.go index c8405248..e86c72ce 100644 --- a/internal/tools/read_audio_resolve.go +++ b/internal/tools/read_audio_resolve.go @@ -60,24 +60,28 @@ func (t *ReadAudioTool) callProvider(ctx context.Context, cp credentialProvider, data, _ := params["data"].([]byte) mime := GetParamString(params, "mime", "audio/mpeg") - // Gemini: use File API (inlineData doesn't work for audio). - if strings.HasPrefix(providerName, "gemini") { - slog.Info("read_audio: using gemini file API", "provider", providerName, "model", model, "size", len(data), "mime", mime) - resp, err := geminiFileAPICall(ctx, cp.APIKey(), model, prompt, data, mime, 120*time.Second) - if err != nil { - return nil, nil, fmt.Errorf("gemini file API: %w", err) + // Provider-specific paths require API credentials; skip when cp is nil + // (e.g. OAuth-based providers that don't expose static keys). + if cp != nil { + // Gemini: use File API (inlineData doesn't work for audio). + if strings.HasPrefix(providerName, "gemini") { + slog.Info("read_audio: using gemini file API", "provider", providerName, "model", model, "size", len(data), "mime", mime) + resp, err := geminiFileAPICall(ctx, cp.APIKey(), model, prompt, data, mime, 120*time.Second) + if err != nil { + return nil, nil, fmt.Errorf("gemini file API: %w", err) + } + return []byte(resp.Content), resp.Usage, nil } - return []byte(resp.Content), resp.Usage, nil - } - // OpenAI: use input_audio content part (supports wav, mp3). - if strings.HasPrefix(providerName, "openai") { - slog.Info("read_audio: using openai input_audio API", "provider", providerName, "model", model, "size", len(data), "mime", mime) - resp, err := openaiAudioCall(ctx, cp.APIKey(), cp.APIBase(), model, prompt, data, mime) - if err != nil { - return nil, nil, fmt.Errorf("openai audio call: %w", err) + // OpenAI: use input_audio content part (supports wav, mp3). + if strings.HasPrefix(providerName, "openai") { + slog.Info("read_audio: using openai input_audio API", "provider", providerName, "model", model, "size", len(data), "mime", mime) + resp, err := openaiAudioCall(ctx, cp.APIKey(), cp.APIBase(), model, prompt, data, mime) + if err != nil { + return nil, nil, fmt.Errorf("openai audio call: %w", err) + } + return []byte(resp.Content), resp.Usage, nil } - return []byte(resp.Content), resp.Usage, nil } // Other providers: try standard Chat API with base64 audio as image_url (best effort). diff --git a/internal/tools/read_document_resolve.go b/internal/tools/read_document_resolve.go index 3ce994b1..222f6a67 100644 --- a/internal/tools/read_document_resolve.go +++ b/internal/tools/read_document_resolve.go @@ -61,8 +61,8 @@ func (t *ReadDocumentTool) callProvider(ctx context.Context, cp credentialProvid data, _ := params["data"].([]byte) mime := GetParamString(params, "mime", "application/octet-stream") - // Gemini: use native API (OpenAI-compat endpoint doesn't support non-image MIME types). - if strings.HasPrefix(providerName, "gemini") { + // Gemini: use native API (requires credentials; OpenAI-compat endpoint doesn't support non-image MIME types). + if cp != nil && strings.HasPrefix(providerName, "gemini") { slog.Info("read_document: using gemini native API", "provider", providerName, "model", model, "doc_size", len(data), "mime", mime) diff --git a/internal/tools/read_video_resolve.go b/internal/tools/read_video_resolve.go index bd4f9831..24c45586 100644 --- a/internal/tools/read_video_resolve.go +++ b/internal/tools/read_video_resolve.go @@ -59,8 +59,8 @@ func (t *ReadVideoTool) callProvider(ctx context.Context, cp credentialProvider, data, _ := params["data"].([]byte) mime := GetParamString(params, "mime", "video/mp4") - // Gemini: use File API. - if strings.HasPrefix(providerName, "gemini") { + // Gemini: use File API (requires credentials). + if cp != nil && strings.HasPrefix(providerName, "gemini") { slog.Info("read_video: using gemini file API", "provider", providerName, "model", model, "size", len(data), "mime", mime) resp, err := geminiFileAPICall(ctx, cp.APIKey(), model, prompt, data, mime, 180*time.Second) if err != nil {