opencode-antigravity-img 0.2.1 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-02-14
9
+
10
+ ### Added
11
+ - Round-robin account selection for multi-account setups (least-recently-used first)
12
+ - Auto-retry with next account on any failure (rate-limit, auth, project ID errors)
13
+ - Rate-limit tracking with 5-minute cooldown per account
14
+ - Per-account quota breakdown in `image_quota` for multi-account configurations
15
+ - Graceful error summary listing each account and its failure reason
16
+ - `/antigravity-quota-img` command for opencode
17
+ - Unit tests for account selection logic (15 test cases)
18
+
19
+ ### Changed
20
+ - Account state (`lastUsed`, `rateLimitedUntil`) persisted to `antigravity-accounts.json`
21
+ - Single-account users see no behavior change
22
+ - Fix command directory from `commands/` to `command/` (matching opencode convention)
23
+ - Command files now include YAML frontmatter with description
24
+
8
25
  ## [0.2.1] - 2026-01-28
9
26
 
10
27
  ### Fixed
package/README.md CHANGED
@@ -33,6 +33,22 @@ opencode
33
33
 
34
34
  This creates `antigravity-accounts.json` in your opencode config directory.
35
35
 
36
+ ### Multi-Account Setup
37
+
38
+ You can add multiple Google accounts for higher throughput and automatic failover:
39
+
40
+ ```bash
41
+ opencode auth login # repeat for each Google account
42
+ ```
43
+
44
+ With multiple accounts the plugin will:
45
+ - **Round-robin** between accounts, picking the least-recently-used one
46
+ - **Auto-retry** with the next account if one fails (rate-limit, auth error, etc.)
47
+ - **Track cooldowns** so rate-limited accounts are skipped for 5 minutes
48
+ - **Show per-account quota** via the `image_quota` tool
49
+
50
+ Single-account users see no behavior change.
51
+
36
52
  ## Tools
37
53
 
38
54
  ### generate_image
@@ -61,10 +77,15 @@ Check the remaining quota for the Gemini 3 Pro Image model.
61
77
 
62
78
  **Arguments:** None
63
79
 
64
- **Output:**
80
+ **Output (single account):**
65
81
  - Visual progress bar showing remaining quota percentage
66
82
  - Time until quota resets
67
83
 
84
+ **Output (multi-account):**
85
+ - Per-account progress bars with quota percentage
86
+ - Rate-limit status per account
87
+ - Time until reset for each account
88
+
68
89
  ## Image Details
69
90
 
70
91
  - **Model**: Gemini 3 Pro Image
@@ -88,7 +109,9 @@ These parameters may work with Imagen models but have no effect with Gemini 3 Pr
88
109
 
89
110
  ## Quota
90
111
 
91
- Image generation uses a separate quota from text models. The quota resets every 5 hours. Use the `image_quota` tool to check your remaining quota.
112
+ Image generation uses a separate quota from text models. The quota resets every 5 hours. Use the `image_quota` tool (or the `/antigravity-quota-img` command) to check your remaining quota.
113
+
114
+ With multiple accounts, each account has its own independent quota. The plugin automatically rotates to the account with the most available quota.
92
115
 
93
116
  ## Troubleshooting
94
117
 
@@ -101,9 +124,10 @@ Make sure you've:
101
124
 
102
125
  ### "Rate limited" or generation fails
103
126
 
104
- - Wait a few seconds and try again
105
- - Check your quota with `image_quota`
106
- - The plugin automatically tries multiple endpoints
127
+ - With multiple accounts, the plugin automatically retries with the next available account
128
+ - Check your quota with `image_quota` to see per-account status
129
+ - If all accounts are exhausted, wait a few minutes for cooldowns to expire
130
+ - The plugin also tries multiple API endpoints per account
107
131
 
108
132
  ### Slow generation
