Unverified Commit dd96ada3 authored by 程序猿MT's avatar 程序猿MT Committed by GitHub
Browse files

Merge branch 'Wei-Shaw:main' into main

parents 31fe0178 8f397548
...@@ -23,7 +23,7 @@ func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) { ...@@ -23,7 +23,7 @@ func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) {
"tool_choice": "auto", "tool_choice": "auto",
} }
applyCodexOAuthTransform(reqBody) applyCodexOAuthTransform(reqBody, false)
// 未显式设置 store=true,默认为 false。 // 未显式设置 store=true,默认为 false。
store, ok := reqBody["store"].(bool) store, ok := reqBody["store"].(bool)
...@@ -59,7 +59,7 @@ func TestApplyCodexOAuthTransform_ExplicitStoreFalsePreserved(t *testing.T) { ...@@ -59,7 +59,7 @@ func TestApplyCodexOAuthTransform_ExplicitStoreFalsePreserved(t *testing.T) {
"tool_choice": "auto", "tool_choice": "auto",
} }
applyCodexOAuthTransform(reqBody) applyCodexOAuthTransform(reqBody, false)
store, ok := reqBody["store"].(bool) store, ok := reqBody["store"].(bool)
require.True(t, ok) require.True(t, ok)
...@@ -79,7 +79,7 @@ func TestApplyCodexOAuthTransform_ExplicitStoreTrueForcedFalse(t *testing.T) { ...@@ -79,7 +79,7 @@ func TestApplyCodexOAuthTransform_ExplicitStoreTrueForcedFalse(t *testing.T) {
"tool_choice": "auto", "tool_choice": "auto",
} }
applyCodexOAuthTransform(reqBody) applyCodexOAuthTransform(reqBody, false)
store, ok := reqBody["store"].(bool) store, ok := reqBody["store"].(bool)
require.True(t, ok) require.True(t, ok)
...@@ -97,7 +97,7 @@ func TestApplyCodexOAuthTransform_NonContinuationDefaultsStoreFalseAndStripsIDs( ...@@ -97,7 +97,7 @@ func TestApplyCodexOAuthTransform_NonContinuationDefaultsStoreFalseAndStripsIDs(
}, },
} }
applyCodexOAuthTransform(reqBody) applyCodexOAuthTransform(reqBody, false)
store, ok := reqBody["store"].(bool) store, ok := reqBody["store"].(bool)
require.True(t, ok) require.True(t, ok)
...@@ -148,7 +148,7 @@ func TestApplyCodexOAuthTransform_NormalizeCodexTools_PreservesResponsesFunction ...@@ -148,7 +148,7 @@ func TestApplyCodexOAuthTransform_NormalizeCodexTools_PreservesResponsesFunction
}, },
} }
applyCodexOAuthTransform(reqBody) applyCodexOAuthTransform(reqBody, false)
tools, ok := reqBody["tools"].([]any) tools, ok := reqBody["tools"].([]any)
require.True(t, ok) require.True(t, ok)
...@@ -169,7 +169,7 @@ func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) { ...@@ -169,7 +169,7 @@ func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) {
"input": []any{}, "input": []any{},
} }
applyCodexOAuthTransform(reqBody) applyCodexOAuthTransform(reqBody, false)
input, ok := reqBody["input"].([]any) input, ok := reqBody["input"].([]any)
require.True(t, ok) require.True(t, ok)
...@@ -196,3 +196,77 @@ func setupCodexCache(t *testing.T) { ...@@ -196,3 +196,77 @@ func setupCodexCache(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "opencode-codex-header-meta.json"), data, 0o644)) require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "opencode-codex-header-meta.json"), data, 0o644))
} }
func TestApplyCodexOAuthTransform_CodexCLI_PreservesExistingInstructions(t *testing.T) {
// Codex CLI 场景:已有 instructions 时不修改
setupCodexCache(t)
reqBody := map[string]any{
"model": "gpt-5.1",
"instructions": "existing instructions",
}
result := applyCodexOAuthTransform(reqBody, true) // isCodexCLI=true
instructions, ok := reqBody["instructions"].(string)
require.True(t, ok)
require.Equal(t, "existing instructions", instructions)
// Modified 仍可能为 true(因为其他字段被修改),但 instructions 应保持不变
_ = result
}
func TestApplyCodexOAuthTransform_CodexCLI_SuppliesDefaultWhenEmpty(t *testing.T) {
// Codex CLI 场景:无 instructions 时补充默认值
setupCodexCache(t)
reqBody := map[string]any{
"model": "gpt-5.1",
// 没有 instructions 字段
}
result := applyCodexOAuthTransform(reqBody, true) // isCodexCLI=true
instructions, ok := reqBody["instructions"].(string)
require.True(t, ok)
require.NotEmpty(t, instructions)
require.True(t, result.Modified)
}
func TestApplyCodexOAuthTransform_NonCodexCLI_OverridesInstructions(t *testing.T) {
// 非 Codex CLI 场景:使用 opencode 指令覆盖
setupCodexCache(t)
reqBody := map[string]any{
"model": "gpt-5.1",
"instructions": "old instructions",
}
result := applyCodexOAuthTransform(reqBody, false) // isCodexCLI=false
instructions, ok := reqBody["instructions"].(string)
require.True(t, ok)
require.NotEqual(t, "old instructions", instructions)
require.True(t, result.Modified)
}
func TestIsInstructionsEmpty(t *testing.T) {
tests := []struct {
name string
reqBody map[string]any
expected bool
}{
{"missing field", map[string]any{}, true},
{"nil value", map[string]any{"instructions": nil}, true},
{"empty string", map[string]any{"instructions": ""}, true},
{"whitespace only", map[string]any{"instructions": " "}, true},
{"non-string", map[string]any{"instructions": 123}, true},
{"valid string", map[string]any{"instructions": "hello"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isInstructionsEmpty(tt.reqBody)
require.Equal(t, tt.expected, result)
})
}
}
...@@ -796,8 +796,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco ...@@ -796,8 +796,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
} }
} }
if account.Type == AccountTypeOAuth && !isCodexCLI { if account.Type == AccountTypeOAuth {
codexResult := applyCodexOAuthTransform(reqBody) codexResult := applyCodexOAuthTransform(reqBody, isCodexCLI)
if codexResult.Modified { if codexResult.Modified {
bodyModified = true bodyModified = true
} }
...@@ -1681,13 +1681,14 @@ func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel ...@@ -1681,13 +1681,14 @@ func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel
// OpenAIRecordUsageInput input for recording usage // OpenAIRecordUsageInput input for recording usage
type OpenAIRecordUsageInput struct { type OpenAIRecordUsageInput struct {
Result *OpenAIForwardResult Result *OpenAIForwardResult
APIKey *APIKey APIKey *APIKey
User *User User *User
Account *Account Account *Account
Subscription *UserSubscription Subscription *UserSubscription
UserAgent string // 请求的 User-Agent UserAgent string // 请求的 User-Agent
IPAddress string // 请求的客户端 IP 地址 IPAddress string // 请求的客户端 IP 地址
APIKeyService APIKeyQuotaUpdater
} }
// RecordUsage records usage and deducts balance // RecordUsage records usage and deducts balance
...@@ -1799,6 +1800,13 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ...@@ -1799,6 +1800,13 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
} }
} }
// Update API key quota if applicable (only for balance mode with quota set)
if shouldBill && cost.ActualCost > 0 && apiKey.Quota > 0 && input.APIKeyService != nil {
if err := input.APIKeyService.UpdateQuotaUsed(ctx, apiKey.ID, cost.ActualCost); err != nil {
log.Printf("Update API key quota failed: %v", err)
}
}
// Schedule batch update for account last_used_at // Schedule batch update for account last_used_at
s.deferredService.ScheduleLastUsedUpdate(account.ID) s.deferredService.ScheduleLastUsedUpdate(account.ID)
......
...@@ -285,6 +285,11 @@ func (c *OpsMetricsCollector) collectAndPersist(ctx context.Context) error { ...@@ -285,6 +285,11 @@ func (c *OpsMetricsCollector) collectAndPersist(ctx context.Context) error {
return fmt.Errorf("query error counts: %w", err) return fmt.Errorf("query error counts: %w", err)
} }
accountSwitchCount, err := c.queryAccountSwitchCount(ctx, windowStart, windowEnd)
if err != nil {
return fmt.Errorf("query account switch counts: %w", err)
}
windowSeconds := windowEnd.Sub(windowStart).Seconds() windowSeconds := windowEnd.Sub(windowStart).Seconds()
if windowSeconds <= 0 { if windowSeconds <= 0 {
windowSeconds = 60 windowSeconds = 60
...@@ -309,9 +314,10 @@ func (c *OpsMetricsCollector) collectAndPersist(ctx context.Context) error { ...@@ -309,9 +314,10 @@ func (c *OpsMetricsCollector) collectAndPersist(ctx context.Context) error {
Upstream429Count: upstream429, Upstream429Count: upstream429,
Upstream529Count: upstream529, Upstream529Count: upstream529,
TokenConsumed: tokenConsumed, TokenConsumed: tokenConsumed,
QPS: float64Ptr(roundTo1DP(qps)), AccountSwitchCount: accountSwitchCount,
TPS: float64Ptr(roundTo1DP(tps)), QPS: float64Ptr(roundTo1DP(qps)),
TPS: float64Ptr(roundTo1DP(tps)),
DurationP50Ms: duration.p50, DurationP50Ms: duration.p50,
DurationP90Ms: duration.p90, DurationP90Ms: duration.p90,
...@@ -551,6 +557,27 @@ WHERE created_at >= $1 AND created_at < $2` ...@@ -551,6 +557,27 @@ WHERE created_at >= $1 AND created_at < $2`
return errorTotal, businessLimited, errorSLA, upstreamExcl429529, upstream429, upstream529, nil return errorTotal, businessLimited, errorSLA, upstreamExcl429529, upstream429, upstream529, nil
} }
func (c *OpsMetricsCollector) queryAccountSwitchCount(ctx context.Context, start, end time.Time) (int64, error) {
q := `
SELECT
COALESCE(SUM(CASE
WHEN split_part(ev->>'kind', ':', 1) IN ('failover', 'retry_exhausted_failover', 'failover_on_400') THEN 1
ELSE 0
END), 0) AS switch_count
FROM ops_error_logs o
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(NULLIF(o.upstream_errors, 'null'::jsonb), '[]'::jsonb)
) AS ev
WHERE o.created_at >= $1 AND o.created_at < $2
AND o.is_count_tokens = FALSE`
var count int64
if err := c.db.QueryRowContext(ctx, q, start, end).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
type opsCollectedSystemStats struct { type opsCollectedSystemStats struct {
cpuUsagePercent *float64 cpuUsagePercent *float64
memoryUsedMB *int64 memoryUsedMB *int64
......
...@@ -161,7 +161,8 @@ type OpsInsertSystemMetricsInput struct { ...@@ -161,7 +161,8 @@ type OpsInsertSystemMetricsInput struct {
Upstream429Count int64 Upstream429Count int64
Upstream529Count int64 Upstream529Count int64
TokenConsumed int64 TokenConsumed int64
AccountSwitchCount int64
QPS *float64 QPS *float64
TPS *float64 TPS *float64
...@@ -223,8 +224,9 @@ type OpsSystemMetricsSnapshot struct { ...@@ -223,8 +224,9 @@ type OpsSystemMetricsSnapshot struct {
DBConnIdle *int `json:"db_conn_idle"` DBConnIdle *int `json:"db_conn_idle"`
DBConnWaiting *int `json:"db_conn_waiting"` DBConnWaiting *int `json:"db_conn_waiting"`
GoroutineCount *int `json:"goroutine_count"` GoroutineCount *int `json:"goroutine_count"`
ConcurrencyQueueDepth *int `json:"concurrency_queue_depth"` ConcurrencyQueueDepth *int `json:"concurrency_queue_depth"`
AccountSwitchCount *int64 `json:"account_switch_count"`
} }
type OpsUpsertJobHeartbeatInput struct { type OpsUpsertJobHeartbeatInput struct {
......
...@@ -12,6 +12,7 @@ import ( ...@@ -12,6 +12,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/lib/pq" "github.com/lib/pq"
...@@ -476,9 +477,13 @@ func (s *OpsService) executeClientRetry(ctx context.Context, reqType opsRetryReq ...@@ -476,9 +477,13 @@ func (s *OpsService) executeClientRetry(ctx context.Context, reqType opsRetryReq
continue continue
} }
attemptCtx := ctx
if switches > 0 {
attemptCtx = context.WithValue(attemptCtx, ctxkey.AccountSwitchCount, switches)
}
exec := func() *opsRetryExecution { exec := func() *opsRetryExecution {
defer selection.ReleaseFunc() defer selection.ReleaseFunc()
return s.executeWithAccount(ctx, reqType, errorLog, body, account) return s.executeWithAccount(attemptCtx, reqType, errorLog, body, account)
}() }()
if exec != nil { if exec != nil {
......
...@@ -6,6 +6,7 @@ type OpsThroughputTrendPoint struct { ...@@ -6,6 +6,7 @@ type OpsThroughputTrendPoint struct {
BucketStart time.Time `json:"bucket_start"` BucketStart time.Time `json:"bucket_start"`
RequestCount int64 `json:"request_count"` RequestCount int64 `json:"request_count"`
TokenConsumed int64 `json:"token_consumed"` TokenConsumed int64 `json:"token_consumed"`
SwitchCount int64 `json:"switch_count"`
QPS float64 `json:"qps"` QPS float64 `json:"qps"`
TPS float64 `json:"tps"` TPS float64 `json:"tps"`
} }
......
...@@ -39,7 +39,7 @@ type UserRepository interface { ...@@ -39,7 +39,7 @@ type UserRepository interface {
ExistsByEmail(ctx context.Context, email string) (bool, error) ExistsByEmail(ctx context.Context, email string) (bool, error)
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
// TOTP 相关方法 // TOTP 双因素认证
UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error
EnableTotp(ctx context.Context, userID int64) error EnableTotp(ctx context.Context, userID int64) error
DisableTotp(ctx context.Context, userID int64) error DisableTotp(ctx context.Context, userID int64) error
......
-- ops_system_metrics 增加账号切换次数统计(按分钟窗口)
ALTER TABLE ops_system_metrics
ADD COLUMN IF NOT EXISTS account_switch_count BIGINT NOT NULL DEFAULT 0;
-- 043_add_group_invalid_request_fallback.sql
-- 添加无效请求兜底分组配置
-- 添加 fallback_group_id_on_invalid_request 字段:无效请求兜底使用的分组
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS fallback_group_id_on_invalid_request BIGINT REFERENCES groups(id) ON DELETE SET NULL;
-- 添加索引优化查询
CREATE INDEX IF NOT EXISTS idx_groups_fallback_group_id_on_invalid_request
ON groups(fallback_group_id_on_invalid_request) WHERE deleted_at IS NULL AND fallback_group_id_on_invalid_request IS NOT NULL;
-- 添加字段注释
COMMENT ON COLUMN groups.fallback_group_id_on_invalid_request IS '无效请求兜底使用的分组 ID';
-- Add mcp_xml_inject field to groups table (for antigravity platform)
ALTER TABLE groups ADD COLUMN mcp_xml_inject BOOLEAN NOT NULL DEFAULT true;
-- Migration: Add quota fields to api_keys table
-- This migration adds independent quota and expiration support for API keys
-- Add quota limit field (0 = unlimited)
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS quota DECIMAL(20, 8) NOT NULL DEFAULT 0;
-- Add used quota amount field
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS quota_used DECIMAL(20, 8) NOT NULL DEFAULT 0;
-- Add expiration time field (NULL = never expires)
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
-- Add indexes for efficient quota queries
CREATE INDEX IF NOT EXISTS idx_api_keys_quota_quota_used ON api_keys(quota, quota_used) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at) WHERE deleted_at IS NULL;
-- Comment on columns for documentation
COMMENT ON COLUMN api_keys.quota IS 'Quota limit in USD for this API key (0 = unlimited)';
COMMENT ON COLUMN api_keys.quota_used IS 'Used quota amount in USD';
COMMENT ON COLUMN api_keys.expires_at IS 'Expiration time for this API key (null = never expires)';
-- 添加分组支持的模型系列字段
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS supported_model_scopes JSONB NOT NULL
DEFAULT '["claude", "gemini_text", "gemini_image"]'::jsonb;
COMMENT ON COLUMN groups.supported_model_scopes IS '支持的模型系列:claude, gemini_text, gemini_image';
//go:build tools
// +build tools
package tools
import (
_ "entgo.io/ent/cmd/ent"
_ "github.com/google/wire/cmd/wire"
)
-- 修正 schema_migrations 中“本地改名”的迁移文件名
-- 适用场景:你已执行过旧文件名的迁移,合并后仅改了自己这边的文件名
BEGIN;
UPDATE schema_migrations
SET filename = '042b_add_ops_system_metrics_switch_count.sql'
WHERE filename = '042_add_ops_system_metrics_switch_count.sql'
AND NOT EXISTS (
SELECT 1 FROM schema_migrations WHERE filename = '042b_add_ops_system_metrics_switch_count.sql'
);
UPDATE schema_migrations
SET filename = '043b_add_group_invalid_request_fallback.sql'
WHERE filename = '043_add_group_invalid_request_fallback.sql'
AND NOT EXISTS (
SELECT 1 FROM schema_migrations WHERE filename = '043b_add_group_invalid_request_fallback.sql'
);
UPDATE schema_migrations
SET filename = '044b_add_group_mcp_xml_inject.sql'
WHERE filename = '044_add_group_mcp_xml_inject.sql'
AND NOT EXISTS (
SELECT 1 FROM schema_migrations WHERE filename = '044b_add_group_mcp_xml_inject.sql'
);
UPDATE schema_migrations
SET filename = '046b_add_group_supported_model_scopes.sql'
WHERE filename = '046_add_group_supported_model_scopes.sql'
AND NOT EXISTS (
SELECT 1 FROM schema_migrations WHERE filename = '046b_add_group_supported_model_scopes.sql'
);
COMMIT;
...@@ -136,6 +136,7 @@ export interface OpsThroughputTrendPoint { ...@@ -136,6 +136,7 @@ export interface OpsThroughputTrendPoint {
bucket_start: string bucket_start: string
request_count: number request_count: number
token_consumed: number token_consumed: number
switch_count?: number
qps: number qps: number
tps: number tps: number
} }
...@@ -284,6 +285,7 @@ export interface OpsSystemMetricsSnapshot { ...@@ -284,6 +285,7 @@ export interface OpsSystemMetricsSnapshot {
goroutine_count?: number | null goroutine_count?: number | null
concurrency_queue_depth?: number | null concurrency_queue_depth?: number | null
account_switch_count?: number | null
} }
export interface OpsJobHeartbeat { export interface OpsJobHeartbeat {
......
...@@ -44,6 +44,8 @@ export async function getById(id: number): Promise<ApiKey> { ...@@ -44,6 +44,8 @@ export async function getById(id: number): Promise<ApiKey> {
* @param customKey - Optional custom key value * @param customKey - Optional custom key value
* @param ipWhitelist - Optional IP whitelist * @param ipWhitelist - Optional IP whitelist
* @param ipBlacklist - Optional IP blacklist * @param ipBlacklist - Optional IP blacklist
* @param quota - Optional quota limit in USD (0 = unlimited)
* @param expiresInDays - Optional days until expiry (undefined = never expires)
* @returns Created API key * @returns Created API key
*/ */
export async function create( export async function create(
...@@ -51,7 +53,9 @@ export async function create( ...@@ -51,7 +53,9 @@ export async function create(
groupId?: number | null, groupId?: number | null,
customKey?: string, customKey?: string,
ipWhitelist?: string[], ipWhitelist?: string[],
ipBlacklist?: string[] ipBlacklist?: string[],
quota?: number,
expiresInDays?: number
): Promise<ApiKey> { ): Promise<ApiKey> {
const payload: CreateApiKeyRequest = { name } const payload: CreateApiKeyRequest = { name }
if (groupId !== undefined) { if (groupId !== undefined) {
...@@ -66,6 +70,12 @@ export async function create( ...@@ -66,6 +70,12 @@ export async function create(
if (ipBlacklist && ipBlacklist.length > 0) { if (ipBlacklist && ipBlacklist.length > 0) {
payload.ip_blacklist = ipBlacklist payload.ip_blacklist = ipBlacklist
} }
if (quota !== undefined && quota > 0) {
payload.quota = quota
}
if (expiresInDays !== undefined && expiresInDays > 0) {
payload.expires_in_days = expiresInDays
}
const { data } = await apiClient.post<ApiKey>('/keys', payload) const { data } = await apiClient.post<ApiKey>('/keys', payload)
return data return data
......
...@@ -56,7 +56,6 @@ ...@@ -56,7 +56,6 @@
></div> ></div>
</div> </div>
</div> </div>
<!-- Rate Limit Indicator (429) --> <!-- Rate Limit Indicator (429) -->
<div v-if="isRateLimited" class="group relative"> <div v-if="isRateLimited" class="group relative">
<span <span
......
...@@ -614,21 +614,87 @@ ...@@ -614,21 +614,87 @@
</div> </div>
</div> </div>
<!-- Account Type Selection (Antigravity - OAuth only) --> <!-- Account Type Selection (Antigravity - OAuth or Upstream) -->
<div v-if="form.platform === 'antigravity'"> <div v-if="form.platform === 'antigravity'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label> <label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2"> <div class="mt-2 grid grid-cols-2 gap-3">
<div <button
class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20" type="button"
@click="antigravityAccountType = 'oauth'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
antigravityAccountType === 'oauth'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
> >
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-purple-500 text-white"> <div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
antigravityAccountType === 'oauth'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="key" size="sm" /> <Icon name="key" size="sm" />
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.antigravityOauth') }}</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.antigravityOauth') }}</span>
</div> </div>
</div> </button>
<button
type="button"
@click="antigravityAccountType = 'upstream'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
antigravityAccountType === 'upstream'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
antigravityAccountType === 'upstream'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="cloud" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.upstream') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.upstreamDesc') }}</span>
</div>
</button>
</div>
</div>
<!-- Upstream config (only for Antigravity upstream type) -->
<div v-if="form.platform === 'antigravity' && antigravityAccountType === 'upstream'" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.accounts.upstream.baseUrl') }}</label>
<input
v-model="upstreamBaseUrl"
type="text"
required
class="input"
placeholder="https://s.konstants.xyz"
/>
<p class="input-hint">{{ t('admin.accounts.upstream.baseUrlHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.upstream.apiKey') }}</label>
<input
v-model="upstreamApiKey"
type="password"
required
class="input font-mono"
placeholder="sk-..."
/>
<p class="input-hint">{{ t('admin.accounts.upstream.apiKeyHint') }}</p>
</div> </div>
</div> </div>
...@@ -1953,6 +2019,9 @@ const customErrorCodeInput = ref<number | null>(null) ...@@ -1953,6 +2019,9 @@ const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true) const autoPauseOnExpired = ref(true)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
const upstreamBaseUrl = ref('') // For upstream type: base URL
const upstreamApiKey = ref('') // For upstream type: API key
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one') const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
...@@ -2055,7 +2124,13 @@ const form = reactive({ ...@@ -2055,7 +2124,13 @@ const form = reactive({
}) })
// Helper to check if current type needs OAuth flow // Helper to check if current type needs OAuth flow
const isOAuthFlow = computed(() => accountCategory.value === 'oauth-based') const isOAuthFlow = computed(() => {
// Antigravity upstream 类型不需要 OAuth 流程
if (form.platform === 'antigravity' && antigravityAccountType.value === 'upstream') {
return false
}
return accountCategory.value === 'oauth-based'
})
const isManualInputMethod = computed(() => { const isManualInputMethod = computed(() => {
return oauthFlowRef.value?.inputMethod === 'manual' return oauthFlowRef.value?.inputMethod === 'manual'
...@@ -2095,10 +2170,15 @@ watch( ...@@ -2095,10 +2170,15 @@ watch(
} }
) )
// Sync form.type based on accountCategory and addMethod // Sync form.type based on accountCategory, addMethod, and antigravityAccountType
watch( watch(
[accountCategory, addMethod], [accountCategory, addMethod, antigravityAccountType],
([category, method]) => { ([category, method, agType]) => {
// Antigravity upstream 类型
if (form.platform === 'antigravity' && agType === 'upstream') {
form.type = 'upstream'
return
}
if (category === 'oauth-based') { if (category === 'oauth-based') {
form.type = method as AccountType // 'oauth' or 'setup-token' form.type = method as AccountType // 'oauth' or 'setup-token'
} else { } else {
...@@ -2126,9 +2206,10 @@ watch( ...@@ -2126,9 +2206,10 @@ watch(
if (newPlatform !== 'anthropic') { if (newPlatform !== 'anthropic') {
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
} }
// Antigravity only supports OAuth // Antigravity: reset to OAuth by default, but allow upstream selection
if (newPlatform === 'antigravity') { if (newPlatform === 'antigravity') {
accountCategory.value = 'oauth-based' accountCategory.value = 'oauth-based'
antigravityAccountType.value = 'oauth'
} }
// Reset OAuth states // Reset OAuth states
oauth.resetState() oauth.resetState()
...@@ -2361,6 +2442,9 @@ const resetForm = () => { ...@@ -2361,6 +2442,9 @@ const resetForm = () => {
sessionIdleTimeout.value = null sessionIdleTimeout.value = null
tlsFingerprintEnabled.value = false tlsFingerprintEnabled.value = false
sessionIdMaskingEnabled.value = false sessionIdMaskingEnabled.value = false
antigravityAccountType.value = 'oauth'
upstreamBaseUrl.value = ''
upstreamApiKey.value = ''
tempUnschedEnabled.value = false tempUnschedEnabled.value = false
tempUnschedRules.value = [] tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
...@@ -2442,6 +2526,36 @@ const handleSubmit = async () => { ...@@ -2442,6 +2526,36 @@ const handleSubmit = async () => {
return return
} }
// For Antigravity upstream type, create directly
if (form.platform === 'antigravity' && antigravityAccountType.value === 'upstream') {
if (!form.name.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return
}
if (!upstreamBaseUrl.value.trim()) {
appStore.showError(t('admin.accounts.upstream.pleaseEnterBaseUrl'))
return
}
if (!upstreamApiKey.value.trim()) {
appStore.showError(t('admin.accounts.upstream.pleaseEnterApiKey'))
return
}
submitting.value = true
try {
const credentials: Record<string, unknown> = {
base_url: upstreamBaseUrl.value.trim(),
api_key: upstreamApiKey.value.trim()
}
await createAccountAndFinish(form.platform, 'upstream', credentials)
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
} finally {
submitting.value = false
}
return
}
// For apikey type, create directly // For apikey type, create directly
if (!apiKeyValue.value.trim()) { if (!apiKeyValue.value.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterApiKey')) appStore.showError(t('admin.accounts.pleaseEnterApiKey'))
......
...@@ -238,14 +238,14 @@ ...@@ -238,14 +238,14 @@
</Teleport> </Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { formatDateTime, formatReasoningEffort } from '@/utils/format' import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import type { AdminUsageLog } from '@/types' import type { AdminUsageLog } from '@/types'
defineProps(['data', 'loading']) defineProps(['data', 'loading'])
const { t } = useI18n() const { t } = useI18n()
......
...@@ -407,6 +407,7 @@ export default { ...@@ -407,6 +407,7 @@ export default {
usage: 'Usage', usage: 'Usage',
today: 'Today', today: 'Today',
total: 'Total', total: 'Total',
quota: 'Quota',
useKey: 'Use Key', useKey: 'Use Key',
useKeyModal: { useKeyModal: {
title: 'Use API Key', title: 'Use API Key',
...@@ -470,6 +471,33 @@ export default { ...@@ -470,6 +471,33 @@ export default {
geminiCli: 'Gemini CLI', geminiCli: 'Gemini CLI',
geminiCliDesc: 'Import as Gemini CLI configuration', geminiCliDesc: 'Import as Gemini CLI configuration',
}, },
// Quota and expiration
quotaLimit: 'Quota Limit',
quotaAmount: 'Quota Amount (USD)',
quotaAmountPlaceholder: 'Enter quota limit in USD',
quotaAmountHint: 'Set the maximum amount this key can spend. 0 = unlimited.',
quotaUsed: 'Quota Used',
reset: 'Reset',
resetQuotaUsed: 'Reset used quota to 0',
resetQuotaTitle: 'Confirm Reset Quota',
resetQuotaConfirmMessage: 'Are you sure you want to reset the used quota (${used}) for key "{name}" to 0? This action cannot be undone.',
quotaResetSuccess: 'Quota reset successfully',
failedToResetQuota: 'Failed to reset quota',
expiration: 'Expiration',
expiresInDays: '{days} days',
extendDays: '+{days} days',
customDate: 'Custom',
expirationDate: 'Expiration Date',
expirationDateHint: 'Select when this API key should expire.',
currentExpiration: 'Current expiration',
expiresAt: 'Expires',
noExpiration: 'Never',
status: {
active: 'Active',
inactive: 'Inactive',
quota_exhausted: 'Quota Exhausted',
expired: 'Expired',
},
}, },
// Usage // Usage
...@@ -1026,6 +1054,11 @@ export default { ...@@ -1026,6 +1054,11 @@ export default {
fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.', fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.',
noFallback: 'No Fallback (Reject)' noFallback: 'No Fallback (Reject)'
}, },
invalidRequestFallback: {
title: 'Invalid Request Fallback Group',
hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.',
noFallback: 'No Fallback'
},
copyAccounts: { copyAccounts: {
title: 'Copy Accounts from Groups', title: 'Copy Accounts from Groups',
tooltip: 'Select one or more groups of the same platform. After creation, all accounts from these groups will be automatically bound to the new group (deduplicated).', tooltip: 'Select one or more groups of the same platform. After creation, all accounts from these groups will be automatically bound to the new group (deduplicated).',
...@@ -1053,6 +1086,20 @@ export default { ...@@ -1053,6 +1086,20 @@ export default {
noRulesHint: 'Add routing rules to route specific model requests to designated accounts', noRulesHint: 'Add routing rules to route specific model requests to designated accounts',
searchAccountPlaceholder: 'Search accounts...', searchAccountPlaceholder: 'Search accounts...',
accountsHint: 'Select accounts to prioritize for this model pattern' accountsHint: 'Select accounts to prioritize for this model pattern'
},
mcpXml: {
title: 'MCP XML Protocol Injection',
tooltip: 'When enabled, if the request contains MCP tools, an XML format call protocol prompt will be injected into the system prompt. Disable this to avoid interference with certain clients.',
enabled: 'Enabled',
disabled: 'Disabled'
},
supportedScopes: {
title: 'Supported Model Families',
tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.',
claude: 'Claude',
geminiText: 'Gemini Text',
geminiImage: 'Gemini Image',
hint: 'Select at least one model family'
} }
}, },
...@@ -1192,7 +1239,9 @@ export default { ...@@ -1192,7 +1239,9 @@ export default {
responsesApi: 'Responses API', responsesApi: 'Responses API',
googleOauth: 'Google OAuth', googleOauth: 'Google OAuth',
codeAssist: 'Code Assist', codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth' antigravityOauth: 'Antigravity OAuth',
upstream: 'Upstream',
upstreamDesc: 'Connect via Base URL + API Key'
}, },
status: { status: {
active: 'Active', active: 'Active',
...@@ -1452,6 +1501,15 @@ export default { ...@@ -1452,6 +1501,15 @@ export default {
pleaseEnterApiKey: 'Please enter API Key', pleaseEnterApiKey: 'Please enter API Key',
apiKeyIsRequired: 'API Key is required', apiKeyIsRequired: 'API Key is required',
leaveEmptyToKeep: 'Leave empty to keep current key', leaveEmptyToKeep: 'Leave empty to keep current key',
// Upstream type
upstream: {
baseUrl: 'Upstream Base URL',
baseUrlHint: 'The address of the upstream Antigravity service, e.g., https://s.konstants.xyz',
apiKey: 'Upstream API Key',
apiKeyHint: 'API Key for the upstream service',
pleaseEnterBaseUrl: 'Please enter upstream Base URL',
pleaseEnterApiKey: 'Please enter upstream API Key'
},
// OAuth flow // OAuth flow
oauth: { oauth: {
title: 'Claude Account Authorization', title: 'Claude Account Authorization',
...@@ -2202,6 +2260,7 @@ export default { ...@@ -2202,6 +2260,7 @@ export default {
waiting: 'waiting', waiting: 'waiting',
conns: 'conns', conns: 'conns',
queue: 'queue', queue: 'queue',
accountSwitches: 'Account switches',
ok: 'ok', ok: 'ok',
lastRun: 'last_run:', lastRun: 'last_run:',
lastSuccess: 'last_success:', lastSuccess: 'last_success:',
...@@ -2250,6 +2309,7 @@ export default { ...@@ -2250,6 +2309,7 @@ export default {
failedToLoadData: 'Failed to load ops data.', failedToLoadData: 'Failed to load ops data.',
failedToLoadOverview: 'Failed to load overview', failedToLoadOverview: 'Failed to load overview',
failedToLoadThroughputTrend: 'Failed to load throughput trend', failedToLoadThroughputTrend: 'Failed to load throughput trend',
failedToLoadSwitchTrend: 'Failed to load avg account switches trend',
failedToLoadLatencyHistogram: 'Failed to load request duration histogram', failedToLoadLatencyHistogram: 'Failed to load request duration histogram',
failedToLoadErrorTrend: 'Failed to load error trend', failedToLoadErrorTrend: 'Failed to load error trend',
failedToLoadErrorDistribution: 'Failed to load error distribution', failedToLoadErrorDistribution: 'Failed to load error distribution',
...@@ -2258,9 +2318,11 @@ export default { ...@@ -2258,9 +2318,11 @@ export default {
tpsK: 'TPS (K)', tpsK: 'TPS (K)',
top: 'Top:', top: 'Top:',
throughputTrend: 'Throughput Trend', throughputTrend: 'Throughput Trend',
switchRateTrend: 'Avg Account Switches',
latencyHistogram: 'Request Duration Histogram', latencyHistogram: 'Request Duration Histogram',
errorTrend: 'Error Trend', errorTrend: 'Error Trend',
errorDistribution: 'Error Distribution', errorDistribution: 'Error Distribution',
switchRate: 'Avg switches',
// Health Score & Diagnosis // Health Score & Diagnosis
health: 'Health', health: 'Health',
healthCondition: 'Health Condition', healthCondition: 'Health Condition',
...@@ -2883,6 +2945,7 @@ export default { ...@@ -2883,6 +2945,7 @@ export default {
tooltips: { tooltips: {
totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.', totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.',
throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.', throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.',
switchRateTrend: 'Trend of account switches / total requests over the last 5 hours (avg switches).',
latencyHistogram: 'Request duration distribution (ms) for successful requests.', latencyHistogram: 'Request duration distribution (ms) for successful requests.',
errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).', errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).',
errorDistribution: 'Error distribution by status code.', errorDistribution: 'Error distribution by status code.',
......
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