986 lines
28 KiB
Go
986 lines
28 KiB
Go
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()
|
||
}
|