fix(runtime): 上游错误体统一走 trace.UpstreamError,正文不进 error

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 里搜不到哨兵正文」用例,且能变异打红
This commit is contained in:
AskaEth committed 2026-10-26 13:44:32 +08:00
1 parent 46a6da13e0
commit 66dda084cd
16 files changed
+570 -183

No files matched your search

@@ -11,6 +11,7 @@ import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// visionMaxTokens 限制 vision/OCR 响应的长度。
@@ -170,8 +171,14 @@ func (a *OpenAICompatibleAdapter) ask(ctx context.Context, model string, img Ima
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxFetchedBody))
return "", fmt.Errorf("vision: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
// 错误路径**不再整段读**:这里原来沿用取图的 maxFetchedBody(50 MiB),
// 等于「最多 50 MiB 的错误体进内存、再进 error」。错误体是攻击面,因此
// 只读 maxErrorBodyBytes,并且一个字节都不进 error —— 只留 status /
// contentType / bodyLen / sha256 与 code/type(trace.UpstreamError)。
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
upstream := trace.UpstreamError(trace.HostOf(a.cfg.BaseURL), resp.StatusCode,
resp.Header.Get("Content-Type"), body)
return "", fmt.Errorf("vision: %w: %w", ErrUpstream, upstream)
}
return a.decode(resp.Body)
}
@@ -3,9 +3,11 @@ package vision
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
@@ -51,3 +53,62 @@ func TestOpenAICompatibleAdapter_NotConfigured(t *testing.T) {
t.Fatal("want error for empty base url, got nil")
}
}
// stubTransport 让测试不经网络直接给出响应。
type stubTransport func(*http.Request) (*http.Response, error)
func (f stubTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// countingBody 记录被读走的字节数,用来断言错误路径**有界读**。
type countingBody struct {
r io.Reader
n *int64
}
func (b *countingBody) Read(p []byte) (int, error) {
n, err := b.r.Read(p)
*b.n += int64(n)
return n, err
}
func (b *countingBody) Close() error { return nil }
// TestOpenAICompatibleAdapter_ErrorBodyKeptOutOfError 覆盖 M41 的视觉一处:
// 旧实现沿用取图上限(50 MiB)读错误体,再把它拼进 error —— 既是日志面
// (整个 50 MiB 的错误体都能进去),也是 DoS 面(一次超大错误响应就吃掉内存)。
//
// 两个断言各能单独打红:① 正文出现在 error 里 ⇒ 又回到「拼原文」;
// ② 错误路径读走超过 maxErrorBodyBytes ⇒ 又回到按取图上限整段读。
// 测试体用 8 MiB(远大于 4 KiB、远小于 50 MiB):足以证伪无界读,又不必在
// 测试里分配 50 MiB。
func TestOpenAICompatibleAdapter_ErrorBodyKeptOutOfError(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-VISION-ERROR-BODY-7d2a"
var read int64
payload := sentinel + strings.Repeat("x", 8<<20)
ad := newAdapter("https://vision.example.com")
ad.client = &http.Client{Transport: stubTransport(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: &countingBody{r: strings.NewReader(payload), n: &read},
}, nil
})}
_, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "")
if err == nil {
t.Fatal("want error, got nil")
}
if !errors.Is(err, ErrUpstream) {
t.Fatalf("err = %v, want ErrUpstream", err)
}
if strings.Contains(err.Error(), sentinel) {
t.Errorf("error text carries the response body: %v", err)
}
if read > maxErrorBodyBytes {
t.Errorf("error path read %d bytes of the error body, want <= %d", read, maxErrorBodyBytes)
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("err = %v, want the status kept for triage", err)
}
}
@@ -18,6 +18,11 @@ import (
const (
maxImageBytes = 50 << 20
maxFetchedBody = 50 << 20
// maxErrorBodyBytes 是非 2xx 响应的读取上限。它与上面两个是**不同口径**:
// 那两个是数据面(图像字节)的预算,这一条是攻击面的预算 —— 错误体只用来
// 数长度 / 取 sha256 前缀 / 抽 error.code,因此 4 KiB 足够,绝不沿用 50 MiB
// (那会把「一个超大错误响应」变成一次内存分配与一次日志面暴露)。
maxErrorBodyBytes = 4 << 10
)
// imageFetchTimeout 限制一次图像 URL 取回的时长。适配器与消息解析器会
+6 -1
View File
@@ -20,7 +20,12 @@ import (
// 约束单次调用。
const (
cloudMaxResponseBytes = 8 << 20
cloudHTTPTimeout = 180 * time.Second
// maxErrorBodyBytes 是非 2xx 响应(错误体)的读取上限。它与
// cloudMaxResponseBytes 是不同口径:那个是数据面(音频/JSON 响应)的预算,
// 这一条是攻击面的预算 —— 错误体只用来数长度 / 取 sha256 前缀 / 抽
// code 与 request_id,因此 4 KiB 足够,绝不整包读进来。
maxErrorBodyBytes = 4 << 10
cloudHTTPTimeout = 180 * time.Second
)
// HTTPSTT 通过向云端 endpoint POST 一个 JSON 请求体并
+7 -148
View File
@@ -371,8 +371,9 @@ func firstNonEmpty(a, b string) string {
}
// dashscopePost POST 一个 JSON 载荷,带上 DashScope bearer 鉴权与可选的
// X-DashScope-SSE 头,返回解码后的响应体。非 200
// 状态会被包装上 DashScope 的错误体。
// X-DashScope-SSE 头,返回解码后的响应体。非 200 状态的错误体**不整包读**:
// 错误路径只读 maxErrorBodyBytes,且正文一个字节都不进 error(见
// classifyDashscopeStatus)。
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 {
@@ -390,52 +391,17 @@ func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string,
return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
return nil, classifyDashscopeStatus(url, resp.StatusCode, resp.Header.Get("Content-Type"), errBody)
}
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, classifyDashscopeStatus(resp.StatusCode, body)
}
return body, nil
}
// classifyDashscopeStatus 把一个非 200 的 DashScope 响应翻成**可分类**的失败:鉴权失败 /
// 欠费额度 / 限流各自成一条哨兵链(都以 ErrProviderFailed 为底),其余原样归入
// ErrProviderFailed(切片 IM-ASR 的熔断据此判定「再试是不是白试」)。
//
// 判据只用**状态码 + 错误码/消息里的两类关键字**(阿里云官方口径:`Arrearage`、
// `Throttling.*`、额度上限),绝不匹配自由文本描述——把「格式不支持」误判成「欠费」
// 会让一条被拒的语音关掉整个转写面。
func classifyDashscopeStatus(status int, body []byte) error {
detail := strings.TrimSpace(string(body))
if err := dashscopeClassifiedError(status, detail); err != nil {
return err
}
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrProviderFailed, status, detail)
}
// dashscopeClassifiedError 给出状态码对应的**分类**失败,无法分类时返回 nil。
func dashscopeClassifiedError(status int, detail string) error {
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrAuthFailed, status, detail)
case status == http.StatusTooManyRequests:
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrRateLimited, status, detail)
}
// 欠费/额度耗尽:阿里云用 HTTP 400(少数情况 402)配上 Arrearage /
// AllocatedQuotaExceeded 这类错误码,因此按错误码关键字归类(不按状态码)。
lowered := strings.ToLower(detail)
if strings.Contains(lowered, "arrearage") || strings.Contains(lowered, "quota") ||
strings.Contains(lowered, "throttling") {
if strings.Contains(lowered, "throttling") {
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrRateLimited, status, detail)
}
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrQuotaExceeded, status, detail)
}
return nil
}
// extractSTTText 从 DashScope ASR 响应中取出转写。ASR
// 的输出形状随模型而异,因此按多个候选路径依次尝试。
func extractSTTText(body []byte) (string, error) {
@@ -488,110 +454,3 @@ func mimeOf(format string) string {
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"`
}
@@ -0,0 +1,115 @@
package voice
// 本文件是 DashScope 的「非 2xx 响应 → error」收口,与 M41 的 LLM / embedding /
// vision 三处同口径:走 internal/runtime/trace.UpstreamError(M40 共享 helper),
// 不自己再写一份「截一段响应体拼进 error」。
//
// 这一家的敏感面是 **TTS 的 input = 她要念的那段话**(与 emotionTTS 同型):
// DashScope 的 4xx 错误体里既有 `code` / `request_id`,也有自由文本 `message`,
// 而 message 会回显请求内容。因此正文只留在内存里做分类判据,出镜的只有
// 状态、体积摘要以及两个受控字段(code / request_id)。
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxErrorTokenBytes 是 error.code / request_id 允许出镜的长度上限(见
// dashscopeErrorToken)。
const maxErrorTokenBytes = 64
// classifyDashscopeStatus 把一个非 200 的 DashScope 响应翻成**可分类**的失败:鉴权失败 /
// 欠费额度 / 限流各自成一条哨兵链(都以 ErrProviderFailed 为底),其余原样归入
// ErrProviderFailed(切片 IM-ASR 的熔断据此判定「再试是不是白试」)。
//
// 判据只用**状态码 + 错误码/消息里的两类关键字**(阿里云官方口径:`Arrearage`、
// `Throttling.*`、额度上限),绝不匹配自由文本描述——把「格式不支持」误判成「欠费」
// 会让一条被拒的语音关掉整个转写面。
//
// 🔴 错误文案里**没有正文**:响应体只用来(a)在内存里做上面的关键字分类、
// (b)数长度 / 取 sha256 前缀、(c)解出 code 与 request_id。出镜的只有
// status / contentType / bodyLen / sha256 / code / request_id —— TTS 的请求
// input 就是**她要念的那段话**,把它(或回显它的 message)拼进 error 等于
// 把台词写进日志。
//
// 双 %w:errors.Is 既能命中分类哨兵(熔断判据),也能命中 trace.ErrUpstream。
func classifyDashscopeStatus(endpoint string, status int, contentType string, body []byte) error {
sentinel := dashscopeSentinelFor(status, strings.ToLower(string(body)))
fields := decodeDashscopeErrorFields(body)
info, ok := trace.UpstreamError(trace.HostOf(endpoint), status, contentType, body).(*trace.UpstreamErrorInfo)
if !ok {
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断。
return fmt.Errorf("voice: %w", sentinel)
}
info.Code = dashscopeErrorToken(fields.code)
if rid := dashscopeErrorToken(fields.requestID); rid != "" {
return fmt.Errorf("voice: %w: request_id=%s: %w", sentinel, rid, info)
}
return fmt.Errorf("voice: %w: %w", sentinel, info)
}
// dashscopeSentinelFor 给出状态码/错误码对应的**分类**哨兵,无法分类时返回
// ErrProviderFailed。loweredBody 只用于关键字判定,绝不外泄 —— 分类是「再试
// 是不是白试」的判据,不是一条给人看的描述。
func dashscopeSentinelFor(status int, loweredBody string) error {
switch status {
case http.StatusUnauthorized, http.StatusForbidden:
return ErrAuthFailed
case http.StatusTooManyRequests:
return ErrRateLimited
}
// 欠费/额度耗尽:阿里云用 HTTP 400(少数情况 402)配上 Arrearage /
// AllocatedQuotaExceeded 这类错误码,因此按错误码关键字归类(不按状态码)。
switch {
case strings.Contains(loweredBody, "throttling"):
return ErrRateLimited
case strings.Contains(loweredBody, "arrearage"), strings.Contains(loweredBody, "quota"):
return ErrQuotaExceeded
}
return ErrProviderFailed
}
// dashscopeErrorFields 是 DashScope 非 2xx 响应体里**唯一**允许出镜的两个字段。
// message 不在此列:它是上游自由文本,TTS 场景下可能回显她要念的文本。
type dashscopeErrorFields struct {
code string
requestID string
}
// decodeDashscopeErrorFields 尽力解出 code / request_id。不是 JSON(纯文本或
// 被截断的报文)⇒ 两个字段都是空串,错误里只剩 status/体积/摘要 —— 正文一个
// 字节都不留。
func decodeDashscopeErrorFields(body []byte) dashscopeErrorFields {
var wire struct {
Code string `json:"code"`
RequestID string `json:"request_id"`
}
if err := json.Unmarshal(body, &wire); err != nil {
return dashscopeErrorFields{}
}
return dashscopeErrorFields{code: wire.Code, requestID: wire.RequestID}
}
// dashscopeErrorToken 只放行「像协议令牌」的短串(字母、数字与 `_ . -`):code
// 在 DashScope 是枚举(InvalidParameter / Throttling.RateQuota / Arrearage …),
// request_id 是十六进制/UUID 形状。两者都是自由文本面,不像令牌的一律丢弃 ——
// 宁可少一条分类提示,不让散文借道出镜(同口径见 runtime/model 的 safeErrorToken)。
func dashscopeErrorToken(s string) string {
s = strings.TrimSpace(s)
if s == "" || len(s) > maxErrorTokenBytes {
return ""
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '_' || r == '.' || r == '-':
default:
return ""
}
}
return s
}
@@ -115,6 +115,40 @@ func TestDashscopeSTT_Non200(t *testing.T) {
}
}
// TestDashscopeTTS_Non200DropsSpokenText 覆盖 M41 的 DashScope 一处:TTS 请求的
// input 就是**她要念的那段话**,而 DashScope 的 4xx 错误体会把它(或回显它的
// message)带回来。旧实现把整包响应体(cloudMaxResponseBytes = 8 MiB,无截断)
// 拼进 error,error 又被语音链打日志。
//
// 断言「文本里搜不到台词」,同时保留两类可诊断字段:分类哨兵链与 code /
// request_id(DashScope 报障时确实要看这两个)。
func TestDashscopeTTS_Non200DropsSpokenText(t *testing.T) {
t.Parallel()
const spoken = "SENTINEL-SPOKEN-TEXT-1e4f"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":"InvalidParameter","message":"text: '` + spoken +
`' is invalid","request_id":"req-0123456789abcdef"}`))
}))
defer srv.Close()
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
_, err := tts.TTS(context.Background(), spoken)
if !errors.Is(err, ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed", err)
}
if strings.Contains(err.Error(), spoken) {
t.Errorf("error text echoes the text to be spoken: %v", err)
}
if !strings.Contains(err.Error(), "InvalidParameter") {
t.Errorf("err = %v, want the error code kept for triage", err)
}
if !strings.Contains(err.Error(), "req-0123456789abcdef") {
t.Errorf("err = %v, want the request id kept for provider support", err)
}
}
func TestDashscopeTTS_URL(t *testing.T) {
t.Parallel()
audioBytes := []byte("RIFFxxxxWAVE")
@@ -0,0 +1,111 @@
package voice
// DashScope 的线格式类型(请求体 / 响应 envelope)。它们只描述 JSON 形状,
// 不含任何逻辑,因此与适配器行为分开成文件。
// 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"`
}