feat(openai): 完善 Chat 与 Responses 参数转发

原生 Chat/Responses 改为透明转发,保留标准工具结构并保护调用方显式参数。补齐 Responses 到 Chat 的兼容转换、协议路由边界、完整响应和流式事件,并同步更新 Swagger、回归测试与真实验收脚本。

验证:
- cd apps/api && env -u AI_GATEWAY_TEST_DATABASE_URL go test ./... -count=1
- pnpm openapi
- pnpm lint
- pnpm test
- pnpm build
- gofmt -l 无输出
- git diff --check 通过

风险:
- Chat 回退无法等价表达的 Responses 原生能力现在会返回 unsupported_response_parameter
- 真实供应商 E2E 因本地没有已启用的平台模型候选而未完成
This commit is contained in:
2026-08-04 19:26:48 +08:00
parent b2c9b4f6d9
commit fe8dcb40ca
22 changed files with 2098 additions and 309 deletions
+187 -36
View File
@@ -202,6 +202,9 @@ 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)
refusalParts := make([]string, 0)
streamLogprobs := make([]any, 0)
streamAnnotations := make([]any, 0)
reasoningParts := make([]string, 0)
var last map[string]any
var usage Usage
@@ -232,6 +235,11 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
if reasoningText != "" {
reasoningParts = append(reasoningParts, reasoningText)
}
if refusal := streamEventRefusal(event); refusal != "" {
refusalParts = append(refusalParts, refusal)
}
streamLogprobs = append(streamLogprobs, streamEventLogprobs(event)...)
streamAnnotations = append(streamAnnotations, streamEventAnnotations(event)...)
aggregateStreamToolCalls(event, toolCalls)
if reason := streamEventFinishReason(event); reason != "" {
finishReason = reason
@@ -259,7 +267,7 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
}
return out, true, nil
}
return buildOpenAIStreamResult(last, parts, reasoningParts, toolCalls, finishReason, usage), true, nil
return buildOpenAIStreamResult(last, parts, refusalParts, streamLogprobs, streamAnnotations, reasoningParts, toolCalls, finishReason, usage), true, nil
}
func decodeOpenAIStream(raw []byte) (map[string]any, bool) {
@@ -270,8 +278,8 @@ func decodeOpenAIStream(raw []byte) (map[string]any, bool) {
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 {
func buildOpenAIStreamResult(last map[string]any, parts []string, refusalParts []string, streamLogprobs []any, streamAnnotations []any, reasoningParts []string, toolCalls map[int]map[string]any, finishReason string, usage Usage) map[string]any {
if len(parts) == 0 && len(refusalParts) == 0 && len(streamLogprobs) == 0 && len(streamAnnotations) == 0 && len(reasoningParts) == 0 && len(toolCalls) == 0 {
return last
}
message := map[string]any{
@@ -281,6 +289,12 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
if len(reasoningParts) > 0 {
message["reasoning_content"] = strings.Join(reasoningParts, "")
}
if len(refusalParts) > 0 {
message["refusal"] = strings.Join(refusalParts, "")
}
if len(streamAnnotations) > 0 {
message["annotations"] = streamAnnotations
}
if len(toolCalls) > 0 {
message["tool_calls"] = sortedStreamToolCalls(toolCalls)
}
@@ -288,15 +302,17 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
finishReason = "stop"
}
var out map[string]any
choice := map[string]any{
"index": 0, "message": message, "finish_reason": finishReason,
}
if len(streamLogprobs) > 0 {
choice["logprobs"] = map[string]any{"content": streamLogprobs, "refusal": nil}
}
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,
}},
"id": stringFromAny(firstPresent(last["id"], "chatcmpl-stream")),
"object": "chat.completion",
"model": stringFromAny(last["model"]),
"choices": []any{choice},
}
if usage.TotalTokens > 0 {
usageMap := map[string]any{
@@ -314,6 +330,42 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
return out
}
func streamEventRefusal(event map[string]any) string {
choices, _ := event["choices"].([]any)
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if refusal := stringFromAny(delta["refusal"]); refusal != "" {
return refusal
}
}
return ""
}
func streamEventLogprobs(event map[string]any) []any {
choices, _ := event["choices"].([]any)
out := make([]any, 0)
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
logprobs, _ := choice["logprobs"].(map[string]any)
content, _ := logprobs["content"].([]any)
out = append(out, content...)
}
return out
}
func streamEventAnnotations(event map[string]any) []any {
choices, _ := event["choices"].([]any)
out := make([]any, 0)
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
annotations, _ := delta["annotations"].([]any)
out = append(out, annotations...)
}
return out
}
// NormalizeChatCompletionRequestBody 将后续请求里的工具调用上下文还原为
// OpenAI Chat Completions 标准格式,便于再次发送给 OpenAI-compatible 上游。
func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any {
@@ -333,7 +385,7 @@ func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any {
continue
}
copied := cloneMapAny(message)
normalizeToolCallsContainer(copied, false)
normalizeRequestToolCallsContainer(copied)
normalizeToolMessageFields(copied)
toolMessages, cleanContent, changed := toolResultMessagesFromContent(copied["content"])
if changed {
@@ -355,6 +407,67 @@ func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any {
return out
}
// normalizeRequestToolCallsContainer preserves canonical OpenAI Chat
// structures. Only known provider aliases are normalized. Legacy
// function_call and standard custom tool calls must remain intact.
func normalizeRequestToolCallsContainer(container map[string]any) {
if container == nil {
return
}
toolCalls := canonicalToolCalls(container["tool_calls"], false)
for _, key := range []string{"tool_call", "toolCall", "toolCalls"} {
if raw, ok := container[key]; ok {
for _, normalized := range normalizeRawToolCalls(raw, len(toolCalls), false) {
toolCalls = append(toolCalls, normalized)
}
delete(container, key)
}
}
if contentToolCalls, cleanContent, changed := toolCallsFromContent(container["content"], len(toolCalls), false); changed {
toolCalls = append(toolCalls, contentToolCalls...)
setNormalizedContent(container, cleanContent, false)
}
if partToolCalls := toolCallsFromParts(container["parts"], len(toolCalls), false); len(partToolCalls) > 0 {
toolCalls = append(toolCalls, partToolCalls...)
delete(container, "parts")
}
if len(toolCalls) > 0 {
container["tool_calls"] = toolCalls
}
if functionCall, ok := container["functionCall"]; ok {
if _, canonical := container["function_call"]; !canonical {
if normalized := normalizeToolCall(functionCall, 0, false); normalized != nil {
container["function_call"] = normalized["function"]
}
}
delete(container, "functionCall")
}
}
func canonicalToolCalls(value any, stream bool) []any {
items, ok := value.([]any)
if !ok {
if value == nil {
return nil
}
items = []any{value}
}
out := make([]any, 0, len(items))
for index, raw := range items {
toolCall, _ := raw.(map[string]any)
typeName := stringFromAny(toolCall["type"])
if (typeName == "custom" && len(mapFromAny(toolCall["custom"])) > 0) ||
((typeName == "" || typeName == "function") && len(mapFromAny(toolCall["function"])) > 0) {
out = append(out, cloneMapAny(toolCall))
continue
}
if normalized := normalizeToolCall(raw, index, stream); normalized != nil {
out = append(out, normalized)
}
}
return out
}
func cloneMapAny(source map[string]any) map[string]any {
if source == nil {
return nil
@@ -446,14 +559,27 @@ 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)
toolCalls := canonicalToolCalls(container["tool_calls"], stream)
for _, key := range []string{"tool_call", "toolCall", "toolCalls"} {
if rawToolCall, ok := container[key]; ok {
for _, normalized := range normalizeRawToolCalls(rawToolCall, len(toolCalls), stream) {
toolCalls = append(toolCalls, normalized)
}
delete(container, key)
}
}
if functionCall, ok := container["functionCall"]; ok {
if _, canonical := container["function_call"]; !canonical {
if normalized := normalizeToolCall(functionCall, 0, stream); normalized != nil {
container["function_call"] = normalized["function"]
}
}
delete(container, "functionCall")
}
if contentToolCalls, cleanContent, changed := toolCallsFromContent(container["content"], len(toolCalls), stream); changed {
toolCalls = append(toolCalls, contentToolCalls...)
for _, normalized := range contentToolCalls {
toolCalls = append(toolCalls, normalized)
}
setNormalizedContent(container, cleanContent, stream)
}
if partToolCalls := toolCallsFromParts(container["parts"], len(toolCalls), stream); len(partToolCalls) > 0 {
@@ -463,9 +589,6 @@ func normalizeToolCallsContainer(container map[string]any, stream bool) {
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) {
@@ -557,6 +680,20 @@ func normalizeToolCall(value any, index int, stream bool) map[string]any {
if len(source) == 0 {
return nil
}
if stringFromAny(source["type"]) == "custom" {
customSource := mapFromAny(source["custom"])
if len(customSource) == 0 {
return nil
}
toolCall := cloneMapAny(source)
toolCall["custom"] = cloneMapAny(customSource)
if stream {
if _, ok := toolCall["index"]; !ok {
toolCall["index"] = index
}
}
return toolCall
}
functionSource := mapFromAny(source["function"])
if len(functionSource) == 0 {
functionSource = mapFromAny(firstPresent(source["function_call"], source["functionCall"]))
@@ -976,7 +1113,7 @@ func aggregateStreamToolCalls(event map[string]any, toolCalls map[int]map[string
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
rawToolCalls, _ := delta["tool_calls"].([]any)
rawToolCalls := streamToolCallsFromDelta(delta)
for _, rawToolCall := range rawToolCalls {
incoming, _ := rawToolCall.(map[string]any)
index := intFromAny(incoming["index"])
@@ -990,25 +1127,39 @@ func aggregateStreamToolCalls(event map[string]any, toolCalls map[int]map[string
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
}
aggregateStreamToolPayload(current, incoming, "function", "arguments")
aggregateStreamToolPayload(current, incoming, "custom", "input")
}
}
}
func streamToolCallsFromDelta(delta map[string]any) []any {
rawToolCalls, _ := delta["tool_calls"].([]any)
out := append([]any(nil), rawToolCalls...)
if functionCall, ok := delta["function_call"].(map[string]any); ok {
out = append(out, map[string]any{"index": 0, "type": "function", "function": functionCall})
}
return out
}
func aggregateStreamToolPayload(current map[string]any, incoming map[string]any, containerKey string, payloadKey string) {
incomingPayload, _ := incoming[containerKey].(map[string]any)
if len(incomingPayload) == 0 {
return
}
currentPayload, _ := current[containerKey].(map[string]any)
if currentPayload == nil {
currentPayload = map[string]any{}
current[containerKey] = currentPayload
}
if name, ok := incomingPayload["name"].(string); ok && name != "" {
currentPayload["name"] = stringFromAny(currentPayload["name"]) + name
}
if payload, ok := incomingPayload[payloadKey].(string); ok && payload != "" {
currentPayload[payloadKey] = stringFromAny(currentPayload[payloadKey]) + payload
}
}
func sortedStreamToolCalls(toolCalls map[int]map[string]any) []any {
indices := make([]int, 0, len(toolCalls))
for index := range toolCalls {