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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 SSE;stream=false 或未传返回兼容 JSON;该接口忽略 X-Async。
|
||||
// @Description /api/v1/chat/completions 同步执行:除 Gateway 路由字段外透明转发当前及未来 OpenAI 字段与嵌套结构,完整保留 function/custom tools、tool_choice、tool_calls 和旧版 function_call;stream=true 返回 text/event-stream SSE,stream=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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user