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