@triflux/core 10.50.1 → 10.50.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hub/index.mjs +2 -8
- package/hub/lib/cto-env.mjs +0 -23
- package/hub/paths.mjs +0 -6
- package/hub/pipeline/index.mjs +0 -190
- package/hub/pipeline/transitions.mjs +0 -10
- package/hub/reflexion.mjs +1 -84
- package/hud/cli-policy.mjs +1 -5
- package/hud/colors.mjs +0 -13
- package/hud/constants.mjs +4 -135
- package/hud/context-monitor.mjs +3 -64
- package/hud/hud-qos-status.mjs +43 -225
- package/hud/providers/claude.mjs +4 -16
- package/hud/providers/gemini.mjs +7 -698
- package/hud/renderers.mjs +40 -294
- package/hud/terminal.mjs +1 -63
- package/hud/utils.mjs +0 -24
- package/package.json +1 -2
- package/scripts/lib/gemini-profiles.mjs +1 -1
- package/scripts/lib/legacy-mcp-cleanup.mjs +14 -3
- package/scripts/lib/psmux-info.mjs +18 -49
- package/scripts/lib/skill-template.mjs +0 -26
- package/hub/codex-compat.mjs +0 -12
- package/hub/fullcycle.mjs +0 -113
- package/hub/gemini-adapter.mjs +0 -174
- package/hub/intent.mjs +0 -321
- package/hub/lib/mcp-response-cache.mjs +0 -239
- package/hub/lib/path-utils.mjs +0 -165
- package/hub/lib/timeline-adapter.mjs +0 -190
- package/hub/pipeline/gates/confidence.mjs +0 -75
- package/hub/pipeline/gates/consensus.mjs +0 -106
- package/hub/pipeline/gates/index.mjs +0 -9
- package/hub/pipeline/gates/selfcheck.mjs +0 -120
- package/hub/quality/deslop.mjs +0 -274
- package/hub/research.mjs +0 -155
- package/hub/role-control-events.mjs +0 -113
- package/hub/routing/complexity.mjs +0 -269
- package/hub/routing/index.mjs +0 -122
- package/hub/routing/q-learning.mjs +0 -387
- package/hub/token-mode.mjs +0 -228
- package/hud/mission-board.mjs +0 -56
- package/hud/providers/cto.mjs +0 -76
- package/scripts/lib/agent-json.mjs +0 -27
- package/scripts/lib/async.mjs +0 -174
- package/scripts/lib/cli-agy.mjs +0 -85
- package/scripts/lib/cli-codex.mjs +0 -62
- package/scripts/lib/cli-gemini.mjs +0 -37
- package/scripts/lib/env.mjs +0 -14
- package/scripts/lib/hub.mjs +0 -48
- package/scripts/lib/pid.mjs +0 -63
- package/scripts/lib/quota.mjs +0 -47
- package/scripts/lib/skill-state.mjs +0 -220
- package/scripts/lib/team.mjs +0 -75
- package/scripts/lib/timeout.mjs +0 -66
- package/scripts/lib/tmp.mjs +0 -26
- package/scripts/lib/toml.mjs +0 -132
package/hud/providers/gemini.mjs
CHANGED
|
@@ -1,120 +1,10 @@
|
|
|
1
|
-
// ============================================================================
|
|
2
|
-
// Gemini 쿼터 API / 세션 토큰 / RPM 트래커
|
|
3
|
-
// ============================================================================
|
|
4
|
-
|
|
5
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
-
import https from "node:https";
|
|
8
|
-
import { homedir } from "node:os";
|
|
9
|
-
import { join } from "node:path";
|
|
10
1
|
import {
|
|
11
|
-
ANTIGRAVITY_KEYCHAIN_ACCOUNT,
|
|
12
|
-
ANTIGRAVITY_KEYCHAIN_SERVICE,
|
|
13
2
|
ANTIGRAVITY_MODEL_ABBREV,
|
|
14
3
|
ANTIGRAVITY_OAUTH_PATHS,
|
|
15
4
|
ANTIGRAVITY_SETTINGS_PATH,
|
|
16
|
-
GEMINI_API_TIMEOUT_MS,
|
|
17
5
|
GEMINI_OAUTH_PATH,
|
|
18
|
-
GEMINI_PROJECT_CACHE_PATH,
|
|
19
|
-
GEMINI_QUOTA_CACHE_PATH,
|
|
20
|
-
GEMINI_QUOTA_REFRESH_LOCK_PATH,
|
|
21
|
-
GEMINI_QUOTA_STALE_MS,
|
|
22
|
-
GEMINI_REFRESH_FLAG,
|
|
23
|
-
GEMINI_RPM_TRACKER_PATH,
|
|
24
|
-
GEMINI_RPM_WINDOW_MS,
|
|
25
|
-
GEMINI_SESSION_CACHE_PATH,
|
|
26
|
-
GEMINI_SESSION_REFRESH_FLAG,
|
|
27
|
-
GEMINI_SESSION_REFRESH_LOCK_PATH,
|
|
28
|
-
GEMINI_SESSION_STALE_MS,
|
|
29
|
-
LEGACY_GEMINI_PROJECT_CACHE,
|
|
30
|
-
LEGACY_GEMINI_QUOTA_CACHE,
|
|
31
|
-
LEGACY_GEMINI_RPM_TRACKER,
|
|
32
|
-
LEGACY_GEMINI_SESSION_CACHE,
|
|
33
|
-
SPAWN_LOCK_TTL_MS,
|
|
34
6
|
} from "../constants.mjs";
|
|
35
|
-
import {
|
|
36
|
-
clampPercent,
|
|
37
|
-
createHttpsPost,
|
|
38
|
-
decodeJwtEmail,
|
|
39
|
-
makeHash,
|
|
40
|
-
readJson,
|
|
41
|
-
readJsonMigrate,
|
|
42
|
-
writeJsonSafe,
|
|
43
|
-
} from "../utils.mjs";
|
|
44
|
-
|
|
45
|
-
const httpsPost = createHttpsPost(https, GEMINI_API_TIMEOUT_MS);
|
|
46
|
-
|
|
47
|
-
// Gemini 모델별 RPM 한도 (실측 기반: Pro 25, Flash 300)
|
|
48
|
-
export function getGeminiRpmLimit(model) {
|
|
49
|
-
if (model?.includes("pro")) return 25;
|
|
50
|
-
return 300; // Flash 기본
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Gemini 모델 ID → HUD 표시 라벨 (동적 매핑)
|
|
54
|
-
export function getGeminiModelLabel(model) {
|
|
55
|
-
if (!model) return "";
|
|
56
|
-
// 버전 + 티어 추출: gemini-3.1-pro-preview → [3.1Pro], gemini-2.5-flash → [2.5Flash]
|
|
57
|
-
const m = model.match(/gemini-(\d+(?:\.\d+)?)-(\w+)/);
|
|
58
|
-
if (!m) return "";
|
|
59
|
-
const ver = m[1];
|
|
60
|
-
const tier = m[2].charAt(0).toUpperCase() + m[2].slice(1);
|
|
61
|
-
return `[${ver}${tier}]`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// remainingFraction → 사용 퍼센트 변환 (remainingAmount가 있으면 절대값도 제공)
|
|
65
|
-
export function deriveGeminiLimits(bucket) {
|
|
66
|
-
if (!bucket) return null;
|
|
67
|
-
// TODO(verify): Workspace quota 응답 모양 라이브 확인 필요
|
|
68
|
-
if (isGeminiUnlimitedBucket(bucket, bucket.remainingFraction)) {
|
|
69
|
-
return {
|
|
70
|
-
unlimited: true,
|
|
71
|
-
usedPct: null,
|
|
72
|
-
remaining: null,
|
|
73
|
-
limit: null,
|
|
74
|
-
resetTime: bucket.resetTime,
|
|
75
|
-
modelId: bucket.modelId,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
if (bucket.remainingFraction == null) return null;
|
|
79
|
-
const fraction = bucket.remainingFraction;
|
|
80
|
-
const usedPct = clampPercent(Math.round((1 - fraction) * 100));
|
|
81
|
-
// remainingAmount가 API에서 오면 절대값 역산 (Gemini CLI 방식)
|
|
82
|
-
if (bucket.remainingAmount != null) {
|
|
83
|
-
const remaining = parseInt(bucket.remainingAmount, 10);
|
|
84
|
-
const limit = fraction > 0 ? Math.round(remaining / fraction) : 0;
|
|
85
|
-
return {
|
|
86
|
-
usedPct,
|
|
87
|
-
remaining,
|
|
88
|
-
limit,
|
|
89
|
-
resetTime: bucket.resetTime,
|
|
90
|
-
modelId: bucket.modelId,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
return {
|
|
94
|
-
usedPct,
|
|
95
|
-
remaining: null,
|
|
96
|
-
limit: null,
|
|
97
|
-
resetTime: bucket.resetTime,
|
|
98
|
-
modelId: bucket.modelId,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function isUnlimitedValue(value) {
|
|
103
|
-
if (value === Infinity) return true;
|
|
104
|
-
if (typeof value !== "string") return false;
|
|
105
|
-
const normalized = value.trim().toLowerCase();
|
|
106
|
-
return normalized === "infinity" || normalized === "unlimited";
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function isGeminiUnlimitedBucket(bucket, fraction) {
|
|
110
|
-
return (
|
|
111
|
-
bucket?.unlimited === true ||
|
|
112
|
-
isUnlimitedValue(fraction) ||
|
|
113
|
-
isUnlimitedValue(bucket?.limit) ||
|
|
114
|
-
isUnlimitedValue(bucket?.remaining) ||
|
|
115
|
-
isUnlimitedValue(bucket?.remainingAmount)
|
|
116
|
-
);
|
|
117
|
-
}
|
|
7
|
+
import { decodeJwtEmail, readJson } from "../utils.mjs";
|
|
118
8
|
|
|
119
9
|
function getCredentialEmail(credential) {
|
|
120
10
|
return (
|
|
@@ -128,39 +18,29 @@ function getCredentialEmail(credential) {
|
|
|
128
18
|
}
|
|
129
19
|
|
|
130
20
|
export function getGeminiEmail() {
|
|
131
|
-
|
|
132
|
-
const oauth = readJson(GEMINI_OAUTH_PATH, null);
|
|
133
|
-
return getCredentialEmail(oauth);
|
|
134
|
-
} catch {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
21
|
+
return getCredentialEmail(readJson(GEMINI_OAUTH_PATH, null));
|
|
137
22
|
}
|
|
138
23
|
|
|
139
|
-
// GCP 프로젝트 인증이면 계정 이메일 대신 프로젝트 ID
|
|
24
|
+
// GCP 프로젝트 인증이면 계정 이메일 대신 프로젝트 ID를 보여 준다.
|
|
140
25
|
export function getAntigravityAccountLabel() {
|
|
141
26
|
const project = readJson(ANTIGRAVITY_SETTINGS_PATH, null)?.gcp?.project;
|
|
142
27
|
if (project) return project;
|
|
143
28
|
for (const oauthPath of ANTIGRAVITY_OAUTH_PATHS) {
|
|
144
29
|
try {
|
|
145
|
-
const
|
|
146
|
-
const email = getCredentialEmail(oauth);
|
|
30
|
+
const email = getCredentialEmail(readJson(oauthPath, null));
|
|
147
31
|
if (email) return email;
|
|
148
32
|
} catch {
|
|
149
|
-
//
|
|
33
|
+
// 다음 Antigravity 인증 경로를 확인한다.
|
|
150
34
|
}
|
|
151
35
|
}
|
|
152
36
|
return null;
|
|
153
37
|
}
|
|
154
38
|
|
|
155
|
-
// agy 1.0.1+ settings.json `model` 필드에서 현재 장착 모델 라벨을 읽는다.
|
|
156
|
-
// 예: "Gemini 3.5 Flash (High)". 없으면 null.
|
|
157
39
|
export function getAntigravityCurrentModel() {
|
|
158
|
-
const
|
|
159
|
-
return
|
|
40
|
+
const settings = readJson(ANTIGRAVITY_SETTINGS_PATH, null);
|
|
41
|
+
return settings?.model || null;
|
|
160
42
|
}
|
|
161
43
|
|
|
162
|
-
// 모델 라벨 → 2자 약어 (constants의 ANTIGRAVITY_MODEL_ABBREV 매핑).
|
|
163
|
-
// 매핑에 없으면 "<Head> ... (<Level>)" 패턴에서 자동 추출.
|
|
164
44
|
export function getAntigravityModelAbbrev(label) {
|
|
165
45
|
if (!label) return "??";
|
|
166
46
|
const known = ANTIGRAVITY_MODEL_ABBREV[label];
|
|
@@ -173,574 +53,3 @@ export function getAntigravityModelAbbrev(label) {
|
|
|
173
53
|
if (head) return head[1].slice(0, 2);
|
|
174
54
|
return "??";
|
|
175
55
|
}
|
|
176
|
-
|
|
177
|
-
// 모델 라벨 → family ("gemini" | "claude" | "gpt-oss" | "unknown")
|
|
178
|
-
// quota 풀이 family 단위로 분리되므로 slot1 데이터 채울지 결정할 때 사용.
|
|
179
|
-
export function getAntigravityModelFamily(label) {
|
|
180
|
-
if (!label) return "unknown";
|
|
181
|
-
if (label.startsWith("Gemini")) return "gemini";
|
|
182
|
-
if (label.startsWith("Claude")) return "claude";
|
|
183
|
-
if (label.startsWith("GPT")) return "gpt-oss";
|
|
184
|
-
return "unknown";
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Gemini family 통합 quota — 모든 bucket 중 최소 remainingFraction을 가진 bucket을 대표로 반환.
|
|
188
|
-
// /usage 실측 (2026-05-22): 4개 모델이 같은 reset window 공유하므로 결과는 사실상 family 단일 카운터.
|
|
189
|
-
export function deriveGeminiFamilyBucket(buckets) {
|
|
190
|
-
if (!Array.isArray(buckets) || buckets.length === 0) return null;
|
|
191
|
-
let chosen = null;
|
|
192
|
-
for (const b of buckets) {
|
|
193
|
-
if (b?.remainingFraction == null) continue;
|
|
194
|
-
if (!chosen || b.remainingFraction < chosen.remainingFraction) {
|
|
195
|
-
chosen = b;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
return chosen;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function buildGeminiAuthContext(accountId) {
|
|
202
|
-
let oauth = readJson(GEMINI_OAUTH_PATH, null);
|
|
203
|
-
let authSource = oauth?.access_token ? "gemini-file" : "none";
|
|
204
|
-
let expiryMissing = oauth?.access_token ? oauth.expiry_date == null : false;
|
|
205
|
-
const fileExpired =
|
|
206
|
-
oauth?.expiry_date != null && oauth.expiry_date < Date.now();
|
|
207
|
-
// Preserve a valid Gemini file token; agy Keychain is only a missing/expired fallback.
|
|
208
|
-
if (!oauth?.access_token || fileExpired) {
|
|
209
|
-
const keychainOAuth = getAntigravityTokenFromKeychain();
|
|
210
|
-
if (keychainOAuth?.access_token) {
|
|
211
|
-
const {
|
|
212
|
-
expiry_date: _dropExpiry,
|
|
213
|
-
access_token: _dropToken,
|
|
214
|
-
...fileRest
|
|
215
|
-
} = oauth || {};
|
|
216
|
-
oauth = { ...fileRest, ...keychainOAuth };
|
|
217
|
-
authSource = "antigravity-keychain";
|
|
218
|
-
expiryMissing = keychainOAuth.expiry_date == null;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
const tokenSource =
|
|
222
|
-
oauth?.refresh_token || oauth?.id_token || oauth?.access_token || "";
|
|
223
|
-
const tokenFingerprint = tokenSource ? makeHash(tokenSource) : "none";
|
|
224
|
-
const cacheKey = `${accountId || "gemini-main"}::${tokenFingerprint}`;
|
|
225
|
-
return { oauth, tokenFingerprint, cacheKey, authSource, expiryMissing };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function firstPresent(...values) {
|
|
229
|
-
for (const value of values) {
|
|
230
|
-
if (value != null && value !== "") return value;
|
|
231
|
-
}
|
|
232
|
-
return null;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function normalizeExpiryDate(value) {
|
|
236
|
-
if (value == null || value === "") return null;
|
|
237
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
238
|
-
return value < 1_000_000_000_000 ? value * 1000 : value;
|
|
239
|
-
}
|
|
240
|
-
if (typeof value === "string") {
|
|
241
|
-
const numeric = Number(value);
|
|
242
|
-
if (Number.isFinite(numeric)) return normalizeExpiryDate(numeric);
|
|
243
|
-
const parsed = Date.parse(value);
|
|
244
|
-
if (Number.isFinite(parsed)) return parsed;
|
|
245
|
-
}
|
|
246
|
-
return null;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function normalizeAntigravityCredential(parsed) {
|
|
250
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
const accessToken = firstPresent(
|
|
254
|
-
parsed.access_token,
|
|
255
|
-
parsed.accessToken,
|
|
256
|
-
parsed.access?.token,
|
|
257
|
-
parsed.token,
|
|
258
|
-
);
|
|
259
|
-
if (!accessToken) return null;
|
|
260
|
-
const normalized = { ...parsed, access_token: accessToken };
|
|
261
|
-
const idToken = firstPresent(parsed.id_token, parsed.idToken);
|
|
262
|
-
if (idToken) normalized.id_token = idToken;
|
|
263
|
-
const refreshToken = firstPresent(parsed.refresh_token, parsed.refreshToken);
|
|
264
|
-
if (refreshToken) normalized.refresh_token = refreshToken;
|
|
265
|
-
const expiryDate = normalizeExpiryDate(
|
|
266
|
-
firstPresent(
|
|
267
|
-
parsed.expiry_date,
|
|
268
|
-
parsed.expiryDate,
|
|
269
|
-
parsed.expiry,
|
|
270
|
-
parsed.expires_at,
|
|
271
|
-
parsed.expiresAt,
|
|
272
|
-
),
|
|
273
|
-
);
|
|
274
|
-
if (expiryDate != null) normalized.expiry_date = expiryDate;
|
|
275
|
-
return normalized;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function getAntigravityTokenFromKeychain() {
|
|
279
|
-
// macOS Keychain only; elsewhere `security` is absent (wasted spawn) or a
|
|
280
|
-
// different PATH binary entirely, so skip on Linux/Windows and let the
|
|
281
|
-
// file-token fallback apply. TFX_HUD_KEYCHAIN_FORCE=1 re-enables the spawn
|
|
282
|
-
// for cross-platform tests that inject a fake `security` via PATH.
|
|
283
|
-
if (
|
|
284
|
-
process.platform !== "darwin" &&
|
|
285
|
-
process.env.TFX_HUD_KEYCHAIN_FORCE !== "1"
|
|
286
|
-
) {
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
try {
|
|
290
|
-
const result = spawnSync(
|
|
291
|
-
"security",
|
|
292
|
-
[
|
|
293
|
-
"find-generic-password",
|
|
294
|
-
"-s",
|
|
295
|
-
ANTIGRAVITY_KEYCHAIN_SERVICE,
|
|
296
|
-
"-a",
|
|
297
|
-
ANTIGRAVITY_KEYCHAIN_ACCOUNT,
|
|
298
|
-
"-w",
|
|
299
|
-
],
|
|
300
|
-
{ encoding: "utf8", timeout: 3000 },
|
|
301
|
-
);
|
|
302
|
-
if (result.status !== 0) return null;
|
|
303
|
-
const stdout = result.stdout?.trim();
|
|
304
|
-
if (!stdout) return null;
|
|
305
|
-
try {
|
|
306
|
-
return normalizeAntigravityCredential(JSON.parse(stdout));
|
|
307
|
-
} catch {
|
|
308
|
-
return { access_token: stdout };
|
|
309
|
-
}
|
|
310
|
-
} catch {
|
|
311
|
-
return null;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
export function classifyGeminiQuotaFailure(response, authContext = {}) {
|
|
316
|
-
if (!response) return "network";
|
|
317
|
-
const error = response?.error || {};
|
|
318
|
-
const code = Number(error.code ?? response.code ?? response.status);
|
|
319
|
-
const status = String(error.status || response.status || "").toUpperCase();
|
|
320
|
-
const message = String(
|
|
321
|
-
error.message || error.code || response.error || response.message || "",
|
|
322
|
-
);
|
|
323
|
-
if (
|
|
324
|
-
code === 401 ||
|
|
325
|
-
code === 403 ||
|
|
326
|
-
status === "UNAUTHENTICATED" ||
|
|
327
|
-
status === "PERMISSION_DENIED" ||
|
|
328
|
-
/(unauthorized|forbidden|invalid authentication|invalid credentials|oauth|token|credential|auth)/i.test(
|
|
329
|
-
message,
|
|
330
|
-
)
|
|
331
|
-
) {
|
|
332
|
-
return "auth";
|
|
333
|
-
}
|
|
334
|
-
if (
|
|
335
|
-
authContext.authSource === "antigravity-keychain" &&
|
|
336
|
-
authContext.expiryMissing === true &&
|
|
337
|
-
/(expired|invalid|unauthenticated|permission denied)/i.test(message)
|
|
338
|
-
) {
|
|
339
|
-
return "auth";
|
|
340
|
-
}
|
|
341
|
-
return "api";
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function formatGeminiQuotaFailure(response, authContext = {}, stage = "quota") {
|
|
345
|
-
const error =
|
|
346
|
-
response?.error?.message ||
|
|
347
|
-
response?.error?.status ||
|
|
348
|
-
response?.error?.code ||
|
|
349
|
-
response?.error ||
|
|
350
|
-
response?.message ||
|
|
351
|
-
"no buckets in response";
|
|
352
|
-
const base = String(error);
|
|
353
|
-
if (
|
|
354
|
-
authContext.authSource === "antigravity-keychain" &&
|
|
355
|
-
authContext.expiryMissing === true
|
|
356
|
-
) {
|
|
357
|
-
return `expiry-less Antigravity Keychain token failed bounded ${stage} freshness probe: ${base}`;
|
|
358
|
-
}
|
|
359
|
-
return base;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function writeGeminiQuotaErrorCache(cache, authContext, errorType, errorHint) {
|
|
363
|
-
const sameKey = cache?.cacheKey === authContext.cacheKey;
|
|
364
|
-
writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
|
|
365
|
-
...(sameKey ? cache || {} : {}),
|
|
366
|
-
timestamp: sameKey && cache?.timestamp ? cache.timestamp : Date.now(),
|
|
367
|
-
cacheKey: authContext.cacheKey,
|
|
368
|
-
accountId: authContext.accountId || "gemini-main",
|
|
369
|
-
tokenFingerprint: authContext.tokenFingerprint,
|
|
370
|
-
authSource: authContext.authSource,
|
|
371
|
-
expiryMissing: authContext.expiryMissing === true,
|
|
372
|
-
error: true,
|
|
373
|
-
errorType,
|
|
374
|
-
errorHint,
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// ============================================================================
|
|
379
|
-
// Gemini 쿼터 API 호출 (5분 캐시)
|
|
380
|
-
// ============================================================================
|
|
381
|
-
export async function fetchGeminiQuota(accountId, options = {}) {
|
|
382
|
-
const authContext = options.authContext || buildGeminiAuthContext(accountId);
|
|
383
|
-
const { oauth, tokenFingerprint, cacheKey } = authContext;
|
|
384
|
-
authContext.accountId = accountId || "gemini-main";
|
|
385
|
-
const forceRefresh = options.forceRefresh === true;
|
|
386
|
-
|
|
387
|
-
// 1. 캐시 확인 (계정/토큰별)
|
|
388
|
-
const cache = readJsonMigrate(
|
|
389
|
-
GEMINI_QUOTA_CACHE_PATH,
|
|
390
|
-
LEGACY_GEMINI_QUOTA_CACHE,
|
|
391
|
-
null,
|
|
392
|
-
);
|
|
393
|
-
if (
|
|
394
|
-
!forceRefresh &&
|
|
395
|
-
cache?.cacheKey === cacheKey &&
|
|
396
|
-
cache?.timestamp &&
|
|
397
|
-
Date.now() - cache.timestamp < GEMINI_QUOTA_STALE_MS
|
|
398
|
-
) {
|
|
399
|
-
return cache;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (!oauth?.access_token) {
|
|
403
|
-
// access_token 없음: 에러 힌트를 캐시에 기록하고 stale 캐시 반환
|
|
404
|
-
writeGeminiQuotaErrorCache(
|
|
405
|
-
cache,
|
|
406
|
-
authContext,
|
|
407
|
-
"auth",
|
|
408
|
-
"no access_token in oauth_creds.json",
|
|
409
|
-
);
|
|
410
|
-
return cache;
|
|
411
|
-
}
|
|
412
|
-
if (oauth.expiry_date && oauth.expiry_date < Date.now()) {
|
|
413
|
-
// OAuth 토큰 만료: 에러 힌트를 캐시에 기록 (refresh_token 갱신은 Gemini CLI 담당)
|
|
414
|
-
writeGeminiQuotaErrorCache(
|
|
415
|
-
cache,
|
|
416
|
-
authContext,
|
|
417
|
-
"auth",
|
|
418
|
-
`token expired at ${new Date(oauth.expiry_date).toISOString()}`,
|
|
419
|
-
);
|
|
420
|
-
return cache;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// 3. projectId (캐시 or API)
|
|
424
|
-
let loadCodeAssistResponse = null;
|
|
425
|
-
const fetchProjectId = async () => {
|
|
426
|
-
loadCodeAssistResponse = await httpsPost(
|
|
427
|
-
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
|
428
|
-
{ metadata: { pluginType: "GEMINI" } },
|
|
429
|
-
oauth.access_token,
|
|
430
|
-
);
|
|
431
|
-
const id = loadCodeAssistResponse?.cloudaicompanionProject;
|
|
432
|
-
if (id)
|
|
433
|
-
writeJsonSafe(GEMINI_PROJECT_CACHE_PATH, {
|
|
434
|
-
cacheKey,
|
|
435
|
-
projectId: id,
|
|
436
|
-
timestamp: Date.now(),
|
|
437
|
-
});
|
|
438
|
-
return id || null;
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
const projCache = readJsonMigrate(
|
|
442
|
-
GEMINI_PROJECT_CACHE_PATH,
|
|
443
|
-
LEGACY_GEMINI_PROJECT_CACHE,
|
|
444
|
-
null,
|
|
445
|
-
);
|
|
446
|
-
let projectId =
|
|
447
|
-
projCache?.cacheKey === cacheKey ? projCache?.projectId : null;
|
|
448
|
-
if (!projectId) projectId = await fetchProjectId();
|
|
449
|
-
if (!projectId) {
|
|
450
|
-
writeGeminiQuotaErrorCache(
|
|
451
|
-
cache,
|
|
452
|
-
authContext,
|
|
453
|
-
classifyGeminiQuotaFailure(loadCodeAssistResponse, authContext),
|
|
454
|
-
formatGeminiQuotaFailure(
|
|
455
|
-
loadCodeAssistResponse,
|
|
456
|
-
authContext,
|
|
457
|
-
"loadCodeAssist",
|
|
458
|
-
),
|
|
459
|
-
);
|
|
460
|
-
return cache;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// 4. retrieveUserQuota 호출
|
|
464
|
-
let quotaRes = await httpsPost(
|
|
465
|
-
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
|
|
466
|
-
{ project: projectId },
|
|
467
|
-
oauth.access_token,
|
|
468
|
-
);
|
|
469
|
-
|
|
470
|
-
// projectId 캐시가 만료/변경된 경우 1회 재시도
|
|
471
|
-
if (!quotaRes?.buckets && projCache?.projectId) {
|
|
472
|
-
projectId = await fetchProjectId();
|
|
473
|
-
if (!projectId) {
|
|
474
|
-
writeGeminiQuotaErrorCache(
|
|
475
|
-
cache,
|
|
476
|
-
authContext,
|
|
477
|
-
classifyGeminiQuotaFailure(loadCodeAssistResponse, authContext),
|
|
478
|
-
formatGeminiQuotaFailure(
|
|
479
|
-
loadCodeAssistResponse,
|
|
480
|
-
authContext,
|
|
481
|
-
"loadCodeAssist",
|
|
482
|
-
),
|
|
483
|
-
);
|
|
484
|
-
return cache;
|
|
485
|
-
}
|
|
486
|
-
quotaRes = await httpsPost(
|
|
487
|
-
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
|
|
488
|
-
{ project: projectId },
|
|
489
|
-
oauth.access_token,
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (!quotaRes?.buckets) {
|
|
494
|
-
// API 응답에 buckets 없음: 에러 코드 또는 응답 내용을 캐시에 기록
|
|
495
|
-
writeGeminiQuotaErrorCache(
|
|
496
|
-
cache,
|
|
497
|
-
authContext,
|
|
498
|
-
classifyGeminiQuotaFailure(quotaRes, authContext),
|
|
499
|
-
formatGeminiQuotaFailure(quotaRes, authContext, "quota"),
|
|
500
|
-
);
|
|
501
|
-
return cache;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// 5. 캐시 저장
|
|
505
|
-
const result = {
|
|
506
|
-
timestamp: Date.now(),
|
|
507
|
-
cacheKey,
|
|
508
|
-
accountId: accountId || "gemini-main",
|
|
509
|
-
tokenFingerprint,
|
|
510
|
-
buckets: quotaRes.buckets,
|
|
511
|
-
};
|
|
512
|
-
writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, result);
|
|
513
|
-
return result;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/**
|
|
517
|
-
* Gemini RPM 트래커에서 최근 60초 내 요청 수를 읽는다.
|
|
518
|
-
* @returns {{ count: number, percent: number, remainingSec: number }}
|
|
519
|
-
*/
|
|
520
|
-
export function readGeminiRpm(model) {
|
|
521
|
-
try {
|
|
522
|
-
// 새 경로 → 레거시 경로 fallback
|
|
523
|
-
const rpmPath = existsSync(GEMINI_RPM_TRACKER_PATH)
|
|
524
|
-
? GEMINI_RPM_TRACKER_PATH
|
|
525
|
-
: existsSync(LEGACY_GEMINI_RPM_TRACKER)
|
|
526
|
-
? LEGACY_GEMINI_RPM_TRACKER
|
|
527
|
-
: null;
|
|
528
|
-
if (!rpmPath) return { count: 0, percent: 0, remainingSec: 60 };
|
|
529
|
-
const raw = readFileSync(rpmPath, "utf-8");
|
|
530
|
-
const parsed = JSON.parse(raw);
|
|
531
|
-
const timestamps = Array.isArray(parsed.timestamps)
|
|
532
|
-
? parsed.timestamps
|
|
533
|
-
: [];
|
|
534
|
-
const now = Date.now();
|
|
535
|
-
const recent = timestamps.filter((t) => now - t < GEMINI_RPM_WINDOW_MS);
|
|
536
|
-
const count = recent.length;
|
|
537
|
-
const rpmLimit = getGeminiRpmLimit(model);
|
|
538
|
-
const percent = clampPercent(Math.round((count / rpmLimit) * 100));
|
|
539
|
-
// 가장 오래된 엔트리가 윈도우에서 빠지기까지 남은 초 (0건이면 0s)
|
|
540
|
-
// 5초 단위 반올림으로 HUD 깜빡임 감소
|
|
541
|
-
const rawRemainingSec =
|
|
542
|
-
recent.length > 0
|
|
543
|
-
? Math.max(
|
|
544
|
-
0,
|
|
545
|
-
Math.ceil(
|
|
546
|
-
(GEMINI_RPM_WINDOW_MS - (now - Math.min(...recent))) / 1000,
|
|
547
|
-
),
|
|
548
|
-
)
|
|
549
|
-
: 0;
|
|
550
|
-
const remainingSec = Math.ceil(rawRemainingSec / 5) * 5;
|
|
551
|
-
return { count, percent, remainingSec };
|
|
552
|
-
} catch {
|
|
553
|
-
return { count: 0, percent: 0, remainingSec: 60 };
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
export function readGeminiQuotaSnapshot(accountId, authContext) {
|
|
558
|
-
const cache = readJsonMigrate(
|
|
559
|
-
GEMINI_QUOTA_CACHE_PATH,
|
|
560
|
-
LEGACY_GEMINI_QUOTA_CACHE,
|
|
561
|
-
null,
|
|
562
|
-
);
|
|
563
|
-
if (!cache?.buckets) {
|
|
564
|
-
return { quota: null, shouldRefresh: true };
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
const cacheKey = authContext.cacheKey;
|
|
568
|
-
const isLegacyCache = !cache.cacheKey;
|
|
569
|
-
const keyMatched = cache.cacheKey === cacheKey;
|
|
570
|
-
const cacheTs = Number(cache.timestamp);
|
|
571
|
-
const ageMs = Number.isFinite(cacheTs)
|
|
572
|
-
? Date.now() - cacheTs
|
|
573
|
-
: Number.MAX_SAFE_INTEGER;
|
|
574
|
-
const isFresh = ageMs < GEMINI_QUOTA_STALE_MS;
|
|
575
|
-
|
|
576
|
-
if (keyMatched) {
|
|
577
|
-
// resetTime이 지난 버킷의 remainingFraction을 1로 보정 (stale 캐시 방지)
|
|
578
|
-
if (Array.isArray(cache.buckets)) {
|
|
579
|
-
const now = Date.now();
|
|
580
|
-
const patchedBuckets = cache.buckets.map((b) =>
|
|
581
|
-
b?.resetTime && new Date(b.resetTime).getTime() <= now
|
|
582
|
-
? { ...b, remainingFraction: 1 }
|
|
583
|
-
: b,
|
|
584
|
-
);
|
|
585
|
-
return {
|
|
586
|
-
quota: { ...cache, buckets: patchedBuckets },
|
|
587
|
-
shouldRefresh: !isFresh,
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
return { quota: cache, shouldRefresh: !isFresh };
|
|
591
|
-
}
|
|
592
|
-
if (isLegacyCache) {
|
|
593
|
-
return { quota: cache, shouldRefresh: true };
|
|
594
|
-
}
|
|
595
|
-
return { quota: null, shouldRefresh: true };
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
export function scheduleGeminiQuotaRefresh(accountId) {
|
|
599
|
-
const scriptPath = process.argv[1];
|
|
600
|
-
if (!scriptPath) return;
|
|
601
|
-
|
|
602
|
-
// 스폰 락: 30초 내 이미 스폰했으면 중복 방지
|
|
603
|
-
try {
|
|
604
|
-
if (existsSync(GEMINI_QUOTA_REFRESH_LOCK_PATH)) {
|
|
605
|
-
const lockAge =
|
|
606
|
-
Date.now() - readJson(GEMINI_QUOTA_REFRESH_LOCK_PATH, {}).t;
|
|
607
|
-
if (lockAge < SPAWN_LOCK_TTL_MS) return;
|
|
608
|
-
}
|
|
609
|
-
writeJsonSafe(GEMINI_QUOTA_REFRESH_LOCK_PATH, { t: Date.now() });
|
|
610
|
-
} catch {
|
|
611
|
-
/* 락 실패 무시 — 스폰 진행 */
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
try {
|
|
615
|
-
const child = spawn(
|
|
616
|
-
process.execPath,
|
|
617
|
-
[
|
|
618
|
-
scriptPath,
|
|
619
|
-
GEMINI_REFRESH_FLAG,
|
|
620
|
-
"--account",
|
|
621
|
-
accountId || "gemini-main",
|
|
622
|
-
],
|
|
623
|
-
{
|
|
624
|
-
detached: true,
|
|
625
|
-
stdio: "ignore",
|
|
626
|
-
windowsHide: true,
|
|
627
|
-
},
|
|
628
|
-
);
|
|
629
|
-
child.unref();
|
|
630
|
-
} catch (spawnErr) {
|
|
631
|
-
// spawn 실패 시 캐시에 에러 힌트 기록 (다음 HUD 렌더에서 원인 확인 가능)
|
|
632
|
-
writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
|
|
633
|
-
timestamp: Date.now(),
|
|
634
|
-
error: true,
|
|
635
|
-
errorHint: String(spawnErr?.message || spawnErr),
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
export function readGeminiSessionSnapshot() {
|
|
641
|
-
const cache = readJsonMigrate(
|
|
642
|
-
GEMINI_SESSION_CACHE_PATH,
|
|
643
|
-
LEGACY_GEMINI_SESSION_CACHE,
|
|
644
|
-
null,
|
|
645
|
-
);
|
|
646
|
-
if (!cache?.session) {
|
|
647
|
-
return { session: null, shouldRefresh: true };
|
|
648
|
-
}
|
|
649
|
-
const ts = Number(cache.timestamp);
|
|
650
|
-
const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
|
|
651
|
-
const isFresh = ageMs < GEMINI_SESSION_STALE_MS;
|
|
652
|
-
return { session: cache.session, shouldRefresh: !isFresh };
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
export function refreshGeminiSessionCache() {
|
|
656
|
-
const session = scanGeminiSessionTokens();
|
|
657
|
-
if (!session) return null;
|
|
658
|
-
writeJsonSafe(GEMINI_SESSION_CACHE_PATH, { timestamp: Date.now(), session });
|
|
659
|
-
return session;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
export function scheduleGeminiSessionRefresh() {
|
|
663
|
-
const scriptPath = process.argv[1];
|
|
664
|
-
if (!scriptPath) return;
|
|
665
|
-
|
|
666
|
-
// 스폰 락: 30초 내 이미 스폰했으면 중복 방지
|
|
667
|
-
try {
|
|
668
|
-
if (existsSync(GEMINI_SESSION_REFRESH_LOCK_PATH)) {
|
|
669
|
-
const lockAge =
|
|
670
|
-
Date.now() - readJson(GEMINI_SESSION_REFRESH_LOCK_PATH, {}).t;
|
|
671
|
-
if (lockAge < SPAWN_LOCK_TTL_MS) return;
|
|
672
|
-
}
|
|
673
|
-
writeJsonSafe(GEMINI_SESSION_REFRESH_LOCK_PATH, { t: Date.now() });
|
|
674
|
-
} catch {
|
|
675
|
-
/* 락 실패 무시 — 스폰 진행 */
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
try {
|
|
679
|
-
const child = spawn(
|
|
680
|
-
process.execPath,
|
|
681
|
-
[scriptPath, GEMINI_SESSION_REFRESH_FLAG],
|
|
682
|
-
{
|
|
683
|
-
detached: true,
|
|
684
|
-
stdio: "ignore",
|
|
685
|
-
windowsHide: true,
|
|
686
|
-
},
|
|
687
|
-
);
|
|
688
|
-
child.unref();
|
|
689
|
-
} catch {
|
|
690
|
-
/* 백그라운드 실행 실패 무시 */
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// ============================================================================
|
|
695
|
-
// Gemini 세션 JSON에서 토큰 사용량 추출
|
|
696
|
-
// ============================================================================
|
|
697
|
-
export function scanGeminiSessionTokens() {
|
|
698
|
-
const tmpDir = join(homedir(), ".gemini", "tmp");
|
|
699
|
-
if (!existsSync(tmpDir)) return null;
|
|
700
|
-
let best = null;
|
|
701
|
-
let bestTime = 0;
|
|
702
|
-
try {
|
|
703
|
-
const dirs = readdirSync(tmpDir).filter((d) =>
|
|
704
|
-
existsSync(join(tmpDir, d, "chats")),
|
|
705
|
-
);
|
|
706
|
-
for (const dir of dirs) {
|
|
707
|
-
const chatsDir = join(tmpDir, dir, "chats");
|
|
708
|
-
let files;
|
|
709
|
-
try {
|
|
710
|
-
files = readdirSync(chatsDir).filter((f) => f.endsWith(".json"));
|
|
711
|
-
} catch {
|
|
712
|
-
continue;
|
|
713
|
-
}
|
|
714
|
-
for (const file of files) {
|
|
715
|
-
try {
|
|
716
|
-
const data = JSON.parse(readFileSync(join(chatsDir, file), "utf-8"));
|
|
717
|
-
const updatedAt = new Date(data.lastUpdated || 0).getTime();
|
|
718
|
-
if (updatedAt <= bestTime) continue;
|
|
719
|
-
let input = 0,
|
|
720
|
-
output = 0;
|
|
721
|
-
let model = "unknown";
|
|
722
|
-
for (const msg of data.messages || []) {
|
|
723
|
-
if (msg.tokens) {
|
|
724
|
-
input += msg.tokens.input || 0;
|
|
725
|
-
output += msg.tokens.output || 0;
|
|
726
|
-
}
|
|
727
|
-
if (msg.model) model = msg.model;
|
|
728
|
-
}
|
|
729
|
-
bestTime = updatedAt;
|
|
730
|
-
best = {
|
|
731
|
-
input,
|
|
732
|
-
output,
|
|
733
|
-
total: input + output,
|
|
734
|
-
model,
|
|
735
|
-
lastUpdated: data.lastUpdated,
|
|
736
|
-
};
|
|
737
|
-
} catch {
|
|
738
|
-
/* 무시 */
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
} catch {
|
|
743
|
-
/* 무시 */
|
|
744
|
-
}
|
|
745
|
-
return best;
|
|
746
|
-
}
|