From 437e27e835165d7595a02f3a90a9ef59a3ae1a57 Mon Sep 17 00:00:00 2001 From: phyce Date: Mon, 6 Jul 2026 10:44:51 +0000 Subject: [PATCH 1/2] Harden TTS engine HTTP clients - Add request timeouts to ElevenLabs and OpenAI HTTP clients (a hung endpoint no longer blocks a pooled instance forever) - Send the Gemini API key via the x-goog-api-key header instead of a URL query param (avoids leaking the key into logs/proxies/errors) - Reuse a single Google TextToSpeech client instead of creating one per request Co-Authored-By: Claude Opus 4.8 (1M context) --- app/tts/engine/elevenlabs/api.go | 10 ++----- app/tts/engine/elevenlabs/elevenlabs.go | 12 +++++--- app/tts/engine/gemini/api.go | 5 +++- app/tts/engine/google/api.go | 21 +++---------- app/tts/engine/google/google.go | 39 +++++++++++++++++++++++++ app/tts/engine/openai/api.go | 12 +++++--- 6 files changed, 65 insertions(+), 34 deletions(-) diff --git a/app/tts/engine/elevenlabs/api.go b/app/tts/engine/elevenlabs/api.go index fbb971d..07a826b 100644 --- a/app/tts/engine/elevenlabs/api.go +++ b/app/tts/engine/elevenlabs/api.go @@ -20,16 +20,13 @@ func FetchModels() (map[string]engine.Model, error) { modelsMap := make(map[string]engine.Model) - client := &http.Client{} - defer client.CloseIdleConnections() - request, err := http.NewRequest("GET", "https://api.elevenlabs.io/v1/models", nil) if err != nil { return modelsMap, response.Err(err) } request.Header.Set("xi-api-key", apiKey) - httpResponse, err := client.Do(request) + httpResponse, err := httpClient.Do(request) if err != nil { response.Error(util.MessageData{ Summary: "Failed to fetch elevenlabs models", @@ -73,9 +70,6 @@ func FetchVoices() ([]engine.Voice, error) { return make([]engine.Voice, 0), response.Err(fmt.Errorf("api key is empty")) } - client := &http.Client{} - defer client.CloseIdleConnections() - request, err := http.NewRequest("GET", "https://api.elevenlabs.io/v1/voices", nil) if err != nil { return make([]engine.Voice, 0), response.Err(fmt.Errorf("creating request failed: %w", err)) @@ -83,7 +77,7 @@ func FetchVoices() ([]engine.Voice, error) { request.Header.Set("xi-api-key", apiKey) - httpResponse, err := client.Do(request) + httpResponse, err := httpClient.Do(request) if err != nil { return make([]engine.Voice, 0), response.Err(fmt.Errorf("performing request failed: %w", err)) } diff --git a/app/tts/engine/elevenlabs/elevenlabs.go b/app/tts/engine/elevenlabs/elevenlabs.go index 76bca41..e3f73d8 100644 --- a/app/tts/engine/elevenlabs/elevenlabs.go +++ b/app/tts/engine/elevenlabs/elevenlabs.go @@ -12,8 +12,13 @@ import ( "nstudio/app/common/util/fileIndex" "nstudio/app/config" "nstudio/app/tts/engine" + "time" ) +// requestTimeout bounds each HTTP call so a hung endpoint cannot block a +// pooled engine instance forever. +const requestTimeout = 30 * time.Second + type ElevenLabs struct { Models map[string]Model outputType string @@ -21,6 +26,8 @@ type ElevenLabs struct { var voices = make([]engine.Voice, 0) +var httpClient = &http.Client{Timeout: requestTimeout} + // func (labs *ElevenLabs) Initialize() error { var err error @@ -201,10 +208,7 @@ func (labs *ElevenLabs) sendRequest(voiceID string, data ElevenLabsRequest) ([]b httpRequest.Header.Set("xi-api-key", apiKey) httpRequest.Header.Set("Content-Type", "application/json") - client := &http.Client{} - defer client.CloseIdleConnections() - - httpResponse, err := client.Do(httpRequest) + httpResponse, err := httpClient.Do(httpRequest) if err != nil { return nil, response.Err(fmt.Errorf("failed to send HTTP request: %v", err)) } diff --git a/app/tts/engine/gemini/api.go b/app/tts/engine/gemini/api.go index 76e637a..48a708b 100644 --- a/app/tts/engine/gemini/api.go +++ b/app/tts/engine/gemini/api.go @@ -23,13 +23,16 @@ func (gemini *Gemini) sendRequest(request GeminiRequest, modelName string) ([]by return nil, response.Err(err) } - url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", modelName, apiKey) + url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent", modelName) httpRequest, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { return nil, response.Err(err) } + // Send the API key via the documented header instead of a URL query + // param so the secret does not leak into logs, proxies, or error bodies. + httpRequest.Header.Set("x-goog-api-key", apiKey) httpRequest.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} diff --git a/app/tts/engine/google/api.go b/app/tts/engine/google/api.go index 294151c..ed02a52 100644 --- a/app/tts/engine/google/api.go +++ b/app/tts/engine/google/api.go @@ -5,27 +5,19 @@ import ( "fmt" "nstudio/app/common/response" "nstudio/app/common/util" - "nstudio/app/config" "nstudio/app/tts/engine" "strings" - texttospeech "cloud.google.com/go/texttospeech/apiv1" "cloud.google.com/go/texttospeech/apiv1/texttospeechpb" - "google.golang.org/api/option" ) func (google *Google) sendRequest(data GoogleRequest) ([]byte, error) { ctx := context.Background() - apiKey := config.GetEngine().Api.Google.ApiKey - if apiKey == "" { - return nil, response.Err(fmt.Errorf("Google Cloud API key is not set")) - } - client, err := texttospeech.NewClient(ctx, option.WithAPIKey(apiKey)) + client, err := google.getClient(ctx) if err != nil { - return nil, response.Err(fmt.Errorf("Failed to create Google TTS client: %v", err)) + return nil, err } - defer client.Close() audioEncoding := texttospeechpb.AudioEncoding_MP3 if data.AudioConfig.AudioEncoding == "LINEAR16" { @@ -103,16 +95,11 @@ func (google *Google) fetchVoices(model string) ([]engine.Voice, error) { } ctx := context.Background() - apiKey := config.GetEngine().Api.Google.ApiKey - if apiKey == "" { - return nil, response.Err(fmt.Errorf("Google Cloud API key is not set")) - } - client, err := texttospeech.NewClient(ctx, option.WithAPIKey(apiKey)) + client, err := google.getClient(ctx) if err != nil { - return nil, response.Err(fmt.Errorf("Failed to create Google TTS client: %v", err)) + return nil, err } - defer client.Close() request := &texttospeechpb.ListVoicesRequest{} dataResponse, err := client.ListVoices(ctx, request) diff --git a/app/tts/engine/google/google.go b/app/tts/engine/google/google.go index d889ecd..3e7a8ba 100644 --- a/app/tts/engine/google/google.go +++ b/app/tts/engine/google/google.go @@ -1,6 +1,7 @@ package google import ( + "context" "encoding/json" "fmt" "nstudio/app/common/audio" @@ -11,12 +12,42 @@ import ( "nstudio/app/tts/engine" "strings" "sync" + + texttospeech "cloud.google.com/go/texttospeech/apiv1" + "google.golang.org/api/option" ) type Google struct { Models map[string]Model voiceCache map[string][]engine.Voice mu sync.RWMutex + + clientMu sync.Mutex + client *texttospeech.Client +} + +// getClient lazily creates and caches a single TextToSpeech client, reusing +// it across requests so each call does not repeat auth and connection setup. +func (google *Google) getClient(ctx context.Context) (*texttospeech.Client, error) { + google.clientMu.Lock() + defer google.clientMu.Unlock() + + if google.client != nil { + return google.client, nil + } + + apiKey := config.GetEngine().Api.Google.ApiKey + if apiKey == "" { + return nil, response.Err(fmt.Errorf("Google Cloud API key is not set")) + } + + client, err := texttospeech.NewClient(ctx, option.WithAPIKey(apiKey)) + if err != nil { + return nil, response.Err(fmt.Errorf("Failed to create Google TTS client: %v", err)) + } + + google.client = client + return client, nil } func (google *Google) Initialize() error { @@ -32,6 +63,14 @@ func (google *Google) Start(modelName string) error { } func (google *Google) Stop(modelName string) error { + google.clientMu.Lock() + defer google.clientMu.Unlock() + if google.client != nil { + if err := google.client.Close(); err != nil { + return response.Err(err) + } + google.client = nil + } return nil } diff --git a/app/tts/engine/openai/api.go b/app/tts/engine/openai/api.go index 6851147..6adbc8c 100644 --- a/app/tts/engine/openai/api.go +++ b/app/tts/engine/openai/api.go @@ -9,8 +9,15 @@ import ( "nstudio/app/common/response" "nstudio/app/common/util" "nstudio/app/config" + "time" ) +// requestTimeout bounds each HTTP call so a hung endpoint cannot block a +// pooled engine instance forever. +const requestTimeout = 30 * time.Second + +var httpClient = &http.Client{Timeout: requestTimeout} + func (openAI *OpenAI) sendRequest(data OpenAIRequest) ([]byte, error) { apiKey := config.GetEngine().Api.OpenAI.ApiKey if apiKey == "" { @@ -30,10 +37,7 @@ func (openAI *OpenAI) sendRequest(data OpenAIRequest) ([]byte, error) { httpRequest.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) httpRequest.Header.Set("Content-Type", "application/json") - client := &http.Client{} - defer client.CloseIdleConnections() - - httpResponse, err := client.Do(httpRequest) + httpResponse, err := httpClient.Do(httpRequest) if err != nil { return nil, response.Err(fmt.Errorf("Failed to send HTTP httpRequest: %v", err)) } From 77c56a7ae015248976673da748d9306b844ada3c Mon Sep 17 00:00:00 2001 From: phyce Date: Mon, 6 Jul 2026 11:56:07 +0000 Subject: [PATCH 2/2] Deduplicate TTS engine HTTP request scaffolding ElevenLabs, OpenAI and Gemini each hand-rolled the same marshal / new-request / set-headers / do / close / status-check / read-body sequence. Extract it into a shared httpapi.Do helper (with the timeout-bounded client) and route the four sendRequest/Fetch functions through it. Also drops leftover debug response.Success noise and a dead return in ElevenLabs.Play. Google is unchanged (it uses the gRPC SDK, not raw HTTP). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/tts/engine/elevenlabs/api.go | 186 ++++++++------------- app/tts/engine/elevenlabs/elevenlabs.go | 55 +----- app/tts/engine/gemini/api.go | 125 ++++++-------- app/tts/engine/internal/httpapi/httpapi.go | 74 ++++++++ app/tts/engine/openai/api.go | 87 +++------- 5 files changed, 237 insertions(+), 290 deletions(-) create mode 100644 app/tts/engine/internal/httpapi/httpapi.go diff --git a/app/tts/engine/elevenlabs/api.go b/app/tts/engine/elevenlabs/api.go index 07a826b..c3b8c14 100644 --- a/app/tts/engine/elevenlabs/api.go +++ b/app/tts/engine/elevenlabs/api.go @@ -1,112 +1,74 @@ -package elevenlabs - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "nstudio/app/common/response" - "nstudio/app/common/util" - "nstudio/app/config" - "nstudio/app/tts/engine" -) - -func FetchModels() (map[string]engine.Model, error) { - apiKey := config.GetEngine().Api.ElevenLabs.ApiKey - if apiKey == "" { - return make(map[string]engine.Model, 0), response.Err(fmt.Errorf("Api key is empty")) - } - - modelsMap := make(map[string]engine.Model) - - request, err := http.NewRequest("GET", "https://api.elevenlabs.io/v1/models", nil) - if err != nil { - return modelsMap, response.Err(err) - } - request.Header.Set("xi-api-key", apiKey) - - httpResponse, err := httpClient.Do(request) - if err != nil { - response.Error(util.MessageData{ - Summary: "Failed to fetch elevenlabs models", - Detail: err.Error(), - }) - return modelsMap, response.Err(err) - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(httpResponse.Body) - return make(map[string]engine.Model, 0), response.Err(errors.New(string(bodyBytes))) - } - - bodyBytes, err := io.ReadAll(httpResponse.Body) - if err != nil { - return make(map[string]engine.Model, 0), response.Err(err) - } - - var modelsResponse []ModelResponse - err = json.Unmarshal(bodyBytes, &modelsResponse) - if err != nil { - return make(map[string]engine.Model, 0), response.Err(err) - } - - for _, m := range modelsResponse { - model := engine.Model{ - ID: m.ModelID, - Name: m.Name, - Engine: "elevenlabs", - } - modelsMap[m.ModelID] = model - } - - return modelsMap, nil -} - -func FetchVoices() ([]engine.Voice, error) { - apiKey := config.GetEngine().Api.ElevenLabs.ApiKey - if apiKey == "" { - return make([]engine.Voice, 0), response.Err(fmt.Errorf("api key is empty")) - } - - request, err := http.NewRequest("GET", "https://api.elevenlabs.io/v1/voices", nil) - if err != nil { - return make([]engine.Voice, 0), response.Err(fmt.Errorf("creating request failed: %w", err)) - } - - request.Header.Set("xi-api-key", apiKey) - - httpResponse, err := httpClient.Do(request) - if err != nil { - return make([]engine.Voice, 0), response.Err(fmt.Errorf("performing request failed: %w", err)) - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(httpResponse.Body) - return make([]engine.Voice, 0), response.Err(fmt.Errorf("unexpected status code: %d, response: %s", httpResponse.StatusCode, string(bodyBytes))) - } - - bodyBytes, err := io.ReadAll(httpResponse.Body) - if err != nil { - return make([]engine.Voice, 0), response.Err(fmt.Errorf("reading response body failed: %w", err)) - } - - var voicesResp VoicesResponse - err = json.Unmarshal(bodyBytes, &voicesResp) - if err != nil { - return make([]engine.Voice, 0), response.Err(fmt.Errorf("parsing JSON failed: %w", err)) - } - - responseVoices := make([]engine.Voice, 0, len(voicesResp.Voices)) - for _, vd := range voicesResp.Voices { - voice := engine.Voice{ - ID: vd.VoiceID, - Name: vd.Name, - Gender: vd.Labels.Gender, - } - responseVoices = append(responseVoices, voice) - } - return responseVoices, nil -} +package elevenlabs + +import ( + "encoding/json" + "fmt" + "nstudio/app/common/response" + "nstudio/app/config" + "nstudio/app/tts/engine" + "nstudio/app/tts/engine/internal/httpapi" +) + +func FetchModels() (map[string]engine.Model, error) { + apiKey := config.GetEngine().Api.ElevenLabs.ApiKey + if apiKey == "" { + return make(map[string]engine.Model, 0), response.Err(fmt.Errorf("Api key is empty")) + } + + body, err := httpapi.Do(httpapi.Request{ + Method: "GET", + URL: "https://api.elevenlabs.io/v1/models", + Headers: map[string]string{"xi-api-key": apiKey}, + }) + if err != nil { + return make(map[string]engine.Model, 0), err + } + + var modelsResponse []ModelResponse + if err := json.Unmarshal(body, &modelsResponse); err != nil { + return make(map[string]engine.Model, 0), response.Err(err) + } + + modelsMap := make(map[string]engine.Model) + for _, m := range modelsResponse { + modelsMap[m.ModelID] = engine.Model{ + ID: m.ModelID, + Name: m.Name, + Engine: "elevenlabs", + } + } + + return modelsMap, nil +} + +func FetchVoices() ([]engine.Voice, error) { + apiKey := config.GetEngine().Api.ElevenLabs.ApiKey + if apiKey == "" { + return make([]engine.Voice, 0), response.Err(fmt.Errorf("api key is empty")) + } + + body, err := httpapi.Do(httpapi.Request{ + Method: "GET", + URL: "https://api.elevenlabs.io/v1/voices", + Headers: map[string]string{"xi-api-key": apiKey}, + }) + if err != nil { + return make([]engine.Voice, 0), err + } + + var voicesResp VoicesResponse + if err := json.Unmarshal(body, &voicesResp); err != nil { + return make([]engine.Voice, 0), response.Err(fmt.Errorf("parsing JSON failed: %w", err)) + } + + responseVoices := make([]engine.Voice, 0, len(voicesResp.Voices)) + for _, vd := range voicesResp.Voices { + responseVoices = append(responseVoices, engine.Voice{ + ID: vd.VoiceID, + Name: vd.Name, + Gender: vd.Labels.Gender, + }) + } + + return responseVoices, nil +} diff --git a/app/tts/engine/elevenlabs/elevenlabs.go b/app/tts/engine/elevenlabs/elevenlabs.go index e3f73d8..f64b91d 100644 --- a/app/tts/engine/elevenlabs/elevenlabs.go +++ b/app/tts/engine/elevenlabs/elevenlabs.go @@ -1,24 +1,17 @@ package elevenlabs import ( - "bytes" "encoding/json" "fmt" - "io" - "net/http" commonAudio "nstudio/app/common/audio" "nstudio/app/common/response" "nstudio/app/common/util" "nstudio/app/common/util/fileIndex" "nstudio/app/config" "nstudio/app/tts/engine" - "time" + "nstudio/app/tts/engine/internal/httpapi" ) -// requestTimeout bounds each HTTP call so a hung endpoint cannot block a -// pooled engine instance forever. -const requestTimeout = 30 * time.Second - type ElevenLabs struct { Models map[string]Model outputType string @@ -26,8 +19,6 @@ type ElevenLabs struct { var voices = make([]engine.Voice, 0) -var httpClient = &http.Client{Timeout: requestTimeout} - // func (labs *ElevenLabs) Initialize() error { var err error @@ -79,8 +70,6 @@ func (labs *ElevenLabs) Play(message util.CharacterMessage) error { return response.Success(util.MessageData{ Summary: "ElevenLabs finished playing audio", }) - - return nil } func (labs *ElevenLabs) Save(messages []util.CharacterMessage, play bool) error { @@ -193,43 +182,17 @@ func (labs *ElevenLabs) sendRequest(voiceID string, data ElevenLabsRequest) ([]b return nil, response.Err(fmt.Errorf("Elevenlabs API Key is not set")) } - jsonData, err := json.Marshal(data) - if err != nil { - return nil, response.Err(fmt.Errorf("failed to marshal request body: %v", err)) - } - url := fmt.Sprintf("https://api.elevenlabs.io/v1/text-to-speech/%s?output_format=%s", voiceID, labs.outputType) - httpRequest, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, response.Err(fmt.Errorf("failed to create HTTP request: %v", err)) - } - - httpRequest.Header.Set("xi-api-key", apiKey) - httpRequest.Header.Set("Content-Type", "application/json") - - httpResponse, err := httpClient.Do(httpRequest) - if err != nil { - return nil, response.Err(fmt.Errorf("failed to send HTTP request: %v", err)) - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(httpResponse.Body) - return nil, response.Err(fmt.Errorf("request failed with status %d: %s", httpResponse.StatusCode, string(bodyBytes))) - } - - responseData, err := io.ReadAll(httpResponse.Body) - if err != nil { - return nil, response.Err(fmt.Errorf("failed to read response body: %v", err)) - } - - response.Success(util.MessageData{ - Summary: "ElevenLabs request succeeded", - Detail: "Response Status: " + httpResponse.Status, + return httpapi.Do(httpapi.Request{ + Method: "POST", + URL: url, + Headers: map[string]string{ + "xi-api-key": apiKey, + "Content-Type": "application/json", + }, + Body: data, }) - - return responseData, nil } // diff --git a/app/tts/engine/gemini/api.go b/app/tts/engine/gemini/api.go index 48a708b..1083890 100644 --- a/app/tts/engine/gemini/api.go +++ b/app/tts/engine/gemini/api.go @@ -1,70 +1,55 @@ -package gemini - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "nstudio/app/common/response" - "nstudio/app/config" - "time" -) - -func (gemini *Gemini) sendRequest(request GeminiRequest, modelName string) ([]byte, error) { - apiKey := config.GetEngine().Api.Gemini.ApiKey - if apiKey == "" { - return nil, fmt.Errorf("Gemini API key is not configured") - } - - jsonData, err := json.Marshal(request) - if err != nil { - return nil, response.Err(err) - } - - url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent", modelName) - - httpRequest, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, response.Err(err) - } - - // Send the API key via the documented header instead of a URL query - // param so the secret does not leak into logs, proxies, or error bodies. - httpRequest.Header.Set("x-goog-api-key", apiKey) - httpRequest.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 30 * time.Second} - httpResponse, err := client.Do(httpRequest) - if err != nil { - return nil, response.Err(err) - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(httpResponse.Body) - return nil, fmt.Errorf("Gemini API error: %s - %s", httpResponse.Status, string(bodyBytes)) - } - - var geminiResponse GeminiResponse - if err := json.NewDecoder(httpResponse.Body).Decode(&geminiResponse); err != nil { - return nil, response.Err(err) - } - - if len(geminiResponse.Candidates) == 0 || len(geminiResponse.Candidates[0].Content.Parts) == 0 { - return nil, fmt.Errorf("no content in Gemini response") - } - - base64Data := geminiResponse.Candidates[0].Content.Parts[0].InlineData.Data - if base64Data == "" { - return nil, fmt.Errorf("no audio data in Gemini response") - } - - audioData, err := base64.StdEncoding.DecodeString(base64Data) - if err != nil { - return nil, response.Err(err) - } - - return audioData, nil -} +package gemini + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "nstudio/app/common/response" + "nstudio/app/config" + "nstudio/app/tts/engine/internal/httpapi" +) + +func (gemini *Gemini) sendRequest(request GeminiRequest, modelName string) ([]byte, error) { + apiKey := config.GetEngine().Api.Gemini.ApiKey + if apiKey == "" { + return nil, fmt.Errorf("Gemini API key is not configured") + } + + url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent", modelName) + + // Send the API key via the documented header instead of a URL query param + // so the secret does not leak into logs, proxies, or error bodies. + body, err := httpapi.Do(httpapi.Request{ + Method: "POST", + URL: url, + Headers: map[string]string{ + "x-goog-api-key": apiKey, + "Content-Type": "application/json", + }, + Body: request, + }) + if err != nil { + return nil, err + } + + var geminiResponse GeminiResponse + if err := json.Unmarshal(body, &geminiResponse); err != nil { + return nil, response.Err(err) + } + + if len(geminiResponse.Candidates) == 0 || len(geminiResponse.Candidates[0].Content.Parts) == 0 { + return nil, fmt.Errorf("no content in Gemini response") + } + + base64Data := geminiResponse.Candidates[0].Content.Parts[0].InlineData.Data + if base64Data == "" { + return nil, fmt.Errorf("no audio data in Gemini response") + } + + audioData, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return nil, response.Err(err) + } + + return audioData, nil +} diff --git a/app/tts/engine/internal/httpapi/httpapi.go b/app/tts/engine/internal/httpapi/httpapi.go new file mode 100644 index 0000000..f4c7d4a --- /dev/null +++ b/app/tts/engine/internal/httpapi/httpapi.go @@ -0,0 +1,74 @@ +// Package httpapi centralizes the JSON-over-HTTP request scaffolding shared by +// the API-backed TTS engines (ElevenLabs, OpenAI, Gemini). Each engine used to +// hand-roll the same marshal/new-request/set-headers/do/close/status-check/read +// sequence; Do collapses that into one place with a shared, timeout-bounded +// client. +package httpapi + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "nstudio/app/common/response" +) + +// DefaultTimeout bounds each HTTP call so a hung endpoint cannot block a pooled +// engine instance forever. +const DefaultTimeout = 30 * time.Second + +var client = &http.Client{Timeout: DefaultTimeout} + +// Request describes a single HTTP request with an optional JSON body. +type Request struct { + Method string + URL string + Headers map[string]string + // Body, when non-nil, is JSON-encoded and sent as the request body. The + // caller is responsible for setting the Content-Type header. + Body any +} + +// Do performs req and returns the response body on a 2xx status. On a non-2xx +// status it returns an error containing the status code and response body. All +// returned errors are already routed through response.Err (logged), so callers +// can return them directly without re-wrapping. +func Do(req Request) ([]byte, error) { + var bodyReader io.Reader + if req.Body != nil { + encoded, err := json.Marshal(req.Body) + if err != nil { + return nil, response.Err(fmt.Errorf("failed to marshal request body: %w", err)) + } + bodyReader = bytes.NewReader(encoded) + } + + httpRequest, err := http.NewRequest(req.Method, req.URL, bodyReader) + if err != nil { + return nil, response.Err(fmt.Errorf("failed to create HTTP request: %w", err)) + } + + for key, value := range req.Headers { + httpRequest.Header.Set(key, value) + } + + httpResponse, err := client.Do(httpRequest) + if err != nil { + return nil, response.Err(fmt.Errorf("failed to send HTTP request: %w", err)) + } + defer httpResponse.Body.Close() + + body, err := io.ReadAll(httpResponse.Body) + if err != nil { + return nil, response.Err(fmt.Errorf("failed to read response body: %w", err)) + } + + if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 { + return nil, response.Err(fmt.Errorf("request failed with status %d: %s", httpResponse.StatusCode, string(body))) + } + + return body, nil +} diff --git a/app/tts/engine/openai/api.go b/app/tts/engine/openai/api.go index 6adbc8c..854304a 100644 --- a/app/tts/engine/openai/api.go +++ b/app/tts/engine/openai/api.go @@ -1,62 +1,25 @@ -package openai - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "nstudio/app/common/response" - "nstudio/app/common/util" - "nstudio/app/config" - "time" -) - -// requestTimeout bounds each HTTP call so a hung endpoint cannot block a -// pooled engine instance forever. -const requestTimeout = 30 * time.Second - -var httpClient = &http.Client{Timeout: requestTimeout} - -func (openAI *OpenAI) sendRequest(data OpenAIRequest) ([]byte, error) { - apiKey := config.GetEngine().Api.OpenAI.ApiKey - if apiKey == "" { - return nil, response.Err(fmt.Errorf("OpenAI API key is not set")) - } - - jsonData, err := json.Marshal(data) - if err != nil { - return nil, response.Err(fmt.Errorf("Failed to marshal httpRequest body: %v", err)) - } - - httpRequest, err := http.NewRequest("POST", "https://api.openai.com/v1/audio/speech", bytes.NewBuffer(jsonData)) - if err != nil { - return nil, response.Err(fmt.Errorf("Failed to create HTTP httpRequest: %v", err)) - } - - httpRequest.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) - httpRequest.Header.Set("Content-Type", "application/json") - - httpResponse, err := httpClient.Do(httpRequest) - if err != nil { - return nil, response.Err(fmt.Errorf("Failed to send HTTP httpRequest: %v", err)) - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(httpResponse.Body) - return nil, response.Err(fmt.Errorf("HttpRequest failed with status %d: %s", httpResponse.StatusCode, string(bodyBytes))) - } - - responseData, err := io.ReadAll(httpResponse.Body) - if err != nil { - return nil, response.Err(fmt.Errorf("Failed to read response body: %v", err)) - } - - response.Success(util.MessageData{ - Summary: "Request succeeded?", - Detail: "Response Status: " + httpResponse.Status, - }) - - return responseData, nil -} +package openai + +import ( + "fmt" + "nstudio/app/common/response" + "nstudio/app/config" + "nstudio/app/tts/engine/internal/httpapi" +) + +func (openAI *OpenAI) sendRequest(data OpenAIRequest) ([]byte, error) { + apiKey := config.GetEngine().Api.OpenAI.ApiKey + if apiKey == "" { + return nil, response.Err(fmt.Errorf("OpenAI API key is not set")) + } + + return httpapi.Do(httpapi.Request{ + Method: "POST", + URL: "https://api.openai.com/v1/audio/speech", + Headers: map[string]string{ + "Authorization": "Bearer " + apiKey, + "Content-Type": "application/json", + }, + Body: data, + }) +}