diff --git a/.github/workflows/ci.yml b/.github/workflows/continuous-integration.yml similarity index 74% rename from .github/workflows/ci.yml rename to .github/workflows/continuous-integration.yml index 5c8e599..e02289b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/continuous-integration.yml @@ -1,26 +1,27 @@ name: CI on: + workflow_dispatch: push: branches: [master] pull_request: branches: [master] env: - GOPRIVATE: github.com/DouDOU-start/airgate-sdk + GOPRIVATE: github.com/DevilGenius/airgate-sdk jobs: ci: name: Lint & Test & Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: - version: 11.1.1 + version: 11.5.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm @@ -29,7 +30,7 @@ jobs: - name: Build frontend run: cd web && pnpm install --frozen-lockfile && pnpm run build - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1822940..c005cce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ permissions: contents: write env: - GOPRIVATE: github.com/DouDOU-start/airgate-sdk + GOPRIVATE: github.com/DevilGenius/airgate-sdk jobs: build: @@ -27,13 +27,13 @@ jobs: - goos: darwin goarch: arm64 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: - version: 11.1.1 + version: 11.5.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm @@ -47,7 +47,7 @@ jobs: rm -rf backend/internal/gateway/webdist cp -r web/dist backend/internal/gateway/webdist - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum @@ -63,7 +63,7 @@ jobs: echo "Injecting PluginVersion=${VERSION}" mkdir -p bin cd backend && go build -buildvcs=false -trimpath \ - -ldflags "-X 'github.com/DouDOU-start/airgate-claude/backend/internal/gateway.PluginVersion=${VERSION}'" \ + -ldflags "-X 'github.com/DevilGenius/airgate-claude/backend/internal/gateway.PluginVersion=${VERSION}'" \ -o ../bin/gateway-claude-${{ matrix.goos }}-${{ matrix.goarch }} . - name: Generate SHA256 @@ -85,7 +85,7 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download all artifacts uses: actions/download-artifact@v4 @@ -97,7 +97,7 @@ jobs: run: ls -la dist/ - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} diff --git a/Makefile b/Makefile index 8593691..6acde81 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ lint: ## 代码检查(需要安装 golangci-lint) fmt: ## 格式化代码 @cd backend && \ if command -v goimports > /dev/null 2>&1; then \ - goimports -w -local github.com/DouDOU-start .; \ + goimports -w -local github.com/DevilGenius .; \ else \ $(GO) fmt ./...; \ fi diff --git a/README.md b/README.md index 4bfd13b..60bf4b1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ --- -AirGate Anthropic 是 [airgate-core](https://github.com/DouDOU-start/airgate-core) 的 Claude 网关插件,基于 [airgate-sdk](https://github.com/DouDOU-start/airgate-sdk) 构建。它以 gRPC 子进程方式运行,负责将 Claude Messages API 请求转发到 Anthropic 上游,支持多种认证方式和连接池复用。 +AirGate Anthropic 是 [airgate-core](https://github.com/DevilGenius/airgate-core) 的 Claude 网关插件,基于 [airgate-sdk](https://github.com/DevilGenius/airgate-sdk) 构建。它以 gRPC 子进程方式运行,负责将 Claude Messages API 请求转发到 Anthropic 上游,支持多种认证方式和连接池复用。 ## 核心特性 @@ -20,7 +20,7 @@ AirGate Anthropic 是 [airgate-core](https://github.com/DouDOU-start/airgate-cor - **进程内 Token 刷新锁** — 基于 sync.Mutex 的 per-account 并发保护,double-check 防止重复刷新,不可重试错误自动分类 + 60 秒冷却窗口 - **TLS 指纹伪装** — OAuth/session_key 账号使用 uTLS 模拟 Claude CLI 2.x 的 JA3 指纹,API Key 账号使用标准 TLS - **连接池复用** — 标准 TLS 和 uTLS 指纹各自独立连接池,按 proxyURL 分桶缓存 Transport,避免每次请求创建新连接 -- **精确成本计算** — 插件内按 Anthropic 定价填充 `UsageMetric` / `UsageCostDetail`,支持 standard/priority/flex 计费层级 +- **精确成本计算** — 插件内按 Anthropic 定价填充标准 Usage 成本字段,Claude 专属维度写入 metadata - **流式 Usage 提取** — SSE 流中实时提取 input_tokens、output_tokens、cache_read_input_tokens、reasoning_output_tokens,记录 FirstTokenMs - **Console 批量导入** — 支持通过 Session Key 批量一键创建 OAuth/Setup Token 账号 - **完整前端 Widget** — 四种账号类型的表单面板,OAuth 授权引导、Session Key 一键获取、状态展示 @@ -137,7 +137,7 @@ AirGate Anthropic 是 [airgate-core](https://github.com/DouDOU-start/airgate-cor | 层 | 技术 | |---|---| -| 后端 | Go 1.25 · gRPC · gjson · uTLS(JA3 指纹) | +| 后端 | Go 1.26.3 · gRPC · gjson · uTLS(JA3 指纹) | | 前端 | React 19 · Vite · TypeScript(账号表单 Widget) | | 插件协议 | hashicorp/go-plugin (gRPC) | | 上游协议 | Anthropic Messages API · Anthropic OAuth | @@ -156,12 +156,12 @@ AirGate Anthropic 是 [airgate-core](https://github.com/DouDOU-start/airgate-cor ### 方式 2:源码运行(开发) -需要 Go 1.25+、Node 22+,以及兄弟目录 `airgate-sdk` 与 `airgate-core`: +需要 Go 1.26.3+、Node 22+,以及兄弟目录 `airgate-sdk` 与 `airgate-core`: ```bash -git clone https://github.com/DouDOU-start/airgate-sdk.git -git clone https://github.com/DouDOU-start/airgate-core.git -git clone https://github.com/DouDOU-start/airgate-claude.git +git clone https://github.com/DevilGenius/airgate-sdk.git +git clone https://github.com/DevilGenius/airgate-core.git +git clone https://github.com/DevilGenius/airgate-claude.git cd airgate-claude ``` @@ -225,6 +225,6 @@ airgate-claude/ ## 相关项目 -- [airgate-core](https://github.com/DouDOU-start/airgate-core) — 核心网关(账号调度、计费、管理后台) -- [airgate-sdk](https://github.com/DouDOU-start/airgate-sdk) — 插件 SDK -- [airgate-openai](https://github.com/DouDOU-start/airgate-openai) — OpenAI 网关插件(参考实现) +- [airgate-core](https://github.com/DevilGenius/airgate-core) — 核心网关(账号调度、计费、管理后台) +- [airgate-sdk](https://github.com/DevilGenius/airgate-sdk) — 插件 SDK +- [airgate-openai](https://github.com/DevilGenius/airgate-openai) — OpenAI 网关插件(参考实现) diff --git a/backend/cmd/devserver/main.go b/backend/cmd/devserver/main.go index d51dc67..7dc2d4f 100644 --- a/backend/cmd/devserver/main.go +++ b/backend/cmd/devserver/main.go @@ -6,9 +6,9 @@ import ( "log" "net/http" - "github.com/DouDOU-start/airgate-sdk/devkit/devserver" + "github.com/DevilGenius/airgate-sdk/devkit/devserver" - "github.com/DouDOU-start/airgate-claude/backend/internal/gateway" + "github.com/DevilGenius/airgate-claude/backend/internal/gateway" ) func main() { diff --git a/backend/cmd/fp/main.go b/backend/cmd/fp/main.go index 45c440f..4d130e9 100644 --- a/backend/cmd/fp/main.go +++ b/backend/cmd/fp/main.go @@ -34,7 +34,7 @@ import ( utls "github.com/refraction-networking/utls" - "github.com/DouDOU-start/airgate-claude/backend/internal/gateway" + "github.com/DevilGenius/airgate-claude/backend/internal/gateway" ) // snapshot 即将写盘的指纹快照 diff --git a/backend/cmd/genmanifest/main.go b/backend/cmd/genmanifest/main.go index ef4baba..1916aea 100644 --- a/backend/cmd/genmanifest/main.go +++ b/backend/cmd/genmanifest/main.go @@ -9,8 +9,8 @@ import ( "gopkg.in/yaml.v3" - "github.com/DouDOU-start/airgate-claude/backend/internal/gateway" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + "github.com/DevilGenius/airgate-claude/backend/internal/gateway" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" ) const generatedComment = "# 本文件由 backend/cmd/genmanifest 自动生成,请勿手工修改。\n\n" diff --git a/backend/go.mod b/backend/go.mod index 0190710..36f72b3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,9 +1,9 @@ -module github.com/DouDOU-start/airgate-claude/backend +module github.com/DevilGenius/airgate-claude/backend -go 1.25.7 +go 1.26.3 require ( - github.com/DouDOU-start/airgate-sdk v0.2.1 + github.com/DevilGenius/airgate-sdk v0.2.2 github.com/imroc/req/v3 v3.57.0 github.com/refraction-networking/utls v1.8.2 github.com/tidwall/gjson v1.18.0 diff --git a/backend/go.sum b/backend/go.sum index fdb2f4f..620b7fa 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,5 @@ -github.com/DouDOU-start/airgate-sdk v0.2.1 h1:MylrFMifIJy5mT1wSFdXcSvAXjIcB6itp4dw8pP4pes= -github.com/DouDOU-start/airgate-sdk v0.2.1/go.mod h1:784vC4lIfCnUdOroWuvn55Dv/b9TucVMpFwuu40gc0Q= +github.com/DevilGenius/airgate-sdk v0.2.2 h1:sw9YbbQGpYQwUfm7BmOpcArqSnasEeAqUNO/TEbE0+0= +github.com/DevilGenius/airgate-sdk v0.2.2/go.mod h1:EnjPPVBiONFv0gcQqiBPqs86HRhcVKICdFaB03yJcI8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= diff --git a/backend/internal/gateway/errors.go b/backend/internal/gateway/errors.go index c39468a..6eddf2a 100644 --- a/backend/internal/gateway/errors.go +++ b/backend/internal/gateway/errors.go @@ -2,16 +2,19 @@ package gateway import ( "encoding/json" + "io" "net/http" "regexp" "strconv" "strings" "time" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" "github.com/tidwall/gjson" ) +const maxErrorResponseBodyBytes = 1 << 20 + // classifyHTTPFailure 根据 HTTP 状态码 + 错误文本归一化为 OutcomeKind。 // // 429 → AccountRateLimited @@ -95,6 +98,10 @@ func truncate(s string, maxLen int) string { return s[:maxLen] + "..." } +func readLimitedErrorBody(r io.Reader) ([]byte, error) { + return io.ReadAll(io.LimitReader(r, maxErrorResponseBodyBytes+1)) +} + func jsonError(msg string) []byte { b, _ := json.Marshal(map[string]string{"error": msg}) return b diff --git a/backend/internal/gateway/forward.go b/backend/internal/gateway/forward.go index 27ae65c..03db5b9 100644 --- a/backend/internal/gateway/forward.go +++ b/backend/internal/gateway/forward.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "net/url" "strings" @@ -14,7 +13,7 @@ import ( "github.com/tidwall/gjson" "github.com/tidwall/sjson" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" ) const ( @@ -402,7 +401,7 @@ func preprocessOAuthBody(body []byte, account *sdk.Account) []byte { } // 2. 注入 metadata.user_id(如果缺失) - // CLI >= 2.1.78 使用 JSON 格式;使用 sticky session 30 min 内复用同一 session_id + // CLI >= 2.1.78 使用 JSON 格式;使用 sticky session 3600s 内复用同一 session_id if !gjson.GetBytes(body, "metadata.user_id").Exists() { accountUUID := account.Credentials["account_uuid"] if accountUUID == "" { @@ -493,7 +492,7 @@ func normalizeRequestBody(body []byte) []byte { // unwritten 让 Core canFailover 不被短路;Core 会 failover + 触发状态机 // - UpstreamTransient(5xx):不写 w,让 Core failover func handleErrorResponse(resp *http.Response, _ http.ResponseWriter, start time.Time) sdk.ForwardOutcome { - respBody, _ := io.ReadAll(resp.Body) + respBody, _ := readLimitedErrorBody(resp.Body) msg := extractErrorMessage(respBody) if msg == "" { msg = truncate(string(respBody), 200) diff --git a/backend/internal/gateway/gateway.go b/backend/internal/gateway/gateway.go index b7e34cd..91cf1a3 100644 --- a/backend/internal/gateway/gateway.go +++ b/backend/internal/gateway/gateway.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "io" "log/slog" "net/http" "net/url" @@ -13,18 +12,19 @@ import ( "strings" "time" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" ) // AnthropicGateway Claude 网关插件 type AnthropicGateway struct { - logger *slog.Logger - ctx sdk.PluginContext - tokenMgr *tokenManager - stdPool *StandardTransportPool - fpPool *FingerprintTransportPool - registry *accountRegistry - sidecar *sidecarRunner + logger *slog.Logger + ctx sdk.PluginContext + tokenMgr *tokenManager + stdPool *StandardTransportPool + fpPool *FingerprintTransportPool + oauthReqPool *OAuthReqClientPool + registry *accountRegistry + sidecar *sidecarRunner } func (g *AnthropicGateway) Info() sdk.PluginInfo { @@ -43,6 +43,7 @@ func (g *AnthropicGateway) Init(ctx sdk.PluginContext) error { // 初始化连接池 g.stdPool = NewStandardTransportPool() g.fpPool = NewFingerprintTransportPool() + g.oauthReqPool = NewOAuthReqClientPool() // 账号注册表 + sidecar 运行器 g.registry = newAccountRegistry() @@ -74,6 +75,9 @@ func (g *AnthropicGateway) Stop(_ context.Context) error { if g.fpPool != nil { g.fpPool.Close() } + if g.oauthReqPool != nil { + g.oauthReqPool.Close() + } return nil } @@ -138,8 +142,12 @@ func (g *AnthropicGateway) ValidateAccount(ctx context.Context, credentials map[ client := &http.Client{Timeout: 30 * time.Second} if proxyURL := credentials["proxy_url"]; proxyURL != "" { - if u, err := url.Parse(proxyURL); err == nil { - client.Transport = &http.Transport{Proxy: http.ProxyURL(u)} + if g != nil && g.stdPool != nil { + client.Transport = g.stdPool.Get(proxyURL) + } else if u, err := url.Parse(proxyURL); err == nil { + transport := &http.Transport{Proxy: http.ProxyURL(u)} + client.Transport = transport + defer transport.CloseIdleConnections() } } resp, err := client.Do(req) @@ -169,7 +177,7 @@ func (g *AnthropicGateway) QueryQuota(ctx context.Context, credentials map[strin } // 调用 Anthropic usage API - usageResp, err := g.fetchUsage(ctx, accessToken, credentials["proxy_url"]) + usageResp, err := g.fetchUsage(ctx, 0, accessToken, credentials["proxy_url"], credentials["tls_profile"]) if err != nil { return nil, fmt.Errorf("查询用量失败: %w", err) } @@ -334,7 +342,7 @@ func (g *AnthropicGateway) HandleRequest(ctx context.Context, _, path, _ string, continue } - usageResp, err := g.fetchUsage(ctx, accessToken, a.Credentials["proxy_url"]) + usageResp, err := g.fetchUsage(ctx, a.ID, accessToken, a.Credentials["proxy_url"], a.Credentials["tls_profile"]) if err != nil { resp.Errors = append(resp.Errors, accountUsageError{ ID: a.ID, @@ -492,7 +500,7 @@ type UsageResponse struct { } // fetchUsage 从 Anthropic API 获取 OAuth 账号用量 -func (g *AnthropicGateway) fetchUsage(ctx context.Context, accessToken, proxyURL string) (*UsageResponse, error) { +func (g *AnthropicGateway) fetchUsage(ctx context.Context, accountID int64, accessToken, proxyURL, tlsProfile string) (*UsageResponse, error) { httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, usageAPIURL, nil) if err != nil { return nil, err @@ -509,7 +517,7 @@ func (g *AnthropicGateway) fetchUsage(ctx context.Context, accessToken, proxyURL // 使用 TLS 指纹客户端(api.anthropic.com 用 Claude CLI 指纹) client := &http.Client{ Timeout: 30 * time.Second, - Transport: buildFingerprintTransport(proxyURL), + Transport: g.usageTransport(accountID, proxyURL, tlsProfile), } resp, err := client.Do(httpReq) @@ -519,7 +527,7 @@ func (g *AnthropicGateway) fetchUsage(ctx context.Context, accessToken, proxyURL defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, _ := readLimitedErrorBody(resp.Body) return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 200)) } @@ -530,6 +538,13 @@ func (g *AnthropicGateway) fetchUsage(ctx context.Context, accessToken, proxyURL return &usageResp, nil } +func (g *AnthropicGateway) usageTransport(accountID int64, proxyURL, tlsProfile string) http.RoundTripper { + if g != nil && g.fpPool != nil { + return g.fpPool.Get(accountID, proxyURL, tlsProfile) + } + return buildFingerprintTransportWithProfile(proxyURL, tlsProfile) +} + // ────────────────────────────────────────────────────── // 工具函数 // ────────────────────────────────────────────────────── @@ -603,7 +618,7 @@ func (g *AnthropicGateway) forwardCountTokens(ctx context.Context, req *sdk.Forw } defer func() { _ = resp.Body.Close() }() - respBody, err := io.ReadAll(resp.Body) + respBody, err := readLimitedErrorBody(resp.Body) if err != nil { reason := fmt.Sprintf("读取上游响应失败: %v", err) logger.Warn("upstream_response_read_failed", diff --git a/backend/internal/gateway/headers.go b/backend/internal/gateway/headers.go index eb3fd6a..ec64c82 100644 --- a/backend/internal/gateway/headers.go +++ b/backend/internal/gateway/headers.go @@ -5,7 +5,7 @@ import ( "net/http" "strings" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" ) // ────────────────────────────────────────────────────── @@ -174,7 +174,12 @@ func pickRetryCount() string { // setRawHeader 绕过 Go 的 header 大小写规范化,直接设置原始 key func setRawHeader(h http.Header, key, value string) { - h.Del(key) + if strings.ContainsAny(key, "\r\n") || strings.ContainsAny(value, "\r\n") { + return + } + canonicalKey := http.CanonicalHeaderKey(key) + h.Del(canonicalKey) + delete(h, canonicalKey) delete(h, key) h[key] = []string{value} } diff --git a/backend/internal/gateway/metadata.go b/backend/internal/gateway/metadata.go index 6ae3d42..b0cb548 100644 --- a/backend/internal/gateway/metadata.go +++ b/backend/internal/gateway/metadata.go @@ -1,6 +1,6 @@ package gateway -import sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" +import sdk "github.com/DevilGenius/airgate-sdk/sdkgo" //go:generate go run ../../cmd/genmanifest diff --git a/backend/internal/gateway/models.go b/backend/internal/gateway/models.go index 208ce9b..707d032 100644 --- a/backend/internal/gateway/models.go +++ b/backend/internal/gateway/models.go @@ -2,11 +2,12 @@ package gateway import ( "encoding/json" - "fmt" "sort" + "strconv" "strings" + "sync" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" ) // Spec 单个模型的完整元数据 @@ -42,25 +43,14 @@ var modelRegistry = map[string]Spec{ "claude-haiku-4-5-20251001": {"Claude Haiku 4.5", 200000, 64000, 1.0, 0.1, 1.25, 2.0, 5.0}, } +var modelRegistryMu sync.RWMutex + const ( usageCurrencyUSD = "USD" - usageAttrModel = "model" - - usageMetricInputTokens = "input_tokens" - usageMetricCachedInputTokens = "cached_input_tokens" - usageMetricCacheCreationTokens = "cache_creation_input_tokens" - usageMetricCacheCreation5mTokens = "cache_creation_5m_input_tokens" - usageMetricCacheCreation1hTokens = "cache_creation_1h_input_tokens" - usageMetricOutputTokens = "output_tokens" - usageMetricReasoningOutputTokens = "reasoning_output_tokens" - usageMetricTotalTokens = "total_tokens" - - usageCostInput = "input_tokens" - usageCostCachedInput = "cached_input_tokens" - usageCostCacheCreation5m = "cache_creation_5m_input_tokens" - usageCostCacheCreation1h = "cache_creation_1h_input_tokens" - usageCostOutput = "output_tokens" + usageMetaCacheCreation5mTokens = "claude.cache_creation_5m_tokens" + usageMetaCacheCreation1hTokens = "claude.cache_creation_1h_tokens" + usageMetaCacheCreation1hPrice = "claude.cache_creation_1h_price" ) // ModelIDOverrides 短名到长名的映射 @@ -89,8 +79,9 @@ func NormalizeModelID(id string) string { // AllModelSpecs 返回所有注册模型的 SDK ModelInfo 列表 func AllModelSpecs() []sdk.ModelInfo { - models := make([]sdk.ModelInfo, 0, len(modelRegistry)) - for _, item := range AllPricingSpecs() { + pricing := AllPricingSpecs() + models := make([]sdk.ModelInfo, 0, len(pricing)) + for _, item := range pricing { models = append(models, specToModelInfo(item.ID, item.Spec)) } return models @@ -104,6 +95,8 @@ type NamedSpec struct { // AllPricingSpecs 返回带价格的插件私有模型规格,用于 manifest 生成和计费。 func AllPricingSpecs() []NamedSpec { + modelRegistryMu.RLock() + defer modelRegistryMu.RUnlock() items := make([]NamedSpec, 0, len(modelRegistry)) for id, spec := range modelRegistry { items = append(items, NamedSpec{ID: id, Spec: spec}) @@ -128,6 +121,9 @@ var fallbackSpec = Spec{ // LookupModelSpec 查找模型计费规格,未知模型返回兜底规格。 func LookupModelSpec(modelID string) (string, Spec) { + modelRegistryMu.RLock() + defer modelRegistryMu.RUnlock() + // 精确匹配 if spec, ok := modelRegistry[modelID]; ok { return modelID, spec @@ -189,158 +185,86 @@ func newTokenUsage(modelID string, tokens tokenUsage, firstTokenMs int64) *sdk.U Currency: usageCurrencyUSD, FirstTokenMs: firstTokenMs, } - setUsageModelAttribute(usage, modelID) setUsageTokens(usage, tokens) return usage } -func setUsageModelAttribute(usage *sdk.Usage, modelID string) { - if usage == nil || modelID == "" { - return - } - setUsageAttribute(usage, sdk.UsageAttribute{ - Key: usageAttrModel, - Label: "模型", - Kind: "model", - Value: modelID, - }) -} - func setUsageTokens(usage *sdk.Usage, tokens tokenUsage) { if usage == nil { return } - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricInputTokens, - Label: "输入 Token", - Kind: "token", - Unit: "token", - Value: float64(tokens.inputTokens), - }) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricCachedInputTokens, - Label: "缓存输入 Token", - Kind: "token", - Unit: "token", - Value: float64(tokens.cachedInputTokens), - }) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricCacheCreationTokens, - Label: "缓存写入 Token", - Kind: "token", - Unit: "token", - Value: float64(tokens.cacheCreationTokens), - }) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricCacheCreation5mTokens, - Label: "缓存写入 Token 5m", - Kind: "token", - Unit: "token", - Value: float64(tokens.cacheCreation5mTokens), - }) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricCacheCreation1hTokens, - Label: "缓存写入 Token 1h", - Kind: "token", - Unit: "token", - Value: float64(tokens.cacheCreation1hTokens), - }) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricOutputTokens, - Label: "输出 Token", - Kind: "token", - Unit: "token", - Value: float64(tokens.outputTokens), - }) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricReasoningOutputTokens, - Label: "推理 Token", - Kind: "token", - Unit: "token", - Value: float64(tokens.reasoningOutputTokens), - }) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricTotalTokens, - Label: "总 Token", - Kind: "token", - Unit: "token", - Value: float64(tokens.inputTokens + tokens.cachedInputTokens + tokens.cacheCreationTokens + tokens.outputTokens), - }) + usage.InputTokens = tokens.inputTokens + usage.OutputTokens = tokens.outputTokens + usage.CachedInputTokens = tokens.cachedInputTokens + usage.CacheCreationTokens = tokens.cacheCreationTokens + usage.ReasoningOutputTokens = tokens.reasoningOutputTokens + setUsageMetadataInt(usage, usageMetaCacheCreation5mTokens, tokens.cacheCreation5mTokens) + setUsageMetadataInt(usage, usageMetaCacheCreation1hTokens, tokens.cacheCreation1hTokens) +} + +func usageCacheCreation5mTokens(usage *sdk.Usage) int { + return int(usageMetadataFloat(usage, usageMetaCacheCreation5mTokens)) } -func usageMetricInt(usage *sdk.Usage, key string) int { - return int(usageMetricValue(usage, key)) +func usageCacheCreation1hTokens(usage *sdk.Usage) int { + return int(usageMetadataFloat(usage, usageMetaCacheCreation1hTokens)) } -func usageMetricValue(usage *sdk.Usage, key string) float64 { +func usageTotalTokens(usage *sdk.Usage) int { if usage == nil { return 0 } - for _, metric := range usage.Metrics { - if metric.Key == key { - return metric.Value - } - } - return 0 + return usage.InputTokens + usage.CachedInputTokens + usage.CacheCreationTokens + usage.OutputTokens } -func setUsageAttribute(usage *sdk.Usage, attr sdk.UsageAttribute) { - for i := range usage.Attributes { - if usage.Attributes[i].Key == attr.Key { - usage.Attributes[i] = attr - return - } +func setUsageMetadata(usage *sdk.Usage, key, value string) { + if usage == nil { + return + } + value = strings.TrimSpace(value) + if value == "" { + return + } + if usage.Metadata == nil { + usage.Metadata = map[string]string{} } - usage.Attributes = append(usage.Attributes, attr) + usage.Metadata[key] = value } -func setUsageMetric(usage *sdk.Usage, metric sdk.UsageMetric) { - for i := range usage.Metrics { - if usage.Metrics[i].Key == metric.Key { - usage.Metrics[i] = metric - return - } +func setUsageMetadataInt(usage *sdk.Usage, key string, value int) { + if value <= 0 { + return } - usage.Metrics = append(usage.Metrics, metric) + setUsageMetadata(usage, key, strconv.Itoa(value)) } -func setUsageCostDetail(usage *sdk.Usage, detail sdk.UsageCostDetail) { - if detail.AccountCost <= 0 { - removeUsageCostDetail(usage, detail.Key) +func setUsageMetadataFloat(usage *sdk.Usage, key string, value float64) { + if value <= 0 { return } - for i := range usage.CostDetails { - if usage.CostDetails[i].Key == detail.Key { - usage.CostDetails[i] = detail - recomputeUsageAccountCost(usage) - return - } - } - usage.CostDetails = append(usage.CostDetails, detail) - recomputeUsageAccountCost(usage) + setUsageMetadata(usage, key, strconv.FormatFloat(value, 'f', -1, 64)) } -func removeUsageCostDetail(usage *sdk.Usage, key string) { +func usageMetadataFloat(usage *sdk.Usage, key string) float64 { if usage == nil { - return + return 0 } - for i := range usage.CostDetails { - if usage.CostDetails[i].Key == key { - usage.CostDetails = append(usage.CostDetails[:i], usage.CostDetails[i+1:]...) - recomputeUsageAccountCost(usage) - return - } + raw := strings.TrimSpace(usage.Metadata[key]) + if raw == "" { + return 0 + } + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0 } + return value } func recomputeUsageAccountCost(usage *sdk.Usage) { if usage == nil { return } - var total float64 - for _, detail := range usage.CostDetails { - total += detail.AccountCost - } + total := usage.InputCost + usage.OutputCost + usage.CachedInputCost + usage.CacheCreationCost usage.AccountCost = total if usage.Currency == "" { usage.Currency = usageCurrencyUSD @@ -354,17 +278,6 @@ func tokenCost(tokens int, pricePerMillion float64) float64 { return float64(tokens) * pricePerMillion / 1_000_000 } -func priceMetadata(price float64, pricingModel string) map[string]string { - metadata := map[string]string{ - "unit_price": fmt.Sprintf("%.10g", price), - "unit": "USD/1M tokens", - } - if pricingModel != "" { - metadata["pricing_model"] = pricingModel - } - return metadata -} - // fillUsageCost 根据 Usage 中的 token 计量和插件私有价格表填充费用。 // 未知 model 只在计费规格上兜底,Usage.Model 仍保持上游实际返回值。 func fillUsageCost(usage *sdk.Usage) { @@ -372,13 +285,13 @@ func fillUsageCost(usage *sdk.Usage) { return } - pricingModel, spec := LookupModelSpec(usage.Model) - inputTokens := usageMetricInt(usage, usageMetricInputTokens) - outputTokens := usageMetricInt(usage, usageMetricOutputTokens) - cachedInputTokens := usageMetricInt(usage, usageMetricCachedInputTokens) - cacheCreationTokens := usageMetricInt(usage, usageMetricCacheCreationTokens) - cacheCreation5mTokens := usageMetricInt(usage, usageMetricCacheCreation5mTokens) - cacheCreation1hTokens := usageMetricInt(usage, usageMetricCacheCreation1hTokens) + _, spec := LookupModelSpec(usage.Model) + inputTokens := usage.InputTokens + outputTokens := usage.OutputTokens + cachedInputTokens := usage.CachedInputTokens + cacheCreationTokens := usage.CacheCreationTokens + cacheCreation5mTokens := usageCacheCreation5mTokens(usage) + cacheCreation1hTokens := usageCacheCreation1hTokens(usage) genericCacheCreationTokens := cacheCreationTokens - cacheCreation5mTokens - cacheCreation1hTokens if genericCacheCreationTokens < 0 { @@ -391,92 +304,17 @@ func fillUsageCost(usage *sdk.Usage) { cacheCreation5mCost := tokenCost(billableCacheCreation5mTokens, spec.CacheCreationPrice) cacheCreation1hCost := tokenCost(cacheCreation1hTokens, spec.CacheCreation1hPrice) outputCost := tokenCost(outputTokens, spec.OutputPrice) + usage.InputPrice = spec.InputPrice + usage.CachedInputPrice = spec.CachedPrice + usage.CacheCreationPrice = spec.CacheCreationPrice + usage.OutputPrice = spec.OutputPrice + usage.InputCost = inputCost + usage.CachedInputCost = cachedCost + usage.CacheCreationCost = cacheCreation5mCost + cacheCreation1hCost + usage.OutputCost = outputCost + setUsageMetadataFloat(usage, usageMetaCacheCreation1hPrice, spec.CacheCreation1hPrice) + recomputeUsageAccountCost(usage) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricInputTokens, - Label: "输入 Token", - Kind: "token", - Unit: "token", - Value: float64(inputTokens), - AccountCost: inputCost, - Currency: usageCurrencyUSD, - Metadata: priceMetadata(spec.InputPrice, pricingModel), - }) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricCachedInputTokens, - Label: "缓存输入 Token", - Kind: "token", - Unit: "token", - Value: float64(cachedInputTokens), - AccountCost: cachedCost, - Currency: usageCurrencyUSD, - Metadata: priceMetadata(spec.CachedPrice, pricingModel), - }) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricCacheCreation5mTokens, - Label: "缓存写入 Token 5m", - Kind: "token", - Unit: "token", - Value: float64(billableCacheCreation5mTokens), - AccountCost: cacheCreation5mCost, - Currency: usageCurrencyUSD, - Metadata: priceMetadata(spec.CacheCreationPrice, pricingModel), - }) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricCacheCreation1hTokens, - Label: "缓存写入 Token 1h", - Kind: "token", - Unit: "token", - Value: float64(cacheCreation1hTokens), - AccountCost: cacheCreation1hCost, - Currency: usageCurrencyUSD, - Metadata: priceMetadata(spec.CacheCreation1hPrice, pricingModel), - }) - setUsageMetric(usage, sdk.UsageMetric{ - Key: usageMetricOutputTokens, - Label: "输出 Token", - Kind: "token", - Unit: "token", - Value: float64(outputTokens), - AccountCost: outputCost, - Currency: usageCurrencyUSD, - Metadata: priceMetadata(spec.OutputPrice, pricingModel), - }) - setUsageCostDetail(usage, sdk.UsageCostDetail{ - Key: usageCostInput, - Label: "输入 Token", - AccountCost: inputCost, - Currency: usageCurrencyUSD, - Metadata: priceMetadata(spec.InputPrice, pricingModel), - }) - setUsageCostDetail(usage, sdk.UsageCostDetail{ - Key: usageCostCachedInput, - Label: "缓存输入 Token", - AccountCost: cachedCost, - Currency: usageCurrencyUSD, - Metadata: priceMetadata(spec.CachedPrice, pricingModel), - }) - setUsageCostDetail(usage, sdk.UsageCostDetail{ - Key: usageCostCacheCreation5m, - Label: "缓存写入 Token 5m", - AccountCost: cacheCreation5mCost, - Currency: usageCurrencyUSD, - Metadata: priceMetadata(spec.CacheCreationPrice, pricingModel), - }) - setUsageCostDetail(usage, sdk.UsageCostDetail{ - Key: usageCostCacheCreation1h, - Label: "缓存写入 Token 1h", - AccountCost: cacheCreation1hCost, - Currency: usageCurrencyUSD, - Metadata: priceMetadata(spec.CacheCreation1hPrice, pricingModel), - }) - setUsageCostDetail(usage, sdk.UsageCostDetail{ - Key: usageCostOutput, - Label: "输出 Token", - AccountCost: outputCost, - Currency: usageCurrencyUSD, - Metadata: priceMetadata(spec.OutputPrice, pricingModel), - }) } // claudeModelListEntry Anthropic /v1/models 接口返回的单个模型 diff --git a/backend/internal/gateway/oauth.go b/backend/internal/gateway/oauth.go index d0865bd..0a02f3b 100644 --- a/backend/internal/gateway/oauth.go +++ b/backend/internal/gateway/oauth.go @@ -8,7 +8,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "io" "net/http" "net/url" "strings" @@ -27,8 +26,8 @@ const ( OAuthAuthorizeURL = "https://claude.ai/oauth/authorize" OAuthTokenURL = "https://platform.claude.com/v1/oauth/token" OAuthRedirectURI = "https://platform.claude.com/oauth/code/callback" - OAuthScopeBrowser = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" - OAuthScopeAPI = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" + OAuthScopeBrowser = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" + OAuthScopeAPI = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" // Session Key 通过 claude.ai 获取 org 和 authorization code 的端点 claudeAIBaseURL = "https://claude.ai" @@ -36,8 +35,11 @@ const ( // PKCE 字符集 codeVerifierCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - // OAuth Session TTL - oauthSessionTTL = 30 * time.Minute + // OAuth Session TTL(3600s) + oauthSessionTTL = 3600 * time.Second + + // oauthSessionMaxEntries 是异常流量下的高水位安全阀;正常回收主要靠 TTL。 + oauthSessionMaxEntries = 1000000 ) // ────────────────────────────────────────────────────── @@ -64,17 +66,26 @@ var sessionStore = &oauthSessionStore{ func (s *oauthSessionStore) Set(state string, session *OAuthSession) { s.mu.Lock() defer s.mu.Unlock() + now := time.Now() + if session.CreatedAt.IsZero() { + session.CreatedAt = now + } + s.cleanExpiredLocked(now) + if _, exists := s.sessions[state]; !exists && len(s.sessions) >= oauthSessionMaxEntries { + s.deleteOldestLocked() + } s.sessions[state] = session } func (s *oauthSessionStore) Get(state string) (*OAuthSession, bool) { - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() session, ok := s.sessions[state] if !ok { return nil, false } if time.Since(session.CreatedAt) > oauthSessionTTL { + delete(s.sessions, state) return nil, false } return session, true @@ -166,13 +177,31 @@ func (g *AnthropicGateway) HandleOAuthCallback(ctx context.Context, code, state, func (s *oauthSessionStore) CleanExpired() { s.mu.Lock() defer s.mu.Unlock() + s.cleanExpiredLocked(time.Now()) +} + +func (s *oauthSessionStore) cleanExpiredLocked(now time.Time) { for state, session := range s.sessions { - if time.Since(session.CreatedAt) > oauthSessionTTL { + if now.Sub(session.CreatedAt) > oauthSessionTTL { delete(s.sessions, state) } } } +func (s *oauthSessionStore) deleteOldestLocked() { + var oldestState string + var oldestAt time.Time + for state, session := range s.sessions { + if oldestState == "" || session.CreatedAt.Before(oldestAt) { + oldestState = state + oldestAt = session.CreatedAt + } + } + if oldestState != "" { + delete(s.sessions, oldestState) + } +} + // ────────────────────────────────────────────────────── // Session Key → OAuth Token 完整流程 // ────────────────────────────────────────────────────── @@ -208,6 +237,100 @@ func (g *AnthropicGateway) ExchangeSessionKeyForToken(ctx context.Context, sessi return g.exchangeSessionKeyWithScope(ctx, sessionKey, proxyURL, OAuthScopeAPI) } +const ( + oauthReqClientMaxEntries = 100000 + oauthReqClientIdleTTL = 2 * time.Hour + oauthReqClientCleanupInterval = 10 * time.Minute +) + +type oauthReqClientEntry struct { + client *req.Client + lastUsedAt time.Time +} + +// OAuthReqClientPool 缓存 claude.ai 请求使用的 req.Client,按 proxyURL 隔离。 +type OAuthReqClientPool struct { + mu sync.Mutex + clients map[string]*oauthReqClientEntry + lastCleanupTime time.Time +} + +func NewOAuthReqClientPool() *OAuthReqClientPool { + return &OAuthReqClientPool{ + clients: make(map[string]*oauthReqClientEntry), + lastCleanupTime: time.Now(), + } +} + +func (p *OAuthReqClientPool) Get(proxyURL string) *req.Client { + if p == nil { + return buildOAuthReqClient(proxyURL) + } + now := time.Now() + p.mu.Lock() + defer p.mu.Unlock() + if entry, ok := p.clients[proxyURL]; ok { + entry.lastUsedAt = now + p.cleanupIdleLocked(now) + return entry.client + } + p.cleanupIdleLocked(now) + if len(p.clients) >= oauthReqClientMaxEntries { + p.deleteOldestLocked() + } + client := buildOAuthReqClient(proxyURL) + p.clients[proxyURL] = &oauthReqClientEntry{client: client, lastUsedAt: now} + return client +} + +func (p *OAuthReqClientPool) cleanupIdleLocked(now time.Time) { + if now.Sub(p.lastCleanupTime) < oauthReqClientCleanupInterval && len(p.clients) < oauthReqClientMaxEntries { + return + } + for proxyURL, entry := range p.clients { + if now.Sub(entry.lastUsedAt) > oauthReqClientIdleTTL { + closeOAuthReqClient(entry.client) + delete(p.clients, proxyURL) + } + } + p.lastCleanupTime = now +} + +func (p *OAuthReqClientPool) deleteOldestLocked() { + var oldestKey string + var oldestAt time.Time + for proxyURL, entry := range p.clients { + if oldestKey == "" || entry.lastUsedAt.Before(oldestAt) { + oldestKey = proxyURL + oldestAt = entry.lastUsedAt + } + } + if oldestKey != "" { + closeOAuthReqClient(p.clients[oldestKey].client) + delete(p.clients, oldestKey) + } +} + +func (p *OAuthReqClientPool) Close() { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + for _, entry := range p.clients { + closeOAuthReqClient(entry.client) + } + p.clients = make(map[string]*oauthReqClientEntry) + p.lastCleanupTime = time.Now() +} + +func closeOAuthReqClient(client *req.Client) { + if client == nil || client.GetTransport() == nil { + return + } + client.GetTransport().CloseIdleConnections() +} + // buildOAuthReqClient 构建用于 claude.ai 请求的 req 客户端(Chrome TLS 指纹 + 绕过 Cloudflare) func buildOAuthReqClient(proxyURL string) *req.Client { client := req.C(). @@ -222,17 +345,25 @@ func buildOAuthReqClient(proxyURL string) *req.Client { // buildOAuthClient 构建用于 token 交换的标准 HTTP 客户端(platform.claude.com 无 Cloudflare) func (g *AnthropicGateway) buildOAuthClient(proxyURL string) *http.Client { - transport := buildFingerprintTransport(proxyURL) return &http.Client{ Timeout: 60 * time.Second, - Transport: transport, + Transport: g.usageTransport(0, proxyURL, ""), } } // exchangeSessionKeyWithScope 通用的 Session Key → OAuth Token 流程 func (g *AnthropicGateway) exchangeSessionKeyWithScope(ctx context.Context, sessionKey, proxyURL, scope string) (*TokenResponse, error) { + if strings.TrimSpace(proxyURL) != "" { + return nil, fmt.Errorf("session_key exchange does not support proxy_url") + } + // claude.ai 请求使用 Chrome 指纹客户端(绕过 Cloudflare) - reqClient := buildOAuthReqClient(proxyURL) + var reqClient *req.Client + if g != nil && g.oauthReqPool != nil { + reqClient = g.oauthReqPool.Get(proxyURL) + } else { + reqClient = buildOAuthReqClient(proxyURL) + } // Step 1: 获取组织 UUID orgUUID, err := g.getOrganizationUUID(ctx, reqClient, sessionKey) @@ -407,7 +538,7 @@ func (g *AnthropicGateway) exchangeCodeForToken(ctx context.Context, client *htt defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, _ := readLimitedErrorBody(resp.Body) return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 200)) } @@ -448,7 +579,7 @@ func (g *AnthropicGateway) RefreshToken(ctx context.Context, refreshToken, proxy defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, _ := readLimitedErrorBody(resp.Body) return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 200)) } @@ -505,4 +636,3 @@ func base64URLEncode(data []byte) string { encoded := base64.URLEncoding.EncodeToString(data) return strings.TrimRight(encoded, "=") } - diff --git a/backend/internal/gateway/oauth_handler.go b/backend/internal/gateway/oauth_handler.go index 63784cd..d5e59e8 100644 --- a/backend/internal/gateway/oauth_handler.go +++ b/backend/internal/gateway/oauth_handler.go @@ -1,7 +1,6 @@ package gateway import ( - "context" "encoding/json" "fmt" "net/http" @@ -10,8 +9,8 @@ import ( "strings" "time" - "github.com/DouDOU-start/airgate-sdk/devkit/devserver" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + "github.com/DevilGenius/airgate-sdk/devkit/devserver" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" ) // OAuthDevHandler devserver 的 OAuth HTTP handler @@ -273,7 +272,7 @@ func (h *OAuthDevHandler) handleUsage(w http.ResponseWriter, r *http.Request) { return } - usageResp, err := h.Gateway.fetchUsage(context.Background(), accessToken, account.Credentials["proxy_url"]) + usageResp, err := h.Gateway.fetchUsage(r.Context(), id, accessToken, account.Credentials["proxy_url"], account.Credentials["tls_profile"]) if err != nil { http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError) return diff --git a/backend/internal/gateway/outcome.go b/backend/internal/gateway/outcome.go index c27bceb..ed0ad56 100644 --- a/backend/internal/gateway/outcome.go +++ b/backend/internal/gateway/outcome.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" ) // 构造 ForwardOutcome 的 helper,让各转发路径不重复写 struct literal。 diff --git a/backend/internal/gateway/session.go b/backend/internal/gateway/session.go index 3d22038..81d7d44 100644 --- a/backend/internal/gateway/session.go +++ b/backend/internal/gateway/session.go @@ -12,14 +12,20 @@ import ( ) // ────────────────────────────────────────────────────── -// Session 身份:UUIDv4 + 30 min sticky 复用 +// Session 身份:UUIDv4 + 3600s sticky 复用 // ────────────────────────────────────────────────────── // // 真实 Claude CLI 在同一会话内多次请求会复用同一 metadata.user_id; // 每次都重新生成会被 Anthropic 识别为非 CLI 流量。 -// 这里以 accountID + messages 前缀哈希为 key,TTL 30 min。 +// 这里以 accountID + messages 前缀哈希为 key,TTL 3600s。 -const sessionTTL = 30 * time.Minute +const ( + sessionTTL = 3600 * time.Second + + // sessionCacheMaxEntries 只是异常流量下的高水位安全阀;正常回收主要靠 TTL。 + sessionCacheMaxEntries = 1000000 + sessionCleanupInterval = time.Minute +) // newDeviceID 生成 64 字符十六进制 device_id(per-account 确定性) // 真实 CLI 使用 64-char hex device identifier @@ -47,11 +53,15 @@ type sessionEntry struct { // sessionCache accountID+conversation 指纹 → 固定 user_id type sessionCache struct { - mu sync.Mutex - entries map[string]sessionEntry + mu sync.Mutex + entries map[string]sessionEntry + lastCleanupTime time.Time } -var defaultSessionCache = &sessionCache{entries: make(map[string]sessionEntry)} +var defaultSessionCache = &sessionCache{ + entries: make(map[string]sessionEntry), + lastCleanupTime: time.Now(), +} // conversationFingerprint 以 messages 数组的稳定切片计算指纹 // 使用首条消息 + 倒数第二条消息(保持多轮对话一致,对末尾问句不敏感) @@ -86,20 +96,43 @@ func (c *sessionCache) stickyUserID(accountID int64, fingerprint string) string c.mu.Lock() defer c.mu.Unlock() - // 清理过期项(低频访问时顺带 GC) - if len(c.entries) > 1024 { - for k, e := range c.entries { - if now.After(e.expiresAt) { - delete(c.entries, k) - } - } - } + c.cleanupExpiredLocked(now) if e, ok := c.entries[key]; ok && now.Before(e.expiresAt) { return e.userID + } else if ok { + delete(c.entries, key) } + if len(c.entries) >= sessionCacheMaxEntries { + c.deleteOneExpiredOrArbitraryLocked(now) + } id := newUUIDv4() c.entries[key] = sessionEntry{userID: id, expiresAt: now.Add(sessionTTL)} return id } + +func (c *sessionCache) cleanupExpiredLocked(now time.Time) { + if now.Sub(c.lastCleanupTime) < sessionCleanupInterval && len(c.entries) < sessionCacheMaxEntries { + return + } + for k, e := range c.entries { + if now.After(e.expiresAt) { + delete(c.entries, k) + } + } + c.lastCleanupTime = now +} + +func (c *sessionCache) deleteOneExpiredOrArbitraryLocked(now time.Time) { + for k, e := range c.entries { + if now.After(e.expiresAt) { + delete(c.entries, k) + return + } + } + for k := range c.entries { + delete(c.entries, k) + return + } +} diff --git a/backend/internal/gateway/sidecar.go b/backend/internal/gateway/sidecar.go index 5d4a2cf..0c65ac5 100644 --- a/backend/internal/gateway/sidecar.go +++ b/backend/internal/gateway/sidecar.go @@ -10,7 +10,7 @@ import ( "sync" "time" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" ) // ────────────────────────────────────────────────────── @@ -29,7 +29,12 @@ const ( countTokensChanBuffer = 128 countTokensRequestTimeout = 20 * time.Second probeRequestTimeout = 15 * time.Second + probeMaxConcurrency = 64 probeModel = "claude-haiku-4-5-20251001" + + accountRegistryMaxEntries = 100000 + accountRegistryIdleTTL = 24 * time.Hour + accountRegistryCleanupInterval = 30 * time.Minute ) // ────────────────────────────────────────────────────── @@ -48,12 +53,16 @@ type accountSnapshot struct { // accountRegistry 记录被 forward 路径见到过的 oauth/session_key 账号 // sidecar 只对 registry 里的账号发起流量,避免并发冲击所有账号 type accountRegistry struct { - mu sync.RWMutex - accounts map[int64]*accountSnapshot + mu sync.RWMutex + accounts map[int64]*accountSnapshot + lastCleanupTime time.Time } func newAccountRegistry() *accountRegistry { - return &accountRegistry{accounts: make(map[int64]*accountSnapshot)} + return &accountRegistry{ + accounts: make(map[int64]*accountSnapshot), + lastCleanupTime: time.Now(), + } } // register 登记/更新账号快照(凭证按值拷贝,避免 race) @@ -75,14 +84,20 @@ func (r *accountRegistry) register(account *sdk.Account) { snap.credentials[k] = v } r.mu.Lock() + r.cleanupLocked(snap.lastSeenAt) + if _, exists := r.accounts[account.ID]; !exists && len(r.accounts) >= accountRegistryMaxEntries { + r.deleteOldestLocked() + } r.accounts[account.ID] = snap r.mu.Unlock() } // snapshot 返回当前所有账号快照的切片拷贝 func (r *accountRegistry) snapshot() []*accountSnapshot { - r.mu.RLock() - defer r.mu.RUnlock() + now := time.Now() + r.mu.Lock() + defer r.mu.Unlock() + r.cleanupLocked(now) out := make([]*accountSnapshot, 0, len(r.accounts)) for _, a := range r.accounts { out = append(out, a) @@ -90,6 +105,34 @@ func (r *accountRegistry) snapshot() []*accountSnapshot { return out } +func (r *accountRegistry) cleanupLocked(now time.Time) { + if now.Sub(r.lastCleanupTime) < accountRegistryCleanupInterval && len(r.accounts) < accountRegistryMaxEntries { + return + } + for accountID, snap := range r.accounts { + if now.Sub(snap.lastSeenAt) > accountRegistryIdleTTL { + delete(r.accounts, accountID) + } + } + r.lastCleanupTime = now +} + +func (r *accountRegistry) deleteOldestLocked() { + var oldestID int64 + var oldestAt time.Time + found := false + for accountID, snap := range r.accounts { + if !found || snap.lastSeenAt.Before(oldestAt) { + oldestID = accountID + oldestAt = snap.lastSeenAt + found = true + } + } + if found { + delete(r.accounts, oldestID) + } +} + // ────────────────────────────────────────────────────── // sidecarRunner // ────────────────────────────────────────────────────── @@ -104,14 +147,21 @@ type countTokensJob struct { type sidecarRunner struct { gateway *AnthropicGateway jobs chan countTokensJob + ctx context.Context cancel context.CancelFunc wg sync.WaitGroup + probes sync.WaitGroup + probeCh chan struct{} + mu sync.Mutex + stopped bool } func newSidecarRunner(g *AnthropicGateway) *sidecarRunner { return &sidecarRunner{ gateway: g, jobs: make(chan countTokensJob, countTokensChanBuffer), + probeCh: make(chan struct{}, probeMaxConcurrency), + stopped: true, } } @@ -119,7 +169,17 @@ func newSidecarRunner(g *AnthropicGateway) *sidecarRunner { // postRefreshProbe 是事件驱动(由 tokenManager 成功刷新后触发),不占用常驻 goroutine func (s *sidecarRunner) start() { ctx, cancel := context.WithCancel(context.Background()) + s.mu.Lock() + if !s.stopped { + s.mu.Unlock() + cancel() + return + } + s.jobs = make(chan countTokensJob, countTokensChanBuffer) + s.ctx = ctx s.cancel = cancel + s.stopped = false + s.mu.Unlock() s.wg.Add(2) go s.usagePollerLoop(ctx) @@ -128,11 +188,19 @@ func (s *sidecarRunner) start() { // stop 停止所有 sidecar func (s *sidecarRunner) stop() { + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return + } + s.stopped = true if s.cancel != nil { s.cancel() } close(s.jobs) + s.mu.Unlock() s.wg.Wait() + s.probes.Wait() } // scheduleCountTokens 非阻塞投递 count_tokens 任务;队列满则丢弃(sidecar 流量不应反压主链路) @@ -142,11 +210,17 @@ func (s *sidecarRunner) scheduleCountTokens(accountID int64, body []byte) { } bodyCopy := make([]byte, len(body)) copy(bodyCopy, body) + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return + } select { case s.jobs <- countTokensJob{accountID: accountID, body: bodyCopy}: default: // 队列满,安静丢弃 } + s.mu.Unlock() } // ────────────────────────────────────────────────────── @@ -197,7 +271,7 @@ func (s *sidecarRunner) pollOne(ctx context.Context, snap *accountSnapshot, logg pollCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - _, err := s.gateway.fetchUsage(pollCtx, token, snap.credentials["proxy_url"]) + _, err := s.gateway.fetchUsage(pollCtx, snap.id, token, snap.credentials["proxy_url"], snap.credentials["tls_profile"]) if err != nil { logger.Warn("sidecar usage poll failed", "account_id", snap.id, "error", err) return @@ -281,8 +355,26 @@ func (s *sidecarRunner) fireRefreshProbe(account *sdk.Account) { snap.credentials[k] = v } + s.mu.Lock() + if s.stopped || s.ctx == nil { + s.mu.Unlock() + return + } + parentCtx := s.ctx + select { + case s.probeCh <- struct{}{}: + default: + s.mu.Unlock() + s.gateway.logger.Warn("sidecar probe skipped: concurrency limit reached", "account_id", account.ID) + return + } + s.probes.Add(1) + s.mu.Unlock() + go func() { - ctx, cancel := context.WithTimeout(context.Background(), probeRequestTimeout) + defer s.probes.Done() + defer func() { <-s.probeCh }() + ctx, cancel := context.WithTimeout(parentCtx, probeRequestTimeout) defer cancel() s.runProbe(ctx, snap) }() diff --git a/backend/internal/gateway/stream.go b/backend/internal/gateway/stream.go index baaf715..05e712b 100644 --- a/backend/internal/gateway/stream.go +++ b/backend/internal/gateway/stream.go @@ -2,6 +2,7 @@ package gateway import ( "bufio" + "context" "fmt" "io" "net/http" @@ -11,10 +12,11 @@ import ( "github.com/tidwall/gjson" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" ) // SSE 流式响应透传 + usage 提取。调用者保证 resp.StatusCode 是 2xx(4xx/5xx 由 handleErrorResponse 处理)。 +const sseIdleTimeout = 90 * time.Second // handleStreamResponse 透传 Anthropic SSE 流,同时累加 usage 字段。 // 流中途断开时返回 OutcomeStreamAborted(字节已开写,Core 不会 failover)。 @@ -29,44 +31,95 @@ func handleStreamResponse(resp *http.Response, w http.ResponseWriter, start time var tokens tokenUsage var firstTokenOnce sync.Once + baseCtx := context.Background() + if resp.Request != nil && resp.Request.Context() != nil { + baseCtx = resp.Request.Context() + } + ctx, cancel := context.WithCancel(baseCtx) + defer cancel() + scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) - - for scanner.Scan() { - line := scanner.Text() - if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { - break - } - if flusher, ok := w.(http.Flusher); ok { - flusher.Flush() + lines := make(chan string) + scanErr := make(chan error, 1) + go func() { + defer close(lines) + for scanner.Scan() { + line := scanner.Text() + select { + case lines <- line: + case <-ctx.Done(): + scanErr <- ctx.Err() + return + } } - - data, ok := extractSSEData(line) - if !ok || data == "" { - continue - } - eventType := gjson.Get(data, "type").String() - - if eventType == "content_block_delta" { - firstTokenOnce.Do(func() { - usage.FirstTokenMs = time.Since(start).Milliseconds() - }) + scanErr <- scanner.Err() + }() + + idleTimer := time.NewTimer(sseIdleTimeout) + defer idleTimer.Stop() + + for { + select { + case line, ok := <-lines: + if !ok { + elapsed := time.Since(start) + if err := <-scanErr; err != nil { + return streamAbortedOutcome(resp.StatusCode, err.Error(), usage), fmt.Errorf("读取上游 SSE 失败: %w", err) + } + fillUsageCost(usage) + return sdk.ForwardOutcome{ + Kind: sdk.OutcomeSuccess, + Upstream: sdk.UpstreamResponse{StatusCode: resp.StatusCode}, + Usage: usage, + Duration: elapsed, + }, nil + } + resetSSEIdleTimer(idleTimer) + + if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { + _ = resp.Body.Close() + return streamAbortedOutcome(resp.StatusCode, err.Error(), usage), fmt.Errorf("写入 SSE 响应失败: %w", err) + } + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + + data, ok := extractSSEData(line) + if !ok || data == "" { + continue + } + eventType := gjson.Get(data, "type").String() + + if eventType == "content_block_delta" { + firstTokenOnce.Do(func() { + usage.FirstTokenMs = time.Since(start).Milliseconds() + }) + } + extractAnthropicUsage(data, eventType, usage, &tokens) + + case <-idleTimer.C: + err := fmt.Errorf("上游 SSE 空闲超时") + cancel() + _ = resp.Body.Close() + return streamAbortedOutcome(resp.StatusCode, err.Error(), usage), err + + case <-ctx.Done(): + err := ctx.Err() + _ = resp.Body.Close() + return streamAbortedOutcome(resp.StatusCode, err.Error(), usage), err } - extractAnthropicUsage(data, eventType, usage, &tokens) } +} - elapsed := time.Since(start) - if err := scanner.Err(); err != nil { - return streamAbortedOutcome(resp.StatusCode, err.Error(), usage), fmt.Errorf("读取上游 SSE 失败: %w", err) +func resetSSEIdleTimer(timer *time.Timer) { + if !timer.Stop() { + select { + case <-timer.C: + default: + } } - - fillUsageCost(usage) - return sdk.ForwardOutcome{ - Kind: sdk.OutcomeSuccess, - Upstream: sdk.UpstreamResponse{StatusCode: resp.StatusCode}, - Usage: usage, - Duration: elapsed, - }, nil + timer.Reset(sseIdleTimeout) } // handleNonStreamResponse 处理非流式响应。resp.StatusCode 预设 2xx。 @@ -118,7 +171,6 @@ func extractAnthropicUsage(data string, eventType string, usage *sdk.Usage, toke switch eventType { case "message_start": usage.Model = gjson.Get(data, "message.model").String() - setUsageModelAttribute(usage, usage.Model) if applyAnthropicUsageNode(gjson.Get(data, "message.usage"), tokens, true) { setUsageTokens(usage, *tokens) } diff --git a/backend/internal/gateway/stream_test.go b/backend/internal/gateway/stream_test.go index 7d755e7..3383a5c 100644 --- a/backend/internal/gateway/stream_test.go +++ b/backend/internal/gateway/stream_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" ) func TestExtractAnthropicUsage_MessageStartFallsBackToCachedTokens(t *testing.T) { @@ -22,12 +22,12 @@ func TestExtractAnthropicUsage_MessageStartFallsBackToCachedTokens(t *testing.T) if usage.Model != "claude-opus-4-7" { t.Fatalf("model = %q, want claude-opus-4-7", usage.Model) } - requireUsageMetric(t, usage, usageMetricInputTokens, 12) - requireUsageMetric(t, usage, usageMetricCachedInputTokens, 9) - requireUsageMetric(t, usage, usageMetricCacheCreationTokens, 7) - requireUsageMetric(t, usage, usageMetricCacheCreation5mTokens, 3) - requireUsageMetric(t, usage, usageMetricCacheCreation1hTokens, 4) - requireUsageMetric(t, usage, usageMetricTotalTokens, 28) + requireUsageInt(t, "input tokens", usage.InputTokens, 12) + requireUsageInt(t, "cached input tokens", usage.CachedInputTokens, 9) + requireUsageInt(t, "cache creation tokens", usage.CacheCreationTokens, 7) + requireUsageInt(t, "cache creation 5m tokens", usageCacheCreation5mTokens(usage), 3) + requireUsageInt(t, "cache creation 1h tokens", usageCacheCreation1hTokens(usage), 4) + requireUsageInt(t, "total tokens", usageTotalTokens(usage), 28) } func TestExtractAnthropicUsage_MessageDeltaKeepsStartValues(t *testing.T) { @@ -40,14 +40,14 @@ func TestExtractAnthropicUsage_MessageDeltaKeepsStartValues(t *testing.T) { delta := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":1046,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"reasoning_output_tokens":128}}` extractAnthropicUsage(delta, "message_delta", usage, &tokens) - requireUsageMetric(t, usage, usageMetricInputTokens, 10) - requireUsageMetric(t, usage, usageMetricCachedInputTokens, 6) - requireUsageMetric(t, usage, usageMetricCacheCreationTokens, 7) - requireUsageMetric(t, usage, usageMetricCacheCreation5mTokens, 3) - requireUsageMetric(t, usage, usageMetricCacheCreation1hTokens, 4) - requireUsageMetric(t, usage, usageMetricOutputTokens, 1046) - requireUsageMetric(t, usage, usageMetricReasoningOutputTokens, 128) - requireUsageMetric(t, usage, usageMetricTotalTokens, 1069) + requireUsageInt(t, "input tokens", usage.InputTokens, 10) + requireUsageInt(t, "cached input tokens", usage.CachedInputTokens, 6) + requireUsageInt(t, "cache creation tokens", usage.CacheCreationTokens, 7) + requireUsageInt(t, "cache creation 5m tokens", usageCacheCreation5mTokens(usage), 3) + requireUsageInt(t, "cache creation 1h tokens", usageCacheCreation1hTokens(usage), 4) + requireUsageInt(t, "output tokens", usage.OutputTokens, 1046) + requireUsageInt(t, "reasoning output tokens", usage.ReasoningOutputTokens, 128) + requireUsageInt(t, "total tokens", usageTotalTokens(usage), 1069) } func TestExtractAnthropicUsage_MessageDeltaFallsBackToCachedTokens(t *testing.T) { @@ -60,9 +60,9 @@ func TestExtractAnthropicUsage_MessageDeltaFallsBackToCachedTokens(t *testing.T) delta := `{"type":"message_delta","usage":{"output_tokens":42,"cache_read_input_tokens":0,"cached_tokens":11}}` extractAnthropicUsage(delta, "message_delta", usage, &tokens) - requireUsageMetric(t, usage, usageMetricCachedInputTokens, 11) - requireUsageMetric(t, usage, usageMetricOutputTokens, 42) - requireUsageMetric(t, usage, usageMetricTotalTokens, 70) + requireUsageInt(t, "cached input tokens", usage.CachedInputTokens, 11) + requireUsageInt(t, "output tokens", usage.OutputTokens, 42) + requireUsageInt(t, "total tokens", usageTotalTokens(usage), 70) } func TestHandleNonStreamResponseParsesAnthropicUsageFallbacks(t *testing.T) { @@ -92,20 +92,20 @@ func TestHandleNonStreamResponseParsesAnthropicUsageFallbacks(t *testing.T) { if usage.Model != "claude-opus-4-7" { t.Fatalf("model = %q, want claude-opus-4-7", usage.Model) } - requireUsageMetric(t, usage, usageMetricInputTokens, 123) - requireUsageMetric(t, usage, usageMetricCachedInputTokens, 7) - requireUsageMetric(t, usage, usageMetricCacheCreationTokens, 7) - requireUsageMetric(t, usage, usageMetricCacheCreation5mTokens, 5) - requireUsageMetric(t, usage, usageMetricCacheCreation1hTokens, 2) - requireUsageMetric(t, usage, usageMetricOutputTokens, 456) + requireUsageInt(t, "input tokens", usage.InputTokens, 123) + requireUsageInt(t, "cached input tokens", usage.CachedInputTokens, 7) + requireUsageInt(t, "cache creation tokens", usage.CacheCreationTokens, 7) + requireUsageInt(t, "cache creation 5m tokens", usageCacheCreation5mTokens(usage), 5) + requireUsageInt(t, "cache creation 1h tokens", usageCacheCreation1hTokens(usage), 2) + requireUsageInt(t, "output tokens", usage.OutputTokens, 456) if diff := usage.AccountCost - 0.01206975; diff < -1e-9 || diff > 1e-9 { t.Fatalf("account cost = %.12f, want %.12f", usage.AccountCost, 0.01206975) } } -func requireUsageMetric(t *testing.T, usage *sdk.Usage, key string, want int) { +func requireUsageInt(t *testing.T, label string, got, want int) { t.Helper() - if got := usageMetricInt(usage, key); got != want { - t.Fatalf("%s = %d, want %d", key, got, want) + if got != want { + t.Fatalf("%s = %d, want %d", label, got, want) } } diff --git a/backend/internal/gateway/tlsfingerprint.go b/backend/internal/gateway/tlsfingerprint.go index 4ae1ed6..9dd247e 100644 --- a/backend/internal/gateway/tlsfingerprint.go +++ b/backend/internal/gateway/tlsfingerprint.go @@ -82,20 +82,20 @@ var defaultSignatureAlgorithms = []utls.SignatureScheme{ // 不应通过人为加 GREASE 打乱 JA3。 func buildBunClientHelloSpec() *utls.ClientHelloSpec { extensions := []utls.TLSExtension{ - &utls.SNIExtension{}, // 0: server_name - &utls.GREASEEncryptedClientHelloExtension{}, // 65037: ECH (boringssl GREASE) - &utls.ExtendedMasterSecretExtension{}, // 23 - &utls.RenegotiationInfoExtension{Renegotiation: utls.RenegotiateOnceAsClient}, // 65281 - &utls.SupportedCurvesExtension{Curves: defaultCurves}, // 10 - &utls.SupportedPointsExtension{SupportedPoints: []uint8{0}}, // 11 (uncompressed) - &utls.SessionTicketExtension{}, // 35 - &utls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}}, // 16 - &utls.StatusRequestExtension{}, // 5 - &utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: defaultSignatureAlgorithms}, // 13 - &utls.SCTExtension{}, // 18 - &utls.KeyShareExtension{KeyShares: []utls.KeyShare{{Group: utls.X25519}}}, // 51 - &utls.PSKKeyExchangeModesExtension{Modes: []uint8{uint8(utls.PskModeDHE)}}, // 45 - &utls.SupportedVersionsExtension{Versions: []uint16{utls.VersionTLS13, utls.VersionTLS12}}, // 43 + &utls.SNIExtension{}, // 0: server_name + &utls.GREASEEncryptedClientHelloExtension{}, // 65037: ECH (boringssl GREASE) + &utls.ExtendedMasterSecretExtension{}, // 23 + &utls.RenegotiationInfoExtension{Renegotiation: utls.RenegotiateOnceAsClient}, // 65281 + &utls.SupportedCurvesExtension{Curves: defaultCurves}, // 10 + &utls.SupportedPointsExtension{SupportedPoints: []uint8{0}}, // 11 (uncompressed) + &utls.SessionTicketExtension{}, // 35 + &utls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}}, // 16 + &utls.StatusRequestExtension{}, // 5 + &utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: defaultSignatureAlgorithms}, // 13 + &utls.SCTExtension{}, // 18 + &utls.KeyShareExtension{KeyShares: []utls.KeyShare{{Group: utls.X25519}}}, // 51 + &utls.PSKKeyExchangeModesExtension{Modes: []uint8{uint8(utls.PskModeDHE)}}, // 45 + &utls.SupportedVersionsExtension{Versions: []uint16{utls.VersionTLS13, utls.VersionTLS12}}, // 43 } return &utls.ClientHelloSpec{ @@ -130,18 +130,13 @@ func selectClientHelloSpec(profile string) *utls.ClientHelloSpec { // TLS 指纹 Transport 构建 // ────────────────────────────────────────────────────── -// buildFingerprintTransport 构建带 Bun 1.3.x TLS 指纹的 Transport +// buildFingerprintTransportWithProfile 允许账号指定 tls_profile 精确选择 ClientHelloSpec // // Bun 默认 fetch 只协商 http/1.1(ground truth),因此: // - ALPN 只带 http/1.1 // - ForceAttemptHTTP2=false,不启 http2.ConfigureTransport // // 不强制 h2 是刻意的:Anthropic 侧有行为模型对比,配置与真实 CLI 不一致会拉高识别分数。 -func buildFingerprintTransport(proxyURL string) *http.Transport { - return buildFingerprintTransportWithProfile(proxyURL, "") -} - -// buildFingerprintTransportWithProfile 允许账号指定 tls_profile 精确选择 ClientHelloSpec func buildFingerprintTransportWithProfile(proxyURL, profile string) *http.Transport { dialer := &net.Dialer{ Timeout: httpDialTimeout, @@ -281,9 +276,24 @@ func dialThroughProxy(ctx context.Context, proxyURL string, targetAddr string, d return nil, fmt.Errorf("proxy CONNECT failed: %s", resp.Status) } + if br.Buffered() > 0 { + return &bufferedProxyConn{Conn: conn, reader: br}, nil + } return conn, nil } +type bufferedProxyConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedProxyConn) Read(b []byte) (int, error) { + if c.reader != nil && c.reader.Buffered() > 0 { + return c.reader.Read(b) + } + return c.Conn.Read(b) +} + func hasPort(addr string) bool { _, _, err := net.SplitHostPort(addr) return err == nil diff --git a/backend/internal/gateway/token_manager.go b/backend/internal/gateway/token_manager.go index 668cd77..3202ccb 100644 --- a/backend/internal/gateway/token_manager.go +++ b/backend/internal/gateway/token_manager.go @@ -8,7 +8,7 @@ import ( "sync" "time" - sdk "github.com/DouDOU-start/airgate-sdk/sdkgo" + sdk "github.com/DevilGenius/airgate-sdk/sdkgo" ) // ────────────────────────────────────────────────────── @@ -21,13 +21,19 @@ const ( refreshCooldown = 60 * time.Second // 已知失败的冷却窗口 maxRefreshRetries = 2 // 最大重试次数 refreshRetryBackoff = 1 * time.Second // 重试退避间隔 + + tokenStateMaxEntries = 100000 + tokenStateIdleTTL = 24 * time.Hour + tokenStateCleanupInterval = 30 * time.Minute ) // tokenManager 管理 OAuth token 的并发安全刷新 type tokenManager struct { - gateway *AnthropicGateway - logger *slog.Logger - locks sync.Map // accountID (int64) -> *accountRefreshState + gateway *AnthropicGateway + logger *slog.Logger + mu sync.Mutex + locks map[int64]*accountRefreshState + lastCleanupTime time.Time } // accountRefreshState 单个账号的刷新状态 @@ -37,20 +43,63 @@ type accountRefreshState struct { lastToken string // 上次刷新后的 access_token(用于 double-check) lastError error lastErrorAt time.Time + lastSeenAt time.Time } // newTokenManager 创建 token 管理器 func newTokenManager(gw *AnthropicGateway, logger *slog.Logger) *tokenManager { return &tokenManager{ - gateway: gw, - logger: logger, + gateway: gw, + logger: logger, + locks: make(map[int64]*accountRefreshState), + lastCleanupTime: time.Now(), } } // getState 获取或创建账号的刷新状态 func (m *tokenManager) getState(accountID int64) *accountRefreshState { - val, _ := m.locks.LoadOrStore(accountID, &accountRefreshState{}) - return val.(*accountRefreshState) + now := time.Now() + m.mu.Lock() + defer m.mu.Unlock() + m.cleanupLocked(now) + if state, ok := m.locks[accountID]; ok { + state.lastSeenAt = now + return state + } + if len(m.locks) >= tokenStateMaxEntries { + m.deleteOldestLocked() + } + state := &accountRefreshState{lastSeenAt: now} + m.locks[accountID] = state + return state +} + +func (m *tokenManager) cleanupLocked(now time.Time) { + if now.Sub(m.lastCleanupTime) < tokenStateCleanupInterval && len(m.locks) < tokenStateMaxEntries { + return + } + for accountID, state := range m.locks { + if now.Sub(state.lastSeenAt) > tokenStateIdleTTL { + delete(m.locks, accountID) + } + } + m.lastCleanupTime = now +} + +func (m *tokenManager) deleteOldestLocked() { + var oldestID int64 + var oldestAt time.Time + found := false + for accountID, state := range m.locks { + if !found || state.lastSeenAt.Before(oldestAt) { + oldestID = accountID + oldestAt = state.lastSeenAt + found = true + } + } + if found { + delete(m.locks, oldestID) + } } // ensureValidToken 检查 token 过期状态,必要时自动刷新 @@ -195,10 +244,17 @@ func (m *tokenManager) doRefresh(ctx context.Context, account *sdk.Account) (map var lastErr error for attempt := 0; attempt <= maxRefreshRetries; attempt++ { if attempt > 0 { + timer := time.NewTimer(refreshRetryBackoff * time.Duration(attempt)) select { case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } return nil, ctx.Err() - case <-time.After(refreshRetryBackoff * time.Duration(attempt)): + case <-timer.C: } } diff --git a/backend/internal/gateway/transport_pool.go b/backend/internal/gateway/transport_pool.go index 62bda6a..7dc8a43 100644 --- a/backend/internal/gateway/transport_pool.go +++ b/backend/internal/gateway/transport_pool.go @@ -6,6 +6,8 @@ import ( "net" "net/http" "net/url" + "strconv" + "strings" "sync" "time" ) @@ -14,18 +16,31 @@ import ( // 传输连接池:复用 HTTP Transport,避免每次请求创建新连接 // ────────────────────────────────────────────────────── +const ( + standardTransportMaxEntries = 100000 + standardTransportIdleTTL = 2 * time.Hour + standardTransportCleanupInterval = 10 * time.Minute +) + +type standardTransportEntry struct { + transport *http.Transport + lastUsedAt time.Time +} + // StandardTransportPool API Key 账号的标准 TLS 连接池 // 按 proxyURL 分组缓存 Transport type StandardTransportPool struct { - mu sync.RWMutex - pool map[string]*http.Transport // key = proxyURL (空字符串表示直连) - dialer *net.Dialer + mu sync.RWMutex + pool map[string]*standardTransportEntry // key = proxyURL (空字符串表示直连) + dialer *net.Dialer + lastCleanupTime time.Time } // NewStandardTransportPool 创建标准 Transport 连接池 func NewStandardTransportPool() *StandardTransportPool { return &StandardTransportPool{ - pool: make(map[string]*http.Transport), + pool: make(map[string]*standardTransportEntry), + lastCleanupTime: time.Now(), dialer: &net.Dialer{ Timeout: httpDialTimeout, KeepAlive: 30 * time.Second, @@ -35,9 +50,12 @@ func NewStandardTransportPool() *StandardTransportPool { // Get 获取或创建 Transport func (p *StandardTransportPool) Get(proxyURL string) *http.Transport { + now := time.Now() p.mu.RLock() - if t, ok := p.pool[proxyURL]; ok { + if entry, ok := p.pool[proxyURL]; ok { + t := entry.transport p.mu.RUnlock() + p.touch(proxyURL, now) return t } p.mu.RUnlock() @@ -46,13 +64,18 @@ func (p *StandardTransportPool) Get(proxyURL string) *http.Transport { defer p.mu.Unlock() // Double-check - if t, ok := p.pool[proxyURL]; ok { - return t + if entry, ok := p.pool[proxyURL]; ok { + entry.lastUsedAt = now + return entry.transport } + p.cleanupIdleLocked(now) + if len(p.pool) >= standardTransportMaxEntries { + p.deleteOldestLocked() + } t := &http.Transport{ DialContext: p.dialer.DialContext, - TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: httpTLSTimeout, MaxIdleConns: httpMaxIdleConns, MaxIdleConnsPerHost: httpIdleConnsPerHost, @@ -66,32 +89,83 @@ func (p *StandardTransportPool) Get(proxyURL string) *http.Transport { } } - p.pool[proxyURL] = t + p.pool[proxyURL] = &standardTransportEntry{transport: t, lastUsedAt: now} return t } +func (p *StandardTransportPool) touch(proxyURL string, now time.Time) { + p.mu.Lock() + if entry, ok := p.pool[proxyURL]; ok { + entry.lastUsedAt = now + } + p.cleanupIdleLocked(now) + p.mu.Unlock() +} + +func (p *StandardTransportPool) cleanupIdleLocked(now time.Time) { + if now.Sub(p.lastCleanupTime) < standardTransportCleanupInterval && len(p.pool) < standardTransportMaxEntries { + return + } + for proxyURL, entry := range p.pool { + if now.Sub(entry.lastUsedAt) > standardTransportIdleTTL { + entry.transport.CloseIdleConnections() + delete(p.pool, proxyURL) + } + } + p.lastCleanupTime = now +} + +func (p *StandardTransportPool) deleteOldestLocked() { + var oldestKey string + var oldestAt time.Time + for proxyURL, entry := range p.pool { + if oldestKey == "" || entry.lastUsedAt.Before(oldestAt) { + oldestKey = proxyURL + oldestAt = entry.lastUsedAt + } + } + if oldestKey != "" { + p.pool[oldestKey].transport.CloseIdleConnections() + delete(p.pool, oldestKey) + } +} + // Close 关闭所有 Transport func (p *StandardTransportPool) Close() { p.mu.Lock() defer p.mu.Unlock() - for _, t := range p.pool { - t.CloseIdleConnections() + for _, entry := range p.pool { + entry.transport.CloseIdleConnections() } - p.pool = make(map[string]*http.Transport) + p.pool = make(map[string]*standardTransportEntry) + p.lastCleanupTime = time.Now() } // FingerprintTransportPool OAuth/session_key 的 uTLS 指纹连接池 // 按 (accountID, proxyURL) 分桶:不同账号持有独立 Transport, // 互不共享连接 / session ticket / PSK,使得多账号在上游看来是独立 CLI 实例。 +const ( + fingerprintTransportMaxEntries = 100000 + fingerprintTransportIdleTTL = 2 * time.Hour + fingerprintTransportCleanupInterval = 10 * time.Minute +) + +type fingerprintTransportEntry struct { + transport *http.Transport + lastUsedAt time.Time +} + type FingerprintTransportPool struct { - mu sync.RWMutex - pool map[string]*http.Transport // key = "accountID|proxyURL" + mu sync.RWMutex + pool map[string]*fingerprintTransportEntry // key = "accountID|proxyURL|profile" + lastCleanupTime time.Time } // NewFingerprintTransportPool 创建 TLS 指纹 Transport 连接池 func NewFingerprintTransportPool() *FingerprintTransportPool { return &FingerprintTransportPool{ - pool: make(map[string]*http.Transport), + pool: make(map[string]*fingerprintTransportEntry), + lastCleanupTime: time.Now(), } } @@ -103,10 +177,13 @@ func fpKey(accountID int64, proxyURL, profile string) string { // Get 获取或创建指纹化 Transport(按账号 + profile 隔离) func (p *FingerprintTransportPool) Get(accountID int64, proxyURL, profile string) *http.Transport { key := fpKey(accountID, proxyURL, profile) + now := time.Now() p.mu.RLock() - if t, ok := p.pool[key]; ok { + if entry, ok := p.pool[key]; ok { + t := entry.transport p.mu.RUnlock() + p.touch(key, now) return t } p.mu.RUnlock() @@ -114,23 +191,82 @@ func (p *FingerprintTransportPool) Get(accountID int64, proxyURL, profile string p.mu.Lock() defer p.mu.Unlock() - if t, ok := p.pool[key]; ok { - return t + if entry, ok := p.pool[key]; ok { + entry.lastUsedAt = now + return entry.transport } + p.cleanupIdleLocked(now) + if len(p.pool) >= fingerprintTransportMaxEntries { + p.deleteOldestLocked() + } t := buildFingerprintTransportWithProfile(proxyURL, profile) - p.pool[key] = t + p.pool[key] = &fingerprintTransportEntry{transport: t, lastUsedAt: now} return t } +func (p *FingerprintTransportPool) touch(key string, now time.Time) { + p.mu.Lock() + if entry, ok := p.pool[key]; ok { + entry.lastUsedAt = now + } + p.cleanupIdleLocked(now) + p.mu.Unlock() +} + +func (p *FingerprintTransportPool) cleanupIdleLocked(now time.Time) { + if now.Sub(p.lastCleanupTime) < fingerprintTransportCleanupInterval && len(p.pool) < fingerprintTransportMaxEntries { + return + } + for key, entry := range p.pool { + if now.Sub(entry.lastUsedAt) > fingerprintTransportIdleTTL { + entry.transport.CloseIdleConnections() + delete(p.pool, key) + } + } + p.lastCleanupTime = now +} + +func (p *FingerprintTransportPool) deleteOldestLocked() { + var oldestKey string + var oldestAt time.Time + for key, entry := range p.pool { + if oldestKey == "" || entry.lastUsedAt.Before(oldestAt) { + oldestKey = key + oldestAt = entry.lastUsedAt + } + } + if oldestKey != "" { + p.pool[oldestKey].transport.CloseIdleConnections() + delete(p.pool, oldestKey) + } +} + +// RemoveAccount 移除指定账号的指纹 Transport,供账号删除/禁用事件调用。 +func (p *FingerprintTransportPool) RemoveAccount(accountID int64) { + if p == nil { + return + } + prefix := strconv.FormatInt(accountID, 10) + "|" + p.mu.Lock() + defer p.mu.Unlock() + for key, entry := range p.pool { + if strings.HasPrefix(key, prefix) { + entry.transport.CloseIdleConnections() + delete(p.pool, key) + } + } +} + // Close 关闭所有 Transport func (p *FingerprintTransportPool) Close() { p.mu.Lock() defer p.mu.Unlock() - for _, t := range p.pool { - t.CloseIdleConnections() + for _, entry := range p.pool { + entry.transport.CloseIdleConnections() } - p.pool = make(map[string]*http.Transport) + p.pool = make(map[string]*fingerprintTransportEntry) + p.lastCleanupTime = time.Now() } // getHTTPClient 根据账号类型从连接池获取 HTTP Client diff --git a/backend/main.go b/backend/main.go index caf2bba..56d8fcb 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,8 +1,8 @@ package main import ( - "github.com/DouDOU-start/airgate-claude/backend/internal/gateway" - sdkgrpc "github.com/DouDOU-start/airgate-sdk/runtimego/grpc" + "github.com/DevilGenius/airgate-claude/backend/internal/gateway" + sdkgrpc "github.com/DevilGenius/airgate-sdk/runtimego/grpc" ) func main() { diff --git a/web/package.json b/web/package.json index fe4c223..b12e557 100644 --- a/web/package.json +++ b/web/package.json @@ -3,13 +3,13 @@ "private": true, "version": "1.0.0", "type": "module", - "packageManager": "pnpm@11.1.1", + "packageManager": "pnpm@11.5.0", "scripts": { "build": "vite build", "dev": "vite build --watch" }, "dependencies": { - "@doudou-start/airgate-theme": "https://github.com/DouDOU-start/airgate-sdk/releases/download/v0.2.1/airgate-theme-v0.2.1.tgz", + "@devilgenius/airgate-theme": "https://github.com/DevilGenius/airgate-sdk/releases/download/v0.2.2/airgate-theme-v0.2.2.tgz", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 54a920c..9abc73b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,22 +8,22 @@ importers: .: dependencies: - '@doudou-start/airgate-theme': - specifier: https://github.com/DouDOU-start/airgate-sdk/releases/download/v0.2.1/airgate-theme-v0.2.1.tgz - version: https://github.com/DouDOU-start/airgate-sdk/releases/download/v0.2.1/airgate-theme-v0.2.1.tgz(react@19.2.5) + '@devilgenius/airgate-theme': + specifier: https://github.com/DevilGenius/airgate-sdk/releases/download/v0.2.2/airgate-theme-v0.2.2.tgz + version: https://github.com/DevilGenius/airgate-sdk/releases/download/v0.2.2/airgate-theme-v0.2.2.tgz(react@19.2.6) react: specifier: ^19.0.0 - version: 19.2.5 + version: 19.2.6 react-dom: specifier: ^19.0.0 - version: 19.2.5(react@19.2.5) + version: 19.2.6(react@19.2.6) devDependencies: '@types/react': specifier: ^19.0.0 - version: 19.2.14 + version: 19.2.15 '@types/react-dom': specifier: ^19.0.0 - version: 19.2.3(@types/react@19.2.14) + version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': specifier: ^4.3.0 version: 4.7.0(vite@6.4.2) @@ -36,92 +36,92 @@ importers: packages: - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@doudou-start/airgate-theme@https://github.com/DouDOU-start/airgate-sdk/releases/download/v0.2.1/airgate-theme-v0.2.1.tgz': - resolution: {tarball: https://github.com/DouDOU-start/airgate-sdk/releases/download/v0.2.1/airgate-theme-v0.2.1.tgz} - version: 0.2.1 + '@devilgenius/airgate-theme@https://github.com/DevilGenius/airgate-sdk/releases/download/v0.2.2/airgate-theme-v0.2.2.tgz': + resolution: {integrity: sha512-vN/msFVriS/yMfcLUJrvYPr8z/d4GcEJse4HNvswHoQg78+ujBs0jtPEJD7RN40PHrUZ0qoOwI5G4VTsHyc3KA==, tarball: https://github.com/DevilGenius/airgate-sdk/releases/download/v0.2.2/airgate-theme-v0.2.2.tgz} + version: 0.2.2 peerDependencies: react: ^19.0.0 @@ -300,141 +300,141 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rollup/rollup-android-arm-eabi@4.60.2': - resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.2': - resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.2': - resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.2': - resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.2': - resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.2': - resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': - resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.2': - resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.60.2': - resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.2': - resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.2': - resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.2': - resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.2': - resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.2': - resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.2': - resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.2': - resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.60.2': - resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.2': - resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.2': - resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.2': - resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.2': - resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.2': - resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.2': - resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.2': - resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.2': - resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} cpu: [x64] os: [win32] @@ -458,8 +458,8 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/react@19.2.15': + resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} @@ -467,8 +467,8 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - baseline-browser-mapping@2.10.24: - resolution: {integrity: sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==} + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} engines: {node: '>=6.0.0'} hasBin: true @@ -477,8 +477,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - caniuse-lite@1.0.30001791: - resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -495,8 +495,8 @@ packages: supports-color: optional: true - electron-to-chromium@1.5.345: - resolution: {integrity: sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==} + electron-to-chromium@1.5.364: + resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} @@ -544,13 +544,14 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -559,25 +560,25 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.12: - resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - react-dom@19.2.5: - resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: - react: ^19.2.5 + react: ^19.2.6 react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react@19.2.5: - resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} - rollup@4.60.2: - resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -652,25 +653,25 @@ packages: snapshots: - '@babel/code-frame@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0': + '@babel/core@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -680,93 +681,93 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': + '@babel/generator@7.29.7': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@7.29.7': {} - '@babel/helpers@7.29.2': + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 - '@babel/parser@7.29.2': + '@babel/parser@7.29.7': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/template@7.28.6': + '@babel/template@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': + '@babel/types@7.29.7': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 - '@doudou-start/airgate-theme@https://github.com/DouDOU-start/airgate-sdk/releases/download/v0.2.1/airgate-theme-v0.2.1.tgz(react@19.2.5)': + '@devilgenius/airgate-theme@https://github.com/DevilGenius/airgate-sdk/releases/download/v0.2.2/airgate-theme-v0.2.2.tgz(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 '@esbuild/aix-ppc64@0.25.12': optional: true @@ -867,117 +868,117 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rollup/rollup-android-arm-eabi@4.60.2': + '@rollup/rollup-android-arm-eabi@4.60.4': optional: true - '@rollup/rollup-android-arm64@4.60.2': + '@rollup/rollup-android-arm64@4.60.4': optional: true - '@rollup/rollup-darwin-arm64@4.60.2': + '@rollup/rollup-darwin-arm64@4.60.4': optional: true - '@rollup/rollup-darwin-x64@4.60.2': + '@rollup/rollup-darwin-x64@4.60.4': optional: true - '@rollup/rollup-freebsd-arm64@4.60.2': + '@rollup/rollup-freebsd-arm64@4.60.4': optional: true - '@rollup/rollup-freebsd-x64@4.60.2': + '@rollup/rollup-freebsd-x64@4.60.4': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.2': + '@rollup/rollup-linux-arm-musleabihf@4.60.4': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.2': + '@rollup/rollup-linux-arm64-gnu@4.60.4': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.2': + '@rollup/rollup-linux-arm64-musl@4.60.4': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.2': + '@rollup/rollup-linux-loong64-gnu@4.60.4': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.2': + '@rollup/rollup-linux-loong64-musl@4.60.4': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.2': + '@rollup/rollup-linux-ppc64-gnu@4.60.4': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.2': + '@rollup/rollup-linux-ppc64-musl@4.60.4': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.2': + '@rollup/rollup-linux-riscv64-gnu@4.60.4': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.2': + '@rollup/rollup-linux-riscv64-musl@4.60.4': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.2': + '@rollup/rollup-linux-s390x-gnu@4.60.4': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.2': + '@rollup/rollup-linux-x64-gnu@4.60.4': optional: true - '@rollup/rollup-linux-x64-musl@4.60.2': + '@rollup/rollup-linux-x64-musl@4.60.4': optional: true - '@rollup/rollup-openbsd-x64@4.60.2': + '@rollup/rollup-openbsd-x64@4.60.4': optional: true - '@rollup/rollup-openharmony-arm64@4.60.2': + '@rollup/rollup-openharmony-arm64@4.60.4': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.2': + '@rollup/rollup-win32-arm64-msvc@4.60.4': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.2': + '@rollup/rollup-win32-ia32-msvc@4.60.4': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.2': + '@rollup/rollup-win32-x64-gnu@4.60.4': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.2': + '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/estree@1.0.8': {} - '@types/react-dom@19.2.3(@types/react@19.2.14)': + '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@types/react@19.2.14': + '@types/react@19.2.15': dependencies: csstype: 3.2.3 '@vitejs/plugin-react@4.7.0(vite@6.4.2)': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 @@ -985,17 +986,17 @@ snapshots: transitivePeerDependencies: - supports-color - baseline-browser-mapping@2.10.24: {} + baseline-browser-mapping@2.10.32: {} browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.24 - caniuse-lite: 1.0.30001791 - electron-to-chromium: 1.5.345 - node-releases: 2.0.38 + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.364 + node-releases: 2.0.46 update-browserslist-db: 1.2.3(browserslist@4.28.2) - caniuse-lite@1.0.30001791: {} + caniuse-lite@1.0.30001793: {} convert-source-map@2.0.0: {} @@ -1005,7 +1006,7 @@ snapshots: dependencies: ms: 2.1.3 - electron-to-chromium@1.5.345: {} + electron-to-chromium@1.5.364: {} esbuild@0.25.12: optionalDependencies: @@ -1059,58 +1060,58 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.11: {} + nanoid@3.3.12: {} - node-releases@2.0.38: {} + node-releases@2.0.46: {} picocolors@1.1.1: {} picomatch@4.0.4: {} - postcss@8.5.12: + postcss@8.5.15: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 - react-dom@19.2.5(react@19.2.5): + react-dom@19.2.6(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 scheduler: 0.27.0 react-refresh@0.17.0: {} - react@19.2.5: {} + react@19.2.6: {} - rollup@4.60.2: + rollup@4.60.4: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.2 - '@rollup/rollup-android-arm64': 4.60.2 - '@rollup/rollup-darwin-arm64': 4.60.2 - '@rollup/rollup-darwin-x64': 4.60.2 - '@rollup/rollup-freebsd-arm64': 4.60.2 - '@rollup/rollup-freebsd-x64': 4.60.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 - '@rollup/rollup-linux-arm-musleabihf': 4.60.2 - '@rollup/rollup-linux-arm64-gnu': 4.60.2 - '@rollup/rollup-linux-arm64-musl': 4.60.2 - '@rollup/rollup-linux-loong64-gnu': 4.60.2 - '@rollup/rollup-linux-loong64-musl': 4.60.2 - '@rollup/rollup-linux-ppc64-gnu': 4.60.2 - '@rollup/rollup-linux-ppc64-musl': 4.60.2 - '@rollup/rollup-linux-riscv64-gnu': 4.60.2 - '@rollup/rollup-linux-riscv64-musl': 4.60.2 - '@rollup/rollup-linux-s390x-gnu': 4.60.2 - '@rollup/rollup-linux-x64-gnu': 4.60.2 - '@rollup/rollup-linux-x64-musl': 4.60.2 - '@rollup/rollup-openbsd-x64': 4.60.2 - '@rollup/rollup-openharmony-arm64': 4.60.2 - '@rollup/rollup-win32-arm64-msvc': 4.60.2 - '@rollup/rollup-win32-ia32-msvc': 4.60.2 - '@rollup/rollup-win32-x64-gnu': 4.60.2 - '@rollup/rollup-win32-x64-msvc': 4.60.2 + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 scheduler@0.27.0: {} @@ -1137,8 +1138,8 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.12 - rollup: 4.60.2 + postcss: 8.5.15 + rollup: 4.60.4 tinyglobby: 0.2.16 optionalDependencies: fsevents: 2.3.3 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index 5ed0b5a..f2f342e 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -1,2 +1,4 @@ allowBuilds: esbuild: true +minimumReleaseAgeExclude: + - '@devilgenius/airgate-theme@0.2.2' diff --git a/web/src/components/AccountForm.tsx b/web/src/components/AccountForm.tsx index bf6b9b2..7763b7b 100644 --- a/web/src/components/AccountForm.tsx +++ b/web/src/components/AccountForm.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { cssVar } from '@doudou-start/airgate-theme'; +import { cssVar } from '@devilgenius/airgate-theme'; import type { AccountFormProps, PluginBatchAccountInput, PluginOAuthBatchExchangeResult, -} from '@doudou-start/airgate-theme/plugin'; +} from '@devilgenius/airgate-theme/plugin'; type BatchExchangeResult = PluginOAuthBatchExchangeResult; type BatchAccountInput = PluginBatchAccountInput; diff --git a/web/src/components/AccountIdentity.tsx b/web/src/components/AccountIdentity.tsx index 657ae47..3ebf3ed 100644 --- a/web/src/components/AccountIdentity.tsx +++ b/web/src/components/AccountIdentity.tsx @@ -1,5 +1,5 @@ import type { CSSProperties } from 'react'; -import type { AccountSurfaceProps } from '@doudou-start/airgate-theme/plugin'; +import type { AccountSurfaceProps } from '@devilgenius/airgate-theme/plugin'; type AccountLike = { type?: string; diff --git a/web/src/components/UsageCostDetail.tsx b/web/src/components/UsageCostDetail.tsx index 152d1a8..0bebec0 100644 --- a/web/src/components/UsageCostDetail.tsx +++ b/web/src/components/UsageCostDetail.tsx @@ -1,14 +1,11 @@ import type { CSSProperties, ReactNode } from 'react'; -import type { UsageRecordSurfaceProps } from '@doudou-start/airgate-theme/plugin'; +import type { UsageRecordSurfaceProps } from '@devilgenius/airgate-theme/plugin'; interface UsageCostDetailItem { key?: string; label?: string; account_cost?: number; user_cost?: number; - billing_multiplier?: number; - currency?: string; - metadata?: Record; } interface UsageRecordLike { @@ -27,6 +24,8 @@ interface UsageRecordLike { service_tier?: string; input_price?: number; output_price?: number; + cache_creation_price?: number; + usage_metadata?: Record; } const panelStyle: CSSProperties = { @@ -100,16 +99,24 @@ const dividerStyle: CSSProperties = { borderTop: '1px solid var(--ag-border)', }; -function contextArray(context: UsageRecordSurfaceProps['context'], camel: string, snake: string): T[] { - const value = context?.[camel] ?? context?.[snake]; - return Array.isArray(value) ? value as T[] : []; -} - function recordFromContext(context: UsageRecordSurfaceProps['context']): UsageRecordLike { const record = context?.record; return record && typeof record === 'object' ? record as UsageRecordLike : {}; } +function metadataFromContext(context: UsageRecordSurfaceProps['context'], record: UsageRecordLike): Record { + const fromContext = context?.usage_metadata; + if (fromContext && typeof fromContext === 'object' && !Array.isArray(fromContext)) { + return fromContext as Record; + } + return record.usage_metadata ?? {}; +} + +function metadataText(metadata: Record, key: string) { + const value = metadata[key]?.trim(); + return value || ''; +} + function money(value: unknown) { const amount = typeof value === 'number' && Number.isFinite(value) ? value : 0; return `$${amount.toFixed(6)}`; @@ -134,42 +141,38 @@ function toCostLabel(raw: string): string { return stripTokenSuffix(s) + '成本'; } -function toUnitLabel(raw: string): string { - const s = raw.trim(); - if (s.includes('单价') || s.toLowerCase().includes('price')) return s; - return stripTokenSuffix(s) + '单价'; -} - function fallbackDetails(record: UsageRecordLike): UsageCostDetailItem[] { return [ { key: 'input_tokens', label: '输入', account_cost: record.input_cost }, { key: 'cached_input_tokens', label: '缓存读取', account_cost: record.cached_input_cost }, - { key: 'cache_creation_tokens', label: '缓存写入', account_cost: record.cache_creation_cost }, + { key: 'cache_creation_tokens', label: '缓存创建', account_cost: record.cache_creation_cost }, { key: 'output_tokens', label: '输出', account_cost: record.output_cost }, ].filter((item) => (item.account_cost ?? 0) > 0); } export function UsageCostDetail({ context }: UsageRecordSurfaceProps) { const record = recordFromContext(context); + const metadata = metadataFromContext(context, record); const isAdmin = context?.adminView !== false; - const details = contextArray(context, 'usageCostDetails', 'usage_cost_details'); - const rows = details.length > 0 ? details : fallbackDetails(record); + const rows = fallbackDetails(record); const unitPrices: { label: string; value: string }[] = []; - for (const item of rows) { - if (item.metadata?.unit_price && item.metadata?.unit) { - unitPrices.push({ - label: toUnitLabel(item.label || item.key || ''), - value: `$${Number(item.metadata.unit_price).toFixed(4)} / ${item.metadata.unit.replace(/^USD\//, '')}`, - }); - } + const imageUnitPrice = Number(metadataText(metadata, 'openai.image.unit_price')); + const imageUnit = metadataText(metadata, 'openai.image.unit') || 'USD/image'; + if (Number.isFinite(imageUnitPrice) && imageUnitPrice > 0) { + unitPrices.push({ + label: '图片单价', + value: `$${imageUnitPrice.toFixed(4)} / ${imageUnit.replace(/^USD\//, '')}`, + }); } if (unitPrices.length === 0) { if (record.input_price && record.input_price > 0) unitPrices.push({ label: '输入单价', value: `$${record.input_price.toFixed(4)} / 1M Token` }); if (record.output_price && record.output_price > 0) unitPrices.push({ label: '输出单价', value: `$${record.output_price.toFixed(4)} / 1M Token` }); + if (record.cache_creation_price && record.cache_creation_price > 0) + unitPrices.push({ label: '缓存创建单价', value: `$${record.cache_creation_price.toFixed(4)} / 1M Token` }); } const hasRateInfo = !!record.service_tier diff --git a/web/src/components/UsageMetricDetail.tsx b/web/src/components/UsageMetricDetail.tsx index 7271d64..90ed71f 100644 --- a/web/src/components/UsageMetricDetail.tsx +++ b/web/src/components/UsageMetricDetail.tsx @@ -1,13 +1,5 @@ import type { CSSProperties, ReactNode } from 'react'; -import type { UsageRecordSurfaceProps } from '@doudou-start/airgate-theme/plugin'; - -interface UsageMetric { - key?: string; - label?: string; - kind?: string; - unit?: string; - value?: number; -} +import type { UsageRecordSurfaceProps } from '@devilgenius/airgate-theme/plugin'; interface UsageRecordLike { model?: string; @@ -15,11 +7,19 @@ interface UsageRecordLike { output_tokens?: number; cached_input_tokens?: number; cache_creation_tokens?: number; - cache_creation_5m_tokens?: number; - cache_creation_1h_tokens?: number; reasoning_output_tokens?: number; + usage_metadata?: Record; } +const TOKEN_COLORS = { + cacheCreation: 'var(--ag-usage-token-cache-creation)', + cacheRead: 'var(--ag-usage-token-cache-read)', + input: 'var(--ag-usage-token-input)', + output: 'var(--ag-usage-token-output)', + reasoning: 'var(--ag-usage-token-reasoning)', + total: 'var(--ag-text)', +} as const; + const panelStyle: CSSProperties = { overflow: 'hidden', borderRadius: 'var(--radius)', @@ -73,6 +73,32 @@ const labelStyle: CSSProperties = { whiteSpace: 'nowrap', }; +const inlineValueStyle: CSSProperties = { + display: 'inline-flex', + minWidth: 0, + maxWidth: '100%', + alignItems: 'baseline', + justifyContent: 'flex-end', + gap: '0.25rem', +}; + +const inlineValueMetaStyle: CSSProperties = { + color: TOKEN_COLORS.reasoning, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; + +const cacheCreationDetailStyle: CSSProperties = { + ...inlineValueMetaStyle, + color: TOKEN_COLORS.cacheRead, +}; + +const inlineValueNumberStyle: CSSProperties = { + flexShrink: 0, +}; + const valueStyle: CSSProperties = { minWidth: 0, maxWidth: '12rem', @@ -86,22 +112,24 @@ const valueStyle: CSSProperties = { whiteSpace: 'nowrap', }; -function contextArray(context: UsageRecordSurfaceProps['context'], camel: string, snake: string): T[] { - const value = context?.[camel] ?? context?.[snake]; - return Array.isArray(value) ? value as T[] : []; -} +const strongValueStyle: CSSProperties = { + fontWeight: 700, +}; function recordFromContext(context: UsageRecordSurfaceProps['context']): UsageRecordLike { const record = context?.record; return record && typeof record === 'object' ? record as UsageRecordLike : {}; } -function norm(value?: string) { - return (value || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); +function metadataFromContext(context: UsageRecordSurfaceProps['context'], record: UsageRecordLike): Record { + const direct = context?.usage_metadata; + if (direct && typeof direct === 'object') return direct as Record; + return record.usage_metadata ?? {}; } -function numberValue(value: unknown) { - return typeof value === 'number' && Number.isFinite(value) ? value : 0; +function metadataNumber(metadata: Record, key: string) { + const value = Number(metadata[key]); + return Number.isFinite(value) ? value : 0; } function formatNumber(value: number) { @@ -110,31 +138,54 @@ function formatNumber(value: number) { : value.toLocaleString(undefined, { maximumFractionDigits: 4 }); } -function metricValue(metrics: UsageMetric[], keys: string[]) { - const metric = metrics.find((item) => keys.includes(norm(item.key || item.kind || item.label))); - return metric ? numberValue(metric.value) : 0; -} - -function Row({ label, tone, value }: { label: ReactNode; tone?: string; value: ReactNode }) { +function Row({ label, strong, tone, value }: { label: ReactNode; strong?: boolean; tone?: string; value: ReactNode }) { return (
{label} - {value} + {value}
); } +function outputTokenValue(reasoningTokens: number, outputTokens: number) { + return ( + + {reasoningTokens > 0 ? ( + (推理 {formatNumber(reasoningTokens)}) + ) : null} + {formatNumber(outputTokens)} + + ); +} + +function cacheCreationTokenValue(totalTokens: number, cacheCreation5mTokens: number, cacheCreation1hTokens: number) { + const parts: Array<[string, number]> = []; + if (cacheCreation5mTokens > 0) parts.push(['5m', cacheCreation5mTokens]); + if (cacheCreation1hTokens > 0) parts.push(['1h', cacheCreation1hTokens]); + + if (parts.length === 0) return formatNumber(totalTokens); + + return ( + + + ({parts.map(([label, value]) => `${label} ${formatNumber(value)}`).join(',')}) + + {formatNumber(totalTokens)} + + ); +} + export function UsageMetricDetail({ context }: UsageRecordSurfaceProps) { const record = recordFromContext(context); - const metrics = contextArray(context, 'usageMetrics', 'usage_metrics'); - const inputTokens = metricValue(metrics, ['input_tokens', 'input_token', 'prompt_tokens', 'prompt_token']) || record.input_tokens || 0; - const outputTokens = metricValue(metrics, ['output_tokens', 'output_token', 'completion_tokens', 'completion_token']) || record.output_tokens || 0; - const cacheReadTokens = metricValue(metrics, ['cached_input_tokens', 'cached_input_token', 'cache_read_tokens', 'cache_read_token']) || record.cached_input_tokens || 0; - const cacheCreationTokens = metricValue(metrics, ['cache_creation_tokens', 'cache_creation_input_tokens', 'cache_creation_token']) || record.cache_creation_tokens || 0; - const cacheCreation5mTokens = metricValue(metrics, ['cache_creation_5m_tokens', 'cache_creation_5m_input_tokens']) || record.cache_creation_5m_tokens || 0; - const cacheCreation1hTokens = metricValue(metrics, ['cache_creation_1h_tokens', 'cache_creation_1h_input_tokens']) || record.cache_creation_1h_tokens || 0; - const reasoningTokens = metricValue(metrics, ['reasoning_output_tokens', 'reasoning_tokens', 'reasoning_token']) || record.reasoning_output_tokens || 0; - const totalTokens = metricValue(metrics, ['total_tokens', 'total_token']) || inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens; + const metadata = metadataFromContext(context, record); + const inputTokens = record.input_tokens || 0; + const outputTokens = record.output_tokens || 0; + const cacheReadTokens = record.cached_input_tokens || 0; + const cacheCreationTokens = record.cache_creation_tokens || 0; + const cacheCreation5mTokens = metadataNumber(metadata, 'claude.cache_creation_5m_tokens'); + const cacheCreation1hTokens = metadataNumber(metadata, 'claude.cache_creation_1h_tokens'); + const reasoningTokens = record.reasoning_output_tokens || 0; + const totalTokens = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens; return (
@@ -143,14 +194,17 @@ export function UsageMetricDetail({ context }: UsageRecordSurfaceProps) { {record.model ?
{record.model}
: null}
- - - {cacheReadTokens > 0 ? : null} - {cacheCreationTokens > 0 ? : null} - {cacheCreation5mTokens > 0 ? : null} - {cacheCreation1hTokens > 0 ? : null} - {reasoningTokens > 0 ? : null} - + + + {cacheReadTokens > 0 ? : null} + {cacheCreationTokens > 0 ? ( + + ) : null} +
); diff --git a/web/src/components/UsageModelMeta.tsx b/web/src/components/UsageModelMeta.tsx index 443c4bc..c246561 100644 --- a/web/src/components/UsageModelMeta.tsx +++ b/web/src/components/UsageModelMeta.tsx @@ -1,4 +1,4 @@ -import type { UsageRecordSurfaceProps } from '@doudou-start/airgate-theme/plugin'; +import type { UsageRecordSurfaceProps } from '@devilgenius/airgate-theme/plugin'; import type { CSSProperties } from 'react'; type UsageContext = { @@ -10,6 +10,7 @@ const EFFORT_COLORS: Record = { medium: 'rgb(59,130,246)', high: 'rgb(249,115,22)', xhigh: 'rgb(239,68,68)', + max: 'rgb(239,68,68)', }; function chipStyle(color: string): CSSProperties { diff --git a/web/src/components/UsageWindow.tsx b/web/src/components/UsageWindow.tsx index 618e514..4088e14 100644 --- a/web/src/components/UsageWindow.tsx +++ b/web/src/components/UsageWindow.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type CSSProperties } from 'react'; -import type { AccountSurfaceProps } from '@doudou-start/airgate-theme/plugin'; +import type { AccountSurfaceProps } from '@devilgenius/airgate-theme/plugin'; interface UsageWindowItem { key?: string; diff --git a/web/src/index.ts b/web/src/index.ts index 8adede5..e9c8a61 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -1,5 +1,5 @@ import { AccountForm } from './components/AccountForm'; -import type { PluginFrontendModule } from '@doudou-start/airgate-theme/plugin'; +import type { PluginFrontendModule } from '@devilgenius/airgate-theme/plugin'; import { ClaudeIcon } from './components/ClaudeIcon'; import { AccountIdentity } from './components/AccountIdentity'; import { UsageCostDetail } from './components/UsageCostDetail';