@triflux/core 10.50.1 → 10.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/agy-session-hook.mjs +11 -56
- package/hooks/codex-session-hook.mjs +11 -101
- package/hub/bridge.mjs +1 -79
- package/hub/cli-adapter-base.mjs +12 -90
- package/hub/codex-adapter.mjs +0 -1
- package/hub/codex-preflight.mjs +5 -28
- package/hub/index.mjs +3 -9
- package/hub/lib/cto-env.mjs +0 -23
- package/hub/lib/known-errors.json +2 -2
- package/hub/lib/process-utils.mjs +0 -123
- package/hub/lib/spawn-trace.mjs +3 -2
- package/hub/lib/timeout-defaults.mjs +0 -1
- 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/hub/role-contract.mjs +0 -2
- package/hud/cli-policy.mjs +0 -26
- package/hud/colors.mjs +0 -13
- package/hud/constants.mjs +7 -153
- package/hud/context-monitor.mjs +3 -64
- package/hud/hud-qos-status.mjs +54 -264
- package/hud/providers/claude.mjs +4 -16
- package/hud/providers/gemini.mjs +83 -689
- package/hud/renderers.mjs +59 -367
- package/hud/terminal.mjs +1 -63
- package/hud/utils.mjs +2 -26
- package/package.json +1 -4
- package/scripts/lib/agent-route-policy.mjs +162 -14
- package/scripts/lib/claudemd-scanner.mjs +2 -199
- package/scripts/lib/cli-claude.mjs +0 -2
- package/scripts/lib/context.mjs +3 -19
- package/scripts/lib/env-probe.mjs +5 -64
- package/scripts/lib/gemini-profiles.mjs +2 -69
- package/scripts/lib/legacy-mcp-cleanup.mjs +14 -3
- package/scripts/lib/machine-profile.mjs +3 -17
- package/scripts/lib/mcp-filter.mjs +109 -53
- package/scripts/lib/mcp-health.mjs +2 -2
- package/scripts/lib/mcp-server-catalog.mjs +2 -2
- package/scripts/lib/psmux-info.mjs +25 -104
- package/scripts/lib/session-presence.mjs +1 -1
- package/scripts/lib/skill-template.mjs +0 -271
- package/hub/codex-compat.mjs +0 -12
- package/hub/dynamic-routing-engine.mjs +0 -511
- 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/risk-tier.mjs +0 -53
- package/hub/lib/ssh-command.mjs +0 -231
- package/hub/lib/staged-file-detect.mjs +0 -43
- package/hub/lib/tfx-route-args.mjs +0 -353
- 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/routing-snapshot.mjs +0 -261
- package/hub/token-mode.mjs +0 -228
- package/hud/mission-board.mjs +0 -56
- package/hud/providers/cto.mjs +0 -76
- package/mesh/index.mjs +0 -69
- package/mesh/mesh-budget.mjs +0 -137
- package/mesh/mesh-heartbeat.mjs +0 -100
- package/mesh/mesh-protocol.mjs +0 -96
- package/mesh/mesh-queue.mjs +0 -163
- package/mesh/mesh-registry.mjs +0 -78
- package/mesh/mesh-router.mjs +0 -77
- 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/dynamic-route-cli.mjs +0 -107
- 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,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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";
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
10
3
|
import {
|
|
11
|
-
ANTIGRAVITY_KEYCHAIN_ACCOUNT,
|
|
12
|
-
ANTIGRAVITY_KEYCHAIN_SERVICE,
|
|
13
|
-
ANTIGRAVITY_MODEL_ABBREV,
|
|
14
4
|
ANTIGRAVITY_OAUTH_PATHS,
|
|
5
|
+
ANTIGRAVITY_QUOTA_CACHE_PATH,
|
|
6
|
+
ANTIGRAVITY_QUOTA_STALE_MS,
|
|
7
|
+
ANTIGRAVITY_REFRESH_FLAG,
|
|
8
|
+
ANTIGRAVITY_REFRESH_LOCK_PATH,
|
|
15
9
|
ANTIGRAVITY_SETTINGS_PATH,
|
|
16
|
-
GEMINI_API_TIMEOUT_MS,
|
|
17
|
-
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
10
|
SPAWN_LOCK_TTL_MS,
|
|
34
11
|
} 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
|
-
}
|
|
12
|
+
import { decodeJwtEmail, readJson, writeJsonSafe } from "../utils.mjs";
|
|
118
13
|
|
|
119
14
|
function getCredentialEmail(credential) {
|
|
120
15
|
return (
|
|
@@ -127,558 +22,111 @@ function getCredentialEmail(credential) {
|
|
|
127
22
|
);
|
|
128
23
|
}
|
|
129
24
|
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
const oauth = readJson(GEMINI_OAUTH_PATH, null);
|
|
133
|
-
return getCredentialEmail(oauth);
|
|
134
|
-
} catch {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// GCP 프로젝트 인증이면 계정 이메일 대신 프로젝트 ID 를 보여 준다.
|
|
25
|
+
// GCP 프로젝트 인증이면 계정 이메일 대신 프로젝트 ID를 보여 준다.
|
|
140
26
|
export function getAntigravityAccountLabel() {
|
|
141
27
|
const project = readJson(ANTIGRAVITY_SETTINGS_PATH, null)?.gcp?.project;
|
|
142
28
|
if (project) return project;
|
|
143
29
|
for (const oauthPath of ANTIGRAVITY_OAUTH_PATHS) {
|
|
144
30
|
try {
|
|
145
|
-
const
|
|
146
|
-
const email = getCredentialEmail(oauth);
|
|
31
|
+
const email = getCredentialEmail(readJson(oauthPath, null));
|
|
147
32
|
if (email) return email;
|
|
148
33
|
} catch {
|
|
149
|
-
//
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// agy 1.0.1+ settings.json `model` 필드에서 현재 장착 모델 라벨을 읽는다.
|
|
156
|
-
// 예: "Gemini 3.5 Flash (High)". 없으면 null.
|
|
157
|
-
export function getAntigravityCurrentModel() {
|
|
158
|
-
const s = readJson(ANTIGRAVITY_SETTINGS_PATH, null);
|
|
159
|
-
return s?.model || null;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// 모델 라벨 → 2자 약어 (constants의 ANTIGRAVITY_MODEL_ABBREV 매핑).
|
|
163
|
-
// 매핑에 없으면 "<Head> ... (<Level>)" 패턴에서 자동 추출.
|
|
164
|
-
export function getAntigravityModelAbbrev(label) {
|
|
165
|
-
if (!label) return "??";
|
|
166
|
-
const known = ANTIGRAVITY_MODEL_ABBREV[label];
|
|
167
|
-
if (known) return known;
|
|
168
|
-
const parens = label.match(/\(([^)]+)\)/);
|
|
169
|
-
const head = label.match(/^(\w+)/);
|
|
170
|
-
if (head && parens) {
|
|
171
|
-
return `${head[1][0].toUpperCase()}${parens[1][0].toLowerCase()}`;
|
|
172
|
-
}
|
|
173
|
-
if (head) return head[1].slice(0, 2);
|
|
174
|
-
return "??";
|
|
175
|
-
}
|
|
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;
|
|
34
|
+
// 다음 Antigravity 인증 경로를 확인한다.
|
|
219
35
|
}
|
|
220
36
|
}
|
|
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
37
|
return null;
|
|
247
38
|
}
|
|
248
39
|
|
|
249
|
-
function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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,
|
|
40
|
+
function isGcpProjectAuth() {
|
|
41
|
+
return Boolean(readJson(ANTIGRAVITY_SETTINGS_PATH, null)?.gcp?.project);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readAntigravityQuotaSnapshot() {
|
|
45
|
+
// 프로젝트 과금의 기본 응답을 개인 플랜의 0% 사용률로 표시하지 않는다.
|
|
46
|
+
if (isGcpProjectAuth()) return { data: null, shouldRefresh: false };
|
|
47
|
+
const cache = readJson(ANTIGRAVITY_QUOTA_CACHE_PATH, null);
|
|
48
|
+
const matched = cache?.accountLabel === getAntigravityAccountLabel();
|
|
49
|
+
const stale =
|
|
50
|
+
!cache?.timestamp ||
|
|
51
|
+
Date.now() - cache.timestamp >= ANTIGRAVITY_QUOTA_STALE_MS;
|
|
52
|
+
const model = readJson(ANTIGRAVITY_SETTINGS_PATH, null)?.model;
|
|
53
|
+
const buckets = matched && Array.isArray(cache?.buckets) ? cache.buckets : [];
|
|
54
|
+
const bucket = buckets
|
|
55
|
+
.filter(
|
|
56
|
+
(item) =>
|
|
57
|
+
model &&
|
|
58
|
+
item &&
|
|
59
|
+
(item?.name === model || item?.id === model) &&
|
|
60
|
+
Number.isFinite(item.remaining_fraction) &&
|
|
61
|
+
item.remaining_fraction >= 0 &&
|
|
62
|
+
item.remaining_fraction <= 1 &&
|
|
63
|
+
Date.parse(item.reset_time) > Date.now(),
|
|
330
64
|
)
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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,
|
|
65
|
+
.sort((a, b) => a.remaining_fraction - b.remaining_fraction)[0];
|
|
66
|
+
return {
|
|
67
|
+
data: bucket
|
|
68
|
+
? {
|
|
69
|
+
usedPercent: Math.round((1 - bucket.remaining_fraction) * 100),
|
|
70
|
+
resetTime: bucket.reset_time,
|
|
71
|
+
stale,
|
|
72
|
+
}
|
|
73
|
+
: null,
|
|
74
|
+
shouldRefresh: !matched || stale,
|
|
511
75
|
};
|
|
512
|
-
writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, result);
|
|
513
|
-
return result;
|
|
514
76
|
}
|
|
515
77
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
78
|
+
// /usage는 모델 턴을 시작하지 않는 공식 print-mode 조회다.
|
|
79
|
+
export function refreshAntigravityQuotaCache(execFileSyncFn = execFileSync) {
|
|
80
|
+
if (isGcpProjectAuth()) return null;
|
|
81
|
+
const accountLabel = getAntigravityAccountLabel();
|
|
82
|
+
let buckets = null;
|
|
521
83
|
try {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
:
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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,
|
|
84
|
+
const result = JSON.parse(
|
|
85
|
+
execFileSyncFn("agy", ["-p", "/usage", "--output-format", "json"], {
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
timeout: 15_000,
|
|
88
|
+
maxBuffer: 1024 * 1024,
|
|
89
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
if (
|
|
94
|
+
result.status === "SUCCESS" &&
|
|
95
|
+
result.command?.name === "usage" &&
|
|
96
|
+
Array.isArray(result.command.data?.groups)
|
|
97
|
+
) {
|
|
98
|
+
buckets = result.command.data.groups.flatMap((group) =>
|
|
99
|
+
Array.isArray(group?.buckets) ? group.buckets : [],
|
|
584
100
|
);
|
|
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
101
|
}
|
|
609
|
-
writeJsonSafe(GEMINI_QUOTA_REFRESH_LOCK_PATH, { t: Date.now() });
|
|
610
102
|
} catch {
|
|
611
|
-
|
|
103
|
+
// 실패도 캐시해 인증 오류나 CLI 부재 시 매 렌더마다 실행하지 않는다.
|
|
612
104
|
}
|
|
613
|
-
|
|
614
|
-
|
|
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, {
|
|
105
|
+
if (accountLabel === getAntigravityAccountLabel()) {
|
|
106
|
+
writeJsonSafe(ANTIGRAVITY_QUOTA_CACHE_PATH, {
|
|
633
107
|
timestamp: Date.now(),
|
|
634
|
-
|
|
635
|
-
|
|
108
|
+
accountLabel,
|
|
109
|
+
buckets,
|
|
636
110
|
});
|
|
637
111
|
}
|
|
112
|
+
return buckets;
|
|
638
113
|
}
|
|
639
114
|
|
|
640
|
-
export function
|
|
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() {
|
|
115
|
+
export function scheduleAntigravityQuotaRefresh() {
|
|
663
116
|
const scriptPath = process.argv[1];
|
|
664
|
-
if (
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
writeJsonSafe(GEMINI_SESSION_REFRESH_LOCK_PATH, { t: Date.now() });
|
|
674
|
-
} catch {
|
|
675
|
-
/* 락 실패 무시 — 스폰 진행 */
|
|
676
|
-
}
|
|
677
|
-
|
|
117
|
+
if (
|
|
118
|
+
!scriptPath ||
|
|
119
|
+
!existsSync(ANTIGRAVITY_SETTINGS_PATH) ||
|
|
120
|
+
isGcpProjectAuth()
|
|
121
|
+
)
|
|
122
|
+
return;
|
|
123
|
+
const lock = readJson(ANTIGRAVITY_REFRESH_LOCK_PATH, null);
|
|
124
|
+
if (Date.now() - Number(lock?.t || 0) < SPAWN_LOCK_TTL_MS) return;
|
|
125
|
+
writeJsonSafe(ANTIGRAVITY_REFRESH_LOCK_PATH, { t: Date.now() });
|
|
678
126
|
try {
|
|
679
127
|
const child = spawn(
|
|
680
128
|
process.execPath,
|
|
681
|
-
[scriptPath,
|
|
129
|
+
[scriptPath, ANTIGRAVITY_REFRESH_FLAG],
|
|
682
130
|
{
|
|
683
131
|
detached: true,
|
|
684
132
|
stdio: "ignore",
|
|
@@ -687,60 +135,6 @@ export function scheduleGeminiSessionRefresh() {
|
|
|
687
135
|
);
|
|
688
136
|
child.unref();
|
|
689
137
|
} 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
|
-
/* 무시 */
|
|
138
|
+
// 다음 렌더에서 다시 시도한다.
|
|
744
139
|
}
|
|
745
|
-
return best;
|
|
746
140
|
}
|