feat(chat): 完善 Chat Completions 兼容层

This commit is contained in:
2026-05-19 17:46:27 +08:00
parent ba419cd90a
commit 13186f8ed1
13 changed files with 1611 additions and 85 deletions
+697 -14
View File
@@ -8,6 +8,7 @@ import (
"io"
"math"
"net/http"
"sort"
"strings"
"time"
)
@@ -87,8 +88,11 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
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)
@@ -104,13 +108,23 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
if err := json.Unmarshal([]byte(payload), &event); err != nil {
continue
}
event = NormalizeChatCompletionStreamEvent(event)
last = event
if text := streamEventText(event); text != "" {
text := streamEventText(event)
reasoningText := streamEventReasoningContent(event)
if text != "" {
parts = append(parts, text)
if onDelta != nil {
if err := onDelta(text); err != nil {
return nil, true, err
}
}
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 {
@@ -131,7 +145,7 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
}
return out, true, nil
}
return buildOpenAIStreamResult(last, parts, usage), true, nil
return buildOpenAIStreamResult(last, parts, reasoningParts, toolCalls, finishReason, usage), true, nil
}
func decodeOpenAIStream(raw []byte) (map[string]any, bool) {
@@ -142,22 +156,32 @@ func decodeOpenAIStream(raw []byte) (map[string]any, bool) {
return result, ok && err == nil
}
func buildOpenAIStreamResult(last map[string]any, parts []string, usage Usage) map[string]any {
if len(parts) == 0 {
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": map[string]any{
"role": "assistant",
"content": strings.Join(parts, ""),
},
"finish_reason": "stop",
"index": 0,
"message": message,
"finish_reason": finishReason,
}},
}
if usage.TotalTokens > 0 {
@@ -170,6 +194,571 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, usage Usage) m
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 {
@@ -195,6 +784,91 @@ func streamEventText(event map[string]any) string {
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"]))
@@ -254,6 +928,15 @@ func stringFromAny(value any) string {
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 {