antigravity-proxy 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.dockerignore +10 -0
  2. package/.env.example +2 -0
  3. package/Dockerfile +20 -0
  4. package/README.md +132 -0
  5. package/bun.lock +51 -0
  6. package/docker-compose.yml +11 -0
  7. package/package.json +22 -0
  8. package/reset-accounts.ts +17 -0
  9. package/screenshots/screenshot.png +0 -0
  10. package/src/api/quota.ts +187 -0
  11. package/src/auth/manager.ts +326 -0
  12. package/src/auth/oauth.ts +95 -0
  13. package/src/auth/storage.ts +39 -0
  14. package/src/auth/types.ts +73 -0
  15. package/src/config/manager.ts +141 -0
  16. package/src/config/types.ts +73 -0
  17. package/src/frontend/components/config-modal.html +109 -0
  18. package/src/frontend/components/header.html +55 -0
  19. package/src/frontend/components/main.html +64 -0
  20. package/src/frontend/css/styles.css +53 -0
  21. package/src/frontend/index.html +48 -0
  22. package/src/frontend/js/app.js +883 -0
  23. package/src/frontend/js/tailwind-config.js +40 -0
  24. package/src/scripts/check_quota_api.ts +70 -0
  25. package/src/scripts/check_sandbox_quota.ts +42 -0
  26. package/src/scripts/debug-accounts.ts +25 -0
  27. package/src/scripts/debug-quota-raw.ts +47 -0
  28. package/src/scripts/diagnose_claude_quota.ts +97 -0
  29. package/src/scripts/reset-accounts.ts +24 -0
  30. package/src/scripts/test-claude-cli.ts +55 -0
  31. package/src/scripts/test-request.ts +138 -0
  32. package/src/scripts/test-routing-logic.ts +40 -0
  33. package/src/scripts/test_claude_forced.ts +53 -0
  34. package/src/scripts/test_placeholder_model.ts +85 -0
  35. package/src/scripts/verify-claude.ts +51 -0
  36. package/src/server.ts +679 -0
  37. package/src/utils/cache.ts +18 -0
  38. package/src/utils/errors.ts +93 -0
  39. package/src/utils/headers.ts +172 -0
  40. package/src/utils/schema.ts +100 -0
  41. package/src/utils/transform.ts +532 -0
  42. package/tests/functional/gemini-functional.test.ts +122 -0
  43. package/tests/functional/models.test.ts +100 -0
  44. package/tests/unit/manager.test.ts +13 -0
  45. package/tests/unit/transform.test.ts +135 -0
  46. package/tsconfig.json +20 -0
