M41 的「LLM / embedding / 视觉 上游错误体」一族(M38 #5 #6 #7 #8 #9 #14): 非 2xx 时把响应体截断后拼进 error,而 error 会被 chat run / loop step / retrieve_fallback 打日志,等于把她的提问、记忆正文、要念的台词写进日志。 - model:openai/anthropic 两处 upstreamError 收口到 upstream.go 的 providerUpstreamError;anthropic 流内 error 事件的 evt.Error.Message (原本无上限)整段丢弃,只保留令牌形状的 type - embedding:readErrorBody 删除,改走 helper(input 就是她的记忆正文) - vision:错误路径不再沿用取图的 50 MiB 上限,改为 4 KiB 有界读且不拼原文 - voice:classifyDashscopeStatus 只解 code/request_id,正文只留在内存里做 分类判据;错误体读取同样改为 4 KiB 有界 - 测试:五个上游各一条「日志/error 里搜不到哨兵正文」用例,且能变异打红
238 lines
7.3 KiB
Go
238 lines
7.3 KiB
Go
package voice
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
)
|
|
|
|
// cloudMaxResponseBytes 限制读取云端语音响应的体积,使过大
|
|
// 或畸形的响应无法耗尽内存。请求超时
|
|
// 约束单次调用。
|
|
const (
|
|
cloudMaxResponseBytes = 8 << 20
|
|
// maxErrorBodyBytes 是非 2xx 响应(错误体)的读取上限。它与
|
|
// cloudMaxResponseBytes 是不同口径:那个是数据面(音频/JSON 响应)的预算,
|
|
// 这一条是攻击面的预算 —— 错误体只用来数长度 / 取 sha256 前缀 / 抽
|
|
// code 与 request_id,因此 4 KiB 足够,绝不整包读进来。
|
|
maxErrorBodyBytes = 4 << 10
|
|
cloudHTTPTimeout = 180 * time.Second
|
|
)
|
|
|
|
// HTTPSTT 通过向云端 endpoint POST 一个 JSON 请求体并
|
|
// 读回 {"text": "..."} 来转写音频。它是桩式的默认 STT 实现;
|
|
// 真正面向 DashScope/whisper 的适配器是一个适配点。
|
|
type HTTPSTT struct {
|
|
endpoint string
|
|
apiKey string
|
|
client *http.Client
|
|
log *slog.Logger
|
|
}
|
|
|
|
// HTTPTTS 通过 POST 一个 JSON 请求体并读取返回的
|
|
// base64 音频来合成语音。它是桩式的默认 TTS 实现。
|
|
type HTTPTTS struct {
|
|
endpoint string
|
|
apiKey string
|
|
client *http.Client
|
|
log *slog.Logger
|
|
}
|
|
|
|
// HTTPOption 定制 HTTPSTT/HTTPTTS 的 client 与日志器。
|
|
type HTTPOption func(*httpConfig)
|
|
|
|
type httpConfig struct {
|
|
client *http.Client
|
|
log *slog.Logger
|
|
}
|
|
|
|
// WithHTTPClient 设置该调用所用的 *http.Client;nil 表示保持默认。
|
|
func WithHTTPClient(c *http.Client) HTTPOption {
|
|
return func(o *httpConfig) {
|
|
if c != nil {
|
|
o.client = c
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithLogger 设置失败时使用的日志器;nil 表示保持丢弃日志器。
|
|
func WithLogger(l *slog.Logger) HTTPOption {
|
|
return func(o *httpConfig) {
|
|
if l != nil {
|
|
o.log = l
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewHTTPSTT 为给定 endpoint 与 API key 构造一个 HTTPSTT。
|
|
func NewHTTPSTT(endpoint, apiKey string, opts ...HTTPOption) *HTTPSTT {
|
|
h := &httpConfig{client: defaultHTTPClient(), log: discardLogger()}
|
|
for _, o := range opts {
|
|
o(h)
|
|
}
|
|
return &HTTPSTT{endpoint: strings.TrimRight(endpoint, "/"), apiKey: apiKey, client: h.client, log: h.log}
|
|
}
|
|
|
|
// NewHTTPTTS 为给定 endpoint 与 API key 构造一个 HTTPTTS。
|
|
func NewHTTPTTS(endpoint, apiKey string, opts ...HTTPOption) *HTTPTTS {
|
|
h := &httpConfig{client: defaultHTTPClient(), log: discardLogger()}
|
|
for _, o := range opts {
|
|
o(h)
|
|
}
|
|
return &HTTPTTS{endpoint: strings.TrimRight(endpoint, "/"), apiKey: apiKey, client: h.client, log: h.log}
|
|
}
|
|
|
|
// sttRequest 是发往 STT endpoint 的 JSON 请求体。
|
|
type sttRequest struct {
|
|
Audio string `json:"audio"`
|
|
Format string `json:"format"`
|
|
}
|
|
|
|
// sttResponse 是 STT endpoint 返回的转写。
|
|
type sttResponse struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// STT 发送音频并返回识别出的文字。endpoint 为空时返回
|
|
// ErrNotConfigured,链随之降级。
|
|
func (s *HTTPSTT) STT(ctx context.Context, audio Audio) (string, error) {
|
|
if s.endpoint == "" {
|
|
return "", ErrNotConfigured
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
payload, err := json.Marshal(sttRequest{Audio: base64.StdEncoding.EncodeToString(audio.Data), Format: audio.Format})
|
|
if err != nil {
|
|
return "", fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
|
|
}
|
|
resp, err := postJSON(ctx, s.client, s.apiKey, s.endpoint, payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
var decoded sttResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
|
return "", fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
|
|
}
|
|
return decoded.Text, nil
|
|
}
|
|
|
|
// ttsRequest 是发往 TTS endpoint 的 JSON 请求体。
|
|
type ttsRequest struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// ttsResponse 是 TTS endpoint 返回的合成音频。
|
|
type ttsResponse struct {
|
|
Audio string `json:"audio"`
|
|
Format string `json:"format"`
|
|
}
|
|
|
|
// TTS 发送文本并返回解码后的音频。endpoint 为空时返回
|
|
// ErrNotConfigured,链随之降级。
|
|
func (s *HTTPTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
|
if s.endpoint == "" {
|
|
return Audio{}, ErrNotConfigured
|
|
}
|
|
if strings.TrimSpace(text) == "" {
|
|
return Audio{}, ErrEmptyText
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return Audio{}, err
|
|
}
|
|
payload, err := json.Marshal(ttsRequest{Text: text})
|
|
if err != nil {
|
|
return Audio{}, fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
|
|
}
|
|
resp, err := postJSON(ctx, s.client, s.apiKey, s.endpoint, payload)
|
|
if err != nil {
|
|
return Audio{}, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
var decoded ttsResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
|
return Audio{}, fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(decoded.Audio)
|
|
if err != nil {
|
|
return Audio{}, fmt.Errorf("voice: %w: decode audio: %v", ErrProviderFailed, err)
|
|
}
|
|
format := decoded.Format
|
|
if format == "" {
|
|
format = "mp3"
|
|
}
|
|
return Audio{Format: format, Data: data}, nil
|
|
}
|
|
|
|
// postJSON POST 一个 JSON 载荷,接好鉴权并检查状态码。
|
|
func postJSON(ctx context.Context, client *http.Client, apiKey, url string, payload []byte) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
_ = resp.Body.Close()
|
|
return nil, fmt.Errorf("voice: %w: status %d", ErrProviderFailed, resp.StatusCode)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// defaultHTTPClient 构造一个带请求超时的 client。这里每次调用
|
|
// 返回新 client,而不是包级全局变量——后者为约束所禁止。
|
|
func defaultHTTPClient() *http.Client {
|
|
return &http.Client{Transport: http.DefaultTransport, Timeout: cloudHTTPTimeout}
|
|
}
|
|
|
|
// discardLogger 返回一个丢弃全部输出的日志器。
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// newDashboardSTT 为 DashScope 配置返回云端 STT 适配器。未设置
|
|
// API key 时调用会返回 ErrNotConfigured,链随之降级。
|
|
func newDashboardSTT(cfg config.VoiceConfig) STT {
|
|
return NewDashscopeSTT(cfg)
|
|
}
|
|
|
|
// newDashboardTTS 为 DashScope 配置返回云端 TTS 适配器。
|
|
func newDashboardTTS(cfg config.VoiceConfig) TTS {
|
|
return NewDashscopeTTS(cfg)
|
|
}
|
|
|
|
// newDashboardCloner 为 DashScope 配置返回云端音色注册 cloner,
|
|
// 供 create_voice(声音复刻)endpoint 使用。
|
|
func newDashboardCloner(cfg config.VoiceConfig) VoiceCloner {
|
|
return NewDashscopeCloner(cfg)
|
|
}
|
|
|
|
// newEdgeTTS 返回 edge-tts 适配点(尚未实现)。
|
|
func newEdgeTTS() TTS { return notImplementedTTS{} }
|
|
|
|
// newEspeak 返回 espeak 适配点(尚未实现)。
|
|
func newEspeak() TTS { return notImplementedTTS{} }
|
|
|
|
// notImplementedTTS 是一个总是降级的适配点。
|
|
type notImplementedTTS struct{}
|
|
|
|
func (notImplementedTTS) TTS(_ context.Context, _ string) (Audio, error) {
|
|
return Audio{}, ErrNotImplemented
|
|
}
|