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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user