package/.dockerignore ADDED
@@ -0,0 +1,10 @@
1
+ node_modules
2
+ *.log
3
+ antigravity-accounts.json
4
+ .git
5
+ .gitignore
6
+ README.md
7
+ AGENTS.md
8
+ proxy.log
9
+ server.log
10
+ bun.lockb
package/.env.example ADDED
@@ -0,0 +1,2 @@
1
+ BASE_URL=http://localhost:3000
2
+ SAFETY_THRESHOLD=BLOCK_NONE
package/Dockerfile ADDED
@@ -0,0 +1,20 @@
1
+ FROM oven/bun:alpine AS builder
2
+ WORKDIR /app
3
+
4
+ COPY package.json bun.lock ./
5
+ RUN bun install --frozen-lockfile --production
6
+
7
+ FROM oven/bun:alpine
8
+ WORKDIR /app
9
+
10
+ COPY --from=builder /app/node_modules ./node_modules
11
+ COPY src ./src
12
+ COPY package.json ./
13
+
14
+ # Create a data directory for persistence and set permissions
15
+ RUN mkdir -p /app/data && chown -R bun:bun /app/data
16
+
17
+ EXPOSE 3000
18
+
19
+ USER bun
20
+ CMD ["bun", "run", "src/server.ts"]
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # Antigravity Proxy
2
+
3
+ ![Antigravity Proxy Dashboard](screenshots/screenshot.png)
4
+
5
+ Antigravity Proxy is a high-performance, Bun-native gateway that exposes Google's internal Gemini and CloudCode APIs through an **OpenAI-compatible interface**. It enables seamless integration between advanced models (like Claude 3.5 Sonnet, Gemini 3, and GPT-equivalent models) and CLI agents (such as **OpenCode** or **Claude Code**), as well as any application supporting the OpenAI API standard.
6
+
7
+ This project is strongly inspired by [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth).
8
+
9
+ ## Features
10
+
11
+ - **OpenAI API Compatibility**: Full support for `v1/chat/completions` with streaming (SSE).
12
+ - **Multi-Agent Support**: Specifically designed to work with **Claude Code**, **OpenCode**, and other agentic frameworks.
13
+ - **Account Rotation & Health Scoring**: Automatically rotates multiple Google accounts, penalizing those with errors and favoring healthy ones.
14
+ - **Quota Management**: Real-time monitoring and automatic cooldowns (backoff) on `429 Too Many Requests` errors.
15
+ - **Dual-Pool Routing**:
16
+ - **CLI Pool**: Routes to production endpoints (Gemini 2.5/3 Flash & Pro).
17
+ - **Sandbox Pool**: Accesses internal/experimental models (Claude 3.5 Sonnet, Thinking models, GPT-equivalent).
18
+ - **Integrated Dashboard**: Manage accounts, monitor health, and view real-time logs via a built-in web interface.
19
+ - **Automatic Project Discovery**: Auto-detects Google Cloud Project IDs via Cloud SDK impersonation.
20
+
21
+ ## Deployment Options
22
+
23
+ ### Bunx (Recommended)
24
+ You can run the proxy instantly using `bunx`:
25
+ ```bash
26
+ bunx antigravity-proxy
27
+ ```
28
+
29
+ ### Docker Hub
30
+ ```bash
31
+ docker run -d -p 3000:3000 -e BASE_URL=http://localhost:3000 --name antigravity-proxy frieserpaldi/antigravity-proxy:0.1.0
32
+ ```
33
+
34
+ ### Local Execution (Bun)
35
+ Requirements: Bun (v1.0.0 or higher).
36
+ ```bash
37
+ bun install
38
+ bun run start
39
+ ```
40
+ The server starts on port 3000.
41
+
42
+ ## Integration Guides
43
+
44
+ ### Claude Code Configuration
45
+ To use **Claude Code** with Antigravity Proxy, point the API base URL to your local instance and specify a model from the Sandbox Pool:
46
+
47
+ ```bash
48
+ # Point Claude Code to the proxy
49
+ export CLAUDE_CODE_API_BASE="http://localhost:3000/v1"
50
+
51
+ # Run Claude specifying an Antigravity model
52
+ claude --model antigravity-claude-sonnet-4-5
53
+ ```
54
+
55
+ ### OpenCode Configuration
56
+ Add the following provider to your `~/.config/opencode/opencode.json` under the `"provider"` key:
57
+
58
+ ```json
59
+ "provider": {
60
+ "antigravity-proxy": {
61
+ "npm": "@ai-sdk/openai-compatible",
62
+ "name": "Antigravity Proxy",
63
+ "options": {
64
+ "baseURL": "http://localhost:3000/v1"
65
+ },
66
+ "models": {
67
+ "antigravity-gemini-3-pro-low": {
68
+ "name": "Gemini 3 Pro Low (Antigravity)",
69
+ "limit": { "context": 1048576, "output": 65535 }
70
+ },
71
+ "antigravity-gemini-3-pro-high": {
72
+ "name": "Gemini 3 Pro High (Antigravity)",
73
+ "limit": { "context": 1048576, "output": 65535 }
74
+ },
75
+ "antigravity-gemini-3-flash": {
76
+ "name": "Gemini 3 Flash (Antigravity)",
77
+ "limit": { "context": 1048576, "output": 65536 }
78
+ },
79
+ "antigravity-claude-sonnet-4-5": {
80
+ "name": "Claude Sonnet 4.5 (Antigravity)",
81
+ "limit": { "context": 200000, "output": 64000 }
82
+ },
83
+ "antigravity-claude-sonnet-4-5-thinking-low": {
84
+ "name": "Claude Sonnet 4.5 Think Low (Antigravity)",
85
+ "limit": { "context": 200000, "output": 64000 }
86
+ },
87
+ "antigravity-claude-sonnet-4-5-thinking-medium": {
88
+ "name": "Claude Sonnet 4.5 Think Medium (Antigravity)",
89
+ "limit": { "context": 200000, "output": 64000 }
90
+ },
91
+ "antigravity-claude-sonnet-4-5-thinking-high": {
92
+ "name": "Claude Sonnet 4.5 Think High (Antigravity)",
93
+ "limit": { "context": 200000, "output": 64000 }
94
+ },
95
+ "antigravity-claude-opus-4-5-thinking-low": {
96
+ "name": "Claude Opus 4.5 Think Low (Antigravity)",
97
+ "limit": { "context": 200000, "output": 64000 }
98
+ },
99
+ "antigravity-claude-opus-4-5-thinking-medium": {
100
+ "name": "Claude Opus 4.5 Think Medium (Antigravity)",
101
+ "limit": { "context": 200000, "output": 64000 }
102
+ },
103
+ "antigravity-claude-opus-4-5-thinking-high": {
104
+ "name": "Claude Opus 4.5 Think High (Antigravity)",
105
+ "limit": { "context": 200000, "output": 64000 }
106
+ },
107
+ "gemini-2.5-flash": {
108
+ "name": "Gemini 2.5 Flash (CLI)",
109
+ "limit": { "context": 1048576, "output": 65536 }
110
+ },
111
+ "gemini-2.5-pro": {
112
+ "name": "Gemini 2.5 Pro (CLI)",
113
+ "limit": { "context": 1048576, "output": 65536 }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ ## How It Works
121
+
122
+ Antigravity Proxy acts as a sophisticated bridge that translates OpenAI-formatted requests into Google's internal RPC protocols. It manages the complexities of authentication, session handling, and response streaming, allowing you to use high-tier models with your favorite tools.
123
+
124
+ ### Account Selection Strategy
125
+ - **Hybrid (Default)**: Ranks accounts based on `(Health Score × 2) + (Idle Time × 0.1)`.
126
+ - **Sticky**: Keeps a client session tied to the same account for consistency.
127
+ - **Round-Robin**: Cycles through all available accounts evenly.
128
+
129
+ ## Security Notes
130
+ - **Safety Filters**: Controlled via `SAFETY_THRESHOLD` (default: `BLOCK_NONE`).
131
+ - **Credentials**: OAuth tokens are stored locally in `antigravity-accounts.json`. Do not share or commit this file.
132
+
package/bun.lock ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "antigravity-llm-proxy",
7
+ "dependencies": {
8
+ "bun": "^1.0.0",
9
+ },
10
+ "devDependencies": {
11
+ "@types/bun": "latest",
12
+ "typescript": "^5.9.3",
13
+ },
14
+ },
15
+ },
16
+ "packages": {
17
+ "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mh78f4B+vNTOhFpI7RWHRWDqSKTnFXj/MauRx7I/GmNwEfw56sUx98gWRwXyF4lkW+9VNU+33wuw6E+M22W66w=="],
18
+
19
+ "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-dFfKdSVz6Ois5zjEJboUC7igcYAVd+c//ajotd0L6WUQAKQrHMVq/+6LjOj/0zjC6VPFNGWzeF8erymNo1y0Jw=="],
20
+
21
+ "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-bUND1aQoTCfIL+idALT7FWtuX59ltOIRo954c7p/JkESbSIJ01jY06BSNVbkGk8RQM19v/7qiqZZqi4NyO4Utw=="],
22
+
23
+ "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-m03OtzEs+/RkWtk6tBf8yw0GW4P8ajfzTXnTt984tQBgkMubGQYUyUnFasWgr3mD2820LhkVjhYeBf1rkz/biQ=="],
24
+
25
+ "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-QDxrROdUnC1d/uoilXtUeFHaLhYdRN7dRIzw/Iqj/vrrhnkA6VS+HYoCWtyyVvci/K+JrPmDwxOWlSRpmV4INA=="],
26
+
27
+ "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-uttKQ/eIRVGc4uBtLRqmQqXGf57/dmQaF0AEd37RQNRRRd1P/VYnFMiMcVaot3HJ6IFjHjGtcPO9ekT49LxBYQ=="],
28
+
29
+ "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-Jlb/AcrIFU3QDeR3EL4UVT1CIKqnLJDgbU+R0k/+NaSWMrBEpZV+gJJT5L1cmEKTNhU/d+c7hudxkjtqA7XXqA=="],
30
+
31
+ "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-aK8fvkCosrHRG3CNdVqMom1C8Rj3XkqZp0ZFSBXgaXlKP22RkxlEE9tS7OmSq9yVgEk6euTB3dW4NFo/jlXqeg=="],
32
+
33
+ "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-lySQQ7zJJsoa5hQH+PE5bQyQaTI8G2Erszhu4iQuDtsocwy3zSxjB6TxGWTd4HmetPl9aRvg3nb2KR8RVAd7ug=="],
34
+
35
+ "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.7", "", { "os": "win32", "cpu": "x64" }, "sha512-3QdIGdSn3fkssCq/vPjtPLAQxo+eMUzcwJedn1c5mXDy1AoisjhoxhWnbVl8+uk+wt9N6JUPdISoe0N4OdwXfg=="],
36
+
37
+ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.7", "", { "os": "win32", "cpu": "x64" }, "sha512-wMgELfW5vFceh4qEOYb5iV5TjrjjnBJzE383ixA3kqGKzaubksSxNc11eZhS0ptcJ5a0UjN5hfbMh6sYoh+cRQ=="],
38
+
39
+ "@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
40
+
41
+ "@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
42
+
43
+ "bun": ["bun@1.3.7", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.7", "@oven/bun-darwin-x64": "1.3.7", "@oven/bun-darwin-x64-baseline": "1.3.7", "@oven/bun-linux-aarch64": "1.3.7", "@oven/bun-linux-aarch64-musl": "1.3.7", "@oven/bun-linux-x64": "1.3.7", "@oven/bun-linux-x64-baseline": "1.3.7", "@oven/bun-linux-x64-musl": "1.3.7", "@oven/bun-linux-x64-musl-baseline": "1.3.7", "@oven/bun-windows-x64": "1.3.7", "@oven/bun-windows-x64-baseline": "1.3.7" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-ha86NG8WiAXYR7eQw/9S+7V7Lo8KfD36XutWJNS1VndzaipWS0QIen5n3K9MT3PpP/sdGmmHjhkrU0sCM2lGGQ=="],
44
+
45
+ "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
46
+
47
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
48
+
49
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
50
+ }
51
+ }
@@ -0,0 +1,11 @@
1
+ services:
2
+ proxy:
3
+ build: .
4
+ ports:
5
+ - "3000:3000"
6
+ environment:
7
+ - ACCOUNTS_FILE=/app/data/antigravity-accounts.json
8
+ - BASE_URL=http://127.0.0.1:3000
9
+ volumes:
10
+ - ./data:/app/data:z
11
+ restart: unless-stopped
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "antigravity-proxy",
3
+ "version": "0.1.1",
4
+ "module": "index.ts",
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "bun run src/server.ts",
8
+ "dev": "bun run --watch src/server.ts",
9
+ "lint:check": "bun x tsc --noEmit --skipLibCheck",
10
+ "test": "bun test tests/unit",
11
+ "test:functional": "bun test tests/functional",
12
+ "reset-accounts": "bun run src/scripts/reset-accounts.ts",
13
+ "lint:html": "bun -e \"const c=await Bun.file('src/frontend/index.html').text(); try { new Function(c.match(/<script>([\\s\\S]*?)<\\/script>/)[1]) } catch(e) { console.error(e); process.exit(1) }\""
14
+ },
15
+ "dependencies": {
16
+ "bun": "^1.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/bun": "latest",
20
+ "typescript": "^5.9.3"
21
+ }
22
+ }
@@ -0,0 +1,17 @@
1
+ import { readFileSync, writeFileSync } from 'fs';
2
+
3
+ const path = './antigravity-accounts.json';
4
+ const data = JSON.parse(readFileSync(path, 'utf8'));
5
+
6
+ for (const acc of data.accounts) {
7
+ acc.healthScore = 100;
8
+ acc.consecutiveFailures = 0;
9
+ acc.cooldowns = {};
10
+ acc.modelScores = {};
11
+ acc.history = [];
12
+ acc.quota = [];
13
+ delete acc.challenge;
14
+ }
15
+
16
+ writeFileSync(path, JSON.stringify(data, null, 2));
17
+ console.log("Reset all accounts successfully.");
Binary file
@@ -0,0 +1,187 @@
1
+ import { getImpersonationHeaders, generateFingerprint } from "../utils/headers";
2
+ import { type AntigravityAccount } from "../auth/types";
3
+ import { getAccounts, saveAccounts } from "../auth/manager";
4
+ import { refreshAccessToken } from "../auth/oauth";
5
+
6
+ export async function fetchQuota(account: AntigravityAccount, retry = true): Promise<AntigravityAccount['quota'] | null> {
7
+ if (!account.projectId || !account.accessToken) return null;
8
+
9
+ if (!account.fingerprint || !account.fingerprint.clientMetadata?.sqmId) {
10
+ account.fingerprint = generateFingerprint(account.email);
11
+ }
12
+
13
+ try {
14
+ const res = await fetch(`https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels`, {
15
+ method: "POST",
16
+ headers: {
17
+ ...getImpersonationHeaders(account.accessToken, account.fingerprint),
18
+ "User-Agent": "antigravity",
19
+ },
20
+ body: JSON.stringify({
21
+ project: account.projectId
22
+ })
23
+ });
24
+
25
+ if (res.status === 401 && retry) {
26
+ console.log(`Quota fetch 401 for ${account.email}, refreshing token...`);
27
+ try {
28
+ const tokens = await refreshAccessToken(account.refreshToken);
29
+ account.accessToken = tokens.access_token;
30
+ account.expiresAt = Date.now() + (tokens.expires_in * 1000);
31
+ await saveAccounts(getAccounts());
32
+ return fetchQuota(account, false); // Retry once
33
+ } catch (e) {
34
+ console.error(`Failed to refresh token for ${account.email} during quota fetch`, e);
35
+ return null;
36
+ }
37
+ }
38
+
39
+ if (!res.ok) {
40
+ console.error(`Quota fetch failed for ${account.email}: ${res.status}`);
41
+ return null;
42
+ }
43
+
44
+ return parseQuotaResponse(await res.json());
45
+ } catch (e) {
46
+ console.error(`Error fetching quota for ${account.email}`, e);
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function getNextMidnightPT(): string {
52
+ const now = new Date();
53
+ const ptDateStr = now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" });
54
+ const ptDate = new Date(ptDateStr);
55
+
56
+ const midnightPT = new Date(ptDate);
57
+ midnightPT.setHours(24, 0, 0, 0);
58
+
59
+ const diffMs = midnightPT.getTime() - ptDate.getTime();
60
+ return new Date(now.getTime() + diffMs).toISOString();
61
+ }
62
+
63
+ export const supportedModelsCache: Set<string> = new Set();
64
+
65
+ function parseQuotaResponse(data: any): AntigravityAccount['quota'] | null {
66
+ // Handle both array and map formats
67
+ let rawModels = data.availableModels || data.models || [];
68
+ if (!Array.isArray(rawModels) && typeof rawModels === 'object') {
69
+ rawModels = Object.values(rawModels);
70
+ }
71
+
72
+ const groups = new Map<string, any>();
73
+
74
+ for (const m of rawModels) {
75
+ if (!m.quotaInfo) continue;
76
+
77
+ const label = m.displayMetadata?.label || m.displayName || m.model?.name || "Unknown";
78
+ const lowerLabel = label.toLowerCase();
79
+
80
+ // Skip unknown or placeholder models
81
+ if (label === "Unknown" || lowerLabel === "unknown") continue;
82
+
83
+ // Cache supported model ID/Name
84
+ if (m.model?.name) {
85
+ const modelName = m.model.name.replace("models/", "");
86
+ supportedModelsCache.add(modelName);
87
+ } else if (label !== "Unknown") {
88
+ supportedModelsCache.add(label);
89
+ }
90
+
91
+ const allowedPatterns = [
92
+ "Claude",
93
+ "Anthropic",
94
+ "GPT",
95
+ "Gemini",
96
+ "chat",
97
+ "tab_flash",
98
+ "MODEL_PLACEHOLDER"
99
+ ];
100
+
101
+ const isAllowed = allowedPatterns.some(pattern => label.includes(pattern));
102
+
103
+ if (!isAllowed) continue;
104
+
105
+ const remainingFraction = m.quotaInfo.remainingFraction ?? 0;
106
+
107
+ const limitName = m.quotaInfo.limitName || label;
108
+
109
+ if (groups.has(limitName)) {
110
+ const group = groups.get(limitName);
111
+ // Append label if not already present
112
+ if (!group.labels.includes(label)) {
113
+ group.labels.push(label);
114
+ group.labels.sort();
115
+ group.groupName = group.labels.join(" / ");
116
+ }
117
+ } else {
118
+ let resetTime = m.quotaInfo.quotaResetTime ||
119
+ m.quotaResetTime ||
120
+ m.quotaInfo.resetTime ||
121
+ m.resetTime ||
122
+ m.quotaInfo.nextResetTime ||
123
+ m.nextResetTime ||
124
+ m.quotaInfo.quota_reset_time ||
125
+ m.quota_reset_time;
126
+
127
+ if (typeof resetTime === 'number') {
128
+ if (resetTime < 10000000000) resetTime *= 1000;
129
+ resetTime = new Date(resetTime).toISOString();
130
+ }
131
+
132
+ if (typeof resetTime === 'string' && resetTime.endsWith('s') && /^\d+/.test(resetTime)) {
133
+ const seconds = parseInt(resetTime, 10);
134
+ if (!isNaN(seconds)) {
135
+ resetTime = new Date(Date.now() + seconds * 1000).toISOString();
136
+ }
137
+ }
138
+
139
+ if (!resetTime || Number.isNaN(new Date(resetTime).getTime())) {
140
+ resetTime = getNextMidnightPT();
141
+ }
142
+
143
+ const diffMs = Math.max(0, new Date(resetTime).getTime() - Date.now());
144
+ const hours = Math.floor(diffMs / (1000 * 60 * 60));
145
+ const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
146
+ const resetIn = `${hours}h ${minutes}m`;
147
+ const pct = Math.round(remainingFraction * 100);
148
+ const quotaLeft = `${pct}%`;
149
+
150
+ groups.set(limitName, {
151
+ groupName: label,
152
+ labels: [label],
153
+ limit: m.quotaInfo.quotaLimit || "Unknown",
154
+ usage: m.quotaInfo.quotaUsage || "Unknown",
155
+ limitName: limitName,
156
+ remainingFraction: remainingFraction,
157
+ resetTime: resetTime,
158
+ quotaLeft,
159
+ resetIn
160
+ });
161
+ }
162
+ }
163
+
164
+ const results = Array.from(groups.values()).map(g => {
165
+ const { labels, ...rest } = g;
166
+ return rest;
167
+ });
168
+
169
+ // Sort by name for consistency
170
+ results.sort((a, b) => a.groupName.localeCompare(b.groupName));
171
+
172
+ return results.length > 0 ? results : null;
173
+ }
174
+
175
+ export async function refreshAllQuotas() {
176
+ const accounts = getAccounts();
177
+
178
+ await Promise.all(accounts.map(async (acc) => {
179
+ if (acc.projectId) {
180
+ const quota = await fetchQuota(acc);
181
+ if (quota) {
182
+ acc.quota = quota;
183
+ await saveAccounts(getAccounts());
184
+ }
185
+ }
186
+ }));
187
+ }