Skip to content

Commit 19b0cbd

Browse files
Alexey Panfilovclaude
andcommitted
fix: voice transcription via native Gemini API instead of OpenAI-compat
The OpenAI-compatible endpoint silently ignores input_audio parts, causing Gemini to respond with "provide the audio file" instead of transcribing. Switch to native Gemini generateContent API with inline_data for reliable audio transcription. - Add transcribe.go with direct Gemini REST call (inline_data + mime_type) - TranscribeAudio now uses TranscribeConfig (model + API key) - EnableTranscription() wired in main.go from gemini-flash config - Pass audio/ogg MIME type from handler instead of format string Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4fdd084 commit 19b0cbd

4 files changed

Lines changed: 121 additions & 20 deletions

File tree

cmd/agent/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,15 @@ func main() {
171171

172172
ag := agent.New(router, s, mcpClient, compacter, string(sysPromptBytes), logger)
173173

174+
// Enable voice transcription if a multimodal Gemini model is configured.
175+
if cfg.Models.GeminiFlash.APIKey != "" && cfg.Models.GeminiFlash.Model != "" {
176+
ag.EnableTranscription(agent.TranscribeConfig{
177+
Model: cfg.Models.GeminiFlash.Model,
178+
APIKey: cfg.Models.GeminiFlash.APIKey,
179+
})
180+
logger.Info("voice transcription enabled", "model", cfg.Models.GeminiFlash.Model)
181+
}
182+
174183
if cfg.WebSearch.Enabled {
175184
baseURL := cfg.WebSearch.BaseURL
176185
if baseURL == "" {

internal/agent/agent.go

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,15 @@ const (
3333
)
3434

3535
type Agent struct {
36-
router *llm.Router
37-
store store.Store
38-
mcp *mcp.Client
39-
compacter *Compacter
40-
cache *ResponseCache
41-
sysPrompt string
42-
logger *slog.Logger
43-
webSearch *WebSearchConfig // nil = disabled
36+
router *llm.Router
37+
store store.Store
38+
mcp *mcp.Client
39+
compacter *Compacter
40+
cache *ResponseCache
41+
sysPrompt string
42+
logger *slog.Logger
43+
webSearch *WebSearchConfig // nil = disabled
44+
transcribe *TranscribeConfig // nil = disabled
4445
}
4546

4647
func New(router *llm.Router, s store.Store, mcpClient *mcp.Client, compacter *Compacter, sysPrompt string, logger *slog.Logger) *Agent {
@@ -55,21 +56,22 @@ func New(router *llm.Router, s store.Store, mcpClient *mcp.Client, compacter *Co
5556
}
5657
}
5758

58-
// TranscribeAudio sends audio data to the multimodal LLM for transcription.
59+
// TranscribeAudio transcribes audio data to text via the native Gemini API.
5960
// Returns the transcribed text. The audio is not stored in conversation history.
60-
func (a *Agent) TranscribeAudio(ctx context.Context, audioData []byte, format string) (string, error) {
61-
msg := llm.Message{
62-
Role: "user",
63-
Parts: []llm.ContentPart{
64-
{Type: "text", Text: "Transcribe this voice message exactly as spoken. Return only the transcription, no commentary."},
65-
{Type: "input_audio", InputAudio: &llm.InputAudio{Data: encodeBase64(audioData), Format: format}},
66-
},
61+
func (a *Agent) TranscribeAudio(ctx context.Context, audioData []byte, mimeType string) (string, error) {
62+
if a.transcribe == nil {
63+
return "", fmt.Errorf("transcription not configured")
6764
}
68-
resp, err := a.router.Chat(ctx, []llm.Message{msg}, "", nil)
65+
text, err := transcribeViaGemini(ctx, *a.transcribe, audioData, mimeType)
6966
if err != nil {
70-
return "", fmt.Errorf("transcribe: %w", err)
67+
return "", err
7168
}
72-
return strings.TrimSpace(resp.Content), nil
69+
return strings.TrimSpace(text), nil
70+
}
71+
72+
// EnableTranscription configures audio transcription via native Gemini API.
73+
func (a *Agent) EnableTranscription(cfg TranscribeConfig) {
74+
a.transcribe = &cfg
7375
}
7476

7577
// EnableWebSearch activates the Ollama web search tool.

internal/agent/transcribe.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package agent
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/base64"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"time"
12+
)
13+
14+
const (
15+
geminiGenerateURL = "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent"
16+
transcribePrompt = "Transcribe this voice message exactly as spoken. Return only the transcription, no commentary."
17+
transcribeTimeout_ = 30 * time.Second
18+
)
19+
20+
var transcribeHTTPClient = &http.Client{Timeout: transcribeTimeout_}
21+
22+
// TranscribeConfig holds the Gemini model and API key used for audio transcription.
23+
type TranscribeConfig struct {
24+
Model string // e.g. "gemini-2.0-flash"
25+
APIKey string
26+
}
27+
28+
// transcribeViaGemini calls the native Gemini generateContent API with inline audio data.
29+
// This bypasses the OpenAI-compat layer which does not reliably support input_audio.
30+
func transcribeViaGemini(ctx context.Context, cfg TranscribeConfig, audioData []byte, mimeType string) (string, error) {
31+
reqBody := map[string]any{
32+
"contents": []map[string]any{
33+
{
34+
"parts": []map[string]any{
35+
{"text": transcribePrompt},
36+
{
37+
"inline_data": map[string]any{
38+
"mime_type": mimeType,
39+
"data": base64.StdEncoding.EncodeToString(audioData),
40+
},
41+
},
42+
},
43+
},
44+
},
45+
}
46+
47+
body, err := json.Marshal(reqBody)
48+
if err != nil {
49+
return "", fmt.Errorf("transcribe: marshal: %w", err)
50+
}
51+
52+
url := fmt.Sprintf(geminiGenerateURL, cfg.Model)
53+
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
54+
if err != nil {
55+
return "", fmt.Errorf("transcribe: request: %w", err)
56+
}
57+
req.Header.Set("Content-Type", "application/json")
58+
req.Header.Set("x-goog-api-key", cfg.APIKey)
59+
60+
resp, err := transcribeHTTPClient.Do(req)
61+
if err != nil {
62+
return "", fmt.Errorf("transcribe: %w", err)
63+
}
64+
defer resp.Body.Close()
65+
66+
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
67+
if err != nil {
68+
return "", fmt.Errorf("transcribe: read: %w", err)
69+
}
70+
if resp.StatusCode != 200 {
71+
return "", fmt.Errorf("transcribe: HTTP %d: %s", resp.StatusCode, string(respBody))
72+
}
73+
74+
var result struct {
75+
Candidates []struct {
76+
Content struct {
77+
Parts []struct {
78+
Text string `json:"text"`
79+
} `json:"parts"`
80+
} `json:"content"`
81+
} `json:"candidates"`
82+
}
83+
if err := json.Unmarshal(respBody, &result); err != nil {
84+
return "", fmt.Errorf("transcribe: parse: %w", err)
85+
}
86+
if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
87+
return "", fmt.Errorf("transcribe: empty response")
88+
}
89+
return result.Candidates[0].Content.Parts[0].Text, nil
90+
}

internal/telegram/handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ func (h *Handler) transcribeVoice(chatID int64, msg *tgbotapi.Message) string {
452452
ctx, cancel := context.WithTimeout(context.Background(), transcribeTimeout)
453453
defer cancel()
454454

455-
text, err := h.agent.TranscribeAudio(ctx, data, "ogg")
455+
text, err := h.agent.TranscribeAudio(ctx, data, "audio/ogg")
456456
if err != nil {
457457
h.logger.Error("transcription failed", "chat_id", chatID, "err", err)
458458
return ""

0 commit comments

Comments
 (0)