Skip to content
This repository was archived by the owner on Feb 28, 2026. It is now read-only.

Commit b01235c

Browse files
authored
feat: add tier detection and split gemini model families for rate limiting (#7)
* feat: add tier detection and split gemini model families for rate limiting - Add automatic tier detection (free vs paid) via loadCodeAssist API - Split ModelFamily: gemini -> gemini-flash + gemini-pro for independent rate limiting - Implement paid account prioritization in multi-account rotation - Add strict account stickiness to preserve context cache - Remove redundant code in OAuth flow - Add comprehensive tier priority tests Fixes #6 * fix: add storage v3 migration for split gemini model families - Bump storage version to 3 with gemini-flash and gemini-pro keys - Add v2 interface with legacy single 'gemini' key - Add migrateV2ToV3 that splits gemini -> gemini-flash + gemini-pro - Migrations now preserve expired rate limits (cleanup at runtime) - Add tests for v2->v3 migration and tier preservation
1 parent 6859d8e commit b01235c

13 files changed

Lines changed: 402 additions & 62 deletions

File tree

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,3 @@ gemini-debug-*.log
3636

3737
# Log files
3838
*.log
39-
CLIProxyAPI

src/antigravity/oauth.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ interface AntigravityTokenExchangeSuccess {
3232
expires: number;
3333
email?: string;
3434
projectId: string;
35+
tier?: "free" | "paid";
3536
}
3637

3738
interface AntigravityTokenExchangeFailure {
@@ -72,10 +73,10 @@ function decodeState(state: string): AntigravityAuthState {
7273
}
7374

7475
/**
75-
* Automatically discover project ID from Antigravity API.
76-
* Tries multiple endpoints to find the user's default project.
76+
* Automatically discover project ID and account tier from Antigravity API.
77+
* Tries multiple endpoints to find the user's default project and tier.
7778
*/
78-
async function fetchProjectID(accessToken: string): Promise<string> {
79+
async function fetchAccountInfo(accessToken: string): Promise<{ projectId: string; tier: "free" | "paid" }> {
7980
const errors: string[] = [];
8081

8182
const loadHeaders: Record<string, string> = {
@@ -110,15 +111,38 @@ async function fetchProjectID(accessToken: string): Promise<string> {
110111
}
111112

112113
const data = await response.json();
114+
let projectId = "";
115+
let tier: "free" | "paid" = "free";
116+
117+
// Extract Project ID
113118
if (typeof data.cloudaicompanionProject === "string" && data.cloudaicompanionProject) {
114-
return data.cloudaicompanionProject;
115-
}
116-
if (
119+
projectId = data.cloudaicompanionProject;
120+
} else if (
117121
data.cloudaicompanionProject &&
118122
typeof data.cloudaicompanionProject.id === "string" &&
119123
data.cloudaicompanionProject.id
120124
) {
121-
return data.cloudaicompanionProject.id;
125+
projectId = data.cloudaicompanionProject.id;
126+
}
127+
128+
// Extract Tier
129+
// Default to "free" (legacy-tier), check for paid indicators
130+
if (Array.isArray(data.allowedTiers)) {
131+
const defaultTier = data.allowedTiers.find((t: any) => t.isDefault);
132+
if (defaultTier && typeof defaultTier.id === "string") {
133+
const tierId = defaultTier.id;
134+
// "legacy-tier" is the default free tier. Anything else is likely paid/upgraded.
135+
// We can refine this list as we learn more about tier IDs.
136+
if (tierId !== "legacy-tier" && !tierId.includes("free") && !tierId.includes("zero")) {
137+
tier = "paid";
138+
} else if (tierId !== "legacy-tier") {
139+
console.debug(`[antigravity] Detected free tier variant: ${tierId}`);
140+
}
141+
}
142+
}
143+
144+
if (projectId) {
145+
return { projectId, tier };
122146
}
123147

124148
errors.push(`loadCodeAssist missing project id at ${baseEndpoint}`);
@@ -132,9 +156,9 @@ async function fetchProjectID(accessToken: string): Promise<string> {
132156
}
133157

134158
if (errors.length) {
135-
console.warn("Failed to resolve Antigravity project via loadCodeAssist:", errors.join("; "));
159+
console.warn("Failed to resolve Antigravity account info via loadCodeAssist:", errors.join("; "));
136160
}
137-
return "";
161+
return { projectId: "", tier: "free" };
138162
}
139163

140164
export async function authorizeAntigravity(projectId = ""): Promise<AntigravityAuthorization> {
@@ -208,11 +232,14 @@ export async function exchangeAntigravity(
208232
return { type: "failed", error: "Missing refresh token in response" };
209233
}
210234

211-
// Auto-discover project ID if not provided
235+
// Auto-discover project ID and tier if not provided, or fetch tier if project ID is provided
212236
let effectiveProjectId = projectId;
237+
238+
const accountInfo = await fetchAccountInfo(tokenPayload.access_token);
213239
if (!effectiveProjectId) {
214-
effectiveProjectId = await fetchProjectID(tokenPayload.access_token);
240+
effectiveProjectId = accountInfo.projectId;
215241
}
242+
const tier = accountInfo.tier;
216243

217244
// Don't embed email in refresh token - we store it separately in accounts.json
218245
const storedRefresh = `${refreshToken}|${effectiveProjectId || ""}`;
@@ -224,6 +251,7 @@ export async function exchangeAntigravity(
224251
expires: Date.now() + tokenPayload.expires_in * 1000,
225252
email: userInfo.email,
226253
projectId: effectiveProjectId || "",
254+
tier,
227255
};
228256
} catch (error) {
229257
return {

src/plugin.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { AntigravityTokenExchangeResult } from "./antigravity/oauth";
33
import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth";
44
import { ANTIGRAVITY_PROVIDER_ID, MAX_ACCOUNTS } from "./constants";
55
import { accessTokenExpired, isOAuthAuth, parseRefreshParts, formatMultiAccountRefresh } from "./plugin/auth";
6-
import { AccountManager } from "./plugin/accounts";
6+
import { AccountManager, type AccountTier } from "./plugin/accounts";
77
import { openBrowser } from "./plugin/browser";
88
import { promptProjectId, promptAddAnotherAccount } from "./plugin/cli";
99
import { createAntigravityFetch } from "./plugin/fetch-wrapper";
@@ -35,7 +35,7 @@ async function getAuthContext(
3535

3636
const storedAccounts = await loadAccounts();
3737
const accountManager = new AccountManager(auth, storedAccounts);
38-
const account = accountManager.getCurrentOrNextForFamily("gemini");
38+
const account = accountManager.getCurrentOrNextForFamily("gemini-flash");
3939
if (!account) {
4040
return null;
4141
}
@@ -107,7 +107,7 @@ function createGoogleSearchTool(getAuth: GetAuth, client: PluginContext["client"
107107
async function authenticateSingleAccount(
108108
client: PluginContext["client"],
109109
isHeadless: boolean,
110-
): Promise<{ refresh: string; access: string; expires: number; projectId: string; email?: string } | null> {
110+
): Promise<{ refresh: string; access: string; expires: number; projectId: string; email?: string; tier?: AccountTier } | null> {
111111
let listener: OAuthListener | null = null;
112112
if (!isHeadless) {
113113
try {
@@ -229,6 +229,7 @@ async function authenticateSingleAccount(
229229
expires: result.expires,
230230
projectId: result.projectId,
231231
email: result.email,
232+
tier: result.tier as AccountTier,
232233
};
233234
}
234235

@@ -280,6 +281,7 @@ export const AntigravityOAuthPlugin = async ({ client }: PluginContext): Promise
280281
expires: number;
281282
projectId: string;
282283
email?: string;
284+
tier?: AccountTier;
283285
}> = [];
284286

285287
const firstAccount = await authenticateSingleAccount(client, isHeadless);
@@ -337,11 +339,12 @@ export const AntigravityOAuthPlugin = async ({ client }: PluginContext): Promise
337339

338340
try {
339341
await saveAccounts({
340-
version: 2,
342+
version: 3,
341343
accounts: accounts.map((acc, index) => ({
342344
email: acc.email,
343345
refreshToken: acc.refresh,
344346
projectId: acc.projectId,
347+
tier: acc.tier,
345348
managedProjectId: undefined,
346349
addedAt: Date.now(),
347350
lastUsed: index === 0 ? Date.now() : 0,

src/plugin/accounts.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect } from "bun:test";
22
import { AccountManager, type ModelFamily } from "./accounts";
33
import type { OAuthAuthDetails } from "./types";
44

5-
const FAMILY: ModelFamily = "gemini";
5+
const FAMILY: ModelFamily = "gemini-flash";
66

77
describe("AccountManager", () => {
88
it("should initialize with single account", () => {
@@ -149,7 +149,7 @@ describe("AccountManager", () => {
149149
const claudeAccount = manager.getCurrentOrNextForFamily("claude");
150150
expect(claudeAccount).toBeNull();
151151

152-
const geminiAccount = manager.getCurrentOrNextForFamily("gemini");
152+
const geminiAccount = manager.getCurrentOrNextForFamily("gemini-flash");
153153
expect(geminiAccount).not.toBeNull();
154154
expect(geminiAccount?.index).toBe(0);
155155
});

src/plugin/accounts.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import {
55
parseRefreshParts,
66
formatRefreshParts,
77
} from "./auth";
8-
import { saveAccounts, type AccountStorage, type RateLimitState, type ModelFamily } from "./storage";
8+
import { saveAccounts, type AccountStorage, type RateLimitState, type ModelFamily, type AccountTier } from "./storage";
99

10-
export type { ModelFamily } from "./storage";
10+
export type { ModelFamily, AccountTier } from "./storage";
1111

1212
export interface ManagedAccount {
1313
index: number;
@@ -17,6 +17,7 @@ export interface ManagedAccount {
1717
rateLimitResetTimes: RateLimitState;
1818
lastUsed: number;
1919
email?: string;
20+
tier?: AccountTier;
2021
lastSwitchReason?: "rate-limit" | "initial" | "rotation";
2122
}
2223

@@ -30,8 +31,11 @@ function clearExpiredRateLimits(account: ManagedAccount): void {
3031
if (account.rateLimitResetTimes.claude !== undefined && now >= account.rateLimitResetTimes.claude) {
3132
delete account.rateLimitResetTimes.claude;
3233
}
33-
if (account.rateLimitResetTimes.gemini !== undefined && now >= account.rateLimitResetTimes.gemini) {
34-
delete account.rateLimitResetTimes.gemini;
34+
if (account.rateLimitResetTimes["gemini-flash"] !== undefined && now >= account.rateLimitResetTimes["gemini-flash"]) {
35+
delete account.rateLimitResetTimes["gemini-flash"];
36+
}
37+
if (account.rateLimitResetTimes["gemini-pro"] !== undefined && now >= account.rateLimitResetTimes["gemini-pro"]) {
38+
delete account.rateLimitResetTimes["gemini-pro"];
3539
}
3640
}
3741

@@ -67,6 +71,7 @@ export class AccountManager {
6771
rateLimitResetTimes: acc.rateLimitResetTimes ?? {},
6872
lastUsed: acc.lastUsed,
6973
email: acc.email,
74+
tier: acc.tier,
7075
lastSwitchReason: acc.lastSwitchReason,
7176
}));
7277
} else {
@@ -99,9 +104,10 @@ export class AccountManager {
99104

100105
async save(): Promise<void> {
101106
const storage: AccountStorage = {
102-
version: 2,
107+
version: 3,
103108
accounts: this.accounts.map((acc) => ({
104109
email: acc.email,
110+
tier: acc.tier,
105111
refreshToken: acc.parts.refreshToken,
106112
projectId: acc.parts.projectId,
107113
managedProjectId: acc.parts.managedProjectId,
@@ -133,12 +139,19 @@ export class AccountManager {
133139
}
134140

135141
getCurrentOrNextForFamily(family: ModelFamily): ManagedAccount | null {
142+
this.accounts.forEach(clearExpiredRateLimits);
143+
136144
const current = this.getCurrentAccount();
137145
if (current) {
138-
clearExpiredRateLimits(current);
139146
if (!isRateLimitedForFamily(current, family)) {
140-
current.lastUsed = Date.now();
141-
return current;
147+
const betterTierAvailable =
148+
current.tier !== "paid" &&
149+
this.accounts.some((a) => a.tier === "paid" && !isRateLimitedForFamily(a, family));
150+
151+
if (!betterTierAvailable) {
152+
current.lastUsed = Date.now();
153+
return current;
154+
}
142155
}
143156
}
144157

@@ -150,16 +163,17 @@ export class AccountManager {
150163
}
151164

152165
getNextForFamily(family: ModelFamily): ManagedAccount | null {
153-
const available = this.accounts.filter((a) => {
154-
clearExpiredRateLimits(a);
155-
return !isRateLimitedForFamily(a, family);
156-
});
166+
const available = this.accounts.filter((a) => !isRateLimitedForFamily(a, family));
157167

158168
if (available.length === 0) {
159169
return null;
160170
}
161171

162-
const account = available[this.currentIndex % available.length];
172+
// Prioritize paid accounts
173+
const paidAvailable = available.filter((a) => a.tier === "paid");
174+
const pool = paidAvailable.length > 0 ? paidAvailable : available;
175+
176+
const account = pool[this.currentIndex % pool.length];
163177
if (!account) {
164178
return null;
165179
}
@@ -195,7 +209,7 @@ export class AccountManager {
195209
};
196210
}
197211

198-
addAccount(parts: RefreshParts, access?: string, expires?: number, email?: string): void {
212+
addAccount(parts: RefreshParts, access?: string, expires?: number, email?: string, tier?: AccountTier): void {
199213
this.accounts.push({
200214
index: this.accounts.length,
201215
parts,
@@ -204,6 +218,7 @@ export class AccountManager {
204218
rateLimitResetTimes: {},
205219
lastUsed: 0,
206220
email,
221+
tier,
207222
});
208223
}
209224

src/plugin/cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function clearCachedAuth(refresh?: string): void {
6969

7070
const signatureCache = new Map<string, string>();
7171

72-
export type ModelFamily = "claude" | "gemini";
72+
export type ModelFamily = "claude" | "gemini-flash" | "gemini-pro";
7373

7474
/**
7575
* Generates a SHA-256 hash key for a thought block.

src/plugin/fetch-wrapper.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ function getModelFamilyFromUrl(urlString: string): ModelFamily {
5252
if (model && model.includes("claude")) {
5353
return "claude";
5454
}
55-
return "gemini";
55+
if (model && model.includes("flash")) {
56+
return "gemini-flash";
57+
}
58+
return "gemini-pro";
5659
}
5760

5861
export function sleep(ms: number, signal?: AbortSignal | null): Promise<void> {
@@ -464,6 +467,7 @@ async function handleServerError(
464467
): Promise<EndpointLoopResult> {
465468
const retryAfterMs = 60000;
466469

470+
// For 500 errors, we use a fixed short retry (1 min) rather than the heavy defaults
467471
accountManager.markRateLimited(account, retryAfterMs, family);
468472

469473
log.warn(`Account ${account.index + 1}/${accountCount} received ${response.status} error on all endpoints`, {

src/plugin/request.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ const MODEL_FALLBACKS: Record<string, string> = {
3939
};
4040

4141
function getModelFamily(model: string): ModelFamily {
42-
return model.includes("claude") ? "claude" : "gemini";
42+
return model.includes("claude")
43+
? "claude"
44+
: model.includes("flash")
45+
? "gemini-flash"
46+
: "gemini-pro";
4347
}
4448

4549
export function isGenerativeLanguageRequest(input: RequestInfo): boolean {
@@ -642,7 +646,7 @@ export async function transformAntigravityResponse(
642646
const contentType = response.headers.get("content-type") ?? "";
643647
const isJsonResponse = contentType.includes("application/json");
644648
const isEventStreamResponse = contentType.includes("text/event-stream");
645-
const family: ModelFamily = requestedModel ? getModelFamily(requestedModel) : "gemini";
649+
const family: ModelFamily = requestedModel ? getModelFamily(requestedModel) : "gemini-flash";
646650

647651
if (!isJsonResponse && !isEventStreamResponse) {
648652
logAntigravityDebugResponse(debugContext, response, {

0 commit comments

Comments
 (0)