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
+144 -28
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -46,7 +47,7 @@ var geminiGenerateContentRoutePrefixes = []string{
}
func (s *Server) registerGeminiGenerateContentRoutes(mux *http.ServeMux) {
handler := s.requireUser(auth.PermissionBasic, http.HandlerFunc(s.geminiGenerateContent))
handler := s.requireProtocolUser(clients.ProtocolGeminiGenerateContent, http.HandlerFunc(s.geminiGenerateContent))
for _, prefix := range geminiGenerateContentRoutePrefixes {
mux.Handle("POST "+prefix, geminiGenerateContentRouteHandler(prefix, handler))
}
@@ -54,22 +55,37 @@ func (s *Server) registerGeminiGenerateContentRoutes(mux *http.ServeMux) {
func geminiGenerateContentRouteHandler(prefix string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
model, ok := geminiGenerateContentModelFromPath(prefix, r.URL.Path)
model, stream, ok := geminiContentModelFromPath(prefix, r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
r.SetPathValue("model", model)
r.SetPathValue("stream", strconv.FormatBool(stream))
next.ServeHTTP(w, r)
})
}
func geminiGenerateContentModelFromPath(prefix string, requestPath string) (string, bool) {
func geminiContentModelFromPath(prefix string, requestPath string) (string, bool, bool) {
if !strings.HasPrefix(requestPath, prefix) {
return "", false
return "", false, false
}
model, ok := strings.CutSuffix(strings.TrimPrefix(requestPath, prefix), ":generateContent")
if !ok || strings.TrimSpace(model) == "" || strings.Contains(model, "/") {
relative := strings.TrimPrefix(requestPath, prefix)
for _, action := range []struct {
suffix string
stream bool
}{{":generateContent", false}, {":streamGenerateContent", true}} {
model, ok := strings.CutSuffix(relative, action.suffix)
if ok && strings.TrimSpace(model) != "" && !strings.Contains(model, "/") {
return model, action.stream, true
}
}
return "", false, false
}
func geminiGenerateContentModelFromPath(prefix string, requestPath string) (string, bool) {
model, stream, ok := geminiContentModelFromPath(prefix, requestPath)
if !ok || stream {
return "", false
}
return model, true
@@ -81,38 +97,47 @@ func geminiGenerateContentModelFromPath(prefix string, requestPath string) (stri
// @Tags gemini-compatible
// @Accept json
// @Produce json
// @Produce text/event-stream
// @Security BearerAuth
// @Param model path string true "模型名称"
// @Param input body map[string]interface{} true "Gemini generateContent 请求"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Failure 400 {object} GeminiErrorEnvelope
// @Failure 401 {object} GeminiErrorEnvelope
// @Failure 403 {object} GeminiErrorEnvelope
// @Failure 502 {object} GeminiErrorEnvelope
// @Router /api/v1/models/{model}:generateContent [post]
// @Router /api/v1/models/{model}:streamGenerateContent [post]
func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
writeGeminiTaskError := func(status int, message string, details map[string]any, code string) {
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, status, message, details, code)
}
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
writeGeminiTaskError(http.StatusUnauthorized, "unauthorized", nil, "unauthorized")
return
}
var native map[string]any
if err := json.NewDecoder(r.Body).Decode(&native); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
writeGeminiTaskError(http.StatusBadRequest, "invalid json body", nil, "invalid_json_body")
return
}
mapping, err := geminiImageTaskBody(r.PathValue("model"), native)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
writeGeminiTaskError(http.StatusBadRequest, err.Error(), nil, clients.ErrorCode(err))
return
}
streamMode := r.PathValue("stream") == "true"
if streamMode {
mapping.Body["stream"] = true
}
if !apiKeyScopeAllowed(user, mapping.Kind) {
writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
writeGeminiTaskError(http.StatusForbidden, "api key scope does not allow this capability", nil, "permission_denied")
return
}
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
if err != nil {
writeError(w, http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", "invalid_idempotency_key")
writeGeminiTaskError(http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", nil, "invalid_idempotency_key")
return
}
prepared, err := s.prepareTaskRequest(r.Context(), r, user, mapping.Body)
@@ -122,7 +147,7 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
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))
writeGeminiTaskError(status, err.Error(), nil, clients.ErrorCode(err))
return
}
createInput := store.CreateTaskInput{
@@ -137,16 +162,16 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
}
if hasIdempotencyKey {
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(mapping.Kind, false, false, prepared.Body)
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(mapping.Kind, false, streamMode, prepared.Body)
}
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")
writeGeminiTaskError(http.StatusConflict, "Idempotency-Key was reused for a different request", nil, "idempotency_key_reused")
return
}
s.logger.Error("create gemini task failed", "kind", mapping.Kind, "error_category", "task_create_failed")
writeError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
writeGeminiTaskError(http.StatusInternalServerError, "create task failed", nil, "task_create_failed")
return
}
task := created.Task
@@ -154,31 +179,122 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
if s.billingMetrics != nil {
s.billingMetrics.ObserveBillingEvent("idempotent_replay")
}
w.Header().Set("Idempotent-Replayed", "true")
w.Header().Set("X-Gateway-Task-Id", task.ID)
if streamMode {
writeGeminiTaskError(http.StatusConflict, "streaming idempotent replay is not supported", nil, "idempotency_stream_replay_unsupported")
return
}
if task.Status == "succeeded" {
if _, native := task.Result["candidates"]; native {
writeJSON(w, http.StatusOK, task.Result)
return
}
writeJSON(w, http.StatusOK, geminiGenerateContentResponse(task.Result, mapping.Model))
return
}
writeIdempotentTaskReplay(w, task, true)
status := http.StatusConflict
if task.Status == "failed" || task.Status == "cancelled" {
status = storedTaskErrorStatus(task.ErrorCode)
}
writeGeminiTaskError(status, firstNonEmpty(task.ErrorMessage, task.Error, task.Message, "task is not complete"), nil, firstNonEmpty(task.ErrorCode, task.Status))
return
}
runCtx, cancelRun := s.requestExecutionContext(r)
defer cancelRun()
if streamMode {
s.writeGeminiGenerateContentStream(runCtx, w, r, task, user, mapping.Model, writeGeminiTaskError)
return
}
result, runErr := s.runner.Execute(runCtx, task, user)
if runErr != nil {
if !requestStillConnected(r) {
return
}
writeErrorWithDetails(w, statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
if wire := clients.ErrorWireResponse(runErr); wireResponseMatches(wire, clients.ProtocolGeminiGenerateContent) {
writeWireResponse(w, wire)
return
}
writeGeminiTaskError(statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
return
}
if !requestStillConnected(r) {
return
}
if wireResponseMatches(result.Wire, clients.ProtocolGeminiGenerateContent) {
writeWireResponse(w, result.Wire)
return
}
writeJSON(w, http.StatusOK, geminiGenerateContentResponse(result.Output, mapping.Model))
}
func (s *Server) writeGeminiGenerateContentStream(runCtx context.Context, w http.ResponseWriter, r *http.Request, task store.GatewayTask, user *auth.User, model string, writeGeminiTaskError func(int, string, map[string]any, string)) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher, _ := w.(http.Flusher)
nativePassthrough := false
convertedFrames := false
result, runErr := s.runner.ExecuteStream(runCtx, task, user, func(delta clients.StreamDeltaEvent) error {
if !requestStillConnected(r) {
return nil
}
if delta.Event != nil && delta.WireProtocol == clients.ProtocolGeminiGenerateContent && !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)
}
}
}
nativePassthrough = true
writeGeminiSSEFrame(w, delta.Event)
} else if delta.Text != "" {
convertedFrames = true
writeGeminiSSEFrame(w, geminiTextStreamFrame(delta.Text))
}
if flusher != nil {
flusher.Flush()
}
return nil
})
if runErr != nil {
if !requestStillConnected(r) {
return
}
if !nativePassthrough && !convertedFrames {
if wire := clients.ErrorWireResponse(runErr); wireResponseMatches(wire, clients.ProtocolGeminiGenerateContent) {
writeWireResponse(w, wire)
return
}
writeGeminiTaskError(statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
} else if !nativePassthrough {
status := statusFromRunError(runErr)
writeGeminiSSEFrame(w, geminiErrorEnvelope(status, runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr)))
if flusher != nil {
flusher.Flush()
}
}
return
}
if !nativePassthrough && !convertedFrames && requestStillConnected(r) {
writeGeminiSSEFrame(w, geminiGenerateContentResponse(result.Output, model))
if flusher != nil {
flusher.Flush()
}
}
}
func writeGeminiSSEFrame(w http.ResponseWriter, payload map[string]any) {
raw, _ := json.Marshal(payload)
_, _ = fmt.Fprintf(w, "data: %s\n\n", raw)
}
func geminiTextStreamFrame(text string) map[string]any {
return map[string]any{"candidates": []any{map[string]any{
"content": map[string]any{"role": "model", "parts": []any{map[string]any{"text": text}}},
}}}
}
func geminiImageTaskBody(model string, native map[string]any) (geminiImageTaskMapping, error) {
model = strings.TrimSpace(model)
if model == "" {
@@ -424,7 +540,7 @@ func geminiUsageMetadataFromOutput(output map[string]any) map[string]any {
// @Router /api/v1/gemini/upload/{version}/files [post]
func (s *Server) geminiFilesUpload(w http.ResponseWriter, r *http.Request) {
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, http.StatusBadRequest, err.Error(), nil, clients.ErrorCode(err))
return
}
if geminiUploadCommandHas(r, "start") {
@@ -453,13 +569,13 @@ func (s *Server) geminiFilesUpload(w http.ResponseWriter, r *http.Request) {
// @Router /api/v1/gemini/upload/{version}/files/{uploadID} [post]
func (s *Server) geminiFilesUploadFinalize(w http.ResponseWriter, r *http.Request) {
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, http.StatusBadRequest, err.Error(), nil, clients.ErrorCode(err))
return
}
uploadID := strings.TrimSpace(r.PathValue("uploadID"))
value, ok := s.geminiUploadSessions.LoadAndDelete(uploadID)
if !ok {
writeError(w, http.StatusBadRequest, "Gemini upload session not found", "bad_request")
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, http.StatusBadRequest, "Gemini upload session not found", nil, "bad_request")
return
}
session, _ := value.(geminiUploadSession)
@@ -498,7 +614,7 @@ func (s *Server) finalizeGeminiFilesUpload(w http.ResponseWriter, r *http.Reques
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
payload, err := io.ReadAll(r.Body)
if err != nil || len(payload) == 0 {
writeError(w, http.StatusBadRequest, "file payload is required", "bad_request")
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, http.StatusBadRequest, "file payload is required", nil, "bad_request")
return
}
mimeType := firstNonEmptyGeminiString(session.MimeType, http.DetectContentType(payload), "application/octet-stream")
@@ -515,7 +631,7 @@ func (s *Server) finalizeGeminiFilesUpload(w http.ResponseWriter, r *http.Reques
if clients.ErrorCode(err) == "upload_no_channel" {
status = http.StatusServiceUnavailable
}
writeError(w, status, err.Error(), clients.ErrorCode(err))
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, status, err.Error(), nil, clients.ErrorCode(err))
return
}
sizeBytes := session.SizeBytes