model / multimodal / tts / embedding 里所有「把 net/http 的 *url.Error 直接打进 错误串」的地方(M51 清单的 B1 组 17 处 + 清单外的同根因 2 处)统一改走 trace.URLCause:错误串只少一段地址,底层原因、哨兵分类、状态码、重试/降级判据 一字不动。 清单内(17): - model/openai.go: Chat / ChatStream 的 NewRequest、doRequest - model/anthropic.go: chatOnce / streamOnce 的 NewRequest、doRequest - multimodal/vision/adapter.go: ask 的 NewRequest、client.Do - multimodal/voice/cloud.go: postJSON 的 NewRequest、client.Do - multimodal/voice/dashscope.go: download(预签名 OSS 地址)与 dashscopePost 各两处 - voice/tts/client.go: unreachable(返回值与同一行日志的 err 字段都只留原因, 地址只由 redactURL(endpoint) 出现一次) - embedding/api.go: embedChunk 的 client.Do、newEmbeddingRequest 的 NewRequest 清单外同根因(2,均为 %w 形态,B1 清单按 %v 搜时漏掉): - voice/tts/client.go: speak 的 NewRequest 失败 - embedding/endpoint.go: normalizeEndpointURL 的 url.Parse 失败 测试:5 个新文件 18 个用例,逐处可变异打红(实测:把任一处的 URLCause 去掉 ⇒ 对应用例红);dashscope 预签名地址单独一条用例,并另有一条钉住 403 状态码仍在。
467 lines
17 KiB
Go
467 lines
17 KiB
Go
package model
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// Anthropic Messages(/v1/messages)线协议常量。
|
||
const (
|
||
// anthropicAPIVersion 是每次请求都要发送的 Messages API 版本。
|
||
anthropicAPIVersion = "2023-06-01"
|
||
// anthropicDefaultMaxTokens 是 Request.MaxTokens 为零时发送的默认
|
||
// max_tokens。Anthropic 要求 max_tokens 必须存在且为正,
|
||
// 因此这里给出合理默认值而不是省略它。
|
||
// max_tokens 由思考与正文共享,必须给两者都留出空间。
|
||
anthropicDefaultMaxTokens = 8192
|
||
// anthropicDataPrefix 标记以 data: URI 内嵌的负载。
|
||
anthropicDataPrefix = "data:"
|
||
// anthropicThinkingTypeEnabled 是「开启扩展思考」指令的
|
||
// 线取值。
|
||
anthropicThinkingTypeEnabled = "enabled"
|
||
)
|
||
|
||
// anthropicThinking 是扩展思考指令的线格式。调用方未要求思考
|
||
// (或要求关闭)时整段省略,因为 Anthropic 把字段缺失视为「不思考」。
|
||
// DeepSeek 的 /anthropic 端点恰好相反、默认开启,所以显式关闭会带着
|
||
// 不带预算的 {"type":"disabled"} 发出去
|
||
// (design/04 §3.2 事实 2)。
|
||
type anthropicThinking struct {
|
||
Type string `json:"type"`
|
||
BudgetTokens int `json:"budget_tokens,omitempty"`
|
||
}
|
||
|
||
// anthropicOutputConfig 是 Anthropic 格式的推理强度块。只有
|
||
// DeepSeek 的 /anthropic 端点记录了它(且只有 effort 字段),所以
|
||
// 它永远不会发给 Anthropic 端点(design/04 §3.2 事实 6)。
|
||
type anthropicOutputConfig struct {
|
||
Effort string `json:"effort"`
|
||
}
|
||
|
||
// anthropicRequest 是发给 /v1/messages 的请求体。Messages 采用 Anthropic
|
||
// 的内容块模型,system 提示词位于请求根。
|
||
type anthropicRequest struct {
|
||
Model string `json:"model"`
|
||
System string `json:"system,omitempty"`
|
||
Messages []anthropicMessage `json:"messages"`
|
||
Tools []anthropicTool `json:"tools,omitempty"`
|
||
ToolChoice any `json:"tool_choice,omitempty"`
|
||
Temperature *float64 `json:"temperature,omitempty"`
|
||
MaxTokens int `json:"max_tokens"`
|
||
Thinking *anthropicThinking `json:"thinking,omitempty"`
|
||
OutputConfig *anthropicOutputConfig `json:"output_config,omitempty"`
|
||
Stream bool `json:"stream,omitempty"`
|
||
}
|
||
|
||
// anthropicMessage 是一个会话回合。Content 是内容块数组
|
||
// (目前为文本 / 图片;tool_result 块由 M2 补上)。
|
||
type anthropicMessage struct {
|
||
Role string `json:"role"`
|
||
Content any `json:"content"`
|
||
}
|
||
|
||
// anthropicTool 是工具 schema 的线格式。
|
||
type anthropicTool struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description,omitempty"`
|
||
InputSchema any `json:"input_schema"`
|
||
}
|
||
|
||
// anthropicResponse 是 /v1/messages 返回的 JSON 响应体。
|
||
type anthropicResponse struct {
|
||
ID string `json:"id"`
|
||
Model string `json:"model"`
|
||
Content []anthropicBlock `json:"content"`
|
||
StopReason string `json:"stop_reason,omitempty"`
|
||
Usage *anthropicUsage `json:"usage,omitempty"`
|
||
}
|
||
|
||
// anthropicBlock 是响应里的单个内容块。对 tool_use 块而言,
|
||
// Input 字段是已完全解码的对象(绝不是 JSON 字符串)。
|
||
type anthropicBlock struct {
|
||
Type string `json:"type"`
|
||
Text string `json:"text,omitempty"`
|
||
ID string `json:"id,omitempty"`
|
||
Name string `json:"name,omitempty"`
|
||
Input map[string]any `json:"input,omitempty"`
|
||
}
|
||
|
||
// anthropicUsage 上报 token 消耗。Anthropic 上报的是 input_tokens
|
||
// 与 output_tokens,而不是单一总量;提示缓存另有两个字段
|
||
// (cache_creation_input_tokens = 本次写入缓存的输入 token,
|
||
// cache_read_input_tokens = 本次从缓存读到的输入 token)。它们全都容忍缺失:
|
||
// 未启用缓存的端点只会给前两个。
|
||
type anthropicUsage struct {
|
||
InputTokens int `json:"input_tokens"`
|
||
OutputTokens int `json:"output_tokens"`
|
||
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||
}
|
||
|
||
// applyAnthropicUsage 把一个 usage 对象折进 Response(只填用量字段,
|
||
// input_tokens 与 output_tokens 的既有口径不变)。缓存两档的对应关系:
|
||
// 命中 = cache_read_input_tokens;未命中 = input_tokens + cache_creation_input_tokens
|
||
// —— Anthropic 的 input_tokens **不含**缓存读到的部分,若只取它,命中率会被算成 0。
|
||
func applyAnthropicUsage(out *Response, u *anthropicUsage) {
|
||
if u == nil {
|
||
return
|
||
}
|
||
out.PromptTokens = u.InputTokens
|
||
out.CompletionTokens = u.OutputTokens
|
||
out.CacheHitTokens = u.CacheReadInputTokens
|
||
out.CacheMissTokens = u.InputTokens + u.CacheCreationInputTokens
|
||
}
|
||
|
||
// AnthropicChatAdapter 调用 Anthropic Messages(/v1/messages)端点。
|
||
// 它由 config.LLMConfig 构建,同时实现非流式与 token 级 SSE 流式的
|
||
// Adapter,直接说提供商的原生内容块协议,
|
||
// 因此 tool_use.input 以结构化对象到达。
|
||
type AnthropicChatAdapter struct {
|
||
name string
|
||
baseURL string
|
||
apiKey string
|
||
model string
|
||
client *http.Client
|
||
recorder Recorder
|
||
// usageSink 是可选的用量落库出口(切片 U1)。为 nil 时只记结构化日志,
|
||
// 行为逐字等于引入落库之前。
|
||
usageSink UsageSink
|
||
// log 上报推理调整与被忽略的指令。它永不为 nil:
|
||
// 调用方未提供时,会装入一个带有本适配器来源标记的丢弃式日志器,
|
||
// 因此提供商的每个决定都可追溯。
|
||
log *slog.Logger
|
||
}
|
||
|
||
// AnthropicOption 用于配置 AnthropicChatAdapter。
|
||
type AnthropicOption func(*AnthropicChatAdapter)
|
||
|
||
// WithAnthropicHTTPClient 覆盖默认 HTTP 客户端(用于在测试与生产
|
||
// 中注入 mock 或调优过的 transport)。
|
||
func WithAnthropicHTTPClient(c *http.Client) AnthropicOption {
|
||
return func(a *AnthropicChatAdapter) { a.client = c }
|
||
}
|
||
|
||
// WithAnthropicRecorder 装入按调用计账的 Recorder,用于 CallRecord 统计。
|
||
func WithAnthropicRecorder(r Recorder) AnthropicOption {
|
||
return func(a *AnthropicChatAdapter) { a.recorder = r }
|
||
}
|
||
|
||
// WithAnthropicUsageSink 装入用量落库出口(切片 U1)。nil 会被忽略,从而保留
|
||
// 「只记日志」的默认行为;实现方必须非阻塞(见 UsageSink)。
|
||
func WithAnthropicUsageSink(s UsageSink) AnthropicOption {
|
||
return func(a *AnthropicChatAdapter) { a.usageSink = s }
|
||
}
|
||
|
||
// WithAnthropicModel 覆盖适配器的默认模型名。
|
||
func WithAnthropicModel(m string) AnthropicOption {
|
||
return func(a *AnthropicChatAdapter) { a.model = m }
|
||
}
|
||
|
||
// WithAnthropicLogger 挂上用于上报推理调整的日志器
|
||
// (为容纳 max_tokens 而抬高的预算、被忽略的指令)。nil 日志器会被忽略,
|
||
// 从而保留丢弃式的默认值。
|
||
func WithAnthropicLogger(l *slog.Logger) AnthropicOption {
|
||
return func(a *AnthropicChatAdapter) {
|
||
if l != nil {
|
||
a.log = l
|
||
}
|
||
}
|
||
}
|
||
|
||
// NewAnthropicChat 由供应商配置构建 Anthropic 适配器。baseURL 会先
|
||
// 去掉末尾斜杠,再拼接 /v1/messages 路径。
|
||
func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicChatAdapter {
|
||
a := &AnthropicChatAdapter{
|
||
name: "anthropic",
|
||
baseURL: strings.TrimRight(cfg.AnthropicBaseURL, "/"),
|
||
apiKey: cfg.AnthropicAPIKey,
|
||
model: cfg.AnthropicModel,
|
||
client: &http.Client{
|
||
Transport: http.DefaultTransport,
|
||
Timeout: defaultHTTPTimeout,
|
||
},
|
||
log: discardLogger("runtime/model/anthropic"),
|
||
}
|
||
if kind := strings.TrimSpace(cfg.ReasoningKind); kind != "" && kind != runtimeconfig.ReasoningKindAuto {
|
||
// anthropic 协议自带思考形状,因此这里声明 openai
|
||
// 形状是无效的。要上报它,而不是任由一个已配置的选项
|
||
// 静默失效(裁决 03.4.2:禁止静默无效)。
|
||
a.log.Warn("reasoning.kind ignored: the anthropic protocol defines its own thinking shape",
|
||
"provider", ProviderAnthropic, "field", "reasoning.kind", "kind", kind)
|
||
}
|
||
for _, o := range opts {
|
||
o(a)
|
||
}
|
||
return a
|
||
}
|
||
|
||
// Name 返回适配器标识。
|
||
func (a *AnthropicChatAdapter) Name() string { return a.name }
|
||
|
||
// Chat 执行一次非流式补全。被 token 预算截断且没有任何可见输出
|
||
// (stop_reason 为 max_tokens,且既无文本也无 tool_use 块)
|
||
// 的响应,会用翻倍的预算重试一次;
|
||
// 若仍无输出,则替换为非空占位文本,
|
||
// 让调用方永远不会看到静默的空回复。
|
||
func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
|
||
start := time.Now()
|
||
if a.baseURL == "" {
|
||
return Response{}, ErrUpstream
|
||
}
|
||
ctx, cancel := withCallTimeout(ctx, opts)
|
||
defer cancel()
|
||
model, err := a.resolveModel(req)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
req.Model = model
|
||
out, err := callWithTruncationRetry(req.MaxTokens, anthropicDefaultMaxTokens,
|
||
func(limit int) (Response, error) {
|
||
attempt := req
|
||
attempt.MaxTokens = limit
|
||
return a.chatOnce(ctx, attempt)
|
||
})
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
a.record(ctx, req, start, out)
|
||
return out, nil
|
||
}
|
||
|
||
// chatOnce 执行一次非流式 /v1/messages 调用,并把线格式
|
||
// 响应体归一化为 Response。
|
||
func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Response, error) {
|
||
body, err := a.buildRequest(req, false)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
|
||
if err != nil {
|
||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
|
||
}
|
||
resp, err := a.doRequest(httpReq, false)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return Response{}, a.upstreamError(resp)
|
||
}
|
||
var wire anthropicResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
|
||
return Response{}, fmt.Errorf("model: %w: decode response: %v", ErrUpstream, err)
|
||
}
|
||
return responseFromAnthropicWire(&wire), nil
|
||
}
|
||
|
||
// ChatStream 通过 onEvent 流出结构化事件,并在提供商上报时
|
||
// 返回带用量的聚合响应。文本块以 text_start/text_delta/text_end
|
||
// 事件投递;tool_use 块以
|
||
// tool_call_start/tool_call_delta/tool_call_end 投递,
|
||
// 前端因此可以独立渲染各片段。被预算截断的空流会用翻倍预算重试一次
|
||
// (重试产生的思考被抑制,以免出现重复块),
|
||
// 若仍为空,则用一段非空占位文本关闭。
|
||
func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
|
||
start := time.Now()
|
||
if a.baseURL == "" {
|
||
return Response{}, ErrUpstream
|
||
}
|
||
model, err := a.resolveModel(req)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
req.Model = model
|
||
out, err := callStreamWithTruncationRetry(req.MaxTokens, anthropicDefaultMaxTokens,
|
||
func(limit int, ev func(StreamEvent)) (Response, error) {
|
||
attempt := req
|
||
attempt.MaxTokens = limit
|
||
return a.streamOnce(ctx, attempt, ev)
|
||
}, onEvent)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
out.Model = model
|
||
a.record(ctx, req, start, out)
|
||
return out, nil
|
||
}
|
||
|
||
// streamOnce 执行一次流式 /v1/messages 调用,并把其 SSE
|
||
// 事件聚合为 Response。
|
||
func (a *AnthropicChatAdapter) streamOnce(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
|
||
body, err := a.buildRequest(req, true)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
|
||
if err != nil {
|
||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
|
||
}
|
||
resp, err := a.doRequest(httpReq, true)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return Response{}, a.upstreamError(resp)
|
||
}
|
||
return scanAnthropicSSE(upstreamLabel(a.name, a.baseURL), resp.Body, onEvent)
|
||
}
|
||
|
||
// resolveModel 返回实际生效的模型名。
|
||
func (a *AnthropicChatAdapter) resolveModel(req Request) (string, error) {
|
||
if req.Model != "" {
|
||
return req.Model, nil
|
||
}
|
||
if a.model != "" {
|
||
return a.model, nil
|
||
}
|
||
return "", ErrNoModel
|
||
}
|
||
|
||
// endpoint 返回完整的 /v1/messages URL。
|
||
func (a *AnthropicChatAdapter) endpoint() string {
|
||
return a.baseURL + "/v1/messages"
|
||
}
|
||
|
||
func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, error) {
|
||
system, msgs, err := buildAnthropicMessages(req.Messages)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
tokenLimit := effectiveMaxTokens(req.MaxTokens, anthropicDefaultMaxTokens)
|
||
var thinking *anthropicThinking
|
||
var output *anthropicOutputConfig
|
||
if deepSeekAnthropicEndpoint(req.Model, a.baseURL) {
|
||
// DeepSeek 的 /anthropic 端点忽略 budget_tokens,因此这里刻意
|
||
// 不抬高 max_tokens:抬高了就会错误地反映提供商实际采用的预算。
|
||
// 强度改走 output_config.effort
|
||
// (design/04 §3.2 事实 6)。
|
||
thinking, output = anthropicDeepSeekReasoning(a.log, req.Model, req.Thinking, req.ReasoningEffort)
|
||
reportForcedToolChoiceWithThinking(a.log, string(vendorDeepSeek), req.Model, req.ToolChoice, anthropicThinkingOn(thinking))
|
||
} else {
|
||
// 扩展思考与可见答案共享 max_tokens,所以在 Anthropic 端点上,
|
||
// 预算是与它所需要的限额一起解出的
|
||
// (design/04 §3.2 红线)。
|
||
tokenLimit, thinking = anthropicThinkingFor(req.Thinking, tokenLimit, a.log)
|
||
a.reportAnthropicEffortIgnored(req)
|
||
}
|
||
wire := anthropicRequest{
|
||
Model: req.Model,
|
||
System: system,
|
||
Messages: msgs,
|
||
Tools: buildAnthropicTools(req.Tools),
|
||
ToolChoice: req.ToolChoice,
|
||
Temperature: req.Temperature,
|
||
MaxTokens: tokenLimit,
|
||
Thinking: thinking,
|
||
OutputConfig: output,
|
||
Stream: stream,
|
||
}
|
||
buf, err := json.Marshal(wire)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("model: %w: marshal request: %v", ErrBadRequest, err)
|
||
}
|
||
return buf, nil
|
||
}
|
||
|
||
// reportAnthropicEffortIgnored 上报落在没有对应字段的端点上的
|
||
// reasoning_effort:Anthropic Messages 协议用 budget_tokens
|
||
// 承载思考强度。它只上报——凭空编一个预算会改变调用方的
|
||
// 意图。
|
||
func (a *AnthropicChatAdapter) reportAnthropicEffortIgnored(req Request) {
|
||
if strings.TrimSpace(req.ReasoningEffort) == "" {
|
||
return
|
||
}
|
||
a.log.Warn("reasoning_effort ignored: the Anthropic Messages protocol takes thinking.budget_tokens, not an effort tier",
|
||
"provider", "anthropic", "field", "reasoning_effort", "value", req.ReasoningEffort, "model", req.Model)
|
||
}
|
||
|
||
func (a *AnthropicChatAdapter) doRequest(httpReq *http.Request, stream bool) (*http.Response, error) {
|
||
if a.apiKey != "" {
|
||
httpReq.Header.Set("x-api-key", a.apiKey)
|
||
}
|
||
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
if stream {
|
||
httpReq.Header.Set("Accept", "text/event-stream")
|
||
} else {
|
||
httpReq.Header.Set("Accept", "application/json")
|
||
}
|
||
resp, err := a.client.Do(httpReq)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, trace.URLCause(err))
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
// upstreamError 把一次非 2xx 响应交给包内唯一的上游错误收口(见 upstream.go):
|
||
// 只留 status / code / type 与体积摘要,响应体正文与 error.message 不出镜。
|
||
func (a *AnthropicChatAdapter) upstreamError(resp *http.Response) error {
|
||
return providerUpstreamError(a.name, a.baseURL, resp)
|
||
}
|
||
|
||
// record 上报一次成功调用的用量:先记一条结构化用量日志(含缓存命中率,
|
||
// 所有调用点都覆盖 —— 见 usage.go),再喂给可选的 CallRecord 计账出口。
|
||
// ctx 用来取本次调用所属的会话(loop 播种,见 session_ctx.go)。
|
||
func (a *AnthropicChatAdapter) record(ctx context.Context, req Request, start time.Time, resp Response) {
|
||
logUsage(ctx, a.log, a.usageSink, a.name, req, resp, time.Since(start))
|
||
if a.recorder == nil {
|
||
return
|
||
}
|
||
a.recorder(CallRecord{
|
||
Model: req.Model,
|
||
Purpose: req.Purpose,
|
||
Tokens: resp.TotalTokens,
|
||
TookMs: time.Since(start).Milliseconds(),
|
||
})
|
||
}
|
||
|
||
// responseFromAnthropicWire 把非流式 /v1/messages 响应转换为
|
||
// 归一化的 Response。每个文本块汇入助手内容;
|
||
// 每个 tool_use 块变成一个 ToolCall,其 Args 直接取自已解码的
|
||
// input 对象。
|
||
func responseFromAnthropicWire(wire *anthropicResponse) Response {
|
||
out := Response{Model: wire.Model}
|
||
var b strings.Builder
|
||
var toolCalls []ToolCall
|
||
for _, blk := range wire.Content {
|
||
switch blk.Type {
|
||
case "text":
|
||
b.WriteString(blk.Text)
|
||
case "tool_use":
|
||
toolCalls = append(toolCalls, ToolCall{Name: blk.Name, ID: blk.ID, Args: blk.Input})
|
||
}
|
||
}
|
||
out.Content = b.String()
|
||
out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: out.Content}}}
|
||
out.ToolCalls = toolCalls
|
||
out.StopReason = wire.StopReason
|
||
applyAnthropicUsage(&out, wire.Usage)
|
||
if wire.Usage != nil {
|
||
out.TotalTokens = wire.Usage.InputTokens + wire.Usage.OutputTokens
|
||
}
|
||
return out
|
||
}
|
||
|
||
// AnthropicToolResult 构建把工具结果回喂给 Anthropic 的内容块
|
||
// (role: user,第一个内容元素)。toolUseID 必须等于此前某个
|
||
// tool_use 块的 id;isError 标记执行失败。
|
||
func AnthropicToolResult(toolUseID, content string, isError bool) map[string]any {
|
||
return map[string]any{
|
||
"type": "tool_result",
|
||
"tool_use_id": toolUseID,
|
||
"content": content,
|
||
"is_error": isError,
|
||
}
|
||
}
|