Files
easyai-ai-gateway/apps/api/internal/httpapi/gemini_compat.go
T
easyai e07a997aa9 feat(api): 统一官方兼容接口响应协议
兼容接口现在以入口协议作为最终响应协议,同协议保留官方 Wire 响应,跨协议统一转换成功、任务状态与错误结构。

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

验证:go vet ./...;go test ./...;govulncheck ./...;pnpm lint;pnpm test;pnpm build;pnpm audit --audit-level high;pnpm openapi;全部 CI 脚本。
2026-07-22 15:34:59 +08:00

794 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}