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