Unverified Commit c7e18bd5 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #627 from touwaeriol/pr/bugfixes-and-enhancements

feat: 反重力(Antigravity)增强、Failover 重构及新模型支持
parents 516f8f28 8365a832
...@@ -76,6 +76,12 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { ...@@ -76,6 +76,12 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) {
}, },
// 3. 默认映射中的透传(映射到自己) // 3. 默认映射中的透传(映射到自己)
{
name: "默认映射透传 - claude-sonnet-4-6",
requestedModel: "claude-sonnet-4-6",
accountMapping: nil,
expected: "claude-sonnet-4-6",
},
{ {
name: "默认映射透传 - claude-sonnet-4-5", name: "默认映射透传 - claude-sonnet-4-5",
requestedModel: "claude-sonnet-4-5", requestedModel: "claude-sonnet-4-5",
......
...@@ -197,6 +197,22 @@ func TestHandleUpstreamError_429_NonModelRateLimit(t *testing.T) { ...@@ -197,6 +197,22 @@ func TestHandleUpstreamError_429_NonModelRateLimit(t *testing.T) {
require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey) require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey)
} }
// TestHandleUpstreamError_429_NonModelRateLimit_UsesMappedModelKey 测试 429 非模型限流场景
// 验证:requestedModel 会被映射到 Antigravity 最终模型(例如 claude-opus-4-6 -> claude-opus-4-6-thinking)
func TestHandleUpstreamError_429_NonModelRateLimit_UsesMappedModelKey(t *testing.T) {
repo := &stubAntigravityAccountRepo{}
svc := &AntigravityGatewayService{accountRepo: repo}
account := &Account{ID: 20, Name: "acc-20", Platform: PlatformAntigravity}
body := buildGeminiRateLimitBody("5s")
result := svc.handleUpstreamError(context.Background(), "[test]", account, http.StatusTooManyRequests, http.Header{}, body, "claude-opus-4-6", 0, "", false)
require.Nil(t, result)
require.Len(t, repo.modelRateLimitCalls, 1)
require.Equal(t, "claude-opus-4-6-thinking", repo.modelRateLimitCalls[0].modelKey)
}
// TestHandleUpstreamError_503_ModelCapacityExhausted 测试 503 模型容量不足场景 // TestHandleUpstreamError_503_ModelCapacityExhausted 测试 503 模型容量不足场景
// MODEL_CAPACITY_EXHAUSTED 时应等待重试,不切换账号 // MODEL_CAPACITY_EXHAUSTED 时应等待重试,不切换账号
func TestHandleUpstreamError_503_ModelCapacityExhausted(t *testing.T) { func TestHandleUpstreamError_503_ModelCapacityExhausted(t *testing.T) {
......
...@@ -133,6 +133,18 @@ func (s *BillingService) initFallbackPricing() { ...@@ -133,6 +133,18 @@ func (s *BillingService) initFallbackPricing() {
CacheReadPricePerToken: 0.03e-6, // $0.03 per MTok CacheReadPricePerToken: 0.03e-6, // $0.03 per MTok
SupportsCacheBreakdown: false, SupportsCacheBreakdown: false,
} }
// Claude 4.6 Opus (与4.5同价)
s.fallbackPrices["claude-opus-4.6"] = s.fallbackPrices["claude-opus-4.5"]
// Gemini 3.1 Pro
s.fallbackPrices["gemini-3.1-pro"] = &ModelPricing{
InputPricePerToken: 2e-6, // $2 per MTok
OutputPricePerToken: 12e-6, // $12 per MTok
CacheCreationPricePerToken: 2e-6, // $2 per MTok
CacheReadPricePerToken: 0.2e-6, // $0.20 per MTok
SupportsCacheBreakdown: false,
}
} }
// getFallbackPricing 根据模型系列获取回退价格 // getFallbackPricing 根据模型系列获取回退价格
...@@ -141,6 +153,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { ...@@ -141,6 +153,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
// 按模型系列匹配 // 按模型系列匹配
if strings.Contains(modelLower, "opus") { if strings.Contains(modelLower, "opus") {
if strings.Contains(modelLower, "4.6") || strings.Contains(modelLower, "4-6") {
return s.fallbackPrices["claude-opus-4.6"]
}
if strings.Contains(modelLower, "4.5") || strings.Contains(modelLower, "4-5") { if strings.Contains(modelLower, "4.5") || strings.Contains(modelLower, "4-5") {
return s.fallbackPrices["claude-opus-4.5"] return s.fallbackPrices["claude-opus-4.5"]
} }
...@@ -158,6 +173,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { ...@@ -158,6 +173,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
} }
return s.fallbackPrices["claude-3-haiku"] return s.fallbackPrices["claude-3-haiku"]
} }
if strings.Contains(modelLower, "gemini-3.1-pro") || strings.Contains(modelLower, "gemini-3-1-pro") {
return s.fallbackPrices["gemini-3.1-pro"]
}
// 默认使用Sonnet价格 // 默认使用Sonnet价格
return s.fallbackPrices["claude-sonnet-4"] return s.fallbackPrices["claude-sonnet-4"]
......
...@@ -895,6 +895,55 @@ func TestGatewayService_SelectAccountForModelWithPlatform_GeminiPreferOAuth(t *t ...@@ -895,6 +895,55 @@ func TestGatewayService_SelectAccountForModelWithPlatform_GeminiPreferOAuth(t *t
require.Equal(t, int64(2), acc.ID) require.Equal(t, int64(2), acc.ID)
} }
func TestGatewayService_SelectAccountForModelWithPlatform_GeminiAPIKeyModelMappingFilter(t *testing.T) {
ctx := context.Background()
repo := &mockAccountRepoForPlatform{
accounts: []Account{
{
ID: 1,
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Priority: 1,
Status: StatusActive,
Schedulable: true,
Credentials: map[string]any{"model_mapping": map[string]any{"gemini-2.5-pro": "gemini-2.5-pro"}},
},
{
ID: 2,
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Priority: 2,
Status: StatusActive,
Schedulable: true,
Credentials: map[string]any{"model_mapping": map[string]any{"gemini-2.5-flash": "gemini-2.5-flash"}},
},
},
accountsByID: map[int64]*Account{},
}
for i := range repo.accounts {
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
}
cache := &mockGatewayCacheForPlatform{}
svc := &GatewayService{
accountRepo: repo,
cache: cache,
cfg: testConfig(),
}
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "gemini-2.5-flash", nil, PlatformGemini)
require.NoError(t, err)
require.NotNil(t, acc)
require.Equal(t, int64(2), acc.ID, "应过滤不支持请求模型的 APIKey 账号")
acc, err = svc.selectAccountForModelWithPlatform(ctx, nil, "", "gemini-3-pro-preview", nil, PlatformGemini)
require.Error(t, err)
require.Nil(t, acc)
require.Contains(t, err.Error(), "supporting model")
}
func TestGatewayService_SelectAccountForModelWithPlatform_StickyInGroup(t *testing.T) { func TestGatewayService_SelectAccountForModelWithPlatform_StickyInGroup(t *testing.T) {
ctx := context.Background() ctx := context.Background()
groupID := int64(50) groupID := int64(50)
...@@ -1070,6 +1119,36 @@ func TestGatewayService_isModelSupportedByAccount(t *testing.T) { ...@@ -1070,6 +1119,36 @@ func TestGatewayService_isModelSupportedByAccount(t *testing.T) {
model: "claude-3-5-sonnet-20241022", model: "claude-3-5-sonnet-20241022",
expected: true, expected: true,
}, },
{
name: "Gemini平台-无映射配置-支持所有模型",
account: &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey},
model: "gemini-2.5-flash",
expected: true,
},
{
name: "Gemini平台-有映射配置-只支持配置的模型",
account: &Account{
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"model_mapping": map[string]any{"gemini-2.5-pro": "gemini-2.5-pro"},
},
},
model: "gemini-2.5-flash",
expected: false,
},
{
name: "Gemini平台-有映射配置-支持配置的模型",
account: &Account{
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"model_mapping": map[string]any{"gemini-2.5-pro": "gemini-2.5-pro"},
},
},
model: "gemini-2.5-pro",
expected: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
......
...@@ -2825,10 +2825,6 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo ...@@ -2825,10 +2825,6 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
if account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey { if account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
requestedModel = claude.NormalizeModelID(requestedModel) requestedModel = claude.NormalizeModelID(requestedModel)
} }
// Gemini API Key 账户直接透传,由上游判断模型是否支持
if account.Platform == PlatformGemini && account.Type == AccountTypeAPIKey {
return true
}
// 其他平台使用账户的模型支持检查 // 其他平台使用账户的模型支持检查
return account.IsModelSupported(requestedModel) return account.IsModelSupported(requestedModel)
} }
......
...@@ -107,12 +107,12 @@ func TestIsModelRateLimited(t *testing.T) { ...@@ -107,12 +107,12 @@ func TestIsModelRateLimited(t *testing.T) {
expected: true, expected: true,
}, },
{ {
name: "antigravity platform - gemini-3-pro-preview mapped to gemini-3.1-pro-high", name: "antigravity platform - gemini-3-pro-preview mapped to gemini-3-pro-high",
account: &Account{ account: &Account{
Platform: PlatformAntigravity, Platform: PlatformAntigravity,
Extra: map[string]any{ Extra: map[string]any{
modelRateLimitsKey: map[string]any{ modelRateLimitsKey: map[string]any{
"gemini-3.1-pro-high": map[string]any{ "gemini-3-pro-high": map[string]any{
"rate_limit_reset_at": future, "rate_limit_reset_at": future,
}, },
}, },
......
-- Add claude-sonnet-4-6 to model_mapping for all Antigravity accounts
--
-- Background:
-- Antigravity now supports claude-sonnet-4-6
--
-- Strategy:
-- Directly overwrite the entire model_mapping with updated mappings
-- This ensures consistency with DefaultAntigravityModelMapping in constants.go
UPDATE accounts
SET credentials = jsonb_set(
credentials,
'{model_mapping}',
'{
"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",
"claude-opus-4-5-20251101": "claude-opus-4-6-thinking",
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-sonnet-4-5": "claude-sonnet-4-5",
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
"claude-haiku-4-5": "claude-sonnet-4-5",
"claude-haiku-4-5-20251001": "claude-sonnet-4-5",
"gemini-2.5-flash": "gemini-2.5-flash",
"gemini-2.5-flash-lite": "gemini-2.5-flash-lite",
"gemini-2.5-flash-thinking": "gemini-2.5-flash-thinking",
"gemini-2.5-pro": "gemini-2.5-pro",
"gemini-3-flash": "gemini-3-flash",
"gemini-3-pro-high": "gemini-3-pro-high",
"gemini-3-pro-low": "gemini-3-pro-low",
"gemini-3-pro-image": "gemini-3-pro-image",
"gemini-3-flash-preview": "gemini-3-flash",
"gemini-3-pro-preview": "gemini-3-pro-high",
"gemini-3-pro-image-preview": "gemini-3-pro-image",
"gpt-oss-120b-medium": "gpt-oss-120b-medium",
"tab_flash_lite_preview": "tab_flash_lite_preview"
}'::jsonb
)
WHERE platform = 'antigravity'
AND deleted_at IS NULL
AND credentials->'model_mapping' IS NOT NULL;
-- Add gemini-3.1-pro-high, gemini-3.1-pro-low, gemini-3.1-pro-preview to model_mapping
--
-- Background:
-- Antigravity now supports gemini-3.1-pro-high and gemini-3.1-pro-low
--
-- Strategy:
-- Directly overwrite the entire model_mapping with updated mappings
-- This ensures consistency with DefaultAntigravityModelMapping in constants.go
UPDATE accounts
SET credentials = jsonb_set(
credentials,
'{model_mapping}',
'{
"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",
"claude-opus-4-5-20251101": "claude-opus-4-6-thinking",
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-sonnet-4-5": "claude-sonnet-4-5",
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
"claude-haiku-4-5": "claude-sonnet-4-5",
"claude-haiku-4-5-20251001": "claude-sonnet-4-5",
"gemini-2.5-flash": "gemini-2.5-flash",
"gemini-2.5-flash-lite": "gemini-2.5-flash-lite",
"gemini-2.5-flash-thinking": "gemini-2.5-flash-thinking",
"gemini-2.5-pro": "gemini-2.5-pro",
"gemini-3-flash": "gemini-3-flash",
"gemini-3-pro-high": "gemini-3-pro-high",
"gemini-3-pro-low": "gemini-3-pro-low",
"gemini-3-pro-image": "gemini-3-pro-image",
"gemini-3-flash-preview": "gemini-3-flash",
"gemini-3-pro-preview": "gemini-3-pro-high",
"gemini-3-pro-image-preview": "gemini-3-pro-image",
"gemini-3.1-pro-high": "gemini-3.1-pro-high",
"gemini-3.1-pro-low": "gemini-3.1-pro-low",
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
"gpt-oss-120b-medium": "gpt-oss-120b-medium",
"tab_flash_lite_preview": "tab_flash_lite_preview"
}'::jsonb
)
WHERE platform = 'antigravity'
AND deleted_at IS NULL
AND credentials->'model_mapping' IS NOT NULL;
...@@ -15,7 +15,9 @@ import type { ...@@ -15,7 +15,9 @@ import type {
AccountUsageStatsResponse, AccountUsageStatsResponse,
TempUnschedulableStatus, TempUnschedulableStatus,
AdminDataPayload, AdminDataPayload,
AdminDataImportResult AdminDataImportResult,
CheckMixedChannelRequest,
CheckMixedChannelResponse
} from '@/types' } from '@/types'
/** /**
...@@ -133,6 +135,16 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise ...@@ -133,6 +135,16 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise
return data return data
} }
/**
* Check mixed-channel risk for account-group binding.
*/
export async function checkMixedChannelRisk(
payload: CheckMixedChannelRequest
): Promise<CheckMixedChannelResponse> {
const { data } = await apiClient.post<CheckMixedChannelResponse>('/admin/accounts/check-mixed-channel', payload)
return data
}
/** /**
* Delete account * Delete account
* @param id - Account ID * @param id - Account ID
...@@ -535,6 +547,7 @@ export const accountsAPI = { ...@@ -535,6 +547,7 @@ export const accountsAPI = {
getById, getById,
create, create,
update, update,
checkMixedChannelRisk,
delete: deleteAccount, delete: deleteAccount,
toggleStatus, toggleStatus,
testAccount, testAccount,
......
...@@ -77,13 +77,23 @@ ...@@ -77,13 +77,23 @@
</div> </div>
<!-- Model Rate Limit Indicators (Antigravity OAuth Smart Retry) --> <!-- Model Rate Limit Indicators (Antigravity OAuth Smart Retry) -->
<template v-if="activeModelRateLimits.length > 0"> <div
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative"> v-if="activeModelRateLimits.length > 0"
:class="[
activeModelRateLimits.length <= 4
? 'flex flex-col gap-1'
: activeModelRateLimits.length <= 8
? 'columns-2 gap-x-2'
: 'columns-3 gap-x-2'
]"
>
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative mb-1 break-inside-avoid">
<span <span
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400" class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
> >
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" /> <Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
{{ formatScopeName(item.model) }} {{ formatScopeName(item.model) }}
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
</span> </span>
<!-- Tooltip --> <!-- Tooltip -->
<div <div
...@@ -95,7 +105,7 @@ ...@@ -95,7 +105,7 @@
></div> ></div>
</div> </div>
</div> </div>
</template> </div>
<!-- Overload Indicator (529) --> <!-- Overload Indicator (529) -->
<div v-if="isOverloaded" class="group relative"> <div v-if="isOverloaded" class="group relative">
...@@ -154,17 +164,50 @@ const activeModelRateLimits = computed(() => { ...@@ -154,17 +164,50 @@ const activeModelRateLimits = computed(() => {
}) })
const formatScopeName = (scope: string): string => { const formatScopeName = (scope: string): string => {
const names: Record<string, string> = { const aliases: Record<string, string> = {
// Claude 系列
'claude-opus-4-6-thinking': 'COpus46',
'claude-sonnet-4-6': 'CSon46',
'claude-sonnet-4-5': 'CSon45',
'claude-sonnet-4-5-thinking': 'CSon45T',
// Gemini 2.5 系列
'gemini-2.5-flash': 'G25F',
'gemini-2.5-flash-lite': 'G25FL',
'gemini-2.5-flash-thinking': 'G25FT',
'gemini-2.5-pro': 'G25P',
// Gemini 3 系列
'gemini-3-flash': 'G3F',
'gemini-3.1-pro-high': 'G3PH',
'gemini-3.1-pro-low': 'G3PL',
'gemini-3-pro-image': 'G3PI',
// 其他
'gpt-oss-120b-medium': 'GPT120',
'tab_flash_lite_preview': 'TabFL',
// 旧版 scope 别名(兼容)
claude: 'Claude', claude: 'Claude',
claude_sonnet: 'Claude Sonnet', claude_sonnet: 'CSon',
claude_opus: 'Claude Opus', claude_opus: 'COpus',
claude_haiku: 'Claude Haiku', claude_haiku: 'CHaiku',
gemini_text: 'Gemini', gemini_text: 'Gemini',
gemini_image: 'Image', gemini_image: 'GImg',
gemini_flash: 'Gemini Flash', gemini_flash: 'GFlash',
gemini_pro: 'Gemini Pro' gemini_pro: 'GPro',
} }
return names[scope] || scope return aliases[scope] || scope
}
const formatModelResetTime = (resetAt: string): string => {
const date = new Date(resetAt)
const now = new Date()
const diffMs = date.getTime() - now.getTime()
if (diffMs <= 0) return ''
const totalSecs = Math.floor(diffMs / 1000)
const h = Math.floor(totalSecs / 3600)
const m = Math.floor((totalSecs % 3600) / 60)
const s = totalSecs % 60
if (h > 0) return `${h}h${m}m`
if (m > 0) return `${m}m${s}s`
return `${s}s`
} }
// Computed: is overloaded (529) // Computed: is overloaded (529)
......
...@@ -172,12 +172,12 @@ ...@@ -172,12 +172,12 @@
color="purple" color="purple"
/> />
<!-- Claude 4.5 --> <!-- Claude -->
<UsageProgressBar <UsageProgressBar
v-if="antigravityClaude45UsageFromAPI !== null" v-if="antigravityClaudeUsageFromAPI !== null"
:label="t('admin.accounts.usageWindow.claude45')" :label="t('admin.accounts.usageWindow.claude')"
:utilization="antigravityClaude45UsageFromAPI.utilization" :utilization="antigravityClaudeUsageFromAPI.utilization"
:resets-at="antigravityClaude45UsageFromAPI.resetTime" :resets-at="antigravityClaudeUsageFromAPI.resetTime"
color="amber" color="amber"
/> />
</div> </div>
...@@ -400,9 +400,12 @@ const antigravity3FlashUsageFromAPI = computed(() => getAntigravityUsageFromAPI( ...@@ -400,9 +400,12 @@ const antigravity3FlashUsageFromAPI = computed(() => getAntigravityUsageFromAPI(
// Gemini 3 Image from API // Gemini 3 Image from API
const antigravity3ImageUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-pro-image'])) const antigravity3ImageUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-pro-image']))
// Claude 4.5 from API // Claude from API (all Claude model variants)
const antigravityClaude45UsageFromAPI = computed(() => const antigravityClaudeUsageFromAPI = computed(() =>
getAntigravityUsageFromAPI(['claude-sonnet-4-5', 'claude-opus-4-5-thinking']) getAntigravityUsageFromAPI([
'claude-sonnet-4-5', 'claude-opus-4-5-thinking',
'claude-sonnet-4-6', 'claude-opus-4-6-thinking',
])
) )
// Antigravity 账户类型(从 load_code_assist 响应中提取) // Antigravity 账户类型(从 load_code_assist 响应中提取)
......
...@@ -209,7 +209,7 @@ ...@@ -209,7 +209,7 @@
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2"> <div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
<div <div
v-for="(mapping, index) in modelMappings" v-for="(mapping, index) in modelMappings"
:key="getModelMappingKey(mapping)" :key="index"
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<input <input
...@@ -654,7 +654,7 @@ import Select from '@/components/common/Select.vue' ...@@ -654,7 +654,7 @@ import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { buildModelMappingObject as buildModelMappingPayload } from '@/composables/useModelWhitelist'
interface Props { interface Props {
show: boolean show: boolean
...@@ -696,7 +696,6 @@ const baseUrl = ref('') ...@@ -696,7 +696,6 @@ const baseUrl = ref('')
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([]) const allowedModels = ref<string[]>([])
const modelMappings = ref<ModelMapping[]>([]) const modelMappings = ref<ModelMapping[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('bulk-model-mapping')
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
...@@ -707,7 +706,7 @@ const rateMultiplier = ref(1) ...@@ -707,7 +706,7 @@ const rateMultiplier = ref(1)
const status = ref<'active' | 'inactive'>('active') const status = ref<'active' | 'inactive'>('active')
const groupIds = ref<number[]>([]) const groupIds = ref<number[]>([])
// All models list (combined Anthropic + OpenAI) // All models list (combined Anthropic + OpenAI + Gemini)
const allModels = [ const allModels = [
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
...@@ -719,17 +718,21 @@ const allModels = [ ...@@ -719,17 +718,21 @@ const allModels = [
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' }, { value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' }, { value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' }, { value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
{ value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' }, { value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' }, { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' }, { value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' }, { value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' }, { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' } { value: 'gpt-5-2025-08-07', label: 'GPT-5' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }
] ]
// Preset mappings (combined Anthropic + OpenAI) // Preset mappings (combined Anthropic + OpenAI + Gemini)
const presetMappings = [ const presetMappings = [
{ {
label: 'Sonnet 4', label: 'Sonnet 4',
...@@ -796,12 +799,6 @@ const presetMappings = [ ...@@ -796,12 +799,6 @@ const presetMappings = [
to: 'claude-sonnet-4-5-20250929', 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' color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
}, },
{
label: 'GPT-5.3 Codex Spark',
from: 'gpt-5.3-codex-spark',
to: 'gpt-5.3-codex-spark',
color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400'
},
{ {
label: 'GPT-5.2', label: 'GPT-5.2',
from: 'gpt-5.2-2025-12-11', from: 'gpt-5.2-2025-12-11',
...@@ -938,23 +935,11 @@ const removeErrorCode = (code: number) => { ...@@ -938,23 +935,11 @@ const removeErrorCode = (code: number) => {
} }
const buildModelMappingObject = (): Record<string, string> | null => { const buildModelMappingObject = (): Record<string, string> | null => {
const mapping: Record<string, string> = {} return buildModelMappingPayload(
modelRestrictionMode.value,
if (modelRestrictionMode.value === 'whitelist') { allowedModels.value,
for (const model of allowedModels.value) { modelMappings.value
mapping[model] = model )
}
} else {
for (const m of modelMappings.value) {
const from = m.from.trim()
const to = m.to.trim()
if (from && to) {
mapping[from] = to
}
}
}
return Object.keys(mapping).length > 0 ? mapping : null
} }
const buildUpdatePayload = (): Record<string, unknown> | null => { const buildUpdatePayload = (): Record<string, unknown> | null => {
......
...@@ -916,8 +916,8 @@ ...@@ -916,8 +916,8 @@
<p class="input-hint">{{ t('admin.accounts.gemini.tier.aiStudioHint') }}</p> <p class="input-hint">{{ t('admin.accounts.gemini.tier.aiStudioHint') }}</p>
</div> </div>
<!-- Model Restriction Section (不适用于 Gemini,Antigravity 已在上层条件排除) --> <!-- Model Restriction Section (Antigravity 已在上层条件排除) -->
<div v-if="form.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label> <label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<div <div
...@@ -1200,34 +1200,6 @@ ...@@ -1200,34 +1200,6 @@
</div> </div>
</div> </div>
<!-- Gemini 模型说明 -->
<div v-if="form.platform === 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="flex items-start gap-3">
<svg
class="h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.modelPassthrough') }}
</p>
<p class="mt-1 text-xs text-blue-700 dark:text-blue-400">
{{ t('admin.accounts.gemini.modelPassthroughDesc') }}
</p>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Temp Unschedulable Rules --> <!-- Temp Unschedulable Rules -->
...@@ -1378,9 +1350,9 @@ ...@@ -1378,9 +1350,9 @@
</div> </div>
</div> </div>
<!-- Intercept Warmup Requests (Anthropic only) --> <!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
<div <div
v-if="form.platform === 'anthropic'" v-if="form.platform === 'anthropic' || form.platform === 'antigravity'"
class="border-t border-gray-200 pt-4 dark:border-dark-600" class="border-t border-gray-200 pt-4 dark:border-dark-600"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
...@@ -2157,7 +2129,7 @@ ...@@ -2157,7 +2129,7 @@
<ConfirmDialog <ConfirmDialog
:show="showMixedChannelWarning" :show="showMixedChannelWarning"
:title="t('admin.accounts.mixedChannelWarningTitle')" :title="t('admin.accounts.mixedChannelWarningTitle')"
:message="mixedChannelWarningDetails ? t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails) : ''" :message="mixedChannelWarningMessageText"
:confirm-text="t('common.confirm')" :confirm-text="t('common.confirm')"
:cancel-text="t('common.cancel')" :cancel-text="t('common.cancel')"
:danger="true" :danger="true"
...@@ -2189,13 +2161,21 @@ import { ...@@ -2189,13 +2161,21 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth' import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth' import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Proxy, AdminGroup, AccountPlatform, AccountType } from '@/types' import type {
Proxy,
AdminGroup,
AccountPlatform,
AccountType,
CheckMixedChannelResponse,
CreateAccountRequest
} from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
...@@ -2337,10 +2317,13 @@ const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm> ...@@ -2337,10 +2317,13 @@ const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one') const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
const geminiAIStudioOAuthEnabled = ref(false) const geminiAIStudioOAuthEnabled = ref(false)
// Mixed channel warning dialog state
const showMixedChannelWarning = ref(false) const showMixedChannelWarning = ref(false)
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(null) const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
const pendingCreatePayload = ref<any>(null) null
)
const mixedChannelWarningRawMessage = ref('')
const mixedChannelWarningAction = ref<(() => Promise<void>) | null>(null)
const antigravityMixedChannelConfirmed = ref(false)
const showAdvancedOAuth = ref(false) const showAdvancedOAuth = ref(false)
const showGeminiHelpDialog = ref(false) const showGeminiHelpDialog = ref(false)
...@@ -2378,6 +2361,13 @@ const isOpenAIModelRestrictionDisabled = computed(() => ...@@ -2378,6 +2361,13 @@ const isOpenAIModelRestrictionDisabled = computed(() =>
form.platform === 'openai' && openaiPassthroughEnabled.value form.platform === 'openai' && openaiPassthroughEnabled.value
) )
const mixedChannelWarningMessageText = computed(() => {
if (mixedChannelWarningDetails.value) {
return t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails.value)
}
return mixedChannelWarningRawMessage.value
})
const geminiQuotaDocs = { const geminiQuotaDocs = {
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas', codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
aiStudio: 'https://ai.google.dev/pricing', aiStudio: 'https://ai.google.dev/pricing',
...@@ -2544,8 +2534,8 @@ watch( ...@@ -2544,8 +2534,8 @@ watch(
antigravityModelMappings.value = [] antigravityModelMappings.value = []
antigravityModelRestrictionMode.value = 'mapping' antigravityModelRestrictionMode.value = 'mapping'
} }
// Reset Anthropic-specific settings when switching to other platforms // Reset Anthropic/Antigravity-specific settings when switching to other platforms
if (newPlatform !== 'anthropic') { if (newPlatform !== 'anthropic' && newPlatform !== 'antigravity') {
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
} }
if (newPlatform === 'sora') { if (newPlatform === 'sora') {
...@@ -2794,6 +2784,105 @@ const splitTempUnschedKeywords = (value: string) => { ...@@ -2794,6 +2784,105 @@ const splitTempUnschedKeywords = (value: string) => {
.filter((item) => item.length > 0) .filter((item) => item.length > 0)
} }
const needsMixedChannelCheck = (platform: AccountPlatform) => platform === 'antigravity' || platform === 'anthropic'
const buildMixedChannelDetails = (resp?: CheckMixedChannelResponse) => {
const details = resp?.details
if (!details) {
return null
}
return {
groupName: details.group_name || 'Unknown',
currentPlatform: details.current_platform || 'Unknown',
otherPlatform: details.other_platform || 'Unknown'
}
}
const clearMixedChannelDialog = () => {
showMixedChannelWarning.value = false
mixedChannelWarningDetails.value = null
mixedChannelWarningRawMessage.value = ''
mixedChannelWarningAction.value = null
}
const openMixedChannelDialog = (opts: {
response?: CheckMixedChannelResponse
message?: string
onConfirm: () => Promise<void>
}) => {
mixedChannelWarningDetails.value = buildMixedChannelDetails(opts.response)
mixedChannelWarningRawMessage.value =
opts.message || opts.response?.message || t('admin.accounts.failedToCreate')
mixedChannelWarningAction.value = opts.onConfirm
showMixedChannelWarning.value = true
}
const withAntigravityConfirmFlag = (payload: CreateAccountRequest): CreateAccountRequest => {
if (needsMixedChannelCheck(payload.platform) && antigravityMixedChannelConfirmed.value) {
return {
...payload,
confirm_mixed_channel_risk: true
}
}
const cloned = { ...payload }
delete cloned.confirm_mixed_channel_risk
return cloned
}
const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<void>): Promise<boolean> => {
if (!needsMixedChannelCheck(form.platform)) {
return true
}
if (antigravityMixedChannelConfirmed.value) {
return true
}
try {
const result = await adminAPI.accounts.checkMixedChannelRisk({
platform: form.platform,
group_ids: form.group_ids
})
if (!result.has_risk) {
return true
}
openMixedChannelDialog({
response: result,
onConfirm: async () => {
antigravityMixedChannelConfirmed.value = true
await onConfirm()
}
})
return false
} catch (error: any) {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToCreate'))
return false
}
}
const submitCreateAccount = async (payload: CreateAccountRequest) => {
submitting.value = true
try {
await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning' && needsMixedChannelCheck(form.platform)) {
openMixedChannelDialog({
message: error.response?.data?.message,
onConfirm: async () => {
antigravityMixedChannelConfirmed.value = true
await submitCreateAccount(payload)
}
})
return
}
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToCreate'))
} finally {
submitting.value = false
}
}
// Methods // Methods
const resetForm = () => { const resetForm = () => {
step.value = 1 step.value = 1
...@@ -2855,9 +2944,13 @@ const resetForm = () => { ...@@ -2855,9 +2944,13 @@ const resetForm = () => {
geminiOAuth.resetState() geminiOAuth.resetState()
antigravityOAuth.resetState() antigravityOAuth.resetState()
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
antigravityMixedChannelConfirmed.value = false
clearMixedChannelDialog()
} }
const handleClose = () => { const handleClose = () => {
antigravityMixedChannelConfirmed.value = false
clearMixedChannelDialog()
emit('close') emit('close')
} }
...@@ -2916,56 +3009,34 @@ const buildSoraExtra = ( ...@@ -2916,56 +3009,34 @@ const buildSoraExtra = (
} }
// Helper function to create account with mixed channel warning handling // Helper function to create account with mixed channel warning handling
const doCreateAccount = async (payload: any) => { const doCreateAccount = async (payload: CreateAccountRequest) => {
submitting.value = true const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
try { await submitCreateAccount(payload)
await adminAPI.accounts.create(payload) })
appStore.showSuccess(t('admin.accounts.accountCreated')) if (!canContinue) {
emit('created') return
handleClose()
} catch (error: any) {
// Handle 409 mixed_channel_warning - show confirmation dialog
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning') {
const details = error.response.data.details || {}
mixedChannelWarningDetails.value = {
groupName: details.group_name || 'Unknown',
currentPlatform: details.current_platform || 'Unknown',
otherPlatform: details.other_platform || 'Unknown'
}
pendingCreatePayload.value = payload
showMixedChannelWarning.value = true
} else {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
}
} finally {
submitting.value = false
} }
await submitCreateAccount(payload)
} }
// Handle mixed channel warning confirmation // Handle mixed channel warning confirmation
const handleMixedChannelConfirm = async () => { const handleMixedChannelConfirm = async () => {
showMixedChannelWarning.value = false const action = mixedChannelWarningAction.value
if (pendingCreatePayload.value) { if (!action) {
pendingCreatePayload.value.confirm_mixed_channel_risk = true clearMixedChannelDialog()
return
}
clearMixedChannelDialog()
submitting.value = true submitting.value = true
try { try {
await adminAPI.accounts.create(pendingCreatePayload.value) await action()
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
} finally { } finally {
submitting.value = false submitting.value = false
pendingCreatePayload.value = null
}
} }
} }
const handleMixedChannelCancel = () => { const handleMixedChannelCancel = () => {
showMixedChannelWarning.value = false clearMixedChannelDialog()
pendingCreatePayload.value = null
mixedChannelWarningDetails.value = null
} }
const handleSubmit = async () => { const handleSubmit = async () => {
...@@ -2975,6 +3046,12 @@ const handleSubmit = async () => { ...@@ -2975,6 +3046,12 @@ const handleSubmit = async () => {
appStore.showError(t('admin.accounts.pleaseEnterAccountName')) appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return return
} }
const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
step.value = 2
})
if (!canContinue) {
return
}
step.value = 2 step.value = 2
return return
} }
...@@ -3010,15 +3087,10 @@ const handleSubmit = async () => { ...@@ -3010,15 +3087,10 @@ const handleSubmit = async () => {
credentials.model_mapping = antigravityModelMapping credentials.model_mapping = antigravityModelMapping
} }
submitting.value = true applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
try {
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined
await createAccountAndFinish(form.platform, 'apikey', credentials, extra) await createAccountAndFinish(form.platform, 'apikey', credentials, extra)
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
} finally {
submitting.value = false
}
return return
} }
...@@ -3059,10 +3131,7 @@ const handleSubmit = async () => { ...@@ -3059,10 +3131,7 @@ const handleSubmit = async () => {
credentials.custom_error_codes = [...selectedErrorCodes.value] credentials.custom_error_codes = [...selectedErrorCodes.value]
} }
// Add intercept warmup requests setting applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
if (interceptWarmupRequests.value) {
credentials.intercept_warmup_requests = true
}
if (!applyTempUnschedConfig(credentials)) { if (!applyTempUnschedConfig(credentials)) {
return return
} }
...@@ -3132,7 +3201,7 @@ const createAccountAndFinish = async ( ...@@ -3132,7 +3201,7 @@ const createAccountAndFinish = async (
if (!applyTempUnschedConfig(credentials)) { if (!applyTempUnschedConfig(credentials)) {
return return
} }
await adminAPI.accounts.create({ await doCreateAccount({
name: form.name, name: form.name,
notes: form.notes, notes: form.notes,
platform, platform,
...@@ -3147,9 +3216,6 @@ const createAccountAndFinish = async ( ...@@ -3147,9 +3216,6 @@ const createAccountAndFinish = async (
expires_at: form.expires_at, expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value auto_pause_on_expired: autoPauseOnExpired.value
}) })
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} }
// OpenAI OAuth 授权码兑换 // OpenAI OAuth 授权码兑换
...@@ -3497,7 +3563,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => { ...@@ -3497,7 +3563,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
// Note: Antigravity doesn't have buildExtraInfo, so we pass empty extra or rely on credentials // Note: Antigravity doesn't have buildExtraInfo, so we pass empty extra or rely on credentials
await adminAPI.accounts.create({ const createPayload = withAntigravityConfirmFlag({
name: accountName, name: accountName,
notes: form.notes, notes: form.notes,
platform: 'antigravity', platform: 'antigravity',
...@@ -3512,6 +3578,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => { ...@@ -3512,6 +3578,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
expires_at: form.expires_at, expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value auto_pause_on_expired: autoPauseOnExpired.value
}) })
await adminAPI.accounts.create(createPayload)
successCount++ successCount++
} catch (error: any) { } catch (error: any) {
failedCount++ failedCount++
...@@ -3606,6 +3673,7 @@ const handleAntigravityExchange = async (authCode: string) => { ...@@ -3606,6 +3673,7 @@ const handleAntigravityExchange = async (authCode: string) => {
if (!tokenInfo) return if (!tokenInfo) return
const credentials = antigravityOAuth.buildCredentials(tokenInfo) const credentials = antigravityOAuth.buildCredentials(tokenInfo)
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
// Antigravity 只使用映射模式 // Antigravity 只使用映射模式
const antigravityModelMapping = buildModelMappingObject( const antigravityModelMapping = buildModelMappingObject(
'mapping', 'mapping',
...@@ -3677,10 +3745,8 @@ const handleAnthropicExchange = async (authCode: string) => { ...@@ -3677,10 +3745,8 @@ const handleAnthropicExchange = async (authCode: string) => {
extra.cache_ttl_override_target = cacheTTLOverrideTarget.value extra.cache_ttl_override_target = cacheTTLOverrideTarget.value
} }
const credentials = { const credentials: Record<string, unknown> = { ...tokenInfo }
...tokenInfo, applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
}
await createAccountAndFinish(form.platform, addMethod.value as AccountType, credentials, extra) await createAccountAndFinish(form.platform, addMethod.value as AccountType, credentials, extra)
} catch (error: any) { } catch (error: any) {
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
...@@ -3779,11 +3845,8 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -3779,11 +3845,8 @@ const handleCookieAuth = async (sessionKey: string) => {
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
// Merge interceptWarmupRequests into credentials const credentials: Record<string, unknown> = { ...tokenInfo }
const credentials: Record<string, unknown> = { applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
...tokenInfo,
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
}
if (tempUnschedEnabled.value) { if (tempUnschedEnabled.value) {
credentials.temp_unschedulable_enabled = true credentials.temp_unschedulable_enabled = true
credentials.temp_unschedulable_rules = tempUnschedPayload credentials.temp_unschedulable_rules = tempUnschedPayload
......
...@@ -65,8 +65,8 @@ ...@@ -65,8 +65,8 @@
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p> <p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
</div> </div>
<!-- Model Restriction Section (不适用于 Gemini 和 Antigravity) --> <!-- Model Restriction Section (不适用于 Antigravity) -->
<div v-if="account.platform !== 'gemini' && account.platform !== 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div v-if="account.platform !== 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label> <label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<div <div
...@@ -349,34 +349,6 @@ ...@@ -349,34 +349,6 @@
</div> </div>
</div> </div>
<!-- Gemini 模型说明 -->
<div v-if="account.platform === 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="flex items-start gap-3">
<svg
class="h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.modelPassthrough') }}
</p>
<p class="mt-1 text-xs text-blue-700 dark:text-blue-400">
{{ t('admin.accounts.gemini.modelPassthroughDesc') }}
</p>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Upstream fields (only for upstream type) --> <!-- Upstream fields (only for upstream type) -->
...@@ -641,9 +613,9 @@ ...@@ -641,9 +613,9 @@
</div> </div>
</div> </div>
<!-- Intercept Warmup Requests (Anthropic only) --> <!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
<div <div
v-if="account?.platform === 'anthropic'" v-if="account?.platform === 'anthropic' || account?.platform === 'antigravity'"
class="border-t border-gray-200 pt-4 dark:border-dark-600" class="border-t border-gray-200 pt-4 dark:border-dark-600"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
...@@ -1139,7 +1111,7 @@ ...@@ -1139,7 +1111,7 @@
<ConfirmDialog <ConfirmDialog
:show="showMixedChannelWarning" :show="showMixedChannelWarning"
:title="t('admin.accounts.mixedChannelWarningTitle')" :title="t('admin.accounts.mixedChannelWarningTitle')"
:message="mixedChannelWarningDetails ? t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails) : ''" :message="mixedChannelWarningMessageText"
:confirm-text="t('common.confirm')" :confirm-text="t('common.confirm')"
:cancel-text="t('common.cancel')" :cancel-text="t('common.cancel')"
:danger="true" :danger="true"
...@@ -1154,7 +1126,7 @@ import { useI18n } from 'vue-i18n' ...@@ -1154,7 +1126,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, Proxy, AdminGroup } from '@/types' import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
...@@ -1162,6 +1134,7 @@ import Icon from '@/components/icons/Icon.vue' ...@@ -1162,6 +1134,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { import {
...@@ -1233,10 +1206,13 @@ const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-mod ...@@ -1233,10 +1206,13 @@ const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-mod
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping') const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule') const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
// Mixed channel warning dialog state
const showMixedChannelWarning = ref(false) const showMixedChannelWarning = ref(false)
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(null) const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
const pendingUpdatePayload = ref<Record<string, unknown> | null>(null) null
)
const mixedChannelWarningRawMessage = ref('')
const mixedChannelWarningAction = ref<(() => Promise<void>) | null>(null)
const antigravityMixedChannelConfirmed = ref(false)
// Quota control state (Anthropic OAuth/SetupToken only) // Quota control state (Anthropic OAuth/SetupToken only)
const windowCostEnabled = ref(false) const windowCostEnabled = ref(false)
...@@ -1297,6 +1273,13 @@ const defaultBaseUrl = computed(() => { ...@@ -1297,6 +1273,13 @@ const defaultBaseUrl = computed(() => {
return 'https://api.anthropic.com' return 'https://api.anthropic.com'
}) })
const mixedChannelWarningMessageText = computed(() => {
if (mixedChannelWarningDetails.value) {
return t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails.value)
}
return mixedChannelWarningRawMessage.value
})
const form = reactive({ const form = reactive({
name: '', name: '',
notes: '', notes: '',
...@@ -1326,6 +1309,11 @@ watch( ...@@ -1326,6 +1309,11 @@ watch(
() => props.account, () => props.account,
(newAccount) => { (newAccount) => {
if (newAccount) { if (newAccount) {
antigravityMixedChannelConfirmed.value = false
showMixedChannelWarning.value = false
mixedChannelWarningDetails.value = null
mixedChannelWarningRawMessage.value = ''
mixedChannelWarningAction.value = null
form.name = newAccount.name form.name = newAccount.name
form.notes = newAccount.notes || '' form.notes = newAccount.notes || ''
form.proxy_id = newAccount.proxy_id form.proxy_id = newAccount.proxy_id
...@@ -1725,18 +1713,123 @@ function toPositiveNumber(value: unknown) { ...@@ -1725,18 +1713,123 @@ function toPositiveNumber(value: unknown) {
return Math.trunc(num) return Math.trunc(num)
} }
const needsMixedChannelCheck = () => props.account?.platform === 'antigravity' || props.account?.platform === 'anthropic'
const buildMixedChannelDetails = (resp?: CheckMixedChannelResponse) => {
const details = resp?.details
if (!details) {
return null
}
return {
groupName: details.group_name || 'Unknown',
currentPlatform: details.current_platform || 'Unknown',
otherPlatform: details.other_platform || 'Unknown'
}
}
const clearMixedChannelDialog = () => {
showMixedChannelWarning.value = false
mixedChannelWarningDetails.value = null
mixedChannelWarningRawMessage.value = ''
mixedChannelWarningAction.value = null
}
const openMixedChannelDialog = (opts: {
response?: CheckMixedChannelResponse
message?: string
onConfirm: () => Promise<void>
}) => {
mixedChannelWarningDetails.value = buildMixedChannelDetails(opts.response)
mixedChannelWarningRawMessage.value =
opts.message || opts.response?.message || t('admin.accounts.failedToUpdate')
mixedChannelWarningAction.value = opts.onConfirm
showMixedChannelWarning.value = true
}
const withAntigravityConfirmFlag = (payload: Record<string, unknown>) => {
if (needsMixedChannelCheck() && antigravityMixedChannelConfirmed.value) {
return {
...payload,
confirm_mixed_channel_risk: true
}
}
const cloned = { ...payload }
delete cloned.confirm_mixed_channel_risk
return cloned
}
const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<void>): Promise<boolean> => {
if (!needsMixedChannelCheck()) {
return true
}
if (antigravityMixedChannelConfirmed.value) {
return true
}
if (!props.account) {
return false
}
try {
const result = await adminAPI.accounts.checkMixedChannelRisk({
platform: props.account.platform,
group_ids: form.group_ids,
account_id: props.account.id
})
if (!result.has_risk) {
return true
}
openMixedChannelDialog({
response: result,
onConfirm: async () => {
antigravityMixedChannelConfirmed.value = true
await onConfirm()
}
})
return false
} catch (error: any) {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
return false
}
}
const formatDateTimeLocal = formatDateTimeLocalInput const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput const parseDateTimeLocal = parseDateTimeLocalInput
// Methods // Methods
const handleClose = () => { const handleClose = () => {
antigravityMixedChannelConfirmed.value = false
clearMixedChannelDialog()
emit('close') emit('close')
} }
const submitUpdateAccount = async (accountID: number, updatePayload: Record<string, unknown>) => {
submitting.value = true
try {
const updatedAccount = await adminAPI.accounts.update(accountID, withAntigravityConfirmFlag(updatePayload))
appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated', updatedAccount)
handleClose()
} catch (error: any) {
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning' && needsMixedChannelCheck()) {
openMixedChannelDialog({
message: error.response?.data?.message,
onConfirm: async () => {
antigravityMixedChannelConfirmed.value = true
await submitUpdateAccount(accountID, updatePayload)
}
})
return
}
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
} finally {
submitting.value = false
}
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (!props.account) return if (!props.account) return
const accountID = props.account.id
submitting.value = true
const updatePayload: Record<string, unknown> = { ...form } const updatePayload: Record<string, unknown> = { ...form }
try { try {
// 后端期望 proxy_id: 0 表示清除代理,而不是 null // 后端期望 proxy_id: 0 表示清除代理,而不是 null
...@@ -1768,7 +1861,6 @@ const handleSubmit = async () => { ...@@ -1768,7 +1861,6 @@ const handleSubmit = async () => {
newCredentials.api_key = currentCredentials.api_key newCredentials.api_key = currentCredentials.api_key
} else { } else {
appStore.showError(t('admin.accounts.apiKeyIsRequired')) appStore.showError(t('admin.accounts.apiKeyIsRequired'))
submitting.value = false
return return
} }
...@@ -1789,11 +1881,8 @@ const handleSubmit = async () => { ...@@ -1789,11 +1881,8 @@ const handleSubmit = async () => {
} }
// Add intercept warmup requests setting // Add intercept warmup requests setting
if (interceptWarmupRequests.value) { applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
newCredentials.intercept_warmup_requests = true
}
if (!applyTempUnschedConfig(newCredentials)) { if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return return
} }
...@@ -1808,8 +1897,10 @@ const handleSubmit = async () => { ...@@ -1808,8 +1897,10 @@ const handleSubmit = async () => {
newCredentials.api_key = editApiKey.value.trim() newCredentials.api_key = editApiKey.value.trim()
} }
// Add intercept warmup requests setting
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
if (!applyTempUnschedConfig(newCredentials)) { if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return return
} }
...@@ -1819,13 +1910,8 @@ const handleSubmit = async () => { ...@@ -1819,13 +1910,8 @@ const handleSubmit = async () => {
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {} const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
const newCredentials: Record<string, unknown> = { ...currentCredentials } const newCredentials: Record<string, unknown> = { ...currentCredentials }
if (interceptWarmupRequests.value) { applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
newCredentials.intercept_warmup_requests = true
} else {
delete newCredentials.intercept_warmup_requests
}
if (!applyTempUnschedConfig(newCredentials)) { if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return return
} }
...@@ -1955,52 +2041,36 @@ const handleSubmit = async () => { ...@@ -1955,52 +2041,36 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra updatePayload.extra = newExtra
} }
const updatedAccount = await adminAPI.accounts.update(props.account.id, updatePayload) const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
appStore.showSuccess(t('admin.accounts.accountUpdated')) await submitUpdateAccount(accountID, updatePayload)
emit('updated', updatedAccount) })
handleClose() if (!canContinue) {
} catch (error: any) { return
// Handle 409 mixed_channel_warning - show confirmation dialog
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning') {
const details = error.response.data.details || {}
mixedChannelWarningDetails.value = {
groupName: details.group_name || 'Unknown',
currentPlatform: details.current_platform || 'Unknown',
otherPlatform: details.other_platform || 'Unknown'
} }
pendingUpdatePayload.value = updatePayload
showMixedChannelWarning.value = true await submitUpdateAccount(accountID, updatePayload)
} else { } catch (error: any) {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate')) appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
} }
} finally {
submitting.value = false
}
} }
// Handle mixed channel warning confirmation // Handle mixed channel warning confirmation
const handleMixedChannelConfirm = async () => { const handleMixedChannelConfirm = async () => {
showMixedChannelWarning.value = false const action = mixedChannelWarningAction.value
if (pendingUpdatePayload.value && props.account) { if (!action) {
pendingUpdatePayload.value.confirm_mixed_channel_risk = true clearMixedChannelDialog()
return
}
clearMixedChannelDialog()
submitting.value = true submitting.value = true
try { try {
const updatedAccount = await adminAPI.accounts.update(props.account.id, pendingUpdatePayload.value) await action()
appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated', updatedAccount)
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
} finally { } finally {
submitting.value = false submitting.value = false
pendingUpdatePayload.value = null
}
} }
} }
const handleMixedChannelCancel = () => { const handleMixedChannelCancel = () => {
showMixedChannelWarning.value = false clearMixedChannelDialog()
pendingUpdatePayload.value = null
mixedChannelWarningDetails.value = null
} }
</script> </script>
import { describe, it, expect } from 'vitest'
import { applyInterceptWarmup } from '../credentialsBuilder'
describe('applyInterceptWarmup', () => {
it('create + enabled=true: should set intercept_warmup_requests to true', () => {
const creds: Record<string, unknown> = { access_token: 'tok' }
applyInterceptWarmup(creds, true, 'create')
expect(creds.intercept_warmup_requests).toBe(true)
})
it('create + enabled=false: should not add the field', () => {
const creds: Record<string, unknown> = { access_token: 'tok' }
applyInterceptWarmup(creds, false, 'create')
expect('intercept_warmup_requests' in creds).toBe(false)
})
it('edit + enabled=true: should set intercept_warmup_requests to true', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyInterceptWarmup(creds, true, 'edit')
expect(creds.intercept_warmup_requests).toBe(true)
})
it('edit + enabled=false + field exists: should delete the field', () => {
const creds: Record<string, unknown> = { api_key: 'sk', intercept_warmup_requests: true }
applyInterceptWarmup(creds, false, 'edit')
expect('intercept_warmup_requests' in creds).toBe(false)
})
it('edit + enabled=false + field absent: should not throw', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyInterceptWarmup(creds, false, 'edit')
expect('intercept_warmup_requests' in creds).toBe(false)
})
it('should not affect other fields', () => {
const creds: Record<string, unknown> = {
api_key: 'sk',
base_url: 'url',
intercept_warmup_requests: true
}
applyInterceptWarmup(creds, false, 'edit')
expect(creds.api_key).toBe('sk')
expect(creds.base_url).toBe('url')
expect('intercept_warmup_requests' in creds).toBe(false)
})
})
export function applyInterceptWarmup(
credentials: Record<string, unknown>,
enabled: boolean,
mode: 'create' | 'edit'
): void {
if (enabled) {
credentials.intercept_warmup_requests = true
} else if (mode === 'edit') {
delete credentials.intercept_warmup_requests
}
}
...@@ -76,6 +76,7 @@ const antigravityModels = [ ...@@ -76,6 +76,7 @@ const antigravityModels = [
// Claude 4.5+ 系列 // Claude 4.5+ 系列
'claude-opus-4-6', 'claude-opus-4-6',
'claude-opus-4-5-thinking', 'claude-opus-4-5-thinking',
'claude-sonnet-4-6',
'claude-sonnet-4-5', 'claude-sonnet-4-5',
'claude-sonnet-4-5-thinking', 'claude-sonnet-4-5-thinking',
// Gemini 2.5 系列 // Gemini 2.5 系列
...@@ -88,6 +89,9 @@ const antigravityModels = [ ...@@ -88,6 +89,9 @@ const antigravityModels = [
'gemini-3-pro-high', 'gemini-3-pro-high',
'gemini-3-pro-low', 'gemini-3-pro-low',
'gemini-3-pro-image', 'gemini-3-pro-image',
// Gemini 3.1 系列
'gemini-3.1-pro-high',
'gemini-3.1-pro-low',
// 其他 // 其他
'gpt-oss-120b-medium', 'gpt-oss-120b-medium',
'tab_flash_lite_preview' 'tab_flash_lite_preview'
...@@ -301,6 +305,7 @@ const antigravityPresetMappings = [ ...@@ -301,6 +305,7 @@ const antigravityPresetMappings = [
{ label: '3-Flash透传', from: 'gemini-3-flash', to: 'gemini-3-flash', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' }, { label: '3-Flash透传', from: 'gemini-3-flash', to: 'gemini-3-flash', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
{ label: '2.5-Flash-Lite透传', from: 'gemini-2.5-flash-lite', to: 'gemini-2.5-flash-lite', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' }, { label: '2.5-Flash-Lite透传', from: 'gemini-2.5-flash-lite', to: 'gemini-2.5-flash-lite', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
// 精确映射 // 精确映射
{ 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: '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-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' }
] ]
......
...@@ -2047,7 +2047,7 @@ export default { ...@@ -2047,7 +2047,7 @@ export default {
gemini3Pro: 'G3P', gemini3Pro: 'G3P',
gemini3Flash: 'G3F', gemini3Flash: 'G3F',
gemini3Image: 'G3I', gemini3Image: 'G3I',
claude45: 'C4.5' claude: 'Claude'
}, },
tier: { tier: {
free: 'Free', free: 'Free',
......
...@@ -1583,7 +1583,7 @@ export default { ...@@ -1583,7 +1583,7 @@ export default {
gemini3Pro: 'G3P', gemini3Pro: 'G3P',
gemini3Flash: 'G3F', gemini3Flash: 'G3F',
gemini3Image: 'G3I', gemini3Image: 'G3I',
claude45: 'C4.5' claude: 'Claude'
}, },
tier: { tier: {
free: 'Free', free: 'Free',
......
...@@ -581,6 +581,7 @@ export interface GeminiCredentials { ...@@ -581,6 +581,7 @@ export interface GeminiCredentials {
token_type?: string token_type?: string
scope?: string scope?: string
expires_at?: string expires_at?: string
model_mapping?: Record<string, string>
} }
export interface TempUnschedulableRule { export interface TempUnschedulableRule {
...@@ -766,6 +767,26 @@ export interface UpdateAccountRequest { ...@@ -766,6 +767,26 @@ export interface UpdateAccountRequest {
confirm_mixed_channel_risk?: boolean confirm_mixed_channel_risk?: boolean
} }
export interface CheckMixedChannelRequest {
platform: AccountPlatform
group_ids: number[]
account_id?: number
}
export interface MixedChannelWarningDetails {
group_id: number
group_name: string
current_platform: string
other_platform: string
}
export interface CheckMixedChannelResponse {
has_risk: boolean
error?: string
message?: string
details?: MixedChannelWarningDetails
}
export interface CreateProxyRequest { export interface CreateProxyRequest {
name: string name: string
protocol: ProxyProtocol protocol: ProxyProtocol
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment