兼容接口现在以入口协议作为最终响应协议,同协议保留官方 Wire 响应,跨协议统一转换成功、任务状态与错误结构。 同时修正异步提交状态边界,持久化兼容公开任务标识和官方提交响应,并新增迁移、流式响应及协议契约测试。 验证:go vet ./...;go test ./...;govulncheck ./...;pnpm lint;pnpm test;pnpm build;pnpm audit --audit-level high;pnpm openapi;全部 CI 脚本。
794 lines
26 KiB
Go
794 lines
26 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"mime"
|
||
"net/http"
|
||
"path"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||
)
|
||
|
||
type geminiImageTaskMapping struct {
|
||
Kind string
|
||
Body map[string]any
|
||
Model string
|
||
}
|
||
|
||
type geminiImageInput struct {
|
||
URI string
|
||
MimeType string
|
||
}
|
||
|
||
type geminiUploadSession struct {
|
||
Version string
|
||
DisplayName string
|
||
MimeType string
|
||
SizeBytes int64
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
var geminiGenerateContentRoutePrefixes = []string{
|
||
"/api/v1/models/",
|
||
"/v1beta/models/",
|
||
"/v1/models/",
|
||
}
|
||
|
||
func (s *Server) registerGeminiGenerateContentRoutes(mux *http.ServeMux) {
|
||
handler := s.requireProtocolUser(clients.ProtocolGeminiGenerateContent, http.HandlerFunc(s.geminiGenerateContent))
|
||
for _, prefix := range geminiGenerateContentRoutePrefixes {
|
||
mux.Handle("POST "+prefix, geminiGenerateContentRouteHandler(prefix, handler))
|
||
}
|
||
}
|
||
|
||
func geminiGenerateContentRouteHandler(prefix string, next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
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 geminiContentModelFromPath(prefix string, requestPath string) (string, bool, bool) {
|
||
if !strings.HasPrefix(requestPath, prefix) {
|
||
return "", false, false
|
||
}
|
||
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
|
||
}
|
||
|
||
// geminiGenerateContent godoc
|
||
// @Summary Gemini generateContent 兼容接口
|
||
// @Description 使用统一 /api/v1 前缀接收 Gemini generateContent 请求;旧 /v1 和 /v1beta 路径保留兼容。
|
||
// @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} 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 {
|
||
writeGeminiTaskError(http.StatusUnauthorized, "unauthorized", nil, "unauthorized")
|
||
return
|
||
}
|
||
var native map[string]any
|
||
if err := json.NewDecoder(r.Body).Decode(&native); err != nil {
|
||
writeGeminiTaskError(http.StatusBadRequest, "invalid json body", nil, "invalid_json_body")
|
||
return
|
||
}
|
||
mapping, err := geminiImageTaskBody(r.PathValue("model"), native)
|
||
if err != nil {
|
||
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) {
|
||
writeGeminiTaskError(http.StatusForbidden, "api key scope does not allow this capability", nil, "permission_denied")
|
||
return
|
||
}
|
||
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
|
||
if err != nil {
|
||
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)
|
||
if err != nil {
|
||
s.logger.Warn("prepare gemini task request failed", "kind", mapping.Kind, "error", err)
|
||
status := http.StatusBadRequest
|
||
if code := clients.ErrorCode(err); strings.HasPrefix(code, "upload_") || code == "request_asset_upload_failed" {
|
||
status = http.StatusBadGateway
|
||
}
|
||
writeGeminiTaskError(status, err.Error(), nil, clients.ErrorCode(err))
|
||
return
|
||
}
|
||
createInput := store.CreateTaskInput{
|
||
Kind: mapping.Kind,
|
||
Model: mapping.Model,
|
||
RunMode: runModeFromRequest(prepared.Body),
|
||
Async: false,
|
||
Request: prepared.Body,
|
||
ConversationID: prepared.ConversationID,
|
||
NewMessageCount: prepared.NewMessageCount,
|
||
MessageRefs: prepared.MessageRefs,
|
||
}
|
||
if hasIdempotencyKey {
|
||
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
|
||
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) {
|
||
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")
|
||
writeGeminiTaskError(http.StatusInternalServerError, "create task failed", nil, "task_create_failed")
|
||
return
|
||
}
|
||
task := created.Task
|
||
if created.Replayed {
|
||
if s.billingMetrics != nil {
|
||
s.billingMetrics.ObserveBillingEvent("idempotent_replay")
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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 == "" {
|
||
return geminiImageTaskMapping{}, &clients.ClientError{Code: "bad_request", Message: "model is required", Retryable: false}
|
||
}
|
||
if native == nil {
|
||
native = map[string]any{}
|
||
}
|
||
prompt := geminiPromptFromNative(native)
|
||
imageInputs := geminiImageInputsFromNative(native)
|
||
body := cloneMap(native)
|
||
body["model"] = model
|
||
body["prompt"] = prompt
|
||
if count := geminiCandidateCount(native); count > 0 {
|
||
body["n"] = count
|
||
}
|
||
if aspectRatio, imageSize := geminiImageConfig(native); aspectRatio != "" {
|
||
body["aspect_ratio"] = aspectRatio
|
||
} else if imageSize != "" {
|
||
body["resolution"] = imageSize
|
||
}
|
||
if _, imageSize := geminiImageConfig(native); imageSize != "" {
|
||
body["resolution"] = imageSize
|
||
}
|
||
if len(imageInputs) == 0 {
|
||
body["modelType"] = "image_generate"
|
||
return geminiImageTaskMapping{Kind: "images.generations", Body: body, Model: model}, nil
|
||
}
|
||
images := make([]any, 0, len(imageInputs))
|
||
for _, input := range imageInputs {
|
||
images = append(images, input.URI)
|
||
}
|
||
if len(images) == 1 {
|
||
body["image"] = images[0]
|
||
} else {
|
||
body["image"] = images
|
||
}
|
||
body["modelType"] = "image_edit"
|
||
return geminiImageTaskMapping{Kind: "images.edits", Body: body, Model: model}, nil
|
||
}
|
||
|
||
func geminiPromptFromNative(body map[string]any) string {
|
||
parts := []string{}
|
||
for _, part := range geminiOrderedNativeParts(body) {
|
||
if text := strings.TrimSpace(stringFromRequestAny(part["text"])); text != "" {
|
||
parts = append(parts, text)
|
||
}
|
||
}
|
||
if len(parts) > 0 {
|
||
return strings.Join(parts, "\n")
|
||
}
|
||
return firstNonEmptyRequestString(body, "prompt", "positive", "input")
|
||
}
|
||
|
||
func geminiImageInputsFromNative(body map[string]any) []geminiImageInput {
|
||
inputs := []geminiImageInput{}
|
||
for _, part := range geminiOrderedNativeParts(body) {
|
||
inline := mapFromGeminiAny(firstPresentGemini(part["inlineData"], part["inline_data"]))
|
||
if data := strings.TrimSpace(stringFromRequestAny(inline["data"])); data != "" {
|
||
mimeType := firstNonEmptyGeminiString(inline["mimeType"], inline["mime_type"])
|
||
if mimeType == "" {
|
||
mimeType = "image/png"
|
||
}
|
||
if !strings.HasPrefix(strings.ToLower(data), "data:") {
|
||
data = "data:" + mimeType + ";base64," + data
|
||
}
|
||
inputs = append(inputs, geminiImageInput{URI: data, MimeType: mimeType})
|
||
continue
|
||
}
|
||
fileData := mapFromGeminiAny(firstPresentGemini(part["fileData"], part["file_data"]))
|
||
if fileURI := firstNonEmptyGeminiString(fileData["fileUri"], fileData["file_uri"], fileData["uri"]); fileURI != "" {
|
||
inputs = append(inputs, geminiImageInput{
|
||
URI: fileURI,
|
||
MimeType: firstNonEmptyGeminiString(fileData["mimeType"], fileData["mime_type"]),
|
||
})
|
||
}
|
||
}
|
||
return inputs
|
||
}
|
||
|
||
func geminiOrderedNativeParts(body map[string]any) []map[string]any {
|
||
contents := normalizeGeminiContents(body["contents"])
|
||
parts := []map[string]any{}
|
||
for _, content := range contents {
|
||
contentMap := mapFromGeminiAny(content)
|
||
rawParts, _ := contentMap["parts"].([]any)
|
||
if len(rawParts) == 0 {
|
||
if text := stringFromRequestAny(contentMap["text"]); text != "" {
|
||
rawParts = []any{map[string]any{"text": text}}
|
||
}
|
||
}
|
||
for _, rawPart := range rawParts {
|
||
switch typed := rawPart.(type) {
|
||
case string:
|
||
parts = append(parts, map[string]any{"text": typed})
|
||
case map[string]any:
|
||
parts = append(parts, typed)
|
||
}
|
||
}
|
||
}
|
||
return parts
|
||
}
|
||
|
||
func normalizeGeminiContents(value any) []any {
|
||
switch typed := value.(type) {
|
||
case []any:
|
||
out := make([]any, 0, len(typed))
|
||
for _, item := range typed {
|
||
if text, ok := item.(string); ok {
|
||
out = append(out, map[string]any{"parts": []any{map[string]any{"text": text}}})
|
||
} else {
|
||
out = append(out, item)
|
||
}
|
||
}
|
||
return out
|
||
case string:
|
||
return []any{map[string]any{"parts": []any{map[string]any{"text": typed}}}}
|
||
case map[string]any:
|
||
return []any{typed}
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func geminiCandidateCount(body map[string]any) int {
|
||
generationConfig := mapFromGeminiAny(firstPresentGemini(body["generationConfig"], body["generation_config"]))
|
||
for _, value := range []any{body["n"], body["candidateCount"], body["candidate_count"], generationConfig["n"], generationConfig["candidateCount"], generationConfig["candidate_count"]} {
|
||
if count := positiveIntFromGeminiAny(value); count > 0 {
|
||
return count
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func geminiImageConfig(body map[string]any) (string, string) {
|
||
generationConfig := mapFromGeminiAny(firstPresentGemini(body["generationConfig"], body["generation_config"]))
|
||
imageConfig := mapFromGeminiAny(firstPresentGemini(generationConfig["imageConfig"], generationConfig["image_config"]))
|
||
return firstNonEmptyGeminiString(imageConfig["aspectRatio"], imageConfig["aspect_ratio"]),
|
||
firstNonEmptyGeminiString(imageConfig["imageSize"], imageConfig["image_size"])
|
||
}
|
||
|
||
func geminiGenerateContentResponse(output map[string]any, model string) map[string]any {
|
||
parts := geminiPartsFromOutput(output)
|
||
response := map[string]any{
|
||
"candidates": []any{map[string]any{
|
||
"index": 0,
|
||
"content": map[string]any{
|
||
"role": "model",
|
||
"parts": parts,
|
||
},
|
||
"finishReason": "STOP",
|
||
}},
|
||
"modelVersion": model,
|
||
}
|
||
if usage := geminiUsageMetadataFromOutput(output); len(usage) > 0 {
|
||
response["usageMetadata"] = usage
|
||
}
|
||
return response
|
||
}
|
||
|
||
func geminiPartsFromOutput(output map[string]any) []any {
|
||
parts := []any{}
|
||
data, _ := output["data"].([]any)
|
||
for _, rawItem := range data {
|
||
item, _ := rawItem.(map[string]any)
|
||
if item == nil {
|
||
continue
|
||
}
|
||
if text := firstNonEmptyGeminiString(item["text"], item["content"]); text != "" {
|
||
parts = append(parts, map[string]any{"text": text})
|
||
continue
|
||
}
|
||
mimeType := firstNonEmptyGeminiString(item["mime_type"], item["mimeType"])
|
||
if b64 := strings.TrimSpace(stringFromRequestAny(item["b64_json"])); b64 != "" {
|
||
if parsed := parseGeminiDataURL(b64); parsed != nil {
|
||
mimeType = firstNonEmptyGeminiString(mimeType, parsed.MimeType)
|
||
b64 = parsed.Data
|
||
}
|
||
parts = append(parts, map[string]any{"inlineData": map[string]any{
|
||
"mimeType": firstNonEmptyGeminiString(mimeType, "image/png"),
|
||
"data": b64,
|
||
}})
|
||
continue
|
||
}
|
||
upload := mapFromGeminiAny(item["upload"])
|
||
if uri := firstNonEmptyGeminiString(item["url"], item["image_url"], upload["url"]); uri != "" {
|
||
parts = append(parts, map[string]any{"fileData": map[string]any{
|
||
"mimeType": firstNonEmptyGeminiString(mimeType, mimeTypeFromGeminiURI(uri)),
|
||
"fileUri": uri,
|
||
}})
|
||
}
|
||
}
|
||
if len(parts) == 0 {
|
||
if outputs, ok := output["output"].([]any); ok {
|
||
for _, raw := range outputs {
|
||
if uri := stringFromRequestAny(raw); uri != "" {
|
||
parts = append(parts, map[string]any{"fileData": map[string]any{
|
||
"mimeType": mimeTypeFromGeminiURI(uri),
|
||
"fileUri": uri,
|
||
}})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if len(parts) == 0 {
|
||
parts = append(parts, map[string]any{"text": firstNonEmptyGeminiString(output["message"])})
|
||
}
|
||
return parts
|
||
}
|
||
|
||
func geminiUsageMetadataFromOutput(output map[string]any) map[string]any {
|
||
usage := mapFromGeminiAny(output["usage"])
|
||
prompt := positiveIntFromGeminiAny(firstPresentGemini(usage["prompt_tokens"], usage["input_tokens"]))
|
||
candidates := positiveIntFromGeminiAny(firstPresentGemini(usage["completion_tokens"], usage["output_tokens"]))
|
||
total := positiveIntFromGeminiAny(usage["total_tokens"])
|
||
if total == 0 && (prompt > 0 || candidates > 0) {
|
||
total = prompt + candidates
|
||
}
|
||
meta := map[string]any{}
|
||
if prompt > 0 {
|
||
meta["promptTokenCount"] = prompt
|
||
}
|
||
if candidates > 0 {
|
||
meta["candidatesTokenCount"] = candidates
|
||
}
|
||
if total > 0 {
|
||
meta["totalTokenCount"] = total
|
||
}
|
||
return meta
|
||
}
|
||
|
||
// geminiFilesUpload godoc
|
||
// @Summary Gemini Files 上传接口
|
||
// @Description 使用统一 /api/v1 前缀启动或直接完成 Gemini Files 上传。
|
||
// @Tags gemini-compatible
|
||
// @Accept octet-stream
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param version path string true "Gemini 版本(v1 或 v1beta)"
|
||
// @Success 200 {object} map[string]interface{}
|
||
// @Failure 400 {object} ErrorEnvelope
|
||
// @Failure 401 {object} ErrorEnvelope
|
||
// @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 {
|
||
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, http.StatusBadRequest, err.Error(), nil, clients.ErrorCode(err))
|
||
return
|
||
}
|
||
if geminiUploadCommandHas(r, "start") {
|
||
s.startGeminiFilesUpload(w, r)
|
||
return
|
||
}
|
||
s.finalizeGeminiFilesUpload(w, r, "", geminiUploadSession{
|
||
Version: r.PathValue("version"),
|
||
DisplayName: "gemini-upload-" + newGeminiUploadID(),
|
||
MimeType: r.Header.Get("Content-Type"),
|
||
CreatedAt: time.Now(),
|
||
})
|
||
}
|
||
|
||
// geminiFilesUploadFinalize godoc
|
||
// @Summary 完成 Gemini Files 分段上传
|
||
// @Tags gemini-compatible
|
||
// @Accept octet-stream
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param version path string true "Gemini 版本(v1 或 v1beta)"
|
||
// @Param uploadID path string true "上传会话 ID"
|
||
// @Success 200 {object} map[string]interface{}
|
||
// @Failure 400 {object} ErrorEnvelope
|
||
// @Failure 401 {object} ErrorEnvelope
|
||
// @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 {
|
||
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 {
|
||
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, http.StatusBadRequest, "Gemini upload session not found", nil, "bad_request")
|
||
return
|
||
}
|
||
session, _ := value.(geminiUploadSession)
|
||
s.finalizeGeminiFilesUpload(w, r, uploadID, session)
|
||
}
|
||
|
||
func (s *Server) startGeminiFilesUpload(w http.ResponseWriter, r *http.Request) {
|
||
var body map[string]any
|
||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||
fileMeta := mapFromGeminiAny(body["file"])
|
||
uploadID := newGeminiUploadID()
|
||
session := geminiUploadSession{
|
||
Version: r.PathValue("version"),
|
||
DisplayName: firstNonEmptyGeminiString(fileMeta["display_name"], fileMeta["displayName"], "gemini-upload-"+uploadID),
|
||
MimeType: firstNonEmptyGeminiString(r.Header.Get("X-Goog-Upload-Header-Content-Type"), "application/octet-stream"),
|
||
SizeBytes: int64(positiveIntFromGeminiAny(r.Header.Get("X-Goog-Upload-Header-Content-Length"))),
|
||
CreatedAt: time.Now(),
|
||
}
|
||
s.geminiUploadSessions.Store(uploadID, session)
|
||
w.Header().Set("X-Goog-Upload-URL", absoluteRequestURL(r, geminiUploadPath(r, session.Version, uploadID)))
|
||
w.Header().Set("X-Goog-Upload-Status", "active")
|
||
writeJSON(w, http.StatusOK, map[string]any{})
|
||
}
|
||
|
||
func geminiUploadPath(r *http.Request, version string, uploadID string) string {
|
||
if strings.HasPrefix(r.URL.Path, "/api/v1/gemini/upload/") {
|
||
return "/api/v1/gemini/upload/" + version + "/files/" + uploadID
|
||
}
|
||
return "/upload/" + version + "/files/" + uploadID
|
||
}
|
||
|
||
func (s *Server) finalizeGeminiFilesUpload(w http.ResponseWriter, r *http.Request, uploadID string, session geminiUploadSession) {
|
||
if uploadID == "" {
|
||
uploadID = newGeminiUploadID()
|
||
}
|
||
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
|
||
payload, err := io.ReadAll(r.Body)
|
||
if err != nil || len(payload) == 0 {
|
||
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, http.StatusBadRequest, "file payload is required", nil, "bad_request")
|
||
return
|
||
}
|
||
mimeType := firstNonEmptyGeminiString(session.MimeType, http.DetectContentType(payload), "application/octet-stream")
|
||
upload, err := s.runner.UploadFile(r.Context(), runner.FileUploadPayload{
|
||
Bytes: payload,
|
||
ContentType: mimeType,
|
||
FileName: geminiUploadFileName(uploadID, session.DisplayName, mimeType),
|
||
Source: "gemini-files-api",
|
||
Scene: store.FileStorageSceneUpload,
|
||
})
|
||
if err != nil {
|
||
s.logger.Error("Gemini files upload failed", "error", err)
|
||
status := http.StatusBadGateway
|
||
if clients.ErrorCode(err) == "upload_no_channel" {
|
||
status = http.StatusServiceUnavailable
|
||
}
|
||
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, status, err.Error(), nil, clients.ErrorCode(err))
|
||
return
|
||
}
|
||
sizeBytes := session.SizeBytes
|
||
if sizeBytes <= 0 {
|
||
sizeBytes = int64(len(payload))
|
||
}
|
||
writeJSON(w, http.StatusOK, geminiFileResponse("files/"+uploadID, stringFromRequestAny(upload["url"]), mimeType, sizeBytes))
|
||
}
|
||
|
||
func geminiFileResponse(name string, uri string, mimeType string, sizeBytes int64) map[string]any {
|
||
return map[string]any{"file": map[string]any{
|
||
"name": name,
|
||
"uri": uri,
|
||
"mimeType": firstNonEmptyGeminiString(mimeType, "application/octet-stream"),
|
||
"sizeBytes": strconv.FormatInt(sizeBytes, 10),
|
||
"state": "ACTIVE",
|
||
}}
|
||
}
|
||
|
||
func validateGeminiFilesVersion(version string) error {
|
||
if version == "v1" || version == "v1beta" {
|
||
return nil
|
||
}
|
||
return &clients.ClientError{Code: "bad_request", Message: "unsupported Gemini Files API version", Retryable: false}
|
||
}
|
||
|
||
func geminiUploadCommandHas(r *http.Request, command string) bool {
|
||
for _, item := range strings.Split(r.Header.Get("X-Goog-Upload-Command"), ",") {
|
||
if strings.EqualFold(strings.TrimSpace(item), command) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func newGeminiUploadID() string {
|
||
var payload [12]byte
|
||
if _, err := rand.Read(payload[:]); err != nil {
|
||
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||
}
|
||
return hex.EncodeToString(payload[:])
|
||
}
|
||
|
||
func geminiUploadFileName(uploadID string, displayName string, mimeType string) string {
|
||
name := strings.TrimSpace(strings.ReplaceAll(displayName, " ", ""))
|
||
name = path.Base(name)
|
||
if name == "." || name == "/" {
|
||
name = ""
|
||
}
|
||
if strings.Contains(name, ".") {
|
||
return name
|
||
}
|
||
if name == "" {
|
||
name = "gemini-upload-" + uploadID
|
||
}
|
||
return name + extensionFromGeminiMime(mimeType)
|
||
}
|
||
|
||
func extensionFromGeminiMime(mimeType string) string {
|
||
extensions, err := mime.ExtensionsByType(strings.ToLower(strings.TrimSpace(mimeType)))
|
||
if err == nil && len(extensions) > 0 {
|
||
return extensions[0]
|
||
}
|
||
return ".bin"
|
||
}
|
||
|
||
func absoluteRequestURL(r *http.Request, targetPath string) string {
|
||
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
|
||
if proto == "" {
|
||
proto = "http"
|
||
}
|
||
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
|
||
if host == "" {
|
||
host = r.Host
|
||
}
|
||
return fmt.Sprintf("%s://%s%s", proto, host, targetPath)
|
||
}
|
||
|
||
type parsedGeminiDataURL struct {
|
||
MimeType string
|
||
Data string
|
||
}
|
||
|
||
func parseGeminiDataURL(value string) *parsedGeminiDataURL {
|
||
if !strings.HasPrefix(strings.ToLower(value), "data:") {
|
||
return nil
|
||
}
|
||
prefix, data, ok := strings.Cut(value, ",")
|
||
if !ok || !strings.Contains(strings.ToLower(prefix), ";base64") {
|
||
return nil
|
||
}
|
||
mimeType := strings.TrimPrefix(strings.Split(prefix, ";")[0], "data:")
|
||
if mimeType == "" {
|
||
mimeType = "image/png"
|
||
}
|
||
return &parsedGeminiDataURL{MimeType: mimeType, Data: data}
|
||
}
|
||
|
||
func mimeTypeFromGeminiURI(value string) string {
|
||
extension := strings.ToLower(path.Ext(strings.Split(value, "?")[0]))
|
||
if extension == "" {
|
||
return "image/png"
|
||
}
|
||
if mimeType := mime.TypeByExtension(extension); mimeType != "" {
|
||
return mimeType
|
||
}
|
||
return "image/png"
|
||
}
|
||
|
||
func mapFromGeminiAny(value any) map[string]any {
|
||
if typed, ok := value.(map[string]any); ok {
|
||
return typed
|
||
}
|
||
return map[string]any{}
|
||
}
|
||
|
||
func firstPresentGemini(values ...any) any {
|
||
for _, value := range values {
|
||
if value != nil {
|
||
return value
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func firstNonEmptyGeminiString(values ...any) string {
|
||
for _, value := range values {
|
||
if text := strings.TrimSpace(stringFromRequestAny(value)); text != "" {
|
||
return text
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func positiveIntFromGeminiAny(value any) int {
|
||
switch typed := value.(type) {
|
||
case int:
|
||
if typed > 0 {
|
||
return typed
|
||
}
|
||
case int64:
|
||
if typed > 0 {
|
||
return int(typed)
|
||
}
|
||
case float64:
|
||
if typed > 0 {
|
||
return int(typed)
|
||
}
|
||
case json.Number:
|
||
if parsed, err := typed.Int64(); err == nil && parsed > 0 {
|
||
return int(parsed)
|
||
}
|
||
case string:
|
||
if parsed, err := strconv.Atoi(strings.TrimSpace(typed)); err == nil && parsed > 0 {
|
||
return parsed
|
||
}
|
||
}
|
||
return 0
|
||
}
|