opencode-antigravity-auth-remix 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +723 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/src/antigravity/oauth.d.ts +31 -0
- package/dist/src/antigravity/oauth.d.ts.map +1 -0
- package/dist/src/antigravity/oauth.js +168 -0
- package/dist/src/antigravity/oauth.js.map +1 -0
- package/dist/src/constants.d.ts +107 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +138 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/hooks/auto-update-checker/cache.d.ts +3 -0
- package/dist/src/hooks/auto-update-checker/cache.d.ts.map +1 -0
- package/dist/src/hooks/auto-update-checker/cache.js +71 -0
- package/dist/src/hooks/auto-update-checker/cache.js.map +1 -0
- package/dist/src/hooks/auto-update-checker/checker.d.ts +16 -0
- package/dist/src/hooks/auto-update-checker/checker.d.ts.map +1 -0
- package/dist/src/hooks/auto-update-checker/checker.js +237 -0
- package/dist/src/hooks/auto-update-checker/checker.js.map +1 -0
- package/dist/src/hooks/auto-update-checker/constants.d.ts +9 -0
- package/dist/src/hooks/auto-update-checker/constants.d.ts.map +1 -0
- package/dist/src/hooks/auto-update-checker/constants.js +23 -0
- package/dist/src/hooks/auto-update-checker/constants.js.map +1 -0
- package/dist/src/hooks/auto-update-checker/index.d.ts +34 -0
- package/dist/src/hooks/auto-update-checker/index.d.ts.map +1 -0
- package/dist/src/hooks/auto-update-checker/index.js +125 -0
- package/dist/src/hooks/auto-update-checker/index.js.map +1 -0
- package/dist/src/hooks/auto-update-checker/types.d.ts +25 -0
- package/dist/src/hooks/auto-update-checker/types.d.ts.map +1 -0
- package/dist/src/hooks/auto-update-checker/types.js +1 -0
- package/dist/src/hooks/auto-update-checker/types.js.map +1 -0
- package/dist/src/plugin/accounts.d.ts +58 -0
- package/dist/src/plugin/accounts.d.ts.map +1 -0
- package/dist/src/plugin/accounts.js +350 -0
- package/dist/src/plugin/accounts.js.map +1 -0
- package/dist/src/plugin/auth.d.ts +21 -0
- package/dist/src/plugin/auth.d.ts.map +1 -0
- package/dist/src/plugin/auth.js +46 -0
- package/dist/src/plugin/auth.js.map +1 -0
- package/dist/src/plugin/cache/index.d.ts +5 -0
- package/dist/src/plugin/cache/index.d.ts.map +1 -0
- package/dist/src/plugin/cache/index.js +5 -0
- package/dist/src/plugin/cache/index.js.map +1 -0
- package/dist/src/plugin/cache/signature-cache.d.ts +111 -0
- package/dist/src/plugin/cache/signature-cache.d.ts.map +1 -0
- package/dist/src/plugin/cache/signature-cache.js +373 -0
- package/dist/src/plugin/cache/signature-cache.js.map +1 -0
- package/dist/src/plugin/cache.d.ts +44 -0
- package/dist/src/plugin/cache.d.ts.map +1 -0
- package/dist/src/plugin/cache.js +200 -0
- package/dist/src/plugin/cache.js.map +1 -0
- package/dist/src/plugin/cli.d.ts +19 -0
- package/dist/src/plugin/cli.d.ts.map +1 -0
- package/dist/src/plugin/cli.js +59 -0
- package/dist/src/plugin/cli.js.map +1 -0
- package/dist/src/plugin/config/index.d.ts +16 -0
- package/dist/src/plugin/config/index.d.ts.map +1 -0
- package/dist/src/plugin/config/index.js +16 -0
- package/dist/src/plugin/config/index.js.map +1 -0
- package/dist/src/plugin/config/loader.d.ts +35 -0
- package/dist/src/plugin/config/loader.d.ts.map +1 -0
- package/dist/src/plugin/config/loader.js +178 -0
- package/dist/src/plugin/config/loader.js.map +1 -0
- package/dist/src/plugin/config/schema.d.ts +257 -0
- package/dist/src/plugin/config/schema.d.ts.map +1 -0
- package/dist/src/plugin/config/schema.js +229 -0
- package/dist/src/plugin/config/schema.js.map +1 -0
- package/dist/src/plugin/core/streaming/index.d.ts +3 -0
- package/dist/src/plugin/core/streaming/index.d.ts.map +1 -0
- package/dist/src/plugin/core/streaming/index.js +3 -0
- package/dist/src/plugin/core/streaming/index.js.map +1 -0
- package/dist/src/plugin/core/streaming/transformer.d.ts +9 -0
- package/dist/src/plugin/core/streaming/transformer.d.ts.map +1 -0
- package/dist/src/plugin/core/streaming/transformer.js +134 -0
- package/dist/src/plugin/core/streaming/transformer.js.map +1 -0
- package/dist/src/plugin/core/streaming/types.d.ts +26 -0
- package/dist/src/plugin/core/streaming/types.d.ts.map +1 -0
- package/dist/src/plugin/core/streaming/types.js +1 -0
- package/dist/src/plugin/core/streaming/types.js.map +1 -0
- package/dist/src/plugin/debug.d.ts +68 -0
- package/dist/src/plugin/debug.d.ts.map +1 -0
- package/dist/src/plugin/debug.js +321 -0
- package/dist/src/plugin/debug.js.map +1 -0
- package/dist/src/plugin/errors.d.ts +28 -0
- package/dist/src/plugin/errors.d.ts.map +1 -0
- package/dist/src/plugin/errors.js +42 -0
- package/dist/src/plugin/errors.js.map +1 -0
- package/dist/src/plugin/logger.d.ts +54 -0
- package/dist/src/plugin/logger.d.ts.map +1 -0
- package/dist/src/plugin/logger.js +120 -0
- package/dist/src/plugin/logger.js.map +1 -0
- package/dist/src/plugin/project.d.ts +33 -0
- package/dist/src/plugin/project.d.ts.map +1 -0
- package/dist/src/plugin/project.js +239 -0
- package/dist/src/plugin/project.js.map +1 -0
- package/dist/src/plugin/recovery/constants.d.ts +22 -0
- package/dist/src/plugin/recovery/constants.d.ts.map +1 -0
- package/dist/src/plugin/recovery/constants.js +43 -0
- package/dist/src/plugin/recovery/constants.js.map +1 -0
- package/dist/src/plugin/recovery/index.d.ts +12 -0
- package/dist/src/plugin/recovery/index.d.ts.map +1 -0
- package/dist/src/plugin/recovery/index.js +12 -0
- package/dist/src/plugin/recovery/index.js.map +1 -0
- package/dist/src/plugin/recovery/storage.d.ts +24 -0
- package/dist/src/plugin/recovery/storage.d.ts.map +1 -0
- package/dist/src/plugin/recovery/storage.js +354 -0
- package/dist/src/plugin/recovery/storage.js.map +1 -0
- package/dist/src/plugin/recovery/types.d.ts +116 -0
- package/dist/src/plugin/recovery/types.d.ts.map +1 -0
- package/dist/src/plugin/recovery/types.js +6 -0
- package/dist/src/plugin/recovery/types.js.map +1 -0
- package/dist/src/plugin/recovery.d.ts +61 -0
- package/dist/src/plugin/recovery.d.ts.map +1 -0
- package/dist/src/plugin/recovery.js +376 -0
- package/dist/src/plugin/recovery.js.map +1 -0
- package/dist/src/plugin/refresh-queue.d.ts +101 -0
- package/dist/src/plugin/refresh-queue.d.ts.map +1 -0
- package/dist/src/plugin/refresh-queue.js +244 -0
- package/dist/src/plugin/refresh-queue.js.map +1 -0
- package/dist/src/plugin/request-helpers.d.ts +270 -0
- package/dist/src/plugin/request-helpers.d.ts.map +1 -0
- package/dist/src/plugin/request-helpers.js +2158 -0
- package/dist/src/plugin/request-helpers.js.map +1 -0
- package/dist/src/plugin/request.d.ts +87 -0
- package/dist/src/plugin/request.d.ts.map +1 -0
- package/dist/src/plugin/request.js +1233 -0
- package/dist/src/plugin/request.js.map +1 -0
- package/dist/src/plugin/search.d.ts +19 -0
- package/dist/src/plugin/search.d.ts.map +1 -0
- package/dist/src/plugin/search.js +191 -0
- package/dist/src/plugin/search.js.map +1 -0
- package/dist/src/plugin/server.d.ts +23 -0
- package/dist/src/plugin/server.d.ts.map +1 -0
- package/dist/src/plugin/server.js +222 -0
- package/dist/src/plugin/server.js.map +1 -0
- package/dist/src/plugin/storage.d.ts +77 -0
- package/dist/src/plugin/storage.d.ts.map +1 -0
- package/dist/src/plugin/storage.js +207 -0
- package/dist/src/plugin/storage.js.map +1 -0
- package/dist/src/plugin/stores/signature-store.d.ts +5 -0
- package/dist/src/plugin/stores/signature-store.d.ts.map +1 -0
- package/dist/src/plugin/stores/signature-store.js +25 -0
- package/dist/src/plugin/stores/signature-store.js.map +1 -0
- package/dist/src/plugin/thinking-recovery.d.ts +90 -0
- package/dist/src/plugin/thinking-recovery.d.ts.map +1 -0
- package/dist/src/plugin/thinking-recovery.js +316 -0
- package/dist/src/plugin/thinking-recovery.js.map +1 -0
- package/dist/src/plugin/token.d.ts +19 -0
- package/dist/src/plugin/token.d.ts.map +1 -0
- package/dist/src/plugin/token.js +128 -0
- package/dist/src/plugin/token.js.map +1 -0
- package/dist/src/plugin/transform/claude.d.ts +80 -0
- package/dist/src/plugin/transform/claude.d.ts.map +1 -0
- package/dist/src/plugin/transform/claude.js +265 -0
- package/dist/src/plugin/transform/claude.js.map +1 -0
- package/dist/src/plugin/transform/cross-model-sanitizer.d.ts +35 -0
- package/dist/src/plugin/transform/cross-model-sanitizer.d.ts.map +1 -0
- package/dist/src/plugin/transform/cross-model-sanitizer.js +225 -0
- package/dist/src/plugin/transform/cross-model-sanitizer.js.map +1 -0
- package/dist/src/plugin/transform/gemini.d.ts +63 -0
- package/dist/src/plugin/transform/gemini.d.ts.map +1 -0
- package/dist/src/plugin/transform/gemini.js +142 -0
- package/dist/src/plugin/transform/gemini.js.map +1 -0
- package/dist/src/plugin/transform/index.d.ts +14 -0
- package/dist/src/plugin/transform/index.d.ts.map +1 -0
- package/dist/src/plugin/transform/index.js +14 -0
- package/dist/src/plugin/transform/index.js.map +1 -0
- package/dist/src/plugin/transform/model-resolver.d.ts +78 -0
- package/dist/src/plugin/transform/model-resolver.d.ts.map +1 -0
- package/dist/src/plugin/transform/model-resolver.js +221 -0
- package/dist/src/plugin/transform/model-resolver.js.map +1 -0
- package/dist/src/plugin/transform/types.d.ts +93 -0
- package/dist/src/plugin/transform/types.d.ts.map +1 -0
- package/dist/src/plugin/transform/types.js +1 -0
- package/dist/src/plugin/transform/types.js.map +1 -0
- package/dist/src/plugin/types.d.ts +97 -0
- package/dist/src/plugin/types.d.ts.map +1 -0
- package/dist/src/plugin/types.js +1 -0
- package/dist/src/plugin/types.js.map +1 -0
- package/dist/src/plugin.d.ts +8 -0
- package/dist/src/plugin.d.ts.map +1 -0
- package/dist/src/plugin.js +1845 -0
- package/dist/src/plugin.js.map +1 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1845 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { tool } from "@opencode-ai/plugin";
|
|
3
|
+
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_PROVIDER_ID, } from "./constants";
|
|
4
|
+
import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth";
|
|
5
|
+
import { accessTokenExpired, isOAuthAuth, parseRefreshParts, } from "./plugin/auth";
|
|
6
|
+
import { promptAddAnotherAccount, promptLoginMode, promptProjectId, } from "./plugin/cli";
|
|
7
|
+
import { ensureProjectContext } from "./plugin/project";
|
|
8
|
+
import { executeSearch } from "./plugin/search";
|
|
9
|
+
import { startAntigravityDebugRequest, logAntigravityDebugResponse, logAccountContext, logRateLimitEvent, logRateLimitSnapshot, logResponseBody, logModelFamily, isDebugEnabled, getLogFilePath, initializeDebug, } from "./plugin/debug";
|
|
10
|
+
import { buildThinkingWarmupBody, isGenerativeLanguageRequest, prepareAntigravityRequest, transformAntigravityResponse, } from "./plugin/request";
|
|
11
|
+
import { resolveModelWithTier } from "./plugin/transform/model-resolver";
|
|
12
|
+
import { isEmptyResponseBody, createSyntheticErrorResponse, } from "./plugin/request-helpers";
|
|
13
|
+
import { EmptyResponseError } from "./plugin/errors";
|
|
14
|
+
import { AntigravityTokenRefreshError, refreshAccessToken, } from "./plugin/token";
|
|
15
|
+
import { startOAuthListener } from "./plugin/server";
|
|
16
|
+
import { clearAccounts, loadAccounts, saveAccounts } from "./plugin/storage";
|
|
17
|
+
import { AccountManager } from "./plugin/accounts";
|
|
18
|
+
import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker";
|
|
19
|
+
import { loadConfig } from "./plugin/config";
|
|
20
|
+
import { createSessionRecoveryHook, getRecoverySuccessToast, } from "./plugin/recovery";
|
|
21
|
+
import { initDiskSignatureCache } from "./plugin/cache";
|
|
22
|
+
import { createProactiveRefreshQueue, } from "./plugin/refresh-queue";
|
|
23
|
+
import { initLogger, createLogger } from "./plugin/logger";
|
|
24
|
+
const MAX_OAUTH_ACCOUNTS = 10;
|
|
25
|
+
const MAX_WARMUP_SESSIONS = 1000;
|
|
26
|
+
const MAX_WARMUP_RETRIES = 2;
|
|
27
|
+
const warmupAttemptedSessionIds = new Set();
|
|
28
|
+
const warmupSucceededSessionIds = new Set();
|
|
29
|
+
const log = createLogger("plugin");
|
|
30
|
+
/**
|
|
31
|
+
* Gets authentication context for search tool execution.
|
|
32
|
+
* Returns access token and project ID, or null if not authenticated.
|
|
33
|
+
*/
|
|
34
|
+
async function getAuthContext(getAuth, client) {
|
|
35
|
+
const auth = await getAuth();
|
|
36
|
+
if (!isOAuthAuth(auth)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const storedAccounts = await loadAccounts();
|
|
40
|
+
const accountManager = storedAccounts
|
|
41
|
+
? await AccountManager.loadFromDisk(auth)
|
|
42
|
+
: new AccountManager(auth);
|
|
43
|
+
const account = accountManager.getCurrentOrNextForFamily("gemini");
|
|
44
|
+
if (!account) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
let authRecord = accountManager.toAuthDetails(account);
|
|
48
|
+
if (accessTokenExpired(authRecord)) {
|
|
49
|
+
const refreshed = await refreshAccessToken(authRecord, client, ANTIGRAVITY_PROVIDER_ID);
|
|
50
|
+
if (!refreshed) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
authRecord = refreshed;
|
|
54
|
+
accountManager.updateFromAuth(account, refreshed);
|
|
55
|
+
try {
|
|
56
|
+
await accountManager.saveToDisk();
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
}
|
|
60
|
+
const accessToken = authRecord.access;
|
|
61
|
+
if (!accessToken) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const projectContext = await ensureProjectContext(authRecord);
|
|
66
|
+
return { accessToken, projectId: projectContext.effectiveProjectId };
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Creates the Google Search tool for web searching and URL analysis.
|
|
74
|
+
*/
|
|
75
|
+
function createGoogleSearchTool(getAuth, client) {
|
|
76
|
+
return tool({
|
|
77
|
+
description: "Search the web using Google Search and analyze URLs. Returns real-time information from the internet with source citations. Use this when you need up-to-date information about current events, recent developments, or any topic that may have changed. You can also provide specific URLs to analyze. IMPORTANT: If the user mentions or provides any URLs in their query, you MUST extract those URLs and pass them in the 'urls' parameter for direct analysis.",
|
|
78
|
+
args: {
|
|
79
|
+
query: tool.schema
|
|
80
|
+
.string()
|
|
81
|
+
.describe("The search query or question to answer using web search"),
|
|
82
|
+
urls: tool.schema
|
|
83
|
+
.array(tool.schema.string())
|
|
84
|
+
.optional()
|
|
85
|
+
.describe("List of specific URLs to fetch and analyze. IMPORTANT: Always extract and include any URLs mentioned by the user in their query here."),
|
|
86
|
+
thinking: tool.schema
|
|
87
|
+
.boolean()
|
|
88
|
+
.optional()
|
|
89
|
+
.default(true)
|
|
90
|
+
.describe("Enable deep thinking for more thorough analysis (default: true)"),
|
|
91
|
+
},
|
|
92
|
+
async execute(args, ctx) {
|
|
93
|
+
log.debug("Google Search tool called", {
|
|
94
|
+
query: args.query,
|
|
95
|
+
urlCount: args.urls?.length ?? 0,
|
|
96
|
+
});
|
|
97
|
+
const authContext = await getAuthContext(getAuth, client);
|
|
98
|
+
if (!authContext) {
|
|
99
|
+
return "Error: Not authenticated with Antigravity. Please run `opencode auth login` to authenticate.";
|
|
100
|
+
}
|
|
101
|
+
return executeSearch({
|
|
102
|
+
query: args.query,
|
|
103
|
+
urls: args.urls,
|
|
104
|
+
thinking: args.thinking,
|
|
105
|
+
}, authContext.accessToken, authContext.projectId, ctx.abort);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function trackWarmupAttempt(sessionId) {
|
|
110
|
+
if (warmupSucceededSessionIds.has(sessionId)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (warmupAttemptedSessionIds.size >= MAX_WARMUP_SESSIONS) {
|
|
114
|
+
const first = warmupAttemptedSessionIds.values().next().value;
|
|
115
|
+
if (first) {
|
|
116
|
+
warmupAttemptedSessionIds.delete(first);
|
|
117
|
+
warmupSucceededSessionIds.delete(first);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const attempts = getWarmupAttemptCount(sessionId);
|
|
121
|
+
if (attempts >= MAX_WARMUP_RETRIES) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
warmupAttemptedSessionIds.add(sessionId);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
function getWarmupAttemptCount(sessionId) {
|
|
128
|
+
return warmupAttemptedSessionIds.has(sessionId) ? 1 : 0;
|
|
129
|
+
}
|
|
130
|
+
function markWarmupSuccess(sessionId) {
|
|
131
|
+
warmupSucceededSessionIds.add(sessionId);
|
|
132
|
+
if (warmupSucceededSessionIds.size >= MAX_WARMUP_SESSIONS) {
|
|
133
|
+
const first = warmupSucceededSessionIds.values().next().value;
|
|
134
|
+
if (first)
|
|
135
|
+
warmupSucceededSessionIds.delete(first);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function clearWarmupAttempt(sessionId) {
|
|
139
|
+
warmupAttemptedSessionIds.delete(sessionId);
|
|
140
|
+
}
|
|
141
|
+
function isWSL() {
|
|
142
|
+
if (process.platform !== "linux")
|
|
143
|
+
return false;
|
|
144
|
+
try {
|
|
145
|
+
const { readFileSync } = require("node:fs");
|
|
146
|
+
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
|
147
|
+
return release.includes("microsoft") || release.includes("wsl");
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function isWSL2() {
|
|
154
|
+
if (!isWSL())
|
|
155
|
+
return false;
|
|
156
|
+
try {
|
|
157
|
+
const { readFileSync } = require("node:fs");
|
|
158
|
+
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
|
159
|
+
return version.includes("wsl2") || version.includes("microsoft-standard");
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function isRemoteEnvironment() {
|
|
166
|
+
if (process.env.SSH_CLIENT ||
|
|
167
|
+
process.env.SSH_TTY ||
|
|
168
|
+
process.env.SSH_CONNECTION) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
if (process.platform === "linux" &&
|
|
175
|
+
!process.env.DISPLAY &&
|
|
176
|
+
!process.env.WAYLAND_DISPLAY &&
|
|
177
|
+
!isWSL()) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
function shouldSkipLocalServer() {
|
|
183
|
+
return isWSL2() || isRemoteEnvironment();
|
|
184
|
+
}
|
|
185
|
+
async function openBrowser(url) {
|
|
186
|
+
try {
|
|
187
|
+
if (process.platform === "darwin") {
|
|
188
|
+
exec(`open "${url}"`);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
if (process.platform === "win32") {
|
|
192
|
+
exec(`start "" "${url}"`);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (isWSL()) {
|
|
196
|
+
try {
|
|
197
|
+
exec(`wslview "${url}"`);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
catch { }
|
|
201
|
+
}
|
|
202
|
+
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
exec(`xdg-open "${url}"`);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function promptOAuthCallbackValue(message) {
|
|
213
|
+
const { createInterface } = await import("node:readline/promises");
|
|
214
|
+
const { stdin, stdout } = await import("node:process");
|
|
215
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
216
|
+
try {
|
|
217
|
+
return (await rl.question(message)).trim();
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
rl.close();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function getStateFromAuthorizationUrl(authorizationUrl) {
|
|
224
|
+
try {
|
|
225
|
+
return new URL(authorizationUrl).searchParams.get("state") ?? "";
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return "";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function extractOAuthCallbackParams(url) {
|
|
232
|
+
const code = url.searchParams.get("code");
|
|
233
|
+
const state = url.searchParams.get("state");
|
|
234
|
+
if (!code || !state) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
return { code, state };
|
|
238
|
+
}
|
|
239
|
+
function parseOAuthCallbackInput(value, fallbackState) {
|
|
240
|
+
const trimmed = value.trim();
|
|
241
|
+
if (!trimmed) {
|
|
242
|
+
return { error: "Missing authorization code" };
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const url = new URL(trimmed);
|
|
246
|
+
const code = url.searchParams.get("code");
|
|
247
|
+
const state = url.searchParams.get("state") ?? fallbackState;
|
|
248
|
+
if (!code) {
|
|
249
|
+
return { error: "Missing code in callback URL" };
|
|
250
|
+
}
|
|
251
|
+
if (!state) {
|
|
252
|
+
return { error: "Missing state in callback URL" };
|
|
253
|
+
}
|
|
254
|
+
return { code, state };
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
if (!fallbackState) {
|
|
258
|
+
return {
|
|
259
|
+
error: "Missing state. Paste the full redirect URL instead of only the code.",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return { code: trimmed, state: fallbackState };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async function promptManualOAuthInput(fallbackState) {
|
|
266
|
+
console.log("1. Open the URL above in your browser and complete Google sign-in.");
|
|
267
|
+
console.log("2. After approving, copy the full redirected localhost URL from the address bar.");
|
|
268
|
+
console.log("3. Paste it back here.\n");
|
|
269
|
+
const callbackInput = await promptOAuthCallbackValue("Paste the redirect URL (or just the code) here: ");
|
|
270
|
+
const params = parseOAuthCallbackInput(callbackInput, fallbackState);
|
|
271
|
+
if ("error" in params) {
|
|
272
|
+
return { type: "failed", error: params.error };
|
|
273
|
+
}
|
|
274
|
+
return exchangeAntigravity(params.code, params.state);
|
|
275
|
+
}
|
|
276
|
+
function clampInt(value, min, max) {
|
|
277
|
+
if (!Number.isFinite(value)) {
|
|
278
|
+
return min;
|
|
279
|
+
}
|
|
280
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
281
|
+
}
|
|
282
|
+
async function persistAccountPool(results, replaceAll = false) {
|
|
283
|
+
if (results.length === 0) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const now = Date.now();
|
|
287
|
+
// If replaceAll is true (fresh login), start with empty accounts
|
|
288
|
+
// Otherwise, load existing accounts and merge
|
|
289
|
+
const stored = replaceAll ? null : await loadAccounts();
|
|
290
|
+
const accounts = stored?.accounts ? [...stored.accounts] : [];
|
|
291
|
+
const indexByRefreshToken = new Map();
|
|
292
|
+
const indexByEmail = new Map();
|
|
293
|
+
for (let i = 0; i < accounts.length; i++) {
|
|
294
|
+
const acc = accounts[i];
|
|
295
|
+
if (acc?.refreshToken) {
|
|
296
|
+
indexByRefreshToken.set(acc.refreshToken, i);
|
|
297
|
+
}
|
|
298
|
+
if (acc?.email) {
|
|
299
|
+
indexByEmail.set(acc.email, i);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const result of results) {
|
|
303
|
+
const parts = parseRefreshParts(result.refresh);
|
|
304
|
+
if (!parts.refreshToken) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
// First, check for existing account by email (prevents duplicates when refresh token changes)
|
|
308
|
+
// Only use email-based deduplication if the new account has an email
|
|
309
|
+
const existingByEmail = result.email
|
|
310
|
+
? indexByEmail.get(result.email)
|
|
311
|
+
: undefined;
|
|
312
|
+
const existingByToken = indexByRefreshToken.get(parts.refreshToken);
|
|
313
|
+
// Prefer email-based match to handle refresh token rotation
|
|
314
|
+
const existingIndex = existingByEmail ?? existingByToken;
|
|
315
|
+
if (existingIndex === undefined) {
|
|
316
|
+
// New account - add it
|
|
317
|
+
const newIndex = accounts.length;
|
|
318
|
+
indexByRefreshToken.set(parts.refreshToken, newIndex);
|
|
319
|
+
if (result.email) {
|
|
320
|
+
indexByEmail.set(result.email, newIndex);
|
|
321
|
+
}
|
|
322
|
+
accounts.push({
|
|
323
|
+
email: result.email,
|
|
324
|
+
refreshToken: parts.refreshToken,
|
|
325
|
+
projectId: parts.projectId,
|
|
326
|
+
managedProjectId: parts.managedProjectId,
|
|
327
|
+
addedAt: now,
|
|
328
|
+
lastUsed: now,
|
|
329
|
+
});
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const existing = accounts[existingIndex];
|
|
333
|
+
if (!existing) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
// Update existing account (this handles both email match and token match cases)
|
|
337
|
+
// When email matches but token differs, this effectively replaces the old token
|
|
338
|
+
const oldToken = existing.refreshToken;
|
|
339
|
+
accounts[existingIndex] = {
|
|
340
|
+
...existing,
|
|
341
|
+
email: result.email ?? existing.email,
|
|
342
|
+
refreshToken: parts.refreshToken,
|
|
343
|
+
projectId: parts.projectId ?? existing.projectId,
|
|
344
|
+
managedProjectId: parts.managedProjectId ?? existing.managedProjectId,
|
|
345
|
+
lastUsed: now,
|
|
346
|
+
};
|
|
347
|
+
// Update the token index if the token changed
|
|
348
|
+
if (oldToken !== parts.refreshToken) {
|
|
349
|
+
indexByRefreshToken.delete(oldToken);
|
|
350
|
+
indexByRefreshToken.set(parts.refreshToken, existingIndex);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (accounts.length === 0) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// For fresh logins, always start at index 0
|
|
357
|
+
const activeIndex = replaceAll
|
|
358
|
+
? 0
|
|
359
|
+
: typeof stored?.activeIndex === "number" &&
|
|
360
|
+
Number.isFinite(stored.activeIndex)
|
|
361
|
+
? stored.activeIndex
|
|
362
|
+
: 0;
|
|
363
|
+
await saveAccounts({
|
|
364
|
+
version: 3,
|
|
365
|
+
accounts,
|
|
366
|
+
activeIndex: clampInt(activeIndex, 0, accounts.length - 1),
|
|
367
|
+
activeIndexByFamily: {
|
|
368
|
+
claude: clampInt(activeIndex, 0, accounts.length - 1),
|
|
369
|
+
gemini: clampInt(activeIndex, 0, accounts.length - 1),
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
function retryAfterMsFromResponse(response) {
|
|
374
|
+
const retryAfterMsHeader = response.headers.get("retry-after-ms");
|
|
375
|
+
if (retryAfterMsHeader) {
|
|
376
|
+
const parsed = Number.parseInt(retryAfterMsHeader, 10);
|
|
377
|
+
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
378
|
+
return parsed;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
382
|
+
if (retryAfterHeader) {
|
|
383
|
+
const parsed = Number.parseInt(retryAfterHeader, 10);
|
|
384
|
+
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
385
|
+
return parsed * 1000;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return 60_000;
|
|
389
|
+
}
|
|
390
|
+
function parseDurationToMs(duration) {
|
|
391
|
+
const match = duration.match(/^(\d+(?:\.\d+)?)(s|m|h)?$/i);
|
|
392
|
+
if (!match)
|
|
393
|
+
return null;
|
|
394
|
+
const value = parseFloat(match[1]);
|
|
395
|
+
const unit = (match[2] || "s").toLowerCase();
|
|
396
|
+
switch (unit) {
|
|
397
|
+
case "h":
|
|
398
|
+
return value * 3600 * 1000;
|
|
399
|
+
case "m":
|
|
400
|
+
return value * 60 * 1000;
|
|
401
|
+
case "s":
|
|
402
|
+
return value * 1000;
|
|
403
|
+
default:
|
|
404
|
+
return value * 1000;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function extractRateLimitBodyInfo(body) {
|
|
408
|
+
if (!body || typeof body !== "object") {
|
|
409
|
+
return { retryDelayMs: null };
|
|
410
|
+
}
|
|
411
|
+
const error = body.error;
|
|
412
|
+
const message = error && typeof error === "object"
|
|
413
|
+
? error.message
|
|
414
|
+
: undefined;
|
|
415
|
+
const details = error && typeof error === "object"
|
|
416
|
+
? error.details
|
|
417
|
+
: undefined;
|
|
418
|
+
let reason;
|
|
419
|
+
if (Array.isArray(details)) {
|
|
420
|
+
for (const detail of details) {
|
|
421
|
+
if (!detail || typeof detail !== "object")
|
|
422
|
+
continue;
|
|
423
|
+
const type = detail["@type"];
|
|
424
|
+
if (typeof type === "string" && type.includes("google.rpc.ErrorInfo")) {
|
|
425
|
+
const detailReason = detail.reason;
|
|
426
|
+
if (typeof detailReason === "string") {
|
|
427
|
+
reason = detailReason;
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
for (const detail of details) {
|
|
433
|
+
if (!detail || typeof detail !== "object")
|
|
434
|
+
continue;
|
|
435
|
+
const type = detail["@type"];
|
|
436
|
+
if (typeof type === "string" && type.includes("google.rpc.RetryInfo")) {
|
|
437
|
+
const retryDelay = detail.retryDelay;
|
|
438
|
+
if (typeof retryDelay === "string") {
|
|
439
|
+
const retryDelayMs = parseDurationToMs(retryDelay);
|
|
440
|
+
if (retryDelayMs !== null) {
|
|
441
|
+
return { retryDelayMs, message, reason };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
for (const detail of details) {
|
|
447
|
+
if (!detail || typeof detail !== "object")
|
|
448
|
+
continue;
|
|
449
|
+
const metadata = detail
|
|
450
|
+
.metadata;
|
|
451
|
+
if (metadata && typeof metadata === "object") {
|
|
452
|
+
const quotaResetDelay = metadata.quotaResetDelay;
|
|
453
|
+
const quotaResetTime = metadata.quotaResetTimeStamp;
|
|
454
|
+
if (typeof quotaResetDelay === "string") {
|
|
455
|
+
const quotaResetDelayMs = parseDurationToMs(quotaResetDelay);
|
|
456
|
+
if (quotaResetDelayMs !== null) {
|
|
457
|
+
return {
|
|
458
|
+
retryDelayMs: quotaResetDelayMs,
|
|
459
|
+
message,
|
|
460
|
+
quotaResetTime,
|
|
461
|
+
reason,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (message) {
|
|
469
|
+
const afterMatch = message.match(/reset after\s+([0-9hms.]+)/i);
|
|
470
|
+
const rawDuration = afterMatch?.[1];
|
|
471
|
+
if (rawDuration) {
|
|
472
|
+
const parsed = parseDurationToMs(rawDuration);
|
|
473
|
+
if (parsed !== null) {
|
|
474
|
+
return { retryDelayMs: parsed, message, reason };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return { retryDelayMs: null, message, reason };
|
|
479
|
+
}
|
|
480
|
+
async function extractRetryInfoFromBody(response) {
|
|
481
|
+
try {
|
|
482
|
+
const text = await response.clone().text();
|
|
483
|
+
try {
|
|
484
|
+
const parsed = JSON.parse(text);
|
|
485
|
+
return extractRateLimitBodyInfo(parsed);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return { retryDelayMs: null };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return { retryDelayMs: null };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function formatWaitTime(ms) {
|
|
496
|
+
if (ms < 1000)
|
|
497
|
+
return `${ms}ms`;
|
|
498
|
+
const seconds = Math.ceil(ms / 1000);
|
|
499
|
+
if (seconds < 60)
|
|
500
|
+
return `${seconds}s`;
|
|
501
|
+
const minutes = Math.floor(seconds / 60);
|
|
502
|
+
const remainingSeconds = seconds % 60;
|
|
503
|
+
if (minutes < 60) {
|
|
504
|
+
return remainingSeconds > 0
|
|
505
|
+
? `${minutes}m ${remainingSeconds}s`
|
|
506
|
+
: `${minutes}m`;
|
|
507
|
+
}
|
|
508
|
+
const hours = Math.floor(minutes / 60);
|
|
509
|
+
const remainingMinutes = minutes % 60;
|
|
510
|
+
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
511
|
+
}
|
|
512
|
+
const SHORT_RETRY_THRESHOLD_MS = 5000;
|
|
513
|
+
/**
|
|
514
|
+
* Rate limit state tracking with time-window deduplication.
|
|
515
|
+
*
|
|
516
|
+
* Problem: When multiple subagents hit 429 simultaneously, each would increment
|
|
517
|
+
* the consecutive counter, causing incorrect exponential backoff (5 concurrent
|
|
518
|
+
* 429s = 2^5 backoff instead of 2^1).
|
|
519
|
+
*
|
|
520
|
+
* Solution: Track per account+quota with deduplication window. Multiple 429s
|
|
521
|
+
* within RATE_LIMIT_DEDUP_WINDOW_MS are treated as a single event.
|
|
522
|
+
*/
|
|
523
|
+
const RATE_LIMIT_DEDUP_WINDOW_MS = 2000; // 2 seconds - concurrent requests within this window are deduplicated
|
|
524
|
+
const RATE_LIMIT_STATE_RESET_MS = 120_000; // Reset consecutive counter after 2 minutes of no 429s
|
|
525
|
+
// Key format: `${accountIndex}:${quotaKey}` for per-account-per-quota tracking
|
|
526
|
+
const rateLimitStateByAccountQuota = new Map();
|
|
527
|
+
// Track empty response retry attempts (ported from LLM-API-Key-Proxy)
|
|
528
|
+
const emptyResponseAttempts = new Map();
|
|
529
|
+
/**
|
|
530
|
+
* Get rate limit backoff with time-window deduplication.
|
|
531
|
+
*
|
|
532
|
+
* @param accountIndex - The account index
|
|
533
|
+
* @param quotaKey - The quota key (e.g., "gemini-cli", "gemini-antigravity", "claude")
|
|
534
|
+
* @param serverRetryAfterMs - Server-provided retry delay (if any)
|
|
535
|
+
* @returns { attempt, delayMs, isDuplicate } - isDuplicate=true if within dedup window
|
|
536
|
+
*/
|
|
537
|
+
function getRateLimitBackoff(accountIndex, quotaKey, serverRetryAfterMs) {
|
|
538
|
+
const now = Date.now();
|
|
539
|
+
const stateKey = `${accountIndex}:${quotaKey}`;
|
|
540
|
+
const previous = rateLimitStateByAccountQuota.get(stateKey);
|
|
541
|
+
// Check if this is a duplicate 429 within the dedup window
|
|
542
|
+
if (previous && now - previous.lastAt < RATE_LIMIT_DEDUP_WINDOW_MS) {
|
|
543
|
+
// Same rate limit event from concurrent request - don't increment
|
|
544
|
+
const baseDelay = serverRetryAfterMs ?? 1000;
|
|
545
|
+
const backoffDelay = Math.min(baseDelay * Math.pow(2, previous.consecutive429 - 1), 60_000);
|
|
546
|
+
return {
|
|
547
|
+
attempt: previous.consecutive429,
|
|
548
|
+
delayMs: Math.max(baseDelay, backoffDelay),
|
|
549
|
+
isDuplicate: true,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
// Check if we should reset (no 429 for 2 minutes) or increment
|
|
553
|
+
const attempt = previous && now - previous.lastAt < RATE_LIMIT_STATE_RESET_MS
|
|
554
|
+
? previous.consecutive429 + 1
|
|
555
|
+
: 1;
|
|
556
|
+
rateLimitStateByAccountQuota.set(stateKey, {
|
|
557
|
+
consecutive429: attempt,
|
|
558
|
+
lastAt: now,
|
|
559
|
+
quotaKey,
|
|
560
|
+
});
|
|
561
|
+
const baseDelay = serverRetryAfterMs ?? 1000;
|
|
562
|
+
const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), 60_000);
|
|
563
|
+
return {
|
|
564
|
+
attempt,
|
|
565
|
+
delayMs: Math.max(baseDelay, backoffDelay),
|
|
566
|
+
isDuplicate: false,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Reset rate limit state for an account+quota combination.
|
|
571
|
+
* Only resets the specific quota, not all quotas for the account.
|
|
572
|
+
*/
|
|
573
|
+
function resetRateLimitState(accountIndex, quotaKey) {
|
|
574
|
+
const stateKey = `${accountIndex}:${quotaKey}`;
|
|
575
|
+
rateLimitStateByAccountQuota.delete(stateKey);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Reset all rate limit state for an account (all quotas).
|
|
579
|
+
* Used when account is completely healthy.
|
|
580
|
+
*/
|
|
581
|
+
function resetAllRateLimitStateForAccount(accountIndex) {
|
|
582
|
+
for (const key of rateLimitStateByAccountQuota.keys()) {
|
|
583
|
+
if (key.startsWith(`${accountIndex}:`)) {
|
|
584
|
+
rateLimitStateByAccountQuota.delete(key);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function headerStyleToQuotaKey(headerStyle, family) {
|
|
589
|
+
if (family === "claude")
|
|
590
|
+
return "claude";
|
|
591
|
+
return headerStyle === "antigravity" ? "gemini-antigravity" : "gemini-cli";
|
|
592
|
+
}
|
|
593
|
+
// Track consecutive non-429 failures per account to prevent infinite loops
|
|
594
|
+
const accountFailureState = new Map();
|
|
595
|
+
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
596
|
+
const FAILURE_COOLDOWN_MS = 30_000; // 30 seconds cooldown after max failures
|
|
597
|
+
const FAILURE_STATE_RESET_MS = 120_000; // Reset failure count after 2 minutes of no failures
|
|
598
|
+
function trackAccountFailure(accountIndex) {
|
|
599
|
+
const now = Date.now();
|
|
600
|
+
const previous = accountFailureState.get(accountIndex);
|
|
601
|
+
// Reset if last failure was more than 2 minutes ago
|
|
602
|
+
const failures = previous && now - previous.lastFailureAt < FAILURE_STATE_RESET_MS
|
|
603
|
+
? previous.consecutiveFailures + 1
|
|
604
|
+
: 1;
|
|
605
|
+
accountFailureState.set(accountIndex, {
|
|
606
|
+
consecutiveFailures: failures,
|
|
607
|
+
lastFailureAt: now,
|
|
608
|
+
});
|
|
609
|
+
const shouldCooldown = failures >= MAX_CONSECUTIVE_FAILURES;
|
|
610
|
+
const cooldownMs = shouldCooldown ? FAILURE_COOLDOWN_MS : 0;
|
|
611
|
+
return { failures, shouldCooldown, cooldownMs };
|
|
612
|
+
}
|
|
613
|
+
function resetAccountFailureState(accountIndex) {
|
|
614
|
+
accountFailureState.delete(accountIndex);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Sleep for a given number of milliseconds, respecting an abort signal.
|
|
618
|
+
*/
|
|
619
|
+
function sleep(ms, signal) {
|
|
620
|
+
return new Promise((resolve, reject) => {
|
|
621
|
+
if (signal?.aborted) {
|
|
622
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("Aborted"));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const timeout = setTimeout(() => {
|
|
626
|
+
cleanup();
|
|
627
|
+
resolve();
|
|
628
|
+
}, ms);
|
|
629
|
+
const onAbort = () => {
|
|
630
|
+
cleanup();
|
|
631
|
+
reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted"));
|
|
632
|
+
};
|
|
633
|
+
const cleanup = () => {
|
|
634
|
+
clearTimeout(timeout);
|
|
635
|
+
signal?.removeEventListener("abort", onAbort);
|
|
636
|
+
};
|
|
637
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Creates an Antigravity OAuth plugin for a specific provider ID.
|
|
642
|
+
*/
|
|
643
|
+
export const createAntigravityPlugin = (providerId) => async ({ client, directory }) => {
|
|
644
|
+
// Load configuration from files and environment variables
|
|
645
|
+
const config = loadConfig(directory);
|
|
646
|
+
// Initialize debug with config
|
|
647
|
+
initializeDebug(config);
|
|
648
|
+
// Initialize structured logger for TUI integration
|
|
649
|
+
initLogger(client);
|
|
650
|
+
// Initialize disk signature cache if keep_thinking is enabled
|
|
651
|
+
// This integrates with the in-memory cacheSignature/getCachedSignature functions
|
|
652
|
+
if (config.keep_thinking) {
|
|
653
|
+
initDiskSignatureCache(config.signature_cache);
|
|
654
|
+
}
|
|
655
|
+
// Initialize session recovery hook with full context
|
|
656
|
+
const sessionRecovery = createSessionRecoveryHook({ client, directory }, config);
|
|
657
|
+
const updateChecker = createAutoUpdateCheckerHook(client, directory, {
|
|
658
|
+
showStartupToast: true,
|
|
659
|
+
autoUpdate: config.auto_update,
|
|
660
|
+
});
|
|
661
|
+
// Event handler for session recovery and updates
|
|
662
|
+
const eventHandler = async (input) => {
|
|
663
|
+
// Forward to update checker
|
|
664
|
+
await updateChecker.event(input);
|
|
665
|
+
// Handle session recovery
|
|
666
|
+
if (sessionRecovery && input.event.type === "session.error") {
|
|
667
|
+
const props = input.event.properties;
|
|
668
|
+
const sessionID = props?.sessionID;
|
|
669
|
+
const messageID = props?.messageID;
|
|
670
|
+
const error = props?.error;
|
|
671
|
+
if (sessionRecovery.isRecoverableError(error)) {
|
|
672
|
+
const messageInfo = {
|
|
673
|
+
id: messageID,
|
|
674
|
+
role: "assistant",
|
|
675
|
+
sessionID,
|
|
676
|
+
error,
|
|
677
|
+
};
|
|
678
|
+
// handleSessionRecovery now does the actual fix (injects tool_result, etc.)
|
|
679
|
+
const recovered = await sessionRecovery.handleSessionRecovery(messageInfo);
|
|
680
|
+
// Only send "continue" AFTER successful tool_result_missing recovery
|
|
681
|
+
// (thinking recoveries already resume inside handleSessionRecovery)
|
|
682
|
+
if (recovered && sessionID && config.auto_resume) {
|
|
683
|
+
// For tool_result_missing, we need to send continue after injecting tool_results
|
|
684
|
+
await client.session
|
|
685
|
+
.prompt({
|
|
686
|
+
path: { id: sessionID },
|
|
687
|
+
body: { parts: [{ type: "text", text: config.resume_text }] },
|
|
688
|
+
query: { directory },
|
|
689
|
+
})
|
|
690
|
+
.catch(() => { });
|
|
691
|
+
// Show success toast
|
|
692
|
+
const successToast = getRecoverySuccessToast();
|
|
693
|
+
await client.tui
|
|
694
|
+
.showToast({
|
|
695
|
+
body: {
|
|
696
|
+
title: successToast.title,
|
|
697
|
+
message: successToast.message,
|
|
698
|
+
variant: "success",
|
|
699
|
+
},
|
|
700
|
+
})
|
|
701
|
+
.catch(() => { });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
// Cache getAuth for tool usage
|
|
707
|
+
let cachedGetAuth = null;
|
|
708
|
+
return {
|
|
709
|
+
event: eventHandler,
|
|
710
|
+
auth: {
|
|
711
|
+
provider: providerId,
|
|
712
|
+
loader: async (getAuth, provider) => {
|
|
713
|
+
// Cache getAuth for search tool
|
|
714
|
+
cachedGetAuth = getAuth;
|
|
715
|
+
const auth = await getAuth();
|
|
716
|
+
// If OpenCode has no valid OAuth auth, clear any stale account storage
|
|
717
|
+
if (!isOAuthAuth(auth)) {
|
|
718
|
+
try {
|
|
719
|
+
await clearAccounts();
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
// ignore
|
|
723
|
+
}
|
|
724
|
+
return {};
|
|
725
|
+
}
|
|
726
|
+
// Validate that stored accounts are in sync with OpenCode's auth
|
|
727
|
+
// If OpenCode's refresh token doesn't match any stored account, clear stale storage
|
|
728
|
+
const authParts = parseRefreshParts(auth.refresh);
|
|
729
|
+
const storedAccounts = await loadAccounts();
|
|
730
|
+
if (storedAccounts &&
|
|
731
|
+
storedAccounts.accounts.length > 0 &&
|
|
732
|
+
authParts.refreshToken) {
|
|
733
|
+
const hasMatchingAccount = storedAccounts.accounts.some((acc) => acc.refreshToken === authParts.refreshToken);
|
|
734
|
+
if (!hasMatchingAccount) {
|
|
735
|
+
// OpenCode's auth doesn't match any stored account - storage is stale
|
|
736
|
+
// Clear it and let the user re-authenticate
|
|
737
|
+
log.warn("Stored accounts don't match OpenCode's auth. Clearing stale storage.");
|
|
738
|
+
try {
|
|
739
|
+
await clearAccounts();
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
// ignore
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const accountManager = await AccountManager.loadFromDisk(auth);
|
|
747
|
+
if (accountManager.getAccountCount() > 0) {
|
|
748
|
+
try {
|
|
749
|
+
await accountManager.saveToDisk();
|
|
750
|
+
}
|
|
751
|
+
catch (error) {
|
|
752
|
+
log.error("Failed to persist initial account pool", {
|
|
753
|
+
error: String(error),
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// Initialize proactive token refresh queue (ported from LLM-API-Key-Proxy)
|
|
758
|
+
let refreshQueue = null;
|
|
759
|
+
if (config.proactive_token_refresh &&
|
|
760
|
+
accountManager.getAccountCount() > 0) {
|
|
761
|
+
refreshQueue = createProactiveRefreshQueue(client, providerId, {
|
|
762
|
+
enabled: config.proactive_token_refresh,
|
|
763
|
+
bufferSeconds: config.proactive_refresh_buffer_seconds,
|
|
764
|
+
checkIntervalSeconds: config.proactive_refresh_check_interval_seconds,
|
|
765
|
+
});
|
|
766
|
+
refreshQueue.setAccountManager(accountManager);
|
|
767
|
+
refreshQueue.start();
|
|
768
|
+
}
|
|
769
|
+
if (isDebugEnabled()) {
|
|
770
|
+
const logPath = getLogFilePath();
|
|
771
|
+
if (logPath) {
|
|
772
|
+
try {
|
|
773
|
+
await client.tui.showToast({
|
|
774
|
+
body: { message: `Debug log: ${logPath}`, variant: "info" },
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
// TUI may not be available
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (provider.models) {
|
|
783
|
+
for (const model of Object.values(provider.models)) {
|
|
784
|
+
if (model) {
|
|
785
|
+
model.cost = { input: 0, output: 0 };
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
apiKey: "",
|
|
791
|
+
async fetch(input, init) {
|
|
792
|
+
// If the request is for the *other* provider, we might still want to intercept if URL matches
|
|
793
|
+
// But strict compliance means we only handle requests if the auth provider matches.
|
|
794
|
+
// Since loader is instantiated per provider, we are good.
|
|
795
|
+
if (!isGenerativeLanguageRequest(input)) {
|
|
796
|
+
return fetch(input, init);
|
|
797
|
+
}
|
|
798
|
+
const latestAuth = await getAuth();
|
|
799
|
+
if (!isOAuthAuth(latestAuth)) {
|
|
800
|
+
return fetch(input, init);
|
|
801
|
+
}
|
|
802
|
+
if (accountManager.getAccountCount() === 0) {
|
|
803
|
+
throw new Error("No Antigravity accounts configured. Run `opencode auth login`.");
|
|
804
|
+
}
|
|
805
|
+
const urlString = toUrlString(input);
|
|
806
|
+
const family = getModelFamilyFromUrl(urlString);
|
|
807
|
+
const model = extractModelFromUrl(urlString);
|
|
808
|
+
const debugLines = [];
|
|
809
|
+
const pushDebug = (line) => {
|
|
810
|
+
if (!isDebugEnabled())
|
|
811
|
+
return;
|
|
812
|
+
debugLines.push(line);
|
|
813
|
+
};
|
|
814
|
+
pushDebug(`request=${urlString}`);
|
|
815
|
+
let lastFailure = null;
|
|
816
|
+
let lastError = null;
|
|
817
|
+
const abortSignal = init?.signal ?? undefined;
|
|
818
|
+
// Helper to check if request was aborted
|
|
819
|
+
const checkAborted = () => {
|
|
820
|
+
if (abortSignal?.aborted) {
|
|
821
|
+
throw abortSignal.reason instanceof Error
|
|
822
|
+
? abortSignal.reason
|
|
823
|
+
: new Error("Aborted");
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
// Helper to show toast without blocking on abort
|
|
827
|
+
const showToast = async (message, variant) => {
|
|
828
|
+
if (abortSignal?.aborted)
|
|
829
|
+
return;
|
|
830
|
+
try {
|
|
831
|
+
await client.tui.showToast({
|
|
832
|
+
body: { message, variant },
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
// TUI may not be available
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
// Use while(true) loop to handle rate limits with backoff
|
|
840
|
+
// This ensures we wait and retry when all accounts are rate-limited
|
|
841
|
+
const quietMode = config.quiet_mode;
|
|
842
|
+
const hasOtherAccountWithAntigravity = (currentAccount) => {
|
|
843
|
+
if (family !== "gemini")
|
|
844
|
+
return false;
|
|
845
|
+
const otherAccounts = accountManager
|
|
846
|
+
.getAccounts()
|
|
847
|
+
.filter((acc) => acc.index !== currentAccount.index);
|
|
848
|
+
return otherAccounts.some((acc) => !accountManager.isRateLimitedForHeaderStyle(acc, family, "antigravity", model));
|
|
849
|
+
};
|
|
850
|
+
while (true) {
|
|
851
|
+
// Check for abort at the start of each iteration
|
|
852
|
+
checkAborted();
|
|
853
|
+
const accountCount = accountManager.getAccountCount();
|
|
854
|
+
if (accountCount === 0) {
|
|
855
|
+
throw new Error("No Antigravity accounts available. Run `opencode auth login`.");
|
|
856
|
+
}
|
|
857
|
+
const account = accountManager.getCurrentOrNextForFamily(family, model);
|
|
858
|
+
if (!account) {
|
|
859
|
+
// All accounts are rate-limited - wait and retry
|
|
860
|
+
const waitMs = accountManager.getMinWaitTimeForFamily(family, model) ||
|
|
861
|
+
60_000;
|
|
862
|
+
const waitSecValue = Math.max(1, Math.ceil(waitMs / 1000));
|
|
863
|
+
pushDebug(`all-rate-limited family=${family} accounts=${accountCount} waitMs=${waitMs}`);
|
|
864
|
+
if (isDebugEnabled()) {
|
|
865
|
+
logAccountContext("All accounts rate-limited", {
|
|
866
|
+
index: -1,
|
|
867
|
+
family,
|
|
868
|
+
totalAccounts: accountCount,
|
|
869
|
+
});
|
|
870
|
+
logRateLimitSnapshot(family, accountManager.getAccountsSnapshot());
|
|
871
|
+
}
|
|
872
|
+
// If wait time exceeds max threshold, return error immediately instead of hanging
|
|
873
|
+
// 0 means disabled (wait indefinitely)
|
|
874
|
+
const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 300) * 1000;
|
|
875
|
+
if (maxWaitMs > 0 && waitMs > maxWaitMs) {
|
|
876
|
+
const waitTimeFormatted = formatWaitTime(waitMs);
|
|
877
|
+
await showToast(`Rate limited for ${waitTimeFormatted}. Try again later or add another account.`, "error");
|
|
878
|
+
// Return a proper rate limit error response
|
|
879
|
+
throw new Error(`All ${accountCount} account(s) rate-limited for ${family}. ` +
|
|
880
|
+
`Quota resets in ${waitTimeFormatted}. ` +
|
|
881
|
+
`Add more accounts with \`opencode auth login\` or wait and retry.`);
|
|
882
|
+
}
|
|
883
|
+
await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSecValue}s...`, "warning");
|
|
884
|
+
// Wait for the rate-limit cooldown to expire, then retry
|
|
885
|
+
await sleep(waitMs, abortSignal);
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
pushDebug(`selected idx=${account.index} email=${account.email ?? ""} family=${family} accounts=${accountCount}`);
|
|
889
|
+
if (isDebugEnabled()) {
|
|
890
|
+
logAccountContext("Selected", {
|
|
891
|
+
index: account.index,
|
|
892
|
+
email: account.email,
|
|
893
|
+
family,
|
|
894
|
+
totalAccounts: accountCount,
|
|
895
|
+
rateLimitState: account.rateLimitResetTimes,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
// Show toast when switching to a different account (debounced, respects quiet mode)
|
|
899
|
+
if (!quietMode &&
|
|
900
|
+
accountCount > 1 &&
|
|
901
|
+
accountManager.shouldShowAccountToast(account.index)) {
|
|
902
|
+
const accountLabel = account.email || `Account ${account.index + 1}`;
|
|
903
|
+
await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
|
|
904
|
+
accountManager.markToastShown(account.index);
|
|
905
|
+
}
|
|
906
|
+
try {
|
|
907
|
+
await accountManager.saveToDisk();
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
log.error("Failed to persist rotation state", {
|
|
911
|
+
error: String(error),
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
let authRecord = accountManager.toAuthDetails(account);
|
|
915
|
+
if (accessTokenExpired(authRecord)) {
|
|
916
|
+
try {
|
|
917
|
+
const refreshed = await refreshAccessToken(authRecord, client, providerId);
|
|
918
|
+
if (!refreshed) {
|
|
919
|
+
const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
|
|
920
|
+
lastError = new Error("Antigravity token refresh failed");
|
|
921
|
+
if (shouldCooldown) {
|
|
922
|
+
accountManager.markAccountCoolingDown(account, cooldownMs, "auth-failure");
|
|
923
|
+
accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model);
|
|
924
|
+
pushDebug(`token-refresh-failed: cooldown ${cooldownMs}ms after ${failures} failures`);
|
|
925
|
+
}
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
resetAccountFailureState(account.index);
|
|
929
|
+
accountManager.updateFromAuth(account, refreshed);
|
|
930
|
+
authRecord = refreshed;
|
|
931
|
+
try {
|
|
932
|
+
await accountManager.saveToDisk();
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
log.error("Failed to persist refreshed auth", {
|
|
936
|
+
error: String(error),
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
catch (error) {
|
|
941
|
+
if (error instanceof AntigravityTokenRefreshError &&
|
|
942
|
+
error.code === "invalid_grant") {
|
|
943
|
+
const removed = accountManager.removeAccount(account);
|
|
944
|
+
if (removed) {
|
|
945
|
+
log.warn("Removed revoked account from pool - reauthenticate via `opencode auth login`");
|
|
946
|
+
try {
|
|
947
|
+
await accountManager.saveToDisk();
|
|
948
|
+
}
|
|
949
|
+
catch (persistError) {
|
|
950
|
+
log.error("Failed to persist revoked account removal", { error: String(persistError) });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (accountManager.getAccountCount() === 0) {
|
|
954
|
+
try {
|
|
955
|
+
await client.auth.set({
|
|
956
|
+
path: { id: providerId },
|
|
957
|
+
body: {
|
|
958
|
+
type: "oauth",
|
|
959
|
+
refresh: "",
|
|
960
|
+
access: "",
|
|
961
|
+
expires: 0,
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
catch (storeError) {
|
|
966
|
+
log.error("Failed to clear stored Antigravity OAuth credentials", { error: String(storeError) });
|
|
967
|
+
}
|
|
968
|
+
throw new Error("All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.");
|
|
969
|
+
}
|
|
970
|
+
lastError = error;
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
|
|
974
|
+
lastError =
|
|
975
|
+
error instanceof Error ? error : new Error(String(error));
|
|
976
|
+
if (shouldCooldown) {
|
|
977
|
+
accountManager.markAccountCoolingDown(account, cooldownMs, "auth-failure");
|
|
978
|
+
accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model);
|
|
979
|
+
pushDebug(`token-refresh-error: cooldown ${cooldownMs}ms after ${failures} failures`);
|
|
980
|
+
}
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const accessToken = authRecord.access;
|
|
985
|
+
if (!accessToken) {
|
|
986
|
+
lastError = new Error("Missing access token");
|
|
987
|
+
if (accountCount <= 1) {
|
|
988
|
+
throw lastError;
|
|
989
|
+
}
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
let projectContext;
|
|
993
|
+
try {
|
|
994
|
+
projectContext = await ensureProjectContext(authRecord);
|
|
995
|
+
resetAccountFailureState(account.index);
|
|
996
|
+
}
|
|
997
|
+
catch (error) {
|
|
998
|
+
const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
|
|
999
|
+
lastError =
|
|
1000
|
+
error instanceof Error ? error : new Error(String(error));
|
|
1001
|
+
if (shouldCooldown) {
|
|
1002
|
+
accountManager.markAccountCoolingDown(account, cooldownMs, "project-error");
|
|
1003
|
+
accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model);
|
|
1004
|
+
pushDebug(`project-context-error: cooldown ${cooldownMs}ms after ${failures} failures`);
|
|
1005
|
+
}
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
if (projectContext.auth !== authRecord) {
|
|
1009
|
+
accountManager.updateFromAuth(account, projectContext.auth);
|
|
1010
|
+
authRecord = projectContext.auth;
|
|
1011
|
+
try {
|
|
1012
|
+
await accountManager.saveToDisk();
|
|
1013
|
+
}
|
|
1014
|
+
catch (error) {
|
|
1015
|
+
log.error("Failed to persist project context", {
|
|
1016
|
+
error: String(error),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
const runThinkingWarmup = async (prepared, projectId) => {
|
|
1021
|
+
if (!prepared.needsSignedThinkingWarmup ||
|
|
1022
|
+
!prepared.sessionId) {
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (!trackWarmupAttempt(prepared.sessionId)) {
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const warmupBody = buildThinkingWarmupBody(typeof prepared.init.body === "string"
|
|
1029
|
+
? prepared.init.body
|
|
1030
|
+
: undefined, Boolean(prepared.effectiveModel
|
|
1031
|
+
?.toLowerCase()
|
|
1032
|
+
.includes("claude") &&
|
|
1033
|
+
prepared.effectiveModel
|
|
1034
|
+
?.toLowerCase()
|
|
1035
|
+
.includes("thinking")));
|
|
1036
|
+
if (!warmupBody) {
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
const warmupUrl = toWarmupStreamUrl(prepared.request);
|
|
1040
|
+
const warmupHeaders = new Headers(prepared.init.headers ?? {});
|
|
1041
|
+
warmupHeaders.set("accept", "text/event-stream");
|
|
1042
|
+
const warmupInit = {
|
|
1043
|
+
...prepared.init,
|
|
1044
|
+
method: prepared.init.method ?? "POST",
|
|
1045
|
+
headers: warmupHeaders,
|
|
1046
|
+
body: warmupBody,
|
|
1047
|
+
};
|
|
1048
|
+
const warmupDebugContext = startAntigravityDebugRequest({
|
|
1049
|
+
originalUrl: warmupUrl,
|
|
1050
|
+
resolvedUrl: warmupUrl,
|
|
1051
|
+
method: warmupInit.method,
|
|
1052
|
+
headers: warmupHeaders,
|
|
1053
|
+
body: warmupBody,
|
|
1054
|
+
streaming: true,
|
|
1055
|
+
projectId,
|
|
1056
|
+
});
|
|
1057
|
+
try {
|
|
1058
|
+
pushDebug("thinking-warmup: start");
|
|
1059
|
+
const warmupResponse = await fetch(warmupUrl, warmupInit);
|
|
1060
|
+
const transformed = await transformAntigravityResponse(warmupResponse, true, warmupDebugContext, prepared.requestedModel, projectId, warmupUrl, prepared.effectiveModel, prepared.sessionId);
|
|
1061
|
+
await transformed.text();
|
|
1062
|
+
markWarmupSuccess(prepared.sessionId);
|
|
1063
|
+
pushDebug("thinking-warmup: done");
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
clearWarmupAttempt(prepared.sessionId);
|
|
1067
|
+
pushDebug(`thinking-warmup: failed ${error instanceof Error ? error.message : String(error)}`);
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
// Try endpoint fallbacks with single header style based on model suffix
|
|
1071
|
+
let shouldSwitchAccount = false;
|
|
1072
|
+
// Determine header style from model suffix:
|
|
1073
|
+
// - Models with :antigravity suffix -> use Antigravity quota
|
|
1074
|
+
// - Models without suffix (default) -> use Gemini CLI quota
|
|
1075
|
+
// - Claude models -> always use Antigravity
|
|
1076
|
+
let headerStyle = getHeaderStyleFromUrl(urlString, family);
|
|
1077
|
+
const explicitQuota = isExplicitQuotaFromUrl(urlString);
|
|
1078
|
+
pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`);
|
|
1079
|
+
// Check if this header style is rate-limited for this account
|
|
1080
|
+
if (accountManager.isRateLimitedForHeaderStyle(account, family, headerStyle, model)) {
|
|
1081
|
+
// Quota fallback: try alternate quota on same account (if enabled and not explicit)
|
|
1082
|
+
if (config.quota_fallback &&
|
|
1083
|
+
!explicitQuota &&
|
|
1084
|
+
family === "gemini") {
|
|
1085
|
+
const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);
|
|
1086
|
+
if (alternateStyle && alternateStyle !== headerStyle) {
|
|
1087
|
+
const quotaName = headerStyle === "gemini-cli"
|
|
1088
|
+
? "Gemini CLI"
|
|
1089
|
+
: "Antigravity";
|
|
1090
|
+
const altQuotaName = alternateStyle === "gemini-cli"
|
|
1091
|
+
? "Gemini CLI"
|
|
1092
|
+
: "Antigravity";
|
|
1093
|
+
if (!quietMode) {
|
|
1094
|
+
await showToast(`${quotaName} quota exhausted, using ${altQuotaName} quota`, "warning");
|
|
1095
|
+
}
|
|
1096
|
+
headerStyle = alternateStyle;
|
|
1097
|
+
pushDebug(`quota fallback: ${headerStyle}`);
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
shouldSwitchAccount = true;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
else {
|
|
1104
|
+
shouldSwitchAccount = true;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
while (!shouldSwitchAccount) {
|
|
1108
|
+
// Flag to force thinking recovery on retry after API error
|
|
1109
|
+
let forceThinkingRecovery = false;
|
|
1110
|
+
for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) {
|
|
1111
|
+
const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i];
|
|
1112
|
+
try {
|
|
1113
|
+
const prepared = prepareAntigravityRequest(input, init, accessToken, projectContext.effectiveProjectId, currentEndpoint, headerStyle, forceThinkingRecovery, {
|
|
1114
|
+
claudeToolHardening: config.claude_tool_hardening,
|
|
1115
|
+
});
|
|
1116
|
+
// Show thinking recovery toast (respects quiet mode)
|
|
1117
|
+
if (!quietMode && prepared.thinkingRecoveryMessage) {
|
|
1118
|
+
await showToast(prepared.thinkingRecoveryMessage, "warning");
|
|
1119
|
+
}
|
|
1120
|
+
const originalUrl = toUrlString(input);
|
|
1121
|
+
const resolvedUrl = toUrlString(prepared.request);
|
|
1122
|
+
pushDebug(`endpoint=${currentEndpoint}`);
|
|
1123
|
+
pushDebug(`resolved=${resolvedUrl}`);
|
|
1124
|
+
const debugContext = startAntigravityDebugRequest({
|
|
1125
|
+
originalUrl,
|
|
1126
|
+
resolvedUrl,
|
|
1127
|
+
method: prepared.init.method,
|
|
1128
|
+
headers: prepared.init.headers,
|
|
1129
|
+
body: prepared.init.body,
|
|
1130
|
+
streaming: prepared.streaming,
|
|
1131
|
+
projectId: projectContext.effectiveProjectId,
|
|
1132
|
+
});
|
|
1133
|
+
await runThinkingWarmup(prepared, projectContext.effectiveProjectId);
|
|
1134
|
+
const response = await fetch(prepared.request, prepared.init);
|
|
1135
|
+
pushDebug(`status=${response.status} ${response.statusText}`);
|
|
1136
|
+
// Handle 429 rate limit with improved logic
|
|
1137
|
+
if (response.status === 429) {
|
|
1138
|
+
const headerRetryMs = retryAfterMsFromResponse(response);
|
|
1139
|
+
const bodyInfo = await extractRetryInfoFromBody(response);
|
|
1140
|
+
const serverRetryMs = bodyInfo.retryDelayMs ?? headerRetryMs;
|
|
1141
|
+
const quotaKey = headerStyleToQuotaKey(headerStyle, family);
|
|
1142
|
+
const { attempt, delayMs, isDuplicate } = getRateLimitBackoff(account.index, quotaKey, serverRetryMs);
|
|
1143
|
+
const waitTimeFormatted = formatWaitTime(delayMs);
|
|
1144
|
+
const isCapacityExhausted = bodyInfo.reason === "MODEL_CAPACITY_EXHAUSTED" ||
|
|
1145
|
+
(typeof bodyInfo.message === "string" &&
|
|
1146
|
+
bodyInfo.message
|
|
1147
|
+
.toLowerCase()
|
|
1148
|
+
.includes("no capacity"));
|
|
1149
|
+
pushDebug(`429 idx=${account.index} email=${account.email ?? ""} family=${family} delayMs=${delayMs} attempt=${attempt}`);
|
|
1150
|
+
if (bodyInfo.message) {
|
|
1151
|
+
pushDebug(`429 message=${bodyInfo.message}`);
|
|
1152
|
+
}
|
|
1153
|
+
if (bodyInfo.quotaResetTime) {
|
|
1154
|
+
pushDebug(`429 quotaResetTime=${bodyInfo.quotaResetTime}`);
|
|
1155
|
+
}
|
|
1156
|
+
if (bodyInfo.reason) {
|
|
1157
|
+
pushDebug(`429 reason=${bodyInfo.reason}`);
|
|
1158
|
+
}
|
|
1159
|
+
logRateLimitEvent(account.index, account.email, family, response.status, delayMs, bodyInfo);
|
|
1160
|
+
await logResponseBody(debugContext, response, 429);
|
|
1161
|
+
if (isCapacityExhausted) {
|
|
1162
|
+
accountManager.markRateLimited(account, delayMs, family, headerStyle, model);
|
|
1163
|
+
// For Gemini, try prioritized Antigravity across ALL accounts first
|
|
1164
|
+
if (family === "gemini" &&
|
|
1165
|
+
headerStyle === "antigravity") {
|
|
1166
|
+
if (hasOtherAccountWithAntigravity(account)) {
|
|
1167
|
+
pushDebug(`capacity exhausted on account ${account.index}, but available on others. Switching account.`);
|
|
1168
|
+
shouldSwitchAccount = true;
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
await showToast(`Model capacity exhausted for ${family}. Retrying in ${waitTimeFormatted} (attempt ${attempt})...`, "warning");
|
|
1173
|
+
await sleep(delayMs, abortSignal);
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
const accountLabel = account.email || `Account ${account.index + 1}`;
|
|
1177
|
+
// Short retry: if delay is small, just wait and retry same account
|
|
1178
|
+
if (delayMs <= SHORT_RETRY_THRESHOLD_MS) {
|
|
1179
|
+
await showToast(`Rate limited. Retrying in ${waitTimeFormatted} (attempt ${attempt})...`, "warning");
|
|
1180
|
+
await sleep(delayMs, abortSignal);
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
// Mark this header style as rate-limited for this account
|
|
1184
|
+
accountManager.markRateLimited(account, delayMs, family, headerStyle, model);
|
|
1185
|
+
try {
|
|
1186
|
+
await accountManager.saveToDisk();
|
|
1187
|
+
}
|
|
1188
|
+
catch (error) {
|
|
1189
|
+
log.error("Failed to persist rate-limit state", {
|
|
1190
|
+
error: String(error),
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
// For Gemini, try prioritized Antigravity across ALL accounts first
|
|
1194
|
+
if (family === "gemini") {
|
|
1195
|
+
if (headerStyle === "antigravity") {
|
|
1196
|
+
// Check if any other account has Antigravity quota for this model
|
|
1197
|
+
if (hasOtherAccountWithAntigravity(account)) {
|
|
1198
|
+
pushDebug(`antigravity exhausted on account ${account.index}, but available on others. Switching account.`);
|
|
1199
|
+
shouldSwitchAccount = true;
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
// All accounts exhausted for Antigravity on THIS model.
|
|
1203
|
+
// Before falling back to gemini-cli, check if it's the last option (automatic fallback)
|
|
1204
|
+
if (config.quota_fallback && !explicitQuota) {
|
|
1205
|
+
const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);
|
|
1206
|
+
if (alternateStyle &&
|
|
1207
|
+
alternateStyle !== headerStyle) {
|
|
1208
|
+
const safeModelName = model || "this model";
|
|
1209
|
+
await showToast(`Antigravity quota exhausted for ${safeModelName}. Switching to Gemini CLI quota... Tip: Other Gemini models may still have quota.`, "warning");
|
|
1210
|
+
headerStyle = alternateStyle;
|
|
1211
|
+
pushDebug(`quota fallback: ${headerStyle}`);
|
|
1212
|
+
continue; // Retry with new headerStyle
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
const quotaName = headerStyle === "antigravity"
|
|
1218
|
+
? "Antigravity"
|
|
1219
|
+
: "Gemini CLI";
|
|
1220
|
+
if (accountCount > 1) {
|
|
1221
|
+
const quotaMsg = bodyInfo.quotaResetTime
|
|
1222
|
+
? ` (quota resets ${bodyInfo.quotaResetTime})`
|
|
1223
|
+
: ` (retry in ${waitTimeFormatted})`;
|
|
1224
|
+
await showToast(`Rate limited on ${quotaName} quota for ${accountLabel}${quotaMsg}. Switching account...`, "warning");
|
|
1225
|
+
lastFailure = {
|
|
1226
|
+
response,
|
|
1227
|
+
streaming: prepared.streaming,
|
|
1228
|
+
debugContext,
|
|
1229
|
+
requestedModel: prepared.requestedModel,
|
|
1230
|
+
projectId: prepared.projectId,
|
|
1231
|
+
endpoint: prepared.endpoint,
|
|
1232
|
+
effectiveModel: prepared.effectiveModel,
|
|
1233
|
+
sessionId: prepared.sessionId,
|
|
1234
|
+
toolDebugMissing: prepared.toolDebugMissing,
|
|
1235
|
+
toolDebugSummary: prepared.toolDebugSummary,
|
|
1236
|
+
toolDebugPayload: prepared.toolDebugPayload,
|
|
1237
|
+
};
|
|
1238
|
+
shouldSwitchAccount = true;
|
|
1239
|
+
break;
|
|
1240
|
+
}
|
|
1241
|
+
else {
|
|
1242
|
+
const quotaMsg = bodyInfo.quotaResetTime
|
|
1243
|
+
? `Quota resets ${bodyInfo.quotaResetTime}`
|
|
1244
|
+
: `Waiting ${waitTimeFormatted}`;
|
|
1245
|
+
await showToast(`Rate limited. ${quotaMsg} (attempt ${attempt})...`, "warning");
|
|
1246
|
+
lastFailure = {
|
|
1247
|
+
response,
|
|
1248
|
+
streaming: prepared.streaming,
|
|
1249
|
+
debugContext,
|
|
1250
|
+
requestedModel: prepared.requestedModel,
|
|
1251
|
+
projectId: prepared.projectId,
|
|
1252
|
+
endpoint: prepared.endpoint,
|
|
1253
|
+
effectiveModel: prepared.effectiveModel,
|
|
1254
|
+
sessionId: prepared.sessionId,
|
|
1255
|
+
toolDebugMissing: prepared.toolDebugMissing,
|
|
1256
|
+
toolDebugSummary: prepared.toolDebugSummary,
|
|
1257
|
+
toolDebugPayload: prepared.toolDebugPayload,
|
|
1258
|
+
};
|
|
1259
|
+
await sleep(delayMs, abortSignal);
|
|
1260
|
+
shouldSwitchAccount = true;
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
// Success - reset rate limit backoff state for this quota
|
|
1265
|
+
const quotaKey = headerStyleToQuotaKey(headerStyle, family);
|
|
1266
|
+
resetRateLimitState(account.index, quotaKey);
|
|
1267
|
+
resetAccountFailureState(account.index);
|
|
1268
|
+
const shouldRetryEndpoint = response.status === 403 ||
|
|
1269
|
+
response.status === 404 ||
|
|
1270
|
+
response.status >= 500;
|
|
1271
|
+
if (shouldRetryEndpoint) {
|
|
1272
|
+
await logResponseBody(debugContext, response, response.status);
|
|
1273
|
+
}
|
|
1274
|
+
if (shouldRetryEndpoint &&
|
|
1275
|
+
i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
|
|
1276
|
+
lastFailure = {
|
|
1277
|
+
response,
|
|
1278
|
+
streaming: prepared.streaming,
|
|
1279
|
+
debugContext,
|
|
1280
|
+
requestedModel: prepared.requestedModel,
|
|
1281
|
+
projectId: prepared.projectId,
|
|
1282
|
+
endpoint: prepared.endpoint,
|
|
1283
|
+
effectiveModel: prepared.effectiveModel,
|
|
1284
|
+
sessionId: prepared.sessionId,
|
|
1285
|
+
toolDebugMissing: prepared.toolDebugMissing,
|
|
1286
|
+
toolDebugSummary: prepared.toolDebugSummary,
|
|
1287
|
+
toolDebugPayload: prepared.toolDebugPayload,
|
|
1288
|
+
};
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
// Success or non-retryable error - return the response
|
|
1292
|
+
logAntigravityDebugResponse(debugContext, response, {
|
|
1293
|
+
note: response.ok
|
|
1294
|
+
? "Success"
|
|
1295
|
+
: `Error ${response.status}`,
|
|
1296
|
+
});
|
|
1297
|
+
if (!response.ok) {
|
|
1298
|
+
await logResponseBody(debugContext, response, response.status);
|
|
1299
|
+
// Handle 400 "Prompt too long" with synthetic response to avoid session lock
|
|
1300
|
+
if (response.status === 400) {
|
|
1301
|
+
const cloned = response.clone();
|
|
1302
|
+
const bodyText = await cloned.text();
|
|
1303
|
+
if (bodyText.includes("Prompt is too long") ||
|
|
1304
|
+
bodyText.includes("prompt_too_long")) {
|
|
1305
|
+
if (!quietMode) {
|
|
1306
|
+
await showToast("Context too long - use /compact to reduce size", "warning");
|
|
1307
|
+
}
|
|
1308
|
+
const errorMessage = `[Antigravity Error] Context is too long for this model.\n\nPlease use /compact to reduce context size, then retry your request.\n\nAlternatively, you can:\n- Use /clear to start fresh\n- Use /undo to remove recent messages\n- Switch to a model with larger context window`;
|
|
1309
|
+
return createSyntheticErrorResponse(errorMessage, prepared.requestedModel);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
// Empty response retry logic (ported from LLM-API-Key-Proxy)
|
|
1314
|
+
// For non-streaming responses, check if the response body is empty
|
|
1315
|
+
// and retry if so (up to config.empty_response_max_attempts times)
|
|
1316
|
+
if (response.ok && !prepared.streaming) {
|
|
1317
|
+
const maxAttempts = config.empty_response_max_attempts ?? 4;
|
|
1318
|
+
const retryDelayMs = config.empty_response_retry_delay_ms ?? 2000;
|
|
1319
|
+
// Clone to check body without consuming original
|
|
1320
|
+
const clonedForCheck = response.clone();
|
|
1321
|
+
const bodyText = await clonedForCheck.text();
|
|
1322
|
+
if (isEmptyResponseBody(bodyText)) {
|
|
1323
|
+
// Track empty response attempts per request
|
|
1324
|
+
const emptyAttemptKey = `${prepared.sessionId ?? "none"}:${prepared.effectiveModel ?? "unknown"}`;
|
|
1325
|
+
const currentAttempts = (emptyResponseAttempts.get(emptyAttemptKey) ?? 0) +
|
|
1326
|
+
1;
|
|
1327
|
+
emptyResponseAttempts.set(emptyAttemptKey, currentAttempts);
|
|
1328
|
+
pushDebug(`empty-response: attempt ${currentAttempts}/${maxAttempts}`);
|
|
1329
|
+
if (currentAttempts < maxAttempts) {
|
|
1330
|
+
await showToast(`Empty response received. Retrying (${currentAttempts}/${maxAttempts})...`, "warning");
|
|
1331
|
+
await sleep(retryDelayMs, abortSignal);
|
|
1332
|
+
continue; // Retry the endpoint loop
|
|
1333
|
+
}
|
|
1334
|
+
// Clean up and throw after max attempts
|
|
1335
|
+
emptyResponseAttempts.delete(emptyAttemptKey);
|
|
1336
|
+
throw new EmptyResponseError("antigravity", prepared.effectiveModel ?? "unknown", currentAttempts);
|
|
1337
|
+
}
|
|
1338
|
+
// Clean up successful attempt tracking
|
|
1339
|
+
const emptyAttemptKeyClean = `${prepared.sessionId ?? "none"}:${prepared.effectiveModel ?? "unknown"}`;
|
|
1340
|
+
emptyResponseAttempts.delete(emptyAttemptKeyClean);
|
|
1341
|
+
}
|
|
1342
|
+
const transformedResponse = await transformAntigravityResponse(response, prepared.streaming, debugContext, prepared.requestedModel, prepared.projectId, prepared.endpoint, prepared.effectiveModel, prepared.sessionId, prepared.toolDebugMissing, prepared.toolDebugSummary, prepared.toolDebugPayload, debugLines);
|
|
1343
|
+
// Check for context errors and show appropriate toast
|
|
1344
|
+
const contextError = transformedResponse.headers.get("x-antigravity-context-error");
|
|
1345
|
+
if (contextError && !quietMode) {
|
|
1346
|
+
if (contextError === "prompt_too_long") {
|
|
1347
|
+
await showToast("Context too long - use /compact to reduce size, or trim your request", "warning");
|
|
1348
|
+
}
|
|
1349
|
+
else if (contextError === "tool_pairing") {
|
|
1350
|
+
await showToast("Tool call/result mismatch - use /compact to fix, or /undo last message", "warning");
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
return transformedResponse;
|
|
1354
|
+
}
|
|
1355
|
+
catch (error) {
|
|
1356
|
+
// Handle recoverable thinking errors - retry with forced recovery
|
|
1357
|
+
if (error instanceof Error &&
|
|
1358
|
+
error.message === "THINKING_RECOVERY_NEEDED") {
|
|
1359
|
+
// Only retry once with forced recovery to avoid infinite loops
|
|
1360
|
+
if (!forceThinkingRecovery) {
|
|
1361
|
+
pushDebug("thinking-recovery: API error detected, retrying with forced recovery");
|
|
1362
|
+
forceThinkingRecovery = true;
|
|
1363
|
+
i = -1; // Will become 0 after loop increment, restart endpoint loop
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
// Already tried with forced recovery, give up and return error
|
|
1367
|
+
const recoveryError = error;
|
|
1368
|
+
const originalError = recoveryError.originalError || {
|
|
1369
|
+
error: { message: "Thinking recovery triggered" },
|
|
1370
|
+
};
|
|
1371
|
+
const recoveryMessage = `${originalError.error?.message || "Session recovery failed"}\n\n[RECOVERY] Thinking block corruption could not be resolved. Try starting a new session.`;
|
|
1372
|
+
return new Response(JSON.stringify({
|
|
1373
|
+
type: "error",
|
|
1374
|
+
error: {
|
|
1375
|
+
type: "unrecoverable_error",
|
|
1376
|
+
message: recoveryMessage,
|
|
1377
|
+
},
|
|
1378
|
+
}), {
|
|
1379
|
+
status: 400,
|
|
1380
|
+
headers: { "Content-Type": "application/json" },
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
|
|
1384
|
+
lastError =
|
|
1385
|
+
error instanceof Error
|
|
1386
|
+
? error
|
|
1387
|
+
: new Error(String(error));
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
// All endpoints failed for this account - track failure and try next account
|
|
1391
|
+
const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);
|
|
1392
|
+
lastError =
|
|
1393
|
+
error instanceof Error
|
|
1394
|
+
? error
|
|
1395
|
+
: new Error(String(error));
|
|
1396
|
+
if (shouldCooldown) {
|
|
1397
|
+
accountManager.markAccountCoolingDown(account, cooldownMs, "network-error");
|
|
1398
|
+
accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model);
|
|
1399
|
+
pushDebug(`endpoint-error: cooldown ${cooldownMs}ms after ${failures} failures`);
|
|
1400
|
+
}
|
|
1401
|
+
shouldSwitchAccount = true;
|
|
1402
|
+
break;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
} // end headerStyleLoop
|
|
1406
|
+
if (shouldSwitchAccount) {
|
|
1407
|
+
// Avoid tight retry loops when there's only one account.
|
|
1408
|
+
if (accountCount <= 1) {
|
|
1409
|
+
if (lastFailure) {
|
|
1410
|
+
return transformAntigravityResponse(lastFailure.response, lastFailure.streaming, lastFailure.debugContext, lastFailure.requestedModel, lastFailure.projectId, lastFailure.endpoint, lastFailure.effectiveModel, lastFailure.sessionId, lastFailure.toolDebugMissing, lastFailure.toolDebugSummary, lastFailure.toolDebugPayload, debugLines);
|
|
1411
|
+
}
|
|
1412
|
+
throw (lastError || new Error("All Antigravity endpoints failed"));
|
|
1413
|
+
}
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
// If we get here without returning, something went wrong
|
|
1417
|
+
if (lastFailure) {
|
|
1418
|
+
return transformAntigravityResponse(lastFailure.response, lastFailure.streaming, lastFailure.debugContext, lastFailure.requestedModel, lastFailure.projectId, lastFailure.endpoint, lastFailure.effectiveModel, lastFailure.sessionId, lastFailure.toolDebugMissing, lastFailure.toolDebugSummary, lastFailure.toolDebugPayload, debugLines);
|
|
1419
|
+
}
|
|
1420
|
+
throw lastError || new Error("All Antigravity accounts failed");
|
|
1421
|
+
}
|
|
1422
|
+
},
|
|
1423
|
+
};
|
|
1424
|
+
},
|
|
1425
|
+
methods: [
|
|
1426
|
+
{
|
|
1427
|
+
label: "OAuth with Google (Antigravity)",
|
|
1428
|
+
type: "oauth",
|
|
1429
|
+
authorize: async (inputs) => {
|
|
1430
|
+
const isHeadless = !!(process.env.SSH_CONNECTION ||
|
|
1431
|
+
process.env.SSH_CLIENT ||
|
|
1432
|
+
process.env.SSH_TTY ||
|
|
1433
|
+
process.env.OPENCODE_HEADLESS);
|
|
1434
|
+
// CLI flow (`opencode auth login`) passes an inputs object.
|
|
1435
|
+
if (inputs) {
|
|
1436
|
+
const accounts = [];
|
|
1437
|
+
const noBrowser = inputs.noBrowser === "true" ||
|
|
1438
|
+
inputs["no-browser"] === "true";
|
|
1439
|
+
const useManualMode = noBrowser || shouldSkipLocalServer();
|
|
1440
|
+
// Check for existing accounts and prompt user for login mode
|
|
1441
|
+
let startFresh = true;
|
|
1442
|
+
const existingStorage = await loadAccounts();
|
|
1443
|
+
if (existingStorage && existingStorage.accounts.length > 0) {
|
|
1444
|
+
const existingAccounts = existingStorage.accounts.map((acc, idx) => ({
|
|
1445
|
+
email: acc.email,
|
|
1446
|
+
index: idx,
|
|
1447
|
+
}));
|
|
1448
|
+
const loginMode = await promptLoginMode(existingAccounts);
|
|
1449
|
+
startFresh = loginMode === "fresh";
|
|
1450
|
+
if (startFresh) {
|
|
1451
|
+
console.log("\nStarting fresh - existing accounts will be replaced.\n");
|
|
1452
|
+
}
|
|
1453
|
+
else {
|
|
1454
|
+
console.log("\nAdding to existing accounts.\n");
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
while (accounts.length < MAX_OAUTH_ACCOUNTS) {
|
|
1458
|
+
console.log(`\n=== Antigravity OAuth (Account ${accounts.length + 1}) ===`);
|
|
1459
|
+
const projectId = await promptProjectId();
|
|
1460
|
+
const result = await (async () => {
|
|
1461
|
+
const authorization = await authorizeAntigravity(projectId);
|
|
1462
|
+
const fallbackState = getStateFromAuthorizationUrl(authorization.url);
|
|
1463
|
+
console.log("\nOAuth URL:\n" + authorization.url + "\n");
|
|
1464
|
+
if (useManualMode) {
|
|
1465
|
+
const browserOpened = await openBrowser(authorization.url);
|
|
1466
|
+
if (!browserOpened) {
|
|
1467
|
+
console.log("Could not open browser automatically.");
|
|
1468
|
+
console.log("Please open the URL above manually in your local browser.\n");
|
|
1469
|
+
}
|
|
1470
|
+
return promptManualOAuthInput(fallbackState);
|
|
1471
|
+
}
|
|
1472
|
+
let listener = null;
|
|
1473
|
+
if (!isHeadless) {
|
|
1474
|
+
try {
|
|
1475
|
+
listener = await startOAuthListener();
|
|
1476
|
+
}
|
|
1477
|
+
catch {
|
|
1478
|
+
listener = null;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
if (!isHeadless) {
|
|
1482
|
+
await openBrowser(authorization.url);
|
|
1483
|
+
}
|
|
1484
|
+
if (listener) {
|
|
1485
|
+
try {
|
|
1486
|
+
const SOFT_TIMEOUT_MS = 30000;
|
|
1487
|
+
const callbackPromise = listener.waitForCallback();
|
|
1488
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("SOFT_TIMEOUT")), SOFT_TIMEOUT_MS));
|
|
1489
|
+
let callbackUrl;
|
|
1490
|
+
try {
|
|
1491
|
+
callbackUrl = await Promise.race([
|
|
1492
|
+
callbackPromise,
|
|
1493
|
+
timeoutPromise,
|
|
1494
|
+
]);
|
|
1495
|
+
}
|
|
1496
|
+
catch (err) {
|
|
1497
|
+
if (err instanceof Error &&
|
|
1498
|
+
err.message === "SOFT_TIMEOUT") {
|
|
1499
|
+
console.log("\n⏳ Automatic callback not received after 30 seconds.");
|
|
1500
|
+
console.log("You can paste the redirect URL manually.\n");
|
|
1501
|
+
console.log("OAuth URL (in case you need it again):");
|
|
1502
|
+
console.log(authorization.url + "\n");
|
|
1503
|
+
try {
|
|
1504
|
+
await listener.close();
|
|
1505
|
+
}
|
|
1506
|
+
catch { }
|
|
1507
|
+
return promptManualOAuthInput(fallbackState);
|
|
1508
|
+
}
|
|
1509
|
+
throw err;
|
|
1510
|
+
}
|
|
1511
|
+
const params = extractOAuthCallbackParams(callbackUrl);
|
|
1512
|
+
if (!params) {
|
|
1513
|
+
return {
|
|
1514
|
+
type: "failed",
|
|
1515
|
+
error: "Missing code or state in callback URL",
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
return exchangeAntigravity(params.code, params.state);
|
|
1519
|
+
}
|
|
1520
|
+
catch (error) {
|
|
1521
|
+
if (error instanceof Error &&
|
|
1522
|
+
error.message !== "SOFT_TIMEOUT") {
|
|
1523
|
+
return {
|
|
1524
|
+
type: "failed",
|
|
1525
|
+
error: error.message,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
return {
|
|
1529
|
+
type: "failed",
|
|
1530
|
+
error: error instanceof Error
|
|
1531
|
+
? error.message
|
|
1532
|
+
: "Unknown error",
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
finally {
|
|
1536
|
+
try {
|
|
1537
|
+
await listener.close();
|
|
1538
|
+
}
|
|
1539
|
+
catch { }
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
return promptManualOAuthInput(fallbackState);
|
|
1543
|
+
})();
|
|
1544
|
+
if (result.type === "failed") {
|
|
1545
|
+
if (accounts.length === 0) {
|
|
1546
|
+
return {
|
|
1547
|
+
url: "",
|
|
1548
|
+
instructions: `Authentication failed: ${result.error}`,
|
|
1549
|
+
method: "auto",
|
|
1550
|
+
callback: async () => result,
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
console.warn(`[opencode-antigravity-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`);
|
|
1554
|
+
break;
|
|
1555
|
+
}
|
|
1556
|
+
accounts.push(result);
|
|
1557
|
+
// Show toast for successful account authentication
|
|
1558
|
+
try {
|
|
1559
|
+
await client.tui.showToast({
|
|
1560
|
+
body: {
|
|
1561
|
+
message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : ""}`,
|
|
1562
|
+
variant: "success",
|
|
1563
|
+
},
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
catch {
|
|
1567
|
+
// TUI may not be available in CLI mode
|
|
1568
|
+
}
|
|
1569
|
+
try {
|
|
1570
|
+
// Use startFresh only on first account, subsequent accounts always append
|
|
1571
|
+
const isFirstAccount = accounts.length === 1;
|
|
1572
|
+
await persistAccountPool([result], isFirstAccount && startFresh);
|
|
1573
|
+
}
|
|
1574
|
+
catch {
|
|
1575
|
+
// ignore
|
|
1576
|
+
}
|
|
1577
|
+
if (accounts.length >= MAX_OAUTH_ACCOUNTS) {
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
// Get the actual deduplicated account count from storage for the prompt
|
|
1581
|
+
let currentAccountCount = accounts.length;
|
|
1582
|
+
try {
|
|
1583
|
+
const currentStorage = await loadAccounts();
|
|
1584
|
+
if (currentStorage) {
|
|
1585
|
+
currentAccountCount = currentStorage.accounts.length;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
catch {
|
|
1589
|
+
// Fall back to accounts.length if we can't read storage
|
|
1590
|
+
}
|
|
1591
|
+
const addAnother = await promptAddAnotherAccount(currentAccountCount);
|
|
1592
|
+
if (!addAnother) {
|
|
1593
|
+
break;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
const primary = accounts[0];
|
|
1597
|
+
if (!primary) {
|
|
1598
|
+
return {
|
|
1599
|
+
url: "",
|
|
1600
|
+
instructions: "Authentication cancelled",
|
|
1601
|
+
method: "auto",
|
|
1602
|
+
callback: async () => ({
|
|
1603
|
+
type: "failed",
|
|
1604
|
+
error: "Authentication cancelled",
|
|
1605
|
+
}),
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
// Get the actual deduplicated account count from storage
|
|
1609
|
+
let actualAccountCount = accounts.length;
|
|
1610
|
+
try {
|
|
1611
|
+
const finalStorage = await loadAccounts();
|
|
1612
|
+
if (finalStorage) {
|
|
1613
|
+
actualAccountCount = finalStorage.accounts.length;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
catch {
|
|
1617
|
+
// Fall back to accounts.length if we can't read storage
|
|
1618
|
+
}
|
|
1619
|
+
return {
|
|
1620
|
+
url: "",
|
|
1621
|
+
instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
|
|
1622
|
+
method: "auto",
|
|
1623
|
+
callback: async () => primary,
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
// TUI flow (`/connect`) does not support per-account prompts.
|
|
1627
|
+
// Default to adding new accounts (non-destructive).
|
|
1628
|
+
// Users can run `opencode auth logout` first if they want a fresh start.
|
|
1629
|
+
const projectId = "";
|
|
1630
|
+
// Check existing accounts count for toast message
|
|
1631
|
+
const existingStorage = await loadAccounts();
|
|
1632
|
+
const existingCount = existingStorage?.accounts.length ?? 0;
|
|
1633
|
+
const useManualFlow = isHeadless || shouldSkipLocalServer();
|
|
1634
|
+
let listener = null;
|
|
1635
|
+
if (!useManualFlow) {
|
|
1636
|
+
try {
|
|
1637
|
+
listener = await startOAuthListener();
|
|
1638
|
+
}
|
|
1639
|
+
catch {
|
|
1640
|
+
listener = null;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
const authorization = await authorizeAntigravity(projectId);
|
|
1644
|
+
const fallbackState = getStateFromAuthorizationUrl(authorization.url);
|
|
1645
|
+
if (!useManualFlow) {
|
|
1646
|
+
const browserOpened = await openBrowser(authorization.url);
|
|
1647
|
+
if (!browserOpened) {
|
|
1648
|
+
listener?.close().catch(() => { });
|
|
1649
|
+
listener = null;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
if (listener) {
|
|
1653
|
+
return {
|
|
1654
|
+
url: authorization.url,
|
|
1655
|
+
instructions: "Complete sign-in in your browser. We'll automatically detect the redirect back to localhost.",
|
|
1656
|
+
method: "auto",
|
|
1657
|
+
callback: async () => {
|
|
1658
|
+
const CALLBACK_TIMEOUT_MS = 30000;
|
|
1659
|
+
try {
|
|
1660
|
+
const callbackPromise = listener.waitForCallback();
|
|
1661
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("CALLBACK_TIMEOUT")), CALLBACK_TIMEOUT_MS));
|
|
1662
|
+
let callbackUrl;
|
|
1663
|
+
try {
|
|
1664
|
+
callbackUrl = await Promise.race([
|
|
1665
|
+
callbackPromise,
|
|
1666
|
+
timeoutPromise,
|
|
1667
|
+
]);
|
|
1668
|
+
}
|
|
1669
|
+
catch (err) {
|
|
1670
|
+
if (err instanceof Error &&
|
|
1671
|
+
err.message === "CALLBACK_TIMEOUT") {
|
|
1672
|
+
return {
|
|
1673
|
+
type: "failed",
|
|
1674
|
+
error: "Callback timeout - please use CLI with --no-browser flag for manual input",
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
throw err;
|
|
1678
|
+
}
|
|
1679
|
+
const params = extractOAuthCallbackParams(callbackUrl);
|
|
1680
|
+
if (!params) {
|
|
1681
|
+
return {
|
|
1682
|
+
type: "failed",
|
|
1683
|
+
error: "Missing code or state in callback URL",
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
const result = await exchangeAntigravity(params.code, params.state);
|
|
1687
|
+
if (result.type === "success") {
|
|
1688
|
+
try {
|
|
1689
|
+
await persistAccountPool([result], false);
|
|
1690
|
+
}
|
|
1691
|
+
catch { }
|
|
1692
|
+
const newTotal = existingCount + 1;
|
|
1693
|
+
const toastMessage = existingCount > 0
|
|
1694
|
+
? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
|
|
1695
|
+
: `Authenticated${result.email ? ` (${result.email})` : ""}`;
|
|
1696
|
+
try {
|
|
1697
|
+
await client.tui.showToast({
|
|
1698
|
+
body: {
|
|
1699
|
+
message: toastMessage,
|
|
1700
|
+
variant: "success",
|
|
1701
|
+
},
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
catch { }
|
|
1705
|
+
}
|
|
1706
|
+
return result;
|
|
1707
|
+
}
|
|
1708
|
+
catch (error) {
|
|
1709
|
+
return {
|
|
1710
|
+
type: "failed",
|
|
1711
|
+
error: error instanceof Error
|
|
1712
|
+
? error.message
|
|
1713
|
+
: "Unknown error",
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
finally {
|
|
1717
|
+
try {
|
|
1718
|
+
await listener.close();
|
|
1719
|
+
}
|
|
1720
|
+
catch { }
|
|
1721
|
+
}
|
|
1722
|
+
},
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
return {
|
|
1726
|
+
url: authorization.url,
|
|
1727
|
+
instructions: "Visit the URL above, complete OAuth, then paste either the full redirect URL or the authorization code.",
|
|
1728
|
+
method: "code",
|
|
1729
|
+
callback: async (codeInput) => {
|
|
1730
|
+
const params = parseOAuthCallbackInput(codeInput, fallbackState);
|
|
1731
|
+
if ("error" in params) {
|
|
1732
|
+
return { type: "failed", error: params.error };
|
|
1733
|
+
}
|
|
1734
|
+
const result = await exchangeAntigravity(params.code, params.state);
|
|
1735
|
+
if (result.type === "success") {
|
|
1736
|
+
try {
|
|
1737
|
+
// TUI flow adds to existing accounts (non-destructive)
|
|
1738
|
+
await persistAccountPool([result], false);
|
|
1739
|
+
}
|
|
1740
|
+
catch {
|
|
1741
|
+
// ignore
|
|
1742
|
+
}
|
|
1743
|
+
// Show appropriate toast message
|
|
1744
|
+
const newTotal = existingCount + 1;
|
|
1745
|
+
const toastMessage = existingCount > 0
|
|
1746
|
+
? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
|
|
1747
|
+
: `Authenticated${result.email ? ` (${result.email})` : ""}`;
|
|
1748
|
+
try {
|
|
1749
|
+
await client.tui.showToast({
|
|
1750
|
+
body: {
|
|
1751
|
+
message: toastMessage,
|
|
1752
|
+
variant: "success",
|
|
1753
|
+
},
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
catch {
|
|
1757
|
+
// TUI may not be available
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
return result;
|
|
1761
|
+
},
|
|
1762
|
+
};
|
|
1763
|
+
},
|
|
1764
|
+
},
|
|
1765
|
+
{
|
|
1766
|
+
label: "Manually enter API Key",
|
|
1767
|
+
type: "api",
|
|
1768
|
+
},
|
|
1769
|
+
],
|
|
1770
|
+
},
|
|
1771
|
+
tool: {
|
|
1772
|
+
google_search: createGoogleSearchTool(() => {
|
|
1773
|
+
if (!cachedGetAuth) {
|
|
1774
|
+
throw new Error("Auth not initialized");
|
|
1775
|
+
}
|
|
1776
|
+
return cachedGetAuth();
|
|
1777
|
+
}, client),
|
|
1778
|
+
},
|
|
1779
|
+
};
|
|
1780
|
+
};
|
|
1781
|
+
export const AntigravityCLIOAuthPlugin = createAntigravityPlugin(ANTIGRAVITY_PROVIDER_ID);
|
|
1782
|
+
export const GoogleOAuthPlugin = AntigravityCLIOAuthPlugin;
|
|
1783
|
+
function toUrlString(value) {
|
|
1784
|
+
if (typeof value === "string") {
|
|
1785
|
+
return value;
|
|
1786
|
+
}
|
|
1787
|
+
const candidate = value.url;
|
|
1788
|
+
if (candidate) {
|
|
1789
|
+
return candidate;
|
|
1790
|
+
}
|
|
1791
|
+
return value.toString();
|
|
1792
|
+
}
|
|
1793
|
+
function toWarmupStreamUrl(value) {
|
|
1794
|
+
const urlString = toUrlString(value);
|
|
1795
|
+
try {
|
|
1796
|
+
const url = new URL(urlString);
|
|
1797
|
+
if (!url.pathname.includes(":streamGenerateContent")) {
|
|
1798
|
+
url.pathname = url.pathname.replace(":generateContent", ":streamGenerateContent");
|
|
1799
|
+
}
|
|
1800
|
+
url.searchParams.set("alt", "sse");
|
|
1801
|
+
return url.toString();
|
|
1802
|
+
}
|
|
1803
|
+
catch {
|
|
1804
|
+
return urlString;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
function extractModelFromUrl(urlString) {
|
|
1808
|
+
const match = urlString.match(/\/models\/([^:\/?]+)(?::\w+)?/);
|
|
1809
|
+
return match?.[1] ?? null;
|
|
1810
|
+
}
|
|
1811
|
+
function extractModelFromUrlWithSuffix(urlString) {
|
|
1812
|
+
const match = urlString.match(/\/models\/([^:\/\?]+)/);
|
|
1813
|
+
return match?.[1] ?? null;
|
|
1814
|
+
}
|
|
1815
|
+
function getModelFamilyFromUrl(urlString) {
|
|
1816
|
+
const model = extractModelFromUrl(urlString);
|
|
1817
|
+
let family = "gemini";
|
|
1818
|
+
if (model && model.includes("claude")) {
|
|
1819
|
+
family = "claude";
|
|
1820
|
+
}
|
|
1821
|
+
if (isDebugEnabled()) {
|
|
1822
|
+
logModelFamily(urlString, model, family);
|
|
1823
|
+
}
|
|
1824
|
+
return family;
|
|
1825
|
+
}
|
|
1826
|
+
function getHeaderStyleFromUrl(urlString, family) {
|
|
1827
|
+
if (family === "claude") {
|
|
1828
|
+
return "antigravity";
|
|
1829
|
+
}
|
|
1830
|
+
const modelWithSuffix = extractModelFromUrlWithSuffix(urlString);
|
|
1831
|
+
if (!modelWithSuffix) {
|
|
1832
|
+
return "gemini-cli";
|
|
1833
|
+
}
|
|
1834
|
+
const { quotaPreference } = resolveModelWithTier(modelWithSuffix);
|
|
1835
|
+
return quotaPreference ?? "gemini-cli";
|
|
1836
|
+
}
|
|
1837
|
+
function isExplicitQuotaFromUrl(urlString) {
|
|
1838
|
+
const modelWithSuffix = extractModelFromUrlWithSuffix(urlString);
|
|
1839
|
+
if (!modelWithSuffix) {
|
|
1840
|
+
return false;
|
|
1841
|
+
}
|
|
1842
|
+
const { explicitQuota } = resolveModelWithTier(modelWithSuffix);
|
|
1843
|
+
return explicitQuota ?? false;
|
|
1844
|
+
}
|
|
1845
|
+
//# sourceMappingURL=plugin.js.map
|