package clients import ( "bufio" "bytes" "encoding/json" "fmt" "io" "math" "net/http" "sort" "strings" "time" ) func credential(candidate map[string]any, keys ...string) string { for _, key := range keys { if value, ok := candidate[key].(string); ok && strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func boolValue(body map[string]any, key string) bool { value, _ := body[key].(bool) return value } func stringValue(body map[string]any, key string) string { value, _ := body[key].(string) return strings.TrimSpace(value) } func intValue(body map[string]any, key string, fallback int) int { switch value := body[key].(type) { case float64: return int(math.Round(value)) case int: return value default: return fallback } } func decodeHTTPResponse(resp *http.Response) (map[string]any, error) { defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, &ClientError{ Code: statusCodeName(resp.StatusCode), Message: errorMessage(raw, resp.Status), StatusCode: resp.StatusCode, RequestID: requestIDFromHTTPResponse(resp), Retryable: HTTPRetryable(resp.StatusCode), } } var out map[string]any if len(raw) == 0 { return map[string]any{}, nil } if err := json.Unmarshal(raw, &out); err != nil { return nil, &ClientError{Code: "invalid_response", Message: err.Error(), Retryable: false} } return out, nil } func decodeOpenAIStreamResponse(resp *http.Response, onDelta StreamDelta) (map[string]any, error) { defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024)) return nil, &ClientError{ Code: statusCodeName(resp.StatusCode), Message: errorMessage(raw, resp.Status), StatusCode: resp.StatusCode, RequestID: requestIDFromHTTPResponse(resp), Retryable: HTTPRetryable(resp.StatusCode), } } if result, ok, err := decodeOpenAIStreamReader(resp.Body, onDelta); ok || err != nil { return result, err } return map[string]any{}, nil } func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string]any, bool, error) { scanner := bufio.NewScanner(reader) scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) rawLines := make([]string, 0) parts := make([]string, 0) reasoningParts := make([]string, 0) var last map[string]any var usage Usage finishReason := "" toolCalls := map[int]map[string]any{} for scanner.Scan() { rawLine := scanner.Text() rawLines = append(rawLines, rawLine) line := strings.TrimSpace(rawLine) if !strings.HasPrefix(line, "data:") { continue } payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) if payload == "" || payload == "[DONE]" { continue } var event map[string]any if err := json.Unmarshal([]byte(payload), &event); err != nil { continue } event = NormalizeChatCompletionStreamEvent(event) last = event text := streamEventText(event) reasoningText := streamEventReasoningContent(event) if text != "" { parts = append(parts, text) } if reasoningText != "" { reasoningParts = append(reasoningParts, reasoningText) } aggregateStreamToolCalls(event, toolCalls) if reason := streamEventFinishReason(event); reason != "" { finishReason = reason } if onDelta != nil { if err := onDelta(StreamDeltaEvent{Text: text, ReasoningContent: reasoningText, Event: event}); err != nil { return nil, true, err } } if eventUsage := usageFromOpenAI(event); eventUsage.TotalTokens > 0 { usage = eventUsage } } if err := scanner.Err(); err != nil { return nil, true, &ClientError{Code: "stream_read_error", Message: err.Error(), Retryable: true} } if last == nil { raw := []byte(strings.Join(rawLines, "\n")) if len(raw) == 0 { return map[string]any{}, true, nil } var out map[string]any if err := json.Unmarshal(raw, &out); err != nil { return nil, false, nil } return out, true, nil } return buildOpenAIStreamResult(last, parts, reasoningParts, toolCalls, finishReason, usage), true, nil } func decodeOpenAIStream(raw []byte) (map[string]any, bool) { if !bytes.Contains(raw, []byte("data:")) { return nil, false } result, ok, err := decodeOpenAIStreamReader(bytes.NewReader(raw), nil) return result, ok && err == nil } func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts []string, toolCalls map[int]map[string]any, finishReason string, usage Usage) map[string]any { if len(parts) == 0 && len(reasoningParts) == 0 && len(toolCalls) == 0 { return last } message := map[string]any{ "role": "assistant", "content": strings.Join(parts, ""), } if len(reasoningParts) > 0 { message["reasoning_content"] = strings.Join(reasoningParts, "") } if len(toolCalls) > 0 { message["tool_calls"] = sortedStreamToolCalls(toolCalls) } if finishReason == "" { finishReason = "stop" } var out map[string]any out = map[string]any{ "id": stringFromAny(firstPresent(last["id"], "chatcmpl-stream")), "object": "chat.completion", "model": stringFromAny(last["model"]), "choices": []any{map[string]any{ "index": 0, "message": message, "finish_reason": finishReason, }}, } if usage.TotalTokens > 0 { out["usage"] = map[string]any{ "prompt_tokens": usage.InputTokens, "completion_tokens": usage.OutputTokens, "total_tokens": usage.TotalTokens, } } return out } // NormalizeChatCompletionRequestBody 将后续请求里的工具调用上下文还原为 // OpenAI Chat Completions 标准格式,便于再次发送给 OpenAI-compatible 上游。 func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any { if body == nil { return nil } out := cloneBody(body) messages, ok := out["messages"].([]any) if !ok { return out } normalizedMessages := make([]any, 0, len(messages)) for _, rawMessage := range messages { message, ok := rawMessage.(map[string]any) if !ok { normalizedMessages = append(normalizedMessages, rawMessage) continue } copied := cloneMapAny(message) normalizeToolCallsContainer(copied, false) normalizeToolMessageFields(copied) toolMessages, cleanContent, changed := toolResultMessagesFromContent(copied["content"]) if changed { if cleanContent != nil && contentHasText(cleanContent) { copied["content"] = cleanContent normalizedMessages = append(normalizedMessages, copied) } else if len(copied) > 1 || copied["role"] != nil { delete(copied, "content") if len(copied) > 1 { normalizedMessages = append(normalizedMessages, copied) } } normalizedMessages = append(normalizedMessages, toolMessages...) continue } normalizedMessages = append(normalizedMessages, copied) } out["messages"] = normalizedMessages return out } func cloneMapAny(source map[string]any) map[string]any { if source == nil { return nil } out := make(map[string]any, len(source)) for key, value := range source { out[key] = value } return out } // NormalizeChatCompletionResult 将供应商自定义推理字段归一化到 // message.reasoning_content,并从最终回答 content 中剥离内联推理块。 // 加密推理载荷不可展示,且不应作为正文输出,因此会被忽略。 func NormalizeChatCompletionResult(result map[string]any) map[string]any { if result == nil { return nil } choices, _ := result["choices"].([]any) for _, rawChoice := range choices { choice, _ := rawChoice.(map[string]any) if message, ok := choice["message"].(map[string]any); ok { normalizeToolCallsContainer(message, false) normalizeReasoningContainer(message, false) } if delta, ok := choice["delta"].(map[string]any); ok { normalizeToolCallsContainer(delta, true) normalizeReasoningContainer(delta, true) } } return result } // NormalizeChatCompletionStreamEvent 将供应商自定义流式推理字段 // (例如 reasoning_details 或 reasoning)归一化到 delta.reasoning_content。 func NormalizeChatCompletionStreamEvent(event map[string]any) map[string]any { if event == nil { return nil } choices, _ := event["choices"].([]any) for _, rawChoice := range choices { choice, _ := rawChoice.(map[string]any) if delta, ok := choice["delta"].(map[string]any); ok { normalizeToolCallsContainer(delta, true) normalizeReasoningContainer(delta, true) } if message, ok := choice["message"].(map[string]any); ok { normalizeToolCallsContainer(message, false) normalizeReasoningContainer(message, false) } } return event } func normalizeToolCallsContainer(container map[string]any, stream bool) { if container == nil { return } toolCalls := make([]any, 0) for _, rawToolCall := range rawToolCallValues(container) { for _, normalized := range normalizeRawToolCalls(rawToolCall, len(toolCalls), stream) { toolCalls = append(toolCalls, normalized) } } if contentToolCalls, cleanContent, changed := toolCallsFromContent(container["content"], len(toolCalls), stream); changed { toolCalls = append(toolCalls, contentToolCalls...) setNormalizedContent(container, cleanContent, stream) } if partToolCalls := toolCallsFromParts(container["parts"], len(toolCalls), stream); len(partToolCalls) > 0 { toolCalls = append(toolCalls, partToolCalls...) delete(container, "parts") } if len(toolCalls) > 0 { container["tool_calls"] = toolCalls } for _, key := range []string{"tool_call", "toolCall", "toolCalls", "function_call", "functionCall"} { delete(container, key) } } func normalizeToolMessageFields(message map[string]any) { if message == nil { return } if id := firstNonEmptyString(message["tool_call_id"], message["toolCallId"], message["tool_use_id"], message["toolUseId"], message["call_id"], message["callId"]); id != "" { message["tool_call_id"] = id } for _, key := range []string{"toolCallId", "tool_use_id", "toolUseId", "call_id", "callId"} { delete(message, key) } } func toolResultMessagesFromContent(value any) ([]any, any, bool) { blocks, ok := value.([]any) if !ok { return nil, nil, false } toolMessages := make([]any, 0) remaining := make([]any, 0, len(blocks)) for _, rawBlock := range blocks { block, _ := rawBlock.(map[string]any) if len(block) == 0 || stringFromAny(block["type"]) != "tool_result" { remaining = append(remaining, rawBlock) continue } message := map[string]any{ "role": "tool", "tool_call_id": firstNonEmptyString(block["tool_call_id"], block["toolCallId"], block["tool_use_id"], block["toolUseId"], block["id"]), "content": toolResultContent(block["content"]), } toolMessages = append(toolMessages, message) } if len(toolMessages) == 0 { return nil, nil, false } return toolMessages, contentBlocksText(remaining), true } func toolResultContent(value any) any { if text, ok := value.(string); ok { return text } return jsonStringFromAny(value) } func contentHasText(value any) bool { switch typed := value.(type) { case string: return strings.TrimSpace(typed) != "" case []any: return len(typed) > 0 default: return value != nil } } func rawToolCallValues(container map[string]any) []any { values := make([]any, 0, 6) for _, key := range []string{"tool_calls", "tool_call", "toolCalls", "toolCall", "function_call", "functionCall"} { if value, ok := container[key]; ok { values = append(values, value) } } return values } func normalizeRawToolCalls(value any, startIndex int, stream bool) []any { switch typed := value.(type) { case []any: out := make([]any, 0, len(typed)) for _, raw := range typed { if toolCall := normalizeToolCall(raw, startIndex+len(out), stream); toolCall != nil { out = append(out, toolCall) } } return out default: if toolCall := normalizeToolCall(value, startIndex, stream); toolCall != nil { return []any{toolCall} } return nil } } func normalizeToolCall(value any, index int, stream bool) map[string]any { source, _ := value.(map[string]any) if len(source) == 0 { return nil } functionSource := mapFromAny(source["function"]) if len(functionSource) == 0 { functionSource = mapFromAny(firstPresent(source["function_call"], source["functionCall"])) } name := firstNonEmptyString( functionSource["name"], source["name"], source["function_name"], source["functionName"], source["tool_name"], source["toolName"], ) arguments, hasArguments := toolCallArguments(functionSource) if !hasArguments { arguments, hasArguments = toolCallArguments(source) } if name == "" && !hasArguments && firstNonEmptyString(source["id"], source["call_id"], source["callId"], source["tool_call_id"], source["toolCallId"]) == "" { return nil } function := map[string]any{} if name != "" { function["name"] = name } if hasArguments { function["arguments"] = arguments } toolCall := map[string]any{ "type": firstNonEmptyString(source["type"], "function"), "function": function, } if id := firstNonEmptyString(source["id"], source["call_id"], source["callId"], source["tool_call_id"], source["toolCallId"]); id != "" { toolCall["id"] = id } else if !stream { toolCall["id"] = fmt.Sprintf("call_%d", index) } if stream { if rawIndex, ok := firstPresent(source["index"], source["idx"]).(float64); ok { toolCall["index"] = int(math.Round(rawIndex)) } else if rawIndex, ok := firstPresent(source["index"], source["idx"]).(int); ok { toolCall["index"] = rawIndex } else { toolCall["index"] = index } } return toolCall } func toolCallsFromContent(value any, startIndex int, stream bool) ([]any, any, bool) { blocks, ok := value.([]any) if !ok { return nil, nil, false } toolCalls := make([]any, 0) remaining := make([]any, 0, len(blocks)) containsReasoning := false for _, rawBlock := range blocks { block, _ := rawBlock.(map[string]any) if len(block) == 0 { remaining = append(remaining, rawBlock) continue } switch stringFromAny(block["type"]) { case "tool_use": if toolCall := normalizeToolUseBlock(block, startIndex+len(toolCalls), stream); toolCall != nil { toolCalls = append(toolCalls, toolCall) } case "tool_result": remaining = append(remaining, rawBlock) default: if isReasoningContentBlock(block) { containsReasoning = true } remaining = append(remaining, rawBlock) } } if len(toolCalls) == 0 { return nil, nil, false } if containsReasoning { return toolCalls, remaining, true } return toolCalls, contentBlocksText(remaining), true } func normalizeToolUseBlock(block map[string]any, index int, stream bool) map[string]any { toolCall := map[string]any{ "type": "function", "function": map[string]any{ "name": stringFromAny(block["name"]), "arguments": jsonStringFromAny(block["input"]), }, } if id := firstNonEmptyString(block["id"], block["tool_use_id"], block["toolUseId"]); id != "" { toolCall["id"] = id } else if !stream { toolCall["id"] = fmt.Sprintf("call_%d", index) } if stream { toolCall["index"] = index } return toolCall } func toolCallsFromParts(value any, startIndex int, stream bool) []any { parts, ok := value.([]any) if !ok { return nil } out := make([]any, 0) for _, rawPart := range parts { part, _ := rawPart.(map[string]any) if functionCall := mapFromAny(firstPresent(part["functionCall"], part["function_call"])); len(functionCall) > 0 { if toolCall := normalizeGeminiFunctionCall(functionCall, startIndex+len(out), stream); toolCall != nil { out = append(out, toolCall) } } } return out } func normalizeGeminiFunctionCall(functionCall map[string]any, index int, stream bool) map[string]any { toolCall := map[string]any{ "type": "function", "function": map[string]any{ "name": stringFromAny(functionCall["name"]), "arguments": jsonStringFromAny(firstPresent(functionCall["args"], functionCall["arguments"])), }, } if id := firstNonEmptyString(functionCall["id"], functionCall["call_id"], functionCall["callId"]); id != "" { toolCall["id"] = id } else if !stream { toolCall["id"] = fmt.Sprintf("call_%d", index) } if stream { toolCall["index"] = index } return toolCall } func setNormalizedContent(container map[string]any, value any, stream bool) { if text, ok := value.(string); ok && text == "" { if stream { delete(container, "content") return } container["content"] = nil return } container["content"] = value } func isReasoningContentBlock(block map[string]any) bool { switch stringFromAny(block["type"]) { case "thinking", "redacted_thinking", "reasoning.text", "reasoning.summary", "reasoning.encrypted": return true default: return false } } func contentBlocksText(blocks []any) string { parts := make([]string, 0, len(blocks)) for _, rawBlock := range blocks { switch block := rawBlock.(type) { case string: parts = append(parts, block) case map[string]any: if text := stringFromAny(firstPresent(block["text"], block["content"])); text != "" { parts = append(parts, text) } } } return strings.Join(parts, "") } func toolCallArguments(source map[string]any) (string, bool) { for _, key := range []string{"arguments", "args", "input", "parameters"} { if value, ok := source[key]; ok { return jsonStringFromAny(value), true } } return "", false } func jsonStringFromAny(value any) string { if value == nil { return "" } if text, ok := value.(string); ok { return text } encoded, err := json.Marshal(value) if err != nil { return "" } return string(encoded) } func normalizeReasoningContainer(container map[string]any, deleteEmptyContent bool) { if container == nil { return } reasoningParts := make([]string, 0, 3) if reasoning := reasoningDetailsText(container["reasoning_details"]); reasoning != "" { reasoningParts = append(reasoningParts, reasoning) } else if reasoning := stringFromAny(container["reasoning_content"]); reasoning != "" { reasoningParts = append(reasoningParts, reasoning) } else if reasoning := stringFromAny(container["reasoning"]); reasoning != "" { reasoningParts = append(reasoningParts, reasoning) } if content, ok := container["content"]; ok { cleanContent, contentReasoning, changed := normalizeReasoningContentValue(content) if changed { if deleteEmptyContent { if text, ok := cleanContent.(string); ok && text == "" { delete(container, "content") } else { container["content"] = cleanContent } } else { container["content"] = cleanContent } } if contentReasoning != "" { reasoningParts = append(reasoningParts, contentReasoning) } } if len(reasoningParts) > 0 { container["reasoning_content"] = strings.Join(reasoningParts, "") } delete(container, "reasoning_details") delete(container, "reasoning") } func reasoningDetailsText(value any) string { rawItems, ok := value.([]any) if !ok { return "" } parts := make([]string, 0, len(rawItems)) for _, rawItem := range rawItems { item, _ := rawItem.(map[string]any) switch stringFromAny(item["type"]) { case "reasoning.text": if text := stringFromAny(item["text"]); text != "" { parts = append(parts, text) } case "reasoning.summary": if summary := stringFromAny(item["summary"]); summary != "" { parts = append(parts, summary) } } } return strings.Join(parts, "") } func normalizeReasoningContentValue(value any) (any, string, bool) { switch typed := value.(type) { case string: cleanContent, reasoning, changed := splitTaggedReasoningText(typed) return cleanContent, reasoning, changed case []any: contentParts := make([]string, 0, len(typed)) reasoningParts := make([]string, 0) changed := false for _, rawItem := range typed { switch item := rawItem.(type) { case string: contentParts = append(contentParts, item) case map[string]any: switch stringFromAny(item["type"]) { case "thinking": if thinking := stringFromAny(item["thinking"]); thinking != "" { reasoningParts = append(reasoningParts, thinking) } changed = true case "redacted_thinking", "reasoning.encrypted": changed = true case "reasoning.text": if text := stringFromAny(item["text"]); text != "" { reasoningParts = append(reasoningParts, text) } changed = true case "reasoning.summary": if summary := stringFromAny(item["summary"]); summary != "" { reasoningParts = append(reasoningParts, summary) } changed = true case "text", "output_text": if text := stringFromAny(firstPresent(item["text"], item["content"])); text != "" { cleanText, reasoning, tagged := splitTaggedReasoningText(text) contentParts = append(contentParts, cleanText) if reasoning != "" { reasoningParts = append(reasoningParts, reasoning) } changed = changed || tagged } default: if text := stringFromAny(firstPresent(item["text"], item["content"])); text != "" { contentParts = append(contentParts, text) } } } } if !changed { return value, "", false } return strings.Join(contentParts, ""), strings.Join(reasoningParts, ""), true default: return value, "", false } } func splitTaggedReasoningText(text string) (string, string, bool) { lower := strings.ToLower(text) clean := strings.Builder{} reasoning := strings.Builder{} changed := false for offset := 0; offset < len(text); { start, tag := nextReasoningOpenTag(lower, offset) if start < 0 { clean.WriteString(text[offset:]) break } clean.WriteString(text[offset:start]) openEnd := start + len("<"+tag+">") closeToken := "" closeStart := strings.Index(lower[openEnd:], closeToken) if closeStart < 0 { reasoning.WriteString(text[openEnd:]) offset = len(text) changed = true break } closeStart += openEnd reasoning.WriteString(text[openEnd:closeStart]) offset = closeStart + len(closeToken) changed = true } return clean.String(), reasoning.String(), changed } func nextReasoningOpenTag(lower string, offset int) (int, string) { bestStart := -1 bestTag := "" for _, tag := range []string{"think", "reasoning", "analysis"} { needle := "<" + tag + ">" idx := strings.Index(lower[offset:], needle) if idx < 0 { continue } absolute := offset + idx if bestStart < 0 || absolute < bestStart { bestStart = absolute bestTag = tag } } return bestStart, bestTag } func streamEventText(event map[string]any) string { if choices, ok := event["choices"].([]any); ok { for _, rawChoice := range choices { choice, _ := rawChoice.(map[string]any) if delta, ok := choice["delta"].(map[string]any); ok { if content, ok := delta["content"].(string); ok { return content } } if message, ok := choice["message"].(map[string]any); ok { if content, ok := message["content"].(string); ok { return content } } } } if delta, ok := event["delta"].(string); ok { return delta } if text, ok := event["output_text"].(string); ok { return text } return "" } func streamEventReasoningContent(event map[string]any) string { if choices, ok := event["choices"].([]any); ok { for _, rawChoice := range choices { choice, _ := rawChoice.(map[string]any) if delta, ok := choice["delta"].(map[string]any); ok { if content, ok := delta["reasoning_content"].(string); ok { return content } if content, ok := delta["reasoning"].(string); ok { return content } } if message, ok := choice["message"].(map[string]any); ok { if content, ok := message["reasoning_content"].(string); ok { return content } } } } return "" } func streamEventFinishReason(event map[string]any) string { if choices, ok := event["choices"].([]any); ok { for _, rawChoice := range choices { choice, _ := rawChoice.(map[string]any) if reason, ok := choice["finish_reason"].(string); ok && reason != "" { return reason } } } return "" } func aggregateStreamToolCalls(event map[string]any, toolCalls map[int]map[string]any) { choices, _ := event["choices"].([]any) for _, rawChoice := range choices { choice, _ := rawChoice.(map[string]any) delta, _ := choice["delta"].(map[string]any) rawToolCalls, _ := delta["tool_calls"].([]any) for _, rawToolCall := range rawToolCalls { incoming, _ := rawToolCall.(map[string]any) index := intFromAny(incoming["index"]) current := toolCalls[index] if current == nil { current = map[string]any{} toolCalls[index] = current } for _, key := range []string{"id", "type"} { if value, ok := incoming[key].(string); ok && value != "" { current[key] = value } } incomingFn, _ := incoming["function"].(map[string]any) if len(incomingFn) == 0 { continue } currentFn, _ := current["function"].(map[string]any) if currentFn == nil { currentFn = map[string]any{} current["function"] = currentFn } if name, ok := incomingFn["name"].(string); ok && name != "" { currentFn["name"] = stringFromAny(currentFn["name"]) + name } if arguments, ok := incomingFn["arguments"].(string); ok && arguments != "" { currentFn["arguments"] = stringFromAny(currentFn["arguments"]) + arguments } } } } func sortedStreamToolCalls(toolCalls map[int]map[string]any) []any { indices := make([]int, 0, len(toolCalls)) for index := range toolCalls { indices = append(indices, index) } sort.Ints(indices) out := make([]any, 0, len(indices)) for _, index := range indices { out = append(out, toolCalls[index]) } return out } func usageFromOpenAI(result map[string]any) Usage { usage, _ := result["usage"].(map[string]any) input := intFromAny(firstPresent(usage["prompt_tokens"], usage["input_tokens"])) output := intFromAny(firstPresent(usage["completion_tokens"], usage["output_tokens"])) total := intFromAny(usage["total_tokens"]) if total == 0 { total = input + output } return Usage{InputTokens: input, OutputTokens: output, TotalTokens: total} } func requestIDFromHTTPResponse(resp *http.Response) string { if resp == nil { return "" } for _, key := range []string{ "x-request-id", "x-requestid", "request-id", "x-amzn-requestid", "x-amz-request-id", "cf-ray", } { if value := strings.TrimSpace(resp.Header.Get(key)); value != "" { return value } } return "" } func requestIDFromResult(result map[string]any) string { for _, key := range []string{"request_id", "requestId", "id", "response_id", "responseId"} { if value := strings.TrimSpace(stringFromAny(result[key])); value != "" { return value } } return "" } func intFromAny(value any) int { switch typed := value.(type) { case float64: return int(math.Round(typed)) case int: return typed case int64: return int(typed) default: return 0 } } func stringFromAny(value any) string { if text, ok := value.(string); ok { return text } return "" } func firstNonEmptyString(values ...any) string { for _, value := range values { if text := strings.TrimSpace(stringFromAny(value)); text != "" { return text } } return "" } func firstPresent(values ...any) any { for _, value := range values { if value != nil { return value } } return nil } func errorMessage(raw []byte, fallback string) string { if len(raw) == 0 { return fallback } var parsed map[string]any if json.Unmarshal(raw, &parsed) == nil { if errObj, ok := parsed["error"].(map[string]any); ok { if message, ok := errObj["message"].(string); ok { return message } } if message, ok := parsed["message"].(string); ok { return message } } return string(raw) } func statusCodeName(status int) string { switch status { case http.StatusTooManyRequests: return "rate_limit" case http.StatusRequestTimeout: return "timeout" case http.StatusUnauthorized, http.StatusForbidden: return "auth_failed" default: if status >= 500 { return "server_error" } return fmt.Sprintf("http_%d", status) } } func nowUnix() int64 { return time.Now().Unix() }