easyai-ai-gateway/apps/api/internal/clients/helpers.go

986 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 := "</" + tag + ">"
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()
}