ag-quota 0.0.2
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.
- package/LICENSE +15 -0
- package/README.md +115 -0
- package/bin/ag-quota.js +466 -0
- package/dist/cli-auth.d.ts +12 -0
- package/dist/cli-auth.d.ts.map +1 -0
- package/dist/cli-auth.js +89 -0
- package/dist/cloud.d.ts +40 -0
- package/dist/cloud.d.ts.map +1 -0
- package/dist/cloud.js +110 -0
- package/dist/config.d.ts +101 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +88 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +248 -0
- package/package.json +57 -0
- package/src/cli-auth.ts +136 -0
- package/src/cli.ts +154 -0
- package/src/cloud.ts +195 -0
- package/src/config.ts +193 -0
- package/src/index.test.ts +96 -0
- package/src/index.ts +379 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ISC License
|
|
3
|
+
* Copyright (c) 2026 Philipp
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Quota data source
|
|
12
|
+
* - "cloud": Fetch from Cloud Code API (requires opencode-antigravity-auth)
|
|
13
|
+
* - "local": Fetch from local language server process
|
|
14
|
+
* - "auto": Try cloud first, fallback to local
|
|
15
|
+
*/
|
|
16
|
+
export type QuotaSource = "cloud" | "local" | "auto";
|
|
17
|
+
|
|
18
|
+
export interface QuotaIndicator {
|
|
19
|
+
threshold: number;
|
|
20
|
+
symbol: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Configuration options for the quota plugin
|
|
25
|
+
*/
|
|
26
|
+
export interface QuotaConfig {
|
|
27
|
+
/**
|
|
28
|
+
* Where to fetch quota data from.
|
|
29
|
+
* - "cloud": Use Cloud Code API (requires opencode-antigravity-auth)
|
|
30
|
+
* - "local": Use local language server process
|
|
31
|
+
* - "auto": Try cloud first, fallback to local (default)
|
|
32
|
+
* @default "auto"
|
|
33
|
+
*/
|
|
34
|
+
quotaSource?: QuotaSource;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Format string for quota display.
|
|
38
|
+
* Available placeholders:
|
|
39
|
+
* - {category} - Category name (Flash, Pro, Claude/GPT)
|
|
40
|
+
* - {percent} - Quota percentage (e.g., "85.5")
|
|
41
|
+
* - {resetIn} - Relative time until reset (e.g., "2h 30m")
|
|
42
|
+
* - {resetAt} - Absolute reset time (e.g., "10:30 PM")
|
|
43
|
+
* - {model} - Current model ID
|
|
44
|
+
*
|
|
45
|
+
* For all quotas mode, the format is applied per category and joined with separator.
|
|
46
|
+
* @default "{category}: {percent}% ({resetIn})"
|
|
47
|
+
*/
|
|
48
|
+
format?: string;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Separator between categories when showing all quotas
|
|
52
|
+
* @default " | "
|
|
53
|
+
*/
|
|
54
|
+
separator?: string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Show all quota categories or only the current model's quota
|
|
58
|
+
* @default "all"
|
|
59
|
+
*/
|
|
60
|
+
displayMode?: "all" | "current";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Always append quota info, even when unavailable
|
|
64
|
+
* @default true
|
|
65
|
+
*/
|
|
66
|
+
alwaysAppend?: boolean;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The marker string used to separate quota info from the message
|
|
70
|
+
* @default "> AG Quota:"
|
|
71
|
+
*/
|
|
72
|
+
quotaMarker?: string;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Polling interval in milliseconds
|
|
76
|
+
* @default 30000 (30 seconds)
|
|
77
|
+
*/
|
|
78
|
+
pollingInterval?: number;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Array of quota usage percentages (remaining) that trigger alerts
|
|
82
|
+
* @default [0.5, 0.1, 0.05] (50%, 10%, 5%)
|
|
83
|
+
*/
|
|
84
|
+
alertThresholds?: number[];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Visual indicators appended to quota percentages when remaining fraction is low.
|
|
88
|
+
* The most severe matching indicator is chosen.
|
|
89
|
+
*
|
|
90
|
+
* Example: with indicators [{threshold: 0.1, symbol: "⚠️"}, {threshold: 0.05, symbol: "⛔"}]
|
|
91
|
+
* a remainingFraction of 0.04 will show "⛔".
|
|
92
|
+
*
|
|
93
|
+
* @default [{threshold: 0.1, symbol: "⚠️"}, {threshold: 0.05, symbol: "⛔"}]
|
|
94
|
+
*/
|
|
95
|
+
indicators?: QuotaIndicator[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const DEFAULT_CONFIG: Required<QuotaConfig> = {
|
|
99
|
+
quotaSource: "auto",
|
|
100
|
+
format: "{category}: {percent}% ({resetIn})",
|
|
101
|
+
separator: " | ",
|
|
102
|
+
displayMode: "all",
|
|
103
|
+
alwaysAppend: true,
|
|
104
|
+
quotaMarker: "> AG Quota:",
|
|
105
|
+
pollingInterval: 30000,
|
|
106
|
+
alertThresholds: [0.5, 0.1, 0.05],
|
|
107
|
+
indicators: [
|
|
108
|
+
{ threshold: 0.2, symbol: "⚠️" },
|
|
109
|
+
{ threshold: 0.05, symbol: "🛑" },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Load configuration from file system.
|
|
115
|
+
* Searches in order:
|
|
116
|
+
* 1. .opencode/ag-quota.json (project-local)
|
|
117
|
+
* 2. ~/.config/opencode/ag-quota.json (user global)
|
|
118
|
+
*
|
|
119
|
+
* @param projectDir - The project directory to search from
|
|
120
|
+
* @returns Merged configuration with defaults
|
|
121
|
+
*/
|
|
122
|
+
export async function loadConfig(projectDir?: string): Promise<Required<QuotaConfig>> {
|
|
123
|
+
const paths: string[] = [];
|
|
124
|
+
|
|
125
|
+
// Project-local config
|
|
126
|
+
if (projectDir) {
|
|
127
|
+
paths.push(join(projectDir, ".opencode", "ag-quota.json"));
|
|
128
|
+
} else {
|
|
129
|
+
paths.push(join(process.cwd(), ".opencode", "ag-quota.json"));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// User global config
|
|
133
|
+
paths.push(join(homedir(), ".config", "opencode", "ag-quota.json"));
|
|
134
|
+
|
|
135
|
+
for (const configPath of paths) {
|
|
136
|
+
try {
|
|
137
|
+
const content = await readFile(configPath, "utf-8");
|
|
138
|
+
const userConfig = JSON.parse(content) as QuotaConfig;
|
|
139
|
+
return { ...DEFAULT_CONFIG, ...userConfig };
|
|
140
|
+
} catch {
|
|
141
|
+
// File doesn't exist or is invalid, try next
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return DEFAULT_CONFIG;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Format a quota entry using the format string.
|
|
151
|
+
* Placeholders are only replaced if they exist in the format string.
|
|
152
|
+
*/
|
|
153
|
+
export function formatQuotaEntry(
|
|
154
|
+
format: string,
|
|
155
|
+
data: {
|
|
156
|
+
category: string;
|
|
157
|
+
percent: string;
|
|
158
|
+
resetIn: string | null; // Relative: "2h 30m"
|
|
159
|
+
resetAt: string | null; // Absolute: "10:30 PM"
|
|
160
|
+
model: string;
|
|
161
|
+
},
|
|
162
|
+
): string {
|
|
163
|
+
let result = format
|
|
164
|
+
.replace("{category}", data.category)
|
|
165
|
+
.replace("{percent}", data.percent)
|
|
166
|
+
.replace("{model}", data.model);
|
|
167
|
+
|
|
168
|
+
// Handle resetIn (relative time) - e.g., "2h 30m"
|
|
169
|
+
if (format.includes("{resetIn}")) {
|
|
170
|
+
if (data.resetIn) {
|
|
171
|
+
result = result.replace("{resetIn}", data.resetIn);
|
|
172
|
+
} else {
|
|
173
|
+
result = result
|
|
174
|
+
.replace(/\s*\(\{resetIn}\)/, "")
|
|
175
|
+
.replace(/\s*\{resetIn}/, "");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Handle resetAt (absolute time) - e.g., "10:30 PM"
|
|
180
|
+
if (format.includes("{resetAt}")) {
|
|
181
|
+
if (data.resetAt) {
|
|
182
|
+
result = result.replace("{resetAt}", data.resetAt);
|
|
183
|
+
} else {
|
|
184
|
+
result = result
|
|
185
|
+
.replace(/\s*\(\{resetAt}\)/, "")
|
|
186
|
+
.replace(/\s*\{resetAt}/, "");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export { DEFAULT_CONFIG };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { fetchAntigravityStatus, type ShellRunner } from "./index.js";
|
|
3
|
+
|
|
4
|
+
type HttpRequestModule = typeof import("node:http");
|
|
5
|
+
type HttpsRequestModule = typeof import("node:https");
|
|
6
|
+
|
|
7
|
+
type MockedHttpModule = HttpRequestModule & { request: ReturnType<typeof vi.fn> };
|
|
8
|
+
type MockedHttpsModule = HttpsRequestModule & { request: ReturnType<typeof vi.fn> };
|
|
9
|
+
|
|
10
|
+
vi.mock("node:http", () => ({ request: vi.fn() }));
|
|
11
|
+
vi.mock("node:https", () => ({ request: vi.fn() }));
|
|
12
|
+
|
|
13
|
+
describe("fetchAntigravityStatus", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.resetAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should fetch status correctly when discovery succeeds", async () => {
|
|
19
|
+
const http = (await import("node:http")) as unknown as MockedHttpModule;
|
|
20
|
+
const https = (await import("node:https")) as unknown as MockedHttpsModule;
|
|
21
|
+
|
|
22
|
+
const mockCsrf = "test-csrf-123";
|
|
23
|
+
const mockPort = 12345;
|
|
24
|
+
|
|
25
|
+
const shellRunner: ShellRunner = vi.fn().mockImplementation(async (cmd: string) => {
|
|
26
|
+
if (cmd.includes("ps aux")) {
|
|
27
|
+
return `user 123 0.0 0.1 1234 5678 ? Ss 12:00 0:00 /path/to/language_server --csrf_token=${mockCsrf} --extension_server_port=${mockPort}`;
|
|
28
|
+
}
|
|
29
|
+
if (cmd.includes("ss -tlnp")) {
|
|
30
|
+
return `LISTEN 0 128 127.0.0.1:${mockPort} 0.0.0.0:* users:(("language_server",pid=123,fd=4))`;
|
|
31
|
+
}
|
|
32
|
+
return "";
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const mockResponse = {
|
|
36
|
+
userStatus: {
|
|
37
|
+
cascadeModelConfigData: {
|
|
38
|
+
clientModelConfigs: [
|
|
39
|
+
{ modelName: "test-model", quotaInfo: { remainingFraction: 0.5 } },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Mock https.request to fail (trigger fallback to http)
|
|
46
|
+
https.request.mockImplementationOnce((_options: unknown, _cb: unknown) => {
|
|
47
|
+
let errorHandler: (() => void) | null = null;
|
|
48
|
+
const req = {
|
|
49
|
+
on: vi.fn().mockImplementation((event: string, handler: () => void) => {
|
|
50
|
+
if (event === "error") {
|
|
51
|
+
errorHandler = handler;
|
|
52
|
+
}
|
|
53
|
+
return req;
|
|
54
|
+
}),
|
|
55
|
+
write: vi.fn(),
|
|
56
|
+
end: vi.fn().mockImplementation(() => {
|
|
57
|
+
// Trigger error after end() to simulate connection failure
|
|
58
|
+
if (errorHandler) errorHandler();
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
return req as unknown as ReturnType<HttpsRequestModule["request"]>;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Mock http.request to succeed
|
|
65
|
+
http.request.mockImplementationOnce((_options: unknown, cb: unknown) => {
|
|
66
|
+
const mockRes = {
|
|
67
|
+
on: vi.fn().mockImplementation((event: string, handler: (arg?: unknown) => void) => {
|
|
68
|
+
if (event === "data") handler(Buffer.from(JSON.stringify(mockResponse)));
|
|
69
|
+
if (event === "end") handler();
|
|
70
|
+
return mockRes;
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
if (typeof cb === "function") {
|
|
74
|
+
cb(mockRes);
|
|
75
|
+
}
|
|
76
|
+
const req = {
|
|
77
|
+
on: vi.fn().mockReturnThis(),
|
|
78
|
+
write: vi.fn().mockReturnThis(),
|
|
79
|
+
end: vi.fn().mockReturnThis(),
|
|
80
|
+
};
|
|
81
|
+
return req as unknown as ReturnType<HttpRequestModule["request"]>;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result = await fetchAntigravityStatus(shellRunner);
|
|
85
|
+
|
|
86
|
+
expect(result.userStatus).toEqual(mockResponse.userStatus);
|
|
87
|
+
expect(shellRunner).toHaveBeenCalledWith(expect.stringContaining("ps aux"));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should throw error when CSRF token is not found", async () => {
|
|
91
|
+
const shellRunner: ShellRunner = vi.fn().mockResolvedValue("");
|
|
92
|
+
await expect(fetchAntigravityStatus(shellRunner)).rejects.toThrow(
|
|
93
|
+
"Antigravity CSRF token not found",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/*
|
|
2
|
+
ISC License
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2025, Cristian Militaru
|
|
5
|
+
|
|
6
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
7
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
8
|
+
copyright notice and this permission notice appear in all copies.
|
|
9
|
+
|
|
10
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
11
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
12
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
13
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
14
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
15
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
16
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as http from "node:http";
|
|
20
|
+
import * as https from "node:https";
|
|
21
|
+
|
|
22
|
+
export const API_ENDPOINTS = {
|
|
23
|
+
GET_USER_STATUS:
|
|
24
|
+
"/exa.language_server_pb.LanguageServerService/GetUserStatus",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface QuotaInfo {
|
|
28
|
+
remainingFraction: number;
|
|
29
|
+
resetTime?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ModelConfig {
|
|
33
|
+
modelName: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
quotaInfo?: QuotaInfo;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UserStatus {
|
|
39
|
+
cascadeModelConfigData?: {
|
|
40
|
+
clientModelConfigs?: ModelConfig[];
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UserStatusResponse {
|
|
45
|
+
userStatus: UserStatus;
|
|
46
|
+
timestamp: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ShellRunner = (cmd: string) => Promise<string>;
|
|
50
|
+
|
|
51
|
+
function makeRequest<T>(
|
|
52
|
+
port: number,
|
|
53
|
+
csrfToken: string,
|
|
54
|
+
path: string,
|
|
55
|
+
body: object,
|
|
56
|
+
): Promise<T> {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const payload = JSON.stringify(body);
|
|
59
|
+
const options = {
|
|
60
|
+
hostname: "127.0.0.1",
|
|
61
|
+
port,
|
|
62
|
+
path,
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
67
|
+
"X-Codeium-Csrf-Token": csrfToken,
|
|
68
|
+
"Connect-Protocol-Version": "1",
|
|
69
|
+
},
|
|
70
|
+
timeout: 2000,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const handleResponse = (response: http.IncomingMessage) => {
|
|
74
|
+
let data = "";
|
|
75
|
+
response.on("data", (chunk: Buffer) => {
|
|
76
|
+
data += chunk.toString();
|
|
77
|
+
});
|
|
78
|
+
response.on("end", () => {
|
|
79
|
+
try {
|
|
80
|
+
resolve(JSON.parse(data) as T);
|
|
81
|
+
} catch {
|
|
82
|
+
reject(new Error("JSON parse error"));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const req = https.request(
|
|
88
|
+
{ ...options, rejectUnauthorized: false },
|
|
89
|
+
handleResponse,
|
|
90
|
+
);
|
|
91
|
+
req.on("error", () => {
|
|
92
|
+
const reqHttp = http.request(options, handleResponse);
|
|
93
|
+
reqHttp.on("error", (err) => reject(err));
|
|
94
|
+
reqHttp.write(payload);
|
|
95
|
+
reqHttp.end();
|
|
96
|
+
});
|
|
97
|
+
req.write(payload);
|
|
98
|
+
req.end();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function fetchAntigravityStatus(
|
|
103
|
+
runShell: ShellRunner,
|
|
104
|
+
): Promise<UserStatusResponse> {
|
|
105
|
+
let procOutput = "";
|
|
106
|
+
try {
|
|
107
|
+
procOutput = await runShell(
|
|
108
|
+
'ps aux | grep -E "csrf_token|language_server" | grep -v grep',
|
|
109
|
+
);
|
|
110
|
+
} catch {
|
|
111
|
+
procOutput = "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const lines = procOutput.split("\n");
|
|
115
|
+
let csrfToken = "";
|
|
116
|
+
let cmdLinePort = 0;
|
|
117
|
+
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
const csrfMatch = line.match(/--csrf_token[=\s]+([\w-]+)/i);
|
|
120
|
+
if (csrfMatch?.[1]) csrfToken = csrfMatch[1];
|
|
121
|
+
const portMatch = line.match(/--extension_server_port[=\s]+(\d+)/i);
|
|
122
|
+
if (portMatch?.[1]) cmdLinePort = parseInt(portMatch[1], 10);
|
|
123
|
+
if (csrfToken && cmdLinePort) break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!csrfToken) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"Antigravity CSRF token not found. Is the Language Server running?",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let netstatOutput = "";
|
|
133
|
+
try {
|
|
134
|
+
netstatOutput = await runShell(
|
|
135
|
+
'ss -tlnp | grep -E "language_server|opencode|node"',
|
|
136
|
+
);
|
|
137
|
+
} catch {
|
|
138
|
+
netstatOutput = "";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const portMatches = netstatOutput.match(/:(\d+)/g);
|
|
142
|
+
let ports = portMatches
|
|
143
|
+
? portMatches.map((p: string) => parseInt(p.replace(":", ""), 10))
|
|
144
|
+
: [];
|
|
145
|
+
|
|
146
|
+
if (cmdLinePort && !ports.includes(cmdLinePort)) {
|
|
147
|
+
ports.unshift(cmdLinePort);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
ports = Array.from(new Set(ports));
|
|
151
|
+
|
|
152
|
+
if (ports.length === 0) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
"No listening ports found for Antigravity. Check if the server is active.",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let userStatus: UserStatus | null = null;
|
|
159
|
+
let lastError: Error | null = null;
|
|
160
|
+
|
|
161
|
+
for (const p of ports) {
|
|
162
|
+
try {
|
|
163
|
+
const resp = await makeRequest<{ userStatus?: UserStatus }>(
|
|
164
|
+
p,
|
|
165
|
+
csrfToken,
|
|
166
|
+
API_ENDPOINTS.GET_USER_STATUS,
|
|
167
|
+
{ metadata: { ideName: "opencode" } },
|
|
168
|
+
);
|
|
169
|
+
if (resp?.userStatus) {
|
|
170
|
+
userStatus = resp.userStatus;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
} catch (e) {
|
|
174
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!userStatus) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Could not communicate with Antigravity API. ${lastError?.message ?? ""}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
userStatus,
|
|
187
|
+
timestamp: Date.now(),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Format relative time until target date (e.g., "2h 30m")
|
|
193
|
+
*/
|
|
194
|
+
export function formatRelativeTime(targetDate: Date): string {
|
|
195
|
+
const now = new Date();
|
|
196
|
+
const diffMs = targetDate.getTime() - now.getTime();
|
|
197
|
+
if (diffMs <= 0) return "now";
|
|
198
|
+
|
|
199
|
+
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
200
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
201
|
+
const remainingMins = diffMins % 60;
|
|
202
|
+
|
|
203
|
+
if (diffHours > 0) {
|
|
204
|
+
return `${diffHours}h ${remainingMins}m`;
|
|
205
|
+
}
|
|
206
|
+
return `${diffMins}m`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Format absolute time of target date (e.g., "10:30 PM" or "22:30")
|
|
211
|
+
*/
|
|
212
|
+
export function formatAbsoluteTime(targetDate: Date): string {
|
|
213
|
+
return targetDate.toLocaleTimeString(undefined, {
|
|
214
|
+
hour: "2-digit",
|
|
215
|
+
minute: "2-digit",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Re-export config utilities
|
|
220
|
+
export {
|
|
221
|
+
loadConfig,
|
|
222
|
+
formatQuotaEntry,
|
|
223
|
+
DEFAULT_CONFIG,
|
|
224
|
+
type QuotaConfig,
|
|
225
|
+
type QuotaSource,
|
|
226
|
+
} from "./config";
|
|
227
|
+
|
|
228
|
+
// Re-export cloud utilities
|
|
229
|
+
export {
|
|
230
|
+
fetchCloudQuota,
|
|
231
|
+
type CloudQuotaResult,
|
|
232
|
+
type CloudAccountInfo,
|
|
233
|
+
} from "./cloud";
|
|
234
|
+
|
|
235
|
+
// ============================================================================
|
|
236
|
+
// Unified Quota Types and Functions
|
|
237
|
+
// ============================================================================
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Unified category quota info (for the three model groups)
|
|
241
|
+
*/
|
|
242
|
+
export interface CategoryQuota {
|
|
243
|
+
category: "Flash" | "Pro" | "Claude/GPT";
|
|
244
|
+
remainingFraction: number;
|
|
245
|
+
resetTime: Date | null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Unified quota result from either source
|
|
250
|
+
*/
|
|
251
|
+
export interface UnifiedQuotaResult {
|
|
252
|
+
source: "cloud" | "local";
|
|
253
|
+
categories: CategoryQuota[];
|
|
254
|
+
models: ModelConfig[];
|
|
255
|
+
timestamp: number;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Credentials for cloud quota fetching
|
|
260
|
+
*/
|
|
261
|
+
export interface CloudAuthCredentials {
|
|
262
|
+
accessToken: string;
|
|
263
|
+
projectId?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Categorize a model label into one of the three groups.
|
|
268
|
+
*/
|
|
269
|
+
export function categorizeModel(label: string): "Flash" | "Pro" | "Claude/GPT" {
|
|
270
|
+
const lowerLabel = label.toLowerCase();
|
|
271
|
+
if (lowerLabel.includes("flash")) {
|
|
272
|
+
return "Flash";
|
|
273
|
+
}
|
|
274
|
+
if (lowerLabel.includes("gemini") || lowerLabel.includes("pro")) {
|
|
275
|
+
return "Pro";
|
|
276
|
+
}
|
|
277
|
+
return "Claude/GPT";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Group models into the three categories, taking the minimum quota per category.
|
|
282
|
+
*/
|
|
283
|
+
export function groupModelsByCategory(models: ModelConfig[]): CategoryQuota[] {
|
|
284
|
+
const categories: Record<
|
|
285
|
+
string,
|
|
286
|
+
{ remainingFraction: number; resetTime: Date | null }
|
|
287
|
+
> = {};
|
|
288
|
+
|
|
289
|
+
for (const model of models) {
|
|
290
|
+
const label = model.label || model.modelName || "";
|
|
291
|
+
const category = categorizeModel(label);
|
|
292
|
+
const fraction = model.quotaInfo?.remainingFraction ?? 0;
|
|
293
|
+
const resetTime = model.quotaInfo?.resetTime
|
|
294
|
+
? new Date(model.quotaInfo.resetTime)
|
|
295
|
+
: null;
|
|
296
|
+
|
|
297
|
+
if (
|
|
298
|
+
!categories[category] ||
|
|
299
|
+
fraction < categories[category].remainingFraction
|
|
300
|
+
) {
|
|
301
|
+
categories[category] = { remainingFraction: fraction, resetTime };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const result: CategoryQuota[] = [];
|
|
306
|
+
for (const cat of ["Flash", "Pro", "Claude/GPT"] as const) {
|
|
307
|
+
if (categories[cat]) {
|
|
308
|
+
result.push({
|
|
309
|
+
category: cat,
|
|
310
|
+
remainingFraction: categories[cat].remainingFraction,
|
|
311
|
+
resetTime: categories[cat].resetTime,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Fetch quota from either cloud or local source.
|
|
321
|
+
*
|
|
322
|
+
* @param source - "cloud", "local", or "auto" (try cloud first, fallback to local)
|
|
323
|
+
* @param shellRunner - Required for local source
|
|
324
|
+
* @param cloudAuth - Required for cloud source
|
|
325
|
+
* @returns Unified quota result
|
|
326
|
+
*/
|
|
327
|
+
export async function fetchQuota(
|
|
328
|
+
source: "cloud" | "local" | "auto",
|
|
329
|
+
shellRunner?: ShellRunner,
|
|
330
|
+
cloudAuth?: CloudAuthCredentials,
|
|
331
|
+
): Promise<UnifiedQuotaResult> {
|
|
332
|
+
// Import cloud module dynamically to avoid issues if not available
|
|
333
|
+
const { fetchCloudQuota } = await import("./cloud");
|
|
334
|
+
|
|
335
|
+
if (source === "cloud" || source === "auto") {
|
|
336
|
+
// Try cloud first
|
|
337
|
+
if (cloudAuth) {
|
|
338
|
+
try {
|
|
339
|
+
const cloudResult = await fetchCloudQuota(
|
|
340
|
+
cloudAuth.accessToken,
|
|
341
|
+
cloudAuth.projectId,
|
|
342
|
+
);
|
|
343
|
+
const categories = groupModelsByCategory(cloudResult.models);
|
|
344
|
+
return {
|
|
345
|
+
source: "cloud",
|
|
346
|
+
categories,
|
|
347
|
+
models: cloudResult.models,
|
|
348
|
+
timestamp: cloudResult.timestamp,
|
|
349
|
+
};
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (source === "cloud") {
|
|
352
|
+
throw error; // Don't fallback if explicitly requested cloud
|
|
353
|
+
}
|
|
354
|
+
// Fall through to local if auto
|
|
355
|
+
}
|
|
356
|
+
} else if (source === "cloud") {
|
|
357
|
+
throw new Error(
|
|
358
|
+
"Cloud access token not provided. Cannot fetch cloud quota.",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Try local
|
|
364
|
+
if (!shellRunner) {
|
|
365
|
+
throw new Error("Shell runner required for local quota fetching");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const localResult = await fetchAntigravityStatus(shellRunner);
|
|
369
|
+
const models: ModelConfig[] =
|
|
370
|
+
localResult.userStatus.cascadeModelConfigData?.clientModelConfigs || [];
|
|
371
|
+
const categories = groupModelsByCategory(models);
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
source: "local",
|
|
375
|
+
categories,
|
|
376
|
+
models,
|
|
377
|
+
timestamp: localResult.timestamp,
|
|
378
|
+
};
|
|
379
|
+
}
|