2025-12-25 21:24:53 -08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-02-02 22:13:50 +08:00
|
|
|
|
"bytes"
|
2025-12-25 21:24:53 -08:00
|
|
|
|
"context"
|
2026-02-02 22:13:50 +08:00
|
|
|
|
"crypto/sha256"
|
|
|
|
|
|
"encoding/hex"
|
2026-02-07 12:31:10 +08:00
|
|
|
|
"encoding/json"
|
2025-12-27 12:27:47 +08:00
|
|
|
|
"errors"
|
2025-12-25 21:24:53 -08:00
|
|
|
|
"net/http"
|
2026-02-02 22:13:50 +08:00
|
|
|
|
"regexp"
|
2026-04-23 03:33:52 +08:00
|
|
|
|
"strconv"
|
2025-12-25 21:24:53 -08:00
|
|
|
|
"strings"
|
|
|
|
|
|
|
2026-02-09 06:47:22 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/domain"
|
2026-01-02 10:21:05 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
2025-12-25 21:24:53 -08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
|
2026-02-28 15:01:20 +08:00
|
|
|
|
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
2026-01-12 20:44:38 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
2026-02-12 19:01:09 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
2025-12-26 00:17:55 -08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
2025-12-25 21:24:53 -08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
2026-02-07 12:31:10 +08:00
|
|
|
|
"github.com/google/uuid"
|
2025-12-25 21:24:53 -08:00
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
2026-02-12 19:01:09 +08:00
|
|
|
|
"go.uber.org/zap"
|
2025-12-25 21:24:53 -08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// geminiCLITmpDirRegex 用于从 Gemini CLI 请求体中提取 tmp 目录的哈希值
|
|
|
|
|
|
// 匹配格式: /Users/xxx/.gemini/tmp/[64位十六进制哈希]
|
|
|
|
|
|
var geminiCLITmpDirRegex = regexp.MustCompile(`/\.gemini/tmp/([A-Fa-f0-9]{64})`)
|
|
|
|
|
|
|
2025-12-25 21:24:53 -08:00
|
|
|
|
// GeminiV1BetaListModels proxies:
|
|
|
|
|
|
// GET /v1beta/models
|
|
|
|
|
|
func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
2026-01-04 19:27:53 +08:00
|
|
|
|
apiKey, ok := middleware.GetAPIKeyFromContext(c)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
if !ok || apiKey == nil {
|
|
|
|
|
|
googleError(c, http.StatusUnauthorized, "Invalid API key")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-10-15 06:55:32 +09:00
|
|
|
|
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组(composite 分组允许路由到 antigravity)
|
2025-12-29 16:52:55 +08:00
|
|
|
|
forcePlatform, hasForcePlatform := middleware.GetForcePlatformFromContext(c)
|
2026-10-15 06:55:32 +09:00
|
|
|
|
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
|
|
|
|
|
|
if !hasForcePlatform && effectivePlatform != service.PlatformGemini &&
|
|
|
|
|
|
!servesCompositeAntigravityGemini(apiKey, effectivePlatform) {
|
2025-12-25 21:24:53 -08:00
|
|
|
|
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 15:58:26 +08:00
|
|
|
|
// 分组级模型白名单开启时过滤 models[].name(名字形如 models/xxx)。
|
|
|
|
|
|
filterGeminiModels := func(models []gemini.Model) []gemini.Model {
|
|
|
|
|
|
if apiKey.Group == nil || !apiKey.Group.ModelAllowlistEnabled() {
|
|
|
|
|
|
return models
|
|
|
|
|
|
}
|
|
|
|
|
|
filtered := make([]gemini.Model, 0, len(models))
|
|
|
|
|
|
for _, model := range models {
|
|
|
|
|
|
if apiKey.Group.ModelAllowlist.Allows(model.Name) {
|
|
|
|
|
|
filtered = append(filtered, model)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return filtered
|
2025-12-29 16:52:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 16:50:46 +08:00
|
|
|
|
agModelIDs, err := h.geminiCompatService.AntigravityGeminiModelIDs(c.Request.Context(), apiKey.GroupID, forcePlatform != service.PlatformAntigravity)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
googleError(c, http.StatusServiceUnavailable, "Unable to list Antigravity models")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
agModels := make([]gemini.Model, 0, len(agModelIDs))
|
|
|
|
|
|
for _, id := range agModelIDs {
|
|
|
|
|
|
agModels = append(agModels, gemini.FallbackModel(id))
|
|
|
|
|
|
}
|
2026-09-05 15:58:26 +08:00
|
|
|
|
if forcePlatform == service.PlatformAntigravity {
|
2026-09-15 16:50:46 +08:00
|
|
|
|
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)})
|
2026-09-04 22:29:36 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 21:57:19 +08:00
|
|
|
|
account, err := h.geminiCompatService.SelectAccountForModelsEndpoints(c.Request.Context(), apiKey.GroupID)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
if err != nil {
|
2026-09-15 16:50:46 +08:00
|
|
|
|
if len(agModels) > 0 {
|
|
|
|
|
|
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)})
|
2025-12-28 17:48:52 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-18 16:52:32 +08:00
|
|
|
|
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 21:57:19 +08:00
|
|
|
|
res, err := h.geminiCompatService.ForwardListModels(c.Request.Context(), account)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
googleError(c, http.StatusBadGateway, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if shouldFallbackGeminiModels(res) {
|
2026-09-15 16:50:46 +08:00
|
|
|
|
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(mergeGeminiModelLists(gemini.DefaultModels(), agModels))})
|
2025-12-25 21:24:53 -08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-15 16:50:46 +08:00
|
|
|
|
if res.StatusCode == http.StatusOK && len(agModels) > 0 {
|
|
|
|
|
|
if merged, ok := appendUpstreamGeminiModels(res.Body, agModels); ok {
|
|
|
|
|
|
res.Body = merged
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 15:58:26 +08:00
|
|
|
|
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
|
|
|
|
|
if filtered, dropped, ok := filterUpstreamGeminiModelsBody(res.Body, apiKey.Group.ModelAllowlist); ok && dropped {
|
|
|
|
|
|
// 只在确有条目被过滤时替换响应体;全命中或解析失败时保持原始响应,
|
|
|
|
|
|
// 统一经 writeUpstreamResponse 写出(保留全部上游响应头)。
|
|
|
|
|
|
res.Body = filtered
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-25 21:24:53 -08:00
|
|
|
|
writeUpstreamResponse(c, res)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 16:50:46 +08:00
|
|
|
|
// mergeGeminiModelLists keeps native metadata when both sources advertise a model.
|
|
|
|
|
|
func mergeGeminiModelLists(native, extra []gemini.Model) []gemini.Model {
|
|
|
|
|
|
result := append([]gemini.Model{}, native...)
|
|
|
|
|
|
seen := make(map[string]bool, len(native))
|
|
|
|
|
|
for _, model := range native {
|
|
|
|
|
|
seen[model.Name] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, model := range extra {
|
|
|
|
|
|
if !seen[model.Name] {
|
|
|
|
|
|
result = append(result, model)
|
|
|
|
|
|
seen[model.Name] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// appendUpstreamGeminiModels preserves unknown model metadata and envelope fields.
|
|
|
|
|
|
func appendUpstreamGeminiModels(body []byte, extra []gemini.Model) ([]byte, bool) {
|
|
|
|
|
|
var envelope map[string]json.RawMessage
|
|
|
|
|
|
if json.Unmarshal(body, &envelope) != nil || envelope == nil {
|
|
|
|
|
|
return body, false
|
|
|
|
|
|
}
|
|
|
|
|
|
var models []json.RawMessage
|
|
|
|
|
|
raw, exists := envelope["models"]
|
|
|
|
|
|
if !exists || json.Unmarshal(raw, &models) != nil {
|
|
|
|
|
|
return body, false
|
|
|
|
|
|
}
|
|
|
|
|
|
seen := make(map[string]bool, len(models))
|
|
|
|
|
|
for _, raw := range models {
|
|
|
|
|
|
var model struct {
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if json.Unmarshal(raw, &model) != nil {
|
|
|
|
|
|
return body, false
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[model.Name] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
changed := false
|
|
|
|
|
|
for _, model := range extra {
|
|
|
|
|
|
if seen[model.Name] {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
raw, err := json.Marshal(model)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return body, false
|
|
|
|
|
|
}
|
|
|
|
|
|
models = append(models, raw)
|
|
|
|
|
|
seen[model.Name] = true
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
if !changed {
|
|
|
|
|
|
return body, true
|
|
|
|
|
|
}
|
|
|
|
|
|
envelope["models"], _ = json.Marshal(models)
|
|
|
|
|
|
merged, err := json.Marshal(envelope)
|
|
|
|
|
|
return merged, err == nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 15:58:26 +08:00
|
|
|
|
// filterUpstreamGeminiModelsBody 按白名单过滤上游 /v1beta/models 响应中的
|
|
|
|
|
|
// models[].name,其余信封字段(如 nextPageToken)原样保留。
|
|
|
|
|
|
// 返回值:filtered 为过滤后的响应体;dropped 表示是否有条目被移除(全命中时
|
|
|
|
|
|
// 为 false,调用方应保持原始响应以完整透传上游头);ok=false 表示解析失败,
|
|
|
|
|
|
// 调用方同样应透传原始响应。
|
|
|
|
|
|
func filterUpstreamGeminiModelsBody(body []byte, allowlist service.GroupModelAllowlist) (filtered []byte, dropped bool, ok bool) {
|
|
|
|
|
|
var envelope map[string]json.RawMessage
|
|
|
|
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
|
|
|
|
return nil, false, false
|
2026-09-04 22:29:36 +08:00
|
|
|
|
}
|
2026-09-05 15:58:26 +08:00
|
|
|
|
rawModels, hasModels := envelope["models"]
|
|
|
|
|
|
if !hasModels {
|
|
|
|
|
|
return body, false, true
|
2026-09-04 22:29:36 +08:00
|
|
|
|
}
|
2026-09-05 15:58:26 +08:00
|
|
|
|
type geminiModelName struct {
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
}
|
|
|
|
|
|
var models []json.RawMessage
|
|
|
|
|
|
if err := json.Unmarshal(rawModels, &models); err != nil {
|
|
|
|
|
|
return nil, false, false
|
|
|
|
|
|
}
|
|
|
|
|
|
kept := make([]json.RawMessage, 0, len(models))
|
|
|
|
|
|
for _, raw := range models {
|
|
|
|
|
|
var model geminiModelName
|
|
|
|
|
|
if err := json.Unmarshal(raw, &model); err != nil {
|
|
|
|
|
|
return nil, false, false
|
|
|
|
|
|
}
|
|
|
|
|
|
if allowlist.Allows(model.Name) {
|
|
|
|
|
|
kept = append(kept, raw)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(kept) == len(models) {
|
|
|
|
|
|
// 全部命中时直接透传原始响应体。
|
|
|
|
|
|
return body, false, true
|
|
|
|
|
|
}
|
|
|
|
|
|
mergedModels, err := json.Marshal(kept)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, false, false
|
|
|
|
|
|
}
|
|
|
|
|
|
envelope["models"] = mergedModels
|
|
|
|
|
|
merged, err := json.Marshal(envelope)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, false, false
|
|
|
|
|
|
}
|
|
|
|
|
|
return merged, true, true
|
2026-09-04 22:29:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 21:24:53 -08:00
|
|
|
|
// GeminiV1BetaGetModel proxies:
|
|
|
|
|
|
// GET /v1beta/models/{model}
|
|
|
|
|
|
func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
|
2026-01-04 19:27:53 +08:00
|
|
|
|
apiKey, ok := middleware.GetAPIKeyFromContext(c)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
if !ok || apiKey == nil {
|
|
|
|
|
|
googleError(c, http.StatusUnauthorized, "Invalid API key")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-10-15 06:55:32 +09:00
|
|
|
|
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组(composite 分组允许路由到 antigravity)
|
2025-12-29 16:52:55 +08:00
|
|
|
|
forcePlatform, hasForcePlatform := middleware.GetForcePlatformFromContext(c)
|
2026-10-15 06:55:32 +09:00
|
|
|
|
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
|
|
|
|
|
|
if !hasForcePlatform && effectivePlatform != service.PlatformGemini &&
|
|
|
|
|
|
!servesCompositeAntigravityGemini(apiKey, effectivePlatform) {
|
2025-12-25 21:24:53 -08:00
|
|
|
|
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
modelName := strings.TrimSpace(c.Param("model"))
|
|
|
|
|
|
if modelName == "" {
|
|
|
|
|
|
googleError(c, http.StatusBadRequest, "Missing model in URL")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-31 16:11:45 +08:00
|
|
|
|
// 模型名会被拼进上游 URL 的 path,先在入口校验片段合规性,
|
|
|
|
|
|
// 见 service/upstream_path_guard.go。
|
|
|
|
|
|
if !service.IsSafeGeminiModelPathSegment(modelName) {
|
|
|
|
|
|
googleError(c, http.StatusBadRequest, "Invalid model in URL")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-01 09:27:47 +08:00
|
|
|
|
if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" {
|
|
|
|
|
|
modelName = strings.TrimSpace(resolvedModel)
|
|
|
|
|
|
}
|
2026-10-15 06:55:32 +09:00
|
|
|
|
if !hasForcePlatform && servesCompositeAntigravityGemini(apiKey, effectivePlatform) && !isAntigravityGeminiModel(modelName) {
|
|
|
|
|
|
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-25 21:24:53 -08:00
|
|
|
|
|
2026-10-15 06:55:32 +09:00
|
|
|
|
// 强制 antigravity 模式或 composite 路由到 antigravity:返回 antigravity 模型信息
|
|
|
|
|
|
if forcePlatform == service.PlatformAntigravity || effectivePlatform == service.PlatformAntigravity {
|
2026-01-02 10:21:05 +08:00
|
|
|
|
c.JSON(http.StatusOK, antigravity.FallbackGeminiModel(modelName))
|
2025-12-29 16:52:55 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 21:57:19 +08:00
|
|
|
|
account, err := h.geminiCompatService.SelectAccountForModelsEndpoints(c.Request.Context(), apiKey.GroupID)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
if err != nil {
|
2025-12-28 17:48:52 +08:00
|
|
|
|
// 没有 gemini 账户,检查是否有 antigravity 账户可用
|
|
|
|
|
|
hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID)
|
|
|
|
|
|
if hasAntigravity {
|
|
|
|
|
|
// antigravity 账户使用静态模型信息
|
|
|
|
|
|
c.JSON(http.StatusOK, gemini.FallbackModel(modelName))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-18 16:52:32 +08:00
|
|
|
|
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 21:57:19 +08:00
|
|
|
|
res, err := h.geminiCompatService.ForwardGetModel(c.Request.Context(), account, modelName)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
googleError(c, http.StatusBadGateway, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-01 02:20:13 +08:00
|
|
|
|
if shouldFallbackGeminiModel(modelName, res) {
|
2025-12-25 21:24:53 -08:00
|
|
|
|
c.JSON(http.StatusOK, gemini.FallbackModel(modelName))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
writeUpstreamResponse(c, res)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GeminiV1BetaModels proxies Gemini native REST endpoints like:
|
|
|
|
|
|
// POST /v1beta/models/{model}:generateContent
|
|
|
|
|
|
// POST /v1beta/models/{model}:streamGenerateContent?alt=sse
|
|
|
|
|
|
func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
2026-01-04 19:27:53 +08:00
|
|
|
|
apiKey, ok := middleware.GetAPIKeyFromContext(c)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
if !ok || apiKey == nil {
|
|
|
|
|
|
googleError(c, http.StatusUnauthorized, "Invalid API key")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-26 22:07:55 +08:00
|
|
|
|
authSubject, ok := middleware.GetAuthSubjectFromContext(c)
|
|
|
|
|
|
if !ok {
|
2025-12-25 21:24:53 -08:00
|
|
|
|
googleError(c, http.StatusInternalServerError, "User context not found")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog := requestLogger(
|
|
|
|
|
|
c,
|
|
|
|
|
|
"handler.gemini_v1beta.models",
|
|
|
|
|
|
zap.Int64("user_id", authSubject.UserID),
|
|
|
|
|
|
zap.Int64("api_key_id", apiKey.ID),
|
|
|
|
|
|
zap.Any("group_id", apiKey.GroupID),
|
|
|
|
|
|
)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
|
2026-10-15 06:55:32 +09:00
|
|
|
|
// 检查平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则要求 gemini 分组(composite 分组允许路由到 antigravity)
|
|
|
|
|
|
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
|
2025-12-29 16:52:55 +08:00
|
|
|
|
if !middleware.HasForcePlatform(c) {
|
2026-10-15 06:55:32 +09:00
|
|
|
|
if effectivePlatform != service.PlatformGemini &&
|
|
|
|
|
|
!servesCompositeAntigravityGemini(apiKey, effectivePlatform) {
|
2025-12-29 16:52:55 +08:00
|
|
|
|
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-25 21:24:53 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
modelName, action, err := parseGeminiModelAction(strings.TrimPrefix(c.Param("modelAction"), "/"))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
googleError(c, http.StatusNotFound, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-31 16:11:45 +08:00
|
|
|
|
// URL 里的模型名最终会被拼进上游 /v1beta/models/{model}:{action},
|
|
|
|
|
|
// 先在入口校验片段合规性,见 service/upstream_path_guard.go。
|
|
|
|
|
|
if !service.IsSafeGeminiModelPathSegment(modelName) {
|
|
|
|
|
|
googleError(c, http.StatusBadRequest, "Invalid model in URL")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-01 09:27:47 +08:00
|
|
|
|
if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" {
|
|
|
|
|
|
modelName = strings.TrimSpace(resolvedModel)
|
|
|
|
|
|
}
|
2026-10-15 06:55:32 +09:00
|
|
|
|
if !middleware.HasForcePlatform(c) && servesCompositeAntigravityGemini(apiKey, effectivePlatform) && !isAntigravityGeminiModel(modelName) {
|
|
|
|
|
|
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-25 21:24:53 -08:00
|
|
|
|
|
|
|
|
|
|
stream := action == "streamGenerateContent"
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog = reqLog.With(zap.String("model", modelName), zap.String("action", action), zap.Bool("stream", stream))
|
2025-12-25 21:24:53 -08:00
|
|
|
|
|
2026-02-28 15:01:20 +08:00
|
|
|
|
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
if err != nil {
|
2025-12-31 08:50:12 +08:00
|
|
|
|
if maxErr, ok := extractMaxBytesError(err); ok {
|
|
|
|
|
|
googleError(c, http.StatusRequestEntityTooLarge, buildBodyTooLargeMessage(maxErr.Limit))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-25 21:24:53 -08:00
|
|
|
|
googleError(c, http.StatusBadRequest, "Failed to read request body")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(body) == 0 {
|
|
|
|
|
|
googleError(c, http.StatusBadRequest, "Request body is empty")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 19:37:41 +08:00
|
|
|
|
setOpsRequestContext(c, modelName, stream)
|
2026-03-21 23:47:39 +08:00
|
|
|
|
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false)))
|
2026-07-26 14:18:13 -04:00
|
|
|
|
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
|
|
|
|
|
|
c.Request = c.Request.WithContext(pricingCtx)
|
2026-01-09 20:56:37 +08:00
|
|
|
|
|
2026-07-17 00:39:39 +08:00
|
|
|
|
if decision := h.checkSecurityAudit(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && !decision.AllowNextStage {
|
|
|
|
|
|
googleSecurityAuditError(c, decision)
|
2026-05-07 09:01:48 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 13:36:58 +08:00
|
|
|
|
// 解析渠道级模型映射
|
2026-04-02 13:24:30 +08:00
|
|
|
|
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, modelName)
|
2026-03-31 15:26:20 +08:00
|
|
|
|
reqModel := modelName // 保存映射前的原始模型名
|
|
|
|
|
|
if channelMapping.Mapped {
|
|
|
|
|
|
modelName = channelMapping.MappedModel
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 22:21:28 +08:00
|
|
|
|
// 引用显式缓存的请求只能发往持有该缓存的账号,且不参与会话粘性与换号。
|
|
|
|
|
|
cachedContentBinding, body, ok := h.resolveGeminiCachedContentBinding(c, reqLog, apiKey, modelName, body)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 21:24:53 -08:00
|
|
|
|
// Get subscription (may be nil)
|
|
|
|
|
|
subscription, _ := middleware.GetSubscriptionFromContext(c)
|
|
|
|
|
|
|
|
|
|
|
|
// For Gemini native API, do not send Claude-style ping frames.
|
2026-01-04 19:49:59 +08:00
|
|
|
|
geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone, 0)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
|
|
|
|
|
|
// 1) user concurrency slot
|
|
|
|
|
|
streamStarted := false
|
2026-02-07 14:24:55 +08:00
|
|
|
|
if h.errorPassthroughService != nil {
|
|
|
|
|
|
service.BindErrorPassthroughService(c, h.errorPassthroughService)
|
|
|
|
|
|
}
|
2025-12-26 22:07:55 +08:00
|
|
|
|
userReleaseFunc, err := geminiConcurrency.AcquireUserSlotWithWait(c, authSubject.UserID, authSubject.Concurrency, stream, &streamStarted)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
if err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog.Warn("gemini.user_slot_acquire_failed", zap.Error(err))
|
2026-09-26 16:59:08 +08:00
|
|
|
|
googleConcurrencyError(c, err, "user")
|
2025-12-25 21:24:53 -08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-01-04 19:49:59 +08:00
|
|
|
|
// 确保请求取消时也会释放槽位,避免长连接被动中断造成泄漏
|
|
|
|
|
|
userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
if userReleaseFunc != nil {
|
|
|
|
|
|
defer userReleaseFunc()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2) billing eligibility check (after wait)
|
2026-05-25 14:26:11 +08:00
|
|
|
|
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog.Info("gemini.billing_eligibility_check_failed", zap.Error(err))
|
2026-10-10 22:09:14 +08:00
|
|
|
|
status, code, message, retryAfter := billingErrorDetails(err)
|
2026-04-23 03:33:52 +08:00
|
|
|
|
if retryAfter > 0 {
|
|
|
|
|
|
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
|
|
|
|
|
}
|
2026-10-10 22:09:14 +08:00
|
|
|
|
googleErrorWithType(c, status, code, "", message)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 14:20:49 -04:00
|
|
|
|
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
|
2026-09-28 16:14:43 -04:00
|
|
|
|
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(modelName, body))
|
2026-09-28 14:20:49 -04:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
reqLog.Info("gemini.inflight_reservation_rejected", zap.Error(err))
|
2026-10-10 22:09:14 +08:00
|
|
|
|
status, code, message, retryAfter := billingErrorDetails(err)
|
2026-09-28 14:20:49 -04:00
|
|
|
|
if retryAfter > 0 {
|
|
|
|
|
|
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
|
|
|
|
|
}
|
2026-10-10 22:09:14 +08:00
|
|
|
|
googleErrorWithType(c, status, code, "", message)
|
2026-09-28 14:20:49 -04:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer inflightRelease()
|
|
|
|
|
|
|
2025-12-25 21:24:53 -08:00
|
|
|
|
// 3) select account (sticky session based on request body)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 优先使用 Gemini CLI 的会话标识(privileged-user-id + tmp 目录哈希)
|
2026-10-10 22:21:28 +08:00
|
|
|
|
sessionHash := ""
|
|
|
|
|
|
if cachedContentBinding == nil {
|
|
|
|
|
|
sessionHash = extractGeminiCLISessionHash(c, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
if sessionHash == "" && cachedContentBinding == nil {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// Fallback: 使用通用的会话哈希生成逻辑(适用于其他客户端)
|
2026-05-29 21:05:47 +08:00
|
|
|
|
parsedReq, _ := service.ParseGatewayRequest(service.NewRequestBodyRef(body), domain.PlatformGemini)
|
2026-02-09 06:46:32 +08:00
|
|
|
|
if parsedReq != nil {
|
|
|
|
|
|
parsedReq.SessionContext = &service.SessionContext{
|
|
|
|
|
|
ClientIP: ip.GetClientIP(c),
|
|
|
|
|
|
UserAgent: c.GetHeader("User-Agent"),
|
|
|
|
|
|
APIKeyID: apiKey.ID,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
sessionHash = h.gatewayService.GenerateSessionHash(parsedReq)
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
sessionKey := sessionHash
|
|
|
|
|
|
if sessionHash != "" {
|
|
|
|
|
|
sessionKey = "gemini:" + sessionHash
|
|
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 查询粘性会话绑定的账号 ID(用于检测账号切换)
|
|
|
|
|
|
var sessionBoundAccountID int64
|
|
|
|
|
|
if sessionKey != "" {
|
|
|
|
|
|
sessionBoundAccountID, _ = h.gatewayService.GetCachedSessionAccountID(c.Request.Context(), apiKey.GroupID, sessionKey)
|
2026-02-22 13:31:30 +08:00
|
|
|
|
if sessionBoundAccountID > 0 {
|
2026-02-22 16:43:33 +08:00
|
|
|
|
prefetchedGroupID := int64(0)
|
|
|
|
|
|
if apiKey.GroupID != nil {
|
|
|
|
|
|
prefetchedGroupID = *apiKey.GroupID
|
|
|
|
|
|
}
|
2026-02-28 15:01:20 +08:00
|
|
|
|
ctx := service.WithPrefetchedStickySession(c.Request.Context(), sessionBoundAccountID, prefetchedGroupID, h.metadataBridgeEnabled())
|
2026-02-22 13:31:30 +08:00
|
|
|
|
c.Request = c.Request.WithContext(ctx)
|
|
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
}
|
2026-02-07 12:31:10 +08:00
|
|
|
|
|
|
|
|
|
|
// === Gemini 内容摘要会话 Fallback 逻辑 ===
|
|
|
|
|
|
// 当原有会话标识无效时(sessionBoundAccountID == 0),尝试基于内容摘要链匹配
|
|
|
|
|
|
var geminiDigestChain string
|
|
|
|
|
|
var geminiPrefixHash string
|
|
|
|
|
|
var geminiSessionUUID string
|
2026-02-09 07:02:12 +08:00
|
|
|
|
var matchedDigestChain string
|
2026-10-10 22:21:28 +08:00
|
|
|
|
useDigestFallback := sessionBoundAccountID == 0 && cachedContentBinding == nil
|
2026-02-07 12:31:10 +08:00
|
|
|
|
|
|
|
|
|
|
if useDigestFallback {
|
|
|
|
|
|
// 解析 Gemini 请求体
|
|
|
|
|
|
var geminiReq antigravity.GeminiRequest
|
|
|
|
|
|
if err := json.Unmarshal(body, &geminiReq); err == nil && len(geminiReq.Contents) > 0 {
|
|
|
|
|
|
// 生成摘要链
|
|
|
|
|
|
geminiDigestChain = service.BuildGeminiDigestChain(&geminiReq)
|
|
|
|
|
|
if geminiDigestChain != "" {
|
|
|
|
|
|
// 生成前缀 hash
|
|
|
|
|
|
userAgent := c.GetHeader("User-Agent")
|
|
|
|
|
|
clientIP := ip.GetClientIP(c)
|
|
|
|
|
|
platform := ""
|
|
|
|
|
|
if apiKey.Group != nil {
|
|
|
|
|
|
platform = apiKey.Group.Platform
|
|
|
|
|
|
}
|
|
|
|
|
|
geminiPrefixHash = service.GenerateGeminiPrefixHash(
|
|
|
|
|
|
authSubject.UserID,
|
|
|
|
|
|
apiKey.ID,
|
|
|
|
|
|
clientIP,
|
|
|
|
|
|
userAgent,
|
|
|
|
|
|
platform,
|
|
|
|
|
|
modelName,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 查找会话
|
2026-02-09 07:02:12 +08:00
|
|
|
|
foundUUID, foundAccountID, foundMatchedChain, found := h.gatewayService.FindGeminiSession(
|
2026-02-07 12:31:10 +08:00
|
|
|
|
c.Request.Context(),
|
|
|
|
|
|
derefGroupID(apiKey.GroupID),
|
|
|
|
|
|
geminiPrefixHash,
|
|
|
|
|
|
geminiDigestChain,
|
|
|
|
|
|
)
|
|
|
|
|
|
if found {
|
2026-02-09 07:02:12 +08:00
|
|
|
|
matchedDigestChain = foundMatchedChain
|
2026-02-07 12:31:10 +08:00
|
|
|
|
sessionBoundAccountID = foundAccountID
|
|
|
|
|
|
geminiSessionUUID = foundUUID
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog.Info("gemini.digest_fallback_matched",
|
|
|
|
|
|
zap.String("session_uuid_prefix", safeShortPrefix(foundUUID, 8)),
|
|
|
|
|
|
zap.Int64("account_id", foundAccountID),
|
|
|
|
|
|
zap.String("digest_chain", truncateDigestChain(geminiDigestChain)),
|
|
|
|
|
|
)
|
2026-02-07 12:31:10 +08:00
|
|
|
|
|
|
|
|
|
|
// 关键:如果原 sessionKey 为空,使用 prefixHash + uuid 作为 sessionKey
|
|
|
|
|
|
// 这样 SelectAccountWithLoadAwareness 的粘性会话逻辑会优先使用匹配到的账号
|
|
|
|
|
|
if sessionKey == "" {
|
|
|
|
|
|
sessionKey = service.GenerateGeminiDigestSessionKey(geminiPrefixHash, foundUUID)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = h.gatewayService.BindStickySession(c.Request.Context(), apiKey.GroupID, sessionKey, foundAccountID)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 生成新的会话 UUID
|
|
|
|
|
|
geminiSessionUUID = uuid.New().String()
|
|
|
|
|
|
// 为新会话也生成 sessionKey(用于后续请求的粘性会话)
|
|
|
|
|
|
if sessionKey == "" {
|
|
|
|
|
|
sessionKey = service.GenerateGeminiDigestSessionKey(geminiPrefixHash, geminiSessionUUID)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 判断是否真的绑定了粘性会话:有 sessionKey 且已经绑定到某个账号
|
|
|
|
|
|
hasBoundSession := sessionKey != "" && sessionBoundAccountID > 0
|
2026-02-02 22:13:50 +08:00
|
|
|
|
cleanedForUnknownBinding := false
|
|
|
|
|
|
|
2026-10-10 22:21:28 +08:00
|
|
|
|
maxAccountSwitches := h.maxAccountSwitchesGemini
|
|
|
|
|
|
var cachedContentExclusions map[int64]struct{}
|
|
|
|
|
|
if cachedContentBinding != nil {
|
|
|
|
|
|
// 缓存只存在于绑定账号:排除分组内其余账号,失败不换号。
|
|
|
|
|
|
maxAccountSwitches = 0
|
|
|
|
|
|
sessionBoundAccountID = cachedContentBinding.AccountID
|
|
|
|
|
|
c.Request = c.Request.WithContext(service.WithGeminiCachedContentBound(c.Request.Context()))
|
|
|
|
|
|
cachedContentExclusions, err = h.gatewayService.GeminiExplicitCacheExclusions(c.Request.Context(), apiKey.GroupID, cachedContentBinding.AccountID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
reqLog.Warn("gemini.cached_content.list_accounts_failed", zap.Error(err))
|
|
|
|
|
|
googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
fs := NewFailoverState(maxAccountSwitches, hasBoundSession)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
|
2026-02-09 17:25:36 +08:00
|
|
|
|
// 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。
|
|
|
|
|
|
// 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。
|
|
|
|
|
|
if h.gatewayService.IsSingleAntigravityAccountGroup(c.Request.Context(), apiKey.GroupID) {
|
2026-02-28 15:01:20 +08:00
|
|
|
|
ctx := service.WithSingleAccountRetry(c.Request.Context(), true, h.metadataBridgeEnabled())
|
2026-02-09 17:25:36 +08:00
|
|
|
|
c.Request = c.Request.WithContext(ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-27 12:27:47 +08:00
|
|
|
|
for {
|
2026-10-10 22:21:28 +08:00
|
|
|
|
excludedAccountIDs := fs.FailedAccountIDs
|
|
|
|
|
|
if cachedContentExclusions != nil {
|
|
|
|
|
|
excludedAccountIDs = mergeAccountIDSets(cachedContentExclusions, fs.FailedAccountIDs)
|
|
|
|
|
|
}
|
|
|
|
|
|
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, excludedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
|
|
|
|
|
|
if err == nil && cachedContentBinding != nil && selection.Account.ID != cachedContentBinding.AccountID {
|
|
|
|
|
|
if selection.Acquired && selection.ReleaseFunc != nil {
|
|
|
|
|
|
selection.ReleaseFunc()
|
|
|
|
|
|
}
|
|
|
|
|
|
err = service.ErrNoAvailableAccounts
|
|
|
|
|
|
}
|
2025-12-27 12:27:47 +08:00
|
|
|
|
if err != nil {
|
2026-09-26 16:59:08 +08:00
|
|
|
|
if failoverClientGone(c) {
|
|
|
|
|
|
reqLog.Info("gemini.account_select_aborted_client_disconnected", zap.Error(err))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-10-10 22:21:28 +08:00
|
|
|
|
if cachedContentBinding != nil {
|
|
|
|
|
|
if fs.LastFailoverErr != nil {
|
|
|
|
|
|
h.handleGeminiCachedContentFailoverExhausted(c, fs.LastFailoverErr)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
reqLog.Info("gemini.cached_content.bound_account_unavailable", zap.Int64("account_id", cachedContentBinding.AccountID), zap.Error(err))
|
|
|
|
|
|
markOpsRoutingCapacityLimited(c)
|
|
|
|
|
|
googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-24 18:08:04 +08:00
|
|
|
|
if len(fs.FailedAccountIDs) == 0 {
|
2026-10-15 06:55:32 +09:00
|
|
|
|
diagPlatform := service.PlatformGemini
|
|
|
|
|
|
if p := effectiveAPIKeyPlatform(c, apiKey); p == service.PlatformAntigravity {
|
|
|
|
|
|
diagPlatform = service.PlatformAntigravity
|
|
|
|
|
|
}
|
|
|
|
|
|
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, diagPlatform)
|
2026-06-26 15:38:06 +08:00
|
|
|
|
if !cls.ModelNotFound {
|
|
|
|
|
|
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
message := cls.Message
|
|
|
|
|
|
if !cls.ModelNotFound {
|
|
|
|
|
|
message = "No available Gemini accounts: " + err.Error()
|
|
|
|
|
|
}
|
|
|
|
|
|
googleError(c, cls.Status, message)
|
2025-12-27 12:27:47 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-24 18:08:04 +08:00
|
|
|
|
action := fs.HandleSelectionExhausted(c.Request.Context())
|
|
|
|
|
|
switch action {
|
|
|
|
|
|
case FailoverContinue:
|
2026-02-28 15:01:20 +08:00
|
|
|
|
ctx := service.WithSingleAccountRetry(c.Request.Context(), true, h.metadataBridgeEnabled())
|
2026-02-24 18:08:04 +08:00
|
|
|
|
c.Request = c.Request.WithContext(ctx)
|
|
|
|
|
|
continue
|
|
|
|
|
|
case FailoverCanceled:
|
2026-07-15 08:25:23 +08:00
|
|
|
|
failoverClientGone(c)
|
2026-02-24 18:08:04 +08:00
|
|
|
|
return
|
|
|
|
|
|
default: // FailoverExhausted
|
|
|
|
|
|
h.handleGeminiFailoverExhausted(c, fs.LastFailoverErr)
|
|
|
|
|
|
return
|
2026-02-09 14:26:01 +08:00
|
|
|
|
}
|
2025-12-25 21:24:53 -08:00
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
account := selection.Account
|
2026-02-12 16:27:29 +08:00
|
|
|
|
setOpsSelectedAccount(c, account.ID, account.Platform)
|
2025-12-27 12:27:47 +08:00
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 检测账号切换:如果粘性会话绑定的账号与当前选择的账号不同,清除 thoughtSignature
|
|
|
|
|
|
// 注意:Gemini 原生 API 的 thoughtSignature 与具体上游账号强相关;跨账号透传会导致 400。
|
|
|
|
|
|
if sessionBoundAccountID > 0 && sessionBoundAccountID != account.ID {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog.Info("gemini.sticky_session_account_switched",
|
|
|
|
|
|
zap.Int64("from_account_id", sessionBoundAccountID),
|
|
|
|
|
|
zap.Int64("to_account_id", account.ID),
|
|
|
|
|
|
zap.Bool("clean_thought_signature", true),
|
|
|
|
|
|
)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
body = service.CleanGeminiNativeThoughtSignatures(body)
|
|
|
|
|
|
sessionBoundAccountID = account.ID
|
2026-02-09 06:45:01 +08:00
|
|
|
|
} else if sessionKey != "" && sessionBoundAccountID == 0 && !cleanedForUnknownBinding && bytes.Contains(body, []byte(`"thoughtSignature"`)) {
|
|
|
|
|
|
// 无缓存绑定但请求里已有 thoughtSignature:常见于缓存丢失/TTL 过期后,客户端继续携带旧签名。
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 为避免第一次转发就 400,这里做一次确定性清理,让新账号重新生成签名链路。
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog.Info("gemini.sticky_session_binding_missing",
|
|
|
|
|
|
zap.Bool("clean_thought_signature", true),
|
|
|
|
|
|
)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
body = service.CleanGeminiNativeThoughtSignatures(body)
|
|
|
|
|
|
cleanedForUnknownBinding = true
|
|
|
|
|
|
sessionBoundAccountID = account.ID
|
|
|
|
|
|
} else if sessionBoundAccountID == 0 {
|
|
|
|
|
|
// 记录本次请求中首次选择到的账号,便于同一请求内 failover 时检测切换。
|
|
|
|
|
|
sessionBoundAccountID = account.ID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-27 12:27:47 +08:00
|
|
|
|
// 4) account concurrency slot
|
2026-01-01 04:01:51 +08:00
|
|
|
|
accountReleaseFunc := selection.ReleaseFunc
|
|
|
|
|
|
if !selection.Acquired {
|
|
|
|
|
|
if selection.WaitPlan == nil {
|
2026-05-18 16:52:32 +08:00
|
|
|
|
markOpsRoutingCapacityLimited(c)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-01-09 20:56:37 +08:00
|
|
|
|
accountWaitCounted := false
|
2026-01-01 04:01:51 +08:00
|
|
|
|
canWait, err := geminiConcurrency.IncrementAccountWaitCount(c.Request.Context(), account.ID, selection.WaitPlan.MaxWaiting)
|
|
|
|
|
|
if err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog.Warn("gemini.account_wait_counter_increment_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
2026-01-01 04:01:51 +08:00
|
|
|
|
} else if !canWait {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog.Info("gemini.account_wait_queue_full",
|
|
|
|
|
|
zap.Int64("account_id", account.ID),
|
|
|
|
|
|
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
|
|
|
|
|
|
)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
googleError(c, http.StatusTooManyRequests, "Too many pending requests, please retry later")
|
|
|
|
|
|
return
|
2026-01-09 20:56:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
if err == nil && canWait {
|
|
|
|
|
|
accountWaitCounted = true
|
|
|
|
|
|
}
|
|
|
|
|
|
defer func() {
|
|
|
|
|
|
if accountWaitCounted {
|
2026-01-01 04:26:01 +08:00
|
|
|
|
geminiConcurrency.DecrementAccountWaitCount(c.Request.Context(), account.ID)
|
|
|
|
|
|
}
|
2026-01-09 20:56:37 +08:00
|
|
|
|
}()
|
2026-01-01 04:01:51 +08:00
|
|
|
|
|
|
|
|
|
|
accountReleaseFunc, err = geminiConcurrency.AcquireAccountSlotWithWaitTimeout(
|
|
|
|
|
|
c,
|
|
|
|
|
|
account.ID,
|
|
|
|
|
|
selection.WaitPlan.MaxConcurrency,
|
|
|
|
|
|
selection.WaitPlan.Timeout,
|
|
|
|
|
|
stream,
|
|
|
|
|
|
&streamStarted,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog.Warn("gemini.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
2026-09-26 16:59:08 +08:00
|
|
|
|
googleConcurrencyError(c, err, "account")
|
2026-01-01 04:01:51 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-01-09 20:56:37 +08:00
|
|
|
|
if accountWaitCounted {
|
|
|
|
|
|
geminiConcurrency.DecrementAccountWaitCount(c.Request.Context(), account.ID)
|
|
|
|
|
|
accountWaitCounted = false
|
|
|
|
|
|
}
|
2026-07-26 14:18:13 -04:00
|
|
|
|
}
|
2026-08-01 04:10:28 -04:00
|
|
|
|
// 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。
|
|
|
|
|
|
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
|
|
|
|
|
|
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
|
2026-07-26 14:18:13 -04:00
|
|
|
|
if vetoed {
|
|
|
|
|
|
if accountReleaseFunc != nil {
|
|
|
|
|
|
accountReleaseFunc()
|
2026-01-01 04:01:51 +08:00
|
|
|
|
}
|
2026-07-26 14:18:13 -04:00
|
|
|
|
reqLog.Debug("gemini.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
2026-10-10 22:21:28 +08:00
|
|
|
|
if cachedContentBinding != nil {
|
|
|
|
|
|
markOpsRoutingCapacityLimited(c)
|
|
|
|
|
|
googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-01 22:25:35 +08:00
|
|
|
|
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
|
|
|
|
|
|
reqLog.Warn("gemini.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
|
|
|
|
|
|
markOpsRoutingCapacityLimited(c)
|
|
|
|
|
|
googleError(c, http.StatusServiceUnavailable, profitVetoExhaustedMessage)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-26 14:18:13 -04:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
account = latest
|
|
|
|
|
|
selection.Account = latest
|
2026-08-01 04:10:28 -04:00
|
|
|
|
// 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已抢槽
|
|
|
|
|
|
// 的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。
|
|
|
|
|
|
if selection.ProfitGateActive() || !selection.Acquired {
|
|
|
|
|
|
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, sessionKey, account.ID); err != nil {
|
|
|
|
|
|
reqLog.Warn("gemini.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
|
|
|
|
|
}
|
2025-12-27 12:27:47 +08:00
|
|
|
|
}
|
2026-01-04 19:49:59 +08:00
|
|
|
|
// 账号槽位/等待计数需要在超时或断开时安全回收
|
|
|
|
|
|
accountReleaseFunc = wrapReleaseOnDone(c.Request.Context(), accountReleaseFunc)
|
2025-12-27 12:27:47 +08:00
|
|
|
|
|
2025-12-28 17:48:52 +08:00
|
|
|
|
// 5) forward (根据平台分流)
|
|
|
|
|
|
var result *service.ForwardResult
|
2026-01-28 00:01:03 +08:00
|
|
|
|
requestCtx := c.Request.Context()
|
2026-02-24 18:08:04 +08:00
|
|
|
|
if fs.SwitchCount > 0 {
|
2026-02-28 15:01:20 +08:00
|
|
|
|
requestCtx = service.WithAccountSwitchCount(requestCtx, fs.SwitchCount, h.metadataBridgeEnabled())
|
2026-01-28 00:01:03 +08:00
|
|
|
|
}
|
2026-05-31 21:44:40 +08:00
|
|
|
|
sessionGroupID := derefGroupID(apiKey.GroupID)
|
2026-02-08 13:06:25 +08:00
|
|
|
|
if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey {
|
2026-05-31 21:44:40 +08:00
|
|
|
|
result, err = h.antigravityGatewayService.ForwardGemini(
|
|
|
|
|
|
requestCtx,
|
|
|
|
|
|
c,
|
|
|
|
|
|
account,
|
|
|
|
|
|
modelName,
|
|
|
|
|
|
action,
|
|
|
|
|
|
stream,
|
|
|
|
|
|
body,
|
|
|
|
|
|
hasBoundSession,
|
|
|
|
|
|
service.WithForwardGeminiSession(sessionGroupID, sessionKey),
|
|
|
|
|
|
)
|
2025-12-28 17:48:52 +08:00
|
|
|
|
} else {
|
2026-01-28 00:01:03 +08:00
|
|
|
|
result, err = h.geminiCompatService.ForwardNative(requestCtx, c, account, modelName, action, stream, body)
|
2025-12-28 17:48:52 +08:00
|
|
|
|
}
|
2025-12-27 12:27:47 +08:00
|
|
|
|
if accountReleaseFunc != nil {
|
|
|
|
|
|
accountReleaseFunc()
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
var failoverErr *service.UpstreamFailoverError
|
|
|
|
|
|
if errors.As(err, &failoverErr) {
|
2026-07-13 12:57:00 +08:00
|
|
|
|
failoverAction := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr)
|
2026-02-24 18:08:04 +08:00
|
|
|
|
switch failoverAction {
|
|
|
|
|
|
case FailoverContinue:
|
|
|
|
|
|
continue
|
|
|
|
|
|
case FailoverExhausted:
|
2026-10-10 22:21:28 +08:00
|
|
|
|
if cachedContentBinding != nil {
|
|
|
|
|
|
h.handleGeminiCachedContentFailoverExhausted(c, fs.LastFailoverErr)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-24 18:08:04 +08:00
|
|
|
|
h.handleGeminiFailoverExhausted(c, fs.LastFailoverErr)
|
|
|
|
|
|
return
|
|
|
|
|
|
case FailoverCanceled:
|
2026-07-15 08:25:23 +08:00
|
|
|
|
failoverClientGone(c)
|
2025-12-27 12:27:47 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-26 16:59:08 +08:00
|
|
|
|
// 转发层已写出错误响应;客户端断开时转发层不写,响应未提交则标记 499。
|
|
|
|
|
|
failoverClientGone(c)
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog.Error("gemini.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
2025-12-27 12:27:47 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 15:19:40 +08:00
|
|
|
|
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
|
|
|
|
|
userAgent := c.GetHeader("User-Agent")
|
2026-01-12 20:44:38 +08:00
|
|
|
|
clientIP := ip.GetClientIP(c)
|
2026-01-12 15:19:40 +08:00
|
|
|
|
|
2026-02-07 12:31:10 +08:00
|
|
|
|
// 保存 Gemini 内容摘要会话(用于 Fallback 匹配)
|
|
|
|
|
|
if useDigestFallback && geminiDigestChain != "" && geminiPrefixHash != "" {
|
|
|
|
|
|
if err := h.gatewayService.SaveGeminiSession(
|
|
|
|
|
|
c.Request.Context(),
|
|
|
|
|
|
derefGroupID(apiKey.GroupID),
|
|
|
|
|
|
geminiPrefixHash,
|
|
|
|
|
|
geminiDigestChain,
|
|
|
|
|
|
geminiSessionUUID,
|
|
|
|
|
|
account.ID,
|
2026-02-09 07:02:12 +08:00
|
|
|
|
matchedDigestChain,
|
2026-02-07 12:31:10 +08:00
|
|
|
|
); err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog.Warn("gemini.digest_session_save_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
2026-02-07 12:31:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 12:56:57 +08:00
|
|
|
|
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
|
2026-03-12 16:53:18 +08:00
|
|
|
|
requestPayloadHash := service.HashUsageRequestPayload(body)
|
2026-03-15 22:13:42 +08:00
|
|
|
|
inboundEndpoint := GetInboundEndpoint(c)
|
|
|
|
|
|
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
2026-05-30 15:52:33 +08:00
|
|
|
|
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
|
|
|
|
|
|
forceCacheBilling := fs.ForceCacheBilling
|
2026-05-25 14:26:11 +08:00
|
|
|
|
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
2026-07-24 01:22:34 +08:00
|
|
|
|
sessionID := service.ExtractClientSessionID(c)
|
2026-08-28 23:47:48 +08:00
|
|
|
|
// 长上下文阶梯由目录数据驱动,统一在计费路径内生效,入口无需声明。
|
2026-05-28 22:08:02 +08:00
|
|
|
|
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
2026-08-28 23:47:48 +08:00
|
|
|
|
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
|
|
|
|
|
Result: result,
|
|
|
|
|
|
QuotaPlatform: quotaPlatform,
|
|
|
|
|
|
APIKey: apiKey,
|
|
|
|
|
|
User: apiKey.User,
|
|
|
|
|
|
Account: account,
|
|
|
|
|
|
Subscription: subscription,
|
|
|
|
|
|
PricingAt: pricingAt,
|
|
|
|
|
|
InboundEndpoint: inboundEndpoint,
|
|
|
|
|
|
UpstreamEndpoint: upstreamEndpoint,
|
|
|
|
|
|
UserAgent: userAgent,
|
|
|
|
|
|
IPAddress: clientIP,
|
|
|
|
|
|
RequestPayloadHash: requestPayloadHash,
|
|
|
|
|
|
ForceCacheBilling: forceCacheBilling,
|
|
|
|
|
|
APIKeyService: h.apiKeyService,
|
|
|
|
|
|
SessionID: sessionID,
|
|
|
|
|
|
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
2025-12-27 12:27:47 +08:00
|
|
|
|
}); err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.L().With(
|
|
|
|
|
|
zap.String("component", "handler.gemini_v1beta.models"),
|
|
|
|
|
|
zap.Int64("user_id", authSubject.UserID),
|
|
|
|
|
|
zap.Int64("api_key_id", apiKey.ID),
|
|
|
|
|
|
zap.Any("group_id", apiKey.GroupID),
|
|
|
|
|
|
zap.String("model", modelName),
|
2026-02-22 12:56:57 +08:00
|
|
|
|
zap.Int64("account_id", account.ID),
|
2026-02-12 19:01:09 +08:00
|
|
|
|
).Error("gemini.record_usage_failed", zap.Error(err))
|
2025-12-27 12:27:47 +08:00
|
|
|
|
}
|
2026-02-22 12:56:57 +08:00
|
|
|
|
})
|
2026-02-12 19:01:09 +08:00
|
|
|
|
reqLog.Debug("gemini.request_completed",
|
|
|
|
|
|
zap.Int64("account_id", account.ID),
|
2026-02-24 18:08:04 +08:00
|
|
|
|
zap.Int("switch_count", fs.SwitchCount),
|
2026-02-12 19:01:09 +08:00
|
|
|
|
)
|
2025-12-27 12:27:47 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-25 21:24:53 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func parseGeminiModelAction(rest string) (model string, action string, err error) {
|
|
|
|
|
|
rest = strings.TrimSpace(rest)
|
|
|
|
|
|
if rest == "" {
|
|
|
|
|
|
return "", "", &pathParseError{"missing path"}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Standard: {model}:{action}
|
|
|
|
|
|
if i := strings.Index(rest, ":"); i > 0 && i < len(rest)-1 {
|
|
|
|
|
|
return rest[:i], rest[i+1:], nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback: {model}/{action}
|
|
|
|
|
|
if i := strings.Index(rest, "/"); i > 0 && i < len(rest)-1 {
|
|
|
|
|
|
return rest[:i], rest[i+1:], nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return "", "", &pathParseError{"invalid model action path"}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 21:52:54 +08:00
|
|
|
|
func (h *GatewayHandler) handleGeminiFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError) {
|
|
|
|
|
|
if failoverErr == nil {
|
|
|
|
|
|
googleError(c, http.StatusBadGateway, "Upstream request failed")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
statusCode := failoverErr.StatusCode
|
|
|
|
|
|
responseBody := failoverErr.ResponseBody
|
|
|
|
|
|
|
|
|
|
|
|
// 先检查透传规则
|
|
|
|
|
|
if h.errorPassthroughService != nil && len(responseBody) > 0 {
|
|
|
|
|
|
if rule := h.errorPassthroughService.MatchRule(service.PlatformGemini, statusCode, responseBody); rule != nil {
|
|
|
|
|
|
// 确定响应状态码
|
|
|
|
|
|
respCode := statusCode
|
|
|
|
|
|
if !rule.PassthroughCode && rule.ResponseCode != nil {
|
|
|
|
|
|
respCode = *rule.ResponseCode
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 确定响应消息
|
|
|
|
|
|
msg := service.ExtractUpstreamErrorMessage(responseBody)
|
|
|
|
|
|
if !rule.PassthroughBody && rule.CustomMessage != nil {
|
|
|
|
|
|
msg = *rule.CustomMessage
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 20:56:01 +08:00
|
|
|
|
if rule.SkipMonitoring {
|
|
|
|
|
|
c.Set(service.OpsSkipPassthroughKey, true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 22:09:14 +08:00
|
|
|
|
// 按透传规则改写的是上游错误,不登记本地错误类型。
|
|
|
|
|
|
googleErrorWithType(c, respCode, "", "", msg)
|
2026-02-05 21:52:54 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 11:15:02 +08:00
|
|
|
|
// 记录原始上游状态码,以便 ops 错误日志捕获真实的上游错误
|
|
|
|
|
|
upstreamMsg := service.ExtractUpstreamErrorMessage(responseBody)
|
|
|
|
|
|
service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "")
|
|
|
|
|
|
|
2026-02-05 21:52:54 +08:00
|
|
|
|
// 使用默认的错误映射
|
2025-12-27 12:27:47 +08:00
|
|
|
|
status, message := mapGeminiUpstreamError(statusCode)
|
|
|
|
|
|
googleError(c, status, message)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func mapGeminiUpstreamError(statusCode int) (int, string) {
|
|
|
|
|
|
switch statusCode {
|
|
|
|
|
|
case 401:
|
|
|
|
|
|
return http.StatusBadGateway, "Upstream authentication failed, please contact administrator"
|
|
|
|
|
|
case 403:
|
|
|
|
|
|
return http.StatusBadGateway, "Upstream access forbidden, please contact administrator"
|
|
|
|
|
|
case 429:
|
|
|
|
|
|
return http.StatusTooManyRequests, "Upstream rate limit exceeded, please retry later"
|
|
|
|
|
|
case 529:
|
|
|
|
|
|
return http.StatusServiceUnavailable, "Upstream service overloaded, please retry later"
|
|
|
|
|
|
case 500, 502, 503, 504:
|
|
|
|
|
|
return http.StatusBadGateway, "Upstream service temporarily unavailable"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return http.StatusBadGateway, "Upstream request failed"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 21:24:53 -08:00
|
|
|
|
type pathParseError struct{ msg string }
|
|
|
|
|
|
|
|
|
|
|
|
func (e *pathParseError) Error() string { return e.msg }
|
|
|
|
|
|
|
2026-10-10 22:09:14 +08:00
|
|
|
|
// googleError 以 Google 错误格式回写网关产生的错误,并按状态码向 ops 登记错误类型:
|
|
|
|
|
|
// 4xx 为客户端请求问题,其余状态码不登记(按平台错误统计)。
|
2025-12-25 21:24:53 -08:00
|
|
|
|
func googleError(c *gin.Context, status int, message string) {
|
2026-10-10 22:09:14 +08:00
|
|
|
|
googleErrorWithType(c, status, googleClientErrorType(status), "", message)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// googlePlatformError 以 Google 错误格式回写由平台侧原因(如管理员未完成配置)导致的错误,ops 按平台错误统计。
|
|
|
|
|
|
func googlePlatformError(c *gin.Context, status int, message string) {
|
|
|
|
|
|
googleErrorWithType(c, status, "", "", message)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// googleErrorWithType 以 Google 错误格式回写错误,并向 ops 登记错误类型与错误码(Google 错误体没有 type 字段),
|
|
|
|
|
|
// 与 Claude / OpenAI 格式错误同口径分类;errType 为空时不登记。
|
|
|
|
|
|
func googleErrorWithType(c *gin.Context, status int, errType, code, message string) {
|
|
|
|
|
|
service.SetOpsLocalErrorType(c, errType, code)
|
2025-12-25 21:24:53 -08:00
|
|
|
|
c.JSON(status, gin.H{
|
|
|
|
|
|
"error": gin.H{
|
|
|
|
|
|
"code": status,
|
|
|
|
|
|
"message": message,
|
|
|
|
|
|
"status": googleapi.HTTPStatusToGoogleStatus(status),
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 22:09:14 +08:00
|
|
|
|
// googleClientErrorType 返回客户端请求类 4xx 状态码对应的 ops 错误类型;其余状态码返回空串。
|
|
|
|
|
|
func googleClientErrorType(status int) string {
|
|
|
|
|
|
switch status {
|
|
|
|
|
|
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
|
|
|
|
|
|
return "invalid_request_error"
|
|
|
|
|
|
case http.StatusUnauthorized:
|
|
|
|
|
|
return "authentication_error"
|
|
|
|
|
|
case http.StatusForbidden:
|
|
|
|
|
|
return "permission_error"
|
|
|
|
|
|
case http.StatusNotFound:
|
|
|
|
|
|
return "not_found_error"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-26 16:59:08 +08:00
|
|
|
|
// googleConcurrencyError 以 Google 错误格式回写并发槽获取失败,状态码与文案
|
|
|
|
|
|
// 沿用 concurrencyErrorResponse 的统一映射(客户端断开为 499)。
|
|
|
|
|
|
func googleConcurrencyError(c *gin.Context, err error, slotType string) {
|
2026-10-10 22:09:14 +08:00
|
|
|
|
status, errType, code, message := concurrencyErrorResponse(err, slotType)
|
|
|
|
|
|
googleErrorWithType(c, status, errType, code, message)
|
2026-09-26 16:59:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 21:24:53 -08:00
|
|
|
|
func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) {
|
|
|
|
|
|
if res == nil {
|
|
|
|
|
|
googleError(c, http.StatusBadGateway, "Empty upstream response")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
for k, vv := range res.Headers {
|
|
|
|
|
|
// Avoid overriding content-length and hop-by-hop headers.
|
2026-01-05 13:54:43 +08:00
|
|
|
|
if strings.EqualFold(k, "Content-Length") || strings.EqualFold(k, "Transfer-Encoding") || strings.EqualFold(k, "Connection") {
|
2025-12-25 21:24:53 -08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, v := range vv {
|
|
|
|
|
|
c.Writer.Header().Add(k, v)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
contentType := res.Headers.Get("Content-Type")
|
|
|
|
|
|
if contentType == "" {
|
|
|
|
|
|
contentType = "application/json"
|
|
|
|
|
|
}
|
|
|
|
|
|
c.Data(res.StatusCode, contentType, res.Body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func shouldFallbackGeminiModels(res *service.UpstreamHTTPResult) bool {
|
|
|
|
|
|
if res == nil {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if res.StatusCode != http.StatusUnauthorized && res.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(strings.ToLower(res.Headers.Get("Www-Authenticate")), "insufficient_scope") {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(strings.ToLower(string(res.Body)), "insufficient authentication scopes") {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(strings.ToLower(string(res.Body)), "access_token_scope_insufficient") {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
|
2026-04-01 02:20:13 +08:00
|
|
|
|
func shouldFallbackGeminiModel(modelName string, res *service.UpstreamHTTPResult) bool {
|
|
|
|
|
|
if shouldFallbackGeminiModels(res) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if res == nil || res.StatusCode != http.StatusNotFound {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return gemini.HasFallbackModel(modelName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// extractGeminiCLISessionHash 从 Gemini CLI 请求中提取会话标识。
|
|
|
|
|
|
// 组合 x-gemini-api-privileged-user-id header 和请求体中的 tmp 目录哈希。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 会话标识生成策略:
|
|
|
|
|
|
// 1. 从请求体中提取 tmp 目录哈希(64位十六进制)
|
|
|
|
|
|
// 2. 从 header 中提取 privileged-user-id(UUID)
|
|
|
|
|
|
// 3. 组合两者生成 SHA256 哈希作为最终的会话标识
|
|
|
|
|
|
//
|
|
|
|
|
|
// 如果找不到 tmp 目录哈希,返回空字符串(不使用粘性会话)。
|
|
|
|
|
|
//
|
|
|
|
|
|
// extractGeminiCLISessionHash extracts session identifier from Gemini CLI requests.
|
|
|
|
|
|
// Combines x-gemini-api-privileged-user-id header with tmp directory hash from request body.
|
|
|
|
|
|
func extractGeminiCLISessionHash(c *gin.Context, body []byte) string {
|
|
|
|
|
|
// 1. 从请求体中提取 tmp 目录哈希
|
|
|
|
|
|
match := geminiCLITmpDirRegex.FindSubmatch(body)
|
|
|
|
|
|
if len(match) < 2 {
|
|
|
|
|
|
return "" // 没有找到 tmp 目录,不使用粘性会话
|
|
|
|
|
|
}
|
|
|
|
|
|
tmpDirHash := string(match[1])
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 提取 privileged-user-id
|
|
|
|
|
|
privilegedUserID := strings.TrimSpace(c.GetHeader("x-gemini-api-privileged-user-id"))
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 组合生成最终的 session hash
|
|
|
|
|
|
if privilegedUserID != "" {
|
|
|
|
|
|
// 组合两个标识符:privileged-user-id + tmp 目录哈希
|
|
|
|
|
|
combined := privilegedUserID + ":" + tmpDirHash
|
|
|
|
|
|
hash := sha256.Sum256([]byte(combined))
|
|
|
|
|
|
return hex.EncodeToString(hash[:])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果没有 privileged-user-id,直接使用 tmp 目录哈希
|
|
|
|
|
|
return tmpDirHash
|
|
|
|
|
|
}
|
2026-02-07 12:31:10 +08:00
|
|
|
|
|
|
|
|
|
|
// truncateDigestChain 截断摘要链用于日志显示
|
|
|
|
|
|
func truncateDigestChain(chain string) string {
|
|
|
|
|
|
if len(chain) <= 50 {
|
|
|
|
|
|
return chain
|
|
|
|
|
|
}
|
|
|
|
|
|
return chain[:50] + "..."
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 17:12:15 +08:00
|
|
|
|
// safeShortPrefix 返回字符串前 n 个字符;长度不足时返回原字符串。
|
|
|
|
|
|
// 用于日志展示,避免切片越界。
|
|
|
|
|
|
func safeShortPrefix(value string, n int) string {
|
|
|
|
|
|
if n <= 0 || len(value) <= n {
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
return value[:n]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:31:10 +08:00
|
|
|
|
// derefGroupID 安全解引用 *int64,nil 返回 0
|
|
|
|
|
|
func derefGroupID(groupID *int64) int64 {
|
|
|
|
|
|
if groupID == nil {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
return *groupID
|
|
|
|
|
|
}
|
2026-10-15 06:55:32 +09:00
|
|
|
|
|
|
|
|
|
|
func servesCompositeAntigravityGemini(apiKey *service.APIKey, effectivePlatform string) bool {
|
|
|
|
|
|
return apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite && effectivePlatform == service.PlatformAntigravity
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func isAntigravityGeminiModel(model string) bool {
|
|
|
|
|
|
trimmed := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(model, "models/")))
|
|
|
|
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "claude-") {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
detected, ok := service.DetectModelPlatform(trimmed)
|
|
|
|
|
|
return ok && detected == service.PlatformGemini
|
|
|
|
|
|
}
|