109
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-antigravity-img",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCode plugin for Gemini image generation via Antigravity/CloudCode API",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -0,0 +1,165 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { selectAccount, markUsed, markRateLimited, RATE_LIMIT_COOLDOWN_MS } from "./accounts";
3
+ import type { AccountsConfig } from "./types";
4
+
5
+ // Helper to build a config with N accounts
6
+ function makeConfig(count: number, overrides?: Partial<Record<string, any>>[]): AccountsConfig {
7
+ const accounts = Array.from({ length: count }, (_, i) => ({
8
+ email: `user${i + 1}@test.com`,
9
+ refreshToken: `token_${i + 1}`,
10
+ ...((overrides && overrides[i]) || {}),
11
+ }));
12
+ return { accounts };
13
+ }
14
+
15
+ // -- selectAccount ---------------------------------------------------------
16
+
17
+ describe("selectAccount", () => {
18
+ test("returns null for empty accounts", () => {
19
+ const config: AccountsConfig = { accounts: [] };
20
+ expect(selectAccount(config)).toBeNull();
21
+ });
22
+
23
+ test("returns the only account when single", () => {
24
+ const config = makeConfig(1);
25
+ const result = selectAccount(config);
26
+ expect(result?.email).toBe("user1@test.com");
27
+ });
28
+
29
+ test("returns the least-recently-used account", () => {
30
+ const config = makeConfig(3, [
31
+ { lastUsed: 300 },
32
+ { lastUsed: 100 }, // oldest
33
+ { lastUsed: 200 },
34
+ ]);
35
+ const result = selectAccount(config);
36
+ expect(result?.email).toBe("user2@test.com");
37
+ });
38
+
39
+ test("never-used accounts (no lastUsed) get top priority", () => {
40
+ const config = makeConfig(3, [
41
+ { lastUsed: 100 },
42
+ {}, // never used
43
+ { lastUsed: 200 },
44
+ ]);
45
+ const result = selectAccount(config);
46
+ expect(result?.email).toBe("user2@test.com");
47
+ });
48
+
49
+ test("skips rate-limited accounts", () => {
50
+ const now = 1000;
51
+ const config = makeConfig(3, [
52
+ { rateLimitedUntil: now + 60000 }, // rate-limited
53
+ { lastUsed: 500 },
54
+ { lastUsed: 200 }, // oldest non-limited
55
+ ]);
56
+ const result = selectAccount(config, [], now);
57
+ expect(result?.email).toBe("user3@test.com");
58
+ });
59
+
60
+ test("rate-limited accounts with expired cooldown are available", () => {
61
+ const now = 1000;
62
+ const config = makeConfig(2, [
63
+ { rateLimitedUntil: now - 1 }, // expired, available again
64
+ { lastUsed: 500 },
65
+ ]);
66
+ const result = selectAccount(config, [], now);
67
+ expect(result?.email).toBe("user1@test.com");
68
+ });
69
+
70
+ test("skips excluded emails", () => {
71
+ const config = makeConfig(3);
72
+ const result = selectAccount(config, ["user1@test.com", "user2@test.com"]);
73
+ expect(result?.email).toBe("user3@test.com");
74
+ });
75
+
76
+ test("returns null when all accounts are excluded", () => {
77
+ const config = makeConfig(2);
78
+ const result = selectAccount(config, ["user1@test.com", "user2@test.com"]);
79
+ expect(result).toBeNull();
80
+ });
81
+
82
+ test("returns null when all accounts are rate-limited", () => {
83
+ const now = 1000;
84
+ const config = makeConfig(2, [
85
+ { rateLimitedUntil: now + 60000 },
86
+ { rateLimitedUntil: now + 60000 },
87
+ ]);
88
+ const result = selectAccount(config, [], now);
89
+ expect(result).toBeNull();
90
+ });
91
+
92
+ test("round-robin: sequential calls rotate through accounts", () => {
93
+ const config = makeConfig(3);
94
+
95
+ // First call: all unused, picks first in array (all have lastUsed 0)
96
+ const first = selectAccount(config);
97
+ expect(first?.email).toBe("user1@test.com");
98
+
99
+ // Simulate marking it used
100
+ markUsed(config, "user1@test.com", 100);
101
+
102
+ // Second call: user1 has lastUsed=100, user2 and user3 are still 0
103
+ const second = selectAccount(config);
104
+ expect(second?.email).toBe("user2@test.com");
105
+
106
+ markUsed(config, "user2@test.com", 200);
107
+
108
+ // Third call: user3 is still unused
109
+ const third = selectAccount(config);
110
+ expect(third?.email).toBe("user3@test.com");
111
+
112
+ markUsed(config, "user3@test.com", 300);
113
+
114
+ // Fourth call: back to user1 (oldest lastUsed)
115
+ const fourth = selectAccount(config);
116
+ expect(fourth?.email).toBe("user1@test.com");
117
+ });
118
+
119
+ test("rate-limit + exclude combo: finds next available", () => {
120
+ const now = 1000;
121
+ const config = makeConfig(4, [
122
+ { lastUsed: 10 }, // excluded
123
+ { rateLimitedUntil: now + 60000 }, // rate-limited
124
+ { lastUsed: 30 }, // available, older
125
+ { lastUsed: 20 }, // available, oldest -> picked
126
+ ]);
127
+ const result = selectAccount(config, ["user1@test.com"], now);
128
+ expect(result?.email).toBe("user4@test.com");
129
+ });
130
+ });
131
+
132
+ // -- markUsed --------------------------------------------------------------
133
+
134
+ describe("markUsed", () => {
135
+ test("sets lastUsed on the matching account", () => {
136
+ const config = makeConfig(2);
137
+ markUsed(config, "user2@test.com", 12345);
138
+ expect(config.accounts[1].lastUsed).toBe(12345);
139
+ expect(config.accounts[0].lastUsed).toBeUndefined();
140
+ });
141
+
142
+ test("does nothing for unknown email", () => {
143
+ const config = makeConfig(1);
144
+ markUsed(config, "unknown@test.com", 999);
145
+ expect(config.accounts[0].lastUsed).toBeUndefined();
146
+ });
147
+ });
148
+
149
+ // -- markRateLimited -------------------------------------------------------
150
+
151
+ describe("markRateLimited", () => {
152
+ test("sets rateLimitedUntil with cooldown offset", () => {
153
+ const config = makeConfig(2);
154
+ const now = 50000;
155
+ markRateLimited(config, "user1@test.com", now);
156
+ expect(config.accounts[0].rateLimitedUntil).toBe(now + RATE_LIMIT_COOLDOWN_MS);
157
+ expect(config.accounts[1].rateLimitedUntil).toBeUndefined();
158
+ });
159
+
160
+ test("does nothing for unknown email", () => {
161
+ const config = makeConfig(1);
162
+ markRateLimited(config, "unknown@test.com", 999);
163
+ expect(config.accounts[0].rateLimitedUntil).toBeUndefined();
164
+ });
165
+ });
@@ -0,0 +1,51 @@
1
+ import type { AccountsConfig, Account } from "./types";
2
+
3
+ export const MAX_RETRIES = 3;
4
+ export const RATE_LIMIT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
5
+
6
+ /**
7
+ * Select the least-recently-used account that is not rate-limited or excluded.
8
+ * Returns null if no account is available.
9
+ */
10
+ export function selectAccount(
11
+ config: AccountsConfig,
12
+ excludeEmails: string[] = [],
13
+ now: number = Date.now()
14
+ ): Account | null {
15
+ const available = config.accounts.filter((a) => {
16
+ if (excludeEmails.includes(a.email)) return false;
17
+ if (a.rateLimitedUntil && a.rateLimitedUntil > now) return false;
18
+ return true;
19
+ });
20
+
21
+ if (available.length === 0) return null;
22
+
23
+ // Least recently used first (never-used accounts get top priority)
24
+ available.sort((a, b) => (a.lastUsed || 0) - (b.lastUsed || 0));
25
+
26
+ return available[0];
27
+ }
28
+
29
+ /**
30
+ * Mark an account as recently used (mutates in place).
31
+ */
32
+ export function markUsed(config: AccountsConfig, email: string, now: number = Date.now()): void {
33
+ const account = config.accounts.find((a) => a.email === email);
34
+ if (account) {
35
+ account.lastUsed = now;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Mark an account as rate-limited with a cooldown (mutates in place).
41
+ */
42
+ export function markRateLimited(
43
+ config: AccountsConfig,
44
+ email: string,
45
+ now: number = Date.now()
46
+ ): void {
47
+ const account = config.accounts.find((a) => a.email === email);
48
+ if (account) {
49
+ account.rateLimitedUntil = now + RATE_LIMIT_COOLDOWN_MS;
50
+ }
51
+ }
package/src/api.ts CHANGED
@@ -213,6 +213,8 @@ export async function generateImage(
213
213
  };
214
214
 
215
215
  // Try each endpoint
216
+ let allRateLimited = true;
217
+
216
218
  for (const baseUrl of CLOUDCODE_FALLBACK_URLS) {
217
219
  try {
218
220
  const controller = new AbortController();
@@ -239,10 +241,13 @@ export async function generateImage(
239
241
  // Rate limited, try next endpoint
240
242
  continue;
241
243
  }
244
+ allRateLimited = false;
242
245
  const errorText = await response.text();
243
246
  return { success: false, error: `HTTP ${response.status}: ${errorText.slice(0, 200)}` };
244
247
  }
245
248
 
249
+ allRateLimited = false;
250
+
246
251
  // Parse SSE response
247
252
  const text = await response.text();
248
253
  const result = parseSSEResponse(text);
@@ -264,6 +269,7 @@ export async function generateImage(
264
269
 
265
270
  return result;
266
271
  } catch (err) {
272
+ allRateLimited = false;
267
273
  if (err instanceof Error && err.name === "AbortError") {
268
274
  continue; // Timeout, try next endpoint
269
275
  }
@@ -272,7 +278,7 @@ export async function generateImage(
272
278
  }
273
279
  }
274
280
 
275
- return { success: false, error: "All endpoints failed" };
281
+ return { success: false, error: "All endpoints failed", isRateLimited: allRateLimited };
276
282
  } catch (error) {
277
283
  return {
278
284
  success: false,
package/src/constants.ts CHANGED
@@ -40,13 +40,31 @@ export const CONFIG_PATHS = [
40
40
  join(homedir(), ".opencode", "antigravity-accounts.json"),
41
41
  ];
42
42
 
43
- // Command file for opencode discovery
44
- export const COMMAND_DIR = join(getConfigDir(), "commands");
43
+ // Command files for opencode discovery (opencode uses "command" singular)
44
+ export const COMMAND_DIR = join(getConfigDir(), "command");
45
+
45
46
  export const COMMAND_FILE = join(COMMAND_DIR, "generate-image.md");
46
- export const COMMAND_CONTENT = `# Generate Image
47
+ export const COMMAND_CONTENT = `---
48
+ description: Generate an image using Gemini 3 Pro Image model
49
+ ---
47
50
 
48
51
  Generate an image using Gemini 3 Pro Image model.
49
52
 
50
53
  Prompt: $PROMPT
51
54
  Output filename (optional): $FILENAME
52
55
  `;
56
+
57
+ export const QUOTA_COMMAND_FILE = join(COMMAND_DIR, "antigravity-quota-img.md");
58
+ export const QUOTA_COMMAND_CONTENT = `---
59
+ description: Check Antigravity image generation quota for all configured accounts
60
+ ---
61
+
62
+ Use the \`image_quota\` tool to check the current image generation quota status.
63
+
64
+ This will show:
65
+ - Gemini 3 Pro Image quota remaining per account
66
+ - Visual progress bars for each account
67
+ - Time until quota reset
68
+
69
+ IMPORTANT: Display the tool output EXACTLY as it is returned. Do not summarize, reformat, or modify the output in any way.
70
+ `;
package/src/index.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { type Plugin, tool } from "@opencode-ai/plugin";
2
2
  import * as fs from "fs/promises";
3
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
3
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
4
4
  import { join, dirname } from "path";
5
- import { CONFIG_PATHS, COMMAND_DIR, COMMAND_FILE, COMMAND_CONTENT, IMAGE_MODEL } from "./constants";
5
+ import { CONFIG_PATHS, COMMAND_DIR, COMMAND_FILE, COMMAND_CONTENT, QUOTA_COMMAND_FILE, QUOTA_COMMAND_CONTENT, IMAGE_MODEL } from "./constants";
6
6
  import type { AccountsConfig, Account, ImageGenerationOptions, AspectRatio, ImageSize } from "./types";
7
7
  import { generateImage, getImageModelQuota } from "./api";
8
+ import { selectAccount, markUsed, markRateLimited, MAX_RETRIES, RATE_LIMIT_COOLDOWN_MS } from "./accounts";
8
9
 
9
10
  // Create command file for opencode discovery
10
11
  try {
@@ -14,10 +15,16 @@ try {
14
15
  if (!existsSync(COMMAND_FILE)) {
15
16
  writeFileSync(COMMAND_FILE, COMMAND_CONTENT, "utf-8");
16
17
  }
18
+ if (!existsSync(QUOTA_COMMAND_FILE)) {
19
+ writeFileSync(QUOTA_COMMAND_FILE, QUOTA_COMMAND_CONTENT, "utf-8");
20
+ }
17
21
  } catch {
18
22
  // Non-fatal if command file creation fails
19
23
  }
20
24
 
25
+ // Track which config file was loaded so we can write back to it
26
+ let loadedConfigPath: string | null = null;
27
+
21
28
  /**
22
29
  * Load accounts from config file
23
30
  */
@@ -26,6 +33,7 @@ async function loadAccounts(): Promise<AccountsConfig | null> {
26
33
  if (existsSync(configPath)) {
27
34
  try {
28
35
  const content = await fs.readFile(configPath, "utf-8");
36
+ loadedConfigPath = configPath;
29
37
  return JSON.parse(content) as AccountsConfig;
30
38
  } catch {
31
39
  continue;
@@ -36,12 +44,31 @@ async function loadAccounts(): Promise<AccountsConfig | null> {
36
44
  }
37
45
 
38
46
  /**
39
- * Get the first available account
47
+ * Save accounts config back to the file it was loaded from
40
48
  */
41
- async function getAccount(): Promise<Account | null> {
42
- const config = await loadAccounts();
43
- if (!config?.accounts?.length) return null;
44
- return config.accounts[0] || null;
49
+ async function saveAccounts(config: AccountsConfig): Promise<void> {
50
+ const savePath = loadedConfigPath || CONFIG_PATHS[0];
51
+ const dirPath = dirname(savePath);
52
+ if (!existsSync(dirPath)) {
53
+ await fs.mkdir(dirPath, { recursive: true });
54
+ }
55
+ await fs.writeFile(savePath, JSON.stringify(config, null, 2), "utf-8");
56
+ }
57
+
58
+ /**
59
+ * Mark an account as recently used and persist to disk
60
+ */
61
+ async function markAccountUsed(config: AccountsConfig, email: string): Promise<void> {
62
+ markUsed(config, email);
63
+ await saveAccounts(config);
64
+ }
65
+
66
+ /**
67
+ * Mark an account as rate-limited with a cooldown and persist to disk
68
+ */
69
+ async function markAccountRateLimited(config: AccountsConfig, email: string): Promise<void> {
70
+ markRateLimited(config, email);
71
+ await saveAccounts(config);
45
72
  }
46
73
 
47
74
  /**
@@ -100,9 +127,9 @@ export const plugin: Plugin = async (ctx) => {
100
127
  return "Error: Please provide a prompt describing the image to generate.";
101
128
  }
102
129
 
103
- // Get account
104
- const account = await getAccount();
105
- if (!account) {
130
+ // Load all accounts
131
+ const config = await loadAccounts();
132
+ if (!config?.accounts?.length) {
106
133
  return (
107
134
  "Error: No Antigravity account found.\n\n" +
108
135
  "Please install and configure opencode-antigravity-auth first:\n" +
@@ -118,51 +145,87 @@ export const plugin: Plugin = async (ctx) => {
118
145
  const options: ImageGenerationOptions = {};
119
146
  if (aspect_ratio) options.aspectRatio = aspect_ratio as AspectRatio;
120
147
  if (image_size) options.imageSize = image_size as ImageSize;
148
+ const genOptions = Object.keys(options).length > 0 ? options : undefined;
121
149
 
122
- // Generate image
123
- const result = await generateImage(account, prompt, Object.keys(options).length > 0 ? options : undefined);
150
+ // Retry loop: rotate through accounts on any failure
151
+ const excludeEmails: string[] = [];
152
+ const errors: string[] = [];
124
153
 
125
- if (!result.success || !result.imageData) {
126
- return `Error generating image: ${result.error || "Unknown error"}`;
127
- }
154
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
155
+ const account = selectAccount(config, excludeEmails);
156
+ if (!account) break;
128
157
 
129
- // Determine output path (always JPEG regardless of extension)
130
- const dir = output_dir || ctx.directory;
131
- const name = filename || `generated_${Date.now()}.jpg`;
132
- const outputPath = join(dir, name);
158
+ const result = await generateImage(account, prompt, genOptions);
133
159
 
134
- // Ensure directory exists
135
- const dirPath = dirname(outputPath);
136
- if (!existsSync(dirPath)) {
137
- await fs.mkdir(dirPath, { recursive: true });
138
- }
160
+ if (result.success && result.imageData) {
161
+ // Mark account as used for round-robin rotation
162
+ await markAccountUsed(config, account.email);
139
163
 
140
- // Decode and save image
141
- const imageBuffer = Buffer.from(result.imageData, "base64");
142
- await fs.writeFile(outputPath, imageBuffer);
164
+ // Determine output path (always JPEG regardless of extension)
165
+ const dir = output_dir || ctx.directory;
166
+ const name = filename || `generated_${Date.now()}.jpg`;
167
+ const outputPath = join(dir, name);
143
168
 
144
- const sizeStr = formatSize(imageBuffer.length);
169
+ // Ensure directory exists
170
+ const outDir = dirname(outputPath);
171
+ if (!existsSync(outDir)) {
172
+ await fs.mkdir(outDir, { recursive: true });
173
+ }
145
174
 
146
- context.metadata({
147
- title: "Image generated",
148
- metadata: {
149
- path: outputPath,
150
- size: sizeStr,
151
- format: result.mimeType,
152
- },
153
- });
175
+ // Decode and save image
176
+ const imageBuffer = Buffer.from(result.imageData, "base64");
177
+ await fs.writeFile(outputPath, imageBuffer);
178
+
179
+ const sizeStr = formatSize(imageBuffer.length);
180
+ const totalAccounts = config.accounts.length;
181
+ const usedLabel = totalAccounts > 1 ? ` (account: ${account.email})` : "";
182
+
183
+ context.metadata({
184
+ title: "Image generated",
185
+ metadata: {
186
+ path: outputPath,
187
+ size: sizeStr,
188
+ format: result.mimeType,
189
+ },
190
+ });
154
191
 
155
- // Build response
156
- let response = `Image generated successfully!\n\n`;
157
- response += `Path: ${outputPath}\n`;
158
- response += `Size: ${sizeStr}\n`;
159
- response += `Format: ${result.mimeType}\n`;
192
+ // Build response
193
+ let response = `Image generated successfully!${usedLabel}\n\n`;
194
+ response += `Path: ${outputPath}\n`;
195
+ response += `Size: ${sizeStr}\n`;
196
+ response += `Format: ${result.mimeType}\n`;
160
197
 
161
- if (result.quota) {
162
- response += `\nQuota: ${formatQuota(result.quota.remainingPercent)} remaining`;
198
+ if (result.quota) {
199
+ response += `\nQuota: ${formatQuota(result.quota.remainingPercent)} remaining`;
200
+ }
201
+
202
+ return response;
203
+ }
204
+
205
+ // Rate-limited: mark with cooldown so future calls skip it too
206
+ if (result.isRateLimited) {
207
+ await markAccountRateLimited(config, account.email);
208
+ }
209
+
210
+ // Any failure: log it and try the next account
211
+ const reason = result.isRateLimited ? "rate-limited" : (result.error || "unknown error");
212
+ errors.push(`${account.email}: ${reason}`);
213
+ excludeEmails.push(account.email);
163
214
  }
164
215
 
165
- return response;
216
+ // All accounts failed -- build a helpful summary
217
+ let msg = "Error: Image generation failed.\n\n";
218
+ if (errors.length > 0) {
219
+ msg += "Accounts tried:\n";
220
+ msg += errors.map((e) => ` - ${e}`).join("\n") + "\n\n";
221
+ } else {
222
+ msg += "No accounts available to try.\n\n";
223
+ }
224
+ msg += "Possible fixes:\n";
225
+ msg += " - If rate-limited, wait a few minutes for quota to reset\n";
226
+ msg += " - If project ID errors, open the Antigravity IDE once with that Google account\n";
227
+ msg += " - Run image_quota to check account status";
228
+ return msg;
166
229
  },
167
230
  }),
168
231
 
@@ -175,8 +238,8 @@ export const plugin: Plugin = async (ctx) => {
175
238
  "Shows percentage remaining and time until reset.",
176
239
  args: {},
177
240
  async execute(args, context) {
178
- const account = await getAccount();
179
- if (!account) {
241
+ const config = await loadAccounts();
242
+ if (!config?.accounts?.length) {
180
243
  return (
181
244
  "Error: No Antigravity account found.\n" +
182
245
  "Please configure opencode-antigravity-auth first."
@@ -185,35 +248,62 @@ export const plugin: Plugin = async (ctx) => {
185
248
 
186
249
  context.metadata({ title: "Checking quota..." });
187
250
 
188
- const quota = await getImageModelQuota(account);
251
+ const accounts = config.accounts;
252
+ const isSingle = accounts.length === 1;
189
253
 
190
- if (!quota) {
191
- return "Error: Could not fetch quota information.";
192
- }
254
+ // Single account: keep the original compact output
255
+ if (isSingle) {
256
+ const quota = await getImageModelQuota(accounts[0]);
257
+ if (!quota) return "Error: Could not fetch quota information.";
193
258
 
194
- context.metadata({
195
- title: "Quota",
196
- metadata: {
197
- remaining: `${quota.remainingPercent.toFixed(0)}%`,
198
- resetIn: quota.resetIn,
199
- },
200
- });
259
+ context.metadata({
260
+ title: "Quota",
261
+ metadata: {
262
+ remaining: `${quota.remainingPercent.toFixed(0)}%`,
263
+ resetIn: quota.resetIn,
264
+ },
265
+ });
266
+
267
+ const barWidth = 20;
268
+ const filled = Math.round((quota.remainingPercent / 100) * barWidth);
269
+ const bar = "#".repeat(filled) + ".".repeat(barWidth - filled);
270
+
271
+ let response = `${quota.modelName}\n\n`;
272
+ response += `[${bar}] ${quota.remainingPercent.toFixed(0)}% remaining\n`;
273
+ response += `Resets in: ${quota.resetIn}`;
274
+ if (quota.resetTime) {
275
+ const resetDate = new Date(quota.resetTime);
276
+ response += ` (at ${resetDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })})`;
277
+ }
278
+ return response;
279
+ }
201
280
 
202
- // Visual progress bar
203
- const barWidth = 20;
204
- const filled = Math.round((quota.remainingPercent / 100) * barWidth);
205
- const empty = barWidth - filled;
206
- const bar = "#".repeat(filled) + ".".repeat(empty);
281
+ // Multi-account: show per-account breakdown
282
+ let response = `Image quota -- ${accounts.length} accounts\n\n`;
283
+ const now = Date.now();
207
284
 
208
- let response = `${quota.modelName}\n\n`;
209
- response += `[${bar}] ${quota.remainingPercent.toFixed(0)}% remaining\n`;
210
- response += `Resets in: ${quota.resetIn}`;
285
+ for (const account of accounts) {
286
+ const quota = await getImageModelQuota(account);
287
+ const rateLimited = account.rateLimitedUntil && account.rateLimitedUntil > now;
211
288
 
212
- if (quota.resetTime) {
213
- const resetDate = new Date(quota.resetTime);
214
- response += ` (at ${resetDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })})`;
289
+ if (quota) {
290
+ const barWidth = 20;
291
+ const filled = Math.round((quota.remainingPercent / 100) * barWidth);
292
+ const bar = "#".repeat(filled) + ".".repeat(barWidth - filled);
293
+ const flag = rateLimited ? " [rate-limited]" : "";
294
+ response += `${account.email}${flag}\n`;
295
+ response += ` [${bar}] ${quota.remainingPercent.toFixed(0)}% -- resets in ${quota.resetIn}\n`;
296
+ } else {
297
+ response += `${account.email}\n`;
298
+ response += ` [error fetching quota]\n`;
299
+ }
215
300
  }
216
301
 
302
+ context.metadata({
303
+ title: "Quota",
304
+ metadata: { accounts: String(accounts.length) },
305
+ });
306
+
217
307
  return response;
218
308
  },
219
309
  }),
package/src/types.ts CHANGED
@@ -5,6 +5,8 @@ export interface Account {
5
5
  accessToken?: string;
6
6
  projectId?: string;
7
7
  managedProjectId?: string;
8
+ lastUsed?: number;
9
+ rateLimitedUntil?: number;
8
10
  }
9
11
 
10
12
  export interface AccountsConfig {
@@ -105,6 +107,7 @@ export interface ImageGenerationResult {
105
107
  mimeType?: string;
106
108
  sizeBytes?: number;
107
109
  error?: string;
110
+ isRateLimited?: boolean;
108
111
  quota?: {
109
112
  remainingPercent: number;
110
113
  resetTime: string;