Compare commits
12 Commits
0cdb61bceb
...
888b7eeb21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
888b7eeb21 | ||
|
|
56a955e1d6 | ||
|
|
6cfdf4ec05 | ||
|
|
358ff6a608 | ||
|
|
41fbdba104 | ||
|
|
c22d11cedd | ||
|
|
5d586a9f3a | ||
|
|
a789c8c4c7 | ||
|
|
697c41a3f6 | ||
|
|
e44baa1094 | ||
|
|
10699eeb34 | ||
|
|
6c89d8d35c |
@ -1 +1 @@
|
||||
0.1.113
|
||||
0.1.114
|
||||
|
||||
@ -71,6 +71,7 @@ const (
|
||||
// 与前端 useModelWhitelist.ts 中的 antigravityDefaultMappings 保持一致
|
||||
var DefaultAntigravityModelMapping = map[string]string{
|
||||
// Claude 白名单
|
||||
"claude-opus-4-7": "claude-opus-4-7", // 官方模型
|
||||
"claude-opus-4-6-thinking": "claude-opus-4-6-thinking", // 官方模型
|
||||
"claude-opus-4-6": "claude-opus-4-6-thinking", // 简称映射
|
||||
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking", // 迁移旧模型
|
||||
@ -120,6 +121,7 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
// aws_region 自动调整为匹配的区域前缀(如 eu.、apac.、jp. 等)
|
||||
var DefaultBedrockModelMapping = map[string]string{
|
||||
// Claude Opus
|
||||
"claude-opus-4-7": "us.anthropic.claude-opus-4-7-v1",
|
||||
"claude-opus-4-6-thinking": "us.anthropic.claude-opus-4-6-v1",
|
||||
"claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1",
|
||||
"claude-opus-4-5-thinking": "us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
|
||||
@ -213,7 +213,7 @@ func (h *AntigravityHTTPHandler) GetModels(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"models": models,
|
||||
"default_model": "claude-opus-4-6",
|
||||
"default_model": "claude-opus-4-7",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -162,6 +162,7 @@ var claudeModels = []modelDef{
|
||||
{ID: "claude-sonnet-4-5-thinking", DisplayName: "Claude Sonnet 4.5 Thinking", CreatedAt: "2025-09-29T00:00:00Z"},
|
||||
{ID: "claude-opus-4-6", DisplayName: "Claude Opus 4.6", CreatedAt: "2026-02-05T00:00:00Z"},
|
||||
{ID: "claude-opus-4-6-thinking", DisplayName: "Claude Opus 4.6 Thinking", CreatedAt: "2026-02-05T00:00:00Z"},
|
||||
{ID: "claude-opus-4-7", DisplayName: "Claude Opus 4.7", CreatedAt: "2026-04-17T00:00:00Z"},
|
||||
{ID: "claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6", CreatedAt: "2026-02-17T00:00:00Z"},
|
||||
}
|
||||
|
||||
|
||||
@ -1023,8 +1023,12 @@ func maxOutputTokensLimit(model string) int {
|
||||
return maxOutputTokensUpperBound
|
||||
}
|
||||
|
||||
func isAntigravityOpus46Model(model string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(model), "claude-opus-4-6")
|
||||
// isAntigravityOpusHighTierModel 判断是否为高阶 Opus 模型(4.6+),
|
||||
// 用于 adaptive thinking 时覆写为高预算。
|
||||
func isAntigravityOpusHighTierModel(model string) bool {
|
||||
lower := strings.ToLower(model)
|
||||
return strings.HasPrefix(lower, "claude-opus-4-6") ||
|
||||
strings.HasPrefix(lower, "claude-opus-4-7")
|
||||
}
|
||||
|
||||
func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
|
||||
@ -1046,12 +1050,12 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
|
||||
}
|
||||
|
||||
// - thinking.type=enabled:budget_tokens>0 用显式预算
|
||||
// - thinking.type=adaptive:仅在 Antigravity 的 Opus 4.6 上覆写为 (24576)
|
||||
// - thinking.type=adaptive:在 Antigravity 的高阶 Opus(4.6+)上覆写为 (24576)
|
||||
budget := -1
|
||||
if req.Thinking.BudgetTokens > 0 {
|
||||
budget = req.Thinking.BudgetTokens
|
||||
}
|
||||
if req.Thinking.Type == "adaptive" && isAntigravityOpus46Model(req.Model) {
|
||||
if req.Thinking.Type == "adaptive" && isAntigravityOpusHighTierModel(req.Model) {
|
||||
budget = ClaudeAdaptiveHighThinkingBudgetTokens
|
||||
}
|
||||
|
||||
|
||||
@ -341,6 +341,12 @@ var DefaultModels = []Model{
|
||||
DisplayName: "Claude Opus 4.6",
|
||||
CreatedAt: "2026-02-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
ID: "claude-opus-4-7",
|
||||
Type: "model",
|
||||
DisplayName: "Claude Opus 4.7",
|
||||
CreatedAt: "2026-04-17T00:00:00Z",
|
||||
},
|
||||
{
|
||||
ID: "claude-sonnet-4-6",
|
||||
Type: "model",
|
||||
|
||||
@ -191,6 +191,9 @@ func (s *BillingService) initFallbackPricing() {
|
||||
// Claude 4.6 Opus (与4.5同价)
|
||||
s.fallbackPrices["claude-opus-4.6"] = s.fallbackPrices["claude-opus-4.5"]
|
||||
|
||||
// Claude 4.7 Opus (暂与4.6同价,待官方定价更新)
|
||||
s.fallbackPrices["claude-opus-4.7"] = s.fallbackPrices["claude-opus-4.6"]
|
||||
|
||||
// Gemini 3.1 Pro
|
||||
s.fallbackPrices["gemini-3.1-pro"] = &ModelPricing{
|
||||
InputPricePerToken: 2e-6, // $2 per MTok
|
||||
@ -278,6 +281,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
|
||||
|
||||
// 按模型系列匹配
|
||||
if strings.Contains(modelLower, "opus") {
|
||||
if strings.Contains(modelLower, "4.7") || strings.Contains(modelLower, "4-7") {
|
||||
return s.fallbackPrices["claude-opus-4.7"]
|
||||
}
|
||||
if strings.Contains(modelLower, "4.6") || strings.Contains(modelLower, "4-6") {
|
||||
return s.fallbackPrices["claude-opus-4.6"]
|
||||
}
|
||||
|
||||
@ -5180,19 +5180,8 @@ func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough(
|
||||
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
|
||||
}
|
||||
|
||||
maxBytes := resolveUpstreamResponseReadLimit(s.cfg)
|
||||
body, err := readUpstreamResponseBodyLimited(resp.Body, maxBytes)
|
||||
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, anthropicTooLargeError)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream response too large",
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -5558,19 +5547,8 @@ func (s *GatewayService) handleBedrockNonStreamingResponse(
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
) (*ClaudeUsage, error) {
|
||||
maxBytes := resolveUpstreamResponseReadLimit(s.cfg)
|
||||
body, err := readUpstreamResponseBodyLimited(resp.Body, maxBytes)
|
||||
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, anthropicTooLargeError)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream response too large",
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -7277,19 +7255,8 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
|
||||
// 更新5h窗口状态
|
||||
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
|
||||
|
||||
maxBytes := resolveUpstreamResponseReadLimit(s.cfg)
|
||||
body, err := readUpstreamResponseBodyLimited(resp.Body, maxBytes)
|
||||
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, anthropicTooLargeError)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream response too large",
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -8402,16 +8369,15 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
}
|
||||
|
||||
// 读取响应体
|
||||
maxReadBytes := resolveUpstreamResponseReadLimit(s.cfg)
|
||||
respBody, err := readUpstreamResponseBodyLimited(resp.Body, maxReadBytes)
|
||||
countTokensTooLarge := func(c *gin.Context) {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response too large")
|
||||
}
|
||||
respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response too large")
|
||||
return err
|
||||
if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
|
||||
}
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
|
||||
return err
|
||||
}
|
||||
|
||||
@ -8425,15 +8391,12 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if retryErr == nil {
|
||||
resp = retryResp
|
||||
respBody, err = readUpstreamResponseBodyLimited(resp.Body, maxReadBytes)
|
||||
respBody, err = ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response too large")
|
||||
return err
|
||||
if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
|
||||
}
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -8528,16 +8491,15 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex
|
||||
return fmt.Errorf("upstream request failed: %w", err)
|
||||
}
|
||||
|
||||
maxReadBytes := resolveUpstreamResponseReadLimit(s.cfg)
|
||||
respBody, err := readUpstreamResponseBodyLimited(resp.Body, maxReadBytes)
|
||||
countTokensTooLarge := func(c *gin.Context) {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response too large")
|
||||
}
|
||||
respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response too large")
|
||||
return err
|
||||
if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
|
||||
}
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@ -2424,18 +2424,8 @@ func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Co
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[GeminiAPI] ========================================")
|
||||
}
|
||||
|
||||
maxBytes := resolveUpstreamResponseReadLimit(s.cfg)
|
||||
respBody, err := readUpstreamResponseBodyLimited(resp.Body, maxBytes)
|
||||
respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream response too large",
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@ -310,6 +310,24 @@ type ModelConfig struct {
|
||||
// GetAvailableModels 获取可用模型列表
|
||||
func (svc *LanguageServerService) GetAvailableModels(ctx context.Context) ([]ModelConfig, error) {
|
||||
models := []ModelConfig{
|
||||
{
|
||||
Name: "claude-opus-4-7",
|
||||
DisplayName: "Claude Opus 4.7",
|
||||
MaxTokens: 200000,
|
||||
SupportsThinking: true,
|
||||
ThinkingBudget: 32000,
|
||||
SupportsImages: true,
|
||||
Provider: "anthropic",
|
||||
},
|
||||
{
|
||||
Name: "claude-sonnet-4-7",
|
||||
DisplayName: "Claude Sonnet 4.7",
|
||||
MaxTokens: 200000,
|
||||
SupportsThinking: true,
|
||||
ThinkingBudget: 16000,
|
||||
SupportsImages: true,
|
||||
Provider: "anthropic",
|
||||
},
|
||||
{
|
||||
Name: "claude-opus-4-6",
|
||||
DisplayName: "Claude Opus 4.6",
|
||||
|
||||
@ -121,6 +121,28 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
}
|
||||
}
|
||||
|
||||
// For API key accounts (including OpenAI-compatible upstream gateways),
|
||||
// ensure promptCacheKey is also propagated via the request body so that
|
||||
// upstreams using the Responses API can derive a stable session identifier
|
||||
// from prompt_cache_key. This makes our Anthropic /v1/messages compatibility
|
||||
// path behave more like a native Responses client.
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
if trimmedKey := strings.TrimSpace(promptCacheKey); trimmedKey != "" {
|
||||
var reqBody map[string]any
|
||||
if err := json.Unmarshal(responsesBody, &reqBody); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal for prompt cache key injection: %w", err)
|
||||
}
|
||||
if existing, ok := reqBody["prompt_cache_key"].(string); !ok || strings.TrimSpace(existing) == "" {
|
||||
reqBody["prompt_cache_key"] = trimmedKey
|
||||
updated, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remarshal after prompt cache key injection: %w", err)
|
||||
}
|
||||
responsesBody = updated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Get access token
|
||||
token, _, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
|
||||
@ -3010,18 +3010,8 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
) (*OpenAIUsage, error) {
|
||||
maxBytes := resolveUpstreamResponseReadLimit(s.cfg)
|
||||
body, err := readUpstreamResponseBodyLimited(resp.Body, maxBytes)
|
||||
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream response too large",
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -3919,18 +3909,8 @@ func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) {
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*OpenAIUsage, error) {
|
||||
maxBytes := resolveUpstreamResponseReadLimit(s.cfg)
|
||||
body, err := readUpstreamResponseBodyLimited(resp.Body, maxBytes)
|
||||
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream response too large",
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@ -656,65 +656,95 @@ func (s *PricingService) extractBaseName(model string) string {
|
||||
|
||||
// matchByModelFamily 基于模型系列匹配
|
||||
func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing {
|
||||
// Claude模型系列匹配规则
|
||||
familyPatterns := map[string][]string{
|
||||
"opus-4.6": {"claude-opus-4.6", "claude-opus-4-6"},
|
||||
"opus-4.5": {"claude-opus-4.5", "claude-opus-4-5"},
|
||||
"opus-4": {"claude-opus-4", "claude-3-opus"},
|
||||
"sonnet-4.5": {"claude-sonnet-4.5", "claude-sonnet-4-5"},
|
||||
"sonnet-4": {"claude-sonnet-4", "claude-3-5-sonnet"},
|
||||
"sonnet-3.5": {"claude-3-5-sonnet", "claude-3.5-sonnet"},
|
||||
"sonnet-3": {"claude-3-sonnet"},
|
||||
"haiku-3.5": {"claude-3-5-haiku", "claude-3.5-haiku"},
|
||||
"haiku-3": {"claude-3-haiku"},
|
||||
// modelFamily 定义一个模型系列的匹配和定价查找规则。
|
||||
type modelFamily struct {
|
||||
name string // 系列名称
|
||||
match []string // 用于将模型归类到此系列的模式(strings.Contains 匹配)
|
||||
pricing []string // 用于在定价数据中查找价格的模式(nil 则复用 match;可包含低版本 fallback)
|
||||
}
|
||||
|
||||
// 确定模型属于哪个系列
|
||||
var matchedFamily string
|
||||
for family, patterns := range familyPatterns {
|
||||
for _, pattern := range patterns {
|
||||
// 按特异性降序排列:高版本号在前,避免 "claude-opus-4"(opus-4 系列)
|
||||
// 因子串关系误匹配 "claude-opus-4-7"(opus-4.7 系列)。
|
||||
// 注意:原 map 实现存在 Go map 迭代随机性导致的同类 bug,此处改为有序切片修复。
|
||||
families := []modelFamily{
|
||||
{name: "opus-4.7", match: []string{"claude-opus-4-7", "claude-opus-4.7"}, pricing: []string{"claude-opus-4-7", "claude-opus-4.7", "claude-opus-4-6"}},
|
||||
{name: "opus-4.6", match: []string{"claude-opus-4-6", "claude-opus-4.6"}},
|
||||
{name: "opus-4.5", match: []string{"claude-opus-4-5", "claude-opus-4.5"}},
|
||||
{name: "opus-4", match: []string{"claude-opus-4", "claude-3-opus"}},
|
||||
{name: "sonnet-4.5", match: []string{"claude-sonnet-4-5", "claude-sonnet-4.5"}},
|
||||
{name: "sonnet-4", match: []string{"claude-sonnet-4", "claude-3-5-sonnet"}},
|
||||
{name: "sonnet-3.5", match: []string{"claude-3-5-sonnet", "claude-3.5-sonnet"}},
|
||||
{name: "sonnet-3", match: []string{"claude-3-sonnet"}},
|
||||
{name: "haiku-3.5", match: []string{"claude-3-5-haiku", "claude-3.5-haiku"}},
|
||||
{name: "haiku-3", match: []string{"claude-3-haiku"}},
|
||||
}
|
||||
|
||||
// Phase 1: 按有序切片归类(最具体的系列优先匹配)
|
||||
var matched *modelFamily
|
||||
for i := range families {
|
||||
for _, pattern := range families[i].match {
|
||||
if strings.Contains(model, pattern) || strings.Contains(model, strings.ReplaceAll(pattern, "-", "")) {
|
||||
matchedFamily = family
|
||||
matched = &families[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if matchedFamily != "" {
|
||||
if matched != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchedFamily == "" {
|
||||
// 简单的系列匹配
|
||||
if strings.Contains(model, "opus") {
|
||||
if strings.Contains(model, "4.5") || strings.Contains(model, "4-5") {
|
||||
matchedFamily = "opus-4.5"
|
||||
} else {
|
||||
matchedFamily = "opus-4"
|
||||
// Phase 2: 二次兜底——当模型 ID 不含已知模式串时,按关键字粗分
|
||||
if matched == nil {
|
||||
var fallbackName string
|
||||
switch {
|
||||
case strings.Contains(model, "opus"):
|
||||
switch {
|
||||
case strings.Contains(model, "4.7") || strings.Contains(model, "4-7"):
|
||||
fallbackName = "opus-4.7"
|
||||
case strings.Contains(model, "4.6") || strings.Contains(model, "4-6"):
|
||||
fallbackName = "opus-4.6"
|
||||
case strings.Contains(model, "4.5") || strings.Contains(model, "4-5"):
|
||||
fallbackName = "opus-4.5"
|
||||
default:
|
||||
fallbackName = "opus-4"
|
||||
}
|
||||
} else if strings.Contains(model, "sonnet") {
|
||||
if strings.Contains(model, "4.5") || strings.Contains(model, "4-5") {
|
||||
matchedFamily = "sonnet-4.5"
|
||||
} else if strings.Contains(model, "3-5") || strings.Contains(model, "3.5") {
|
||||
matchedFamily = "sonnet-3.5"
|
||||
} else {
|
||||
matchedFamily = "sonnet-4"
|
||||
case strings.Contains(model, "sonnet"):
|
||||
switch {
|
||||
case strings.Contains(model, "4.5") || strings.Contains(model, "4-5"):
|
||||
fallbackName = "sonnet-4.5"
|
||||
case strings.Contains(model, "3-5") || strings.Contains(model, "3.5"):
|
||||
fallbackName = "sonnet-3.5"
|
||||
default:
|
||||
fallbackName = "sonnet-4"
|
||||
}
|
||||
} else if strings.Contains(model, "haiku") {
|
||||
if strings.Contains(model, "3-5") || strings.Contains(model, "3.5") {
|
||||
matchedFamily = "haiku-3.5"
|
||||
} else {
|
||||
matchedFamily = "haiku-3"
|
||||
case strings.Contains(model, "haiku"):
|
||||
switch {
|
||||
case strings.Contains(model, "3-5") || strings.Contains(model, "3.5"):
|
||||
fallbackName = "haiku-3.5"
|
||||
default:
|
||||
fallbackName = "haiku-3"
|
||||
}
|
||||
}
|
||||
if fallbackName != "" {
|
||||
for i := range families {
|
||||
if families[i].name == fallbackName {
|
||||
matched = &families[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matchedFamily == "" {
|
||||
if matched == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 在价格数据中查找该系列的模型
|
||||
patterns := familyPatterns[matchedFamily]
|
||||
for _, pattern := range patterns {
|
||||
// Phase 3: 在定价数据中查找该系列的价格
|
||||
lookups := matched.pricing
|
||||
if lookups == nil {
|
||||
lookups = matched.match
|
||||
}
|
||||
for _, pattern := range lookups {
|
||||
for key, pricing := range s.pricingData {
|
||||
keyLower := strings.ToLower(key)
|
||||
if strings.Contains(keyLower, pattern) {
|
||||
|
||||
@ -152,6 +152,11 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
|
||||
msg := "Credit balance exhausted (400): " + upstreamMsg
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
shouldDisable = true
|
||||
} else if strings.Contains(strings.ToLower(upstreamMsg), "identity verification is required") {
|
||||
// KYC 身份验证要求 → 永久禁用,账号需完成身份验证后才能恢复
|
||||
msg := "Identity verification required (400): " + upstreamMsg
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
shouldDisable = true
|
||||
}
|
||||
// 其他 400 错误(如参数问题)不处理,不禁用账号
|
||||
case 401:
|
||||
|
||||
@ -20,6 +20,14 @@ var (
|
||||
|
||||
const outboxEventTimeout = 2 * time.Minute
|
||||
|
||||
// batchSeenKey tracks which (groupID, platform) bucket sets have already been
|
||||
// rebuilt within a single pollOutbox call, to avoid redundant work when multiple
|
||||
// account_changed events share the same groups.
|
||||
type batchSeenKey struct {
|
||||
groupID int64
|
||||
platform string
|
||||
}
|
||||
|
||||
type SchedulerSnapshotService struct {
|
||||
cache SchedulerCache
|
||||
outboxRepo SchedulerOutboxRepository
|
||||
@ -244,9 +252,10 @@ func (s *SchedulerSnapshotService) pollOutbox() {
|
||||
}
|
||||
|
||||
watermarkForCheck := watermark
|
||||
seen := make(map[batchSeenKey]struct{})
|
||||
for _, event := range events {
|
||||
eventCtx, cancel := context.WithTimeout(context.Background(), outboxEventTimeout)
|
||||
err := s.handleOutboxEvent(eventCtx, event)
|
||||
err := s.handleOutboxEvent(eventCtx, event, seen)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox handle failed: id=%d type=%s err=%v", event.ID, event.EventType, err)
|
||||
@ -255,8 +264,20 @@ func (s *SchedulerSnapshotService) pollOutbox() {
|
||||
}
|
||||
|
||||
lastID := events[len(events)-1].ID
|
||||
if err := s.cache.SetOutboxWatermark(ctx, lastID); err != nil {
|
||||
logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox watermark write failed: %v", err)
|
||||
var wmErr error
|
||||
for i := range 3 {
|
||||
wmCtx, wmCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
wmErr = s.cache.SetOutboxWatermark(wmCtx, lastID)
|
||||
wmCancel()
|
||||
if wmErr == nil {
|
||||
break
|
||||
}
|
||||
if i < 2 {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if wmErr != nil {
|
||||
logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] outbox watermark write failed: %v", wmErr)
|
||||
} else {
|
||||
watermarkForCheck = lastID
|
||||
}
|
||||
@ -264,18 +285,18 @@ func (s *SchedulerSnapshotService) pollOutbox() {
|
||||
s.checkOutboxLag(ctx, events[0], watermarkForCheck)
|
||||
}
|
||||
|
||||
func (s *SchedulerSnapshotService) handleOutboxEvent(ctx context.Context, event SchedulerOutboxEvent) error {
|
||||
func (s *SchedulerSnapshotService) handleOutboxEvent(ctx context.Context, event SchedulerOutboxEvent, seen map[batchSeenKey]struct{}) error {
|
||||
switch event.EventType {
|
||||
case SchedulerOutboxEventAccountLastUsed:
|
||||
return s.handleLastUsedEvent(ctx, event.Payload)
|
||||
case SchedulerOutboxEventAccountBulkChanged:
|
||||
return s.handleBulkAccountEvent(ctx, event.Payload)
|
||||
return s.handleBulkAccountEvent(ctx, event.Payload, seen)
|
||||
case SchedulerOutboxEventAccountGroupsChanged:
|
||||
return s.handleAccountEvent(ctx, event.AccountID, event.Payload)
|
||||
return s.handleAccountEvent(ctx, event.AccountID, event.Payload, seen)
|
||||
case SchedulerOutboxEventAccountChanged:
|
||||
return s.handleAccountEvent(ctx, event.AccountID, event.Payload)
|
||||
return s.handleAccountEvent(ctx, event.AccountID, event.Payload, seen)
|
||||
case SchedulerOutboxEventGroupChanged:
|
||||
return s.handleGroupEvent(ctx, event.GroupID)
|
||||
return s.handleGroupEvent(ctx, event.GroupID, seen)
|
||||
case SchedulerOutboxEventFullRebuild:
|
||||
return s.triggerFullRebuild("outbox")
|
||||
default:
|
||||
@ -309,7 +330,7 @@ func (s *SchedulerSnapshotService) handleLastUsedEvent(ctx context.Context, payl
|
||||
return s.cache.UpdateLastUsed(ctx, updates)
|
||||
}
|
||||
|
||||
func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, payload map[string]any) error {
|
||||
func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, payload map[string]any, seen map[batchSeenKey]struct{}) error {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
@ -323,15 +344,15 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(rawIDs))
|
||||
seen := make(map[int64]struct{}, len(rawIDs))
|
||||
seenIDs := make(map[int64]struct{}, len(rawIDs))
|
||||
for _, id := range rawIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
if _, exists := seenIDs[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
seenIDs[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
@ -384,10 +405,10 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p
|
||||
for gid := range rebuildGroupSet {
|
||||
rebuildGroupIDs = append(rebuildGroupIDs, gid)
|
||||
}
|
||||
return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change")
|
||||
return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen)
|
||||
}
|
||||
|
||||
func (s *SchedulerSnapshotService) handleAccountEvent(ctx context.Context, accountID *int64, payload map[string]any) error {
|
||||
func (s *SchedulerSnapshotService) handleAccountEvent(ctx context.Context, accountID *int64, payload map[string]any, seen map[batchSeenKey]struct{}) error {
|
||||
if accountID == nil || *accountID <= 0 {
|
||||
return nil
|
||||
}
|
||||
@ -408,7 +429,7 @@ func (s *SchedulerSnapshotService) handleAccountEvent(ctx context.Context, accou
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.rebuildByGroupIDs(ctx, groupIDs, "account_miss")
|
||||
return s.rebuildByGroupIDs(ctx, groupIDs, "account_miss", seen)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@ -420,18 +441,18 @@ func (s *SchedulerSnapshotService) handleAccountEvent(ctx context.Context, accou
|
||||
if len(groupIDs) == 0 {
|
||||
groupIDs = account.GroupIDs
|
||||
}
|
||||
return s.rebuildByAccount(ctx, account, groupIDs, "account_change")
|
||||
return s.rebuildByAccount(ctx, account, groupIDs, "account_change", seen)
|
||||
}
|
||||
|
||||
func (s *SchedulerSnapshotService) handleGroupEvent(ctx context.Context, groupID *int64) error {
|
||||
func (s *SchedulerSnapshotService) handleGroupEvent(ctx context.Context, groupID *int64, seen map[batchSeenKey]struct{}) error {
|
||||
if groupID == nil || *groupID <= 0 {
|
||||
return nil
|
||||
}
|
||||
groupIDs := []int64{*groupID}
|
||||
return s.rebuildByGroupIDs(ctx, groupIDs, "group_change")
|
||||
return s.rebuildByGroupIDs(ctx, groupIDs, "group_change", seen)
|
||||
}
|
||||
|
||||
func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account *Account, groupIDs []int64, reason string) error {
|
||||
func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account *Account, groupIDs []int64, reason string, seen map[batchSeenKey]struct{}) error {
|
||||
if account == nil {
|
||||
return nil
|
||||
}
|
||||
@ -441,21 +462,21 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
if err := s.rebuildBucketsForPlatform(ctx, account.Platform, groupIDs, reason); err != nil && firstErr == nil {
|
||||
if err := s.rebuildBucketsForPlatform(ctx, account.Platform, groupIDs, reason, seen); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled() {
|
||||
if err := s.rebuildBucketsForPlatform(ctx, PlatformAnthropic, groupIDs, reason); err != nil && firstErr == nil {
|
||||
if err := s.rebuildBucketsForPlatform(ctx, PlatformAnthropic, groupIDs, reason, seen); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if err := s.rebuildBucketsForPlatform(ctx, PlatformGemini, groupIDs, reason); err != nil && firstErr == nil {
|
||||
if err := s.rebuildBucketsForPlatform(ctx, PlatformGemini, groupIDs, reason, seen); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *SchedulerSnapshotService) rebuildByGroupIDs(ctx context.Context, groupIDs []int64, reason string) error {
|
||||
func (s *SchedulerSnapshotService) rebuildByGroupIDs(ctx context.Context, groupIDs []int64, reason string, seen map[batchSeenKey]struct{}) error {
|
||||
groupIDs = s.normalizeGroupIDs(groupIDs)
|
||||
if len(groupIDs) == 0 {
|
||||
return nil
|
||||
@ -463,19 +484,30 @@ func (s *SchedulerSnapshotService) rebuildByGroupIDs(ctx context.Context, groupI
|
||||
platforms := []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity}
|
||||
var firstErr error
|
||||
for _, platform := range platforms {
|
||||
if err := s.rebuildBucketsForPlatform(ctx, platform, groupIDs, reason); err != nil && firstErr == nil {
|
||||
if err := s.rebuildBucketsForPlatform(ctx, platform, groupIDs, reason, seen); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *SchedulerSnapshotService) rebuildBucketsForPlatform(ctx context.Context, platform string, groupIDs []int64, reason string) error {
|
||||
func (s *SchedulerSnapshotService) rebuildBucketsForPlatform(ctx context.Context, platform string, groupIDs []int64, reason string, seen map[batchSeenKey]struct{}) error {
|
||||
if platform == "" {
|
||||
return nil
|
||||
}
|
||||
var firstErr error
|
||||
for _, gid := range groupIDs {
|
||||
// Within a single poll batch, skip (groupID, platform) pairs that were
|
||||
// already rebuilt. The first rebuild loads fresh DB data for all accounts
|
||||
// in the group, so subsequent rebuilds for the same group+platform within
|
||||
// the same batch are redundant.
|
||||
if seen != nil {
|
||||
key := batchSeenKey{gid, platform}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
if err := s.rebuildBucket(ctx, SchedulerBucket{GroupID: gid, Platform: platform, Mode: SchedulerModeSingle}, reason); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
|
||||
@ -4,8 +4,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var ErrUpstreamResponseBodyTooLarge = errors.New("upstream response body too large")
|
||||
@ -36,3 +38,44 @@ func readUpstreamResponseBodyLimited(reader io.Reader, maxBytes int64) ([]byte,
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// TooLargeWriter 在响应超限时向客户端写格式化的错误响应。
|
||||
type TooLargeWriter func(c *gin.Context)
|
||||
|
||||
// ReadUpstreamResponseBody 读取上游非流式响应体。
|
||||
// 超限时自动记录 ops error 并调用 onTooLarge 向客户端写错误。
|
||||
func ReadUpstreamResponseBody(reader io.Reader, cfg *config.Config, c *gin.Context, onTooLarge TooLargeWriter) ([]byte, error) {
|
||||
maxBytes := resolveUpstreamResponseReadLimit(cfg)
|
||||
body, err := readUpstreamResponseBodyLimited(reader, maxBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
|
||||
if onTooLarge != nil {
|
||||
onTooLarge(c)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// anthropicTooLargeError 以 Anthropic Messages API 格式写入超限错误。
|
||||
func anthropicTooLargeError(c *gin.Context) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream response too large",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// openAITooLargeError 以 OpenAI / Gemini 格式写入超限错误。
|
||||
func openAITooLargeError(c *gin.Context) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream response too large",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -4,8 +4,10 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@ -35,3 +37,44 @@ func TestReadUpstreamResponseBodyLimited(t *testing.T) {
|
||||
require.True(t, errors.Is(err, ErrUpstreamResponseBodyTooLarge))
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadUpstreamResponseBody(t *testing.T) {
|
||||
t.Run("within limit", func(t *testing.T) {
|
||||
body, err := ReadUpstreamResponseBody(bytes.NewReader([]byte("ok")), nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("ok"), body)
|
||||
})
|
||||
|
||||
t.Run("exceeds limit calls onTooLarge", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.UpstreamResponseReadMaxBytes = 3
|
||||
|
||||
called := false
|
||||
onTooLarge := func(_ *gin.Context) { called = true }
|
||||
|
||||
body, err := ReadUpstreamResponseBody(bytes.NewReader([]byte("toolong")), cfg, nil, onTooLarge)
|
||||
require.Nil(t, body)
|
||||
require.True(t, errors.Is(err, ErrUpstreamResponseBodyTooLarge))
|
||||
require.True(t, called)
|
||||
})
|
||||
|
||||
t.Run("nil onTooLarge does not panic", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.UpstreamResponseReadMaxBytes = 3
|
||||
|
||||
body, err := ReadUpstreamResponseBody(bytes.NewReader([]byte("toolong")), cfg, nil, nil)
|
||||
require.Nil(t, body)
|
||||
require.True(t, errors.Is(err, ErrUpstreamResponseBodyTooLarge))
|
||||
})
|
||||
|
||||
t.Run("io error does not call onTooLarge", func(t *testing.T) {
|
||||
called := false
|
||||
onTooLarge := func(_ *gin.Context) { called = true }
|
||||
|
||||
body, err := ReadUpstreamResponseBody(iotest.ErrReader(errors.New("disk failure")), nil, nil, onTooLarge)
|
||||
require.Nil(t, body)
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, ErrUpstreamResponseBodyTooLarge))
|
||||
require.False(t, called)
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,57 +1,82 @@
|
||||
# =============================================================================
|
||||
# Sub2API Docker Compose Configuration
|
||||
# Sub2API Docker Compose Configuration (负载均衡版)
|
||||
# =============================================================================
|
||||
# Quick Start:
|
||||
# 1. Copy .env.example to .env and configure
|
||||
# 2. docker-compose up -d
|
||||
# 3. Check logs: docker-compose logs -f sub2api
|
||||
# 4. Access: http://localhost:8080
|
||||
# 2. docker compose up -d
|
||||
# 3. Check logs: docker compose logs -f
|
||||
# 4. Access: http://localhost (via nginx)
|
||||
#
|
||||
# All configuration is done via environment variables.
|
||||
# No Setup Wizard needed - the system auto-initializes on first run.
|
||||
# 扩缩容:
|
||||
# docker compose up -d --scale sub2api=5 # 扩到 5 个实例
|
||||
# docker compose up -d --scale sub2api=2 # 缩回 2 个实例
|
||||
#
|
||||
# 注意事项:
|
||||
# - JWT_SECRET / TOTP_ENCRYPTION_KEY 必须固定,多实例共享同一个值
|
||||
# - PostgreSQL / Redis 单实例,不参与水平扩展
|
||||
# - postgres 端口默认不对外暴露,如需调试取消注释 127.0.0.1:5433:5432
|
||||
# - redis 端口默认不对外暴露,如需调试取消注释 127.0.0.1:6380:6379
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
# ===========================================================================
|
||||
# Sub2API Application
|
||||
# Nginx 负载均衡(入口)
|
||||
# ===========================================================================
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: sub2api-nginx
|
||||
restart: unless-stopped
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
ports:
|
||||
- "0.0.0.0:80:80"
|
||||
- "0.0.0.0:443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/certs:/etc/nginx/certs:ro
|
||||
depends_on:
|
||||
sub2api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- sub2api-network
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://localhost/health" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# ===========================================================================
|
||||
# Sub2API Application(多实例,通过 --scale 控制数量)
|
||||
# ===========================================================================
|
||||
sub2api:
|
||||
image: weishaw/sub2api:latest
|
||||
container_name: sub2api
|
||||
image: docker.io/zfc931912343/sub2api:latest
|
||||
restart: unless-stopped
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 100000
|
||||
hard: 100000
|
||||
ports:
|
||||
- "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8080}:8080"
|
||||
# 不直接暴露端口,由 nginx 代理
|
||||
expose:
|
||||
- "8080"
|
||||
volumes:
|
||||
# Data persistence (config.yaml will be auto-generated here)
|
||||
- sub2api_data:/app/data
|
||||
# Optional: Mount custom config.yaml (uncomment and create the file first)
|
||||
# Copy config.example.yaml to config.yaml, modify it, then uncomment:
|
||||
# Optional: 挂载自定义 config.yaml(先从 config.example.yaml 复制并修改)
|
||||
# - ./config.yaml:/app/data/config.yaml
|
||||
# Optional: Mount a custom Codex instructions template file, then point
|
||||
# gateway.forced_codex_instructions_template_file at /app/data/codex-instructions.md.tmpl
|
||||
# in config.yaml.
|
||||
# Optional: 自定义 Codex instructions 模板
|
||||
# - ./codex-instructions.md.tmpl:/app/data/codex-instructions.md.tmpl:ro
|
||||
environment:
|
||||
# =======================================================================
|
||||
# Auto Setup (REQUIRED for Docker deployment)
|
||||
# =======================================================================
|
||||
- AUTO_SETUP=true
|
||||
|
||||
# =======================================================================
|
||||
# Server Configuration
|
||||
# =======================================================================
|
||||
# --- Server ---
|
||||
- SERVER_HOST=0.0.0.0
|
||||
- SERVER_PORT=8080
|
||||
- SERVER_MODE=${SERVER_MODE:-release}
|
||||
- RUN_MODE=${RUN_MODE:-standard}
|
||||
|
||||
# =======================================================================
|
||||
# Database Configuration (PostgreSQL)
|
||||
# =======================================================================
|
||||
# --- Database (PostgreSQL) ---
|
||||
- DATABASE_HOST=postgres
|
||||
- DATABASE_PORT=5432
|
||||
- DATABASE_USER=${POSTGRES_USER:-sub2api}
|
||||
@ -63,9 +88,7 @@ services:
|
||||
- DATABASE_CONN_MAX_LIFETIME_MINUTES=${DATABASE_CONN_MAX_LIFETIME_MINUTES:-30}
|
||||
- DATABASE_CONN_MAX_IDLE_TIME_MINUTES=${DATABASE_CONN_MAX_IDLE_TIME_MINUTES:-5}
|
||||
|
||||
# =======================================================================
|
||||
# Redis Configuration
|
||||
# =======================================================================
|
||||
# --- Redis ---
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
@ -74,74 +97,39 @@ services:
|
||||
- REDIS_MIN_IDLE_CONNS=${REDIS_MIN_IDLE_CONNS:-10}
|
||||
- REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-false}
|
||||
|
||||
# =======================================================================
|
||||
# Admin Account (auto-created on first run)
|
||||
# =======================================================================
|
||||
# --- Admin(仅首次启动生效)---
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
||||
|
||||
# =======================================================================
|
||||
# JWT Configuration
|
||||
# =======================================================================
|
||||
# IMPORTANT: Set a fixed JWT_SECRET to prevent login sessions from being
|
||||
# invalidated after container restarts. If left empty, a random secret
|
||||
# will be generated on each startup.
|
||||
# Generate a secure secret: openssl rand -hex 32
|
||||
- JWT_SECRET=${JWT_SECRET:-}
|
||||
# --- JWT(多实例必须固定,否则重启后 session 失效)---
|
||||
# 生成: openssl rand -hex 32
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required for multi-instance}
|
||||
- JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24}
|
||||
|
||||
# =======================================================================
|
||||
# TOTP (2FA) Configuration
|
||||
# =======================================================================
|
||||
# IMPORTANT: Set a fixed encryption key for TOTP secrets. If left empty,
|
||||
# a random key will be generated on each startup, causing all existing
|
||||
# TOTP configurations to become invalid (users won't be able to login
|
||||
# with 2FA).
|
||||
# Generate a secure key: openssl rand -hex 32
|
||||
- TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:-}
|
||||
# --- TOTP 2FA(多实例必须固定,否则 2FA 失效)---
|
||||
# 生成: openssl rand -hex 32
|
||||
- TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:?TOTP_ENCRYPTION_KEY is required for multi-instance}
|
||||
|
||||
# =======================================================================
|
||||
# Timezone Configuration
|
||||
# This affects ALL time operations in the application:
|
||||
# - Database timestamps
|
||||
# - Usage statistics "today" boundary
|
||||
# - Subscription expiry times
|
||||
# - Log timestamps
|
||||
# Common values: Asia/Shanghai, America/New_York, Europe/London, UTC
|
||||
# =======================================================================
|
||||
# --- Timezone ---
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
|
||||
# =======================================================================
|
||||
# Gemini OAuth Configuration (for Gemini accounts)
|
||||
# =======================================================================
|
||||
# --- Gemini OAuth ---
|
||||
- GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-}
|
||||
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
|
||||
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
||||
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
|
||||
|
||||
# Built-in OAuth client secrets (optional)
|
||||
# SECURITY: This repo does not embed third-party client_secret.
|
||||
- GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-}
|
||||
- ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-}
|
||||
|
||||
# =======================================================================
|
||||
# Security Configuration (URL Allowlist)
|
||||
# =======================================================================
|
||||
# Enable URL allowlist validation (false to skip allowlist checks)
|
||||
# --- Security ---
|
||||
- SECURITY_URL_ALLOWLIST_ENABLED=${SECURITY_URL_ALLOWLIST_ENABLED:-false}
|
||||
# Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https)
|
||||
- SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=${SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP:-false}
|
||||
# Allow private IP addresses for upstream/pricing/CRS (for internal deployments)
|
||||
- SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false}
|
||||
# Upstream hosts whitelist (comma-separated, only used when enabled=true)
|
||||
- SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-}
|
||||
|
||||
# =======================================================================
|
||||
# Update Configuration (在线更新配置)
|
||||
# =======================================================================
|
||||
# Proxy for accessing GitHub (online updates + pricing data)
|
||||
# Examples: http://host:port, socks5://host:port
|
||||
# --- Update Proxy(国内机器可配置代理访问 GitHub)---
|
||||
- UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-}
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@ -150,17 +138,7 @@ services:
|
||||
networks:
|
||||
- sub2api-network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"-q",
|
||||
"-T",
|
||||
"5",
|
||||
"-O",
|
||||
"/dev/null",
|
||||
"http://localhost:8080/health",
|
||||
]
|
||||
test: [ "CMD", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://localhost:8080/health" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@ -180,9 +158,7 @@ services:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
# postgres:18-alpine 默认 PGDATA=/var/lib/postgresql/18/docker(位于镜像声明的匿名卷 /var/lib/postgresql 内)。
|
||||
# 若不显式设置 PGDATA,则即使挂载了 postgres_data 到 /var/lib/postgresql/data,数据也不会落盘到该命名卷,
|
||||
# docker compose down/up 后会触发 initdb 重新初始化,导致用户/密码等数据丢失。
|
||||
# postgres:18-alpine 默认 PGDATA 在镜像内部匿名卷,必须显式指定才能持久化到命名卷
|
||||
- PGDATA=/var/lib/postgresql/data
|
||||
- POSTGRES_USER=${POSTGRES_USER:-sub2api}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
@ -191,19 +167,14 @@ services:
|
||||
networks:
|
||||
- sub2api-network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}",
|
||||
]
|
||||
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
ports:
|
||||
- 5432:5432
|
||||
# 注意:不暴露端口到宿主机,应用通过内部网络连接
|
||||
# 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"]
|
||||
# 默认不对外暴露,如需本地调试取消注释
|
||||
# ports:
|
||||
# - "127.0.0.1:5433:5432"
|
||||
|
||||
# ===========================================================================
|
||||
# Redis Cache
|
||||
@ -227,18 +198,19 @@ services:
|
||||
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
|
||||
- REDISCLI_AUTH=${REDIS_PASSWORD:-}
|
||||
networks:
|
||||
- sub2api-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
test: [ "CMD", "redis-cli", "ping" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
ports:
|
||||
- 6379:6379
|
||||
# 默认不对外暴露,如需本地调试取消注释
|
||||
# ports:
|
||||
# - "127.0.0.1:6380:6379"
|
||||
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
|
||||
155
deploy/nginx/nginx.conf
Normal file
155
deploy/nginx/nginx.conf
Normal file
@ -0,0 +1,155 @@
|
||||
# =============================================================================
|
||||
# Nginx 负载均衡配置 for Sub2API(Cloudflare 前置 + Origin Certificate)
|
||||
# =============================================================================
|
||||
# 架构:Cloudflare (HTTPS) → VPS:443 (Docker nginx + CF Origin Cert) → sub2api
|
||||
# - SSL 由 Cloudflare Origin Certificate 端到端加密
|
||||
# - 真实客户端 IP 从 CF-Connecting-IP 头还原
|
||||
# =============================================================================
|
||||
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 4096;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$realip_remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent '
|
||||
'rt=$request_time urt=$upstream_response_time cf_ray=$http_cf_ray';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 75s;
|
||||
keepalive_requests 1000;
|
||||
server_tokens off;
|
||||
|
||||
client_max_body_size 64m;
|
||||
client_body_buffer_size 2m; # 避免请求体落盘(默认 8k/16k 对 AI 请求太小)
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 4;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/json application/javascript
|
||||
text/xml application/xml text/javascript;
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 还原真实客户端 IP(Cloudflare IP 段)
|
||||
# -------------------------------------------------------------------------
|
||||
set_real_ip_from 103.21.244.0/22;
|
||||
set_real_ip_from 103.22.200.0/22;
|
||||
set_real_ip_from 103.31.4.0/22;
|
||||
set_real_ip_from 104.16.0.0/13;
|
||||
set_real_ip_from 104.24.0.0/14;
|
||||
set_real_ip_from 108.162.192.0/18;
|
||||
set_real_ip_from 131.0.72.0/22;
|
||||
set_real_ip_from 141.101.64.0/18;
|
||||
set_real_ip_from 162.158.0.0/15;
|
||||
set_real_ip_from 172.64.0.0/13;
|
||||
set_real_ip_from 173.245.48.0/20;
|
||||
set_real_ip_from 188.114.96.0/20;
|
||||
set_real_ip_from 190.93.240.0/20;
|
||||
set_real_ip_from 197.234.240.0/22;
|
||||
set_real_ip_from 198.41.128.0/17;
|
||||
set_real_ip_from 2400:cb00::/32;
|
||||
set_real_ip_from 2606:4700::/32;
|
||||
set_real_ip_from 2803:f800::/32;
|
||||
set_real_ip_from 2405:b500::/32;
|
||||
set_real_ip_from 2405:8100::/32;
|
||||
set_real_ip_from 2a06:98c0::/29;
|
||||
set_real_ip_from 2c0f:f248::/32;
|
||||
real_ip_header CF-Connecting-IP;
|
||||
real_ip_recursive on;
|
||||
|
||||
# WebSocket upgrade 映射
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 上游实例池(least_conn)
|
||||
# -------------------------------------------------------------------------
|
||||
upstream sub2api_backend {
|
||||
# ip_hash 保证同一客户端 IP 始终路由到同一实例
|
||||
# 解决 OAuth session 内存不共享问题(session 在实例内存中)
|
||||
ip_hash;
|
||||
server sub2api:8080;
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# HTTP → HTTPS 跳转
|
||||
# =========================================================================
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location = /health {
|
||||
proxy_pass http://sub2api_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 10s;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# HTTPS(Cloudflare Origin Certificate)
|
||||
# =========================================================================
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
location = /health {
|
||||
proxy_pass http://sub2api_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 10s;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://sub2api_backend;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,7 @@ export const claudeModels = [
|
||||
'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001',
|
||||
'claude-opus-4-5-20251101',
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-7',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-2.1', 'claude-2.0', 'claude-instant-1.2'
|
||||
]
|
||||
@ -66,6 +67,7 @@ const antigravityModels = [
|
||||
// Claude 4.5+ 系列
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-6-thinking',
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-sonnet-4-5',
|
||||
@ -250,6 +252,7 @@ const anthropicPresetMappings = [
|
||||
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'claude-opus-4-7', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
{ label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'Opus->Sonnet', from: 'claude-opus-4-6', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
|
||||
@ -309,12 +312,14 @@ const antigravityPresetMappings = [
|
||||
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5', to: 'claude-sonnet-4-5', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6-thinking', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Opus 4.6-thinking', from: 'claude-opus-4-6-thinking', to: 'claude-opus-4-6-thinking', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }
|
||||
{ label: 'Opus 4.6-thinking', from: 'claude-opus-4-6-thinking', to: 'claude-opus-4-6-thinking', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'claude-opus-4-7', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }
|
||||
]
|
||||
|
||||
// Bedrock 预设映射(与后端 DefaultBedrockModelMapping 保持一致)
|
||||
const bedrockPresetMappings = [
|
||||
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'us.anthropic.claude-opus-4-6-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'us.anthropic.claude-opus-4-7-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'us.anthropic.claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: 'Opus 4.5', from: 'claude-opus-4-5-thinking', to: 'us.anthropic.claude-opus-4-5-20251101-v1:0', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5', to: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user