2025-12-18 13:50:39 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bufio"
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"log"
|
2026-02-02 22:13:50 +08:00
|
|
|
|
"log/slog"
|
2026-01-16 20:47:07 +08:00
|
|
|
|
mathrand "math/rand"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"net/http"
|
2026-01-16 17:26:05 +08:00
|
|
|
|
"os"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"regexp"
|
2026-01-01 04:01:51 +08:00
|
|
|
|
"sort"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"strings"
|
2026-01-04 20:19:07 +08:00
|
|
|
|
"sync/atomic"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"time"
|
2026-01-15 18:54:42 +08:00
|
|
|
|
"unicode"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
2025-12-29 17:46:52 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
2026-01-02 17:40:57 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
2026-01-15 18:54:42 +08:00
|
|
|
|
"github.com/google/uuid"
|
2025-12-25 14:47:19 +08:00
|
|
|
|
"github.com/tidwall/gjson"
|
|
|
|
|
|
"github.com/tidwall/sjson"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
2025-12-19 11:12:41 +08:00
|
|
|
|
claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
|
|
|
|
|
|
claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true"
|
|
|
|
|
|
stickySessionTTL = time.Hour // 粘性会话TTL
|
2026-01-09 20:57:06 +08:00
|
|
|
|
defaultMaxLineSize = 40 * 1024 * 1024
|
2026-01-29 15:37:07 +08:00
|
|
|
|
// Canonical Claude Code banner. Keep it EXACT (no trailing whitespace/newlines)
|
|
|
|
|
|
// to match real Claude CLI traffic as closely as possible. When we need a visual
|
|
|
|
|
|
// separator between system blocks, we add "\n\n" at concatenation time.
|
|
|
|
|
|
claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
2026-01-29 01:34:58 +08:00
|
|
|
|
maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-29 15:17:46 +08:00
|
|
|
|
const (
|
|
|
|
|
|
claudeMimicDebugInfoKey = "claude_mimic_debug_info"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-16 17:26:05 +08:00
|
|
|
|
func (s *GatewayService) debugModelRoutingEnabled() bool {
|
|
|
|
|
|
v := strings.ToLower(strings.TrimSpace(os.Getenv("SUB2API_DEBUG_MODEL_ROUTING")))
|
|
|
|
|
|
return v == "1" || v == "true" || v == "yes" || v == "on"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 03:13:14 +08:00
|
|
|
|
func (s *GatewayService) debugClaudeMimicEnabled() bool {
|
|
|
|
|
|
v := strings.ToLower(strings.TrimSpace(os.Getenv("SUB2API_DEBUG_CLAUDE_MIMIC")))
|
|
|
|
|
|
return v == "1" || v == "true" || v == "yes" || v == "on"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 17:26:05 +08:00
|
|
|
|
func shortSessionHash(sessionHash string) string {
|
|
|
|
|
|
if sessionHash == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(sessionHash) <= 8 {
|
|
|
|
|
|
return sessionHash
|
|
|
|
|
|
}
|
|
|
|
|
|
return sessionHash[:8]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-23 22:24:46 +08:00
|
|
|
|
func normalizeClaudeModelForAnthropic(requestedModel string) string {
|
|
|
|
|
|
for _, prefix := range anthropicPrefixMappings {
|
|
|
|
|
|
if strings.HasPrefix(requestedModel, prefix) {
|
|
|
|
|
|
return prefix
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return requestedModel
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 03:13:14 +08:00
|
|
|
|
func redactAuthHeaderValue(v string) string {
|
|
|
|
|
|
v = strings.TrimSpace(v)
|
|
|
|
|
|
if v == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
// Keep scheme for debugging, redact secret.
|
|
|
|
|
|
if strings.HasPrefix(strings.ToLower(v), "bearer ") {
|
|
|
|
|
|
return "Bearer [redacted]"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "[redacted]"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func safeHeaderValueForLog(key string, v string) string {
|
|
|
|
|
|
key = strings.ToLower(strings.TrimSpace(key))
|
|
|
|
|
|
switch key {
|
|
|
|
|
|
case "authorization", "x-api-key":
|
|
|
|
|
|
return redactAuthHeaderValue(v)
|
|
|
|
|
|
default:
|
|
|
|
|
|
return strings.TrimSpace(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func extractSystemPreviewFromBody(body []byte) string {
|
|
|
|
|
|
if len(body) == 0 {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
sys := gjson.GetBytes(body, "system")
|
|
|
|
|
|
if !sys.Exists() {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case sys.IsArray():
|
|
|
|
|
|
for _, item := range sys.Array() {
|
|
|
|
|
|
if !item.IsObject() {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.EqualFold(item.Get("type").String(), "text") {
|
|
|
|
|
|
if t := item.Get("text").String(); strings.TrimSpace(t) != "" {
|
|
|
|
|
|
return t
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
case sys.Type == gjson.String:
|
|
|
|
|
|
return sys.String()
|
|
|
|
|
|
default:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 15:17:46 +08:00
|
|
|
|
func buildClaudeMimicDebugLine(req *http.Request, body []byte, account *Account, tokenType string, mimicClaudeCode bool) string {
|
2026-01-29 03:13:14 +08:00
|
|
|
|
if req == nil {
|
2026-01-29 15:17:46 +08:00
|
|
|
|
return ""
|
2026-01-29 03:13:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Only log a minimal fingerprint to avoid leaking user content.
|
|
|
|
|
|
interesting := []string{
|
|
|
|
|
|
"user-agent",
|
|
|
|
|
|
"x-app",
|
|
|
|
|
|
"anthropic-dangerous-direct-browser-access",
|
|
|
|
|
|
"anthropic-version",
|
|
|
|
|
|
"anthropic-beta",
|
|
|
|
|
|
"x-stainless-lang",
|
|
|
|
|
|
"x-stainless-package-version",
|
|
|
|
|
|
"x-stainless-os",
|
|
|
|
|
|
"x-stainless-arch",
|
|
|
|
|
|
"x-stainless-runtime",
|
|
|
|
|
|
"x-stainless-runtime-version",
|
|
|
|
|
|
"x-stainless-retry-count",
|
|
|
|
|
|
"x-stainless-timeout",
|
|
|
|
|
|
"authorization",
|
|
|
|
|
|
"x-api-key",
|
|
|
|
|
|
"content-type",
|
|
|
|
|
|
"accept",
|
|
|
|
|
|
"x-stainless-helper-method",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
h := make([]string, 0, len(interesting))
|
|
|
|
|
|
for _, k := range interesting {
|
|
|
|
|
|
if v := req.Header.Get(k); v != "" {
|
|
|
|
|
|
h = append(h, fmt.Sprintf("%s=%q", k, safeHeaderValueForLog(k, v)))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
metaUserID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String())
|
|
|
|
|
|
sysPreview := strings.TrimSpace(extractSystemPreviewFromBody(body))
|
|
|
|
|
|
|
|
|
|
|
|
// Truncate preview to keep logs sane.
|
|
|
|
|
|
if len(sysPreview) > 300 {
|
|
|
|
|
|
sysPreview = sysPreview[:300] + "..."
|
|
|
|
|
|
}
|
|
|
|
|
|
sysPreview = strings.ReplaceAll(sysPreview, "\n", "\\n")
|
|
|
|
|
|
sysPreview = strings.ReplaceAll(sysPreview, "\r", "\\r")
|
|
|
|
|
|
|
|
|
|
|
|
aid := int64(0)
|
|
|
|
|
|
aname := ""
|
|
|
|
|
|
if account != nil {
|
|
|
|
|
|
aid = account.ID
|
|
|
|
|
|
aname = account.Name
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 15:17:46 +08:00
|
|
|
|
return fmt.Sprintf(
|
|
|
|
|
|
"url=%s account=%d(%s) tokenType=%s mimic=%t meta.user_id=%q system.preview=%q headers={%s}",
|
2026-01-29 03:13:14 +08:00
|
|
|
|
req.URL.String(),
|
|
|
|
|
|
aid,
|
|
|
|
|
|
aname,
|
|
|
|
|
|
tokenType,
|
|
|
|
|
|
mimicClaudeCode,
|
|
|
|
|
|
metaUserID,
|
|
|
|
|
|
sysPreview,
|
|
|
|
|
|
strings.Join(h, " "),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 15:17:46 +08:00
|
|
|
|
func logClaudeMimicDebug(req *http.Request, body []byte, account *Account, tokenType string, mimicClaudeCode bool) {
|
|
|
|
|
|
line := buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)
|
|
|
|
|
|
if line == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
log.Printf("[ClaudeMimicDebug] %s", line)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func isClaudeCodeCredentialScopeError(msg string) bool {
|
|
|
|
|
|
m := strings.ToLower(strings.TrimSpace(msg))
|
|
|
|
|
|
if m == "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Contains(m, "only authorized for use with claude code") &&
|
|
|
|
|
|
strings.Contains(m, "cannot be used for other api requests")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 03:49:55 -08:00
|
|
|
|
// sseDataRe matches SSE data lines with optional whitespace after colon.
|
|
|
|
|
|
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
|
2025-12-31 08:50:12 +08:00
|
|
|
|
var (
|
2026-01-04 10:38:13 +08:00
|
|
|
|
sseDataRe = regexp.MustCompile(`^data:\s*`)
|
|
|
|
|
|
sessionIDRegex = regexp.MustCompile(`session_([a-f0-9-]{36})`)
|
|
|
|
|
|
claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`)
|
2026-01-15 18:54:42 +08:00
|
|
|
|
toolPrefixRe = regexp.MustCompile(`(?i)^(?:oc_|mcp_)`)
|
|
|
|
|
|
toolNameBoundaryRe = regexp.MustCompile(`[^a-zA-Z0-9]+`)
|
|
|
|
|
|
toolNameCamelRe = regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
2026-01-15 19:17:07 +08:00
|
|
|
|
toolNameFieldRe = regexp.MustCompile(`"name"\s*:\s*"([^"]+)"`)
|
|
|
|
|
|
modelFieldRe = regexp.MustCompile(`"model"\s*:\s*"([^"]+)"`)
|
2026-01-16 00:41:29 +08:00
|
|
|
|
toolDescAbsPathRe = regexp.MustCompile(`/\/?(?:home|Users|tmp|var|opt|usr|etc)\/[^\s,\)"'\]]+`)
|
|
|
|
|
|
toolDescWinPathRe = regexp.MustCompile(`(?i)[A-Z]:\\[^\s,\)"'\]]+`)
|
2026-01-15 18:54:42 +08:00
|
|
|
|
|
|
|
|
|
|
claudeToolNameOverrides = map[string]string{
|
|
|
|
|
|
"bash": "Bash",
|
|
|
|
|
|
"read": "Read",
|
|
|
|
|
|
"edit": "Edit",
|
|
|
|
|
|
"write": "Write",
|
|
|
|
|
|
"task": "Task",
|
|
|
|
|
|
"glob": "Glob",
|
|
|
|
|
|
"grep": "Grep",
|
|
|
|
|
|
"webfetch": "WebFetch",
|
|
|
|
|
|
"websearch": "WebSearch",
|
|
|
|
|
|
"todowrite": "TodoWrite",
|
|
|
|
|
|
"question": "AskUserQuestion",
|
|
|
|
|
|
}
|
|
|
|
|
|
openCodeToolOverrides = map[string]string{
|
|
|
|
|
|
"Bash": "bash",
|
|
|
|
|
|
"Read": "read",
|
|
|
|
|
|
"Edit": "edit",
|
|
|
|
|
|
"Write": "write",
|
|
|
|
|
|
"Task": "task",
|
|
|
|
|
|
"Glob": "glob",
|
|
|
|
|
|
"Grep": "grep",
|
|
|
|
|
|
"WebFetch": "webfetch",
|
|
|
|
|
|
"WebSearch": "websearch",
|
|
|
|
|
|
"TodoWrite": "todowrite",
|
|
|
|
|
|
"AskUserQuestion": "question",
|
|
|
|
|
|
}
|
2026-01-07 10:17:09 +08:00
|
|
|
|
|
|
|
|
|
|
// claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
|
|
|
|
|
|
// 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等
|
|
|
|
|
|
// 注意:前缀之间不应存在包含关系,否则会导致冗余匹配
|
|
|
|
|
|
claudeCodePromptPrefixes = []string{
|
|
|
|
|
|
"You are Claude Code, Anthropic's official CLI for Claude", // 标准版 & Agent SDK 版(含 running within...)
|
|
|
|
|
|
"You are a Claude agent, built on Anthropic's Claude Agent SDK", // Agent SDK 变体
|
|
|
|
|
|
"You are a file search specialist for Claude Code", // Explore Agent 版
|
|
|
|
|
|
"You are a helpful AI assistant tasked with summarizing conversations", // Compact 版
|
|
|
|
|
|
}
|
2026-01-23 22:24:46 +08:00
|
|
|
|
|
|
|
|
|
|
anthropicPrefixMappings = []string{
|
|
|
|
|
|
"claude-opus-4-5",
|
|
|
|
|
|
"claude-haiku-4-5",
|
|
|
|
|
|
"claude-sonnet-4-5",
|
|
|
|
|
|
}
|
2025-12-31 08:50:12 +08:00
|
|
|
|
)
|
2025-12-26 03:49:55 -08:00
|
|
|
|
|
2026-01-08 23:07:00 +08:00
|
|
|
|
// ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
|
|
|
|
|
|
var ErrClaudeCodeOnly = errors.New("this group only allows Claude Code clients")
|
|
|
|
|
|
|
2026-02-02 22:20:08 +08:00
|
|
|
|
// ErrModelScopeNotSupported 表示请求的模型系列不在分组支持的范围内
|
|
|
|
|
|
var ErrModelScopeNotSupported = errors.New("model scope not supported by this group")
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// allowedHeaders 白名单headers(参考CRS项目)
|
|
|
|
|
|
var allowedHeaders = map[string]bool{
|
2025-12-18 18:14:20 +08:00
|
|
|
|
"accept": true,
|
|
|
|
|
|
"x-stainless-retry-count": true,
|
|
|
|
|
|
"x-stainless-timeout": true,
|
|
|
|
|
|
"x-stainless-lang": true,
|
|
|
|
|
|
"x-stainless-package-version": true,
|
|
|
|
|
|
"x-stainless-os": true,
|
|
|
|
|
|
"x-stainless-arch": true,
|
|
|
|
|
|
"x-stainless-runtime": true,
|
|
|
|
|
|
"x-stainless-runtime-version": true,
|
|
|
|
|
|
"x-stainless-helper-method": true,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"anthropic-dangerous-direct-browser-access": true,
|
2025-12-18 18:14:20 +08:00
|
|
|
|
"anthropic-version": true,
|
|
|
|
|
|
"x-app": true,
|
|
|
|
|
|
"anthropic-beta": true,
|
|
|
|
|
|
"accept-language": true,
|
|
|
|
|
|
"sec-fetch-mode": true,
|
|
|
|
|
|
"user-agent": true,
|
|
|
|
|
|
"content-type": true,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// GatewayCache 定义网关服务的缓存操作接口。
|
|
|
|
|
|
// 提供粘性会话(Sticky Session)的存储、查询、刷新和删除功能。
|
|
|
|
|
|
//
|
|
|
|
|
|
// GatewayCache defines cache operations for gateway service.
|
|
|
|
|
|
// Provides sticky session storage, retrieval, refresh and deletion capabilities.
|
2025-12-25 17:15:01 +08:00
|
|
|
|
type GatewayCache interface {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// GetSessionAccountID 获取粘性会话绑定的账号 ID
|
|
|
|
|
|
// Get the account ID bound to a sticky session
|
2026-01-08 23:07:00 +08:00
|
|
|
|
GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// SetSessionAccountID 设置粘性会话与账号的绑定关系
|
|
|
|
|
|
// Set the binding between sticky session and account
|
2026-01-08 23:07:00 +08:00
|
|
|
|
SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// RefreshSessionTTL 刷新粘性会话的过期时间
|
|
|
|
|
|
// Refresh the expiration time of a sticky session
|
2026-01-08 23:07:00 +08:00
|
|
|
|
RefreshSessionTTL(ctx context.Context, groupID int64, sessionHash string, ttl time.Duration) error
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// DeleteSessionAccountID 删除粘性会话绑定,用于账号不可用时主动清理
|
|
|
|
|
|
// Delete sticky session binding, used to proactively clean up when account becomes unavailable
|
|
|
|
|
|
DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error
|
2026-01-08 23:07:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// derefGroupID safely dereferences *int64 to int64, returning 0 if nil
|
|
|
|
|
|
func derefGroupID(groupID *int64) int64 {
|
|
|
|
|
|
if groupID == nil {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
return *groupID
|
2025-12-25 17:15:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// shouldClearStickySession 检查账号是否处于不可调度状态,需要清理粘性会话绑定。
|
|
|
|
|
|
// 当账号状态为错误、禁用、不可调度,或处于临时不可调度期间时,返回 true。
|
|
|
|
|
|
// 这确保后续请求不会继续使用不可用的账号。
|
|
|
|
|
|
//
|
|
|
|
|
|
// shouldClearStickySession checks if an account is in an unschedulable state
|
|
|
|
|
|
// and the sticky session binding should be cleared.
|
|
|
|
|
|
// Returns true when account status is error/disabled, schedulable is false,
|
|
|
|
|
|
// or within temporary unschedulable period.
|
|
|
|
|
|
// This ensures subsequent requests won't continue using unavailable accounts.
|
|
|
|
|
|
func shouldClearStickySession(account *Account) bool {
|
|
|
|
|
|
if account == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if account.Status == StatusError || account.Status == StatusDisabled || !account.Schedulable {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if account.TempUnschedulableUntil != nil && time.Now().Before(*account.TempUnschedulableUntil) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:01:51 +08:00
|
|
|
|
type AccountWaitPlan struct {
|
|
|
|
|
|
AccountID int64
|
|
|
|
|
|
MaxConcurrency int
|
|
|
|
|
|
Timeout time.Duration
|
|
|
|
|
|
MaxWaiting int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AccountSelectionResult struct {
|
|
|
|
|
|
Account *Account
|
|
|
|
|
|
Acquired bool
|
|
|
|
|
|
ReleaseFunc func()
|
|
|
|
|
|
WaitPlan *AccountWaitPlan // nil means no wait allowed
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// ClaudeUsage 表示Claude API返回的usage信息
|
|
|
|
|
|
type ClaudeUsage struct {
|
|
|
|
|
|
InputTokens int `json:"input_tokens"`
|
|
|
|
|
|
OutputTokens int `json:"output_tokens"`
|
|
|
|
|
|
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
|
|
|
|
|
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ForwardResult 转发结果
|
|
|
|
|
|
type ForwardResult struct {
|
2026-01-08 11:25:17 +08:00
|
|
|
|
RequestID string
|
|
|
|
|
|
Usage ClaudeUsage
|
|
|
|
|
|
Model string
|
|
|
|
|
|
Stream bool
|
|
|
|
|
|
Duration time.Duration
|
|
|
|
|
|
FirstTokenMs *int // 首字时间(流式请求)
|
|
|
|
|
|
ClientDisconnect bool // 客户端是否在流式传输过程中断开
|
2026-01-05 17:07:29 +08:00
|
|
|
|
|
|
|
|
|
|
// 图片生成计费字段(仅 gemini-3-pro-image 使用)
|
|
|
|
|
|
ImageCount int // 生成的图片数量
|
|
|
|
|
|
ImageSize string // 图片尺寸 "1K", "2K", "4K"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-27 11:44:00 +08:00
|
|
|
|
// UpstreamFailoverError indicates an upstream error that should trigger account failover.
|
|
|
|
|
|
type UpstreamFailoverError struct {
|
|
|
|
|
|
StatusCode int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (e *UpstreamFailoverError) Error() string {
|
|
|
|
|
|
return fmt.Sprintf("upstream error: %d (failover)", e.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// GatewayService handles API gateway operations
|
|
|
|
|
|
type GatewayService struct {
|
2025-12-25 17:15:01 +08:00
|
|
|
|
accountRepo AccountRepository
|
2025-12-29 09:44:39 +08:00
|
|
|
|
groupRepo GroupRepository
|
2025-12-25 17:15:01 +08:00
|
|
|
|
usageLogRepo UsageLogRepository
|
|
|
|
|
|
userRepo UserRepository
|
|
|
|
|
|
userSubRepo UserSubscriptionRepository
|
|
|
|
|
|
cache GatewayCache
|
2025-12-18 13:50:39 +08:00
|
|
|
|
cfg *config.Config
|
2026-01-15 19:42:18 +08:00
|
|
|
|
schedulerSnapshot *SchedulerSnapshotService
|
2025-12-18 13:50:39 +08:00
|
|
|
|
billingService *BillingService
|
|
|
|
|
|
rateLimitService *RateLimitService
|
|
|
|
|
|
billingCacheService *BillingCacheService
|
|
|
|
|
|
identityService *IdentityService
|
2025-12-25 17:15:01 +08:00
|
|
|
|
httpUpstream HTTPUpstream
|
2025-12-28 08:07:15 +08:00
|
|
|
|
deferredService *DeferredService
|
2026-01-01 04:01:51 +08:00
|
|
|
|
concurrencyService *ConcurrencyService
|
2026-01-15 19:42:18 +08:00
|
|
|
|
claudeTokenProvider *ClaudeTokenProvider
|
2026-01-16 23:36:52 +08:00
|
|
|
|
sessionLimitCache SessionLimitCache // 会话数量限制缓存(仅 Anthropic OAuth/SetupToken)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewGatewayService creates a new GatewayService
|
2025-12-19 21:26:19 +08:00
|
|
|
|
func NewGatewayService(
|
2025-12-25 17:15:01 +08:00
|
|
|
|
accountRepo AccountRepository,
|
2025-12-29 09:44:39 +08:00
|
|
|
|
groupRepo GroupRepository,
|
2025-12-25 17:15:01 +08:00
|
|
|
|
usageLogRepo UsageLogRepository,
|
|
|
|
|
|
userRepo UserRepository,
|
|
|
|
|
|
userSubRepo UserSubscriptionRepository,
|
|
|
|
|
|
cache GatewayCache,
|
2025-12-19 21:26:19 +08:00
|
|
|
|
cfg *config.Config,
|
2026-01-12 14:19:06 +08:00
|
|
|
|
schedulerSnapshot *SchedulerSnapshotService,
|
2026-01-01 04:01:51 +08:00
|
|
|
|
concurrencyService *ConcurrencyService,
|
2025-12-19 21:26:19 +08:00
|
|
|
|
billingService *BillingService,
|
|
|
|
|
|
rateLimitService *RateLimitService,
|
|
|
|
|
|
billingCacheService *BillingCacheService,
|
|
|
|
|
|
identityService *IdentityService,
|
2025-12-25 17:15:01 +08:00
|
|
|
|
httpUpstream HTTPUpstream,
|
2025-12-28 08:07:15 +08:00
|
|
|
|
deferredService *DeferredService,
|
2026-01-15 18:27:06 +08:00
|
|
|
|
claudeTokenProvider *ClaudeTokenProvider,
|
2026-01-16 23:36:52 +08:00
|
|
|
|
sessionLimitCache SessionLimitCache,
|
2025-12-19 21:26:19 +08:00
|
|
|
|
) *GatewayService {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return &GatewayService{
|
2025-12-19 21:26:19 +08:00
|
|
|
|
accountRepo: accountRepo,
|
2025-12-29 09:44:39 +08:00
|
|
|
|
groupRepo: groupRepo,
|
2025-12-19 21:26:19 +08:00
|
|
|
|
usageLogRepo: usageLogRepo,
|
|
|
|
|
|
userRepo: userRepo,
|
|
|
|
|
|
userSubRepo: userSubRepo,
|
2025-12-19 23:39:28 +08:00
|
|
|
|
cache: cache,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
cfg: cfg,
|
2026-01-15 19:42:18 +08:00
|
|
|
|
schedulerSnapshot: schedulerSnapshot,
|
2026-01-01 04:01:51 +08:00
|
|
|
|
concurrencyService: concurrencyService,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
billingService: billingService,
|
|
|
|
|
|
rateLimitService: rateLimitService,
|
|
|
|
|
|
billingCacheService: billingCacheService,
|
|
|
|
|
|
identityService: identityService,
|
2025-12-22 22:58:31 +08:00
|
|
|
|
httpUpstream: httpUpstream,
|
2025-12-28 08:07:15 +08:00
|
|
|
|
deferredService: deferredService,
|
2026-01-15 19:42:18 +08:00
|
|
|
|
claudeTokenProvider: claudeTokenProvider,
|
2026-01-16 23:36:52 +08:00
|
|
|
|
sessionLimitCache: sessionLimitCache,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-31 08:50:12 +08:00
|
|
|
|
// GenerateSessionHash 从预解析请求计算粘性会话 hash
|
|
|
|
|
|
func (s *GatewayService) GenerateSessionHash(parsed *ParsedRequest) string {
|
|
|
|
|
|
if parsed == nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-31 08:50:12 +08:00
|
|
|
|
// 1. 最高优先级:从 metadata.user_id 提取 session_xxx
|
|
|
|
|
|
if parsed.MetadataUserID != "" {
|
|
|
|
|
|
if match := sessionIDRegex.FindStringSubmatch(parsed.MetadataUserID); len(match) > 1 {
|
|
|
|
|
|
return match[1]
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-31 08:50:12 +08:00
|
|
|
|
// 2. 提取带 cache_control: {type: "ephemeral"} 的内容
|
|
|
|
|
|
cacheableContent := s.extractCacheableContent(parsed)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if cacheableContent != "" {
|
|
|
|
|
|
return s.hashContent(cacheableContent)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-31 08:50:12 +08:00
|
|
|
|
// 3. Fallback: 使用 system 内容
|
|
|
|
|
|
if parsed.System != nil {
|
|
|
|
|
|
systemText := s.extractTextFromSystem(parsed.System)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if systemText != "" {
|
|
|
|
|
|
return s.hashContent(systemText)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-31 08:50:12 +08:00
|
|
|
|
// 4. 最后 fallback: 使用第一条消息
|
|
|
|
|
|
if len(parsed.Messages) > 0 {
|
|
|
|
|
|
if firstMsg, ok := parsed.Messages[0].(map[string]any); ok {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
msgText := s.extractTextFromContent(firstMsg["content"])
|
|
|
|
|
|
if msgText != "" {
|
|
|
|
|
|
return s.hashContent(msgText)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:01:51 +08:00
|
|
|
|
// BindStickySession sets session -> account binding with standard TTL.
|
2026-01-08 23:07:00 +08:00
|
|
|
|
func (s *GatewayService) BindStickySession(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error {
|
2026-01-02 17:30:07 +08:00
|
|
|
|
if sessionHash == "" || accountID <= 0 || s.cache == nil {
|
2026-01-01 04:01:51 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-01-08 23:07:00 +08:00
|
|
|
|
return s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, accountID, stickySessionTTL)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// GetCachedSessionAccountID retrieves the account ID bound to a sticky session.
|
|
|
|
|
|
// Returns 0 if no binding exists or on error.
|
|
|
|
|
|
func (s *GatewayService) GetCachedSessionAccountID(ctx context.Context, groupID *int64, sessionHash string) (int64, error) {
|
|
|
|
|
|
if sessionHash == "" || s.cache == nil {
|
|
|
|
|
|
return 0, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return accountID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-31 08:50:12 +08:00
|
|
|
|
func (s *GatewayService) extractCacheableContent(parsed *ParsedRequest) string {
|
|
|
|
|
|
if parsed == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2025-12-31 08:50:12 +08:00
|
|
|
|
var builder strings.Builder
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2025-12-31 08:50:12 +08:00
|
|
|
|
// 检查 system 中的 cacheable 内容
|
|
|
|
|
|
if system, ok := parsed.System.([]any); ok {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
for _, part := range system {
|
2025-12-20 16:19:40 +08:00
|
|
|
|
if partMap, ok := part.(map[string]any); ok {
|
|
|
|
|
|
if cc, ok := partMap["cache_control"].(map[string]any); ok {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if cc["type"] == "ephemeral" {
|
|
|
|
|
|
if text, ok := partMap["text"].(string); ok {
|
2025-12-31 14:51:58 +08:00
|
|
|
|
_, _ = builder.WriteString(text)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-31 08:50:12 +08:00
|
|
|
|
systemText := builder.String()
|
|
|
|
|
|
|
|
|
|
|
|
// 检查 messages 中的 cacheable 内容
|
|
|
|
|
|
for _, msg := range parsed.Messages {
|
|
|
|
|
|
if msgMap, ok := msg.(map[string]any); ok {
|
|
|
|
|
|
if msgContent, ok := msgMap["content"].([]any); ok {
|
|
|
|
|
|
for _, part := range msgContent {
|
|
|
|
|
|
if partMap, ok := part.(map[string]any); ok {
|
|
|
|
|
|
if cc, ok := partMap["cache_control"].(map[string]any); ok {
|
|
|
|
|
|
if cc["type"] == "ephemeral" {
|
|
|
|
|
|
return s.extractTextFromContent(msgMap["content"])
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-31 08:50:12 +08:00
|
|
|
|
return systemText
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 16:19:40 +08:00
|
|
|
|
func (s *GatewayService) extractTextFromSystem(system any) string {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
switch v := system.(type) {
|
|
|
|
|
|
case string:
|
|
|
|
|
|
return v
|
2025-12-20 16:19:40 +08:00
|
|
|
|
case []any:
|
2025-12-18 13:50:39 +08:00
|
|
|
|
var texts []string
|
|
|
|
|
|
for _, part := range v {
|
2025-12-20 16:19:40 +08:00
|
|
|
|
if partMap, ok := part.(map[string]any); ok {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if text, ok := partMap["text"].(string); ok {
|
|
|
|
|
|
texts = append(texts, text)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(texts, "")
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 16:19:40 +08:00
|
|
|
|
func (s *GatewayService) extractTextFromContent(content any) string {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
switch v := content.(type) {
|
|
|
|
|
|
case string:
|
|
|
|
|
|
return v
|
2025-12-20 16:19:40 +08:00
|
|
|
|
case []any:
|
2025-12-18 13:50:39 +08:00
|
|
|
|
var texts []string
|
|
|
|
|
|
for _, part := range v {
|
2025-12-20 16:19:40 +08:00
|
|
|
|
if partMap, ok := part.(map[string]any); ok {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if partMap["type"] == "text" {
|
|
|
|
|
|
if text, ok := partMap["text"].(string); ok {
|
|
|
|
|
|
texts = append(texts, text)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(texts, "")
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *GatewayService) hashContent(content string) string {
|
|
|
|
|
|
hash := sha256.Sum256([]byte(content))
|
|
|
|
|
|
return hex.EncodeToString(hash[:16]) // 32字符
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// replaceModelInBody 替换请求体中的model字段
|
|
|
|
|
|
func (s *GatewayService) replaceModelInBody(body []byte, newModel string) []byte {
|
2025-12-20 16:19:40 +08:00
|
|
|
|
var req map[string]any
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
req["model"] = newModel
|
|
|
|
|
|
newBody, err := json.Marshal(req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
return newBody
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 18:54:42 +08:00
|
|
|
|
type claudeOAuthNormalizeOptions struct {
|
|
|
|
|
|
injectMetadata bool
|
|
|
|
|
|
metadataUserID string
|
|
|
|
|
|
stripSystemCacheControl bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func stripToolPrefix(value string) string {
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
return toolPrefixRe.ReplaceAllString(value, "")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func toPascalCase(value string) string {
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
normalized := toolNameBoundaryRe.ReplaceAllString(value, " ")
|
|
|
|
|
|
tokens := make([]string, 0)
|
|
|
|
|
|
for _, token := range strings.Fields(normalized) {
|
|
|
|
|
|
expanded := toolNameCamelRe.ReplaceAllString(token, "$1 $2")
|
|
|
|
|
|
parts := strings.Fields(expanded)
|
|
|
|
|
|
if len(parts) > 0 {
|
|
|
|
|
|
tokens = append(tokens, parts...)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(tokens) == 0 {
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
var builder strings.Builder
|
|
|
|
|
|
for _, token := range tokens {
|
|
|
|
|
|
lower := strings.ToLower(token)
|
|
|
|
|
|
if lower == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
runes := []rune(lower)
|
|
|
|
|
|
runes[0] = unicode.ToUpper(runes[0])
|
2026-01-17 18:16:34 +08:00
|
|
|
|
_, _ = builder.WriteString(string(runes))
|
2026-01-15 18:54:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
return builder.String()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func toSnakeCase(value string) string {
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
output := toolNameCamelRe.ReplaceAllString(value, "$1_$2")
|
|
|
|
|
|
output = toolNameBoundaryRe.ReplaceAllString(output, "_")
|
|
|
|
|
|
output = strings.Trim(output, "_")
|
|
|
|
|
|
return strings.ToLower(output)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeToolNameForClaude(name string, cache map[string]string) string {
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
return name
|
|
|
|
|
|
}
|
|
|
|
|
|
stripped := stripToolPrefix(name)
|
|
|
|
|
|
mapped, ok := claudeToolNameOverrides[strings.ToLower(stripped)]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
mapped = toPascalCase(stripped)
|
|
|
|
|
|
}
|
|
|
|
|
|
if mapped != "" && cache != nil && mapped != stripped {
|
|
|
|
|
|
cache[mapped] = stripped
|
|
|
|
|
|
}
|
|
|
|
|
|
if mapped == "" {
|
|
|
|
|
|
return stripped
|
|
|
|
|
|
}
|
|
|
|
|
|
return mapped
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeToolNameForOpenCode(name string, cache map[string]string) string {
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
return name
|
|
|
|
|
|
}
|
2026-01-16 00:41:29 +08:00
|
|
|
|
stripped := stripToolPrefix(name)
|
2026-01-15 18:54:42 +08:00
|
|
|
|
if cache != nil {
|
2026-01-16 00:41:29 +08:00
|
|
|
|
if mapped, ok := cache[stripped]; ok {
|
2026-01-15 18:54:42 +08:00
|
|
|
|
return mapped
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-16 00:41:29 +08:00
|
|
|
|
if mapped, ok := openCodeToolOverrides[stripped]; ok {
|
2026-01-15 18:54:42 +08:00
|
|
|
|
return mapped
|
|
|
|
|
|
}
|
2026-01-16 00:41:29 +08:00
|
|
|
|
return toSnakeCase(stripped)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeParamNameForOpenCode(name string, cache map[string]string) string {
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
return name
|
|
|
|
|
|
}
|
|
|
|
|
|
if cache != nil {
|
|
|
|
|
|
if mapped, ok := cache[name]; ok {
|
|
|
|
|
|
return mapped
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return name
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 01:40:38 +08:00
|
|
|
|
// sanitizeSystemText rewrites only the fixed OpenCode identity sentence (if present).
|
|
|
|
|
|
// We intentionally avoid broad keyword replacement in system prompts to prevent
|
|
|
|
|
|
// accidentally changing user-provided instructions.
|
|
|
|
|
|
func sanitizeSystemText(text string) string {
|
2026-01-16 00:41:29 +08:00
|
|
|
|
if text == "" {
|
|
|
|
|
|
return text
|
|
|
|
|
|
}
|
2026-01-29 03:03:40 +08:00
|
|
|
|
// Some clients include a fixed OpenCode identity sentence. Anthropic may treat
|
|
|
|
|
|
// this as a non-Claude-Code fingerprint, so rewrite it to the canonical
|
|
|
|
|
|
// Claude Code banner before generic "OpenCode"/"opencode" replacements.
|
|
|
|
|
|
text = strings.ReplaceAll(
|
|
|
|
|
|
text,
|
|
|
|
|
|
"You are OpenCode, the best coding agent on the planet.",
|
|
|
|
|
|
strings.TrimSpace(claudeCodeSystemPrompt),
|
|
|
|
|
|
)
|
2026-01-31 01:40:38 +08:00
|
|
|
|
return text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 00:41:29 +08:00
|
|
|
|
func sanitizeToolDescription(description string) string {
|
|
|
|
|
|
if description == "" {
|
|
|
|
|
|
return description
|
|
|
|
|
|
}
|
|
|
|
|
|
description = toolDescAbsPathRe.ReplaceAllString(description, "[path]")
|
|
|
|
|
|
description = toolDescWinPathRe.ReplaceAllString(description, "[path]")
|
2026-01-31 02:01:51 +08:00
|
|
|
|
// Intentionally do NOT rewrite tool descriptions (OpenCode/Claude strings).
|
|
|
|
|
|
// Tool names/skill names may rely on exact wording, and rewriting can be misleading.
|
|
|
|
|
|
return description
|
2026-01-16 00:41:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeToolInputSchema(inputSchema any, cache map[string]string) {
|
|
|
|
|
|
schema, ok := inputSchema.(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
properties, ok := schema["properties"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
newProperties := make(map[string]any, len(properties))
|
|
|
|
|
|
for key, value := range properties {
|
|
|
|
|
|
snakeKey := toSnakeCase(key)
|
|
|
|
|
|
newProperties[snakeKey] = value
|
|
|
|
|
|
if snakeKey != key && cache != nil {
|
|
|
|
|
|
cache[snakeKey] = key
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
schema["properties"] = newProperties
|
|
|
|
|
|
|
|
|
|
|
|
if required, ok := schema["required"].([]any); ok {
|
|
|
|
|
|
newRequired := make([]any, 0, len(required))
|
|
|
|
|
|
for _, item := range required {
|
|
|
|
|
|
name, ok := item.(string)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
newRequired = append(newRequired, item)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
snakeName := toSnakeCase(name)
|
|
|
|
|
|
newRequired = append(newRequired, snakeName)
|
|
|
|
|
|
if snakeName != name && cache != nil {
|
|
|
|
|
|
cache[snakeName] = name
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
schema["required"] = newRequired
|
|
|
|
|
|
}
|
2026-01-15 18:54:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func stripCacheControlFromSystemBlocks(system any) bool {
|
|
|
|
|
|
blocks, ok := system.([]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
changed := false
|
|
|
|
|
|
for _, item := range blocks {
|
|
|
|
|
|
block, ok := item.(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, exists := block["cache_control"]; !exists {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
delete(block, "cache_control")
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
return changed
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAuthNormalizeOptions) ([]byte, string, map[string]string) {
|
|
|
|
|
|
if len(body) == 0 {
|
|
|
|
|
|
return body, modelID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
var req map[string]any
|
|
|
|
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
|
|
|
|
return body, modelID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
toolNameMap := make(map[string]string)
|
|
|
|
|
|
|
2026-01-16 00:41:29 +08:00
|
|
|
|
if system, ok := req["system"]; ok {
|
|
|
|
|
|
switch v := system.(type) {
|
|
|
|
|
|
case string:
|
2026-01-31 01:40:38 +08:00
|
|
|
|
sanitized := sanitizeSystemText(v)
|
2026-01-16 00:41:29 +08:00
|
|
|
|
if sanitized != v {
|
|
|
|
|
|
req["system"] = sanitized
|
|
|
|
|
|
}
|
|
|
|
|
|
case []any:
|
|
|
|
|
|
for _, item := range v {
|
|
|
|
|
|
block, ok := item.(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if blockType, _ := block["type"].(string); blockType != "text" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
text, ok := block["text"].(string)
|
|
|
|
|
|
if !ok || text == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-31 01:40:38 +08:00
|
|
|
|
sanitized := sanitizeSystemText(text)
|
2026-01-16 00:41:29 +08:00
|
|
|
|
if sanitized != text {
|
|
|
|
|
|
block["text"] = sanitized
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 18:54:42 +08:00
|
|
|
|
if rawModel, ok := req["model"].(string); ok {
|
|
|
|
|
|
normalized := claude.NormalizeModelID(rawModel)
|
|
|
|
|
|
if normalized != rawModel {
|
|
|
|
|
|
req["model"] = normalized
|
|
|
|
|
|
modelID = normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if rawTools, exists := req["tools"]; exists {
|
|
|
|
|
|
switch tools := rawTools.(type) {
|
|
|
|
|
|
case []any:
|
|
|
|
|
|
for idx, tool := range tools {
|
|
|
|
|
|
toolMap, ok := tool.(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if name, ok := toolMap["name"].(string); ok {
|
|
|
|
|
|
normalized := normalizeToolNameForClaude(name, toolNameMap)
|
|
|
|
|
|
if normalized != "" && normalized != name {
|
|
|
|
|
|
toolMap["name"] = normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-16 00:41:29 +08:00
|
|
|
|
if desc, ok := toolMap["description"].(string); ok {
|
|
|
|
|
|
sanitized := sanitizeToolDescription(desc)
|
|
|
|
|
|
if sanitized != desc {
|
|
|
|
|
|
toolMap["description"] = sanitized
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if schema, ok := toolMap["input_schema"]; ok {
|
|
|
|
|
|
normalizeToolInputSchema(schema, toolNameMap)
|
|
|
|
|
|
}
|
2026-01-15 18:54:42 +08:00
|
|
|
|
tools[idx] = toolMap
|
|
|
|
|
|
}
|
|
|
|
|
|
req["tools"] = tools
|
|
|
|
|
|
case map[string]any:
|
|
|
|
|
|
normalizedTools := make(map[string]any, len(tools))
|
|
|
|
|
|
for name, value := range tools {
|
|
|
|
|
|
normalized := normalizeToolNameForClaude(name, toolNameMap)
|
|
|
|
|
|
if normalized == "" {
|
|
|
|
|
|
normalized = name
|
|
|
|
|
|
}
|
|
|
|
|
|
if toolMap, ok := value.(map[string]any); ok {
|
2026-01-16 00:41:29 +08:00
|
|
|
|
toolMap["name"] = normalized
|
|
|
|
|
|
if desc, ok := toolMap["description"].(string); ok {
|
|
|
|
|
|
sanitized := sanitizeToolDescription(desc)
|
|
|
|
|
|
if sanitized != desc {
|
|
|
|
|
|
toolMap["description"] = sanitized
|
2026-01-15 18:54:42 +08:00
|
|
|
|
}
|
2026-01-16 00:41:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
if schema, ok := toolMap["input_schema"]; ok {
|
|
|
|
|
|
normalizeToolInputSchema(schema, toolNameMap)
|
2026-01-15 18:54:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
normalizedTools[normalized] = toolMap
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
normalizedTools[normalized] = value
|
|
|
|
|
|
}
|
|
|
|
|
|
req["tools"] = normalizedTools
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
req["tools"] = []any{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if messages, ok := req["messages"].([]any); ok {
|
|
|
|
|
|
for _, msg := range messages {
|
|
|
|
|
|
msgMap, ok := msg.(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
content, ok := msgMap["content"].([]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, block := range content {
|
|
|
|
|
|
blockMap, ok := block.(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if blockType, _ := blockMap["type"].(string); blockType != "tool_use" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if name, ok := blockMap["name"].(string); ok {
|
|
|
|
|
|
normalized := normalizeToolNameForClaude(name, toolNameMap)
|
|
|
|
|
|
if normalized != "" && normalized != name {
|
|
|
|
|
|
blockMap["name"] = normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if opts.stripSystemCacheControl {
|
|
|
|
|
|
if system, ok := req["system"]; ok {
|
|
|
|
|
|
_ = stripCacheControlFromSystemBlocks(system)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if opts.injectMetadata && opts.metadataUserID != "" {
|
|
|
|
|
|
metadata, ok := req["metadata"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
metadata = map[string]any{}
|
|
|
|
|
|
req["metadata"] = metadata
|
|
|
|
|
|
}
|
|
|
|
|
|
if existing, ok := metadata["user_id"].(string); !ok || existing == "" {
|
|
|
|
|
|
metadata["user_id"] = opts.metadataUserID
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 18:16:34 +08:00
|
|
|
|
delete(req, "temperature")
|
|
|
|
|
|
delete(req, "tool_choice")
|
2026-01-15 18:54:42 +08:00
|
|
|
|
|
|
|
|
|
|
newBody, err := json.Marshal(req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return body, modelID, toolNameMap
|
|
|
|
|
|
}
|
|
|
|
|
|
return newBody, modelID, toolNameMap
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account *Account, fp *Fingerprint) string {
|
2026-01-16 00:41:29 +08:00
|
|
|
|
if parsed == nil || account == nil {
|
2026-01-15 18:54:42 +08:00
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if parsed.MetadataUserID != "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
2026-01-16 00:41:29 +08:00
|
|
|
|
|
|
|
|
|
|
userID := strings.TrimSpace(account.GetClaudeUserID())
|
|
|
|
|
|
if userID == "" && fp != nil {
|
|
|
|
|
|
userID = fp.ClientID
|
|
|
|
|
|
}
|
|
|
|
|
|
if userID == "" {
|
2026-01-29 01:49:51 +08:00
|
|
|
|
// Fall back to a random, well-formed client id so we can still satisfy
|
|
|
|
|
|
// Claude Code OAuth requirements when account metadata is incomplete.
|
|
|
|
|
|
userID = generateClientID()
|
2026-01-16 00:41:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 18:54:42 +08:00
|
|
|
|
sessionHash := s.GenerateSessionHash(parsed)
|
|
|
|
|
|
sessionID := uuid.NewString()
|
|
|
|
|
|
if sessionHash != "" {
|
|
|
|
|
|
seed := fmt.Sprintf("%d::%s", account.ID, sessionHash)
|
|
|
|
|
|
sessionID = generateSessionUUID(seed)
|
|
|
|
|
|
}
|
2026-01-29 01:49:51 +08:00
|
|
|
|
|
|
|
|
|
|
// Prefer the newer format that includes account_uuid (if present),
|
|
|
|
|
|
// otherwise fall back to the legacy Claude Code format.
|
|
|
|
|
|
accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid"))
|
|
|
|
|
|
if accountUUID != "" {
|
|
|
|
|
|
return fmt.Sprintf("user_%s_account_%s_session_%s", userID, accountUUID, sessionID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Sprintf("user_%s_account__session_%s", userID, sessionID)
|
2026-01-15 18:54:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func generateSessionUUID(seed string) string {
|
|
|
|
|
|
if seed == "" {
|
|
|
|
|
|
return uuid.NewString()
|
|
|
|
|
|
}
|
|
|
|
|
|
hash := sha256.Sum256([]byte(seed))
|
|
|
|
|
|
bytes := hash[:16]
|
|
|
|
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
|
|
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
|
|
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x",
|
|
|
|
|
|
bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// SelectAccount 选择账号(粘性会话+优先级)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *GatewayService) SelectAccount(ctx context.Context, groupID *int64, sessionHash string) (*Account, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return s.SelectAccountForModel(ctx, groupID, sessionHash, "")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SelectAccountForModel 选择支持指定模型的账号(粘性会话+优先级+模型映射)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) {
|
2025-12-27 11:44:00 +08:00
|
|
|
|
return s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
|
|
|
|
|
|
func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
|
2025-12-29 16:52:55 +08:00
|
|
|
|
// 优先检查 context 中的强制平台(/antigravity 路由)
|
2025-12-29 09:44:39 +08:00
|
|
|
|
var platform string
|
2025-12-29 17:46:52 +08:00
|
|
|
|
forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string)
|
2025-12-29 16:52:55 +08:00
|
|
|
|
if hasForcePlatform && forcePlatform != "" {
|
|
|
|
|
|
platform = forcePlatform
|
|
|
|
|
|
} else if groupID != nil {
|
2026-01-09 23:01:42 +08:00
|
|
|
|
group, resolvedGroupID, err := s.resolveGatewayGroup(ctx, groupID)
|
2025-12-29 09:44:39 +08:00
|
|
|
|
if err != nil {
|
2026-01-09 23:01:42 +08:00
|
|
|
|
return nil, err
|
2025-12-29 09:44:39 +08:00
|
|
|
|
}
|
2026-01-09 23:01:42 +08:00
|
|
|
|
groupID = resolvedGroupID
|
|
|
|
|
|
ctx = s.withGroupContext(ctx, group)
|
2025-12-29 09:44:39 +08:00
|
|
|
|
platform = group.Platform
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 无分组时只使用原生 anthropic 平台
|
|
|
|
|
|
platform = PlatformAnthropic
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// anthropic/gemini 分组支持混合调度(包含启用了 mixed_scheduling 的 antigravity 账户)
|
2025-12-29 16:52:55 +08:00
|
|
|
|
// 注意:强制平台模式不走混合调度
|
|
|
|
|
|
if (platform == PlatformAnthropic || platform == PlatformGemini) && !hasForcePlatform {
|
2025-12-29 09:44:39 +08:00
|
|
|
|
return s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 16:52:55 +08:00
|
|
|
|
// antigravity 分组、强制平台模式或无分组使用单平台选择
|
2026-01-07 10:56:52 +08:00
|
|
|
|
// 注意:强制平台模式也必须遵守分组限制,不再回退到全平台查询
|
2025-12-29 09:44:39 +08:00
|
|
|
|
return s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
|
2025-12-28 17:48:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:01:51 +08:00
|
|
|
|
// SelectAccountWithLoadAwareness selects account with load-awareness and wait plan.
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// metadataUserID: 已废弃参数,会话限制现在统一使用 sessionHash
|
2026-01-16 23:36:52 +08:00
|
|
|
|
func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string) (*AccountSelectionResult, error) {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 调试日志:记录调度入口参数
|
|
|
|
|
|
excludedIDsList := make([]int64, 0, len(excludedIDs))
|
|
|
|
|
|
for id := range excludedIDs {
|
|
|
|
|
|
excludedIDsList = append(excludedIDsList, id)
|
|
|
|
|
|
}
|
|
|
|
|
|
slog.Debug("account_scheduling_starting",
|
|
|
|
|
|
"group_id", derefGroupID(groupID),
|
|
|
|
|
|
"model", requestedModel,
|
|
|
|
|
|
"session", shortSessionHash(sessionHash),
|
|
|
|
|
|
"excluded_ids", excludedIDsList)
|
|
|
|
|
|
|
2026-01-01 04:01:51 +08:00
|
|
|
|
cfg := s.schedulingConfig()
|
2026-01-16 23:36:52 +08:00
|
|
|
|
|
2026-01-01 04:01:51 +08:00
|
|
|
|
var stickyAccountID int64
|
|
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
2026-01-08 23:07:00 +08:00
|
|
|
|
if accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash); err == nil {
|
2026-01-01 04:01:51 +08:00
|
|
|
|
stickyAccountID = accountID
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-08 23:07:00 +08:00
|
|
|
|
|
|
|
|
|
|
// 检查 Claude Code 客户端限制(可能会替换 groupID 为降级分组)
|
2026-01-09 23:01:42 +08:00
|
|
|
|
group, groupID, err := s.checkClaudeCodeRestriction(ctx, groupID)
|
2026-01-08 23:07:00 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2026-01-09 23:01:42 +08:00
|
|
|
|
ctx = s.withGroupContext(ctx, group)
|
2026-01-08 23:07:00 +08:00
|
|
|
|
|
2026-01-16 17:26:05 +08:00
|
|
|
|
if s.debugModelRoutingEnabled() && requestedModel != "" {
|
|
|
|
|
|
groupPlatform := ""
|
|
|
|
|
|
if group != nil {
|
|
|
|
|
|
groupPlatform = group.Platform
|
|
|
|
|
|
}
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] select entry: group_id=%v group_platform=%s model=%s session=%s sticky_account=%d load_batch=%v concurrency=%v",
|
|
|
|
|
|
derefGroupID(groupID), groupPlatform, requestedModel, shortSessionHash(sessionHash), stickyAccountID, cfg.LoadBatchEnabled, s.concurrencyService != nil)
|
|
|
|
|
|
}
|
2026-01-08 23:07:00 +08:00
|
|
|
|
|
2026-01-01 04:01:51 +08:00
|
|
|
|
if s.concurrencyService == nil || !cfg.LoadBatchEnabled {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 复制排除列表,用于会话限制拒绝时的重试
|
|
|
|
|
|
localExcluded := make(map[int64]struct{})
|
|
|
|
|
|
for k, v := range excludedIDs {
|
|
|
|
|
|
localExcluded[k] = v
|
2026-01-01 04:01:51 +08:00
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
|
account, err := s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, localExcluded)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result, err := s.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency)
|
|
|
|
|
|
if err == nil && result.Acquired {
|
|
|
|
|
|
// 获取槽位后检查会话限制(使用 sessionHash 作为会话标识符)
|
|
|
|
|
|
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
|
|
|
|
|
result.ReleaseFunc() // 释放槽位
|
|
|
|
|
|
localExcluded[account.ID] = struct{}{} // 排除此账号
|
|
|
|
|
|
continue // 重新选择
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
return &AccountSelectionResult{
|
2026-02-02 22:13:50 +08:00
|
|
|
|
Account: account,
|
|
|
|
|
|
Acquired: true,
|
|
|
|
|
|
ReleaseFunc: result.ReleaseFunc,
|
2026-01-01 04:01:51 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 对于等待计划的情况,也需要先检查会话限制
|
|
|
|
|
|
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
|
|
|
|
|
localExcluded[account.ID] = struct{}{}
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if stickyAccountID > 0 && stickyAccountID == account.ID && s.concurrencyService != nil {
|
|
|
|
|
|
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, account.ID)
|
|
|
|
|
|
if waitingCount < cfg.StickySessionMaxWaiting {
|
|
|
|
|
|
return &AccountSelectionResult{
|
|
|
|
|
|
Account: account,
|
|
|
|
|
|
WaitPlan: &AccountWaitPlan{
|
|
|
|
|
|
AccountID: account.ID,
|
|
|
|
|
|
MaxConcurrency: account.Concurrency,
|
|
|
|
|
|
Timeout: cfg.StickySessionWaitTimeout,
|
|
|
|
|
|
MaxWaiting: cfg.StickySessionMaxWaiting,
|
|
|
|
|
|
},
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return &AccountSelectionResult{
|
|
|
|
|
|
Account: account,
|
|
|
|
|
|
WaitPlan: &AccountWaitPlan{
|
|
|
|
|
|
AccountID: account.ID,
|
|
|
|
|
|
MaxConcurrency: account.Concurrency,
|
|
|
|
|
|
Timeout: cfg.FallbackWaitTimeout,
|
|
|
|
|
|
MaxWaiting: cfg.FallbackMaxWaiting,
|
|
|
|
|
|
},
|
|
|
|
|
|
}, nil
|
2026-01-01 04:01:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
platform, hasForcePlatform, err := s.resolvePlatform(ctx, groupID, group)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
preferOAuth := platform == PlatformGemini
|
2026-01-16 17:26:05 +08:00
|
|
|
|
if s.debugModelRoutingEnabled() && platform == PlatformAnthropic && requestedModel != "" {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] load-aware enabled: group_id=%v model=%s session=%s platform=%s", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), platform)
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
|
2026-02-02 22:20:08 +08:00
|
|
|
|
// Antigravity 模型系列检查(在账号选择前检查,确保所有代码路径都经过此检查)
|
|
|
|
|
|
if platform == PlatformAntigravity && groupID != nil && requestedModel != "" {
|
|
|
|
|
|
if err := s.checkAntigravityModelScope(ctx, *groupID, requestedModel); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:01:51 +08:00
|
|
|
|
accounts, useMixed, err := s.listSchedulableAccounts(ctx, groupID, platform, hasForcePlatform)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(accounts) == 0 {
|
|
|
|
|
|
return nil, errors.New("no available accounts")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isExcluded := func(accountID int64) bool {
|
|
|
|
|
|
if excludedIDs == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
_, excluded := excludedIDs[accountID]
|
|
|
|
|
|
return excluded
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 17:26:05 +08:00
|
|
|
|
// 提前构建 accountByID(供 Layer 1 和 Layer 1.5 使用)
|
|
|
|
|
|
accountByID := make(map[int64]*Account, len(accounts))
|
|
|
|
|
|
for i := range accounts {
|
|
|
|
|
|
accountByID[accounts[i].ID] = &accounts[i]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取模型路由配置(仅 anthropic 平台)
|
|
|
|
|
|
var routingAccountIDs []int64
|
|
|
|
|
|
if group != nil && requestedModel != "" && group.Platform == PlatformAnthropic {
|
|
|
|
|
|
routingAccountIDs = group.GetRoutingAccountIDs(requestedModel)
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] context group routing: group_id=%d model=%s enabled=%v rules=%d matched_ids=%v session=%s sticky_account=%d",
|
|
|
|
|
|
group.ID, requestedModel, group.ModelRoutingEnabled, len(group.ModelRouting), routingAccountIDs, shortSessionHash(sessionHash), stickyAccountID)
|
|
|
|
|
|
if len(routingAccountIDs) == 0 && group.ModelRoutingEnabled && len(group.ModelRouting) > 0 {
|
|
|
|
|
|
keys := make([]string, 0, len(group.ModelRouting))
|
|
|
|
|
|
for k := range group.ModelRouting {
|
|
|
|
|
|
keys = append(keys, k)
|
|
|
|
|
|
}
|
|
|
|
|
|
sort.Strings(keys)
|
|
|
|
|
|
const maxKeys = 20
|
|
|
|
|
|
if len(keys) > maxKeys {
|
|
|
|
|
|
keys = keys[:maxKeys]
|
|
|
|
|
|
}
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] context group routing miss: group_id=%d model=%s patterns(sample)=%v", group.ID, requestedModel, keys)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============ Layer 1: 模型路由优先选择(优先级高于粘性会话) ============
|
|
|
|
|
|
if len(routingAccountIDs) > 0 && s.concurrencyService != nil {
|
|
|
|
|
|
// 1. 过滤出路由列表中可调度的账号
|
|
|
|
|
|
var routingCandidates []*Account
|
2026-01-16 23:36:52 +08:00
|
|
|
|
var filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredWindowCost int
|
2026-01-16 17:26:05 +08:00
|
|
|
|
for _, routingAccountID := range routingAccountIDs {
|
|
|
|
|
|
if isExcluded(routingAccountID) {
|
|
|
|
|
|
filteredExcluded++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
account, ok := accountByID[routingAccountID]
|
|
|
|
|
|
if !ok || !account.IsSchedulable() {
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
filteredMissing++
|
|
|
|
|
|
} else {
|
|
|
|
|
|
filteredUnsched++
|
|
|
|
|
|
}
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if !s.isAccountAllowedForPlatform(account, platform, useMixed) {
|
|
|
|
|
|
filteredPlatform++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if !account.IsSchedulableForModel(requestedModel) {
|
|
|
|
|
|
filteredModelScope++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if requestedModel != "" && !s.isModelSupportedByAccount(account, requestedModel) {
|
|
|
|
|
|
filteredModelMapping++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-16 23:36:52 +08:00
|
|
|
|
// 窗口费用检查(非粘性会话路径)
|
|
|
|
|
|
if !s.isAccountSchedulableForWindowCost(ctx, account, false) {
|
|
|
|
|
|
filteredWindowCost++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-16 17:26:05 +08:00
|
|
|
|
routingCandidates = append(routingCandidates, account)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
2026-01-16 23:36:52 +08:00
|
|
|
|
log.Printf("[ModelRoutingDebug] routed candidates: group_id=%v model=%s routed=%d candidates=%d filtered(excluded=%d missing=%d unsched=%d platform=%d model_scope=%d model_mapping=%d window_cost=%d)",
|
2026-01-16 17:26:05 +08:00
|
|
|
|
derefGroupID(groupID), requestedModel, len(routingAccountIDs), len(routingCandidates),
|
2026-01-16 23:36:52 +08:00
|
|
|
|
filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredWindowCost)
|
2026-01-16 17:26:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(routingCandidates) > 0 {
|
|
|
|
|
|
// 1.5. 在路由账号范围内检查粘性会话
|
|
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
|
|
|
|
|
stickyAccountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
|
|
|
|
|
if err == nil && stickyAccountID > 0 && containsInt64(routingAccountIDs, stickyAccountID) && !isExcluded(stickyAccountID) {
|
|
|
|
|
|
// 粘性账号在路由列表中,优先使用
|
|
|
|
|
|
if stickyAccount, ok := accountByID[stickyAccountID]; ok {
|
|
|
|
|
|
if stickyAccount.IsSchedulable() &&
|
|
|
|
|
|
s.isAccountAllowedForPlatform(stickyAccount, platform, useMixed) &&
|
|
|
|
|
|
stickyAccount.IsSchedulableForModel(requestedModel) &&
|
2026-01-16 23:36:52 +08:00
|
|
|
|
(requestedModel == "" || s.isModelSupportedByAccount(stickyAccount, requestedModel)) &&
|
|
|
|
|
|
s.isAccountSchedulableForWindowCost(ctx, stickyAccount, true) { // 粘性会话窗口费用检查
|
2026-01-16 17:26:05 +08:00
|
|
|
|
result, err := s.tryAcquireAccountSlot(ctx, stickyAccountID, stickyAccount.Concurrency)
|
|
|
|
|
|
if err == nil && result.Acquired {
|
2026-01-16 23:36:52 +08:00
|
|
|
|
// 会话数量限制检查
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if !s.checkAndRegisterSession(ctx, stickyAccount, sessionHash) {
|
2026-01-16 23:36:52 +08:00
|
|
|
|
result.ReleaseFunc() // 释放槽位
|
|
|
|
|
|
// 继续到负载感知选择
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), stickyAccountID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return &AccountSelectionResult{
|
|
|
|
|
|
Account: stickyAccount,
|
|
|
|
|
|
Acquired: true,
|
|
|
|
|
|
ReleaseFunc: result.ReleaseFunc,
|
|
|
|
|
|
}, nil
|
2026-01-16 17:26:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, stickyAccountID)
|
|
|
|
|
|
if waitingCount < cfg.StickySessionMaxWaiting {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 会话数量限制检查(等待计划也需要占用会话配额)
|
|
|
|
|
|
if !s.checkAndRegisterSession(ctx, stickyAccount, sessionHash) {
|
|
|
|
|
|
// 会话限制已满,继续到负载感知选择
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return &AccountSelectionResult{
|
|
|
|
|
|
Account: stickyAccount,
|
|
|
|
|
|
WaitPlan: &AccountWaitPlan{
|
|
|
|
|
|
AccountID: stickyAccountID,
|
|
|
|
|
|
MaxConcurrency: stickyAccount.Concurrency,
|
|
|
|
|
|
Timeout: cfg.StickySessionWaitTimeout,
|
|
|
|
|
|
MaxWaiting: cfg.StickySessionMaxWaiting,
|
|
|
|
|
|
},
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
2026-01-16 17:26:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 粘性账号槽位满且等待队列已满,继续使用负载感知选择
|
|
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
2026-01-16 17:26:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 批量获取负载信息
|
|
|
|
|
|
routingLoads := make([]AccountWithConcurrency, 0, len(routingCandidates))
|
|
|
|
|
|
for _, acc := range routingCandidates {
|
|
|
|
|
|
routingLoads = append(routingLoads, AccountWithConcurrency{
|
|
|
|
|
|
ID: acc.ID,
|
|
|
|
|
|
MaxConcurrency: acc.Concurrency,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
routingLoadMap, _ := s.concurrencyService.GetAccountsLoadBatch(ctx, routingLoads)
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 按负载感知排序
|
|
|
|
|
|
type accountWithLoad struct {
|
|
|
|
|
|
account *Account
|
|
|
|
|
|
loadInfo *AccountLoadInfo
|
|
|
|
|
|
}
|
|
|
|
|
|
var routingAvailable []accountWithLoad
|
|
|
|
|
|
for _, acc := range routingCandidates {
|
|
|
|
|
|
loadInfo := routingLoadMap[acc.ID]
|
|
|
|
|
|
if loadInfo == nil {
|
|
|
|
|
|
loadInfo = &AccountLoadInfo{AccountID: acc.ID}
|
|
|
|
|
|
}
|
|
|
|
|
|
if loadInfo.LoadRate < 100 {
|
|
|
|
|
|
routingAvailable = append(routingAvailable, accountWithLoad{account: acc, loadInfo: loadInfo})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(routingAvailable) > 0 {
|
|
|
|
|
|
// 排序:优先级 > 负载率 > 最后使用时间
|
|
|
|
|
|
sort.SliceStable(routingAvailable, func(i, j int) bool {
|
|
|
|
|
|
a, b := routingAvailable[i], routingAvailable[j]
|
|
|
|
|
|
if a.account.Priority != b.account.Priority {
|
|
|
|
|
|
return a.account.Priority < b.account.Priority
|
|
|
|
|
|
}
|
|
|
|
|
|
if a.loadInfo.LoadRate != b.loadInfo.LoadRate {
|
|
|
|
|
|
return a.loadInfo.LoadRate < b.loadInfo.LoadRate
|
|
|
|
|
|
}
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case a.account.LastUsedAt == nil && b.account.LastUsedAt != nil:
|
|
|
|
|
|
return true
|
|
|
|
|
|
case a.account.LastUsedAt != nil && b.account.LastUsedAt == nil:
|
|
|
|
|
|
return false
|
|
|
|
|
|
case a.account.LastUsedAt == nil && b.account.LastUsedAt == nil:
|
|
|
|
|
|
return false
|
|
|
|
|
|
default:
|
|
|
|
|
|
return a.account.LastUsedAt.Before(*b.account.LastUsedAt)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 尝试获取槽位
|
|
|
|
|
|
for _, item := range routingAvailable {
|
|
|
|
|
|
result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency)
|
|
|
|
|
|
if err == nil && result.Acquired {
|
2026-01-16 23:36:52 +08:00
|
|
|
|
// 会话数量限制检查
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if !s.checkAndRegisterSession(ctx, item.account, sessionHash) {
|
2026-01-16 23:36:52 +08:00
|
|
|
|
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-16 17:26:05 +08:00
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
|
|
|
|
|
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL)
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return &AccountSelectionResult{
|
|
|
|
|
|
Account: item.account,
|
|
|
|
|
|
Acquired: true,
|
|
|
|
|
|
ReleaseFunc: result.ReleaseFunc,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 5. 所有路由账号槽位满,尝试返回等待计划(选择负载最低的)
|
|
|
|
|
|
// 遍历找到第一个满足会话限制的账号
|
|
|
|
|
|
for _, item := range routingAvailable {
|
|
|
|
|
|
if !s.checkAndRegisterSession(ctx, item.account, sessionHash) {
|
|
|
|
|
|
continue // 会话限制已满,尝试下一个
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] routed wait: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return &AccountSelectionResult{
|
|
|
|
|
|
Account: item.account,
|
|
|
|
|
|
WaitPlan: &AccountWaitPlan{
|
|
|
|
|
|
AccountID: item.account.ID,
|
|
|
|
|
|
MaxConcurrency: item.account.Concurrency,
|
|
|
|
|
|
Timeout: cfg.StickySessionWaitTimeout,
|
|
|
|
|
|
MaxWaiting: cfg.StickySessionMaxWaiting,
|
|
|
|
|
|
},
|
|
|
|
|
|
}, nil
|
2026-01-16 17:26:05 +08:00
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 所有路由账号会话限制都已满,继续到 Layer 2 回退
|
2026-01-16 17:26:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 路由列表中的账号都不可用(负载率 >= 100),继续到 Layer 2 回退
|
|
|
|
|
|
log.Printf("[ModelRouting] All routed accounts unavailable for model=%s, falling back to normal selection", requestedModel)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============ Layer 1.5: 粘性会话(仅在无模型路由配置时生效) ============
|
|
|
|
|
|
if len(routingAccountIDs) == 0 && sessionHash != "" && s.cache != nil {
|
2026-01-08 23:07:00 +08:00
|
|
|
|
accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
if err == nil && accountID > 0 && !isExcluded(accountID) {
|
2026-01-10 14:39:33 +08:00
|
|
|
|
account, ok := accountByID[accountID]
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if ok {
|
|
|
|
|
|
// 检查账户是否需要清理粘性会话绑定
|
|
|
|
|
|
// Check if the account needs sticky session cleanup
|
|
|
|
|
|
clearSticky := shouldClearStickySession(account)
|
|
|
|
|
|
if clearSticky {
|
|
|
|
|
|
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if !clearSticky && s.isAccountInGroup(account, groupID) &&
|
|
|
|
|
|
s.isAccountAllowedForPlatform(account, platform, useMixed) &&
|
|
|
|
|
|
account.IsSchedulableForModel(requestedModel) &&
|
|
|
|
|
|
(requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) &&
|
|
|
|
|
|
s.isAccountSchedulableForWindowCost(ctx, account, true) { // 粘性会话窗口费用检查
|
|
|
|
|
|
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
|
|
|
|
|
if err == nil && result.Acquired {
|
|
|
|
|
|
// 会话数量限制检查
|
|
|
|
|
|
// Session count limit check
|
|
|
|
|
|
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
|
|
|
|
|
result.ReleaseFunc() // 释放槽位,继续到 Layer 2
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
|
|
|
|
|
|
return &AccountSelectionResult{
|
|
|
|
|
|
Account: account,
|
|
|
|
|
|
Acquired: true,
|
|
|
|
|
|
ReleaseFunc: result.ReleaseFunc,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, accountID)
|
|
|
|
|
|
if waitingCount < cfg.StickySessionMaxWaiting {
|
|
|
|
|
|
// 会话数量限制检查(等待计划也需要占用会话配额)
|
|
|
|
|
|
// Session count limit check (wait plan also requires session quota)
|
|
|
|
|
|
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
|
|
|
|
|
// 会话限制已满,继续到 Layer 2
|
|
|
|
|
|
// Session limit full, continue to Layer 2
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return &AccountSelectionResult{
|
|
|
|
|
|
Account: account,
|
|
|
|
|
|
WaitPlan: &AccountWaitPlan{
|
|
|
|
|
|
AccountID: accountID,
|
|
|
|
|
|
MaxConcurrency: account.Concurrency,
|
|
|
|
|
|
Timeout: cfg.StickySessionWaitTimeout,
|
|
|
|
|
|
MaxWaiting: cfg.StickySessionMaxWaiting,
|
|
|
|
|
|
},
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============ Layer 2: 负载感知选择 ============
|
|
|
|
|
|
candidates := make([]*Account, 0, len(accounts))
|
|
|
|
|
|
for i := range accounts {
|
|
|
|
|
|
acc := &accounts[i]
|
|
|
|
|
|
if isExcluded(acc.ID) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-13 22:49:26 -08:00
|
|
|
|
// Scheduler snapshots can be temporarily stale (bucket rebuild is throttled);
|
|
|
|
|
|
// re-check schedulability here so recently rate-limited/overloaded accounts
|
|
|
|
|
|
// are not selected again before the bucket is rebuilt.
|
|
|
|
|
|
if !acc.IsSchedulable() {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
if !s.isAccountAllowedForPlatform(acc, platform, useMixed) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-09 17:35:02 +08:00
|
|
|
|
if !acc.IsSchedulableForModel(requestedModel) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-16 23:36:52 +08:00
|
|
|
|
// 窗口费用检查(非粘性会话路径)
|
|
|
|
|
|
if !s.isAccountSchedulableForWindowCost(ctx, acc, false) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
candidates = append(candidates, acc)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(candidates) == 0 {
|
|
|
|
|
|
return nil, errors.New("no available accounts")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
accountLoads := make([]AccountWithConcurrency, 0, len(candidates))
|
|
|
|
|
|
for _, acc := range candidates {
|
|
|
|
|
|
accountLoads = append(accountLoads, AccountWithConcurrency{
|
|
|
|
|
|
ID: acc.ID,
|
|
|
|
|
|
MaxConcurrency: acc.Concurrency,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loadMap, err := s.concurrencyService.GetAccountsLoadBatch(ctx, accountLoads)
|
|
|
|
|
|
if err != nil {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if result, ok := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth); ok {
|
2026-01-01 04:01:51 +08:00
|
|
|
|
return result, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
type accountWithLoad struct {
|
|
|
|
|
|
account *Account
|
|
|
|
|
|
loadInfo *AccountLoadInfo
|
|
|
|
|
|
}
|
|
|
|
|
|
var available []accountWithLoad
|
|
|
|
|
|
for _, acc := range candidates {
|
|
|
|
|
|
loadInfo := loadMap[acc.ID]
|
|
|
|
|
|
if loadInfo == nil {
|
|
|
|
|
|
loadInfo = &AccountLoadInfo{AccountID: acc.ID}
|
|
|
|
|
|
}
|
|
|
|
|
|
if loadInfo.LoadRate < 100 {
|
|
|
|
|
|
available = append(available, accountWithLoad{
|
|
|
|
|
|
account: acc,
|
|
|
|
|
|
loadInfo: loadInfo,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(available) > 0 {
|
|
|
|
|
|
sort.SliceStable(available, func(i, j int) bool {
|
|
|
|
|
|
a, b := available[i], available[j]
|
|
|
|
|
|
if a.account.Priority != b.account.Priority {
|
|
|
|
|
|
return a.account.Priority < b.account.Priority
|
|
|
|
|
|
}
|
|
|
|
|
|
if a.loadInfo.LoadRate != b.loadInfo.LoadRate {
|
|
|
|
|
|
return a.loadInfo.LoadRate < b.loadInfo.LoadRate
|
|
|
|
|
|
}
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case a.account.LastUsedAt == nil && b.account.LastUsedAt != nil:
|
|
|
|
|
|
return true
|
|
|
|
|
|
case a.account.LastUsedAt != nil && b.account.LastUsedAt == nil:
|
|
|
|
|
|
return false
|
|
|
|
|
|
case a.account.LastUsedAt == nil && b.account.LastUsedAt == nil:
|
|
|
|
|
|
if preferOAuth && a.account.Type != b.account.Type {
|
|
|
|
|
|
return a.account.Type == AccountTypeOAuth
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
default:
|
|
|
|
|
|
return a.account.LastUsedAt.Before(*b.account.LastUsedAt)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
for _, item := range available {
|
|
|
|
|
|
result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency)
|
|
|
|
|
|
if err == nil && result.Acquired {
|
2026-01-16 23:36:52 +08:00
|
|
|
|
// 会话数量限制检查
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if !s.checkAndRegisterSession(ctx, item.account, sessionHash) {
|
2026-01-16 23:36:52 +08:00
|
|
|
|
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
2026-01-08 23:07:00 +08:00
|
|
|
|
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
return &AccountSelectionResult{
|
|
|
|
|
|
Account: item.account,
|
|
|
|
|
|
Acquired: true,
|
|
|
|
|
|
ReleaseFunc: result.ReleaseFunc,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============ Layer 3: 兜底排队 ============
|
2026-01-16 20:47:07 +08:00
|
|
|
|
s.sortCandidatesForFallback(candidates, preferOAuth, cfg.FallbackSelectionMode)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
for _, acc := range candidates {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 会话数量限制检查(等待计划也需要占用会话配额)
|
|
|
|
|
|
if !s.checkAndRegisterSession(ctx, acc, sessionHash) {
|
|
|
|
|
|
continue // 会话限制已满,尝试下一个账号
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
return &AccountSelectionResult{
|
|
|
|
|
|
Account: acc,
|
|
|
|
|
|
WaitPlan: &AccountWaitPlan{
|
|
|
|
|
|
AccountID: acc.ID,
|
|
|
|
|
|
MaxConcurrency: acc.Concurrency,
|
|
|
|
|
|
Timeout: cfg.FallbackWaitTimeout,
|
|
|
|
|
|
MaxWaiting: cfg.FallbackMaxWaiting,
|
|
|
|
|
|
},
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, errors.New("no available accounts")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates []*Account, groupID *int64, sessionHash string, preferOAuth bool) (*AccountSelectionResult, bool) {
|
2026-01-01 04:01:51 +08:00
|
|
|
|
ordered := append([]*Account(nil), candidates...)
|
|
|
|
|
|
sortAccountsByPriorityAndLastUsed(ordered, preferOAuth)
|
|
|
|
|
|
|
|
|
|
|
|
for _, acc := range ordered {
|
|
|
|
|
|
result, err := s.tryAcquireAccountSlot(ctx, acc.ID, acc.Concurrency)
|
|
|
|
|
|
if err == nil && result.Acquired {
|
2026-01-16 23:36:52 +08:00
|
|
|
|
// 会话数量限制检查
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if !s.checkAndRegisterSession(ctx, acc, sessionHash) {
|
2026-01-16 23:36:52 +08:00
|
|
|
|
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
2026-01-08 23:07:00 +08:00
|
|
|
|
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, acc.ID, stickySessionTTL)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
return &AccountSelectionResult{
|
|
|
|
|
|
Account: acc,
|
|
|
|
|
|
Acquired: true,
|
|
|
|
|
|
ReleaseFunc: result.ReleaseFunc,
|
|
|
|
|
|
}, true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil, false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *GatewayService) schedulingConfig() config.GatewaySchedulingConfig {
|
|
|
|
|
|
if s.cfg != nil {
|
|
|
|
|
|
return s.cfg.Gateway.Scheduling
|
|
|
|
|
|
}
|
|
|
|
|
|
return config.GatewaySchedulingConfig{
|
|
|
|
|
|
StickySessionMaxWaiting: 3,
|
|
|
|
|
|
StickySessionWaitTimeout: 45 * time.Second,
|
|
|
|
|
|
FallbackWaitTimeout: 30 * time.Second,
|
|
|
|
|
|
FallbackMaxWaiting: 100,
|
|
|
|
|
|
LoadBatchEnabled: true,
|
|
|
|
|
|
SlotCleanupInterval: 30 * time.Second,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
func (s *GatewayService) withGroupContext(ctx context.Context, group *Group) context.Context {
|
2026-01-10 07:56:50 +08:00
|
|
|
|
if !IsGroupContextValid(group) {
|
2026-01-09 23:01:42 +08:00
|
|
|
|
return ctx
|
2026-01-08 23:07:00 +08:00
|
|
|
|
}
|
2026-01-10 08:40:27 +08:00
|
|
|
|
if existing, ok := ctx.Value(ctxkey.Group).(*Group); ok && existing != nil && existing.ID == group.ID && IsGroupContextValid(existing) {
|
2026-01-09 23:01:42 +08:00
|
|
|
|
return ctx
|
2026-01-08 23:07:00 +08:00
|
|
|
|
}
|
2026-01-09 23:01:42 +08:00
|
|
|
|
return context.WithValue(ctx, ctxkey.Group, group)
|
|
|
|
|
|
}
|
2026-01-08 23:07:00 +08:00
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
func (s *GatewayService) groupFromContext(ctx context.Context, groupID int64) *Group {
|
2026-01-10 07:56:50 +08:00
|
|
|
|
if group, ok := ctx.Value(ctxkey.Group).(*Group); ok && IsGroupContextValid(group) && group.ID == groupID {
|
2026-01-09 23:01:42 +08:00
|
|
|
|
return group
|
2026-01-08 23:07:00 +08:00
|
|
|
|
}
|
2026-01-09 23:01:42 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-01-08 23:07:00 +08:00
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
func (s *GatewayService) resolveGroupByID(ctx context.Context, groupID int64) (*Group, error) {
|
|
|
|
|
|
if group := s.groupFromContext(ctx, groupID); group != nil {
|
|
|
|
|
|
return group, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
group, err := s.groupRepo.GetByIDLite(ctx, groupID)
|
2026-01-08 23:07:00 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get group failed: %w", err)
|
|
|
|
|
|
}
|
2026-01-09 23:01:42 +08:00
|
|
|
|
return group, nil
|
|
|
|
|
|
}
|
2026-01-08 23:07:00 +08:00
|
|
|
|
|
2026-01-23 22:24:46 +08:00
|
|
|
|
func (s *GatewayService) ResolveGroupByID(ctx context.Context, groupID int64) (*Group, error) {
|
|
|
|
|
|
return s.resolveGroupByID(ctx, groupID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 17:26:05 +08:00
|
|
|
|
func (s *GatewayService) routingAccountIDsForRequest(ctx context.Context, groupID *int64, requestedModel string, platform string) []int64 {
|
|
|
|
|
|
if groupID == nil || requestedModel == "" || platform != PlatformAnthropic {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
group, err := s.resolveGroupByID(ctx, *groupID)
|
|
|
|
|
|
if err != nil || group == nil {
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] resolve group failed: group_id=%v model=%s platform=%s err=%v", derefGroupID(groupID), requestedModel, platform, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
// Preserve existing behavior: model routing only applies to anthropic groups.
|
|
|
|
|
|
if group.Platform != PlatformAnthropic {
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] skip: non-anthropic group platform: group_id=%d group_platform=%s model=%s", group.ID, group.Platform, requestedModel)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
2026-01-08 23:07:00 +08:00
|
|
|
|
}
|
2026-01-16 17:26:05 +08:00
|
|
|
|
ids := group.GetRoutingAccountIDs(requestedModel)
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] routing lookup: group_id=%d model=%s enabled=%v rules=%d matched_ids=%v",
|
|
|
|
|
|
group.ID, requestedModel, group.ModelRoutingEnabled, len(group.ModelRouting), ids)
|
|
|
|
|
|
}
|
|
|
|
|
|
return ids
|
|
|
|
|
|
}
|
2026-01-08 23:07:00 +08:00
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
func (s *GatewayService) resolveGatewayGroup(ctx context.Context, groupID *int64) (*Group, *int64, error) {
|
|
|
|
|
|
if groupID == nil {
|
|
|
|
|
|
return nil, nil, nil
|
2026-01-08 23:07:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
currentID := *groupID
|
2026-01-10 07:56:50 +08:00
|
|
|
|
visited := map[int64]struct{}{}
|
2026-01-09 23:01:42 +08:00
|
|
|
|
for {
|
2026-01-10 07:56:50 +08:00
|
|
|
|
if _, seen := visited[currentID]; seen {
|
|
|
|
|
|
return nil, nil, fmt.Errorf("fallback group cycle detected")
|
|
|
|
|
|
}
|
|
|
|
|
|
visited[currentID] = struct{}{}
|
|
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
group, err := s.resolveGroupByID(ctx, currentID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !group.ClaudeCodeOnly || IsClaudeCodeClient(ctx) {
|
|
|
|
|
|
return group, ¤tID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if group.FallbackGroupID == nil {
|
|
|
|
|
|
return nil, nil, ErrClaudeCodeOnly
|
|
|
|
|
|
}
|
|
|
|
|
|
currentID = *group.FallbackGroupID
|
2026-01-08 23:07:00 +08:00
|
|
|
|
}
|
2026-01-09 23:01:42 +08:00
|
|
|
|
}
|
2026-01-08 23:07:00 +08:00
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
// checkClaudeCodeRestriction 检查分组的 Claude Code 客户端限制
|
|
|
|
|
|
// 如果分组启用了 claude_code_only 且请求不是来自 Claude Code 客户端:
|
|
|
|
|
|
// - 有降级分组:返回降级分组的 ID
|
|
|
|
|
|
// - 无降级分组:返回 ErrClaudeCodeOnly 错误
|
|
|
|
|
|
func (s *GatewayService) checkClaudeCodeRestriction(ctx context.Context, groupID *int64) (*Group, *int64, error) {
|
|
|
|
|
|
if groupID == nil {
|
|
|
|
|
|
return nil, groupID, nil
|
2026-01-08 23:07:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
// 强制平台模式不检查 Claude Code 限制
|
2026-01-23 22:24:46 +08:00
|
|
|
|
if forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string); hasForcePlatform && forcePlatform != "" {
|
2026-01-09 23:01:42 +08:00
|
|
|
|
return nil, groupID, nil
|
2026-01-08 23:07:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
group, resolvedID, err := s.resolveGatewayGroup(ctx, groupID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
2026-01-08 23:07:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
return group, resolvedID, nil
|
2026-01-08 23:07:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 23:01:42 +08:00
|
|
|
|
func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64, group *Group) (string, bool, error) {
|
2026-01-01 04:01:51 +08:00
|
|
|
|
forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string)
|
|
|
|
|
|
if hasForcePlatform && forcePlatform != "" {
|
|
|
|
|
|
return forcePlatform, true, nil
|
|
|
|
|
|
}
|
2026-01-09 23:01:42 +08:00
|
|
|
|
if group != nil {
|
|
|
|
|
|
return group.Platform, false, nil
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
if groupID != nil {
|
2026-01-09 23:01:42 +08:00
|
|
|
|
group, err := s.resolveGroupByID(ctx, *groupID)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
if err != nil {
|
2026-01-09 23:01:42 +08:00
|
|
|
|
return "", false, err
|
2026-01-01 04:01:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
return group.Platform, false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return PlatformAnthropic, false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *int64, platform string, hasForcePlatform bool) ([]Account, bool, error) {
|
2026-01-12 14:19:06 +08:00
|
|
|
|
if s.schedulerSnapshot != nil {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
accounts, useMixed, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, hasForcePlatform)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
slog.Debug("account_scheduling_list_snapshot",
|
|
|
|
|
|
"group_id", derefGroupID(groupID),
|
|
|
|
|
|
"platform", platform,
|
|
|
|
|
|
"use_mixed", useMixed,
|
|
|
|
|
|
"count", len(accounts))
|
|
|
|
|
|
for _, acc := range accounts {
|
|
|
|
|
|
slog.Debug("account_scheduling_account_detail",
|
|
|
|
|
|
"account_id", acc.ID,
|
|
|
|
|
|
"name", acc.Name,
|
|
|
|
|
|
"platform", acc.Platform,
|
|
|
|
|
|
"type", acc.Type,
|
|
|
|
|
|
"status", acc.Status,
|
|
|
|
|
|
"tls_fingerprint", acc.IsTLSFingerprintEnabled())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return accounts, useMixed, err
|
2026-01-12 14:19:06 +08:00
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
useMixed := (platform == PlatformAnthropic || platform == PlatformGemini) && !hasForcePlatform
|
|
|
|
|
|
if useMixed {
|
|
|
|
|
|
platforms := []string{platform, PlatformAntigravity}
|
|
|
|
|
|
var accounts []Account
|
|
|
|
|
|
var err error
|
|
|
|
|
|
if groupID != nil {
|
|
|
|
|
|
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatforms(ctx, *groupID, platforms)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, platforms)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
slog.Debug("account_scheduling_list_failed",
|
|
|
|
|
|
"group_id", derefGroupID(groupID),
|
|
|
|
|
|
"platform", platform,
|
|
|
|
|
|
"error", err)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
return nil, useMixed, err
|
|
|
|
|
|
}
|
|
|
|
|
|
filtered := make([]Account, 0, len(accounts))
|
|
|
|
|
|
for _, acc := range accounts {
|
|
|
|
|
|
if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
filtered = append(filtered, acc)
|
|
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
slog.Debug("account_scheduling_list_mixed",
|
|
|
|
|
|
"group_id", derefGroupID(groupID),
|
|
|
|
|
|
"platform", platform,
|
|
|
|
|
|
"raw_count", len(accounts),
|
|
|
|
|
|
"filtered_count", len(filtered))
|
|
|
|
|
|
for _, acc := range filtered {
|
|
|
|
|
|
slog.Debug("account_scheduling_account_detail",
|
|
|
|
|
|
"account_id", acc.ID,
|
|
|
|
|
|
"name", acc.Name,
|
|
|
|
|
|
"platform", acc.Platform,
|
|
|
|
|
|
"type", acc.Type,
|
|
|
|
|
|
"status", acc.Status,
|
|
|
|
|
|
"tls_fingerprint", acc.IsTLSFingerprintEnabled())
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
return filtered, useMixed, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var accounts []Account
|
|
|
|
|
|
var err error
|
|
|
|
|
|
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
|
|
|
|
|
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
|
|
|
|
|
} else if groupID != nil {
|
|
|
|
|
|
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform)
|
2026-01-07 10:56:52 +08:00
|
|
|
|
// 分组内无账号则返回空列表,由上层处理错误,不再回退到全平台查询
|
2026-01-01 04:01:51 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
slog.Debug("account_scheduling_list_failed",
|
|
|
|
|
|
"group_id", derefGroupID(groupID),
|
|
|
|
|
|
"platform", platform,
|
|
|
|
|
|
"error", err)
|
2026-01-01 04:01:51 +08:00
|
|
|
|
return nil, useMixed, err
|
|
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
slog.Debug("account_scheduling_list_single",
|
|
|
|
|
|
"group_id", derefGroupID(groupID),
|
|
|
|
|
|
"platform", platform,
|
|
|
|
|
|
"count", len(accounts))
|
|
|
|
|
|
for _, acc := range accounts {
|
|
|
|
|
|
slog.Debug("account_scheduling_account_detail",
|
|
|
|
|
|
"account_id", acc.ID,
|
|
|
|
|
|
"name", acc.Name,
|
|
|
|
|
|
"platform", acc.Platform,
|
|
|
|
|
|
"type", acc.Type,
|
|
|
|
|
|
"status", acc.Status,
|
|
|
|
|
|
"tls_fingerprint", acc.IsTLSFingerprintEnabled())
|
|
|
|
|
|
}
|
2026-01-01 04:01:51 +08:00
|
|
|
|
return accounts, useMixed, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *GatewayService) isAccountAllowedForPlatform(account *Account, platform string, useMixed bool) bool {
|
|
|
|
|
|
if account == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if useMixed {
|
|
|
|
|
|
if account.Platform == platform {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()
|
|
|
|
|
|
}
|
|
|
|
|
|
return account.Platform == platform
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 10:56:52 +08:00
|
|
|
|
// isAccountInGroup checks if the account belongs to the specified group.
|
|
|
|
|
|
// Returns true if groupID is nil (no group restriction) or account belongs to the group.
|
|
|
|
|
|
func (s *GatewayService) isAccountInGroup(account *Account, groupID *int64) bool {
|
|
|
|
|
|
if groupID == nil {
|
|
|
|
|
|
return true // 无分组限制
|
|
|
|
|
|
}
|
|
|
|
|
|
if account == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, ag := range account.AccountGroups {
|
|
|
|
|
|
if ag.GroupID == *groupID {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:01:51 +08:00
|
|
|
|
func (s *GatewayService) tryAcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (*AcquireResult, error) {
|
|
|
|
|
|
if s.concurrencyService == nil {
|
|
|
|
|
|
return &AcquireResult{Acquired: true, ReleaseFunc: func() {}}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.concurrencyService.AcquireAccountSlot(ctx, accountID, maxConcurrency)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 23:36:52 +08:00
|
|
|
|
// isAccountSchedulableForWindowCost 检查账号是否可根据窗口费用进行调度
|
|
|
|
|
|
// 仅适用于 Anthropic OAuth/SetupToken 账号
|
|
|
|
|
|
// 返回 true 表示可调度,false 表示不可调度
|
|
|
|
|
|
func (s *GatewayService) isAccountSchedulableForWindowCost(ctx context.Context, account *Account, isSticky bool) bool {
|
|
|
|
|
|
// 只检查 Anthropic OAuth/SetupToken 账号
|
|
|
|
|
|
if !account.IsAnthropicOAuthOrSetupToken() {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
limit := account.GetWindowCostLimit()
|
|
|
|
|
|
if limit <= 0 {
|
|
|
|
|
|
return true // 未启用窗口费用限制
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 尝试从缓存获取窗口费用
|
|
|
|
|
|
var currentCost float64
|
|
|
|
|
|
if s.sessionLimitCache != nil {
|
|
|
|
|
|
if cost, hit, err := s.sessionLimitCache.GetWindowCost(ctx, account.ID); err == nil && hit {
|
|
|
|
|
|
currentCost = cost
|
|
|
|
|
|
goto checkSchedulability
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 缓存未命中,从数据库查询
|
|
|
|
|
|
{
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 使用统一的窗口开始时间计算逻辑(考虑窗口过期情况)
|
|
|
|
|
|
startTime := account.GetCurrentWindowStartTime()
|
2026-01-16 23:36:52 +08:00
|
|
|
|
|
|
|
|
|
|
stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, startTime)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// 失败开放:查询失败时允许调度
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 使用标准费用(不含账号倍率)
|
|
|
|
|
|
currentCost = stats.StandardCost
|
|
|
|
|
|
|
|
|
|
|
|
// 设置缓存(忽略错误)
|
|
|
|
|
|
if s.sessionLimitCache != nil {
|
|
|
|
|
|
_ = s.sessionLimitCache.SetWindowCost(ctx, account.ID, currentCost)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
checkSchedulability:
|
|
|
|
|
|
schedulability := account.CheckWindowCostSchedulability(currentCost)
|
|
|
|
|
|
|
|
|
|
|
|
switch schedulability {
|
|
|
|
|
|
case WindowCostSchedulable:
|
|
|
|
|
|
return true
|
|
|
|
|
|
case WindowCostStickyOnly:
|
|
|
|
|
|
return isSticky
|
|
|
|
|
|
case WindowCostNotSchedulable:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// checkAndRegisterSession 检查并注册会话,用于会话数量限制
|
|
|
|
|
|
// 仅适用于 Anthropic OAuth/SetupToken 账号
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// sessionID: 会话标识符(使用粘性会话的 hash)
|
2026-01-16 23:36:52 +08:00
|
|
|
|
// 返回 true 表示允许(在限制内或会话已存在),false 表示拒绝(超出限制且是新会话)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
func (s *GatewayService) checkAndRegisterSession(ctx context.Context, account *Account, sessionID string) bool {
|
2026-01-16 23:36:52 +08:00
|
|
|
|
// 只检查 Anthropic OAuth/SetupToken 账号
|
|
|
|
|
|
if !account.IsAnthropicOAuthOrSetupToken() {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
maxSessions := account.GetMaxSessions()
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if maxSessions <= 0 || sessionID == "" {
|
2026-01-16 23:36:52 +08:00
|
|
|
|
return true // 未启用会话限制或无会话ID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if s.sessionLimitCache == nil {
|
|
|
|
|
|
return true // 缓存不可用时允许通过
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
idleTimeout := time.Duration(account.GetSessionIdleTimeoutMinutes()) * time.Minute
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
allowed, err := s.sessionLimitCache.RegisterSession(ctx, account.ID, sessionID, maxSessions, idleTimeout)
|
2026-01-16 23:36:52 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
// 失败开放:缓存错误时允许通过
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return allowed
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 14:19:06 +08:00
|
|
|
|
func (s *GatewayService) getSchedulableAccount(ctx context.Context, accountID int64) (*Account, error) {
|
|
|
|
|
|
if s.schedulerSnapshot != nil {
|
|
|
|
|
|
return s.schedulerSnapshot.GetAccount(ctx, accountID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.accountRepo.GetByID(ctx, accountID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:01:51 +08:00
|
|
|
|
func sortAccountsByPriorityAndLastUsed(accounts []*Account, preferOAuth bool) {
|
|
|
|
|
|
sort.SliceStable(accounts, func(i, j int) bool {
|
|
|
|
|
|
a, b := accounts[i], accounts[j]
|
|
|
|
|
|
if a.Priority != b.Priority {
|
|
|
|
|
|
return a.Priority < b.Priority
|
|
|
|
|
|
}
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case a.LastUsedAt == nil && b.LastUsedAt != nil:
|
|
|
|
|
|
return true
|
|
|
|
|
|
case a.LastUsedAt != nil && b.LastUsedAt == nil:
|
|
|
|
|
|
return false
|
|
|
|
|
|
case a.LastUsedAt == nil && b.LastUsedAt == nil:
|
|
|
|
|
|
if preferOAuth && a.Type != b.Type {
|
|
|
|
|
|
return a.Type == AccountTypeOAuth
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
default:
|
|
|
|
|
|
return a.LastUsedAt.Before(*b.LastUsedAt)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 20:47:07 +08:00
|
|
|
|
// sortCandidatesForFallback 根据配置选择排序策略
|
|
|
|
|
|
// mode: "last_used"(按最后使用时间) 或 "random"(随机)
|
|
|
|
|
|
func (s *GatewayService) sortCandidatesForFallback(accounts []*Account, preferOAuth bool, mode string) {
|
|
|
|
|
|
if mode == "random" {
|
|
|
|
|
|
// 先按优先级排序,然后在同优先级内随机打乱
|
|
|
|
|
|
sortAccountsByPriorityOnly(accounts, preferOAuth)
|
|
|
|
|
|
shuffleWithinPriority(accounts)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 默认按最后使用时间排序
|
|
|
|
|
|
sortAccountsByPriorityAndLastUsed(accounts, preferOAuth)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// sortAccountsByPriorityOnly 仅按优先级排序
|
|
|
|
|
|
func sortAccountsByPriorityOnly(accounts []*Account, preferOAuth bool) {
|
|
|
|
|
|
sort.SliceStable(accounts, func(i, j int) bool {
|
|
|
|
|
|
a, b := accounts[i], accounts[j]
|
|
|
|
|
|
if a.Priority != b.Priority {
|
|
|
|
|
|
return a.Priority < b.Priority
|
|
|
|
|
|
}
|
|
|
|
|
|
if preferOAuth && a.Type != b.Type {
|
|
|
|
|
|
return a.Type == AccountTypeOAuth
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// shuffleWithinPriority 在同优先级内随机打乱顺序
|
|
|
|
|
|
func shuffleWithinPriority(accounts []*Account) {
|
|
|
|
|
|
if len(accounts) <= 1 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
r := mathrand.New(mathrand.NewSource(time.Now().UnixNano()))
|
|
|
|
|
|
start := 0
|
|
|
|
|
|
for start < len(accounts) {
|
|
|
|
|
|
priority := accounts[start].Priority
|
|
|
|
|
|
end := start + 1
|
|
|
|
|
|
for end < len(accounts) && accounts[end].Priority == priority {
|
|
|
|
|
|
end++
|
|
|
|
|
|
}
|
|
|
|
|
|
// 对 [start, end) 范围内的账户随机打乱
|
|
|
|
|
|
if end-start > 1 {
|
|
|
|
|
|
r.Shuffle(end-start, func(i, j int) {
|
|
|
|
|
|
accounts[start+i], accounts[start+j] = accounts[start+j], accounts[start+i]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
start = end
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 09:44:39 +08:00
|
|
|
|
// selectAccountForModelWithPlatform 选择单平台账户(完全隔离)
|
|
|
|
|
|
func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, platform string) (*Account, error) {
|
2026-02-02 22:20:08 +08:00
|
|
|
|
// 对 Antigravity 平台,检查请求的模型系列是否在分组支持范围内
|
|
|
|
|
|
if platform == PlatformAntigravity && groupID != nil && requestedModel != "" {
|
|
|
|
|
|
if err := s.checkAntigravityModelScope(ctx, *groupID, requestedModel); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:01:51 +08:00
|
|
|
|
preferOAuth := platform == PlatformGemini
|
2026-01-16 17:26:05 +08:00
|
|
|
|
routingAccountIDs := s.routingAccountIDsForRequest(ctx, groupID, requestedModel, platform)
|
|
|
|
|
|
|
|
|
|
|
|
var accounts []Account
|
|
|
|
|
|
accountsLoaded := false
|
|
|
|
|
|
|
|
|
|
|
|
// ============ Model Routing (legacy path): apply before sticky session ============
|
|
|
|
|
|
// When load-awareness is disabled (e.g. concurrency service not configured), we still honor model routing
|
|
|
|
|
|
// so switching model can switch upstream account within the same sticky session.
|
|
|
|
|
|
if len(routingAccountIDs) > 0 {
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] legacy routed begin: group_id=%v model=%s platform=%s session=%s routed_ids=%v",
|
|
|
|
|
|
derefGroupID(groupID), requestedModel, platform, shortSessionHash(sessionHash), routingAccountIDs)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 1) Sticky session only applies if the bound account is within the routing set.
|
|
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
|
|
|
|
|
accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
|
|
|
|
|
if err == nil && accountID > 0 && containsInt64(routingAccountIDs, accountID) {
|
|
|
|
|
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
|
|
|
|
|
account, err := s.getSchedulableAccount(ctx, accountID)
|
|
|
|
|
|
// 检查账号分组归属和平台匹配(确保粘性会话不会跨分组或跨平台)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if err == nil {
|
|
|
|
|
|
clearSticky := shouldClearStickySession(account)
|
|
|
|
|
|
if clearSticky {
|
|
|
|
|
|
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
2026-01-16 17:26:05 +08:00
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if !clearSticky && s.isAccountInGroup(account, groupID) && account.Platform == platform && account.IsSchedulableForModel(requestedModel) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
|
|
|
|
|
if err := s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL); err != nil {
|
|
|
|
|
|
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] legacy routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), accountID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return account, nil
|
2026-01-16 17:26:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2) Select an account from the routed candidates.
|
|
|
|
|
|
forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string)
|
|
|
|
|
|
if hasForcePlatform && forcePlatform == "" {
|
|
|
|
|
|
hasForcePlatform = false
|
|
|
|
|
|
}
|
|
|
|
|
|
var err error
|
|
|
|
|
|
accounts, _, err = s.listSchedulableAccounts(ctx, groupID, platform, hasForcePlatform)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("query accounts failed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
accountsLoaded = true
|
|
|
|
|
|
|
|
|
|
|
|
routingSet := make(map[int64]struct{}, len(routingAccountIDs))
|
|
|
|
|
|
for _, id := range routingAccountIDs {
|
|
|
|
|
|
if id > 0 {
|
|
|
|
|
|
routingSet[id] = struct{}{}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var selected *Account
|
|
|
|
|
|
for i := range accounts {
|
|
|
|
|
|
acc := &accounts[i]
|
|
|
|
|
|
if _, ok := routingSet[acc.ID]; !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, excluded := excludedIDs[acc.ID]; excluded {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
|
|
|
|
|
|
// avoid selecting accounts that were recently rate-limited/overloaded.
|
|
|
|
|
|
if !acc.IsSchedulable() {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if !acc.IsSchedulableForModel(requestedModel) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if selected == nil {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if acc.Priority < selected.Priority {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
} else if acc.Priority == selected.Priority {
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
case acc.LastUsedAt != nil && selected.LastUsedAt == nil:
|
|
|
|
|
|
// keep selected (never used is preferred)
|
|
|
|
|
|
case acc.LastUsedAt == nil && selected.LastUsedAt == nil:
|
|
|
|
|
|
if preferOAuth && acc.Type != selected.Type && acc.Type == AccountTypeOAuth {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if selected != nil {
|
|
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
|
|
|
|
|
if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil {
|
|
|
|
|
|
log.Printf("set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] legacy routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), selected.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return selected, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
log.Printf("[ModelRouting] No routed accounts available for model=%s, falling back to normal selection", requestedModel)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 1. 查询粘性会话
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
2026-01-08 23:07:00 +08:00
|
|
|
|
accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err == nil && accountID > 0 {
|
2025-12-27 11:44:00 +08:00
|
|
|
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
2026-01-12 14:19:06 +08:00
|
|
|
|
account, err := s.getSchedulableAccount(ctx, accountID)
|
2026-01-07 10:56:52 +08:00
|
|
|
|
// 检查账号分组归属和平台匹配(确保粘性会话不会跨分组或跨平台)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if err == nil {
|
|
|
|
|
|
clearSticky := shouldClearStickySession(account)
|
|
|
|
|
|
if clearSticky {
|
|
|
|
|
|
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !clearSticky && s.isAccountInGroup(account, groupID) && account.Platform == platform && account.IsSchedulableForModel(requestedModel) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
|
|
|
|
|
if err := s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL); err != nil {
|
|
|
|
|
|
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return account, nil
|
2025-12-27 11:44:00 +08:00
|
|
|
|
}
|
2025-12-20 15:29:52 +08:00
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 09:44:39 +08:00
|
|
|
|
// 2. 获取可调度账号列表(单平台)
|
2026-01-16 17:26:05 +08:00
|
|
|
|
if !accountsLoaded {
|
|
|
|
|
|
forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string)
|
|
|
|
|
|
if hasForcePlatform && forcePlatform == "" {
|
|
|
|
|
|
hasForcePlatform = false
|
|
|
|
|
|
}
|
|
|
|
|
|
var err error
|
|
|
|
|
|
accounts, _, err = s.listSchedulableAccounts(ctx, groupID, platform, hasForcePlatform)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("query accounts failed: %w", err)
|
|
|
|
|
|
}
|
2025-12-29 09:44:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 按优先级+最久未用选择(考虑模型支持)
|
|
|
|
|
|
var selected *Account
|
|
|
|
|
|
for i := range accounts {
|
|
|
|
|
|
acc := &accounts[i]
|
|
|
|
|
|
if _, excluded := excludedIDs[acc.ID]; excluded {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-13 22:49:26 -08:00
|
|
|
|
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
|
|
|
|
|
|
// avoid selecting accounts that were recently rate-limited/overloaded.
|
|
|
|
|
|
if !acc.IsSchedulable() {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-09 17:35:02 +08:00
|
|
|
|
if !acc.IsSchedulableForModel(requestedModel) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2025-12-29 09:44:39 +08:00
|
|
|
|
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if selected == nil {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if acc.Priority < selected.Priority {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
} else if acc.Priority == selected.Priority {
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
case acc.LastUsedAt != nil && selected.LastUsedAt == nil:
|
|
|
|
|
|
// keep selected (never used is preferred)
|
|
|
|
|
|
case acc.LastUsedAt == nil && selected.LastUsedAt == nil:
|
2026-01-01 04:01:51 +08:00
|
|
|
|
if preferOAuth && acc.Type != selected.Type && acc.Type == AccountTypeOAuth {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
}
|
2025-12-29 09:44:39 +08:00
|
|
|
|
default:
|
|
|
|
|
|
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if selected == nil {
|
|
|
|
|
|
if requestedModel != "" {
|
|
|
|
|
|
return nil, fmt.Errorf("no available accounts supporting model: %s", requestedModel)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, errors.New("no available accounts")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 建立粘性绑定
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
2026-01-08 23:07:00 +08:00
|
|
|
|
if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil {
|
2025-12-29 09:44:39 +08:00
|
|
|
|
log.Printf("set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return selected, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// selectAccountWithMixedScheduling 选择账户(支持混合调度)
|
|
|
|
|
|
// 查询原生平台账户 + 启用 mixed_scheduling 的 antigravity 账户
|
|
|
|
|
|
func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, nativePlatform string) (*Account, error) {
|
2026-01-01 04:01:51 +08:00
|
|
|
|
preferOAuth := nativePlatform == PlatformGemini
|
2026-01-16 17:26:05 +08:00
|
|
|
|
routingAccountIDs := s.routingAccountIDsForRequest(ctx, groupID, requestedModel, nativePlatform)
|
|
|
|
|
|
|
|
|
|
|
|
var accounts []Account
|
|
|
|
|
|
accountsLoaded := false
|
|
|
|
|
|
|
|
|
|
|
|
// ============ Model Routing (legacy path): apply before sticky session ============
|
|
|
|
|
|
if len(routingAccountIDs) > 0 {
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] legacy mixed routed begin: group_id=%v model=%s platform=%s session=%s routed_ids=%v",
|
|
|
|
|
|
derefGroupID(groupID), requestedModel, nativePlatform, shortSessionHash(sessionHash), routingAccountIDs)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 1) Sticky session only applies if the bound account is within the routing set.
|
|
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
|
|
|
|
|
accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
|
|
|
|
|
if err == nil && accountID > 0 && containsInt64(routingAccountIDs, accountID) {
|
|
|
|
|
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
|
|
|
|
|
account, err := s.getSchedulableAccount(ctx, accountID)
|
|
|
|
|
|
// 检查账号分组归属和有效性:原生平台直接匹配,antigravity 需要启用混合调度
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if err == nil {
|
|
|
|
|
|
clearSticky := shouldClearStickySession(account)
|
|
|
|
|
|
if clearSticky {
|
|
|
|
|
|
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !clearSticky && s.isAccountInGroup(account, groupID) && account.IsSchedulableForModel(requestedModel) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
|
|
|
|
|
if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) {
|
|
|
|
|
|
if err := s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL); err != nil {
|
|
|
|
|
|
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] legacy mixed routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), accountID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return account, nil
|
2026-01-16 17:26:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2) Select an account from the routed candidates.
|
|
|
|
|
|
var err error
|
|
|
|
|
|
accounts, _, err = s.listSchedulableAccounts(ctx, groupID, nativePlatform, false)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("query accounts failed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
accountsLoaded = true
|
|
|
|
|
|
|
|
|
|
|
|
routingSet := make(map[int64]struct{}, len(routingAccountIDs))
|
|
|
|
|
|
for _, id := range routingAccountIDs {
|
|
|
|
|
|
if id > 0 {
|
|
|
|
|
|
routingSet[id] = struct{}{}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var selected *Account
|
|
|
|
|
|
for i := range accounts {
|
|
|
|
|
|
acc := &accounts[i]
|
|
|
|
|
|
if _, ok := routingSet[acc.ID]; !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, excluded := excludedIDs[acc.ID]; excluded {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
|
|
|
|
|
|
// avoid selecting accounts that were recently rate-limited/overloaded.
|
|
|
|
|
|
if !acc.IsSchedulable() {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
// 过滤:原生平台直接通过,antigravity 需要启用混合调度
|
|
|
|
|
|
if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if !acc.IsSchedulableForModel(requestedModel) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if selected == nil {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if acc.Priority < selected.Priority {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
} else if acc.Priority == selected.Priority {
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
case acc.LastUsedAt != nil && selected.LastUsedAt == nil:
|
|
|
|
|
|
// keep selected (never used is preferred)
|
|
|
|
|
|
case acc.LastUsedAt == nil && selected.LastUsedAt == nil:
|
|
|
|
|
|
if preferOAuth && acc.Platform == PlatformGemini && selected.Platform == PlatformGemini && acc.Type != selected.Type && acc.Type == AccountTypeOAuth {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if selected != nil {
|
|
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
|
|
|
|
|
if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil {
|
|
|
|
|
|
log.Printf("set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.debugModelRoutingEnabled() {
|
|
|
|
|
|
log.Printf("[ModelRoutingDebug] legacy mixed routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), selected.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return selected, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
log.Printf("[ModelRouting] No routed accounts available for model=%s, falling back to normal selection", requestedModel)
|
|
|
|
|
|
}
|
2025-12-29 09:44:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 1. 查询粘性会话
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
2026-01-08 23:07:00 +08:00
|
|
|
|
accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
2025-12-29 09:44:39 +08:00
|
|
|
|
if err == nil && accountID > 0 {
|
|
|
|
|
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
2026-01-12 14:19:06 +08:00
|
|
|
|
account, err := s.getSchedulableAccount(ctx, accountID)
|
2026-01-07 10:56:52 +08:00
|
|
|
|
// 检查账号分组归属和有效性:原生平台直接匹配,antigravity 需要启用混合调度
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if err == nil {
|
|
|
|
|
|
clearSticky := shouldClearStickySession(account)
|
|
|
|
|
|
if clearSticky {
|
|
|
|
|
|
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !clearSticky && s.isAccountInGroup(account, groupID) && account.IsSchedulableForModel(requestedModel) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
|
|
|
|
|
if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) {
|
|
|
|
|
|
if err := s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL); err != nil {
|
|
|
|
|
|
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return account, nil
|
2025-12-29 09:44:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 获取可调度账号列表
|
2026-01-16 17:26:05 +08:00
|
|
|
|
if !accountsLoaded {
|
|
|
|
|
|
var err error
|
|
|
|
|
|
accounts, _, err = s.listSchedulableAccounts(ctx, groupID, nativePlatform, false)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("query accounts failed: %w", err)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 09:44:39 +08:00
|
|
|
|
// 3. 按优先级+最久未用选择(考虑模型支持和混合调度)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
var selected *Account
|
2025-12-18 13:50:39 +08:00
|
|
|
|
for i := range accounts {
|
|
|
|
|
|
acc := &accounts[i]
|
2025-12-27 11:44:00 +08:00
|
|
|
|
if _, excluded := excludedIDs[acc.ID]; excluded {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-13 22:49:26 -08:00
|
|
|
|
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
|
|
|
|
|
|
// avoid selecting accounts that were recently rate-limited/overloaded.
|
|
|
|
|
|
if !acc.IsSchedulable() {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2025-12-29 09:44:39 +08:00
|
|
|
|
// 过滤:原生平台直接通过,antigravity 需要启用混合调度
|
|
|
|
|
|
if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-09 17:35:02 +08:00
|
|
|
|
if !acc.IsSchedulableForModel(requestedModel) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2025-12-28 17:48:52 +08:00
|
|
|
|
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if selected == nil {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if acc.Priority < selected.Priority {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
} else if acc.Priority == selected.Priority {
|
2025-12-25 21:24:44 -08:00
|
|
|
|
switch {
|
|
|
|
|
|
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
|
2025-12-18 13:50:39 +08:00
|
|
|
|
selected = acc
|
2025-12-25 21:24:44 -08:00
|
|
|
|
case acc.LastUsedAt != nil && selected.LastUsedAt == nil:
|
|
|
|
|
|
// keep selected (never used is preferred)
|
|
|
|
|
|
case acc.LastUsedAt == nil && selected.LastUsedAt == nil:
|
2026-01-01 04:01:51 +08:00
|
|
|
|
if preferOAuth && acc.Platform == PlatformGemini && selected.Platform == PlatformGemini && acc.Type != selected.Type && acc.Type == AccountTypeOAuth {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
}
|
2025-12-25 21:24:44 -08:00
|
|
|
|
default:
|
|
|
|
|
|
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
|
|
|
|
|
|
selected = acc
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if selected == nil {
|
|
|
|
|
|
if requestedModel != "" {
|
|
|
|
|
|
return nil, fmt.Errorf("no available accounts supporting model: %s", requestedModel)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, errors.New("no available accounts")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 建立粘性绑定
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if sessionHash != "" && s.cache != nil {
|
2026-01-08 23:07:00 +08:00
|
|
|
|
if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil {
|
2025-12-20 15:29:52 +08:00
|
|
|
|
log.Printf("set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return selected, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 17:48:52 +08:00
|
|
|
|
// isModelSupportedByAccount 根据账户平台检查模型支持
|
|
|
|
|
|
func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedModel string) bool {
|
|
|
|
|
|
if account.Platform == PlatformAntigravity {
|
|
|
|
|
|
// Antigravity 平台使用专门的模型支持检查
|
|
|
|
|
|
return IsAntigravityModelSupported(requestedModel)
|
|
|
|
|
|
}
|
2026-01-23 22:24:46 +08:00
|
|
|
|
if account.Platform == PlatformAnthropic {
|
|
|
|
|
|
requestedModel = normalizeClaudeModelForAnthropic(requestedModel)
|
|
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// Gemini API Key 账户直接透传,由上游判断模型是否支持
|
|
|
|
|
|
if account.Platform == PlatformGemini && account.Type == AccountTypeAPIKey {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
2025-12-28 17:48:52 +08:00
|
|
|
|
// 其他平台使用账户的模型支持检查
|
|
|
|
|
|
return account.IsModelSupported(requestedModel)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsAntigravityModelSupported 检查 Antigravity 平台是否支持指定模型
|
2025-12-31 21:16:32 +08:00
|
|
|
|
// 所有 claude- 和 gemini- 前缀的模型都能通过映射或透传支持
|
2025-12-28 17:48:52 +08:00
|
|
|
|
func IsAntigravityModelSupported(requestedModel string) bool {
|
2025-12-31 21:16:32 +08:00
|
|
|
|
return strings.HasPrefix(requestedModel, "claude-") ||
|
|
|
|
|
|
strings.HasPrefix(requestedModel, "gemini-")
|
2025-12-28 17:48:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// GetAccessToken 获取账号凭证
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) (string, string, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
switch account.Type {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
case AccountTypeOAuth, AccountTypeSetupToken:
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// Both oauth and setup-token use OAuth token flow
|
|
|
|
|
|
return s.getOAuthToken(ctx, account)
|
2026-01-04 19:27:53 +08:00
|
|
|
|
case AccountTypeAPIKey:
|
2025-12-18 13:50:39 +08:00
|
|
|
|
apiKey := account.GetCredential("api_key")
|
|
|
|
|
|
if apiKey == "" {
|
|
|
|
|
|
return "", "", errors.New("api_key not found in credentials")
|
|
|
|
|
|
}
|
|
|
|
|
|
return apiKey, "apikey", nil
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (string, string, error) {
|
2026-01-15 18:27:06 +08:00
|
|
|
|
// 对于 Anthropic OAuth 账号,使用 ClaudeTokenProvider 获取缓存的 token
|
|
|
|
|
|
if account.Platform == PlatformAnthropic && account.Type == AccountTypeOAuth && s.claudeTokenProvider != nil {
|
|
|
|
|
|
accessToken, err := s.claudeTokenProvider.GetAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
return accessToken, "oauth", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 其他情况(Gemini 有自己的 TokenProvider,setup-token 类型等)直接从账号读取
|
2025-12-18 13:50:39 +08:00
|
|
|
|
accessToken := account.GetCredential("access_token")
|
2025-12-20 13:01:58 +08:00
|
|
|
|
if accessToken == "" {
|
|
|
|
|
|
return "", "", errors.New("access_token not found in credentials")
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2025-12-20 13:01:58 +08:00
|
|
|
|
// Token刷新由后台 TokenRefreshService 处理,此处只返回当前token
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return accessToken, "oauth", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-24 16:55:46 +08:00
|
|
|
|
// 重试相关常量
|
|
|
|
|
|
const (
|
2026-01-04 21:09:14 +08:00
|
|
|
|
// 最大尝试次数(包含首次请求)。过多重试会导致请求堆积与资源耗尽。
|
|
|
|
|
|
maxRetryAttempts = 5
|
|
|
|
|
|
|
|
|
|
|
|
// 指数退避:第 N 次失败后的等待 = retryBaseDelay * 2^(N-1),并且上限为 retryMaxDelay。
|
|
|
|
|
|
retryBaseDelay = 300 * time.Millisecond
|
|
|
|
|
|
retryMaxDelay = 3 * time.Second
|
|
|
|
|
|
|
|
|
|
|
|
// 最大重试耗时(包含请求本身耗时 + 退避等待时间)。
|
|
|
|
|
|
// 用于防止极端情况下 goroutine 长时间堆积导致资源耗尽。
|
|
|
|
|
|
maxRetryElapsed = 10 * time.Second
|
2025-12-24 16:55:46 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *GatewayService) shouldRetryUpstreamError(account *Account, statusCode int) bool {
|
2025-12-24 16:55:46 +08:00
|
|
|
|
// OAuth/Setup Token 账号:仅 403 重试
|
|
|
|
|
|
if account.IsOAuth() {
|
|
|
|
|
|
return statusCode == 403
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// API Key 账号:未配置的错误码重试
|
|
|
|
|
|
return !account.ShouldHandleErrorCode(statusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-27 11:44:00 +08:00
|
|
|
|
// shouldFailoverUpstreamError determines whether an upstream error should trigger account failover.
|
|
|
|
|
|
func (s *GatewayService) shouldFailoverUpstreamError(statusCode int) bool {
|
|
|
|
|
|
switch statusCode {
|
|
|
|
|
|
case 401, 403, 429, 529:
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return statusCode >= 500
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 21:09:14 +08:00
|
|
|
|
func retryBackoffDelay(attempt int) time.Duration {
|
|
|
|
|
|
// attempt 从 1 开始,表示第 attempt 次请求刚失败,需要等待后进行第 attempt+1 次请求。
|
|
|
|
|
|
if attempt <= 0 {
|
|
|
|
|
|
return retryBaseDelay
|
|
|
|
|
|
}
|
|
|
|
|
|
delay := retryBaseDelay * time.Duration(1<<(attempt-1))
|
|
|
|
|
|
if delay > retryMaxDelay {
|
|
|
|
|
|
return retryMaxDelay
|
|
|
|
|
|
}
|
|
|
|
|
|
return delay
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
|
|
|
|
|
if d <= 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
timer := time.NewTimer(d)
|
|
|
|
|
|
defer func() {
|
|
|
|
|
|
if !timer.Stop() {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-timer.C:
|
|
|
|
|
|
default:
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
|
case <-timer.C:
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 10:38:13 +08:00
|
|
|
|
// isClaudeCodeClient 判断请求是否来自 Claude Code 客户端
|
|
|
|
|
|
// 简化判断:User-Agent 匹配 + metadata.user_id 存在
|
|
|
|
|
|
func isClaudeCodeClient(userAgent string, metadataUserID string) bool {
|
|
|
|
|
|
if metadataUserID == "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return claudeCliUserAgentRe.MatchString(userAgent)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 19:17:07 +08:00
|
|
|
|
func isClaudeCodeRequest(ctx context.Context, c *gin.Context, parsed *ParsedRequest) bool {
|
|
|
|
|
|
if IsClaudeCodeClient(ctx) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if parsed == nil || c == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 10:38:13 +08:00
|
|
|
|
// systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词
|
2026-01-07 10:17:09 +08:00
|
|
|
|
// 使用前缀匹配支持多种变体(标准版、Agent SDK 版等)
|
2026-01-04 10:38:13 +08:00
|
|
|
|
func systemIncludesClaudeCodePrompt(system any) bool {
|
|
|
|
|
|
switch v := system.(type) {
|
|
|
|
|
|
case string:
|
2026-01-07 10:17:09 +08:00
|
|
|
|
return hasClaudeCodePrefix(v)
|
2026-01-04 10:38:13 +08:00
|
|
|
|
case []any:
|
|
|
|
|
|
for _, item := range v {
|
|
|
|
|
|
if m, ok := item.(map[string]any); ok {
|
2026-01-07 10:17:09 +08:00
|
|
|
|
if text, ok := m["text"].(string); ok && hasClaudeCodePrefix(text) {
|
2026-01-04 10:38:13 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 10:17:09 +08:00
|
|
|
|
// hasClaudeCodePrefix 检查文本是否以 Claude Code 提示词的特征前缀开头
|
|
|
|
|
|
func hasClaudeCodePrefix(text string) bool {
|
|
|
|
|
|
for _, prefix := range claudeCodePromptPrefixes {
|
|
|
|
|
|
if strings.HasPrefix(text, prefix) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 10:38:13 +08:00
|
|
|
|
// injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词
|
|
|
|
|
|
// 处理 null、字符串、数组三种格式
|
|
|
|
|
|
func injectClaudeCodePrompt(body []byte, system any) []byte {
|
|
|
|
|
|
claudeCodeBlock := map[string]any{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": claudeCodeSystemPrompt,
|
|
|
|
|
|
"cache_control": map[string]string{"type": "ephemeral"},
|
|
|
|
|
|
}
|
2026-01-29 02:03:54 +08:00
|
|
|
|
// Opencode plugin applies an extra safeguard: it not only prepends the Claude Code
|
|
|
|
|
|
// banner, it also prefixes the next system instruction with the same banner plus
|
|
|
|
|
|
// a blank line. This helps when upstream concatenates system instructions.
|
|
|
|
|
|
claudeCodePrefix := strings.TrimSpace(claudeCodeSystemPrompt)
|
2026-01-04 10:38:13 +08:00
|
|
|
|
|
|
|
|
|
|
var newSystem []any
|
|
|
|
|
|
|
|
|
|
|
|
switch v := system.(type) {
|
|
|
|
|
|
case nil:
|
|
|
|
|
|
newSystem = []any{claudeCodeBlock}
|
|
|
|
|
|
case string:
|
2026-01-29 01:28:43 +08:00
|
|
|
|
// Be tolerant of older/newer clients that may differ only by trailing whitespace/newlines.
|
|
|
|
|
|
if strings.TrimSpace(v) == "" || strings.TrimSpace(v) == strings.TrimSpace(claudeCodeSystemPrompt) {
|
2026-01-04 10:38:13 +08:00
|
|
|
|
newSystem = []any{claudeCodeBlock}
|
|
|
|
|
|
} else {
|
2026-01-29 02:03:54 +08:00
|
|
|
|
// Mirror opencode behavior: keep the banner as a separate system entry,
|
|
|
|
|
|
// but also prefix the next system text with the banner.
|
|
|
|
|
|
merged := v
|
|
|
|
|
|
if !strings.HasPrefix(v, claudeCodePrefix) {
|
|
|
|
|
|
merged = claudeCodePrefix + "\n\n" + v
|
|
|
|
|
|
}
|
|
|
|
|
|
newSystem = []any{claudeCodeBlock, map[string]any{"type": "text", "text": merged}}
|
2026-01-04 10:38:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
case []any:
|
|
|
|
|
|
newSystem = make([]any, 0, len(v)+1)
|
|
|
|
|
|
newSystem = append(newSystem, claudeCodeBlock)
|
2026-01-29 02:03:54 +08:00
|
|
|
|
prefixedNext := false
|
2026-01-04 10:38:13 +08:00
|
|
|
|
for _, item := range v {
|
|
|
|
|
|
if m, ok := item.(map[string]any); ok {
|
2026-01-29 01:28:43 +08:00
|
|
|
|
if text, ok := m["text"].(string); ok && strings.TrimSpace(text) == strings.TrimSpace(claudeCodeSystemPrompt) {
|
2026-01-04 10:38:13 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-29 02:03:54 +08:00
|
|
|
|
// Prefix the first subsequent text system block once.
|
|
|
|
|
|
if !prefixedNext {
|
|
|
|
|
|
if blockType, _ := m["type"].(string); blockType == "text" {
|
|
|
|
|
|
if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" && !strings.HasPrefix(text, claudeCodePrefix) {
|
|
|
|
|
|
m["text"] = claudeCodePrefix + "\n\n" + text
|
|
|
|
|
|
prefixedNext = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-04 10:38:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
newSystem = append(newSystem, item)
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
newSystem = []any{claudeCodeBlock}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result, err := sjson.SetBytes(body, "system", newSystem)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Warning: failed to inject Claude Code prompt: %v", err)
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 10:17:09 +08:00
|
|
|
|
// enforceCacheControlLimit 强制执行 cache_control 块数量限制(最多 4 个)
|
|
|
|
|
|
// 超限时优先从 messages 中移除 cache_control,保护 system 中的缓存控制
|
|
|
|
|
|
func enforceCacheControlLimit(body []byte) []byte {
|
|
|
|
|
|
var data map[string]any
|
|
|
|
|
|
if err := json.Unmarshal(body, &data); err != nil {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 04:56:11 +00:00
|
|
|
|
// 清理 thinking 块中的非法 cache_control(thinking 块不支持该字段)
|
|
|
|
|
|
removeCacheControlFromThinkingBlocks(data)
|
|
|
|
|
|
|
2026-01-07 10:17:09 +08:00
|
|
|
|
// 计算当前 cache_control 块数量
|
|
|
|
|
|
count := countCacheControlBlocks(data)
|
|
|
|
|
|
if count <= maxCacheControlBlocks {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 超限:优先从 messages 中移除,再从 system 中移除
|
|
|
|
|
|
for count > maxCacheControlBlocks {
|
|
|
|
|
|
if removeCacheControlFromMessages(data) {
|
|
|
|
|
|
count--
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if removeCacheControlFromSystem(data) {
|
|
|
|
|
|
count--
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result, err := json.Marshal(data)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// countCacheControlBlocks 统计 system 和 messages 中的 cache_control 块数量
|
2026-01-10 04:56:11 +00:00
|
|
|
|
// 注意:thinking 块不支持 cache_control,统计时跳过
|
2026-01-07 10:17:09 +08:00
|
|
|
|
func countCacheControlBlocks(data map[string]any) int {
|
|
|
|
|
|
count := 0
|
|
|
|
|
|
|
|
|
|
|
|
// 统计 system 中的块
|
|
|
|
|
|
if system, ok := data["system"].([]any); ok {
|
|
|
|
|
|
for _, item := range system {
|
|
|
|
|
|
if m, ok := item.(map[string]any); ok {
|
2026-01-10 04:56:11 +00:00
|
|
|
|
// thinking 块不支持 cache_control,跳过
|
|
|
|
|
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-07 10:17:09 +08:00
|
|
|
|
if _, has := m["cache_control"]; has {
|
|
|
|
|
|
count++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 统计 messages 中的块
|
|
|
|
|
|
if messages, ok := data["messages"].([]any); ok {
|
|
|
|
|
|
for _, msg := range messages {
|
|
|
|
|
|
if msgMap, ok := msg.(map[string]any); ok {
|
|
|
|
|
|
if content, ok := msgMap["content"].([]any); ok {
|
|
|
|
|
|
for _, item := range content {
|
|
|
|
|
|
if m, ok := item.(map[string]any); ok {
|
2026-01-10 04:56:11 +00:00
|
|
|
|
// thinking 块不支持 cache_control,跳过
|
|
|
|
|
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-07 10:17:09 +08:00
|
|
|
|
if _, has := m["cache_control"]; has {
|
|
|
|
|
|
count++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return count
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// removeCacheControlFromMessages 从 messages 中移除一个 cache_control(从头开始)
|
|
|
|
|
|
// 返回 true 表示成功移除,false 表示没有可移除的
|
2026-01-10 04:56:11 +00:00
|
|
|
|
// 注意:跳过 thinking 块(它不支持 cache_control)
|
2026-01-07 10:17:09 +08:00
|
|
|
|
func removeCacheControlFromMessages(data map[string]any) bool {
|
|
|
|
|
|
messages, ok := data["messages"].([]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, msg := range messages {
|
|
|
|
|
|
msgMap, ok := msg.(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
content, ok := msgMap["content"].([]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, item := range content {
|
|
|
|
|
|
if m, ok := item.(map[string]any); ok {
|
2026-01-10 04:56:11 +00:00
|
|
|
|
// thinking 块不支持 cache_control,跳过
|
|
|
|
|
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-07 10:17:09 +08:00
|
|
|
|
if _, has := m["cache_control"]; has {
|
|
|
|
|
|
delete(m, "cache_control")
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// removeCacheControlFromSystem 从 system 中移除一个 cache_control(从尾部开始,保护注入的 prompt)
|
|
|
|
|
|
// 返回 true 表示成功移除,false 表示没有可移除的
|
2026-01-10 04:56:11 +00:00
|
|
|
|
// 注意:跳过 thinking 块(它不支持 cache_control)
|
2026-01-07 10:17:09 +08:00
|
|
|
|
func removeCacheControlFromSystem(data map[string]any) bool {
|
|
|
|
|
|
system, ok := data["system"].([]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 从尾部开始移除,保护开头注入的 Claude Code prompt
|
|
|
|
|
|
for i := len(system) - 1; i >= 0; i-- {
|
|
|
|
|
|
if m, ok := system[i].(map[string]any); ok {
|
2026-01-10 04:56:11 +00:00
|
|
|
|
// thinking 块不支持 cache_control,跳过
|
|
|
|
|
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-07 10:17:09 +08:00
|
|
|
|
if _, has := m["cache_control"]; has {
|
|
|
|
|
|
delete(m, "cache_control")
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 04:56:11 +00:00
|
|
|
|
// removeCacheControlFromThinkingBlocks 强制清理所有 thinking 块中的非法 cache_control
|
|
|
|
|
|
// thinking 块不支持 cache_control 字段,这个函数确保所有 thinking 块都不含该字段
|
|
|
|
|
|
func removeCacheControlFromThinkingBlocks(data map[string]any) {
|
|
|
|
|
|
// 清理 system 中的 thinking 块
|
|
|
|
|
|
if system, ok := data["system"].([]any); ok {
|
|
|
|
|
|
for _, item := range system {
|
|
|
|
|
|
if m, ok := item.(map[string]any); ok {
|
|
|
|
|
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
|
|
|
|
|
if _, has := m["cache_control"]; has {
|
|
|
|
|
|
delete(m, "cache_control")
|
|
|
|
|
|
log.Printf("[Warning] Removed illegal cache_control from thinking block in system")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清理 messages 中的 thinking 块
|
|
|
|
|
|
if messages, ok := data["messages"].([]any); ok {
|
|
|
|
|
|
for msgIdx, msg := range messages {
|
|
|
|
|
|
if msgMap, ok := msg.(map[string]any); ok {
|
|
|
|
|
|
if content, ok := msgMap["content"].([]any); ok {
|
|
|
|
|
|
for contentIdx, item := range content {
|
|
|
|
|
|
if m, ok := item.(map[string]any); ok {
|
|
|
|
|
|
if blockType, _ := m["type"].(string); blockType == "thinking" {
|
|
|
|
|
|
if _, has := m["cache_control"]; has {
|
|
|
|
|
|
delete(m, "cache_control")
|
|
|
|
|
|
log.Printf("[Warning] Removed illegal cache_control from thinking block in messages[%d].content[%d]", msgIdx, contentIdx)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// Forward 转发请求到Claude API
|
2025-12-31 08:50:12 +08:00
|
|
|
|
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
startTime := time.Now()
|
2025-12-31 08:50:12 +08:00
|
|
|
|
if parsed == nil {
|
|
|
|
|
|
return nil, fmt.Errorf("parse request: empty request")
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-31 08:50:12 +08:00
|
|
|
|
body := parsed.Body
|
|
|
|
|
|
reqModel := parsed.Model
|
|
|
|
|
|
reqStream := parsed.Stream
|
2026-01-15 18:54:42 +08:00
|
|
|
|
originalModel := reqModel
|
|
|
|
|
|
var toolNameMap map[string]string
|
2025-12-31 08:50:12 +08:00
|
|
|
|
|
2026-01-15 19:17:07 +08:00
|
|
|
|
isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
|
|
|
|
|
|
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
|
|
|
|
|
|
|
|
|
|
|
if shouldMimicClaudeCode {
|
2026-01-15 18:54:42 +08:00
|
|
|
|
// 智能注入 Claude Code 系统提示词(仅 OAuth/SetupToken 账号需要)
|
|
|
|
|
|
// 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词
|
2026-01-15 19:17:07 +08:00
|
|
|
|
if !strings.Contains(strings.ToLower(reqModel), "haiku") &&
|
2026-01-15 18:54:42 +08:00
|
|
|
|
!systemIncludesClaudeCodePrompt(parsed.System) {
|
|
|
|
|
|
body = injectClaudeCodePrompt(body, parsed.System)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
|
|
|
|
|
|
if s.identityService != nil {
|
|
|
|
|
|
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
|
|
|
|
|
|
if err == nil && fp != nil {
|
|
|
|
|
|
if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
|
|
|
|
|
|
normalizeOpts.injectMetadata = true
|
|
|
|
|
|
normalizeOpts.metadataUserID = metadataUserID
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-31 08:50:12 +08:00
|
|
|
|
|
2026-01-15 18:54:42 +08:00
|
|
|
|
body, reqModel, toolNameMap = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
|
2025-12-25 14:47:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 10:17:09 +08:00
|
|
|
|
// 强制执行 cache_control 块数量限制(最多 4 个)
|
|
|
|
|
|
body = enforceCacheControlLimit(body)
|
|
|
|
|
|
|
2026-01-23 22:24:46 +08:00
|
|
|
|
// 应用模型映射(APIKey 明确映射优先,其次使用 Anthropic 前缀映射)
|
|
|
|
|
|
mappedModel := reqModel
|
|
|
|
|
|
mappingSource := ""
|
2026-01-04 19:27:53 +08:00
|
|
|
|
if account.Type == AccountTypeAPIKey {
|
2026-01-23 22:24:46 +08:00
|
|
|
|
mappedModel = account.GetMappedModel(reqModel)
|
2025-12-31 08:50:12 +08:00
|
|
|
|
if mappedModel != reqModel {
|
2026-01-23 22:24:46 +08:00
|
|
|
|
mappingSource = "account"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-23 22:24:46 +08:00
|
|
|
|
if mappingSource == "" && account.Platform == PlatformAnthropic {
|
|
|
|
|
|
normalized := normalizeClaudeModelForAnthropic(reqModel)
|
|
|
|
|
|
if normalized != reqModel {
|
|
|
|
|
|
mappedModel = normalized
|
|
|
|
|
|
mappingSource = "prefix"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if mappedModel != reqModel {
|
|
|
|
|
|
// 替换请求体中的模型名
|
|
|
|
|
|
body = s.replaceModelInBody(body, mappedModel)
|
|
|
|
|
|
reqModel = mappedModel
|
|
|
|
|
|
log.Printf("Model mapping applied: %s -> %s (account: %s, source=%s)", originalModel, mappedModel, account.Name, mappingSource)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取凭证
|
|
|
|
|
|
token, tokenType, err := s.GetAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:56:11 +08:00
|
|
|
|
// 获取代理URL
|
|
|
|
|
|
proxyURL := ""
|
|
|
|
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|
|
|
|
|
proxyURL = account.Proxy.URL()
|
2025-12-18 18:14:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 调试日志:记录即将转发的账号信息
|
|
|
|
|
|
log.Printf("[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s",
|
|
|
|
|
|
account.ID, account.Name, account.Platform, account.Type, account.IsTLSFingerprintEnabled(), proxyURL)
|
|
|
|
|
|
|
2025-12-24 16:55:46 +08:00
|
|
|
|
// 重试循环
|
|
|
|
|
|
var resp *http.Response
|
2026-01-04 21:09:14 +08:00
|
|
|
|
retryStart := time.Now()
|
|
|
|
|
|
for attempt := 1; attempt <= maxRetryAttempts; attempt++ {
|
2025-12-24 16:55:46 +08:00
|
|
|
|
// 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取)
|
2026-01-15 15:14:44 +08:00
|
|
|
|
// Capture upstream request body for ops retry of this attempt.
|
|
|
|
|
|
c.Set(OpsUpstreamRequestBodyKey, string(body))
|
2026-01-15 19:17:07 +08:00
|
|
|
|
upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
2025-12-24 16:55:46 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 发送请求
|
2026-02-02 22:13:50 +08:00
|
|
|
|
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
2025-12-24 16:55:46 +08:00
|
|
|
|
if err != nil {
|
2026-01-04 21:09:14 +08:00
|
|
|
|
if resp != nil && resp.Body != nil {
|
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
|
}
|
2026-01-11 11:49:34 +08:00
|
|
|
|
// Ensure the client receives an error response (handlers assume Forward writes on non-failover errors).
|
|
|
|
|
|
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
|
|
|
|
|
setOpsUpstreamError(c, 0, safeErr, "")
|
2026-01-11 15:30:27 +08:00
|
|
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
|
|
|
|
Platform: account.Platform,
|
|
|
|
|
|
AccountID: account.ID,
|
2026-01-15 15:14:44 +08:00
|
|
|
|
AccountName: account.Name,
|
2026-01-11 15:30:27 +08:00
|
|
|
|
UpstreamStatusCode: 0,
|
|
|
|
|
|
Kind: "request_error",
|
|
|
|
|
|
Message: safeErr,
|
|
|
|
|
|
})
|
2026-01-11 11:49:34 +08:00
|
|
|
|
c.JSON(http.StatusBadGateway, gin.H{
|
|
|
|
|
|
"type": "error",
|
|
|
|
|
|
"error": gin.H{
|
|
|
|
|
|
"type": "upstream_error",
|
|
|
|
|
|
"message": "Upstream request failed",
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
return nil, fmt.Errorf("upstream request failed: %s", safeErr)
|
2025-12-24 16:55:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 06:32:51 -08:00
|
|
|
|
// 优先检测thinking block签名错误(400)并重试一次
|
|
|
|
|
|
if resp.StatusCode == 400 {
|
|
|
|
|
|
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
|
|
|
|
if readErr == nil {
|
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if s.isThinkingBlockSignatureError(respBody) {
|
2026-01-11 15:30:27 +08:00
|
|
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
|
|
|
|
Platform: account.Platform,
|
|
|
|
|
|
AccountID: account.ID,
|
2026-01-15 15:14:44 +08:00
|
|
|
|
AccountName: account.Name,
|
2026-01-11 15:30:27 +08:00
|
|
|
|
UpstreamStatusCode: resp.StatusCode,
|
|
|
|
|
|
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
|
|
|
|
|
Kind: "signature_error",
|
|
|
|
|
|
Message: extractUpstreamErrorMessage(respBody),
|
|
|
|
|
|
Detail: func() string {
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}(),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-05 00:56:48 +08:00
|
|
|
|
looksLikeToolSignatureError := func(msg string) bool {
|
|
|
|
|
|
m := strings.ToLower(msg)
|
|
|
|
|
|
return strings.Contains(m, "tool_use") ||
|
|
|
|
|
|
strings.Contains(m, "tool_result") ||
|
|
|
|
|
|
strings.Contains(m, "functioncall") ||
|
|
|
|
|
|
strings.Contains(m, "function_call") ||
|
|
|
|
|
|
strings.Contains(m, "functionresponse") ||
|
|
|
|
|
|
strings.Contains(m, "function_response")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 避免在重试预算已耗尽时再发起额外请求
|
|
|
|
|
|
if time.Since(retryStart) >= maxRetryElapsed {
|
|
|
|
|
|
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
|
|
|
|
|
break
|
2026-01-04 21:09:14 +08:00
|
|
|
|
}
|
2026-01-03 06:32:51 -08:00
|
|
|
|
log.Printf("Account %d: detected thinking block signature error, retrying with filtered thinking blocks", account.ID)
|
|
|
|
|
|
|
2026-01-05 00:56:48 +08:00
|
|
|
|
// Conservative two-stage fallback:
|
|
|
|
|
|
// 1) Disable thinking + thinking->text (preserve content)
|
|
|
|
|
|
// 2) Only if upstream still errors AND error message points to tool/function signature issues:
|
|
|
|
|
|
// also downgrade tool_use/tool_result blocks to text.
|
|
|
|
|
|
|
2026-01-03 17:10:25 -08:00
|
|
|
|
filteredBody := FilterThinkingBlocksForRetry(body)
|
2026-01-15 19:17:07 +08:00
|
|
|
|
retryReq, buildErr := s.buildUpstreamRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
2026-01-03 06:32:51 -08:00
|
|
|
|
if buildErr == nil {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
2026-01-03 06:32:51 -08:00
|
|
|
|
if retryErr == nil {
|
2026-01-03 18:05:15 -08:00
|
|
|
|
if retryResp.StatusCode < 400 {
|
2026-01-05 00:56:48 +08:00
|
|
|
|
log.Printf("Account %d: signature error retry succeeded (thinking downgraded)", account.ID)
|
|
|
|
|
|
resp = retryResp
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
2026-01-04 22:32:36 +08:00
|
|
|
|
|
2026-01-05 00:56:48 +08:00
|
|
|
|
retryRespBody, retryReadErr := io.ReadAll(io.LimitReader(retryResp.Body, 2<<20))
|
|
|
|
|
|
_ = retryResp.Body.Close()
|
|
|
|
|
|
if retryReadErr == nil && retryResp.StatusCode == 400 && s.isThinkingBlockSignatureError(retryRespBody) {
|
2026-01-11 15:30:27 +08:00
|
|
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
|
|
|
|
Platform: account.Platform,
|
|
|
|
|
|
AccountID: account.ID,
|
2026-01-15 15:14:44 +08:00
|
|
|
|
AccountName: account.Name,
|
2026-01-11 15:30:27 +08:00
|
|
|
|
UpstreamStatusCode: retryResp.StatusCode,
|
|
|
|
|
|
UpstreamRequestID: retryResp.Header.Get("x-request-id"),
|
|
|
|
|
|
Kind: "signature_retry_thinking",
|
|
|
|
|
|
Message: extractUpstreamErrorMessage(retryRespBody),
|
|
|
|
|
|
Detail: func() string {
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
return truncateString(string(retryRespBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}(),
|
|
|
|
|
|
})
|
2026-01-05 00:56:48 +08:00
|
|
|
|
msg2 := extractUpstreamErrorMessage(retryRespBody)
|
|
|
|
|
|
if looksLikeToolSignatureError(msg2) && time.Since(retryStart) < maxRetryElapsed {
|
|
|
|
|
|
log.Printf("Account %d: signature retry still failing and looks tool-related, retrying with tool blocks downgraded", account.ID)
|
|
|
|
|
|
filteredBody2 := FilterSignatureSensitiveBlocksForRetry(body)
|
2026-01-15 19:17:07 +08:00
|
|
|
|
retryReq2, buildErr2 := s.buildUpstreamRequest(ctx, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
2026-01-05 00:56:48 +08:00
|
|
|
|
if buildErr2 == nil {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
2026-01-05 00:56:48 +08:00
|
|
|
|
if retryErr2 == nil {
|
|
|
|
|
|
resp = retryResp2
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
if retryResp2 != nil && retryResp2.Body != nil {
|
|
|
|
|
|
_ = retryResp2.Body.Close()
|
2026-01-04 22:32:36 +08:00
|
|
|
|
}
|
2026-01-11 15:30:27 +08:00
|
|
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
|
|
|
|
Platform: account.Platform,
|
|
|
|
|
|
AccountID: account.ID,
|
2026-01-15 15:14:44 +08:00
|
|
|
|
AccountName: account.Name,
|
2026-01-11 15:30:27 +08:00
|
|
|
|
UpstreamStatusCode: 0,
|
|
|
|
|
|
Kind: "signature_retry_tools_request_error",
|
|
|
|
|
|
Message: sanitizeUpstreamErrorMessage(retryErr2.Error()),
|
|
|
|
|
|
})
|
2026-01-05 00:56:48 +08:00
|
|
|
|
log.Printf("Account %d: tool-downgrade signature retry failed: %v", account.ID, retryErr2)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("Account %d: tool-downgrade signature retry build failed: %v", account.ID, buildErr2)
|
2026-01-04 22:32:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-03 18:05:15 -08:00
|
|
|
|
}
|
2026-01-05 00:56:48 +08:00
|
|
|
|
|
|
|
|
|
|
// Fall back to the original retry response context.
|
|
|
|
|
|
resp = &http.Response{
|
|
|
|
|
|
StatusCode: retryResp.StatusCode,
|
|
|
|
|
|
Header: retryResp.Header.Clone(),
|
|
|
|
|
|
Body: io.NopCloser(bytes.NewReader(retryRespBody)),
|
2026-01-03 18:05:15 -08:00
|
|
|
|
}
|
2026-01-03 06:32:51 -08:00
|
|
|
|
break
|
|
|
|
|
|
}
|
2026-01-05 00:56:48 +08:00
|
|
|
|
if retryResp != nil && retryResp.Body != nil {
|
|
|
|
|
|
_ = retryResp.Body.Close()
|
|
|
|
|
|
}
|
2026-01-03 18:05:15 -08:00
|
|
|
|
log.Printf("Account %d: signature error retry failed: %v", account.ID, retryErr)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("Account %d: signature error retry build request failed: %v", account.ID, buildErr)
|
2026-01-03 06:32:51 -08:00
|
|
|
|
}
|
2026-01-05 00:56:48 +08:00
|
|
|
|
|
|
|
|
|
|
// Retry failed: restore original response body and continue handling.
|
2026-01-03 06:32:51 -08:00
|
|
|
|
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
// 不是thinking签名错误,恢复响应体
|
|
|
|
|
|
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否需要通用重试(排除400,因为400已经在上面特殊处理过了)
|
|
|
|
|
|
if resp.StatusCode >= 400 && resp.StatusCode != 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
|
2026-01-04 21:09:14 +08:00
|
|
|
|
if attempt < maxRetryAttempts {
|
|
|
|
|
|
elapsed := time.Since(retryStart)
|
|
|
|
|
|
if elapsed >= maxRetryElapsed {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
delay := retryBackoffDelay(attempt)
|
|
|
|
|
|
remaining := maxRetryElapsed - elapsed
|
|
|
|
|
|
if delay > remaining {
|
|
|
|
|
|
delay = remaining
|
|
|
|
|
|
}
|
|
|
|
|
|
if delay <= 0 {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 15:30:27 +08:00
|
|
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
|
|
|
|
Platform: account.Platform,
|
|
|
|
|
|
AccountID: account.ID,
|
2026-01-15 15:14:44 +08:00
|
|
|
|
AccountName: account.Name,
|
2026-01-11 15:30:27 +08:00
|
|
|
|
UpstreamStatusCode: resp.StatusCode,
|
|
|
|
|
|
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
|
|
|
|
|
Kind: "retry",
|
|
|
|
|
|
Message: extractUpstreamErrorMessage(respBody),
|
|
|
|
|
|
Detail: func() string {
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}(),
|
|
|
|
|
|
})
|
2026-01-04 21:09:14 +08:00
|
|
|
|
log.Printf("Account %d: upstream error %d, retry %d/%d after %v (elapsed=%v/%v)",
|
|
|
|
|
|
account.ID, resp.StatusCode, attempt, maxRetryAttempts, delay, elapsed, maxRetryElapsed)
|
|
|
|
|
|
if err := sleepWithContext(ctx, delay); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2025-12-24 16:55:46 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
// 最后一次尝试也失败,跳出循环处理重试耗尽
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 不需要重试(成功或不可重试的错误),跳出循环
|
2026-01-04 15:36:00 +08:00
|
|
|
|
// DEBUG: 输出响应 headers(用于检测 rate limit 信息)
|
|
|
|
|
|
if account.Platform == PlatformGemini && resp.StatusCode < 400 {
|
|
|
|
|
|
log.Printf("[DEBUG] Gemini API Response Headers for account %d:", account.ID)
|
|
|
|
|
|
for k, v := range resp.Header {
|
|
|
|
|
|
log.Printf("[DEBUG] %s: %v", k, v)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-24 16:55:46 +08:00
|
|
|
|
break
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-01-04 21:09:14 +08:00
|
|
|
|
if resp == nil || resp.Body == nil {
|
|
|
|
|
|
return nil, errors.New("upstream request failed: empty response")
|
|
|
|
|
|
}
|
2025-12-20 15:29:52 +08:00
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2025-12-24 16:55:46 +08:00
|
|
|
|
// 处理重试耗尽的情况
|
|
|
|
|
|
if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
|
2025-12-27 11:44:00 +08:00
|
|
|
|
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
2026-01-11 15:30:27 +08:00
|
|
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
|
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 调试日志:打印重试耗尽后的错误响应
|
|
|
|
|
|
log.Printf("[Forward] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s",
|
|
|
|
|
|
account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000))
|
|
|
|
|
|
|
2025-12-27 11:44:00 +08:00
|
|
|
|
s.handleRetryExhaustedSideEffects(ctx, resp, account)
|
2026-01-11 15:30:27 +08:00
|
|
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
|
|
|
|
Platform: account.Platform,
|
|
|
|
|
|
AccountID: account.ID,
|
2026-01-15 15:14:44 +08:00
|
|
|
|
AccountName: account.Name,
|
2026-01-11 15:30:27 +08:00
|
|
|
|
UpstreamStatusCode: resp.StatusCode,
|
|
|
|
|
|
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
|
|
|
|
|
Kind: "retry_exhausted_failover",
|
|
|
|
|
|
Message: extractUpstreamErrorMessage(respBody),
|
|
|
|
|
|
Detail: func() string {
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}(),
|
|
|
|
|
|
})
|
2025-12-27 11:44:00 +08:00
|
|
|
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
|
|
|
|
|
}
|
2025-12-24 16:55:46 +08:00
|
|
|
|
return s.handleRetryExhaustedError(ctx, resp, c, account)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-27 11:44:00 +08:00
|
|
|
|
// 处理可切换账号的错误
|
|
|
|
|
|
if resp.StatusCode >= 400 && s.shouldFailoverUpstreamError(resp.StatusCode) {
|
2026-01-11 15:30:27 +08:00
|
|
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
|
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 调试日志:打印上游错误响应
|
|
|
|
|
|
log.Printf("[Forward] Upstream error (failover): Account=%d(%s) Status=%d RequestID=%s Body=%s",
|
|
|
|
|
|
account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000))
|
|
|
|
|
|
|
2025-12-27 11:44:00 +08:00
|
|
|
|
s.handleFailoverSideEffects(ctx, resp, account)
|
2026-01-11 15:30:27 +08:00
|
|
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
|
|
|
|
Platform: account.Platform,
|
|
|
|
|
|
AccountID: account.ID,
|
|
|
|
|
|
UpstreamStatusCode: resp.StatusCode,
|
|
|
|
|
|
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
|
|
|
|
|
Kind: "failover",
|
|
|
|
|
|
Message: extractUpstreamErrorMessage(respBody),
|
|
|
|
|
|
Detail: func() string {
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}(),
|
|
|
|
|
|
})
|
2025-12-27 11:44:00 +08:00
|
|
|
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-24 16:55:46 +08:00
|
|
|
|
// 处理错误响应(不可重试的错误)
|
2026-01-03 06:57:08 -08:00
|
|
|
|
if resp.StatusCode >= 400 {
|
|
|
|
|
|
// 可选:对部分 400 触发 failover(默认关闭以保持语义)
|
|
|
|
|
|
if resp.StatusCode == 400 && s.cfg != nil && s.cfg.Gateway.FailoverOn400 {
|
|
|
|
|
|
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
|
// ReadAll failed, fall back to normal error handling without consuming the stream
|
|
|
|
|
|
return s.handleErrorResponse(ctx, resp, c, account)
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
|
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
|
|
|
|
|
|
|
|
|
|
|
if s.shouldFailoverOn400(respBody) {
|
2026-01-11 15:30:27 +08:00
|
|
|
|
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
|
|
|
|
|
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
|
|
|
|
|
upstreamDetail := ""
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
|
|
|
|
|
if maxBytes <= 0 {
|
|
|
|
|
|
maxBytes = 2048
|
|
|
|
|
|
}
|
|
|
|
|
|
upstreamDetail = truncateString(string(respBody), maxBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
|
|
|
|
Platform: account.Platform,
|
|
|
|
|
|
AccountID: account.ID,
|
2026-01-15 15:14:44 +08:00
|
|
|
|
AccountName: account.Name,
|
2026-01-11 15:30:27 +08:00
|
|
|
|
UpstreamStatusCode: resp.StatusCode,
|
|
|
|
|
|
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
|
|
|
|
|
Kind: "failover_on_400",
|
|
|
|
|
|
Message: upstreamMsg,
|
|
|
|
|
|
Detail: upstreamDetail,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
if s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
log.Printf(
|
|
|
|
|
|
"Account %d: 400 error, attempting failover: %s",
|
|
|
|
|
|
account.ID,
|
|
|
|
|
|
truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("Account %d: 400 error, attempting failover", account.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
s.handleFailoverSideEffects(ctx, resp, account)
|
|
|
|
|
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return s.handleErrorResponse(ctx, resp, c, account)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理正常响应
|
|
|
|
|
|
var usage *ClaudeUsage
|
|
|
|
|
|
var firstTokenMs *int
|
2026-01-08 11:25:17 +08:00
|
|
|
|
var clientDisconnect bool
|
2025-12-31 08:50:12 +08:00
|
|
|
|
if reqStream {
|
2026-01-15 19:17:07 +08:00
|
|
|
|
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, toolNameMap, shouldMimicClaudeCode)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
2025-12-30 10:48:55 +08:00
|
|
|
|
if err.Error() == "have error in stream" {
|
|
|
|
|
|
return nil, &UpstreamFailoverError{
|
|
|
|
|
|
StatusCode: 403,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
usage = streamResult.usage
|
|
|
|
|
|
firstTokenMs = streamResult.firstTokenMs
|
2026-01-08 11:25:17 +08:00
|
|
|
|
clientDisconnect = streamResult.clientDisconnect
|
2025-12-18 13:50:39 +08:00
|
|
|
|
} else {
|
2026-01-15 19:17:07 +08:00
|
|
|
|
usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel, toolNameMap, shouldMimicClaudeCode)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &ForwardResult{
|
2026-01-08 11:25:17 +08:00
|
|
|
|
RequestID: resp.Header.Get("x-request-id"),
|
|
|
|
|
|
Usage: *usage,
|
|
|
|
|
|
Model: originalModel, // 使用原始模型用于计费和日志
|
|
|
|
|
|
Stream: reqStream,
|
|
|
|
|
|
Duration: time.Since(startTime),
|
|
|
|
|
|
FirstTokenMs: firstTokenMs,
|
|
|
|
|
|
ClientDisconnect: clientDisconnect,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 19:17:07 +08:00
|
|
|
|
func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 确定目标URL
|
|
|
|
|
|
targetURL := claudeAPIURL
|
2026-01-04 19:27:53 +08:00
|
|
|
|
if account.Type == AccountTypeAPIKey {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
baseURL := account.GetBaseURL()
|
2026-01-02 17:40:57 +08:00
|
|
|
|
if baseURL != "" {
|
|
|
|
|
|
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
targetURL = validatedURL + "/v1/messages"
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 02:07:57 +08:00
|
|
|
|
clientHeaders := http.Header{}
|
|
|
|
|
|
if c != nil && c.Request != nil {
|
|
|
|
|
|
clientHeaders = c.Request.Header
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// OAuth账号:应用统一指纹
|
2025-12-25 17:15:01 +08:00
|
|
|
|
var fingerprint *Fingerprint
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if account.IsOAuth() && s.identityService != nil {
|
|
|
|
|
|
// 1. 获取或创建指纹(包含随机生成的ClientID)
|
2026-01-31 02:07:57 +08:00
|
|
|
|
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Warning: failed to get fingerprint for account %d: %v", account.ID, err)
|
|
|
|
|
|
// 失败时降级为透传原始headers
|
|
|
|
|
|
} else {
|
|
|
|
|
|
fingerprint = fp
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
|
2025-12-18 13:50:39 +08:00
|
|
|
|
accountUUID := account.GetExtraString("account_uuid")
|
|
|
|
|
|
if accountUUID != "" && fp.ClientID != "" {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID); err == nil && len(newBody) > 0 {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
body = newBody
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 设置认证头
|
|
|
|
|
|
if tokenType == "oauth" {
|
2025-12-22 22:58:31 +08:00
|
|
|
|
req.Header.Set("authorization", "Bearer "+token)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
req.Header.Set("x-api-key", token)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 白名单透传headers
|
2026-01-31 02:07:57 +08:00
|
|
|
|
for key, values := range clientHeaders {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
lowerKey := strings.ToLower(key)
|
|
|
|
|
|
if allowedHeaders[lowerKey] {
|
|
|
|
|
|
for _, v := range values {
|
|
|
|
|
|
req.Header.Add(key, v)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuth账号:应用缓存的指纹到请求头(覆盖白名单透传的头)
|
|
|
|
|
|
if fingerprint != nil {
|
|
|
|
|
|
s.identityService.ApplyFingerprint(req, fingerprint)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 确保必要的headers存在
|
2025-12-22 22:58:31 +08:00
|
|
|
|
if req.Header.Get("content-type") == "" {
|
|
|
|
|
|
req.Header.Set("content-type", "application/json")
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
if req.Header.Get("anthropic-version") == "" {
|
|
|
|
|
|
req.Header.Set("anthropic-version", "2023-06-01")
|
|
|
|
|
|
}
|
2026-01-19 15:01:32 +08:00
|
|
|
|
if tokenType == "oauth" {
|
2026-01-15 18:54:42 +08:00
|
|
|
|
applyClaudeOAuthHeaderDefaults(req, reqStream)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-16 23:15:52 +08:00
|
|
|
|
// 处理 anthropic-beta header(OAuth 账号需要包含 oauth beta)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if tokenType == "oauth" {
|
2026-01-16 23:15:52 +08:00
|
|
|
|
if mimicClaudeCode {
|
2026-01-29 02:36:28 +08:00
|
|
|
|
// 非 Claude Code 客户端:按 opencode 的策略处理:
|
|
|
|
|
|
// - 强制 Claude Code 指纹相关请求头(尤其是 user-agent/x-stainless/x-app)
|
|
|
|
|
|
// - 保留 incoming beta 的同时,确保 OAuth 所需 beta 存在
|
|
|
|
|
|
applyClaudeCodeMimicHeaders(req, reqStream)
|
|
|
|
|
|
|
|
|
|
|
|
incomingBeta := req.Header.Get("anthropic-beta")
|
2026-01-29 15:31:29 +08:00
|
|
|
|
// Match real Claude CLI traffic (per mitmproxy reports):
|
|
|
|
|
|
// messages requests typically use only oauth + interleaved-thinking.
|
|
|
|
|
|
// Also drop claude-code beta if a downstream client added it.
|
2026-01-29 02:36:28 +08:00
|
|
|
|
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
|
2026-01-29 15:31:29 +08:00
|
|
|
|
drop := map[string]struct{}{claude.BetaClaudeCode: {}}
|
|
|
|
|
|
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop))
|
2026-01-16 00:41:29 +08:00
|
|
|
|
} else {
|
2026-01-16 23:15:52 +08:00
|
|
|
|
// Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
|
|
|
|
|
|
clientBetaHeader := req.Header.Get("anthropic-beta")
|
|
|
|
|
|
req.Header.Set("anthropic-beta", s.getBetaHeader(modelID, clientBetaHeader))
|
2026-01-16 00:41:29 +08:00
|
|
|
|
}
|
2026-01-04 19:27:53 +08:00
|
|
|
|
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" {
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
// API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
|
|
|
|
|
|
if requestNeedsBetaFeatures(body) {
|
2026-01-04 19:27:53 +08:00
|
|
|
|
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
req.Header.Set("anthropic-beta", beta)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 15:17:46 +08:00
|
|
|
|
// Always capture a compact fingerprint line for later error diagnostics.
|
|
|
|
|
|
// We only print it when needed (or when the explicit debug flag is enabled).
|
|
|
|
|
|
if c != nil && tokenType == "oauth" {
|
|
|
|
|
|
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
|
|
|
|
|
|
}
|
2026-01-29 03:13:14 +08:00
|
|
|
|
if s.debugClaudeMimicEnabled() {
|
|
|
|
|
|
logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:56:11 +08:00
|
|
|
|
return req, nil
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// getBetaHeader 处理anthropic-beta header
|
|
|
|
|
|
// 对于OAuth账号,需要确保包含oauth-2025-04-20
|
2025-12-31 08:50:12 +08:00
|
|
|
|
func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string) string {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 如果客户端传了anthropic-beta
|
|
|
|
|
|
if clientBetaHeader != "" {
|
|
|
|
|
|
// 已包含oauth beta则直接返回
|
2025-12-19 15:22:52 +08:00
|
|
|
|
if strings.Contains(clientBetaHeader, claude.BetaOAuth) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return clientBetaHeader
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 需要添加oauth beta
|
|
|
|
|
|
parts := strings.Split(clientBetaHeader, ",")
|
|
|
|
|
|
for i, p := range parts {
|
|
|
|
|
|
parts[i] = strings.TrimSpace(p)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 在claude-code-20250219后面插入oauth beta
|
|
|
|
|
|
claudeCodeIdx := -1
|
|
|
|
|
|
for i, p := range parts {
|
2025-12-19 15:22:52 +08:00
|
|
|
|
if p == claude.BetaClaudeCode {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
claudeCodeIdx = i
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if claudeCodeIdx >= 0 {
|
|
|
|
|
|
// 在claude-code后面插入
|
|
|
|
|
|
newParts := make([]string, 0, len(parts)+1)
|
|
|
|
|
|
newParts = append(newParts, parts[:claudeCodeIdx+1]...)
|
2025-12-19 15:22:52 +08:00
|
|
|
|
newParts = append(newParts, claude.BetaOAuth)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
newParts = append(newParts, parts[claudeCodeIdx+1:]...)
|
|
|
|
|
|
return strings.Join(newParts, ",")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 没有claude-code,放在第一位
|
2025-12-19 15:22:52 +08:00
|
|
|
|
return claude.BetaOAuth + "," + clientBetaHeader
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 客户端没传,根据模型生成
|
2025-12-31 08:50:12 +08:00
|
|
|
|
// haiku 模型不需要 claude-code beta
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if strings.Contains(strings.ToLower(modelID), "haiku") {
|
2025-12-19 15:22:52 +08:00
|
|
|
|
return claude.HaikuBetaHeader
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 15:22:52 +08:00
|
|
|
|
return claude.DefaultBetaHeader
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
func requestNeedsBetaFeatures(body []byte) bool {
|
|
|
|
|
|
tools := gjson.GetBytes(body, "tools")
|
|
|
|
|
|
if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.EqualFold(gjson.GetBytes(body, "thinking.type").String(), "enabled") {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:27:53 +08:00
|
|
|
|
func defaultAPIKeyBetaHeader(body []byte) string {
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
modelID := gjson.GetBytes(body, "model").String()
|
|
|
|
|
|
if strings.Contains(strings.ToLower(modelID), "haiku") {
|
2026-01-04 19:27:53 +08:00
|
|
|
|
return claude.APIKeyHaikuBetaHeader
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
}
|
2026-01-04 19:27:53 +08:00
|
|
|
|
return claude.APIKeyBetaHeader
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 18:54:42 +08:00
|
|
|
|
func applyClaudeOAuthHeaderDefaults(req *http.Request, isStream bool) {
|
|
|
|
|
|
if req == nil {
|
|
|
|
|
|
return
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
}
|
2026-01-15 18:54:42 +08:00
|
|
|
|
if req.Header.Get("accept") == "" {
|
|
|
|
|
|
req.Header.Set("accept", "application/json")
|
|
|
|
|
|
}
|
|
|
|
|
|
for key, value := range claude.DefaultHeaders {
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if req.Header.Get(key) == "" {
|
|
|
|
|
|
req.Header.Set(key, value)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if isStream && req.Header.Get("x-stainless-helper-method") == "" {
|
|
|
|
|
|
req.Header.Set("x-stainless-helper-method", "stream")
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 02:36:28 +08:00
|
|
|
|
func mergeAnthropicBeta(required []string, incoming string) string {
|
|
|
|
|
|
seen := make(map[string]struct{}, len(required)+8)
|
|
|
|
|
|
out := make([]string, 0, len(required)+8)
|
2026-01-03 06:32:51 -08:00
|
|
|
|
|
2026-01-29 02:36:28 +08:00
|
|
|
|
add := func(v string) {
|
|
|
|
|
|
v = strings.TrimSpace(v)
|
|
|
|
|
|
if v == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := seen[v]; ok {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[v] = struct{}{}
|
|
|
|
|
|
out = append(out, v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, r := range required {
|
|
|
|
|
|
add(r)
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, p := range strings.Split(incoming, ",") {
|
|
|
|
|
|
add(p)
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(out, ",")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 15:31:29 +08:00
|
|
|
|
func mergeAnthropicBetaDropping(required []string, incoming string, drop map[string]struct{}) string {
|
|
|
|
|
|
merged := mergeAnthropicBeta(required, incoming)
|
|
|
|
|
|
if merged == "" || len(drop) == 0 {
|
|
|
|
|
|
return merged
|
|
|
|
|
|
}
|
|
|
|
|
|
out := make([]string, 0, 8)
|
|
|
|
|
|
for _, p := range strings.Split(merged, ",") {
|
|
|
|
|
|
p = strings.TrimSpace(p)
|
|
|
|
|
|
if p == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := drop[p]; ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, p)
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(out, ",")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 02:36:28 +08:00
|
|
|
|
// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
|
|
|
|
|
|
// This mirrors opencode-anthropic-auth behavior: do not trust downstream
|
|
|
|
|
|
// headers when using Claude Code-scoped OAuth credentials.
|
|
|
|
|
|
func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
|
|
|
|
|
|
if req == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
// Start with the standard defaults (fill missing).
|
|
|
|
|
|
applyClaudeOAuthHeaderDefaults(req, isStream)
|
|
|
|
|
|
// Then force key headers to match Claude Code fingerprint regardless of what the client sent.
|
|
|
|
|
|
for key, value := range claude.DefaultHeaders {
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set(key, value)
|
|
|
|
|
|
}
|
2026-01-29 15:31:29 +08:00
|
|
|
|
// Real Claude CLI uses Accept: application/json (even for streaming).
|
|
|
|
|
|
req.Header.Set("accept", "application/json")
|
2026-01-29 02:36:28 +08:00
|
|
|
|
if isStream {
|
|
|
|
|
|
req.Header.Set("x-stainless-helper-method", "stream")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
func truncateForLog(b []byte, maxBytes int) string {
|
|
|
|
|
|
if maxBytes <= 0 {
|
|
|
|
|
|
maxBytes = 2048
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(b) > maxBytes {
|
|
|
|
|
|
b = b[:maxBytes]
|
|
|
|
|
|
}
|
|
|
|
|
|
s := string(b)
|
|
|
|
|
|
// 保持一行,避免污染日志格式
|
|
|
|
|
|
s = strings.ReplaceAll(s, "\n", "\\n")
|
|
|
|
|
|
s = strings.ReplaceAll(s, "\r", "\\r")
|
|
|
|
|
|
return s
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 18:05:15 -08:00
|
|
|
|
// isThinkingBlockSignatureError 检测是否是thinking block相关错误
|
2026-01-03 06:32:51 -08:00
|
|
|
|
// 这类错误可以通过过滤thinking blocks并重试来解决
|
|
|
|
|
|
func (s *GatewayService) isThinkingBlockSignatureError(respBody []byte) bool {
|
|
|
|
|
|
msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
|
|
|
|
|
|
if msg == "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 18:05:15 -08:00
|
|
|
|
// Log for debugging
|
|
|
|
|
|
log.Printf("[SignatureCheck] Checking error message: %s", msg)
|
|
|
|
|
|
|
2026-01-03 17:10:25 -08:00
|
|
|
|
// 检测signature相关的错误(更宽松的匹配)
|
|
|
|
|
|
// 例如: "Invalid `signature` in `thinking` block", "***.signature" 等
|
|
|
|
|
|
if strings.Contains(msg, "signature") {
|
2026-01-03 18:05:15 -08:00
|
|
|
|
log.Printf("[SignatureCheck] Detected signature error")
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检测 thinking block 顺序/类型错误
|
|
|
|
|
|
// 例如: "Expected `thinking` or `redacted_thinking`, but found `text`"
|
|
|
|
|
|
if strings.Contains(msg, "expected") && (strings.Contains(msg, "thinking") || strings.Contains(msg, "redacted_thinking")) {
|
|
|
|
|
|
log.Printf("[SignatureCheck] Detected thinking block type error")
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检测空消息内容错误(可能是过滤 thinking blocks 后导致的)
|
|
|
|
|
|
// 例如: "all messages must have non-empty content"
|
|
|
|
|
|
if strings.Contains(msg, "non-empty content") || strings.Contains(msg, "empty content") {
|
|
|
|
|
|
log.Printf("[SignatureCheck] Detected empty content error")
|
2026-01-03 17:10:25 -08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false
|
2026-01-03 06:32:51 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
func (s *GatewayService) shouldFailoverOn400(respBody []byte) bool {
|
|
|
|
|
|
// 只对“可能是兼容性差异导致”的 400 允许切换,避免无意义重试。
|
|
|
|
|
|
// 默认保守:无法识别则不切换。
|
|
|
|
|
|
msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
|
|
|
|
|
|
if msg == "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 缺少/错误的 beta header:换账号/链路可能成功(尤其是混合调度时)。
|
|
|
|
|
|
// 更精确匹配 beta 相关的兼容性问题,避免误触发切换。
|
|
|
|
|
|
if strings.Contains(msg, "anthropic-beta") ||
|
|
|
|
|
|
strings.Contains(msg, "beta feature") ||
|
|
|
|
|
|
strings.Contains(msg, "requires beta") {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// thinking/tool streaming 等兼容性约束(常见于中间转换链路)
|
|
|
|
|
|
if strings.Contains(msg, "thinking") || strings.Contains(msg, "thought_signature") || strings.Contains(msg, "signature") {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(msg, "tool_use") || strings.Contains(msg, "tool_result") || strings.Contains(msg, "tools") {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func extractUpstreamErrorMessage(body []byte) string {
|
|
|
|
|
|
// Claude 风格:{"type":"error","error":{"type":"...","message":"..."}}
|
|
|
|
|
|
if m := gjson.GetBytes(body, "error.message").String(); strings.TrimSpace(m) != "" {
|
|
|
|
|
|
inner := strings.TrimSpace(m)
|
|
|
|
|
|
// 有些上游会把完整 JSON 作为字符串塞进 message
|
|
|
|
|
|
if strings.HasPrefix(inner, "{") {
|
|
|
|
|
|
if innerMsg := gjson.Get(inner, "error.message").String(); strings.TrimSpace(innerMsg) != "" {
|
|
|
|
|
|
return innerMsg
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return m
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 兜底:尝试顶层 message
|
|
|
|
|
|
return gjson.GetBytes(body, "message").String()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) {
|
2026-01-11 11:49:34 +08:00
|
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 调试日志:打印上游错误响应
|
|
|
|
|
|
log.Printf("[Forward] Upstream error (non-retryable): Account=%d(%s) Status=%d RequestID=%s Body=%s",
|
|
|
|
|
|
account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(body), 1000))
|
|
|
|
|
|
|
2026-01-11 11:49:34 +08:00
|
|
|
|
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
|
|
|
|
|
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
|
|
|
|
|
|
2026-01-29 15:17:46 +08:00
|
|
|
|
// Print a compact upstream request fingerprint when we hit the Claude Code OAuth
|
|
|
|
|
|
// credential scope error. This avoids requiring env-var tweaks in a fixed deploy.
|
|
|
|
|
|
if isClaudeCodeCredentialScopeError(upstreamMsg) && c != nil {
|
|
|
|
|
|
if v, ok := c.Get(claudeMimicDebugInfoKey); ok {
|
|
|
|
|
|
if line, ok := v.(string); ok && strings.TrimSpace(line) != "" {
|
|
|
|
|
|
log.Printf("[ClaudeMimicDebugOnError] status=%d request_id=%s %s",
|
|
|
|
|
|
resp.StatusCode,
|
|
|
|
|
|
resp.Header.Get("x-request-id"),
|
|
|
|
|
|
line,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 11:49:34 +08:00
|
|
|
|
// Enrich Ops error logs with upstream status + message, and optionally a truncated body snippet.
|
|
|
|
|
|
upstreamDetail := ""
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
|
|
|
|
|
if maxBytes <= 0 {
|
|
|
|
|
|
maxBytes = 2048
|
|
|
|
|
|
}
|
|
|
|
|
|
upstreamDetail = truncateString(string(body), maxBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
2026-01-11 15:30:27 +08:00
|
|
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
|
|
|
|
Platform: account.Platform,
|
|
|
|
|
|
AccountID: account.ID,
|
|
|
|
|
|
UpstreamStatusCode: resp.StatusCode,
|
|
|
|
|
|
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
|
|
|
|
|
Kind: "http_error",
|
|
|
|
|
|
Message: upstreamMsg,
|
|
|
|
|
|
Detail: upstreamDetail,
|
|
|
|
|
|
})
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 处理上游错误,标记账号状态
|
2026-01-03 06:32:51 -08:00
|
|
|
|
shouldDisable := false
|
|
|
|
|
|
if s.rateLimitService != nil {
|
|
|
|
|
|
shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
if shouldDisable {
|
|
|
|
|
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-11 11:49:34 +08:00
|
|
|
|
// 记录上游错误响应体摘要便于排障(可选:由配置控制;不回显到客户端)
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
log.Printf(
|
|
|
|
|
|
"Upstream error %d (account=%d platform=%s type=%s): %s",
|
|
|
|
|
|
resp.StatusCode,
|
|
|
|
|
|
account.ID,
|
|
|
|
|
|
account.Platform,
|
|
|
|
|
|
account.Type,
|
|
|
|
|
|
truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 根据状态码返回适当的自定义错误响应(不透传上游详细信息)
|
|
|
|
|
|
var errType, errMsg string
|
|
|
|
|
|
var statusCode int
|
|
|
|
|
|
|
|
|
|
|
|
switch resp.StatusCode {
|
2025-12-25 14:47:19 +08:00
|
|
|
|
case 400:
|
|
|
|
|
|
c.Data(http.StatusBadRequest, "application/json", body)
|
2026-01-11 11:49:34 +08:00
|
|
|
|
summary := upstreamMsg
|
|
|
|
|
|
if summary == "" {
|
|
|
|
|
|
summary = truncateForLog(body, 512)
|
|
|
|
|
|
}
|
|
|
|
|
|
if summary == "" {
|
|
|
|
|
|
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, summary)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
case 401:
|
|
|
|
|
|
statusCode = http.StatusBadGateway
|
|
|
|
|
|
errType = "upstream_error"
|
|
|
|
|
|
errMsg = "Upstream authentication failed, please contact administrator"
|
|
|
|
|
|
case 403:
|
|
|
|
|
|
statusCode = http.StatusBadGateway
|
|
|
|
|
|
errType = "upstream_error"
|
|
|
|
|
|
errMsg = "Upstream access forbidden, please contact administrator"
|
|
|
|
|
|
case 429:
|
|
|
|
|
|
statusCode = http.StatusTooManyRequests
|
|
|
|
|
|
errType = "rate_limit_error"
|
|
|
|
|
|
errMsg = "Upstream rate limit exceeded, please retry later"
|
|
|
|
|
|
case 529:
|
|
|
|
|
|
statusCode = http.StatusServiceUnavailable
|
|
|
|
|
|
errType = "overloaded_error"
|
|
|
|
|
|
errMsg = "Upstream service overloaded, please retry later"
|
|
|
|
|
|
case 500, 502, 503, 504:
|
|
|
|
|
|
statusCode = http.StatusBadGateway
|
|
|
|
|
|
errType = "upstream_error"
|
|
|
|
|
|
errMsg = "Upstream service temporarily unavailable"
|
|
|
|
|
|
default:
|
|
|
|
|
|
statusCode = http.StatusBadGateway
|
|
|
|
|
|
errType = "upstream_error"
|
|
|
|
|
|
errMsg = "Upstream request failed"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 返回自定义错误响应
|
|
|
|
|
|
c.JSON(statusCode, gin.H{
|
|
|
|
|
|
"type": "error",
|
|
|
|
|
|
"error": gin.H{
|
|
|
|
|
|
"type": errType,
|
|
|
|
|
|
"message": errMsg,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-11 11:49:34 +08:00
|
|
|
|
if upstreamMsg == "" {
|
|
|
|
|
|
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-27 11:44:00 +08:00
|
|
|
|
func (s *GatewayService) handleRetryExhaustedSideEffects(ctx context.Context, resp *http.Response, account *Account) {
|
2026-01-11 15:30:27 +08:00
|
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
2025-12-24 16:55:46 +08:00
|
|
|
|
statusCode := resp.StatusCode
|
|
|
|
|
|
|
|
|
|
|
|
// OAuth/Setup Token 账号的 403:标记账号异常
|
|
|
|
|
|
if account.IsOAuth() && statusCode == 403 {
|
|
|
|
|
|
s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body)
|
2026-01-04 21:29:09 +08:00
|
|
|
|
log.Printf("Account %d: marked as error after %d retries for status %d", account.ID, maxRetryAttempts, statusCode)
|
2025-12-24 16:55:46 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// API Key 未配置错误码:不标记账号状态
|
2026-01-04 21:29:09 +08:00
|
|
|
|
log.Printf("Account %d: upstream error %d after %d retries (not marking account)", account.ID, statusCode, maxRetryAttempts)
|
2025-12-24 16:55:46 +08:00
|
|
|
|
}
|
2025-12-27 11:44:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *GatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account) {
|
2026-01-11 15:30:27 +08:00
|
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
2025-12-27 11:44:00 +08:00
|
|
|
|
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// handleRetryExhaustedError 处理重试耗尽后的错误
|
|
|
|
|
|
// OAuth 403:标记账号异常
|
|
|
|
|
|
// API Key 未配置错误码:仅返回错误,不标记账号
|
|
|
|
|
|
func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) {
|
2026-01-11 11:49:34 +08:00
|
|
|
|
// Capture upstream error body before side-effects consume the stream.
|
|
|
|
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
|
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
|
|
|
|
|
|
2025-12-27 11:44:00 +08:00
|
|
|
|
s.handleRetryExhaustedSideEffects(ctx, resp, account)
|
2025-12-24 16:55:46 +08:00
|
|
|
|
|
2026-01-11 11:49:34 +08:00
|
|
|
|
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
|
|
|
|
|
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
2026-01-29 15:17:46 +08:00
|
|
|
|
|
|
|
|
|
|
if isClaudeCodeCredentialScopeError(upstreamMsg) && c != nil {
|
|
|
|
|
|
if v, ok := c.Get(claudeMimicDebugInfoKey); ok {
|
|
|
|
|
|
if line, ok := v.(string); ok && strings.TrimSpace(line) != "" {
|
|
|
|
|
|
log.Printf("[ClaudeMimicDebugOnError] status=%d request_id=%s %s",
|
|
|
|
|
|
resp.StatusCode,
|
|
|
|
|
|
resp.Header.Get("x-request-id"),
|
|
|
|
|
|
line,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 11:49:34 +08:00
|
|
|
|
upstreamDetail := ""
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
|
|
|
|
|
if maxBytes <= 0 {
|
|
|
|
|
|
maxBytes = 2048
|
|
|
|
|
|
}
|
|
|
|
|
|
upstreamDetail = truncateString(string(respBody), maxBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
2026-01-11 15:30:27 +08:00
|
|
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
|
|
|
|
Platform: account.Platform,
|
|
|
|
|
|
AccountID: account.ID,
|
|
|
|
|
|
UpstreamStatusCode: resp.StatusCode,
|
|
|
|
|
|
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
|
|
|
|
|
Kind: "retry_exhausted",
|
|
|
|
|
|
Message: upstreamMsg,
|
|
|
|
|
|
Detail: upstreamDetail,
|
|
|
|
|
|
})
|
2026-01-11 11:49:34 +08:00
|
|
|
|
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
log.Printf(
|
|
|
|
|
|
"Upstream error %d retries_exhausted (account=%d platform=%s type=%s): %s",
|
|
|
|
|
|
resp.StatusCode,
|
|
|
|
|
|
account.ID,
|
|
|
|
|
|
account.Platform,
|
|
|
|
|
|
account.Type,
|
|
|
|
|
|
truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-24 16:55:46 +08:00
|
|
|
|
// 返回统一的重试耗尽错误响应
|
|
|
|
|
|
c.JSON(http.StatusBadGateway, gin.H{
|
|
|
|
|
|
"type": "error",
|
|
|
|
|
|
"error": gin.H{
|
|
|
|
|
|
"type": "upstream_error",
|
|
|
|
|
|
"message": "Upstream request failed after retries",
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-11 11:49:34 +08:00
|
|
|
|
if upstreamMsg == "" {
|
|
|
|
|
|
return nil, fmt.Errorf("upstream error: %d (retries exhausted)", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("upstream error: %d (retries exhausted) message=%s", resp.StatusCode, upstreamMsg)
|
2025-12-24 16:55:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// streamingResult 流式响应结果
|
|
|
|
|
|
type streamingResult struct {
|
2026-01-08 11:25:17 +08:00
|
|
|
|
usage *ClaudeUsage
|
|
|
|
|
|
firstTokenMs *int
|
|
|
|
|
|
clientDisconnect bool // 客户端是否在流式传输过程中断开
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 19:17:07 +08:00
|
|
|
|
func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string, toolNameMap map[string]string, mimicClaudeCode bool) (*streamingResult, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 更新5h窗口状态
|
|
|
|
|
|
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
|
|
|
|
|
|
|
2026-01-05 13:54:43 +08:00
|
|
|
|
if s.cfg != nil {
|
|
|
|
|
|
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 设置SSE响应头
|
|
|
|
|
|
c.Header("Content-Type", "text/event-stream")
|
|
|
|
|
|
c.Header("Cache-Control", "no-cache")
|
|
|
|
|
|
c.Header("Connection", "keep-alive")
|
|
|
|
|
|
c.Header("X-Accel-Buffering", "no")
|
|
|
|
|
|
|
|
|
|
|
|
// 透传其他响应头
|
|
|
|
|
|
if v := resp.Header.Get("x-request-id"); v != "" {
|
|
|
|
|
|
c.Header("x-request-id", v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
w := c.Writer
|
|
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return nil, errors.New("streaming not supported")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
usage := &ClaudeUsage{}
|
|
|
|
|
|
var firstTokenMs *int
|
|
|
|
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
|
|
|
|
// 设置更大的buffer以处理长行
|
2026-01-04 19:49:59 +08:00
|
|
|
|
maxLineSize := defaultMaxLineSize
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
|
|
|
|
|
|
maxLineSize = s.cfg.Gateway.MaxLineSize
|
|
|
|
|
|
}
|
|
|
|
|
|
scanner.Buffer(make([]byte, 64*1024), maxLineSize)
|
|
|
|
|
|
|
|
|
|
|
|
type scanEvent struct {
|
|
|
|
|
|
line string
|
|
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
// 独立 goroutine 读取上游,避免读取阻塞导致超时/keepalive无法处理
|
2026-01-04 20:19:07 +08:00
|
|
|
|
events := make(chan scanEvent, 16)
|
2026-01-04 19:49:59 +08:00
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
|
sendEvent := func(ev scanEvent) bool {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case events <- ev:
|
|
|
|
|
|
return true
|
|
|
|
|
|
case <-done:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-04 20:19:07 +08:00
|
|
|
|
var lastReadAt int64
|
|
|
|
|
|
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
2026-01-04 19:49:59 +08:00
|
|
|
|
go func() {
|
|
|
|
|
|
defer close(events)
|
|
|
|
|
|
for scanner.Scan() {
|
2026-01-04 20:19:07 +08:00
|
|
|
|
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
2026-01-04 19:49:59 +08:00
|
|
|
|
if !sendEvent(scanEvent{line: scanner.Text()}) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
|
|
|
|
_ = sendEvent(scanEvent{err: err})
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
defer close(done)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-04 19:49:59 +08:00
|
|
|
|
streamInterval := time.Duration(0)
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 {
|
|
|
|
|
|
streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second
|
|
|
|
|
|
}
|
2026-01-04 20:19:07 +08:00
|
|
|
|
// 仅监控上游数据间隔超时,避免下游写入阻塞导致误判
|
|
|
|
|
|
var intervalTicker *time.Ticker
|
2026-01-04 19:49:59 +08:00
|
|
|
|
if streamInterval > 0 {
|
2026-01-04 20:19:07 +08:00
|
|
|
|
intervalTicker = time.NewTicker(streamInterval)
|
|
|
|
|
|
defer intervalTicker.Stop()
|
|
|
|
|
|
}
|
|
|
|
|
|
var intervalCh <-chan time.Time
|
|
|
|
|
|
if intervalTicker != nil {
|
|
|
|
|
|
intervalCh = intervalTicker.C
|
2026-01-04 19:49:59 +08:00
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-04 19:49:59 +08:00
|
|
|
|
// 仅发送一次错误事件,避免多次写入导致协议混乱(写失败时尽力通知客户端)
|
|
|
|
|
|
errorEventSent := false
|
|
|
|
|
|
sendErrorEvent := func(reason string) {
|
|
|
|
|
|
if errorEventSent {
|
|
|
|
|
|
return
|
2025-12-30 10:48:55 +08:00
|
|
|
|
}
|
2026-01-04 19:49:59 +08:00
|
|
|
|
errorEventSent = true
|
|
|
|
|
|
_, _ = fmt.Fprintf(w, "event: error\ndata: {\"error\":\"%s\"}\n\n", reason)
|
|
|
|
|
|
flusher.Flush()
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-04 19:49:59 +08:00
|
|
|
|
needModelReplace := originalModel != mappedModel
|
2026-01-08 11:25:17 +08:00
|
|
|
|
clientDisconnected := false // 客户端断开标志,断开后继续读取上游以获取完整usage
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-19 03:46:09 +08:00
|
|
|
|
pendingEventLines := make([]string, 0, 4)
|
2026-01-19 16:20:24 +08:00
|
|
|
|
var toolInputBuffers map[int]string
|
|
|
|
|
|
if mimicClaudeCode {
|
|
|
|
|
|
toolInputBuffers = make(map[int]string)
|
|
|
|
|
|
}
|
2026-01-19 03:46:09 +08:00
|
|
|
|
|
|
|
|
|
|
transformToolInputJSON := func(raw string) string {
|
2026-01-19 16:20:24 +08:00
|
|
|
|
if !mimicClaudeCode {
|
|
|
|
|
|
return raw
|
|
|
|
|
|
}
|
2026-01-19 03:46:09 +08:00
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
|
if raw == "" {
|
|
|
|
|
|
return raw
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var parsed any
|
|
|
|
|
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
|
|
|
|
|
return replaceToolNamesInText(raw, toolNameMap)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
rewritten, changed := rewriteParamKeysInValue(parsed, toolNameMap)
|
|
|
|
|
|
if changed {
|
|
|
|
|
|
if bytes, err := json.Marshal(rewritten); err == nil {
|
|
|
|
|
|
return string(bytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return raw
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
processSSEEvent := func(lines []string) ([]string, string, error) {
|
|
|
|
|
|
if len(lines) == 0 {
|
|
|
|
|
|
return nil, "", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
eventName := ""
|
|
|
|
|
|
dataLine := ""
|
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
|
|
if strings.HasPrefix(trimmed, "event:") {
|
|
|
|
|
|
eventName = strings.TrimSpace(strings.TrimPrefix(trimmed, "event:"))
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if dataLine == "" && sseDataRe.MatchString(trimmed) {
|
|
|
|
|
|
dataLine = sseDataRe.ReplaceAllString(trimmed, "")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if eventName == "error" {
|
|
|
|
|
|
return nil, dataLine, errors.New("have error in stream")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if dataLine == "" {
|
|
|
|
|
|
return []string{strings.Join(lines, "\n") + "\n\n"}, "", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if dataLine == "[DONE]" {
|
|
|
|
|
|
block := ""
|
|
|
|
|
|
if eventName != "" {
|
|
|
|
|
|
block = "event: " + eventName + "\n"
|
|
|
|
|
|
}
|
|
|
|
|
|
block += "data: " + dataLine + "\n\n"
|
|
|
|
|
|
return []string{block}, dataLine, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var event map[string]any
|
|
|
|
|
|
if err := json.Unmarshal([]byte(dataLine), &event); err != nil {
|
2026-01-19 16:20:24 +08:00
|
|
|
|
replaced := dataLine
|
|
|
|
|
|
if mimicClaudeCode {
|
|
|
|
|
|
replaced = replaceToolNamesInText(dataLine, toolNameMap)
|
|
|
|
|
|
}
|
2026-01-19 03:46:09 +08:00
|
|
|
|
block := ""
|
|
|
|
|
|
if eventName != "" {
|
|
|
|
|
|
block = "event: " + eventName + "\n"
|
|
|
|
|
|
}
|
|
|
|
|
|
block += "data: " + replaced + "\n\n"
|
|
|
|
|
|
return []string{block}, replaced, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
eventType, _ := event["type"].(string)
|
|
|
|
|
|
if eventName == "" {
|
|
|
|
|
|
eventName = eventType
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 03:53:08 +08:00
|
|
|
|
if needModelReplace {
|
2026-01-19 03:46:09 +08:00
|
|
|
|
if msg, ok := event["message"].(map[string]any); ok {
|
|
|
|
|
|
if model, ok := msg["model"].(string); ok && model == mappedModel {
|
|
|
|
|
|
msg["model"] = originalModel
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 16:20:24 +08:00
|
|
|
|
if mimicClaudeCode && eventType == "content_block_delta" {
|
2026-01-19 03:46:09 +08:00
|
|
|
|
if delta, ok := event["delta"].(map[string]any); ok {
|
|
|
|
|
|
if deltaType, _ := delta["type"].(string); deltaType == "input_json_delta" {
|
|
|
|
|
|
if indexVal, ok := event["index"].(float64); ok {
|
|
|
|
|
|
index := int(indexVal)
|
|
|
|
|
|
if partial, ok := delta["partial_json"].(string); ok {
|
|
|
|
|
|
toolInputBuffers[index] += partial
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, dataLine, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 16:20:24 +08:00
|
|
|
|
if mimicClaudeCode && eventType == "content_block_stop" {
|
2026-01-19 03:46:09 +08:00
|
|
|
|
if indexVal, ok := event["index"].(float64); ok {
|
|
|
|
|
|
index := int(indexVal)
|
|
|
|
|
|
if buffered := toolInputBuffers[index]; buffered != "" {
|
|
|
|
|
|
delete(toolInputBuffers, index)
|
|
|
|
|
|
|
|
|
|
|
|
transformed := transformToolInputJSON(buffered)
|
|
|
|
|
|
synthetic := map[string]any{
|
|
|
|
|
|
"type": "content_block_delta",
|
|
|
|
|
|
"index": index,
|
|
|
|
|
|
"delta": map[string]any{
|
|
|
|
|
|
"type": "input_json_delta",
|
|
|
|
|
|
"partial_json": transformed,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
synthBytes, synthErr := json.Marshal(synthetic)
|
|
|
|
|
|
if synthErr == nil {
|
|
|
|
|
|
synthBlock := "event: content_block_delta\n" + "data: " + string(synthBytes) + "\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
rewriteToolNamesInValue(event, toolNameMap)
|
|
|
|
|
|
stopBytes, stopErr := json.Marshal(event)
|
|
|
|
|
|
if stopErr == nil {
|
|
|
|
|
|
stopBlock := ""
|
|
|
|
|
|
if eventName != "" {
|
|
|
|
|
|
stopBlock = "event: " + eventName + "\n"
|
|
|
|
|
|
}
|
|
|
|
|
|
stopBlock += "data: " + string(stopBytes) + "\n\n"
|
|
|
|
|
|
return []string{synthBlock, stopBlock}, string(stopBytes), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 16:20:24 +08:00
|
|
|
|
if mimicClaudeCode {
|
|
|
|
|
|
rewriteToolNamesInValue(event, toolNameMap)
|
|
|
|
|
|
}
|
2026-01-19 03:46:09 +08:00
|
|
|
|
newData, err := json.Marshal(event)
|
|
|
|
|
|
if err != nil {
|
2026-01-19 16:20:24 +08:00
|
|
|
|
replaced := dataLine
|
|
|
|
|
|
if mimicClaudeCode {
|
|
|
|
|
|
replaced = replaceToolNamesInText(dataLine, toolNameMap)
|
|
|
|
|
|
}
|
2026-01-19 03:46:09 +08:00
|
|
|
|
block := ""
|
|
|
|
|
|
if eventName != "" {
|
|
|
|
|
|
block = "event: " + eventName + "\n"
|
|
|
|
|
|
}
|
|
|
|
|
|
block += "data: " + replaced + "\n\n"
|
|
|
|
|
|
return []string{block}, replaced, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
block := ""
|
|
|
|
|
|
if eventName != "" {
|
|
|
|
|
|
block = "event: " + eventName + "\n"
|
|
|
|
|
|
}
|
|
|
|
|
|
block += "data: " + string(newData) + "\n\n"
|
|
|
|
|
|
return []string{block}, string(newData), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:49:59 +08:00
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case ev, ok := <-events:
|
|
|
|
|
|
if !ok {
|
2026-01-08 11:25:17 +08:00
|
|
|
|
// 上游完成,返回结果
|
|
|
|
|
|
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil
|
2025-12-26 03:49:55 -08:00
|
|
|
|
}
|
2026-01-04 19:49:59 +08:00
|
|
|
|
if ev.err != nil {
|
2026-01-08 11:25:17 +08:00
|
|
|
|
// 检测 context 取消(客户端断开会导致 context 取消,进而影响上游读取)
|
|
|
|
|
|
if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) {
|
|
|
|
|
|
log.Printf("Context canceled during streaming, returning collected usage")
|
|
|
|
|
|
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
// 客户端已通过写入失败检测到断开,上游也出错了,返回已收集的 usage
|
|
|
|
|
|
if clientDisconnected {
|
|
|
|
|
|
log.Printf("Upstream read error after client disconnect: %v, returning collected usage", ev.err)
|
|
|
|
|
|
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
// 客户端未断开,正常的错误处理
|
2026-01-04 19:49:59 +08:00
|
|
|
|
if errors.Is(ev.err, bufio.ErrTooLong) {
|
|
|
|
|
|
log.Printf("SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err)
|
|
|
|
|
|
sendErrorEvent("response_too_large")
|
|
|
|
|
|
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, ev.err
|
|
|
|
|
|
}
|
|
|
|
|
|
sendErrorEvent("stream_read_error")
|
|
|
|
|
|
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream read error: %w", ev.err)
|
|
|
|
|
|
}
|
|
|
|
|
|
line := ev.line
|
2026-01-19 03:46:09 +08:00
|
|
|
|
trimmed := strings.TrimSpace(line)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-19 03:46:09 +08:00
|
|
|
|
if trimmed == "" {
|
|
|
|
|
|
if len(pendingEventLines) == 0 {
|
|
|
|
|
|
continue
|
2026-01-04 19:49:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 03:46:09 +08:00
|
|
|
|
outputBlocks, data, err := processSSEEvent(pendingEventLines)
|
|
|
|
|
|
pendingEventLines = pendingEventLines[:0]
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if clientDisconnected {
|
|
|
|
|
|
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, err
|
2026-01-04 19:49:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 03:46:09 +08:00
|
|
|
|
for _, block := range outputBlocks {
|
|
|
|
|
|
if !clientDisconnected {
|
|
|
|
|
|
if _, werr := fmt.Fprint(w, block); werr != nil {
|
|
|
|
|
|
clientDisconnected = true
|
|
|
|
|
|
log.Printf("Client disconnected during streaming, continuing to drain upstream for billing")
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
flusher.Flush()
|
|
|
|
|
|
}
|
|
|
|
|
|
if data != "" {
|
|
|
|
|
|
if firstTokenMs == nil && data != "[DONE]" {
|
|
|
|
|
|
ms := int(time.Since(startTime).Milliseconds())
|
|
|
|
|
|
firstTokenMs = &ms
|
|
|
|
|
|
}
|
|
|
|
|
|
s.parseSSEUsage(data, usage)
|
|
|
|
|
|
}
|
2026-01-04 19:49:59 +08:00
|
|
|
|
}
|
2026-01-19 03:46:09 +08:00
|
|
|
|
continue
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-01-04 19:49:59 +08:00
|
|
|
|
|
2026-01-19 03:46:09 +08:00
|
|
|
|
pendingEventLines = append(pendingEventLines, line)
|
|
|
|
|
|
|
2026-01-04 20:19:07 +08:00
|
|
|
|
case <-intervalCh:
|
|
|
|
|
|
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
|
|
|
|
|
|
if time.Since(lastRead) < streamInterval {
|
|
|
|
|
|
continue
|
2025-12-26 03:49:55 -08:00
|
|
|
|
}
|
2026-01-08 11:25:17 +08:00
|
|
|
|
if clientDisconnected {
|
|
|
|
|
|
// 客户端已断开,上游也超时了,返回已收集的 usage
|
|
|
|
|
|
log.Printf("Upstream timeout after client disconnect, returning collected usage")
|
|
|
|
|
|
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
|
|
|
|
|
|
}
|
2026-01-04 19:49:59 +08:00
|
|
|
|
log.Printf("Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval)
|
2026-01-11 21:54:52 -08:00
|
|
|
|
// 处理流超时,可能标记账户为临时不可调度或错误状态
|
|
|
|
|
|
if s.rateLimitService != nil {
|
|
|
|
|
|
s.rateLimitService.HandleStreamTimeout(ctx, account, originalModel)
|
|
|
|
|
|
}
|
2026-01-04 19:49:59 +08:00
|
|
|
|
sendErrorEvent("stream_timeout")
|
|
|
|
|
|
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout")
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 00:41:29 +08:00
|
|
|
|
func rewriteParamKeysInValue(value any, cache map[string]string) (any, bool) {
|
|
|
|
|
|
switch v := value.(type) {
|
|
|
|
|
|
case map[string]any:
|
|
|
|
|
|
changed := false
|
|
|
|
|
|
rewritten := make(map[string]any, len(v))
|
|
|
|
|
|
for key, item := range v {
|
|
|
|
|
|
newKey := normalizeParamNameForOpenCode(key, cache)
|
|
|
|
|
|
newItem, childChanged := rewriteParamKeysInValue(item, cache)
|
|
|
|
|
|
if childChanged {
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
if newKey != key {
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
rewritten[newKey] = newItem
|
|
|
|
|
|
}
|
|
|
|
|
|
if !changed {
|
|
|
|
|
|
return value, false
|
|
|
|
|
|
}
|
|
|
|
|
|
return rewritten, true
|
|
|
|
|
|
case []any:
|
|
|
|
|
|
changed := false
|
|
|
|
|
|
rewritten := make([]any, len(v))
|
|
|
|
|
|
for idx, item := range v {
|
|
|
|
|
|
newItem, childChanged := rewriteParamKeysInValue(item, cache)
|
|
|
|
|
|
if childChanged {
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
rewritten[idx] = newItem
|
|
|
|
|
|
}
|
|
|
|
|
|
if !changed {
|
|
|
|
|
|
return value, false
|
|
|
|
|
|
}
|
|
|
|
|
|
return rewritten, true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return value, false
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-01-16 00:41:29 +08:00
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-15 18:54:42 +08:00
|
|
|
|
func rewriteToolNamesInValue(value any, toolNameMap map[string]string) bool {
|
|
|
|
|
|
switch v := value.(type) {
|
|
|
|
|
|
case map[string]any:
|
|
|
|
|
|
changed := false
|
|
|
|
|
|
if blockType, _ := v["type"].(string); blockType == "tool_use" {
|
|
|
|
|
|
if name, ok := v["name"].(string); ok {
|
|
|
|
|
|
mapped := normalizeToolNameForOpenCode(name, toolNameMap)
|
|
|
|
|
|
if mapped != name {
|
|
|
|
|
|
v["name"] = mapped
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-16 00:41:29 +08:00
|
|
|
|
if input, ok := v["input"].(map[string]any); ok {
|
|
|
|
|
|
rewrittenInput, inputChanged := rewriteParamKeysInValue(input, toolNameMap)
|
|
|
|
|
|
if inputChanged {
|
|
|
|
|
|
if m, ok := rewrittenInput.(map[string]any); ok {
|
|
|
|
|
|
v["input"] = m
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-15 18:54:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
for _, item := range v {
|
|
|
|
|
|
if rewriteToolNamesInValue(item, toolNameMap) {
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return changed
|
|
|
|
|
|
case []any:
|
|
|
|
|
|
changed := false
|
|
|
|
|
|
for _, item := range v {
|
|
|
|
|
|
if rewriteToolNamesInValue(item, toolNameMap) {
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return changed
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-01-15 18:54:42 +08:00
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-15 19:17:07 +08:00
|
|
|
|
func replaceToolNamesInText(text string, toolNameMap map[string]string) string {
|
|
|
|
|
|
if text == "" {
|
|
|
|
|
|
return text
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-01-15 19:17:07 +08:00
|
|
|
|
output := toolNameFieldRe.ReplaceAllStringFunc(text, func(match string) string {
|
|
|
|
|
|
submatches := toolNameFieldRe.FindStringSubmatch(match)
|
|
|
|
|
|
if len(submatches) < 2 {
|
|
|
|
|
|
return match
|
|
|
|
|
|
}
|
|
|
|
|
|
name := submatches[1]
|
|
|
|
|
|
mapped := normalizeToolNameForOpenCode(name, toolNameMap)
|
|
|
|
|
|
if mapped == name {
|
|
|
|
|
|
return match
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Replace(match, name, mapped, 1)
|
|
|
|
|
|
})
|
|
|
|
|
|
output = modelFieldRe.ReplaceAllStringFunc(output, func(match string) string {
|
|
|
|
|
|
submatches := modelFieldRe.FindStringSubmatch(match)
|
|
|
|
|
|
if len(submatches) < 2 {
|
|
|
|
|
|
return match
|
|
|
|
|
|
}
|
|
|
|
|
|
model := submatches[1]
|
|
|
|
|
|
mapped := claude.DenormalizeModelID(model)
|
|
|
|
|
|
if mapped == model {
|
|
|
|
|
|
return match
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Replace(match, model, mapped, 1)
|
|
|
|
|
|
})
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-16 00:41:29 +08:00
|
|
|
|
for mapped, original := range toolNameMap {
|
|
|
|
|
|
if mapped == "" || original == "" || mapped == original {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
output = strings.ReplaceAll(output, "\""+mapped+"\":", "\""+original+"\":")
|
|
|
|
|
|
output = strings.ReplaceAll(output, "\\\""+mapped+"\\\":", "\\\""+original+"\\\":")
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 19:17:07 +08:00
|
|
|
|
return output
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
|
2025-12-23 16:53:53 +08:00
|
|
|
|
// 解析message_start获取input tokens(标准Claude API格式)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
var msgStart struct {
|
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
|
Message struct {
|
|
|
|
|
|
Usage ClaudeUsage `json:"usage"`
|
|
|
|
|
|
} `json:"message"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if json.Unmarshal([]byte(data), &msgStart) == nil && msgStart.Type == "message_start" {
|
|
|
|
|
|
usage.InputTokens = msgStart.Message.Usage.InputTokens
|
|
|
|
|
|
usage.CacheCreationInputTokens = msgStart.Message.Usage.CacheCreationInputTokens
|
|
|
|
|
|
usage.CacheReadInputTokens = msgStart.Message.Usage.CacheReadInputTokens
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 16:53:53 +08:00
|
|
|
|
// 解析message_delta获取tokens(兼容GLM等把所有usage放在delta中的API)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
var msgDelta struct {
|
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
|
Usage struct {
|
2025-12-23 16:53:53 +08:00
|
|
|
|
InputTokens int `json:"input_tokens"`
|
|
|
|
|
|
OutputTokens int `json:"output_tokens"`
|
|
|
|
|
|
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
|
|
|
|
|
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
2025-12-18 13:50:39 +08:00
|
|
|
|
} `json:"usage"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if json.Unmarshal([]byte(data), &msgDelta) == nil && msgDelta.Type == "message_delta" {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// message_delta 仅覆盖存在且非0的字段
|
|
|
|
|
|
// 避免覆盖 message_start 中已有的值(如 input_tokens)
|
|
|
|
|
|
// Claude API 的 message_delta 通常只包含 output_tokens
|
|
|
|
|
|
if msgDelta.Usage.InputTokens > 0 {
|
2025-12-23 16:53:53 +08:00
|
|
|
|
usage.InputTokens = msgDelta.Usage.InputTokens
|
|
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if msgDelta.Usage.OutputTokens > 0 {
|
|
|
|
|
|
usage.OutputTokens = msgDelta.Usage.OutputTokens
|
|
|
|
|
|
}
|
|
|
|
|
|
if msgDelta.Usage.CacheCreationInputTokens > 0 {
|
2025-12-23 16:53:53 +08:00
|
|
|
|
usage.CacheCreationInputTokens = msgDelta.Usage.CacheCreationInputTokens
|
|
|
|
|
|
}
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if msgDelta.Usage.CacheReadInputTokens > 0 {
|
2025-12-23 16:53:53 +08:00
|
|
|
|
usage.CacheReadInputTokens = msgDelta.Usage.CacheReadInputTokens
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 19:17:07 +08:00
|
|
|
|
func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string, toolNameMap map[string]string, mimicClaudeCode bool) (*ClaudeUsage, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 更新5h窗口状态
|
|
|
|
|
|
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
|
|
|
|
|
|
|
|
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析usage
|
|
|
|
|
|
var response struct {
|
|
|
|
|
|
Usage ClaudeUsage `json:"usage"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("parse response: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有模型映射,替换响应中的model字段
|
|
|
|
|
|
if originalModel != mappedModel {
|
|
|
|
|
|
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
|
|
|
|
|
|
}
|
2026-01-15 19:17:07 +08:00
|
|
|
|
if mimicClaudeCode {
|
2026-01-15 18:54:42 +08:00
|
|
|
|
body = s.replaceToolNamesInResponseBody(body, toolNameMap)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-02 17:40:57 +08:00
|
|
|
|
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-05 13:54:43 +08:00
|
|
|
|
contentType := "application/json"
|
|
|
|
|
|
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
|
|
|
|
|
|
if upstreamType := resp.Header.Get("Content-Type"); upstreamType != "" {
|
|
|
|
|
|
contentType = upstreamType
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 写入响应
|
2026-01-05 13:54:43 +08:00
|
|
|
|
c.Data(resp.StatusCode, contentType, body)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
return &response.Usage, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// replaceModelInResponseBody 替换响应体中的model字段
|
|
|
|
|
|
func (s *GatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte {
|
2025-12-20 16:19:40 +08:00
|
|
|
|
var resp map[string]any
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
model, ok := resp["model"].(string)
|
|
|
|
|
|
if !ok || model != fromModel {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resp["model"] = toModel
|
|
|
|
|
|
newBody, err := json.Marshal(resp)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return newBody
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 18:54:42 +08:00
|
|
|
|
func (s *GatewayService) replaceToolNamesInResponseBody(body []byte, toolNameMap map[string]string) []byte {
|
|
|
|
|
|
if len(body) == 0 {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
var resp map[string]any
|
|
|
|
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
2026-01-15 19:17:07 +08:00
|
|
|
|
replaced := replaceToolNamesInText(string(body), toolNameMap)
|
|
|
|
|
|
if replaced == string(body) {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
return []byte(replaced)
|
2026-01-15 18:54:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
if !rewriteToolNamesInValue(resp, toolNameMap) {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
newBody, err := json.Marshal(resp)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return body
|
|
|
|
|
|
}
|
|
|
|
|
|
return newBody
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// RecordUsageInput 记录使用量的输入参数
|
|
|
|
|
|
type RecordUsageInput struct {
|
|
|
|
|
|
Result *ForwardResult
|
2026-01-04 19:27:53 +08:00
|
|
|
|
APIKey *APIKey
|
2025-12-26 15:40:24 +08:00
|
|
|
|
User *User
|
|
|
|
|
|
Account *Account
|
|
|
|
|
|
Subscription *UserSubscription // 可选:订阅信息
|
2026-01-06 16:23:56 +08:00
|
|
|
|
UserAgent string // 请求的 User-Agent
|
2026-01-09 21:59:32 +08:00
|
|
|
|
IPAddress string // 请求的客户端 IP 地址
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RecordUsage 记录使用量并扣费(或更新订阅用量)
|
|
|
|
|
|
func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error {
|
|
|
|
|
|
result := input.Result
|
2026-01-04 19:27:53 +08:00
|
|
|
|
apiKey := input.APIKey
|
2025-12-18 13:50:39 +08:00
|
|
|
|
user := input.User
|
|
|
|
|
|
account := input.Account
|
|
|
|
|
|
subscription := input.Subscription
|
|
|
|
|
|
|
|
|
|
|
|
// 获取费率倍数
|
|
|
|
|
|
multiplier := s.cfg.Default.RateMultiplier
|
|
|
|
|
|
if apiKey.GroupID != nil && apiKey.Group != nil {
|
|
|
|
|
|
multiplier = apiKey.Group.RateMultiplier
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-05 17:07:29 +08:00
|
|
|
|
var cost *CostBreakdown
|
|
|
|
|
|
|
|
|
|
|
|
// 根据请求类型选择计费方式
|
|
|
|
|
|
if result.ImageCount > 0 {
|
|
|
|
|
|
// 图片生成计费
|
|
|
|
|
|
var groupConfig *ImagePriceConfig
|
|
|
|
|
|
if apiKey.Group != nil {
|
|
|
|
|
|
groupConfig = &ImagePriceConfig{
|
|
|
|
|
|
Price1K: apiKey.Group.ImagePrice1K,
|
|
|
|
|
|
Price2K: apiKey.Group.ImagePrice2K,
|
|
|
|
|
|
Price4K: apiKey.Group.ImagePrice4K,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
cost = s.billingService.CalculateImageCost(result.Model, result.ImageSize, result.ImageCount, groupConfig, multiplier)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Token 计费
|
|
|
|
|
|
tokens := UsageTokens{
|
|
|
|
|
|
InputTokens: result.Usage.InputTokens,
|
|
|
|
|
|
OutputTokens: result.Usage.OutputTokens,
|
|
|
|
|
|
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
|
|
|
|
|
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
|
|
|
|
|
}
|
|
|
|
|
|
var err error
|
|
|
|
|
|
cost, err = s.billingService.CalculateCost(result.Model, tokens, multiplier)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Calculate cost failed: %v", err)
|
|
|
|
|
|
cost = &CostBreakdown{ActualCost: 0}
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 判断计费方式:订阅模式 vs 余额模式
|
|
|
|
|
|
isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
2025-12-26 15:40:24 +08:00
|
|
|
|
billingType := BillingTypeBalance
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if isSubscriptionBilling {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
billingType = BillingTypeSubscription
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建使用日志
|
|
|
|
|
|
durationMs := int(result.Duration.Milliseconds())
|
2026-01-05 17:07:29 +08:00
|
|
|
|
var imageSize *string
|
|
|
|
|
|
if result.ImageSize != "" {
|
|
|
|
|
|
imageSize = &result.ImageSize
|
|
|
|
|
|
}
|
2026-01-15 15:14:44 +08:00
|
|
|
|
accountRateMultiplier := account.BillingRateMultiplier()
|
2025-12-26 15:40:24 +08:00
|
|
|
|
usageLog := &UsageLog{
|
2026-01-15 15:14:44 +08:00
|
|
|
|
UserID: user.ID,
|
|
|
|
|
|
APIKeyID: apiKey.ID,
|
|
|
|
|
|
AccountID: account.ID,
|
|
|
|
|
|
RequestID: result.RequestID,
|
|
|
|
|
|
Model: result.Model,
|
|
|
|
|
|
InputTokens: result.Usage.InputTokens,
|
|
|
|
|
|
OutputTokens: result.Usage.OutputTokens,
|
|
|
|
|
|
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
|
|
|
|
|
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
|
|
|
|
|
InputCost: cost.InputCost,
|
|
|
|
|
|
OutputCost: cost.OutputCost,
|
|
|
|
|
|
CacheCreationCost: cost.CacheCreationCost,
|
|
|
|
|
|
CacheReadCost: cost.CacheReadCost,
|
|
|
|
|
|
TotalCost: cost.TotalCost,
|
|
|
|
|
|
ActualCost: cost.ActualCost,
|
|
|
|
|
|
RateMultiplier: multiplier,
|
|
|
|
|
|
AccountRateMultiplier: &accountRateMultiplier,
|
|
|
|
|
|
BillingType: billingType,
|
|
|
|
|
|
Stream: result.Stream,
|
|
|
|
|
|
DurationMs: &durationMs,
|
|
|
|
|
|
FirstTokenMs: result.FirstTokenMs,
|
|
|
|
|
|
ImageCount: result.ImageCount,
|
|
|
|
|
|
ImageSize: imageSize,
|
|
|
|
|
|
CreatedAt: time.Now(),
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-06 16:23:56 +08:00
|
|
|
|
// 添加 UserAgent
|
|
|
|
|
|
if input.UserAgent != "" {
|
|
|
|
|
|
usageLog.UserAgent = &input.UserAgent
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 21:59:32 +08:00
|
|
|
|
// 添加 IPAddress
|
|
|
|
|
|
if input.IPAddress != "" {
|
|
|
|
|
|
usageLog.IPAddress = &input.IPAddress
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 添加分组和订阅关联
|
|
|
|
|
|
if apiKey.GroupID != nil {
|
|
|
|
|
|
usageLog.GroupID = apiKey.GroupID
|
|
|
|
|
|
}
|
|
|
|
|
|
if subscription != nil {
|
|
|
|
|
|
usageLog.SubscriptionID = &subscription.ID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 17:10:25 -08:00
|
|
|
|
inserted, err := s.usageLogRepo.Create(ctx, usageLog)
|
|
|
|
|
|
if err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("Create usage log failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 03:17:25 +08:00
|
|
|
|
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
|
|
|
|
|
log.Printf("[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
|
|
|
|
|
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 17:10:25 -08:00
|
|
|
|
shouldBill := inserted || err != nil
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 根据计费类型执行扣费
|
|
|
|
|
|
if isSubscriptionBilling {
|
|
|
|
|
|
// 订阅模式:更新订阅用量(使用 TotalCost 原始费用,不考虑倍率)
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if shouldBill && cost.TotalCost > 0 {
|
2025-12-19 21:26:19 +08:00
|
|
|
|
if err := s.userSubRepo.IncrementUsage(ctx, subscription.ID, cost.TotalCost); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("Increment subscription usage failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 异步更新订阅缓存
|
2025-12-31 08:50:12 +08:00
|
|
|
|
s.billingCacheService.QueueUpdateSubscriptionUsage(user.ID, *apiKey.GroupID, cost.TotalCost)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 余额模式:扣除用户余额(使用 ActualCost 考虑倍率后的费用)
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if shouldBill && cost.ActualCost > 0 {
|
2025-12-19 21:26:19 +08:00
|
|
|
|
if err := s.userRepo.DeductBalance(ctx, user.ID, cost.ActualCost); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("Deduct balance failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 异步更新余额缓存
|
2025-12-31 08:50:12 +08:00
|
|
|
|
s.billingCacheService.QueueDeductBalance(user.ID, cost.ActualCost)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 08:07:15 +08:00
|
|
|
|
// Schedule batch update for account last_used_at
|
|
|
|
|
|
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2025-12-19 11:12:41 +08:00
|
|
|
|
|
2026-02-02 16:37:22 +08:00
|
|
|
|
// RecordUsageLongContextInput 记录使用量的输入参数(支持长上下文双倍计费)
|
|
|
|
|
|
type RecordUsageLongContextInput struct {
|
|
|
|
|
|
Result *ForwardResult
|
|
|
|
|
|
APIKey *APIKey
|
|
|
|
|
|
User *User
|
|
|
|
|
|
Account *Account
|
|
|
|
|
|
Subscription *UserSubscription // 可选:订阅信息
|
|
|
|
|
|
UserAgent string // 请求的 User-Agent
|
|
|
|
|
|
IPAddress string // 请求的客户端 IP 地址
|
|
|
|
|
|
LongContextThreshold int // 长上下文阈值(如 200000)
|
|
|
|
|
|
LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini)
|
|
|
|
|
|
func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *RecordUsageLongContextInput) error {
|
|
|
|
|
|
result := input.Result
|
|
|
|
|
|
apiKey := input.APIKey
|
|
|
|
|
|
user := input.User
|
|
|
|
|
|
account := input.Account
|
|
|
|
|
|
subscription := input.Subscription
|
|
|
|
|
|
|
|
|
|
|
|
// 获取费率倍数
|
|
|
|
|
|
multiplier := s.cfg.Default.RateMultiplier
|
|
|
|
|
|
if apiKey.GroupID != nil && apiKey.Group != nil {
|
|
|
|
|
|
multiplier = apiKey.Group.RateMultiplier
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var cost *CostBreakdown
|
|
|
|
|
|
|
|
|
|
|
|
// 根据请求类型选择计费方式
|
|
|
|
|
|
if result.ImageCount > 0 {
|
|
|
|
|
|
// 图片生成计费
|
|
|
|
|
|
var groupConfig *ImagePriceConfig
|
|
|
|
|
|
if apiKey.Group != nil {
|
|
|
|
|
|
groupConfig = &ImagePriceConfig{
|
|
|
|
|
|
Price1K: apiKey.Group.ImagePrice1K,
|
|
|
|
|
|
Price2K: apiKey.Group.ImagePrice2K,
|
|
|
|
|
|
Price4K: apiKey.Group.ImagePrice4K,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
cost = s.billingService.CalculateImageCost(result.Model, result.ImageSize, result.ImageCount, groupConfig, multiplier)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Token 计费(使用长上下文计费方法)
|
|
|
|
|
|
tokens := UsageTokens{
|
|
|
|
|
|
InputTokens: result.Usage.InputTokens,
|
|
|
|
|
|
OutputTokens: result.Usage.OutputTokens,
|
|
|
|
|
|
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
|
|
|
|
|
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
|
|
|
|
|
}
|
|
|
|
|
|
var err error
|
|
|
|
|
|
cost, err = s.billingService.CalculateCostWithLongContext(result.Model, tokens, multiplier, input.LongContextThreshold, input.LongContextMultiplier)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Calculate cost failed: %v", err)
|
|
|
|
|
|
cost = &CostBreakdown{ActualCost: 0}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 判断计费方式:订阅模式 vs 余额模式
|
|
|
|
|
|
isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
|
|
|
|
|
billingType := BillingTypeBalance
|
|
|
|
|
|
if isSubscriptionBilling {
|
|
|
|
|
|
billingType = BillingTypeSubscription
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建使用日志
|
|
|
|
|
|
durationMs := int(result.Duration.Milliseconds())
|
|
|
|
|
|
var imageSize *string
|
|
|
|
|
|
if result.ImageSize != "" {
|
|
|
|
|
|
imageSize = &result.ImageSize
|
|
|
|
|
|
}
|
|
|
|
|
|
accountRateMultiplier := account.BillingRateMultiplier()
|
|
|
|
|
|
usageLog := &UsageLog{
|
|
|
|
|
|
UserID: user.ID,
|
|
|
|
|
|
APIKeyID: apiKey.ID,
|
|
|
|
|
|
AccountID: account.ID,
|
2026-01-15 15:14:44 +08:00
|
|
|
|
RequestID: result.RequestID,
|
|
|
|
|
|
Model: result.Model,
|
|
|
|
|
|
InputTokens: result.Usage.InputTokens,
|
|
|
|
|
|
OutputTokens: result.Usage.OutputTokens,
|
|
|
|
|
|
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
|
|
|
|
|
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
|
|
|
|
|
InputCost: cost.InputCost,
|
|
|
|
|
|
OutputCost: cost.OutputCost,
|
|
|
|
|
|
CacheCreationCost: cost.CacheCreationCost,
|
|
|
|
|
|
CacheReadCost: cost.CacheReadCost,
|
|
|
|
|
|
TotalCost: cost.TotalCost,
|
|
|
|
|
|
ActualCost: cost.ActualCost,
|
|
|
|
|
|
RateMultiplier: multiplier,
|
|
|
|
|
|
AccountRateMultiplier: &accountRateMultiplier,
|
|
|
|
|
|
BillingType: billingType,
|
|
|
|
|
|
Stream: result.Stream,
|
|
|
|
|
|
DurationMs: &durationMs,
|
|
|
|
|
|
FirstTokenMs: result.FirstTokenMs,
|
|
|
|
|
|
ImageCount: result.ImageCount,
|
|
|
|
|
|
ImageSize: imageSize,
|
|
|
|
|
|
CreatedAt: time.Now(),
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-06 16:23:56 +08:00
|
|
|
|
// 添加 UserAgent
|
|
|
|
|
|
if input.UserAgent != "" {
|
|
|
|
|
|
usageLog.UserAgent = &input.UserAgent
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 21:59:32 +08:00
|
|
|
|
// 添加 IPAddress
|
|
|
|
|
|
if input.IPAddress != "" {
|
|
|
|
|
|
usageLog.IPAddress = &input.IPAddress
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 添加分组和订阅关联
|
|
|
|
|
|
if apiKey.GroupID != nil {
|
|
|
|
|
|
usageLog.GroupID = apiKey.GroupID
|
|
|
|
|
|
}
|
|
|
|
|
|
if subscription != nil {
|
|
|
|
|
|
usageLog.SubscriptionID = &subscription.ID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 17:10:25 -08:00
|
|
|
|
inserted, err := s.usageLogRepo.Create(ctx, usageLog)
|
|
|
|
|
|
if err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("Create usage log failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 03:17:25 +08:00
|
|
|
|
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
|
|
|
|
|
log.Printf("[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
|
|
|
|
|
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 17:10:25 -08:00
|
|
|
|
shouldBill := inserted || err != nil
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 根据计费类型执行扣费
|
|
|
|
|
|
if isSubscriptionBilling {
|
|
|
|
|
|
// 订阅模式:更新订阅用量(使用 TotalCost 原始费用,不考虑倍率)
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if shouldBill && cost.TotalCost > 0 {
|
2025-12-19 21:26:19 +08:00
|
|
|
|
if err := s.userSubRepo.IncrementUsage(ctx, subscription.ID, cost.TotalCost); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("Increment subscription usage failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 异步更新订阅缓存
|
2025-12-31 08:50:12 +08:00
|
|
|
|
s.billingCacheService.QueueUpdateSubscriptionUsage(user.ID, *apiKey.GroupID, cost.TotalCost)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 余额模式:扣除用户余额(使用 ActualCost 考虑倍率后的费用)
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if shouldBill && cost.ActualCost > 0 {
|
2025-12-19 21:26:19 +08:00
|
|
|
|
if err := s.userRepo.DeductBalance(ctx, user.ID, cost.ActualCost); err != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
log.Printf("Deduct balance failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 异步更新余额缓存
|
2025-12-31 08:50:12 +08:00
|
|
|
|
s.billingCacheService.QueueDeductBalance(user.ID, cost.ActualCost)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 08:07:15 +08:00
|
|
|
|
// Schedule batch update for account last_used_at
|
|
|
|
|
|
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2025-12-19 11:12:41 +08:00
|
|
|
|
|
|
|
|
|
|
// ForwardCountTokens 转发 count_tokens 请求到上游 API
|
|
|
|
|
|
// 特点:不记录使用量、仅支持非流式响应
|
2025-12-31 08:50:12 +08:00
|
|
|
|
func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) error {
|
|
|
|
|
|
if parsed == nil {
|
|
|
|
|
|
s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
|
|
|
|
|
|
return fmt.Errorf("parse request: empty request")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body := parsed.Body
|
|
|
|
|
|
reqModel := parsed.Model
|
|
|
|
|
|
|
2026-01-15 19:17:07 +08:00
|
|
|
|
isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
|
|
|
|
|
|
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
|
|
|
|
|
|
|
|
|
|
|
if shouldMimicClaudeCode {
|
2026-01-15 18:54:42 +08:00
|
|
|
|
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
|
|
|
|
|
|
body, reqModel, _ = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 06:32:51 -08:00
|
|
|
|
// Antigravity 账户不支持 count_tokens 转发,直接返回空值
|
2025-12-28 21:56:52 +08:00
|
|
|
|
if account.Platform == PlatformAntigravity {
|
2026-01-03 06:32:51 -08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{"input_tokens": 0})
|
2025-12-28 21:56:52 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-23 22:24:46 +08:00
|
|
|
|
// 应用模型映射(APIKey 明确映射优先,其次使用 Anthropic 前缀映射)
|
|
|
|
|
|
if reqModel != "" {
|
|
|
|
|
|
mappedModel := reqModel
|
|
|
|
|
|
mappingSource := ""
|
|
|
|
|
|
if account.Type == AccountTypeAPIKey {
|
|
|
|
|
|
mappedModel = account.GetMappedModel(reqModel)
|
2025-12-31 08:50:12 +08:00
|
|
|
|
if mappedModel != reqModel {
|
2026-01-23 22:24:46 +08:00
|
|
|
|
mappingSource = "account"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if mappingSource == "" && account.Platform == PlatformAnthropic {
|
|
|
|
|
|
normalized := normalizeClaudeModelForAnthropic(reqModel)
|
|
|
|
|
|
if normalized != reqModel {
|
|
|
|
|
|
mappedModel = normalized
|
|
|
|
|
|
mappingSource = "prefix"
|
2025-12-19 11:12:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-23 22:24:46 +08:00
|
|
|
|
if mappedModel != reqModel {
|
|
|
|
|
|
body = s.replaceModelInBody(body, mappedModel)
|
|
|
|
|
|
reqModel = mappedModel
|
|
|
|
|
|
log.Printf("CountTokens model mapping applied: %s -> %s (account: %s, source=%s)", parsed.Model, mappedModel, account.Name, mappingSource)
|
|
|
|
|
|
}
|
2025-12-19 11:12:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取凭证
|
|
|
|
|
|
token, tokenType, err := s.GetAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token")
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 构建上游请求
|
2026-01-15 19:17:07 +08:00
|
|
|
|
upstreamReq, err := s.buildCountTokensRequest(ctx, c, account, body, token, tokenType, reqModel, shouldMimicClaudeCode)
|
2025-12-19 11:12:41 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.countTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request")
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:56:11 +08:00
|
|
|
|
// 获取代理URL
|
|
|
|
|
|
proxyURL := ""
|
|
|
|
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|
|
|
|
|
proxyURL = account.Proxy.URL()
|
2025-12-19 11:12:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 发送请求
|
2026-02-02 22:13:50 +08:00
|
|
|
|
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
2025-12-19 11:12:41 +08:00
|
|
|
|
if err != nil {
|
2026-01-11 15:30:27 +08:00
|
|
|
|
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
|
2025-12-19 11:12:41 +08:00
|
|
|
|
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
|
|
|
|
|
|
return fmt.Errorf("upstream request failed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 读取响应体
|
|
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
2026-01-03 17:10:25 -08:00
|
|
|
|
_ = resp.Body.Close()
|
2025-12-19 11:12:41 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 17:10:25 -08:00
|
|
|
|
// 检测 thinking block 签名错误(400)并重试一次(过滤 thinking blocks)
|
|
|
|
|
|
if resp.StatusCode == 400 && s.isThinkingBlockSignatureError(respBody) {
|
|
|
|
|
|
log.Printf("Account %d: detected thinking block signature error on count_tokens, retrying with filtered thinking blocks", account.ID)
|
|
|
|
|
|
|
2026-01-04 22:32:36 +08:00
|
|
|
|
filteredBody := FilterThinkingBlocksForRetry(body)
|
2026-01-15 19:17:07 +08:00
|
|
|
|
retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode)
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if buildErr == nil {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
2026-01-03 17:10:25 -08:00
|
|
|
|
if retryErr == nil {
|
|
|
|
|
|
resp = retryResp
|
|
|
|
|
|
respBody, err = io.ReadAll(resp.Body)
|
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 11:12:41 +08:00
|
|
|
|
// 处理错误响应
|
|
|
|
|
|
if resp.StatusCode >= 400 {
|
|
|
|
|
|
// 标记账号状态(429/529等)
|
|
|
|
|
|
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
|
|
|
|
|
|
2026-01-11 15:30:27 +08:00
|
|
|
|
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
|
|
|
|
|
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
|
|
|
|
|
upstreamDetail := ""
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
|
|
|
|
|
if maxBytes <= 0 {
|
|
|
|
|
|
maxBytes = 2048
|
|
|
|
|
|
}
|
|
|
|
|
|
upstreamDetail = truncateString(string(respBody), maxBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
|
|
|
|
|
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
// 记录上游错误摘要便于排障(不回显请求内容)
|
|
|
|
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|
|
|
|
|
log.Printf(
|
|
|
|
|
|
"count_tokens upstream error %d (account=%d platform=%s type=%s): %s",
|
|
|
|
|
|
resp.StatusCode,
|
|
|
|
|
|
account.ID,
|
|
|
|
|
|
account.Platform,
|
|
|
|
|
|
account.Type,
|
|
|
|
|
|
truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 11:12:41 +08:00
|
|
|
|
// 返回简化的错误响应
|
|
|
|
|
|
errMsg := "Upstream request failed"
|
|
|
|
|
|
switch resp.StatusCode {
|
|
|
|
|
|
case 429:
|
|
|
|
|
|
errMsg = "Rate limit exceeded"
|
|
|
|
|
|
case 529:
|
|
|
|
|
|
errMsg = "Service overloaded"
|
|
|
|
|
|
}
|
|
|
|
|
|
s.countTokensError(c, resp.StatusCode, "upstream_error", errMsg)
|
2026-01-11 15:30:27 +08:00
|
|
|
|
if upstreamMsg == "" {
|
|
|
|
|
|
return fmt.Errorf("upstream error: %d", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
|
2025-12-19 11:12:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 透传成功响应
|
|
|
|
|
|
c.Data(resp.StatusCode, "application/json", respBody)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// buildCountTokensRequest 构建 count_tokens 上游请求
|
2026-01-15 19:17:07 +08:00
|
|
|
|
func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, mimicClaudeCode bool) (*http.Request, error) {
|
2025-12-19 11:12:41 +08:00
|
|
|
|
// 确定目标 URL
|
|
|
|
|
|
targetURL := claudeAPICountTokensURL
|
2026-01-04 19:27:53 +08:00
|
|
|
|
if account.Type == AccountTypeAPIKey {
|
2025-12-19 11:12:41 +08:00
|
|
|
|
baseURL := account.GetBaseURL()
|
2026-01-02 17:40:57 +08:00
|
|
|
|
if baseURL != "" {
|
|
|
|
|
|
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
targetURL = validatedURL + "/v1/messages/count_tokens"
|
|
|
|
|
|
}
|
2025-12-19 11:12:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 02:07:57 +08:00
|
|
|
|
clientHeaders := http.Header{}
|
|
|
|
|
|
if c != nil && c.Request != nil {
|
|
|
|
|
|
clientHeaders = c.Request.Header
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 11:12:41 +08:00
|
|
|
|
// OAuth 账号:应用统一指纹和重写 userID
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
|
2025-12-19 11:12:41 +08:00
|
|
|
|
if account.IsOAuth() && s.identityService != nil {
|
2026-01-31 02:07:57 +08:00
|
|
|
|
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
|
2025-12-19 11:12:41 +08:00
|
|
|
|
if err == nil {
|
|
|
|
|
|
accountUUID := account.GetExtraString("account_uuid")
|
|
|
|
|
|
if accountUUID != "" && fp.ClientID != "" {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID); err == nil && len(newBody) > 0 {
|
2025-12-19 11:12:41 +08:00
|
|
|
|
body = newBody
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 设置认证头
|
|
|
|
|
|
if tokenType == "oauth" {
|
2025-12-22 22:58:31 +08:00
|
|
|
|
req.Header.Set("authorization", "Bearer "+token)
|
2025-12-19 11:12:41 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
req.Header.Set("x-api-key", token)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 白名单透传 headers
|
2026-01-31 02:07:57 +08:00
|
|
|
|
for key, values := range clientHeaders {
|
2025-12-19 11:12:41 +08:00
|
|
|
|
lowerKey := strings.ToLower(key)
|
|
|
|
|
|
if allowedHeaders[lowerKey] {
|
|
|
|
|
|
for _, v := range values {
|
|
|
|
|
|
req.Header.Add(key, v)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuth 账号:应用指纹到请求头
|
|
|
|
|
|
if account.IsOAuth() && s.identityService != nil {
|
2026-01-31 02:07:57 +08:00
|
|
|
|
fp, _ := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
|
2025-12-19 11:12:41 +08:00
|
|
|
|
if fp != nil {
|
|
|
|
|
|
s.identityService.ApplyFingerprint(req, fp)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 确保必要的 headers 存在
|
2025-12-22 22:58:31 +08:00
|
|
|
|
if req.Header.Get("content-type") == "" {
|
|
|
|
|
|
req.Header.Set("content-type", "application/json")
|
2025-12-19 11:12:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
if req.Header.Get("anthropic-version") == "" {
|
|
|
|
|
|
req.Header.Set("anthropic-version", "2023-06-01")
|
|
|
|
|
|
}
|
2026-01-19 15:01:32 +08:00
|
|
|
|
if tokenType == "oauth" {
|
2026-01-15 18:54:42 +08:00
|
|
|
|
applyClaudeOAuthHeaderDefaults(req, false)
|
|
|
|
|
|
}
|
2025-12-19 11:12:41 +08:00
|
|
|
|
|
|
|
|
|
|
// OAuth 账号:处理 anthropic-beta header
|
|
|
|
|
|
if tokenType == "oauth" {
|
2026-01-16 23:15:52 +08:00
|
|
|
|
if mimicClaudeCode {
|
2026-01-29 02:36:28 +08:00
|
|
|
|
applyClaudeCodeMimicHeaders(req, false)
|
|
|
|
|
|
|
|
|
|
|
|
incomingBeta := req.Header.Get("anthropic-beta")
|
|
|
|
|
|
requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting}
|
|
|
|
|
|
req.Header.Set("anthropic-beta", mergeAnthropicBeta(requiredBetas, incomingBeta))
|
2026-01-16 23:15:52 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
clientBetaHeader := req.Header.Get("anthropic-beta")
|
|
|
|
|
|
if clientBetaHeader == "" {
|
|
|
|
|
|
req.Header.Set("anthropic-beta", claude.CountTokensBetaHeader)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
beta := s.getBetaHeader(modelID, clientBetaHeader)
|
|
|
|
|
|
if !strings.Contains(beta, claude.BetaTokenCounting) {
|
|
|
|
|
|
beta = beta + "," + claude.BetaTokenCounting
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("anthropic-beta", beta)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-04 19:27:53 +08:00
|
|
|
|
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" {
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
// API-key:与 messages 同步的按需 beta 注入(默认关闭)
|
|
|
|
|
|
if requestNeedsBetaFeatures(body) {
|
2026-01-04 19:27:53 +08:00
|
|
|
|
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
|
fix: 修复 /v1/messages 间歇性 400 错误 (#18)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* feat(gemini): 添加Gemini限额与TierID支持
实现PR1:Gemini限额与TierID功能
后端修改:
- GeminiTokenInfo结构体添加TierID字段
- fetchProjectID函数返回(projectID, tierID, error)
- 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier)
- ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID
- BuildAccountCredentials函数保存tier_id到credentials
前端修改:
- AccountStatusIndicator组件添加tier显示
- 支持LEGACY/PRO/ULTRA等tier类型的友好显示
- 使用蓝色badge展示tier信息
技术细节:
- tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier
- 所有fetchProjectID调用点已更新以处理新的返回签名
- 前端gracefully处理missing/unknown tier_id
* refactor(gemini): 优化TierID实现并添加安全验证
根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进:
安全改进:
- 添加validateTierID函数验证tier_id格式和长度(最大64字符)
- 限制tier_id字符集为字母数字、下划线、连字符和斜杠
- 在BuildAccountCredentials中验证tier_id后再存储
- 静默跳过无效tier_id,不阻塞账户创建
代码质量改进:
- 提取extractTierIDFromAllowedTiers辅助函数消除重复代码
- 重构fetchProjectID函数,tierID提取逻辑只执行一次
- 改进代码可读性和可维护性
审查工具:
- code-reviewer agent (a09848e)
- security-auditor agent (a9a149c)
- gemini CLI (bcc7c81)
- codex (b5d8919)
修复问题:
- HIGH: 未验证的tier_id输入
- MEDIUM: 代码重复(tierID提取逻辑重复2次)
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(upstream): 修复上游格式兼容性问题 (#14)
* fix(upstream): 修复上游格式兼容性问题
- 跳过Claude模型无signature的thinking block
- 支持custom类型工具(MCP)格式转换
- 添加ClaudeCustomToolSpec结构体支持MCP工具
- 添加Custom字段验证,跳过无效custom工具
- 在convertClaudeToolsToGeminiTools中添加schema清理
- 完整的单元测试覆盖,包含边界情况
修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式
改进: Codex审查发现的2个重要问题
测试:
- TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理
- TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况
- TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换
* fix(format): 修复 gofmt 格式问题
- 修复 claude_types.go 中的字段对齐问题
- 修复 gemini_messages_compat_service.go 中的缩进问题
* fix(format): 修复 claude_types.go 的 gofmt 格式问题
* feat(antigravity): 优化 thinking block 和 schema 处理
- 为 dummy thinking block 添加 ThoughtSignature
- 重构 thinking block 处理逻辑,在每个条件分支内创建 part
- 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段
(minItems, maxItems, minimum, maximum, additionalProperties, format)
- 添加详细注释说明 Gemini API 支持的 schema 字段
* fix(antigravity): 增强 schema 清理的安全性
基于 Codex review 建议:
- 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time
- 补充更多不支持的 schema 关键字到黑名单:
* 组合 schema: oneOf, anyOf, allOf, not, if/then/else
* 对象验证: minProperties, maxProperties, patternProperties 等
* 定义引用: $defs, definitions
- 避免不支持的 schema 字段导致 Gemini API 校验失败
* fix(lint): 修复 gemini_messages_compat_service 空分支警告
- 在 cleanToolSchema 的 if 语句中添加 continue
- 移除重复的注释
* fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API
- 将 minItems 和 maxItems 添加到 schema 黑名单
- Claude API (Vertex AI) 不支持这些数组验证字段
- 添加调试日志记录工具 schema 转换过程
- 修复 tools.14.custom.input_schema 验证错误
* fix(antigravity): 修复 additionalProperties schema 对象问题
- 将 additionalProperties 的 schema 对象转换为布尔值 true
- Claude API 只支持 additionalProperties: false,不支持 schema 对象
- 修复 tools.14.custom.input_schema 验证错误
- 参考 Claude 官方文档的 JSON Schema 限制
* fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题
- 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败
- 只在 Gemini 模型中使用 dummy thought signature
- 修改 additionalProperties 默认值为 false(更安全)
- 添加调试日志以便排查问题
* fix(upstream): 修复跨模型切换时的 dummy signature 问题
基于 Codex review 和用户场景分析的修复:
1. 问题场景
- Gemini (thinking) → Claude (thinking) 切换时
- Gemini 返回的 thinking 块使用 dummy signature
- Claude API 会拒绝 dummy signature,导致 400 错误
2. 修复内容
- request_transformer.go:262: 跳过 dummy signature
- 只保留真实的 Claude signature
- 支持频繁的跨模型切换
3. 其他修复(基于 Codex review)
- gateway_service.go:691: 修复 io.ReadAll 错误处理
- gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置)
- gateway_service.go:915: 收紧 400 failover 启发式
- request_transformer.go:188: 移除签名成功日志
4. 新增功能(默认关闭)
- 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY)
- 阶段 2: Antigravity thinking 修复
- 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY)
- 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400)
测试:所有测试通过
* fix(lint): 修复 golangci-lint 问题
- 应用 De Morgan 定律简化条件判断
- 修复 gofmt 格式问题
- 移除未使用的 min 函数
2026-01-01 04:21:18 +08:00
|
|
|
|
req.Header.Set("anthropic-beta", beta)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-19 11:12:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 15:17:46 +08:00
|
|
|
|
if c != nil && tokenType == "oauth" {
|
|
|
|
|
|
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
|
|
|
|
|
|
}
|
2026-01-29 03:13:14 +08:00
|
|
|
|
if s.debugClaudeMimicEnabled() {
|
|
|
|
|
|
logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:56:11 +08:00
|
|
|
|
return req, nil
|
2025-12-19 11:12:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// countTokensError 返回 count_tokens 错误响应
|
|
|
|
|
|
func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, message string) {
|
|
|
|
|
|
c.JSON(status, gin.H{
|
|
|
|
|
|
"type": "error",
|
|
|
|
|
|
"error": gin.H{
|
|
|
|
|
|
"type": errType,
|
|
|
|
|
|
"message": message,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-01-01 16:03:48 +08:00
|
|
|
|
|
2026-01-02 17:40:57 +08:00
|
|
|
|
func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
|
2026-01-05 13:54:43 +08:00
|
|
|
|
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
|
2026-01-05 14:41:08 +08:00
|
|
|
|
normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("invalid base_url: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalized, nil
|
2026-01-05 13:54:43 +08:00
|
|
|
|
}
|
2026-01-02 17:40:57 +08:00
|
|
|
|
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
|
|
|
|
|
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
|
|
|
|
|
RequireAllowlist: true,
|
|
|
|
|
|
AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("invalid base_url: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalized, nil
|
|
|
|
|
|
}
|
2026-01-03 11:36:31 +08:00
|
|
|
|
|
2026-02-02 22:20:08 +08:00
|
|
|
|
// checkAntigravityModelScope 检查 Antigravity 平台的模型系列是否在分组支持范围内
|
|
|
|
|
|
func (s *GatewayService) checkAntigravityModelScope(ctx context.Context, groupID int64, requestedModel string) error {
|
|
|
|
|
|
scope, ok := ResolveAntigravityQuotaScope(requestedModel)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return nil // 无法解析 scope,跳过检查
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
group, err := s.resolveGroupByID(ctx, groupID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil // 查询失败时放行
|
|
|
|
|
|
}
|
|
|
|
|
|
if group == nil {
|
|
|
|
|
|
return nil // 分组不存在时放行
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !IsScopeSupported(group.SupportedModelScopes, scope) {
|
|
|
|
|
|
return ErrModelScopeNotSupported
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 16:03:48 +08:00
|
|
|
|
// GetAvailableModels returns the list of models available for a group
|
|
|
|
|
|
// It aggregates model_mapping keys from all schedulable accounts in the group
|
|
|
|
|
|
func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, platform string) []string {
|
|
|
|
|
|
var accounts []Account
|
|
|
|
|
|
var err error
|
|
|
|
|
|
|
|
|
|
|
|
if groupID != nil {
|
|
|
|
|
|
accounts, err = s.accountRepo.ListSchedulableByGroupID(ctx, *groupID)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
accounts, err = s.accountRepo.ListSchedulable(ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err != nil || len(accounts) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Filter by platform if specified
|
|
|
|
|
|
if platform != "" {
|
|
|
|
|
|
filtered := make([]Account, 0)
|
|
|
|
|
|
for _, acc := range accounts {
|
|
|
|
|
|
if acc.Platform == platform {
|
|
|
|
|
|
filtered = append(filtered, acc)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
accounts = filtered
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Collect unique models from all accounts
|
|
|
|
|
|
modelSet := make(map[string]struct{})
|
|
|
|
|
|
hasAnyMapping := false
|
|
|
|
|
|
|
|
|
|
|
|
for _, acc := range accounts {
|
|
|
|
|
|
mapping := acc.GetModelMapping()
|
|
|
|
|
|
if len(mapping) > 0 {
|
|
|
|
|
|
hasAnyMapping = true
|
|
|
|
|
|
for model := range mapping {
|
|
|
|
|
|
modelSet[model] = struct{}{}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If no account has model_mapping, return nil (use default)
|
|
|
|
|
|
if !hasAnyMapping {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Convert to slice
|
|
|
|
|
|
models := make([]string, 0, len(modelSet))
|
|
|
|
|
|
for model := range modelSet {
|
|
|
|
|
|
models = append(models, model)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return models
|
|
|
|
|
|
}
|