mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 00:15:05 +00:00
* feat(audio): add openai_compat TTS/STT provider for self-hosted endpoints
Adds a provider pair speaking the OpenAI audio wire format against any
compatible endpoint (gpu-manager, Speaches, vLLM, LocalAI, llama.cpp,
Ollama), so a self-hosted engine no longer needs a bespoke HTTP shim
implementing goclaw's proprietary /transcribe_audio contract.
Kept distinct from the openai package on purpose. Both speak the same
wire format, but "openai" carries api.openai.com semantics, and
audio.IsVoiceCompatible applies OpenAI's voice allowlist to any provider
by that name — FilterVoiceForProvider then silently substitutes "alloy"
for anything outside it. A self-hosted voice ID such as Piper's
"fr_FR-gilles-low" would be replaced with no error and no log line, and
the caller would hear the wrong language. Providers without validation
rules pass through untouched, which is the correct behaviour for an
engine whose voice namespace is its own.
Other deliberate departures from the openai package:
- api_base is required. There is no public endpoint to fall back to, and
defaulting to api.openai.com would send self-hosted traffic to a vendor.
- An empty api_key omits the Authorization header rather than sending an
empty Bearer token; self-hosted engines commonly have no auth.
- Empty voice/model are omitted from the request instead of sent blank,
since some engines resolve the model from the requested voice.
- The multipart filename is never blank: several engines type the audio
by extension rather than Content-Type and reject what they cannot type.
Endpoint error bodies are preserved in the returned error so a format
mismatch (e.g. an engine that cannot encode mp3) is diagnosable.
* feat(audio): wire openai_compat TTS/STT provider into gateway config
Adds tts.openai_compat config block and registers both providers at
startup. api_base is the enable switch rather than an API key, since
self-hosted endpoints commonly have no auth and there is no vendor
default to fall back to.
The STT chain is now built from the providers that actually registered
instead of being hardcoded to {elevenlabs, proxy}. Transcribe skips
unregistered names with a warning on every call, so a static chain
naming absent providers is log noise on the hot path. openai_compat goes
first when present: it is an explicit operator choice and keeps audio on
the local network. "proxy" stays last, as BridgeLegacySTT registers it
later per channel.
Behaviour is unchanged when tts.openai_compat.api_base is unset: the
chain resolves to {elevenlabs, proxy} exactly as before.
---------
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
75 lines
3.1 KiB
Go
75 lines
3.1 KiB
Go
// Package openaicompat implements the OpenAI audio API (speech + transcriptions)
|
|
// against any OpenAI-compatible endpoint — gpu-manager, Speaches, vLLM, LocalAI,
|
|
// llama.cpp, Ollama.
|
|
//
|
|
// It is deliberately distinct from the openai package. Both speak the same wire
|
|
// format, but "openai" means api.openai.com semantics: a fixed voice set
|
|
// (alloy, echo, ...), an mp3 default, and a base URL that falls back to the
|
|
// public API. A self-hosted engine shares none of that — its voices are its own
|
|
// (e.g. Piper's "fr_FR-gilles-low"), its formats are whatever it built in, and
|
|
// there is no sane default host.
|
|
//
|
|
// Keeping them separate matters beyond tidiness: audio.IsVoiceCompatible applies
|
|
// OpenAI's voice allowlist to any provider named "openai" and silently
|
|
// substitutes the default voice for anything outside it. Routing a self-hosted
|
|
// engine through that name turns a valid local voice ID into "alloy" with no
|
|
// error. Providers without validation rules pass through untouched, which is the
|
|
// correct behaviour here.
|
|
package openaicompat
|
|
|
|
import "errors"
|
|
|
|
// ErrAPIBaseRequired is returned by the constructors when APIBase is empty.
|
|
// Unlike the openai package there is no public endpoint to fall back to, and
|
|
// defaulting to api.openai.com would send self-hosted traffic to a vendor.
|
|
var ErrAPIBaseRequired = errors.New("openai_compat: api_base is required")
|
|
|
|
// providerName is the stable identifier reported to the audio Manager and used
|
|
// in chain configuration. It must not be "openai" — see the package doc.
|
|
const providerName = "openai_compat"
|
|
|
|
// Default tuning. Formats intentionally follow the OpenAI wire defaults; an
|
|
// endpoint that cannot encode mp3 is configured with Format: "wav".
|
|
const (
|
|
defaultTTSFormat = "mp3"
|
|
defaultTimeoutMs = 30000
|
|
)
|
|
|
|
// Config bundles the endpoint, credentials and defaults for both the TTS and
|
|
// STT providers. APIBase is required; everything else has a default or is
|
|
// resolved per-request.
|
|
type Config struct {
|
|
// APIBase is the OpenAI-compatible root, including any version prefix —
|
|
// e.g. "http://gpu-manager:8080/v1". Required.
|
|
APIBase string
|
|
// APIKey is sent as a Bearer token when non-empty. Self-hosted endpoints
|
|
// frequently have no auth, so an empty key omits the header entirely
|
|
// rather than sending "Bearer ".
|
|
APIKey string
|
|
// TTSModel is the default model for synthesis. Optional: some engines
|
|
// resolve the model from the requested voice instead.
|
|
TTSModel string
|
|
// TTSVoice is the default voice for synthesis. Optional.
|
|
TTSVoice string
|
|
// TTSFormat is the default response_format for synthesis. Defaults to
|
|
// "mp3" to match OpenAI. Set to "wav" or "pcm" for engines without an
|
|
// encoder.
|
|
TTSFormat string
|
|
// STTModel is the default model for transcription. Optional.
|
|
STTModel string
|
|
// TimeoutMs is the per-request timeout. Defaults to 30000.
|
|
TimeoutMs int
|
|
}
|
|
|
|
// withDefaults returns a copy with zero-valued optional fields filled in.
|
|
// APIBase is not defaulted — its absence is an error, not a fallback.
|
|
func (c Config) withDefaults() Config {
|
|
if c.TTSFormat == "" {
|
|
c.TTSFormat = defaultTTSFormat
|
|
}
|
|
if c.TimeoutMs <= 0 {
|
|
c.TimeoutMs = defaultTimeoutMs
|
|
}
|
|
return c
|
|
}
|