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 +17 -0
- package/README.md +29 -5
- package/package.json +1 -1
- package/src/accounts.test.ts +165 -0
- package/src/accounts.ts +51 -0
- package/src/api.ts +7 -1
- package/src/constants.ts +21 -3
- package/src/index.ts +158 -68
- package/src/types.ts +3 -0
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
|
-
-
|
|
105
|
-
- Check your quota with `image_quota`
|
|
106
|
-
-
|
|
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
|
@@ -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
|
+
});
|
package/src/accounts.ts
ADDED
|
@@ -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
|
|
44
|
-
export const COMMAND_DIR = join(getConfigDir(), "
|
|
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 =
|
|
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
|
|
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
|
-
*
|
|
47
|
+
* Save accounts config back to the file it was loaded from
|
|
40
48
|
*/
|
|
41
|
-
async function
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
//
|
|
104
|
-
const
|
|
105
|
-
if (!
|
|
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
|
-
//
|
|
123
|
-
const
|
|
150
|
+
// Retry loop: rotate through accounts on any failure
|
|
151
|
+
const excludeEmails: string[] = [];
|
|
152
|
+
const errors: string[] = [];
|
|
124
153
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
154
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
155
|
+
const account = selectAccount(config, excludeEmails);
|
|
156
|
+
if (!account) break;
|
|
128
157
|
|
|
129
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
169
|
+
// Ensure directory exists
|
|
170
|
+
const outDir = dirname(outputPath);
|
|
171
|
+
if (!existsSync(outDir)) {
|
|
172
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
173
|
+
}
|
|
145
174
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
if (!
|
|
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
|
|
251
|
+
const accounts = config.accounts;
|
|
252
|
+
const isSingle = accounts.length === 1;
|
|
189
253
|
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
//
|
|
203
|
-
|
|
204
|
-
const
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
285
|
+
for (const account of accounts) {
|
|
286
|
+
const quota = await getImageModelQuota(account);
|
|
287
|
+
const rateLimited = account.rateLimitedUntil && account.rateLimitedUntil > now;
|
|
211
288
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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;
|