merge main 并修复与 #5730 的语义冲突

main 侧 #5730 新增的 openai_gateway_cn_fixes_test.go 按旧 11 参签名调用
calculateOpenAIRecordUsageCost;本分支为该函数新增了第 12 个参数
pricingAt。文本无冲突但 test build 会失败,此处按本分支对同类测试
调用点的既有处理方式补传 time.Time{}。
This commit is contained in:
shaw
2026-08-17 22:27:22 +08:00
20 changed files with 551 additions and 232 deletions
@@ -98,7 +98,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -166,7 +166,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -0,0 +1,83 @@
//go:build unit
package admin
import (
"bytes"
"fmt"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验
// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致
// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。
func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
return c.ShouldBindJSON(target)
}
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
allowed := []string{
"anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "composite",
}
for _, platform := range allowed {
t.Run("create_"+platform, func(t *testing.T) {
var req CreateGroupRequest
body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应通过 CreateGroupRequest 校验", platform)
require.Equal(t, platform, req.Platform)
})
t.Run("update_"+platform, func(t *testing.T) {
var req UpdateGroupRequest
body := fmt.Sprintf(`{"platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应通过 UpdateGroupRequest 校验", platform)
require.Equal(t, platform, req.Platform)
})
}
}
func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
invalid := []string{
"moonshot", // 厂商别名,不是平台标识
"Kimi", // 大小写敏感
"openai ", // 尾随空格
"glm",
"bogus",
}
for _, platform := range invalid {
t.Run("create_"+platform, func(t *testing.T) {
var req CreateGroupRequest
body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform)
require.Error(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应被 CreateGroupRequest 拒绝", platform)
})
t.Run("update_"+platform, func(t *testing.T) {
var req UpdateGroupRequest
body := fmt.Sprintf(`{"platform":%q}`, platform)
require.Error(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应被 UpdateGroupRequest 拒绝", platform)
})
}
}
// 守住 composite 路由目标不放行 CN:CN 平台不可作为 composite 路由目标
// DetectModelPlatform/isConcreteRequestPlatform 均无 CN 分支,放行即打开半实现路径)。
func TestCompositeRouteTargetPlatform_StillExcludesCNProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.Error(t, bindGroupPlatformJSON(t, &req, body),
"composite target_platform %q 应保持被拒", platform)
}
}
@@ -255,6 +255,48 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
if err == nil && result != nil && result.FirstTokenMs != nil {
service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
}
// #5148 对齐:错误返回携带的部分 result(流中断前上游已计量的 usage)照常
// 入账;failover 错误恒定 result=nil,不会重复计费。
submitChatUsage := func(res *service.OpenAIForwardResult) {
if res == nil {
return
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: res,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, res.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.chat_completions"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai_chat_completions.record_usage_failed", zap.Error(err))
}
})
}
if err != nil {
if result != nil && result.ImageCount > 0 {
reqLog.Warn("openai_chat_completions.forward_partial_error_with_image_result",
@@ -339,6 +381,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
zap.Bool("upstream_error_response_already_written", upstreamErrorAlreadyCommunicated),
zap.Error(err),
)
submitChatUsage(result)
return
}
}
@@ -348,42 +391,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil)
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.chat_completions"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai_chat_completions.record_usage_failed", zap.Error(err))
}
})
submitChatUsage(result)
reqLog.Debug("openai_chat_completions.request_completed",
zap.Int64("account_id", account.ID),
zap.Int("switch_count", switchCount),
@@ -0,0 +1,29 @@
package handler
// CN 分组 /v1/messages 调度闸门回归(修复:正常途径创建的 CN 分组曾恒 403):
// sanitizeGroupMessagesDispatchFields 对非 openai 平台强制 AllowMessagesDispatch
// =false,故 CN 分组必须与 grok 一样在闸门处豁免,否则原生 Anthropic 直通
//Claude Code 主用例)永远不可达。
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
require.True(t, allowOpenAICompatibleMessagesDispatch(nil), "无 key 保持放行")
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} {
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
require.True(t, allowOpenAICompatibleMessagesDispatch(apiKey),
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
}
// 非回归:openai 分组仍受开关控制。
openaiOff := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: false}}
require.False(t, allowOpenAICompatibleMessagesDispatch(openaiOff))
openaiOn := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: true}}
require.True(t, allowOpenAICompatibleMessagesDispatch(openaiOn))
}
@@ -78,7 +78,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
zap.Any("group_id", apiKey.GroupID),
)
if apiKey.Group != nil && !apiKey.Group.AllowMessagesDispatch {
if !allowOpenAICompatibleMessagesDispatch(apiKey) {
h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error",
"This group does not allow /v1/messages dispatch")
return
@@ -197,6 +197,13 @@ func allowOpenAICompatibleMessagesDispatch(apiKey *service.APIKey) bool {
if apiKey.Group.Platform == service.PlatformGrok {
return true
}
// 国产供应商分组与 grok 同语义:/v1/messages 就是其主要服务形态(anthropic
// 协议账号原生直通 Claude Code),无需 allow_messages_dispatch 开关授权——
// 该开关对非 openai 平台恒被 sanitizeGroupMessagesDispatchFields 置 false,
// 若不豁免,CN 分组将永远 403。
if service.IsCNProvider(apiKey.Group.Platform) {
return true
}
return apiKey.Group.AllowMessagesDispatch
}
@@ -597,6 +604,50 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
if err == nil && result != nil && result.FirstTokenMs != nil {
service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
}
// #5148 对齐:错误返回携带的部分 result(流中断前上游已计量的 usage)照常
// 入账;failover 错误恒定 result=nil,不会重复计费。
submitResponsesUsage := func(res *service.OpenAIForwardResult) {
if res == nil {
return
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: res,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, res.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.responses"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai.record_usage_failed", zap.Error(err))
}
})
}
if err != nil {
if result != nil && result.ImageCount > 0 {
reqLog.Warn("openai.forward_partial_error_with_image_result",
@@ -696,6 +747,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
zap.Bool("upstream_error_response_already_written", upstreamErrorAlreadyCommunicated),
zap.Error(err),
}
submitResponsesUsage(result)
if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) {
reqLog.Warn("openai.forward_failed", fields...)
return
@@ -714,46 +766,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), nil)
}
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.responses"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai.record_usage_failed", zap.Error(err))
}
})
submitResponsesUsage(result)
reqLog.Debug("openai.request_completed",
zap.Int64("account_id", account.ID),
zap.Int("switch_count", switchCount),
@@ -1145,6 +1159,51 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
if err == nil && result != nil && result.FirstTokenMs != nil {
service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
}
// Forward 与错误一起返回的部分结果:流中断/客户端断开排水前上游已计量的
// usage 照常入账,避免上游已产生消耗的请求完全漏记(#5148,对齐 anthropic
// 网关同名修复)。failover 错误恒定 result=nil,不会重复计费。
submitMessagesUsage := func(res *service.OpenAIForwardResult) {
if res == nil {
return
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: res,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMappingMsg, reqModel, res.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.messages"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai_messages.record_usage_failed", zap.Error(err))
}
})
}
if err != nil {
if result != nil && result.ImageCount > 0 {
reqLog.Warn("openai_messages.forward_partial_error_with_image_result",
@@ -1219,6 +1278,9 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
zap.Int64("account_id", account.ID),
zap.Error(err),
)
// 断开排水期间上游已计量的 usage 必须入账(此前直接 return 丢弃,
// payg 上游照常计费而平台漏记)。
submitMessagesUsage(result)
return
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), false, nil)
@@ -1228,6 +1290,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
zap.Bool("fallback_error_response_written", wroteFallback),
zap.Error(err),
)
submitMessagesUsage(result)
return
}
}
@@ -1237,44 +1300,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, nil)
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMappingMsg, reqModel, result.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.messages"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai_messages.record_usage_failed", zap.Error(err))
}
})
submitMessagesUsage(result)
reqLog.Debug("openai_messages.request_completed",
zap.Int64("account_id", account.ID),
zap.Int("switch_count", switchCount),
@@ -0,0 +1,144 @@
//go:build unit
package service
// 国产供应商功能修复回归测试:
// 1. CN 分组不适用 /v1/messages 调度级模型映射(openai 的 gpt-5.x 默认值发给
// CN 上游必错);
// 2. 计费候选链对 CN 账号过滤 claude-* 兜底候选(防按 Claude 原价误计 CN 流量);
// 3. 空候选按 ErrModelPricingUnavailable 处理(零成本落账而非丢弃 usage 记录);
// 4. Responses×anthropic 流式转换器客户端断开后继续排水、usage 汇总完整。
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestResolveMessagesDispatchModel_CNProvidersNoDispatchMapping(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
g := &Group{Platform: platform}
require.Empty(t, g.ResolveMessagesDispatchModel("claude-sonnet-4-5"),
"CN 分组(%s)不得返回调度级映射模型(openai 默认值会发给 CN 上游)", platform)
require.Empty(t, g.ResolveMessagesDispatchModel("claude-opus-4-1"), platform)
}
// 非回归:openai 分组保持原有默认映射行为。
openaiGroup := &Group{Platform: PlatformOpenAI}
require.NotEmpty(t, openaiGroup.ResolveMessagesDispatchModel("claude-sonnet-4-5"),
"openai 分组的调度默认映射不应受 CN 修复影响")
}
func TestFilterCNProviderBillingModelCandidates(t *testing.T) {
svc := &OpenAIGatewayService{} // resolver 为 nil → 无显式分组/渠道定价
apiKey := &APIKey{Group: &Group{ID: 1, Platform: PlatformKimi}}
cnAccount := &Account{ID: 1, Platform: PlatformKimi}
filtered := svc.filterCNProviderBillingModelCandidates(context.Background(), cnAccount, apiKey,
[]string{"kimi-k2-0905-preview", "claude-sonnet-4-5", "moonshot-v1-8k"})
require.Equal(t, []string{"kimi-k2-0905-preview", "moonshot-v1-8k"}, filtered,
"无显式定价时 claude-* 候选必须被过滤")
allClaude := svc.filterCNProviderBillingModelCandidates(context.Background(), cnAccount, apiKey,
[]string{"claude-sonnet-4-5", "claude-sonnet-4-5"})
require.Empty(t, allClaude, "全 claude 候选应被清空(上层走零成本+告警落账)")
// 非 CN 账号完全不受影响。
openaiAccount := &Account{ID: 2, Platform: PlatformOpenAI}
passthrough := svc.filterCNProviderBillingModelCandidates(context.Background(), openaiAccount, apiKey,
[]string{"claude-sonnet-4-5", "gpt-5.4"})
require.Equal(t, []string{"claude-sonnet-4-5", "gpt-5.4"}, passthrough)
require.Nil(t, svc.filterCNProviderBillingModelCandidates(context.Background(), nil, apiKey, nil))
}
func TestCalculateOpenAIRecordUsageCost_EmptyCandidatesIsPricingUnavailable(t *testing.T) {
svc := &OpenAIGatewayService{}
apiKey := &APIKey{Group: &Group{ID: 1, Platform: PlatformKimi}}
_, err := svc.calculateOpenAIRecordUsageCost(
context.Background(), nil, apiKey, nil,
1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 100}, "", nil, time.Time{},
)
require.Error(t, err)
require.True(t, isUsagePricingUnavailableError(err),
"空候选必须按无价可循处理(上层零成本落账),而不是丢弃整条 usage 记录: %v", err)
}
func TestResponsesStreamingFromNativeAnthropic_ClientDisconnectDrainsUsage(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newNativeAnthropicHangTestService(5)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
// failAfter=0:首次写出即失败,模拟客户端断开(复用测试包既有 failingGinWriter)。
failWriter := &failingGinWriter{ResponseWriter: c.Writer, failAfter: 0}
c.Writer = failWriter
resp, pr, pw := newHangingUpstreamResponse()
go func() {
// 首事件触发客户端写失败后,末尾 message_delta 才携带最终 output_tokens
// 断开即弃会把整段生成记成 1 token。
_, _ = pw.Write([]byte(miniAnthropicSSEStream()))
_ = pw.Close()
}()
defer func() { _ = pr.Close() }()
res, err := svc.handleResponsesStreamingFromNativeAnthropic(
resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), apicompat.ResponsesClientToolMapping{})
require.NoError(t, err, "断开排水至上游自然结束应返回 nil error(usage 走成功路径落账)")
require.NotNil(t, res)
require.True(t, res.ClientDisconnect)
require.Equal(t, 10, res.Usage.InputTokens, "input_tokens 应来自 message_start")
require.Equal(t, 5, res.Usage.OutputTokens,
"output_tokens 必须来自排水读到的末尾 message_delta(断开即弃时会是 1")
}
func TestHandle403_CNProviderHTMLBodySkipsAccountPenalty(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
repo := &rateLimitAccountRepoStub{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
account := &Account{ID: 401, Platform: platform, Type: AccountTypeAPIKey}
shouldDisable := service.HandleUpstreamError(
context.Background(),
account,
http.StatusForbidden,
http.Header{},
[]byte("<html><body>Access denied by CDN</body></html>"),
)
require.False(t, shouldDisable, "%s: HTML 403CDN/代理拦截页)不得作为账号失效证据", platform)
require.Equal(t, 0, repo.setErrorCalls, "%s: 不得永久禁用账号", platform)
require.Equal(t, 0, repo.tempCalls, "%s: 不得临时停调账号", platform)
}
}
func TestHandle403_CNProviderStructured403TempUnschedulableFirstHit(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
counter := &openAI403CounterCacheStub{counts: []int64{1}}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetOpenAI403CounterCache(counter)
account := &Account{ID: 402, Platform: PlatformKimi, Type: AccountTypeAPIKey}
shouldDisable := service.HandleUpstreamError(
context.Background(),
account,
http.StatusForbidden,
http.Header{},
[]byte(`{"error":{"message":"forbidden"}}`),
)
require.True(t, shouldDisable)
require.Equal(t, 0, repo.setErrorCalls, "首次结构化 403 应临时停调而非永久禁用")
require.Equal(t, 1, repo.tempCalls)
require.Contains(t, repo.lastTempReason, "(1/3)")
}
@@ -92,15 +92,13 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic(
return fmt.Errorf("count_tokens: missing account")
}
// 国产供应商 Anthropic 协议:上游有原生 /v1/messages/count_tokens 端点,
// 直接透传(仅模型名映射),不走 /v1/responses/input_tokens 估算。
if account.IsAnthropicProtocol() {
return s.forwardCountTokensViaNativeAnthropic(ctx, c, account, body, defaultMappedModel)
}
// 国产供应商其余协议(chat_completions / responses):三家上游均无
// OpenAI 兼容的 /v1/responses/input_tokens 端点,与 Grok 一样本地估算,
// 不发上游请求(Claude Code 客户端会高频调用 count_tokens)。
// 国产供应商(全部协议,含 anthropic):一律本地估算,不发上游请求。
// 依据(2026-08 核实):三家的 Anthropic 兼容层均未提供
// /v1/messages/count_tokens——DeepSeek 官方 anthropic_api 文档无此端点
// (且注明 anthropic-version 头被忽略),聚合网关 OpenModel 明确标注
// count_tokens 为 "Anthropic only"Kimi/智谱亦无任何文档承诺。转发上游
// 只会常态 404,且错误还会流入账号处置逻辑误伤整账号调度;Claude Code
// 高频调用此端点,本地 tiktoken 估算是与 Grok 一致的既有方案。
if account.IsCNProvider() {
estimated, err := estimateAnthropicCountTokensLocally(body)
if err != nil {
@@ -515,95 +515,6 @@ func (s *OpenAIGatewayService) nativeAnthropicStreamResult(
}
}
// forwardCountTokensViaNativeAnthropic 把 Anthropic count_tokens 请求透传到
// 国产供应商原生 Anthropic 端点({base}/v1/messages/count_tokens),
// 仅做模型名映射,不做协议转换。
func (s *OpenAIGatewayService) forwardCountTokensViaNativeAnthropic(
ctx context.Context,
c *gin.Context,
account *Account,
body []byte,
defaultMappedModel string,
) error {
originalModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
if originalModel == "" {
writeAnthropicCountTokensError(c, http.StatusBadRequest, "invalid_request_error", "model is required")
return fmt.Errorf("count_tokens: missing model in request")
}
billingModel := resolveOpenAIForwardModel(account, originalModel, strings.TrimSpace(defaultMappedModel))
upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
if upstreamModel != originalModel {
rewritten, err := sjson.SetBytes(body, "model", upstreamModel)
if err != nil {
return fmt.Errorf("count_tokens: rewrite model: %w", err)
}
body = rewritten
}
apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey())
if apiKey == "" {
writeAnthropicCountTokensError(c, http.StatusBadGateway, "upstream_error", "Account api_key is missing")
return fmt.Errorf("count_tokens: account %d missing api_key", account.ID)
}
targetURL, err := s.nativeAnthropicTargetURL(account)
if err != nil {
return fmt.Errorf("count_tokens: %w", err)
}
targetURL = strings.TrimSuffix(targetURL, "/v1/messages") + "/v1/messages/count_tokens"
upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
if err != nil {
writeAnthropicCountTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request")
return fmt.Errorf("count_tokens: build request: %w", err)
}
reqHeader := upstreamReq.Header
reqHeader.Del("authorization")
reqHeader.Del("x-api-key")
setAnthropicAPIKeyAuthHeader(reqHeader, account, apiKey)
reqHeader.Set("content-type", "application/json")
reqHeader.Set("accept", "application/json")
account.ApplyHeaderOverrides(reqHeader)
proxyURL := ""
if account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
if err != nil {
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
writeAnthropicCountTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
return fmt.Errorf("count_tokens: upstream request failed: %s", safeErr)
}
defer func() { _ = resp.Body.Close() }()
// count_tokens 响应体极小;与其他探测路径一致加 256KB 上限防异常上游放大内存。
respBody, err := io.ReadAll(io.LimitReader(resp.Body, cnQuotaMaxBodyBytes))
if err != nil {
writeAnthropicCountTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
return fmt.Errorf("count_tokens: read response: %w", err)
}
if resp.StatusCode >= 400 {
if s.rateLimitService != nil {
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
}
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, "")
writeAnthropicCountTokensError(c, resp.StatusCode, "upstream_error", "Upstream request failed")
return fmt.Errorf("count_tokens: upstream error: %d", resp.StatusCode)
}
inputTokens := gjson.GetBytes(respBody, "input_tokens")
if !inputTokens.Exists() {
writeAnthropicCountTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response missing input_tokens")
return fmt.Errorf("count_tokens: response missing input_tokens field")
}
c.JSON(http.StatusOK, gin.H{
"input_tokens": int(inputTokens.Int()),
})
return nil
}
// claudeUsageToOpenAIUsage 把 Anthropic 格式 usage 映射到 OpenAI 网关统一的
// 用量结构(字段一一对应)。
func claudeUsageToOpenAIUsage(u *ClaudeUsage) OpenAIUsage {
@@ -377,7 +377,11 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic(
return resultWithUsage(), fmt.Errorf("stream data interval timeout")
}
processAnthropicEvent := func(event *apicompat.AnthropicStreamEvent) bool {
// 与 CC 姊妹路径(handleCCStreamingFromNativeAnthropic.writeChunk)同语义:
// 客户端断开后不再写出,但继续排水上游至流自然结束——Anthropic 的最终
// output_tokens 只在末尾 message_delta 携带,提前退出会把整段生成记成 ~1
// token,payg 上游照常计费而平台漏记。状态机照常推进以保证 finalize 一致。
processAnthropicEvent := func(event *apicompat.AnthropicStreamEvent) {
if firstChunk {
firstChunk = false
ms := int(time.Since(startTime).Milliseconds())
@@ -392,6 +396,9 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic(
}
events := apicompat.AnthropicEventToResponsesEvents(event, state)
if clientDisconnected {
return
}
for _, evt := range events {
payload, err := json.Marshal(evt)
if err != nil {
@@ -406,14 +413,13 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic(
eventType := gjson.GetBytes(restored, "type").String()
if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", eventType, restored); err != nil {
clientDisconnected = true
return true
return
}
}
}
if len(events) > 0 {
c.Writer.Flush()
}
return false
}
for {
@@ -447,21 +453,39 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic(
continue
}
if processAnthropicEvent(&event) {
return resultWithUsage(), nil
}
processAnthropicEvent(&event)
}
// Finalize state machine(客户端已断开时仍执行,保证 usage 汇总完整)。
if finalEvents := apicompat.FinalizeAnthropicResponsesStream(state); len(finalEvents) > 0 {
// Finalize state machine(客户端已断开时仍推进,保证 usage 汇总完整;仅在
// 客户端仍连接时写出)。终态帧与逐事件路径一致过工具名反转与客户端工具还原,
// 避免流截断时终态帧携带改写后的工具名。
if finalEvents := apicompat.FinalizeAnthropicResponsesStream(state); len(finalEvents) > 0 && !clientDisconnected {
wrote := false
for _, evt := range finalEvents {
sse, err := apicompat.ResponsesEventToSSE(evt)
payload, err := json.Marshal(evt)
if err != nil {
continue
}
fmt.Fprint(c.Writer, sse) //nolint:errcheck
payload = reverseToolNamesIfPresent(c, payload)
payloads, _, err := clientToolRestorer.RestoreEvent(payload)
if err != nil {
continue
}
for _, restored := range payloads {
eventType := gjson.GetBytes(restored, "type").String()
if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", eventType, restored); err != nil {
clientDisconnected = true
break
}
wrote = true
}
if clientDisconnected {
break
}
}
if wrote {
c.Writer.Flush()
}
c.Writer.Flush()
}
return resultWithUsage(), nil
@@ -201,6 +201,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
result.UpstreamModel,
result.Model,
)
billingModels = s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, billingModels)
serviceTier := ""
if result.ServiceTier != nil {
serviceTier = strings.TrimSpace(*result.ServiceTier)
@@ -255,7 +256,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
result.AudioUsage != nil || result.SearchCount > 0,
); responseModel != "" && !strings.EqualFold(responseModel, baselineBillingModel) {
if identified, responseChannelPriced := s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey); identified {
responseModels := usageBillingModelCandidates(responseModel)
responseModels := s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, usageBillingModelCandidates(responseModel))
responseCost, responseErr := s.calculateOpenAIRecordUsageCost(
ctx, result, apiKey, responseModels, multiplier, imageMultiplier,
videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, pricingAt,
@@ -589,7 +590,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
if tokenCost == nil {
if tokenBillingAttempted {
if lastErr == nil {
lastErr = errors.New("no non-empty billing model candidates")
lastErr = fmt.Errorf("%w: no non-empty billing model candidates", ErrModelPricingUnavailable)
}
return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr)
}
@@ -597,8 +598,11 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
if searchCost != nil {
return searchCost, nil
}
// 空候选按「无价可循」处理并携带 ErrModelPricingUnavailable:上层据此走
// 零成本+告警落账,而不是丢弃整条 usage 记录。CN 账号的 claude-* 候选被
// filterCNProviderBillingModelCandidates 全数过滤后即落到这里。
if lastErr == nil {
lastErr = errors.New("openai usage billing model is empty")
lastErr = fmt.Errorf("%w: openai usage billing model is empty", ErrModelPricingUnavailable)
}
return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr)
}
@@ -846,6 +850,36 @@ func groupMediaPricingLooksIncomplete(group *Group) bool {
group.VideoPrice480P == nil && group.VideoPrice720P == nil && group.VideoPrice1080P == nil
}
// filterCNProviderBillingModelCandidates 过滤国产供应商(kimi/zhipu/deepseek
// 账号的计费候选模型名:claude-* 候选仅在运营者显式配置了分组/渠道定价时保留。
//
// 背景:候选链的兜底候选含客户端请求的原始模型名。CN 上游的 Anthropic 兼容端点
// 接受 claude-* 模型名但从不真正服务 Claude 模型;若放行,目录里的 Claude 价卡
// 与 getFallbackPricing 的 "claude"→Sonnet 统一兜底会把 CN 流量按 Claude 原价
// (数倍~数十倍)静默误计,且 usage 日志显示的正是 claude-* 名,无从察觉。
// 候选全部落空时走既有的零成本+告警路径(openai_usage.pricing_missing_record_
// zero_cost),与定价层「未知型号不回退以避免误计价」的既有设计意图一致;
// 运营者的修复手段是配置账号级 model_mapping(映射到已定价的 CN 模型)或
// 分组/渠道显式定价。
func (s *OpenAIGatewayService) filterCNProviderBillingModelCandidates(ctx context.Context, account *Account, apiKey *APIKey, candidates []string) []string {
if account == nil || !account.IsCNProvider() {
return candidates
}
out := make([]string, 0, len(candidates))
for _, candidate := range candidates {
trimmed := strings.TrimSpace(candidate)
if trimmed == "" {
continue
}
if strings.Contains(strings.ToLower(trimmed), "claude") &&
s.resolveOpenAIChannelPricing(ctx, trimmed, apiKey) == nil {
continue
}
out = append(out, candidate)
}
return out
}
func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {
if s.resolver == nil || apiKey == nil || apiKey.Group == nil {
return nil
@@ -79,6 +79,13 @@ func (g *Group) ResolveMessagesDispatchModel(requestedModel string) string {
return xai.ModelMappingWithOptions(opts)["claude-*"]
}
// 国产供应商分组:调度级模型映射不适用(其配置被 sanitize 置空,且下方的
// gpt-5.x 默认值是 openai 专属,发给 CN 上游必错)。模型改写完全交给账号级
// model_mapping;anthropic 协议上游本身接受 claude-* 模型名。
if IsCNProvider(g.Platform) {
return ""
}
cfg := normalizeOpenAIMessagesDispatchModelConfig(g.MessagesDispatchModelConfig)
if mappedModel := strings.TrimSpace(cfg.ExactModelMappings[requestedModel]); mappedModel != "" {
return mappedModel
@@ -906,7 +906,10 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst
if account.Platform == PlatformAntigravity {
return s.handleAntigravity403(ctx, account, upstreamMsg, responseBody)
}
if account.Platform == PlatformOpenAI {
// 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据,
// 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/
// 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。
if account.Platform == PlatformOpenAI || IsCNProvider(account.Platform) {
return s.handleOpenAI403(ctx, account, upstreamMsg, responseBody)
}
// 非 Antigravity 平台:保持原有行为
@@ -113,7 +113,9 @@ const formatEntry = (entry: CNProviderBalanceEntry): string => {
const balanceLabel = computed(() => {
if (currentEntries.value.length === 0) {
return t('admin.accounts.grokBalance')?.trim() || 'Balance'
// 修复:此前误引 admin.accounts.grokBalance(实际嵌套在 usageWindow 下),
// 未命中时渲染原始 key。CN 供应商使用自己的占位键。
return t('admin.accounts.cnProviders.balance')
}
return currentEntries.value.map(formatEntry).join(' · ')
})
@@ -162,6 +162,15 @@ const labelClass = computed(() => {
if (props.platform === 'grok') {
return `${base} bg-zinc-300/70 text-zinc-800 dark:bg-zinc-700/60 dark:text-zinc-200`
}
if (props.platform === 'kimi') {
return `${base} bg-pink-200/60 text-pink-800 dark:bg-pink-800/40 dark:text-pink-300`
}
if (props.platform === 'zhipu') {
return `${base} bg-indigo-200/60 text-indigo-800 dark:bg-indigo-800/40 dark:text-indigo-300`
}
if (props.platform === 'deepseek') {
return `${base} bg-teal-200/60 text-teal-800 dark:bg-teal-800/40 dark:text-teal-300`
}
if (props.platform === 'composite') {
return `${base} bg-cyan-200/70 text-cyan-900 dark:bg-cyan-900/50 dark:text-cyan-300`
}
@@ -200,6 +209,21 @@ const badgeClass = computed(() => {
? 'bg-zinc-200 text-zinc-800 dark:bg-zinc-700 dark:text-zinc-100'
: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200'
}
if (props.platform === 'kimi') {
return isSubscription.value
? 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400'
: 'bg-pink-50 text-pink-700 dark:bg-pink-900/20 dark:text-pink-400'
}
if (props.platform === 'zhipu') {
return isSubscription.value
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400'
: 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-400'
}
if (props.platform === 'deepseek') {
return isSubscription.value
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: 'bg-teal-50 text-teal-700 dark:bg-teal-900/20 dark:text-teal-400'
}
if (props.platform === 'composite') {
return isSubscription.value
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300'
@@ -125,6 +125,7 @@ export default {
responses: 'Responses',
responsesDesc: 'Providers native Responses endpoint — ideal for Codex.',
},
balance: 'Balance --',
window5h: '5-hour window',
windowWeekly: 'Weekly window',
probeTooltip: 'Query the provider quota endpoint for 5-hour / weekly rolling window usage',
@@ -950,6 +950,9 @@ export default {
gemini: 'Gemini',
antigravity: 'Antigravity',
grok: 'Grok',
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek',
composite: 'Composite',
},
deleteConfirm:
@@ -328,6 +328,7 @@ export default {
responses: 'Responses',
responsesDesc: '供应商原生 Responses 端点,适配 Codex。',
},
balance: '余额 --',
window5h: '5 小时窗口',
windowWeekly: '每周窗口',
probeTooltip: '请求供应商额度端点,查询 5 小时 / 每周滚动窗口用量',
@@ -883,6 +883,9 @@ export default {
gemini: 'Gemini',
antigravity: 'Antigravity',
grok: 'Grok',
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek',
composite: 'Composite',
},
saving: '保存中...',
+20 -2
View File
@@ -146,7 +146,13 @@
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: value === 'grok'
? 'bg-zinc-200 text-zinc-800 dark:bg-zinc-700 dark:text-zinc-100'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
: value === 'kimi'
? 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400'
: value === 'zhipu'
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400'
: value === 'deepseek'
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
]"
>
<PlatformIcon :platform="value" size="xs" />
@@ -3965,7 +3971,13 @@
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: group.platform === 'grok'
? 'bg-zinc-200 text-zinc-800 dark:bg-zinc-700 dark:text-zinc-100'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
: group.platform === 'kimi'
? 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400'
: group.platform === 'zhipu'
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400'
: group.platform === 'deepseek'
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
]"
>
{{ t("admin.groups.platforms." + group.platform) }}
@@ -4731,6 +4743,9 @@ const platformOptions = computed(() => [
{ value: "gemini", label: "Gemini" },
{ value: "antigravity", label: "Antigravity" },
{ value: "grok", label: "Grok" },
{ value: "kimi", label: "Kimi" },
{ value: "zhipu", label: "Zhipu GLM" },
{ value: "deepseek", label: "DeepSeek" },
{ value: "composite", label: "Composite" },
]);
@@ -4741,6 +4756,9 @@ const platformFilterOptions = computed(() => [
{ value: "gemini", label: "Gemini" },
{ value: "antigravity", label: "Antigravity" },
{ value: "grok", label: "Grok" },
{ value: "kimi", label: "Kimi" },
{ value: "zhipu", label: "Zhipu GLM" },
{ value: "deepseek", label: "DeepSeek" },
{ value: "composite", label: "Composite" },
]);