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
@@ -294,7 +294,7 @@ func TestWriteCompatibleTaskResponseStreamsStructuredToolAndReasoningDeltas(t *t
if roleIndex < 0 || reasoningIndex < 0 || roleIndex > reasoningIndex {
t.Fatalf("assistant role should be emitted before structured deltas: %s", body)
}
for _, want := range []string{`"system_fingerprint":"fp-test"`, `"created":1710000000`, `"reasoning_content":"tagged"`, `"content":"answer"`, `"tool_calls":[{"function":{"arguments":"{\"city\":\"Boston\"}","name":"legacy_lookup"}`, `"tool_calls":[{"function":{"arguments":"{\"q\":"`, `"finish_reason":"tool_calls"`, `"choices":[],"created":1710000000`, `"usage":{"completion_tokens":5,"prompt_tokens":4,"total_tokens":9}`, "data: [DONE]"} {
for _, want := range []string{`"system_fingerprint":"fp-test"`, `"created":1710000000`, `"reasoning_content":"tagged"`, `"content":"answer"`, `"function_call":{"arguments":"{\"city\":\"Boston\"}","name":"legacy_lookup"}`, `"tool_calls":[{"function":{"arguments":"{\"q\":"`, `"finish_reason":"tool_calls"`, `"choices":[],"created":1710000000`, `"usage":{"completion_tokens":5,"prompt_tokens":4,"total_tokens":9}`, "data: [DONE]"} {
if !strings.Contains(body, want) {
t.Fatalf("SSE body missing %s: %s", want, body)
}
+5 -5
View File
@@ -1327,7 +1327,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
// createAPIV1ChatCompletions godoc
// @Summary 创建 Chat Completions
// @Description /api/v1/chat/completions 同步执行:stream=true 返回 text/event-stream SSEstream=false 或未传返回兼容 JSON;该接口忽略 X-Async。
// @Description /api/v1/chat/completions 同步执行:除 Gateway 路由字段外透明转发当前及未来 OpenAI 字段与嵌套结构,完整保留 function/custom tools、tool_choice、tool_calls 和旧版 function_callstream=true 返回 text/event-stream SSEstream=false 或未传返回兼容 JSON;该接口忽略 X-Async。
// @Tags tasks
// @Accept json
// @Produce json
@@ -1350,7 +1350,7 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
// openAIChatCompletionsDoc godoc
// @Summary 创建 OpenAI Chat Completions
// @Description OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter
// @Description OpenAI-compatible Chat Completions 入口。除 Gateway 路由字段外,原生请求会透明转发当前及未来 OpenAI 字段和嵌套结构;Gateway 仅覆盖上游模型名等受控字段。function/custom tools、tool_choice、tool_calls 与旧版 function_call 均保持标准语义
// @Tags chat
// @Accept json
// @Produce json
@@ -1368,15 +1368,15 @@ func openAIChatCompletionsDoc() {}
// openAIResponsesDoc godoc
// @Summary 创建 OpenAI Responses
// @Description 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 Chat Completions 转换;store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接未提供 previous_response_id 时调用方管理完整状态Gateway 以本轮 input/messages 为准且不追加本地历史。
// @Description 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时除 Gateway 路由字段外透明转发当前及未来 OpenAI 字段;否则仅对具有等价 Chat Completions 语义的请求执行转换。background、conversation、prompt template、context management、max_tool_calls、自动 truncation、reasoning summary、内置工具、MCP 和 namespace 等原生专属能力不会静默丢弃:路由会排除 Chat 候选,固定 Chat 链路则返回带参数路径的 unsupported_response_parameter。store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接未提供 previous_response_id 时调用方历史权威Gateway 不追加本地历史。
// @Tags responses
// @Accept json
// @Produce json
// @Produce text/event-stream
// @Security BearerAuth
// @Param input body ResponsesRequest true "Responses 请求;Chat 回退支持自定义 function tools"
// @Param input body ResponsesRequest true "Responses 请求;Chat 回退支持 function/custom tools 及所有可等价能力"
// @Success 200 {object} ResponsesCompatibleResponse
// @Failure 400 {object} OpenAIErrorEnvelope "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter"
// @Failure 400 {object} OpenAIErrorEnvelope "invalid_previous_response_id / unsupported_response_parameter"
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 402 {object} OpenAIErrorEnvelope
// @Failure 403 {object} OpenAIErrorEnvelope
+54 -18
View File
@@ -470,12 +470,14 @@ type ChatMessage struct {
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls interface{} `json:"tool_calls,omitempty"`
FunctionCall interface{} `json:"function_call,omitempty"`
Audio interface{} `json:"audio,omitempty"`
Refusal string `json:"refusal,omitempty"`
}
type ResponsesRequest struct {
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
Background *bool `json:"background,omitempty"`
ContextManagement []map[string]interface{} `json:"context_management,omitempty"`
ContextManagement interface{} `json:"context_management,omitempty"`
Conversation interface{} `json:"conversation,omitempty"`
Include []string `json:"include,omitempty"`
Input interface{} `json:"input"`
@@ -507,15 +509,41 @@ type ResponsesRequest struct {
}
type ResponsesCompatibleResponse struct {
ID string `json:"id" example:"resp_0123456789abcdef0123456789abcdef"`
Object string `json:"object" example:"response"`
CreatedAt int64 `json:"created_at" example:"1710000000"`
Status string `json:"status" example:"completed"`
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_abcdef0123456789abcdef0123456789"`
Output []map[string]interface{} `json:"output"`
OutputText string `json:"output_text,omitempty" example:"Hello"`
Usage map[string]interface{} `json:"usage,omitempty"`
ID string `json:"id" example:"resp_0123456789abcdef0123456789abcdef"`
Object string `json:"object" example:"response"`
CreatedAt int64 `json:"created_at" example:"1710000000"`
Status string `json:"status" example:"completed" enums:"queued,in_progress,completed,incomplete,failed,cancelled"`
CompletedAt *int64 `json:"completed_at"`
Error interface{} `json:"error"`
IncompleteDetails interface{} `json:"incomplete_details"`
Instructions interface{} `json:"instructions"`
Metadata map[string]interface{} `json:"metadata"`
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
Output []map[string]interface{} `json:"output"`
ParallelToolCalls bool `json:"parallel_tool_calls"`
Temperature interface{} `json:"temperature"`
ToolChoice interface{} `json:"tool_choice"`
Tools []map[string]interface{} `json:"tools"`
TopP interface{} `json:"top_p"`
Background bool `json:"background"`
Conversation interface{} `json:"conversation"`
MaxOutputTokens interface{} `json:"max_output_tokens"`
MaxToolCalls interface{} `json:"max_tool_calls"`
Moderation interface{} `json:"moderation"`
OutputText string `json:"output_text" example:"Hello"`
PreviousResponseID interface{} `json:"previous_response_id"`
Prompt interface{} `json:"prompt"`
PromptCacheKey interface{} `json:"prompt_cache_key"`
PromptCacheOptions interface{} `json:"prompt_cache_options"`
PromptCacheRetention interface{} `json:"prompt_cache_retention"`
Reasoning interface{} `json:"reasoning"`
SafetyIdentifier interface{} `json:"safety_identifier"`
ServiceTier interface{} `json:"service_tier"`
Text interface{} `json:"text"`
TopLogprobs interface{} `json:"top_logprobs"`
Truncation interface{} `json:"truncation"`
Usage map[string]interface{} `json:"usage"`
User interface{} `json:"user"`
}
type ImageGenerationRequest struct {
@@ -586,23 +614,31 @@ type CompatibleResponse struct {
}
type ChatCompletionCompatibleResponse struct {
ID string `json:"id" example:"chatcmpl-123"`
Object string `json:"object" example:"chat.completion"`
Created int64 `json:"created,omitempty" example:"1710000000"`
Model string `json:"model" example:"gpt-4o-mini"`
Choices []ChatCompletionChoice `json:"choices"`
Usage *ChatCompletionUsage `json:"usage,omitempty"`
ID string `json:"id" example:"chatcmpl-123"`
Object string `json:"object" example:"chat.completion"`
Created int64 `json:"created,omitempty" example:"1710000000"`
Model string `json:"model" example:"gpt-4o-mini"`
Choices []ChatCompletionChoice `json:"choices"`
Usage *ChatCompletionUsage `json:"usage,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
SystemFingerprint string `json:"system_fingerprint,omitempty"`
}
type ChatCompletionChoice struct {
Index int `json:"index" example:"0"`
Message ChatCompletionChoiceMessage `json:"message"`
FinishReason string `json:"finish_reason,omitempty" example:"stop"`
Logprobs interface{} `json:"logprobs,omitempty"`
}
type ChatCompletionChoiceMessage struct {
Role string `json:"role" example:"assistant"`
Content string `json:"content" example:"Hello"`
Role string `json:"role" example:"assistant"`
Content interface{} `json:"content"`
Refusal string `json:"refusal,omitempty"`
Audio interface{} `json:"audio,omitempty"`
Annotations interface{} `json:"annotations,omitempty"`
ToolCalls interface{} `json:"tool_calls,omitempty"`
FunctionCall interface{} `json:"function_call,omitempty"`
}
type ChatCompletionUsage struct {
@@ -34,3 +34,16 @@ func TestResponsesStreamWriterForwardsStandardEventsAndNeverWritesDoneMarker(t *
t.Fatalf("Responses stream duplicated response.completed: %s", body)
}
}
func TestResponsesStreamWriterUsesIncompleteTerminalWithoutAppendingCompleted(t *testing.T) {
recorder := httptest.NewRecorder()
writer := newCompatibleStreamWriter("responses", "demo", false)
writer.writeDelta(recorder, clients.StreamDeltaEvent{Event: map[string]any{
"type": "response.incomplete", "sequence_number": 3, "response": map[string]any{"id": "resp_123", "status": "incomplete"},
}})
writer.writeDone(recorder, map[string]any{"id": "resp_123", "status": "incomplete"})
body := recorder.Body.String()
if strings.Count(body, "event: response.incomplete") != 1 || strings.Contains(body, "event: response.completed") {
t.Fatalf("incomplete terminal event was duplicated or rewritten: %s", body)
}
}
+25 -2
View File
@@ -49,7 +49,7 @@ func (s *compatibleStreamWriter) writeDelta(w http.ResponseWriter, event clients
if sequence := intFromStreamValue(event.Event["sequence_number"]); sequence >= s.responseSequence {
s.responseSequence = sequence + 1
}
if eventType == "response.completed" {
if isResponsesTerminalEvent(eventType) {
s.sentResponseDone = true
}
sendSSE(w, eventType, event.Event)
@@ -84,7 +84,8 @@ func (s *compatibleStreamWriter) writeDone(w http.ResponseWriter, output map[str
if s.sentResponseDone {
return
}
sendSSE(w, "response.completed", map[string]any{"type": "response.completed", "sequence_number": s.responseSequence, "response": output})
eventType := responsesTerminalEvent(output)
sendSSE(w, eventType, map[string]any{"type": eventType, "sequence_number": s.responseSequence, "response": output})
s.sentResponseDone = true
return
}
@@ -106,6 +107,28 @@ func (s *compatibleStreamWriter) writeDone(w http.ResponseWriter, output map[str
s.writeDoneMarker(w)
}
func isResponsesTerminalEvent(eventType string) bool {
switch eventType {
case "response.completed", "response.incomplete", "response.failed", "response.cancelled":
return true
default:
return false
}
}
func responsesTerminalEvent(output map[string]any) string {
switch status, _ := output["status"].(string); status {
case "incomplete":
return "response.incomplete"
case "failed":
return "response.failed"
case "cancelled":
return "response.cancelled"
default:
return "response.completed"
}
}
func intFromStreamValue(value any) int {
switch typed := value.(type) {
case int: