feat(api): 统一官方兼容接口响应协议

兼容接口现在以入口协议作为最终响应协议,同协议保留官方 Wire 响应,跨协议统一转换成功、任务状态与错误结构。

同时修正异步提交状态边界,持久化兼容公开任务标识和官方提交响应,并新增迁移、流式响应及协议契约测试。

验证:go vet ./...;go test ./...;govulncheck ./...;pnpm lint;pnpm test;pnpm build;pnpm audit --audit-level high;pnpm openapi;全部 CI 脚本。
This commit is contained in:
2026-07-22 15:34:59 +08:00
parent 42e8b517fd
commit e07a997aa9
32 changed files with 2436 additions and 591 deletions
+151 -50
View File
@@ -1038,10 +1038,7 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Failure 404 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/embeddings [post]
// @Router /api/v1/reranks [post]
// @Router /api/v1/images/generations [post]
// @Router /api/v1/images/edits [post]
// @Router /api/v1/videos/generations [post]
// @Router /api/v1/song/generations [post]
// @Router /api/v1/music/generations [post]
@@ -1049,9 +1046,21 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Router /api/v1/voice_clone [post]
func (s *Server) createTask(kind string, compatible bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetProtocol := targetProtocolForTaskRequest(kind, r)
writeTaskError := func(status int, message string, details map[string]any, codes ...string) {
if targetProtocol == "" {
writeErrorWithDetails(w, status, message, details, codes...)
return
}
code := ""
if len(codes) > 0 {
code = codes[0]
}
writeProtocolError(w, targetProtocol, status, message, details, code)
}
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
writeTaskError(http.StatusUnauthorized, "unauthorized", nil)
return
}
@@ -1061,32 +1070,37 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
if code := clients.ErrorCode(err); strings.HasPrefix(code, "upload_") || code == "request_asset_upload_failed" || code == "request_asset_public_url_required" {
status = http.StatusBadGateway
}
writeError(w, status, err.Error(), clients.ErrorCode(err))
writeTaskError(status, err.Error(), nil, clients.ErrorCode(err))
return
}
if kind == "chat.completions" || kind == "responses" {
if err := clients.ValidateOpenAIRequestParameters(kind, body); err != nil {
writeErrorWithDetails(w, http.StatusBadRequest, err.Error(), map[string]any{"param": clients.ErrorParam(err)}, clients.ErrorCode(err))
writeTaskError(http.StatusBadRequest, err.Error(), map[string]any{"param": clients.ErrorParam(err)}, clients.ErrorCode(err))
return
}
}
requestedModel := requestModelName(body)
model := canonicalTaskModelName(kind, requestedModel)
if model == "" {
writeError(w, http.StatusBadRequest, "model is required")
writeTaskError(http.StatusBadRequest, "model is required", nil, "invalid_parameter")
return
}
if model != requestedModel {
body["model"] = model
}
if !apiKeyScopeAllowed(user, kind) {
writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
writeTaskError(http.StatusForbidden, "api key scope does not allow this capability", nil, "permission_denied")
return
}
responsePlan := planTaskResponse(kind, compatible, body, r)
if targetProtocol != "" {
// OpenAI compatibility endpoints are synchronous APIs. Gateway async
// task envelopes belong only to native Gateway routes.
responsePlan.asyncMode = false
}
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
if err != nil {
writeError(w, http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", "invalid_idempotency_key")
writeTaskError(http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", nil, "invalid_idempotency_key")
return
}
prepared, err := s.prepareTaskRequest(r.Context(), r, user, body)
@@ -1096,7 +1110,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
if code := clients.ErrorCode(err); strings.HasPrefix(code, "upload_") || code == "request_asset_upload_failed" {
status = http.StatusBadGateway
}
writeError(w, status, err.Error(), clients.ErrorCode(err))
writeTaskError(status, err.Error(), nil, clients.ErrorCode(err))
return
}
@@ -1117,11 +1131,11 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
if err != nil {
if errors.Is(err, store.ErrIdempotencyKeyReused) {
writeError(w, http.StatusConflict, "Idempotency-Key was reused for a different request", "idempotency_key_reused")
writeTaskError(http.StatusConflict, "Idempotency-Key was reused for a different request", nil, "idempotency_key_reused")
return
}
s.logger.Error("create task failed", "kind", kind, "error_category", "task_create_failed")
writeError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
writeTaskError(http.StatusInternalServerError, "create task failed", nil, "task_create_failed")
return
}
task := created.Task
@@ -1129,9 +1143,23 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
if s.billingMetrics != nil {
s.billingMetrics.ObserveBillingEvent("idempotent_replay")
}
w.Header().Set("Idempotent-Replayed", "true")
if targetProtocol == "" {
w.Header().Set("Idempotent-Replayed", "true")
}
if responsePlan.streamMode {
writeError(w, http.StatusConflict, "streaming idempotent replay is not supported", "idempotency_stream_replay_unsupported")
writeTaskError(http.StatusConflict, "streaming idempotent replay is not supported", nil, "idempotency_stream_replay_unsupported")
return
}
if targetProtocol != "" {
if task.Status == "succeeded" {
writeJSON(w, http.StatusOK, task.Result)
return
}
status := http.StatusConflict
if task.Status == "failed" || task.Status == "cancelled" {
status = storedTaskErrorStatus(task.ErrorCode)
}
writeTaskError(status, firstNonEmpty(task.ErrorMessage, task.Error, task.Message, "task is not complete"), nil, firstNonEmpty(task.ErrorCode, task.Status))
return
}
writeIdempotentTaskReplay(w, task, responsePlan.compatibleMode)
@@ -1139,7 +1167,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
}
if responsePlan.asyncMode {
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "enqueue_failed")
writeTaskError(http.StatusInternalServerError, err.Error(), nil, "enqueue_failed")
return
}
writeTaskAccepted(w, task)
@@ -1148,7 +1176,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
runCtx, cancelRun := s.requestExecutionContext(r)
defer cancelRun()
if responsePlan.compatibleMode {
writeCompatibleTaskResponse(runCtx, w, r, s.runner, kind, model, task, user, responsePlan.streamMode, streamIncludeUsage(body))
writeProtocolCompatibleTaskResponse(runCtx, w, r, s.runner, kind, model, targetProtocol, task, user, responsePlan.streamMode, streamIncludeUsage(body))
return
}
result, runErr := s.runner.Execute(runCtx, task, user)
@@ -1174,13 +1202,13 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
// @Param X-Async header bool false "该接口忽略此参数"
// @Param input body ChatCompletionRequest true "Chat Completions 请求"
// @Success 200 {object} ChatCompletionCompatibleResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 402 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Failure 400 {object} OpenAIErrorEnvelope
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 402 {object} OpenAIErrorEnvelope
// @Failure 403 {object} OpenAIErrorEnvelope
// @Failure 404 {object} OpenAIErrorEnvelope
// @Failure 429 {object} OpenAIErrorEnvelope
// @Failure 502 {object} OpenAIErrorEnvelope
// @Router /api/v1/chat/completions [post]
func (s *Server) createAPIV1ChatCompletions() http.Handler {
return s.createTask("chat.completions", false)
@@ -1196,12 +1224,12 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
// @Security BearerAuth
// @Param input body ChatCompletionRequest true "Chat Completions 请求"
// @Success 200 {object} ChatCompletionCompatibleResponse
// @Failure 400 {object} ErrorEnvelope "invalid_parameter"
// @Failure 401 {object} ErrorEnvelope
// @Failure 402 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Failure 400 {object} OpenAIErrorEnvelope "invalid_parameter"
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 402 {object} OpenAIErrorEnvelope
// @Failure 403 {object} OpenAIErrorEnvelope
// @Failure 429 {object} OpenAIErrorEnvelope
// @Failure 502 {object} OpenAIErrorEnvelope
func openAIChatCompletionsDoc() {}
// openAIResponsesDoc godoc
@@ -1214,15 +1242,45 @@ func openAIChatCompletionsDoc() {}
// @Security BearerAuth
// @Param input body ResponsesRequest true "Responses 请求;Chat 回退只支持自定义 function tools"
// @Success 200 {object} ResponsesCompatibleResponse
// @Header 200 {string} X-Gateway-Task-Id "网关审计任务 ID"
// @Failure 400 {object} ErrorEnvelope "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter"
// @Failure 401 {object} ErrorEnvelope
// @Failure 402 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope "response_chain_unavailable"
// @Failure 400 {object} OpenAIErrorEnvelope "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter"
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 402 {object} OpenAIErrorEnvelope
// @Failure 403 {object} OpenAIErrorEnvelope
// @Failure 503 {object} OpenAIErrorEnvelope "response_chain_unavailable"
// @Router /api/v1/responses [post]
func openAIResponsesDoc() {}
// openAIEmbeddingsDoc godoc
// @Summary 创建 OpenAI Embeddings
// @Tags embeddings
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param input body TaskRequest true "OpenAI Embeddings 请求"
// @Success 200 {object} CompatibleResponse
// @Failure 400 {object} OpenAIErrorEnvelope
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 429 {object} OpenAIErrorEnvelope
// @Failure 502 {object} OpenAIErrorEnvelope
// @Router /api/v1/embeddings [post]
func openAIEmbeddingsDoc() {}
// openAIImagesDoc godoc
// @Summary 创建或编辑 OpenAI Images
// @Tags images
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param input body TaskRequest true "OpenAI Images 请求"
// @Success 200 {object} CompatibleResponse
// @Failure 400 {object} OpenAIErrorEnvelope
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 429 {object} OpenAIErrorEnvelope
// @Failure 502 {object} OpenAIErrorEnvelope
// @Router /api/v1/images/generations [post]
// @Router /api/v1/images/edits [post]
func openAIImagesDoc() {}
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
base := context.WithoutCancel(r.Context())
if s.ctx == nil {
@@ -1254,14 +1312,44 @@ type taskExecutor interface {
}
func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter, r *http.Request, executor taskExecutor, kind string, model string, task store.GatewayTask, user *auth.User, streamMode bool, includeUsage bool) {
w.Header().Set("X-Gateway-Task-Id", task.ID)
writeProtocolCompatibleTaskResponse(runCtx, w, r, executor, kind, model, "", task, user, streamMode, includeUsage)
}
func writeProtocolCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter, r *http.Request, executor taskExecutor, kind string, model string, targetProtocol string, task store.GatewayTask, user *auth.User, streamMode bool, includeUsage bool) {
if targetProtocol == "" {
w.Header().Set("X-Gateway-Task-Id", task.ID)
}
if streamMode {
flusher := prepareCompatibleStream(w)
streamWriter := newCompatibleStreamWriter(kind, model, includeUsage)
nativeStreamPassthrough := false
result, runErr := executor.ExecuteStream(runCtx, task, user, func(delta clients.StreamDeltaEvent) error {
if !requestStillConnected(r) {
return nil
}
if targetProtocol != "" && delta.Event != nil && delta.WireProtocol == targetProtocol && !delta.WireConverted {
for name, values := range delta.WireHeaders {
for _, value := range values {
if strings.EqualFold(name, "Content-Type") {
w.Header().Set(name, value)
} else {
w.Header().Add(name, value)
}
}
}
nativeStreamPassthrough = true
if kind == "responses" {
eventType, _ := delta.Event["type"].(string)
sendSSE(w, eventType, delta.Event)
} else {
raw, _ := json.Marshal(delta.Event)
_, _ = fmt.Fprintf(w, "data: %s\n\n", raw)
}
if flusher != nil {
flusher.Flush()
}
return nil
}
streamWriter.writeDelta(w, delta)
if flusher != nil {
flusher.Flush()
@@ -1272,20 +1360,12 @@ func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter,
if !requestStillConnected(r) {
return
}
status := statusFromRunError(runErr)
errorPayload := map[string]any{
"code": runErrorCode(runErr),
"message": runErrorMessage(runErr),
"status": status,
errorPayload := map[string]any{"message": runErrorMessage(runErr), "type": "server_error", "param": nil, "code": runErrorCode(runErr)}
if status := statusFromRunError(runErr); status >= 400 && status < 500 {
errorPayload["type"] = "invalid_request_error"
}
if result.Task.ID != "" {
errorPayload["taskId"] = result.Task.ID
}
if result.Task.RequestID != "" {
errorPayload["requestId"] = result.Task.RequestID
}
for key, value := range runErrorDetails(runErr) {
errorPayload[key] = value
if param := runErrorDetails(runErr)["param"]; param != nil {
errorPayload["param"] = param
}
sendSSE(w, "error", map[string]any{"error": errorPayload})
if flusher != nil {
@@ -1296,6 +1376,15 @@ func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter,
if !requestStillConnected(r) {
return
}
if nativeStreamPassthrough {
if kind != "responses" {
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
}
if flusher != nil {
flusher.Flush()
}
return
}
streamWriter.writeDone(w, result.Output)
if flusher != nil {
flusher.Flush()
@@ -1308,12 +1397,24 @@ func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter,
if !requestStillConnected(r) {
return
}
if wire := clients.ErrorWireResponse(runErr); wireResponseMatches(wire, targetProtocol) {
writeWireResponse(w, wire)
return
}
if targetProtocol != "" {
writeProtocolError(w, targetProtocol, statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
return
}
writeErrorWithDetails(w, statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
return
}
if !requestStillConnected(r) {
return
}
if wireResponseMatches(result.Wire, targetProtocol) {
writeWireResponse(w, result.Wire)
return
}
writeJSON(w, http.StatusOK, result.Output)
}
@@ -1482,7 +1583,7 @@ func statusFromRunError(err error) int {
return http.StatusBadRequest
case clients.ErrorCode(err) == "response_chain_unavailable":
return http.StatusServiceUnavailable
case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "invalid_parameter" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable" || clients.ErrorCode(err) == "cloned_voice_platform_unavailable" || clients.ErrorCode(err) == "unsupported_operation" || clients.ErrorCode(err) == "invalid_proxy":
case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "invalid_parameter" || clients.ErrorCode(err) == "unsupported_kind" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable" || clients.ErrorCode(err) == "cloned_voice_platform_unavailable" || clients.ErrorCode(err) == "unsupported_operation" || clients.ErrorCode(err) == "invalid_proxy":
return http.StatusBadRequest
case store.ModelCandidateErrorCode(err) == "invalid_parameter":
return http.StatusBadRequest