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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Unified quota info structure (shared between cloud and local)
3
+ */
4
+ export interface QuotaInfo {
5
+ remainingFraction: number;
6
+ resetTime?: string;
7
+ }
8
+ /**
9
+ * Unified model config structure (shared between cloud and local)
10
+ */
11
+ export interface ModelConfig {
12
+ modelName: string;
13
+ label?: string;
14
+ quotaInfo?: QuotaInfo;
15
+ }
16
+ /**
17
+ * Cloud-specific account info
18
+ */
19
+ export interface CloudAccountInfo {
20
+ email?: string;
21
+ projectId?: string;
22
+ }
23
+ /**
24
+ * Result from cloud quota fetch
25
+ */
26
+ export interface CloudQuotaResult {
27
+ account: CloudAccountInfo;
28
+ models: ModelConfig[];
29
+ timestamp: number;
30
+ }
31
+ /**
32
+ * Fetch quota information from the Cloud Code API.
33
+ *
34
+ * @param accessToken - Valid OAuth2 access token
35
+ * @param projectId - Optional Google Cloud project ID
36
+ * @returns CloudQuotaResult with account info and model quotas
37
+ * @throws Error if API fails
38
+ */
39
+ export declare function fetchCloudQuota(accessToken: string, projectId?: string): Promise<CloudQuotaResult>;
40
+ //# sourceMappingURL=cloud.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cloud.d.ts","sourceRoot":"","sources":["../src/cloud.ts"],"names":[],"mappings":"AAkDA;;GAEG;AACH,MAAM,WAAW,SAAS;IACtB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,SAAS,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC7B,OAAO,EAAE,gBAAgB,CAAC;IAC1B,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;CACrB;AAoED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACjC,WAAW,EAAE,MAAM,EACnB,SAAS,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,gBAAgB,CAAC,CAiC3B"}
package/dist/cloud.js ADDED
@@ -0,0 +1,110 @@
1
+ /*
2
+ * ISC License
3
+ * Copyright (c) 2025, Cristian Militaru
4
+ * Copyright (c) 2026, Philipp
5
+ *
6
+ * Cloud quota fetching via Google Cloud Code API.
7
+ */
8
+ // ============================================================================
9
+ // Constants from opencode-antigravity-auth
10
+ // ============================================================================
11
+ // Endpoint fallback order (daily → autopush → prod)
12
+ const CLOUDCODE_ENDPOINTS = [
13
+ "https://daily-cloudcode-pa.sandbox.googleapis.com",
14
+ "https://autopush-cloudcode-pa.sandbox.googleapis.com",
15
+ "https://cloudcode-pa.googleapis.com",
16
+ ];
17
+ // Headers matching opencode-antigravity-auth
18
+ const CLOUDCODE_HEADERS = {
19
+ "Content-Type": "application/json",
20
+ "User-Agent": "antigravity/1.11.5 windows/amd64",
21
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
22
+ "Client-Metadata": '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}',
23
+ };
24
+ // ============================================================================
25
+ // API Fetching
26
+ // ============================================================================
27
+ /**
28
+ * Fetch available models with quota from the Cloud Code API.
29
+ */
30
+ async function fetchAvailableModels(accessToken, projectId) {
31
+ const payload = projectId ? { project: projectId } : {};
32
+ let lastError = null;
33
+ const headers = {
34
+ ...CLOUDCODE_HEADERS,
35
+ Authorization: `Bearer ${accessToken}`,
36
+ };
37
+ for (const endpoint of CLOUDCODE_ENDPOINTS) {
38
+ try {
39
+ const url = `${endpoint}/v1internal:fetchAvailableModels`;
40
+ const response = await fetch(url, {
41
+ method: "POST",
42
+ headers,
43
+ body: JSON.stringify(payload),
44
+ });
45
+ if (response.status === 401) {
46
+ throw new Error("Authorization expired or invalid.");
47
+ }
48
+ if (response.status === 403) {
49
+ throw new Error("Access forbidden (403). Check your account permissions.");
50
+ }
51
+ if (!response.ok) {
52
+ const text = await response.text();
53
+ throw new Error(`Cloud Code API error ${response.status}: ${text.slice(0, 200)}`);
54
+ }
55
+ return (await response.json());
56
+ }
57
+ catch (error) {
58
+ lastError = error instanceof Error ? error : new Error(String(error));
59
+ // Auth errors should not fallback to other endpoints
60
+ if (lastError.message.includes("Authorization") ||
61
+ lastError.message.includes("forbidden") ||
62
+ lastError.message.includes("invalid_grant")) {
63
+ throw lastError;
64
+ }
65
+ // Try next endpoint
66
+ }
67
+ }
68
+ throw lastError || new Error("All Cloud Code API endpoints failed");
69
+ }
70
+ // ============================================================================
71
+ // Main Export
72
+ // ============================================================================
73
+ /**
74
+ * Fetch quota information from the Cloud Code API.
75
+ *
76
+ * @param accessToken - Valid OAuth2 access token
77
+ * @param projectId - Optional Google Cloud project ID
78
+ * @returns CloudQuotaResult with account info and model quotas
79
+ * @throws Error if API fails
80
+ */
81
+ export async function fetchCloudQuota(accessToken, projectId) {
82
+ if (!accessToken) {
83
+ throw new Error("Access token is required for cloud quota fetching");
84
+ }
85
+ // Fetch quota data
86
+ const response = await fetchAvailableModels(accessToken, projectId);
87
+ // Convert to unified format
88
+ const models = [];
89
+ if (response.models) {
90
+ for (const [modelKey, info] of Object.entries(response.models)) {
91
+ if (!info.quotaInfo)
92
+ continue;
93
+ models.push({
94
+ modelName: info.model || modelKey,
95
+ label: info.displayName || modelKey,
96
+ quotaInfo: {
97
+ remainingFraction: info.quotaInfo.remainingFraction ?? 0,
98
+ resetTime: info.quotaInfo.resetTime,
99
+ },
100
+ });
101
+ }
102
+ }
103
+ return {
104
+ account: {
105
+ projectId,
106
+ },
107
+ models,
108
+ timestamp: Date.now(),
109
+ };
110
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Quota data source
3
+ * - "cloud": Fetch from Cloud Code API (requires opencode-antigravity-auth)
4
+ * - "local": Fetch from local language server process
5
+ * - "auto": Try cloud first, fallback to local
6
+ */
7
+ export type QuotaSource = "cloud" | "local" | "auto";
8
+ export interface QuotaIndicator {
9
+ threshold: number;
10
+ symbol: string;
11
+ }
12
+ /**
13
+ * Configuration options for the quota plugin
14
+ */
15
+ export interface QuotaConfig {
16
+ /**
17
+ * Where to fetch quota data from.
18
+ * - "cloud": Use Cloud Code API (requires opencode-antigravity-auth)
19
+ * - "local": Use local language server process
20
+ * - "auto": Try cloud first, fallback to local (default)
21
+ * @default "auto"
22
+ */
23
+ quotaSource?: QuotaSource;
24
+ /**
25
+ * Format string for quota display.
26
+ * Available placeholders:
27
+ * - {category} - Category name (Flash, Pro, Claude/GPT)
28
+ * - {percent} - Quota percentage (e.g., "85.5")
29
+ * - {resetIn} - Relative time until reset (e.g., "2h 30m")
30
+ * - {resetAt} - Absolute reset time (e.g., "10:30 PM")
31
+ * - {model} - Current model ID
32
+ *
33
+ * For all quotas mode, the format is applied per category and joined with separator.
34
+ * @default "{category}: {percent}% ({resetIn})"
35
+ */
36
+ format?: string;
37
+ /**
38
+ * Separator between categories when showing all quotas
39
+ * @default " | "
40
+ */
41
+ separator?: string;
42
+ /**
43
+ * Show all quota categories or only the current model's quota
44
+ * @default "all"
45
+ */
46
+ displayMode?: "all" | "current";
47
+ /**
48
+ * Always append quota info, even when unavailable
49
+ * @default true
50
+ */
51
+ alwaysAppend?: boolean;
52
+ /**
53
+ * The marker string used to separate quota info from the message
54
+ * @default "> AG Quota:"
55
+ */
56
+ quotaMarker?: string;
57
+ /**
58
+ * Polling interval in milliseconds
59
+ * @default 30000 (30 seconds)
60
+ */
61
+ pollingInterval?: number;
62
+ /**
63
+ * Array of quota usage percentages (remaining) that trigger alerts
64
+ * @default [0.5, 0.1, 0.05] (50%, 10%, 5%)
65
+ */
66
+ alertThresholds?: number[];
67
+ /**
68
+ * Visual indicators appended to quota percentages when remaining fraction is low.
69
+ * The most severe matching indicator is chosen.
70
+ *
71
+ * Example: with indicators [{threshold: 0.1, symbol: "⚠️"}, {threshold: 0.05, symbol: "⛔"}]
72
+ * a remainingFraction of 0.04 will show "⛔".
73
+ *
74
+ * @default [{threshold: 0.1, symbol: "⚠️"}, {threshold: 0.05, symbol: "⛔"}]
75
+ */
76
+ indicators?: QuotaIndicator[];
77
+ }
78
+ declare const DEFAULT_CONFIG: Required<QuotaConfig>;
79
+ /**
80
+ * Load configuration from file system.
81
+ * Searches in order:
82
+ * 1. .opencode/ag-quota.json (project-local)
83
+ * 2. ~/.config/opencode/ag-quota.json (user global)
84
+ *
85
+ * @param projectDir - The project directory to search from
86
+ * @returns Merged configuration with defaults
87
+ */
88
+ export declare function loadConfig(projectDir?: string): Promise<Required<QuotaConfig>>;
89
+ /**
90
+ * Format a quota entry using the format string.
91
+ * Placeholders are only replaced if they exist in the format string.
92
+ */
93
+ export declare function formatQuotaEntry(format: string, data: {
94
+ category: string;
95
+ percent: string;
96
+ resetIn: string | null;
97
+ resetAt: string | null;
98
+ model: string;
99
+ }): string;
100
+ export { DEFAULT_CONFIG };
101
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AASA;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,CAAC;AAErD,MAAM,WAAW,cAAc;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IACxB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,WAAW,CAAC;IAE1B;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;OAGG;IACH,WAAW,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;IAEhC;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAE3B;;;;;;;;OAQG;IACH,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;CACjC;AAED,QAAA,MAAM,cAAc,EAAE,QAAQ,CAAC,WAAW,CAazC,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAsB,UAAU,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAyBpF;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC5B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE;IACF,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;CACjB,GACF,MAAM,CA6BR;AAED,OAAO,EAAE,cAAc,EAAE,CAAC"}
package/dist/config.js ADDED
@@ -0,0 +1,88 @@
1
+ /*
2
+ * ISC License
3
+ * Copyright (c) 2026 Philipp
4
+ */
5
+ import { readFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ const DEFAULT_CONFIG = {
9
+ quotaSource: "auto",
10
+ format: "{category}: {percent}% ({resetIn})",
11
+ separator: " | ",
12
+ displayMode: "all",
13
+ alwaysAppend: true,
14
+ quotaMarker: "> AG Quota:",
15
+ pollingInterval: 30000,
16
+ alertThresholds: [0.5, 0.1, 0.05],
17
+ indicators: [
18
+ { threshold: 0.2, symbol: "⚠️" },
19
+ { threshold: 0.05, symbol: "🛑" },
20
+ ],
21
+ };
22
+ /**
23
+ * Load configuration from file system.
24
+ * Searches in order:
25
+ * 1. .opencode/ag-quota.json (project-local)
26
+ * 2. ~/.config/opencode/ag-quota.json (user global)
27
+ *
28
+ * @param projectDir - The project directory to search from
29
+ * @returns Merged configuration with defaults
30
+ */
31
+ export async function loadConfig(projectDir) {
32
+ const paths = [];
33
+ // Project-local config
34
+ if (projectDir) {
35
+ paths.push(join(projectDir, ".opencode", "ag-quota.json"));
36
+ }
37
+ else {
38
+ paths.push(join(process.cwd(), ".opencode", "ag-quota.json"));
39
+ }
40
+ // User global config
41
+ paths.push(join(homedir(), ".config", "opencode", "ag-quota.json"));
42
+ for (const configPath of paths) {
43
+ try {
44
+ const content = await readFile(configPath, "utf-8");
45
+ const userConfig = JSON.parse(content);
46
+ return { ...DEFAULT_CONFIG, ...userConfig };
47
+ }
48
+ catch {
49
+ // File doesn't exist or is invalid, try next
50
+ continue;
51
+ }
52
+ }
53
+ return DEFAULT_CONFIG;
54
+ }
55
+ /**
56
+ * Format a quota entry using the format string.
57
+ * Placeholders are only replaced if they exist in the format string.
58
+ */
59
+ export function formatQuotaEntry(format, data) {
60
+ let result = format
61
+ .replace("{category}", data.category)
62
+ .replace("{percent}", data.percent)
63
+ .replace("{model}", data.model);
64
+ // Handle resetIn (relative time) - e.g., "2h 30m"
65
+ if (format.includes("{resetIn}")) {
66
+ if (data.resetIn) {
67
+ result = result.replace("{resetIn}", data.resetIn);
68
+ }
69
+ else {
70
+ result = result
71
+ .replace(/\s*\(\{resetIn}\)/, "")
72
+ .replace(/\s*\{resetIn}/, "");
73
+ }
74
+ }
75
+ // Handle resetAt (absolute time) - e.g., "10:30 PM"
76
+ if (format.includes("{resetAt}")) {
77
+ if (data.resetAt) {
78
+ result = result.replace("{resetAt}", data.resetAt);
79
+ }
80
+ else {
81
+ result = result
82
+ .replace(/\s*\(\{resetAt}\)/, "")
83
+ .replace(/\s*\{resetAt}/, "");
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+ export { DEFAULT_CONFIG };
@@ -0,0 +1,75 @@
1
+ export declare const API_ENDPOINTS: {
2
+ GET_USER_STATUS: string;
3
+ };
4
+ export interface QuotaInfo {
5
+ remainingFraction: number;
6
+ resetTime?: string;
7
+ }
8
+ export interface ModelConfig {
9
+ modelName: string;
10
+ label?: string;
11
+ quotaInfo?: QuotaInfo;
12
+ }
13
+ export interface UserStatus {
14
+ cascadeModelConfigData?: {
15
+ clientModelConfigs?: ModelConfig[];
16
+ };
17
+ }
18
+ export interface UserStatusResponse {
19
+ userStatus: UserStatus;
20
+ timestamp: number;
21
+ }
22
+ export type ShellRunner = (cmd: string) => Promise<string>;
23
+ export declare function fetchAntigravityStatus(runShell: ShellRunner): Promise<UserStatusResponse>;
24
+ /**
25
+ * Format relative time until target date (e.g., "2h 30m")
26
+ */
27
+ export declare function formatRelativeTime(targetDate: Date): string;
28
+ /**
29
+ * Format absolute time of target date (e.g., "10:30 PM" or "22:30")
30
+ */
31
+ export declare function formatAbsoluteTime(targetDate: Date): string;
32
+ export { loadConfig, formatQuotaEntry, DEFAULT_CONFIG, type QuotaConfig, type QuotaSource, } from "./config";
33
+ export { fetchCloudQuota, type CloudQuotaResult, type CloudAccountInfo, } from "./cloud";
34
+ /**
35
+ * Unified category quota info (for the three model groups)
36
+ */
37
+ export interface CategoryQuota {
38
+ category: "Flash" | "Pro" | "Claude/GPT";
39
+ remainingFraction: number;
40
+ resetTime: Date | null;
41
+ }
42
+ /**
43
+ * Unified quota result from either source
44
+ */
45
+ export interface UnifiedQuotaResult {
46
+ source: "cloud" | "local";
47
+ categories: CategoryQuota[];
48
+ models: ModelConfig[];
49
+ timestamp: number;
50
+ }
51
+ /**
52
+ * Credentials for cloud quota fetching
53
+ */
54
+ export interface CloudAuthCredentials {
55
+ accessToken: string;
56
+ projectId?: string;
57
+ }
58
+ /**
59
+ * Categorize a model label into one of the three groups.
60
+ */
61
+ export declare function categorizeModel(label: string): "Flash" | "Pro" | "Claude/GPT";
62
+ /**
63
+ * Group models into the three categories, taking the minimum quota per category.
64
+ */
65
+ export declare function groupModelsByCategory(models: ModelConfig[]): CategoryQuota[];
66
+ /**
67
+ * Fetch quota from either cloud or local source.
68
+ *
69
+ * @param source - "cloud", "local", or "auto" (try cloud first, fallback to local)
70
+ * @param shellRunner - Required for local source
71
+ * @param cloudAuth - Required for cloud source
72
+ * @returns Unified quota result
73
+ */
74
+ export declare function fetchQuota(source: "cloud" | "local" | "auto", shellRunner?: ShellRunner, cloudAuth?: CloudAuthCredentials): Promise<UnifiedQuotaResult>;
75
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAqBA,eAAO,MAAM,aAAa;;CAGzB,CAAC;AAEF,MAAM,WAAW,SAAS;IACtB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,WAAW;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,SAAS,CAAC;CACzB;AAED,MAAM,WAAW,UAAU;IACvB,sBAAsB,CAAC,EAAE;QACrB,kBAAkB,CAAC,EAAE,WAAW,EAAE,CAAC;KACtC,CAAC;CACL;AAED,MAAM,WAAW,kBAAkB;IAC/B,UAAU,EAAE,UAAU,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAqD3D,wBAAsB,sBAAsB,CACxC,QAAQ,EAAE,WAAW,GACtB,OAAO,CAAC,kBAAkB,CAAC,CAqF7B;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,IAAI,GAAG,MAAM,CAa3D;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,IAAI,GAAG,MAAM,CAK3D;AAGD,OAAO,EACH,UAAU,EACV,gBAAgB,EAChB,cAAc,EACd,KAAK,WAAW,EAChB,KAAK,WAAW,GACnB,MAAM,UAAU,CAAC;AAGlB,OAAO,EACH,eAAe,EACf,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,GACxB,MAAM,SAAS,CAAC;AAMjB;;GAEG;AACH,MAAM,WAAW,aAAa;IAC1B,QAAQ,EAAE,OAAO,GAAG,KAAK,GAAG,YAAY,CAAC;IACzC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;CAC1B;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IAC/B,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC;IAC1B,UAAU,EAAE,aAAa,EAAE,CAAC;IAC5B,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,KAAK,GAAG,YAAY,CAS7E;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,aAAa,EAAE,CAkC5E;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC5B,MAAM,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,EAClC,WAAW,CAAC,EAAE,WAAW,EACzB,SAAS,CAAC,EAAE,oBAAoB,GACjC,OAAO,CAAC,kBAAkB,CAAC,CAgD7B"}
package/dist/index.js ADDED
@@ -0,0 +1,248 @@
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
+ import * as http from "node:http";
19
+ import * as https from "node:https";
20
+ export const API_ENDPOINTS = {
21
+ GET_USER_STATUS: "/exa.language_server_pb.LanguageServerService/GetUserStatus",
22
+ };
23
+ function makeRequest(port, csrfToken, path, body) {
24
+ return new Promise((resolve, reject) => {
25
+ const payload = JSON.stringify(body);
26
+ const options = {
27
+ hostname: "127.0.0.1",
28
+ port,
29
+ path,
30
+ method: "POST",
31
+ headers: {
32
+ "Content-Type": "application/json",
33
+ "Content-Length": Buffer.byteLength(payload),
34
+ "X-Codeium-Csrf-Token": csrfToken,
35
+ "Connect-Protocol-Version": "1",
36
+ },
37
+ timeout: 2000,
38
+ };
39
+ const handleResponse = (response) => {
40
+ let data = "";
41
+ response.on("data", (chunk) => {
42
+ data += chunk.toString();
43
+ });
44
+ response.on("end", () => {
45
+ try {
46
+ resolve(JSON.parse(data));
47
+ }
48
+ catch {
49
+ reject(new Error("JSON parse error"));
50
+ }
51
+ });
52
+ };
53
+ const req = https.request({ ...options, rejectUnauthorized: false }, handleResponse);
54
+ req.on("error", () => {
55
+ const reqHttp = http.request(options, handleResponse);
56
+ reqHttp.on("error", (err) => reject(err));
57
+ reqHttp.write(payload);
58
+ reqHttp.end();
59
+ });
60
+ req.write(payload);
61
+ req.end();
62
+ });
63
+ }
64
+ export async function fetchAntigravityStatus(runShell) {
65
+ let procOutput = "";
66
+ try {
67
+ procOutput = await runShell('ps aux | grep -E "csrf_token|language_server" | grep -v grep');
68
+ }
69
+ catch {
70
+ procOutput = "";
71
+ }
72
+ const lines = procOutput.split("\n");
73
+ let csrfToken = "";
74
+ let cmdLinePort = 0;
75
+ for (const line of lines) {
76
+ const csrfMatch = line.match(/--csrf_token[=\s]+([\w-]+)/i);
77
+ if (csrfMatch?.[1])
78
+ csrfToken = csrfMatch[1];
79
+ const portMatch = line.match(/--extension_server_port[=\s]+(\d+)/i);
80
+ if (portMatch?.[1])
81
+ cmdLinePort = parseInt(portMatch[1], 10);
82
+ if (csrfToken && cmdLinePort)
83
+ break;
84
+ }
85
+ if (!csrfToken) {
86
+ throw new Error("Antigravity CSRF token not found. Is the Language Server running?");
87
+ }
88
+ let netstatOutput = "";
89
+ try {
90
+ netstatOutput = await runShell('ss -tlnp | grep -E "language_server|opencode|node"');
91
+ }
92
+ catch {
93
+ netstatOutput = "";
94
+ }
95
+ const portMatches = netstatOutput.match(/:(\d+)/g);
96
+ let ports = portMatches
97
+ ? portMatches.map((p) => parseInt(p.replace(":", ""), 10))
98
+ : [];
99
+ if (cmdLinePort && !ports.includes(cmdLinePort)) {
100
+ ports.unshift(cmdLinePort);
101
+ }
102
+ ports = Array.from(new Set(ports));
103
+ if (ports.length === 0) {
104
+ throw new Error("No listening ports found for Antigravity. Check if the server is active.");
105
+ }
106
+ let userStatus = null;
107
+ let lastError = null;
108
+ for (const p of ports) {
109
+ try {
110
+ const resp = await makeRequest(p, csrfToken, API_ENDPOINTS.GET_USER_STATUS, { metadata: { ideName: "opencode" } });
111
+ if (resp?.userStatus) {
112
+ userStatus = resp.userStatus;
113
+ break;
114
+ }
115
+ }
116
+ catch (e) {
117
+ lastError = e instanceof Error ? e : new Error(String(e));
118
+ continue;
119
+ }
120
+ }
121
+ if (!userStatus) {
122
+ throw new Error(`Could not communicate with Antigravity API. ${lastError?.message ?? ""}`);
123
+ }
124
+ return {
125
+ userStatus,
126
+ timestamp: Date.now(),
127
+ };
128
+ }
129
+ /**
130
+ * Format relative time until target date (e.g., "2h 30m")
131
+ */
132
+ export function formatRelativeTime(targetDate) {
133
+ const now = new Date();
134
+ const diffMs = targetDate.getTime() - now.getTime();
135
+ if (diffMs <= 0)
136
+ return "now";
137
+ const diffMins = Math.floor(diffMs / (1000 * 60));
138
+ const diffHours = Math.floor(diffMins / 60);
139
+ const remainingMins = diffMins % 60;
140
+ if (diffHours > 0) {
141
+ return `${diffHours}h ${remainingMins}m`;
142
+ }
143
+ return `${diffMins}m`;
144
+ }
145
+ /**
146
+ * Format absolute time of target date (e.g., "10:30 PM" or "22:30")
147
+ */
148
+ export function formatAbsoluteTime(targetDate) {
149
+ return targetDate.toLocaleTimeString(undefined, {
150
+ hour: "2-digit",
151
+ minute: "2-digit",
152
+ });
153
+ }
154
+ // Re-export config utilities
155
+ export { loadConfig, formatQuotaEntry, DEFAULT_CONFIG, } from "./config";
156
+ // Re-export cloud utilities
157
+ export { fetchCloudQuota, } from "./cloud";
158
+ /**
159
+ * Categorize a model label into one of the three groups.
160
+ */
161
+ export function categorizeModel(label) {
162
+ const lowerLabel = label.toLowerCase();
163
+ if (lowerLabel.includes("flash")) {
164
+ return "Flash";
165
+ }
166
+ if (lowerLabel.includes("gemini") || lowerLabel.includes("pro")) {
167
+ return "Pro";
168
+ }
169
+ return "Claude/GPT";
170
+ }
171
+ /**
172
+ * Group models into the three categories, taking the minimum quota per category.
173
+ */
174
+ export function groupModelsByCategory(models) {
175
+ const categories = {};
176
+ for (const model of models) {
177
+ const label = model.label || model.modelName || "";
178
+ const category = categorizeModel(label);
179
+ const fraction = model.quotaInfo?.remainingFraction ?? 0;
180
+ const resetTime = model.quotaInfo?.resetTime
181
+ ? new Date(model.quotaInfo.resetTime)
182
+ : null;
183
+ if (!categories[category] ||
184
+ fraction < categories[category].remainingFraction) {
185
+ categories[category] = { remainingFraction: fraction, resetTime };
186
+ }
187
+ }
188
+ const result = [];
189
+ for (const cat of ["Flash", "Pro", "Claude/GPT"]) {
190
+ if (categories[cat]) {
191
+ result.push({
192
+ category: cat,
193
+ remainingFraction: categories[cat].remainingFraction,
194
+ resetTime: categories[cat].resetTime,
195
+ });
196
+ }
197
+ }
198
+ return result;
199
+ }
200
+ /**
201
+ * Fetch quota from either cloud or local source.
202
+ *
203
+ * @param source - "cloud", "local", or "auto" (try cloud first, fallback to local)
204
+ * @param shellRunner - Required for local source
205
+ * @param cloudAuth - Required for cloud source
206
+ * @returns Unified quota result
207
+ */
208
+ export async function fetchQuota(source, shellRunner, cloudAuth) {
209
+ // Import cloud module dynamically to avoid issues if not available
210
+ const { fetchCloudQuota } = await import("./cloud");
211
+ if (source === "cloud" || source === "auto") {
212
+ // Try cloud first
213
+ if (cloudAuth) {
214
+ try {
215
+ const cloudResult = await fetchCloudQuota(cloudAuth.accessToken, cloudAuth.projectId);
216
+ const categories = groupModelsByCategory(cloudResult.models);
217
+ return {
218
+ source: "cloud",
219
+ categories,
220
+ models: cloudResult.models,
221
+ timestamp: cloudResult.timestamp,
222
+ };
223
+ }
224
+ catch (error) {
225
+ if (source === "cloud") {
226
+ throw error; // Don't fallback if explicitly requested cloud
227
+ }
228
+ // Fall through to local if auto
229
+ }
230
+ }
231
+ else if (source === "cloud") {
232
+ throw new Error("Cloud access token not provided. Cannot fetch cloud quota.");
233
+ }
234
+ }
235
+ // Try local
236
+ if (!shellRunner) {
237
+ throw new Error("Shell runner required for local quota fetching");
238
+ }
239
+ const localResult = await fetchAntigravityStatus(shellRunner);
240
+ const models = localResult.userStatus.cascadeModelConfigData?.clientModelConfigs || [];
241
+ const categories = groupModelsByCategory(models);
242
+ return {
243
+ source: "local",
244
+ categories,
245
+ models,
246
+ timestamp: localResult.timestamp,
247
+ };
248
+ }