- 只翻注释/文档字符串,代码零变化:go/scanner 词法流比对 21 文件 token+字面量全等,增删行数 1:1(467/467) - 刻意保留非散文英文:JSON Schema description、工具 Description() 返回值、adapter prompt、API 路径与命令示例、环境变量名 - 逐行确认代码前缀未变,未触碰其他模块
549 lines
17 KiB
Go
549 lines
17 KiB
Go
package voice
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// 语音链云端一级的 DashScope(阿里百炼)STT/TTS 适配器。请求
|
||
// 遵循 DashScope 公开 API:
|
||
//
|
||
// - STT: POST {base}/services/aigc/multimodal-generation/generation
|
||
// (qwen-audio-3.0-asr-flash,base64 输入音频)
|
||
// - TTS: POST {base}/services/audio/tts/SpeechSynthesizer
|
||
// (cosyvoice-v3-flash,按 voice_id 合成)
|
||
//
|
||
// 两者在这里都取非流式响应:STT 从 output envelope 中返回
|
||
// 转写;TTS(不带 X-DashScope-SSE 头)返回一个 24h 音频 URL,我们
|
||
// 把它下载下来,使调用方总能拿到真实字节。音频格式 ↔ mime
|
||
// 双向映射(mimeOf / STT 请求中的 data: URI)。
|
||
|
||
const (
|
||
// defaultSTTSampleRate 是 ASR 期望的输入音频采样率。
|
||
defaultSTTSampleRate = 16000
|
||
// dashscopeSTTPath 是 multimodal-generation 服务路径(ASR)。
|
||
dashscopeSTTPath = "/services/aigc/multimodal-generation/generation"
|
||
// dashscopeTTSPath 是 SpeechSynthesizer 服务路径(TTS)。
|
||
dashscopeTTSPath = "/services/audio/tts/SpeechSynthesizer"
|
||
// dashscopeClonePath 是音色定制(声音复刻 create_voice)路径。
|
||
dashscopeClonePath = "/services/audio/tts/customization"
|
||
// defaultDashscopeBaseURL 是未配置时使用的百炼 API 根地址。
|
||
defaultDashscopeBaseURL = "https://dashscope.aliyuncs.com/api/v1"
|
||
// defaultClonePrefix 是未提供时的音色名前缀。
|
||
defaultClonePrefix = "cyrene"
|
||
// voiceEnrollmentModel 是 DashScope 音色注册模型("create_voice")。
|
||
voiceEnrollmentModel = "voice-enrollment"
|
||
)
|
||
|
||
// DashscopeSTT 经 DashScope multimodal-generation 转写音频。
|
||
type DashscopeSTT struct {
|
||
endpoint string
|
||
apiKey string
|
||
model string
|
||
format string
|
||
language string
|
||
sampleRate int
|
||
client *http.Client
|
||
}
|
||
|
||
// NewDashscopeSTT 从 cfg 构造 DashScope STT 适配器。缺失的 model /
|
||
// language / base URL 回退到 DashScope 默认值。
|
||
func NewDashscopeSTT(cfg config.VoiceConfig) *DashscopeSTT {
|
||
model := firstNonEmpty(cfg.STTModel, "qwen-audio-3.0-asr-flash")
|
||
language := firstNonEmpty(cfg.STTLanguage, "zh")
|
||
return &DashscopeSTT{
|
||
endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeSTTPath,
|
||
apiKey: cfg.DashscopeAPIKey,
|
||
model: model,
|
||
format: "wav",
|
||
language: language,
|
||
sampleRate: defaultSTTSampleRate,
|
||
client: defaultHTTPClient(),
|
||
}
|
||
}
|
||
|
||
// STT 把音频作为 base64 data: URI 发送并返回识别出的文字。
|
||
func (s *DashscopeSTT) STT(ctx context.Context, audio Audio) (string, error) {
|
||
if s.apiKey == "" {
|
||
return "", ErrNotConfigured
|
||
}
|
||
if len(audio.Data) == 0 {
|
||
return "", fmt.Errorf("voice: %w: empty audio", ErrProviderFailed)
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return "", err
|
||
}
|
||
format := audio.Format
|
||
if format == "" {
|
||
format = "wav"
|
||
}
|
||
payload, err := json.Marshal(dashscopeSTTRequest{
|
||
Model: s.model,
|
||
Input: dashscopeSTTInput{
|
||
Messages: []dashscopeSTTMessage{{
|
||
Role: "user",
|
||
Content: []dashscopeSTTContent{{
|
||
Type: "input_audio",
|
||
InputAudio: dashscopeInputAudio{
|
||
Data: "data:" + mimeOf(format) + ";base64," + base64.StdEncoding.EncodeToString(audio.Data),
|
||
},
|
||
}},
|
||
}},
|
||
},
|
||
Parameters: dashscopeSTTParameters{
|
||
Format: format,
|
||
SampleRate: s.sampleRate,
|
||
LanguageHints: []string{s.language},
|
||
},
|
||
})
|
||
if err != nil {
|
||
return "", fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
|
||
}
|
||
body, err := dashscopePost(ctx, s.client, s.apiKey, s.endpoint, payload, "disable")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
text, err := extractSTTText(body)
|
||
if err != nil {
|
||
return "", fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
|
||
}
|
||
return text, nil
|
||
}
|
||
|
||
// DashscopeTTS 经 DashScope SpeechSynthesizer 合成语音。
|
||
type DashscopeTTS struct {
|
||
endpoint string
|
||
apiKey string
|
||
model string
|
||
voiceID string
|
||
format string
|
||
sampleRate int
|
||
instruction string
|
||
client *http.Client
|
||
}
|
||
|
||
// NewDashscopeTTS 从 cfg 构造 DashScope TTS 适配器。缺失的 model /
|
||
// voice / format / base URL 回退到 DashScope 默认值。
|
||
func NewDashscopeTTS(cfg config.VoiceConfig) *DashscopeTTS {
|
||
model := firstNonEmpty(cfg.TTSModel, "cosyvoice-v3-flash")
|
||
voiceID := firstNonEmpty(cfg.TTSVoiceID, "longxiaochun_v3")
|
||
format := firstNonEmpty(cfg.TTSFormat, "wav")
|
||
sampleRate := cfg.TTSSampleRate
|
||
if sampleRate == 0 {
|
||
sampleRate = 24000
|
||
}
|
||
return &DashscopeTTS{
|
||
endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeTTSPath,
|
||
apiKey: cfg.DashscopeAPIKey,
|
||
model: model,
|
||
voiceID: voiceID,
|
||
format: format,
|
||
sampleRate: sampleRate,
|
||
instruction: cfg.VoiceTTSInstruction,
|
||
client: defaultHTTPClient(),
|
||
}
|
||
}
|
||
|
||
// TTS 为文字合成音频。非流式的 DashScope 响应携带一个
|
||
// 24h 音频 URL(或一段 base64);我们把它解析成字节并返回,
|
||
// 使调用方拿到完整的音频文件。
|
||
func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
||
if t.apiKey == "" {
|
||
return Audio{}, ErrNotConfigured
|
||
}
|
||
if strings.TrimSpace(text) == "" {
|
||
return Audio{}, ErrEmptyText
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return Audio{}, err
|
||
}
|
||
req := dashscopeTTSRequest{
|
||
Model: t.model,
|
||
Input: dashscopeTTSInput{
|
||
Text: text,
|
||
Voice: t.voiceID,
|
||
Format: t.format,
|
||
SampleRate: t.sampleRate,
|
||
},
|
||
}
|
||
if instruction := t.EffectiveInstruction(ctx); instruction != "" {
|
||
req.Input.Instruction = instruction
|
||
}
|
||
payload, err := json.Marshal(req)
|
||
if err != nil {
|
||
return Audio{}, fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
|
||
}
|
||
// 不带 X-DashScope-SSE 头 → 返回非流式 URL 响应。
|
||
body, err := dashscopePost(ctx, t.client, t.apiKey, t.endpoint, payload, "")
|
||
if err != nil {
|
||
return Audio{}, err
|
||
}
|
||
data, err := t.audioBytes(ctx, body)
|
||
if err != nil {
|
||
return Audio{}, err
|
||
}
|
||
return Audio{Format: t.format, Data: data}, nil
|
||
}
|
||
|
||
// EffectiveInstruction 返回本次合成要发送的 instruction:经 ctx 透传的
|
||
// 逐请求覆盖优先,否则用配置的默认值。结果为空表示该请求
|
||
// 不携带 instruction。系统音色永远不会收到自由形式的 instruction
|
||
// (见 canInstruct),因为 cosyvoice v3 系统音色
|
||
// 要求固定的 instruction 格式。
|
||
func (t *DashscopeTTS) EffectiveInstruction(ctx context.Context) string {
|
||
if !canInstruct(t.voiceID) {
|
||
return ""
|
||
}
|
||
if override := TTSInstructionFromContext(ctx); override != "" {
|
||
return override
|
||
}
|
||
return t.instruction
|
||
}
|
||
|
||
// canInstruct 报告 voiceID 是否接受自由形式的 instruction。cosyvoice v3
|
||
// 系统音色(long*_v3 系列,例如 longxiaochun_v3)要求固定的
|
||
// instruction 格式;复刻/设计音色(声音复刻,其 id 永不以
|
||
// "_v3" 结尾)接受任意 instruction。
|
||
func canInstruct(voiceID string) bool {
|
||
return !strings.HasSuffix(voiceID, "_v3")
|
||
}
|
||
|
||
// audioBytes 从 DashScope 响应中解析合成音频:24h
|
||
// URL 会被下载,内联的 base64 块会被解码。
|
||
func (t *DashscopeTTS) audioBytes(ctx context.Context, body []byte) ([]byte, error) {
|
||
var r dashscopeTTSResponse
|
||
if err := json.Unmarshal(body, &r); err != nil {
|
||
return nil, fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
|
||
}
|
||
if url := r.Output.Audio.URL; url != "" {
|
||
data, err := t.download(ctx, url)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return data, nil
|
||
}
|
||
if b64 := r.Output.Audio.Data; b64 != "" {
|
||
data, err := base64.StdEncoding.DecodeString(b64)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("voice: %w: decode audio: %v", ErrProviderFailed, err)
|
||
}
|
||
return data, nil
|
||
}
|
||
return nil, fmt.Errorf("voice: %w: no audio in response", ErrProviderFailed)
|
||
}
|
||
|
||
// download 把音频 URL 抓进内存,并限制体积,使过大的
|
||
// 媒体响应无法耗尽内存。
|
||
func (t *DashscopeTTS) download(ctx context.Context, url string) ([]byte, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err)
|
||
}
|
||
resp, err := t.client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("voice: %w: download: %v", ErrProviderFailed, err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("voice: %w: download status %d", ErrProviderFailed, resp.StatusCode)
|
||
}
|
||
data, err := io.ReadAll(io.LimitReader(resp.Body, cloudMaxResponseBytes))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("voice: %w: download read: %v", ErrProviderFailed, err)
|
||
}
|
||
return data, nil
|
||
}
|
||
|
||
// DashscopeCloner 经 DashScope 音色注册 API
|
||
// (声音复刻 create_voice)创建自定义 TTS 音色。参考音频必须是公开
|
||
// URL —— create_voice 动作只接受 input.url(不支持 base64)。返回的 voice_id
|
||
// 绑定到 TTSModel,因此 target_model 必须与合成时使用的模型一致。
|
||
type DashscopeCloner struct {
|
||
endpoint string
|
||
apiKey string
|
||
model string
|
||
client *http.Client
|
||
}
|
||
|
||
// NewDashscopeCloner 从 cfg 构造 DashScope 音色复刻器。缺失的 model /
|
||
// base URL 回退到 DashScope 默认值;model 跟随 TTSModel,使
|
||
// 复刻出的音色可被 TTS 一级使用(target_model 必须一致)。
|
||
func NewDashscopeCloner(cfg config.VoiceConfig) *DashscopeCloner {
|
||
return &DashscopeCloner{
|
||
endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeClonePath,
|
||
apiKey: cfg.DashscopeAPIKey,
|
||
model: firstNonEmpty(cfg.TTSModel, "cosyvoice-v3-flash"),
|
||
client: defaultHTTPClient(),
|
||
}
|
||
}
|
||
|
||
// CloneVoice 提交一次 create_voice 请求并返回创建出的 voice_id。它
|
||
// 是 REST /api/v1/audio/voice-clone endpoint 背后的适配器。缺少 API
|
||
// key 时返回 ErrNotConfigured;空 URL 或非法 prefix 会被拒绝。
|
||
func (d *DashscopeCloner) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) {
|
||
if d.apiKey == "" {
|
||
return "", ErrNotConfigured
|
||
}
|
||
referenceURL = strings.TrimSpace(referenceURL)
|
||
if referenceURL == "" {
|
||
return "", fmt.Errorf("voice: %w: reference audio url is required", ErrProviderFailed)
|
||
}
|
||
prefix, err := normalizeClonePrefix(prefix)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return "", err
|
||
}
|
||
payload, err := json.Marshal(dashscopeCloneRequest{
|
||
Model: voiceEnrollmentModel,
|
||
Input: dashscopeCloneInput{
|
||
Action: "create_voice",
|
||
TargetModel: d.model,
|
||
Prefix: prefix,
|
||
URL: referenceURL,
|
||
},
|
||
})
|
||
if err != nil {
|
||
return "", fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
|
||
}
|
||
body, err := dashscopePost(ctx, d.client, d.apiKey, d.endpoint, payload, "")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
var resp dashscopeCloneResponse
|
||
if err := json.Unmarshal(body, &resp); err != nil {
|
||
return "", fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
|
||
}
|
||
if resp.Message != "" {
|
||
return "", fmt.Errorf("voice: %w: %s", ErrProviderFailed, resp.Message)
|
||
}
|
||
if resp.Output.VoiceID == "" {
|
||
return "", fmt.Errorf("voice: %w: no voice_id in response", ErrProviderFailed)
|
||
}
|
||
return resp.Output.VoiceID, nil
|
||
}
|
||
|
||
// normalizeClonePrefix 去空白并校验音色名前缀。空值
|
||
// 回退到 defaultClonePrefix;结果必须是 ≤10 个字母/数字字符
|
||
// (音色注册对 prefix 的约束)。
|
||
func normalizeClonePrefix(prefix string) (string, error) {
|
||
prefix = strings.TrimSpace(prefix)
|
||
if prefix == "" {
|
||
return defaultClonePrefix, nil
|
||
}
|
||
if len(prefix) > 10 {
|
||
return "", fmt.Errorf("voice: %w: prefix %q exceeds 10 chars", ErrProviderFailed, prefix)
|
||
}
|
||
for _, r := range prefix {
|
||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
||
return "", fmt.Errorf("voice: %w: prefix must be alphanumeric", ErrProviderFailed)
|
||
}
|
||
}
|
||
return prefix, nil
|
||
}
|
||
|
||
// dashscopeEndpoint 返回去掉尾部斜杠的 DashScope API 根地址,
|
||
// 未配置时默认为公开的百炼根地址。
|
||
func dashscopeEndpoint(baseURL string) string {
|
||
base := strings.TrimRight(baseURL, "/")
|
||
if base == "" {
|
||
return defaultDashscopeBaseURL
|
||
}
|
||
return base
|
||
}
|
||
|
||
// firstNonEmpty 在 a 非空时返回 a,否则返回 b。
|
||
func firstNonEmpty(a, b string) string {
|
||
if a != "" {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// dashscopePost POST 一个 JSON 载荷,带上 DashScope bearer 鉴权与可选的
|
||
// X-DashScope-SSE 头,返回解码后的响应体。非 200
|
||
// 状态会被包装上 DashScope 的错误体。
|
||
func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string, payload []byte, sse string) ([]byte, 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)
|
||
}
|
||
if sse != "" {
|
||
req.Header.Set("X-DashScope-SSE", sse)
|
||
}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, cloudMaxResponseBytes))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("voice: %w: read: %v", ErrProviderFailed, err)
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("voice: %w: dashscope status %d: %s", ErrProviderFailed, resp.StatusCode, strings.TrimSpace(string(body)))
|
||
}
|
||
return body, nil
|
||
}
|
||
|
||
// extractSTTText 从 DashScope ASR 响应中取出转写。ASR
|
||
// 的输出形状随模型而异,因此按多个候选路径依次尝试。
|
||
func extractSTTText(body []byte) (string, error) {
|
||
var r dashscopeSTTResponse
|
||
if err := json.Unmarshal(body, &r); err != nil {
|
||
return "", err
|
||
}
|
||
if s := r.Output.Output.Sentence.Text; s != "" {
|
||
return s, nil
|
||
}
|
||
if s := r.Output.Output.Text; s != "" {
|
||
return s, nil
|
||
}
|
||
if s := r.Output.Text; s != "" {
|
||
return s, nil
|
||
}
|
||
for _, ch := range r.Output.Choices {
|
||
if len(ch.Message.Content) > 0 {
|
||
if s := ch.Message.Content[0].Text; s != "" {
|
||
return s, nil
|
||
}
|
||
}
|
||
}
|
||
return r.Text, nil
|
||
}
|
||
|
||
// mimeOf 把音频格式映射为其 MIME 类型,供 data: URI / Content-Type 使用。
|
||
func mimeOf(format string) string {
|
||
switch strings.ToLower(format) {
|
||
case "mp3", "mpeg":
|
||
return "audio/mpeg"
|
||
case "ogg", "oga":
|
||
return "audio/ogg"
|
||
case "webm":
|
||
return "audio/webm"
|
||
default:
|
||
return "audio/wav"
|
||
}
|
||
}
|
||
|
||
// dashscopeSTTResponse 是 DashScope ASR 响应 envelope。
|
||
type dashscopeSTTResponse struct {
|
||
Output struct {
|
||
Output struct {
|
||
Sentence struct {
|
||
Text string `json:"text"`
|
||
} `json:"sentence"`
|
||
Text string `json:"text"`
|
||
} `json:"output"`
|
||
Text string `json:"text"`
|
||
Choices []struct {
|
||
Message struct {
|
||
Content []struct {
|
||
Text string `json:"text"`
|
||
} `json:"content"`
|
||
} `json:"message"`
|
||
} `json:"choices"`
|
||
} `json:"output"`
|
||
Text string `json:"text"`
|
||
}
|
||
|
||
// dashscopeTTSResponse 是 DashScope TTS 响应 envelope。
|
||
type dashscopeTTSResponse struct {
|
||
Output struct {
|
||
Audio struct {
|
||
URL string `json:"url"`
|
||
Data string `json:"data"`
|
||
} `json:"audio"`
|
||
} `json:"output"`
|
||
}
|
||
|
||
// dashscopeSTTRequest 是 ASR 请求体。
|
||
type dashscopeSTTRequest struct {
|
||
Model string `json:"model"`
|
||
Input dashscopeSTTInput `json:"input"`
|
||
Parameters dashscopeSTTParameters `json:"parameters"`
|
||
}
|
||
|
||
// dashscopeSTTInput 承载多模态消息列表。
|
||
type dashscopeSTTInput struct {
|
||
Messages []dashscopeSTTMessage `json:"messages"`
|
||
}
|
||
|
||
// dashscopeSTTMessage 是一个 ASR 用户回合。
|
||
type dashscopeSTTMessage struct {
|
||
Role string `json:"role"`
|
||
Content []dashscopeSTTContent `json:"content"`
|
||
}
|
||
|
||
// dashscopeSTTContent 是一个音频内容块。
|
||
type dashscopeSTTContent struct {
|
||
Type string `json:"type"`
|
||
InputAudio dashscopeInputAudio `json:"input_audio"`
|
||
}
|
||
|
||
// dashscopeInputAudio 承载 base64 data: URI 音频。
|
||
type dashscopeInputAudio struct {
|
||
Data string `json:"data"`
|
||
}
|
||
|
||
// dashscopeSTTParameters 调整 ASR 请求。
|
||
type dashscopeSTTParameters struct {
|
||
Format string `json:"format"`
|
||
SampleRate int `json:"sample_rate"`
|
||
LanguageHints []string `json:"language_hints,omitempty"`
|
||
}
|
||
|
||
// dashscopeTTSRequest 是 TTS 请求体。
|
||
type dashscopeTTSRequest struct {
|
||
Model string `json:"model"`
|
||
Input dashscopeTTSInput `json:"input"`
|
||
}
|
||
|
||
// dashscopeTTSInput 承载文字 + 音色 + 输出格式。
|
||
type dashscopeTTSInput struct {
|
||
Text string `json:"text"`
|
||
Voice string `json:"voice"`
|
||
Format string `json:"format"`
|
||
SampleRate int `json:"sample_rate"`
|
||
// Instruction 是 CosyVoice 的情绪/语气 instruction;omitempty 使
|
||
// 系统音色(必须使用固定 instruction)不携带自由形式的 instruction。
|
||
Instruction string `json:"instruction,omitempty"`
|
||
}
|
||
|
||
// dashscopeCloneRequest 是音色注册 create_voice 请求体。
|
||
type dashscopeCloneRequest struct {
|
||
Model string `json:"model"`
|
||
Input dashscopeCloneInput `json:"input"`
|
||
}
|
||
|
||
// dashscopeCloneInput 承载 create_voice 动作参数。
|
||
type dashscopeCloneInput struct {
|
||
Action string `json:"action"`
|
||
TargetModel string `json:"target_model"`
|
||
Prefix string `json:"prefix"`
|
||
URL string `json:"url"`
|
||
}
|
||
|
||
// dashscopeCloneResponse 是音色注册响应 envelope。顶层
|
||
// message 表示模型级错误——即使 HTTP 200 也会暴露。
|
||
type dashscopeCloneResponse struct {
|
||
Output struct {
|
||
VoiceID string `json:"voice_id"`
|
||
} `json:"output"`
|
||
Message string `json:"message"`
|
||
}
|