dsh-agy-link 0.4.15 → 0.4.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/client.js +14 -4
- package/dist/index.js +75 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.18 (2026-08-25)
|
|
4
|
+
|
|
5
|
+
- **Quota Fallback Never Clobbers Good Data (5h=100%/weekly-missing 根因).**
|
|
6
|
+
- **What happened**: `retrieveUserQuotaSummary` transiently failed (proxy blip) while `fetchAvailableModels` still answered; the per-model fallback then OVERWROTE the stored family entry with a single-window partial shape — weeklyFraction vanished and the 5h row received wrong-window numbers (observed 5h=100%, reset a week out, weekly `—`, while the live API actually reported 5h 84% / weekly 47%).
|
|
7
|
+
- **`mergeFallbackFamilyQuota`**: last-known-good complete family data now always wins over partial fallback; the fallback only fills families with no usable previous entry (first-ever refresh). Verified live: both endpoints answer correctly and a successful summary refresh fully restores the display.
|
|
8
|
+
- Added `scripts/diag-quota.mts` one-shot endpoint probe for future incidents.
|
|
9
|
+
|
|
10
|
+
## 0.4.17 (2026-08-25)
|
|
11
|
+
|
|
12
|
+
- **Ghost-Cooldown & Quota-Display Fix (额度显示 0% 根因).**
|
|
13
|
+
- **What happened**: the UI showed 5h quota as 0% while `agy` reported 98% — the parsed quota data was CORRECT all along, but (a) any active local cooldown forced the 5h bar to render 0%, and (b) the loose rate-limit classifier kept creating ghost cooldowns: it scanned the ENTIRE stdout (model prose mentioning "rate limit"/"quota", hash fragments containing "429") and matched bare keywords, so an unrelated tool/permission error froze a healthy account out of rotation with a 15-minute+ cooldown (captured real reason: `rate limit reached: declaring permissions: cortex tool write_to_file … invalid tool call error`).
|
|
14
|
+
- **Hard vs soft classification**: new `looksLikeHardRateLimit` (RESOURCE_EXHAUSTED / code·status·HTTP 429 / too many requests / individual quota reached / quota exceeded·reached·exhausted / rate limit exceeded·reached·hit) is the ONLY pattern allowed to put an account into cooldown; soft signals (model overloaded / high traffic) still shape the error message but never cool accounts.
|
|
15
|
+
- **Scan scope narrowed**: error classification reads stderr + the result envelope's error field only — stdout (event JSON + model prose) no longer participates.
|
|
16
|
+
- **Honest quota bars**: a local cooldown no longer overwrites the server-reported fraction with 0%; it now appends a `· 本地冷却中` note next to the reset time instead.
|
|
17
|
+
- Regression tests pin the exact incident text (cortex tool permission error) as a non-rate-limit fixture.
|
|
18
|
+
|
|
19
|
+
## 0.4.16 (2026-08-24)
|
|
20
|
+
|
|
21
|
+
- **External Re-Login Sync (换号自动/手动同步).**
|
|
22
|
+
- **Root cause fixed**: after `agy logout` + re-login as a DIFFERENT account, the pool slot kept the old account's email, cooldowns, quotas and `auth_required` quarantine — and poll gating (0.4.15) then skipped the flagged slot forever, freezing the UI on the stale account.
|
|
23
|
+
- **`resetAccountIdentity`**: detecting a changed email (from local CLI logs, zero network) now resets all identity-bound state (cooldowns / quotas / auth quarantine) while keeping slot config — the new account starts clean instead of inheriting the old one's restrictions.
|
|
24
|
+
- **Zero-network reconciliation before poll gating**: the background poller pre-checks flagged slots via local log scan, so an external re-login self-heals within one poll cycle (≤15 min) without any extra request to Google. Manual click is instant.
|
|
25
|
+
- **Auth self-heal on success**: a successful authenticated quota fetch clears a stale `auth_required` flag (the old token's invalid_grant no longer condemns the new login).
|
|
26
|
+
- **Manual refresh upgrades**: the 刷新 button now also re-reads the model catalog (one `agy models` spawn per explicit click only — new subscription tier may expose different models), and every account card gains a 同步 button for single-account refresh.
|
|
27
|
+
- **Primary slot always re-bootstrapped (real root cause of "UI stuck on old account")**: the primary slot was only created for an EMPTY pool, so once deleted it never came back while other accounts remained — the system-HOME login (e.g. after `agy logout` + re-login) had no slot to attach to and the UI kept showing an isolated account as 主账号. The slot is now recreated at the front on every load; deleting it no longer promotes an isolated account to primary. Disable the slot instead of deleting if unwanted.
|
|
28
|
+
|
|
3
29
|
## 0.4.15 (2026-08-24)
|
|
4
30
|
|
|
5
31
|
- **Quota Polling Risk-Exposure Minimization (root-cause follow-up).**
|
package/dist/client.js
CHANGED
|
@@ -963,8 +963,8 @@ body.dark,
|
|
|
963
963
|
const info = acc.quotas[familyKey];
|
|
964
964
|
const cd = acc.cooldowns[familyKey];
|
|
965
965
|
const inCooldown = cd && cd.cooldownUntil > Date.now();
|
|
966
|
-
|
|
967
|
-
|
|
966
|
+
const pct5h = typeof info?.remainingFraction === "number" && Number.isFinite(info.remainingFraction) ? Math.max(0, Math.min(100, Math.round(info.remainingFraction * 100))) : -1;
|
|
967
|
+
const cdNote = inCooldown ? " · 本地冷却中" : "";
|
|
968
968
|
const w5h = formatQuotaWindow(info?.resetTime);
|
|
969
969
|
const pctWeekly = typeof info?.weeklyFraction === "number" && Number.isFinite(info.weeklyFraction) ? Math.max(0, Math.min(100, Math.round(info.weeklyFraction * 100))) : -1;
|
|
970
970
|
const wWeekly = formatQuotaWindow(info?.weeklyResetTime);
|
|
@@ -1052,7 +1052,7 @@ body.dark,
|
|
|
1052
1052
|
fontSize: "12.5px",
|
|
1053
1053
|
marginBottom: "5px",
|
|
1054
1054
|
color: "var(--agy-text-primary)"
|
|
1055
|
-
} }, brandIcon(FAMILY_BRAND[familyKey], 14), h("span", null, label)), renderLine("5h 额度", pct5h, c5h, w5h.resetText), renderLine("周额度", pctWeekly, cWeekly, wWeekly.resetText));
|
|
1055
|
+
} }, brandIcon(FAMILY_BRAND[familyKey], 14), h("span", null, label)), renderLine("5h 额度", pct5h, c5h, w5h.resetText ? w5h.resetText + cdNote : cdNote.replace(/^ · /, "")), renderLine("周额度", pctWeekly, cWeekly, wWeekly.resetText));
|
|
1056
1056
|
};
|
|
1057
1057
|
const renderedAccountCards = accounts.map((acc) => {
|
|
1058
1058
|
const isPrimary = acc.id === pool?.primaryAccountId;
|
|
@@ -1140,7 +1140,17 @@ body.dark,
|
|
|
1140
1140
|
gap: "3px"
|
|
1141
1141
|
},
|
|
1142
1142
|
onClick: () => toggleExpand(acc.id)
|
|
1143
|
-
}, isExpanded ? [uiIcon("chevronUp", 11), " 收起"] : [uiIcon("chevronDown", 11), " 明细"]) : null,
|
|
1143
|
+
}, isExpanded ? [uiIcon("chevronUp", 11), " 收起"] : [uiIcon("chevronDown", 11), " 明细"]) : null, h("button", {
|
|
1144
|
+
type: "button",
|
|
1145
|
+
className: "agy-btn",
|
|
1146
|
+
style: {
|
|
1147
|
+
...S.btnSm,
|
|
1148
|
+
gap: "4px"
|
|
1149
|
+
},
|
|
1150
|
+
title: "刷新此账号(换号后点这里立即同步 email / 额度 / 模型)",
|
|
1151
|
+
disabled: isBusy,
|
|
1152
|
+
onClick: () => void refreshQuota(acc.id)
|
|
1153
|
+
}, loadingAction === `refresh:${acc.id}` ? [renderSpinner(), "同步中"] : [uiIcon("refresh", 11), " 同步"]), !isPrimary ? h("button", {
|
|
1144
1154
|
type: "button",
|
|
1145
1155
|
className: "agy-btn",
|
|
1146
1156
|
style: {
|
package/dist/index.js
CHANGED
|
@@ -115,9 +115,25 @@ function extractAuthUrl(text) {
|
|
|
115
115
|
if (!m) return void 0;
|
|
116
116
|
return m[0].replace(/[)\]>.,;\x27\x22]+$/, "");
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* HARD, server-issued rate-limit signatures — the ONLY patterns allowed to
|
|
120
|
+
* put an account into cooldown. Deliberately narrow: bare `429` or
|
|
121
|
+
* `rate limit` matched incidental substrings in the wild (hash/UUID
|
|
122
|
+
* fragments, model prose mentioning quotas, unrelated tool/permission
|
|
123
|
+
* errors quoting such words) and produced ghost cooldowns that froze
|
|
124
|
+
* healthy accounts out of rotation.
|
|
125
|
+
*/
|
|
126
|
+
function looksLikeHardRateLimit(text) {
|
|
127
|
+
if (!text) return false;
|
|
128
|
+
return /RESOURCE_EXHAUSTED|code[ :]?429\b|status[ :]?429\b|HTTP[ :]?429\b|too many requests|individual quota reached|quota (?:exceeded|reached|exhausted)|rate[ -]?limit(?:ed)? (?:exceeded|reached|hit)|exceeded (?:your |the )?quota/i.test(text);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Soft heuristic adds capacity signals (model overloaded / high traffic).
|
|
132
|
+
* Shapes the user-facing error message only — NEVER cools an account down.
|
|
133
|
+
*/
|
|
118
134
|
function looksLikeRateLimit(text) {
|
|
119
135
|
if (!text) return false;
|
|
120
|
-
return
|
|
136
|
+
return looksLikeHardRateLimit(text) || /model overloaded|experiencing high traffic/i.test(text);
|
|
121
137
|
}
|
|
122
138
|
/**
|
|
123
139
|
* Parse reset duration in milliseconds from rate-limit / quota-exhausted error strings.
|
|
@@ -2364,11 +2380,8 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
2364
2380
|
const conversationId = streamCid ?? diffed;
|
|
2365
2381
|
const r = rec.getResultEvent();
|
|
2366
2382
|
const consumable = r !== null && (r.ok || r.response !== "");
|
|
2367
|
-
const
|
|
2368
|
-
|
|
2369
|
-
outcome.stdout,
|
|
2370
|
-
parser.stats.lastResultError
|
|
2371
|
-
].filter(Boolean).join(" "));
|
|
2383
|
+
const rawErrText = [outcome.stderrTail, parser.stats.lastResultError].filter(Boolean).join(" ");
|
|
2384
|
+
const isRateLimit = looksLikeRateLimit(rawErrText);
|
|
2372
2385
|
let failure = null;
|
|
2373
2386
|
if (outcome.aborted) failure = {
|
|
2374
2387
|
kind: "aborted",
|
|
@@ -2423,7 +2436,7 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
2423
2436
|
}
|
|
2424
2437
|
} else {
|
|
2425
2438
|
const effectiveRateLimit = isRateLimit || looksLikeRateLimit(failure.message);
|
|
2426
|
-
if (account &&
|
|
2439
|
+
if (account && looksLikeHardRateLimit(rawErrText)) this.deps.pool?.recordFailure(account.id, family, failure.message);
|
|
2427
2440
|
if (account && (failure.code === Err.AUTH || /invalid_grant|not signed in|auth/i.test(failure.message))) this.deps.pool?.markAuthRequired(account.id, failure.message);
|
|
2428
2441
|
if (!isAux && sessionAccountKey !== "") {
|
|
2429
2442
|
if (failure.code === Err.AUTH || effectiveRateLimit || failure.message && /conversation.*(not found|invalid|not recognized|expired|does not exist)|session.*(expired|invalid)/i.test(failure.message)) this.deps.store.delete(sessionAccountKey);
|
|
@@ -27082,7 +27095,7 @@ var AccountPoolManager = class {
|
|
|
27082
27095
|
* and signed in via /agy add-account.
|
|
27083
27096
|
*/
|
|
27084
27097
|
bootstrapDefaultAccount() {
|
|
27085
|
-
if (this.data.accounts.
|
|
27098
|
+
if (this.data.accounts.some((a) => a.systemHome)) return;
|
|
27086
27099
|
const primary = {
|
|
27087
27100
|
id: "acc_primary",
|
|
27088
27101
|
alias: "主账号 (系统登录)",
|
|
@@ -27093,7 +27106,7 @@ var AccountPoolManager = class {
|
|
|
27093
27106
|
cooldowns: {},
|
|
27094
27107
|
quotas: {}
|
|
27095
27108
|
};
|
|
27096
|
-
this.data.accounts.
|
|
27109
|
+
this.data.accounts.unshift(primary);
|
|
27097
27110
|
this.data.primaryAccountId = primary.id;
|
|
27098
27111
|
this.persist();
|
|
27099
27112
|
}
|
|
@@ -27243,7 +27256,7 @@ var AccountPoolManager = class {
|
|
|
27243
27256
|
force: true
|
|
27244
27257
|
});
|
|
27245
27258
|
} catch {}
|
|
27246
|
-
if (this.data.primaryAccountId === id) this.data.primaryAccountId =
|
|
27259
|
+
if (this.data.primaryAccountId === id) this.data.primaryAccountId = void 0;
|
|
27247
27260
|
if (this.data.activeAccountIds) {
|
|
27248
27261
|
for (const [fam, accId] of Object.entries(this.data.activeAccountIds)) if (accId === id) delete this.data.activeAccountIds[fam];
|
|
27249
27262
|
}
|
|
@@ -27284,6 +27297,22 @@ var AccountPoolManager = class {
|
|
|
27284
27297
|
}
|
|
27285
27298
|
this.persist();
|
|
27286
27299
|
}
|
|
27300
|
+
/**
|
|
27301
|
+
* External re-login detected (agy logout + new login): identity-bound
|
|
27302
|
+
* state from the PREVIOUS account (cooldowns, quotas, auth quarantine)
|
|
27303
|
+
* must not leak onto the new one. Resets everything email-bound while
|
|
27304
|
+
* keeping slot config (alias, dir, proxy, enabled).
|
|
27305
|
+
*/
|
|
27306
|
+
resetAccountIdentity(id, newEmail) {
|
|
27307
|
+
const acc = this.getAccount(id);
|
|
27308
|
+
if (!acc) return;
|
|
27309
|
+
acc.email = newEmail;
|
|
27310
|
+
acc.cooldowns = {};
|
|
27311
|
+
acc.quotas = {};
|
|
27312
|
+
delete acc.authRequired;
|
|
27313
|
+
delete acc.authError;
|
|
27314
|
+
this.persist();
|
|
27315
|
+
}
|
|
27287
27316
|
clearAuthRequired(id) {
|
|
27288
27317
|
const acc = this.getAccount(id);
|
|
27289
27318
|
if (!acc) return;
|
|
@@ -27652,6 +27681,18 @@ var PoolAuthFlow = class {
|
|
|
27652
27681
|
};
|
|
27653
27682
|
//#endregion
|
|
27654
27683
|
//#region src/host/quota.ts
|
|
27684
|
+
/**
|
|
27685
|
+
* When the quota-summary endpoint transiently fails, per-model fallback
|
|
27686
|
+
* data carries a SINGLE window (sometimes the weekly one) and no weekly
|
|
27687
|
+
* fields. Overwriting a previously COMPLETE family entry with that partial
|
|
27688
|
+
* shape dropped weeklyFraction to none and put wrong-window numbers into
|
|
27689
|
+
* the 5h row (observed: 5h=100% / reset a week out / weekly missing).
|
|
27690
|
+
* Rule: last-known-good complete data always wins over partial fallback.
|
|
27691
|
+
*/
|
|
27692
|
+
function mergeFallbackFamilyQuota(prev, fallback) {
|
|
27693
|
+
if (prev && typeof prev.remainingFraction === "number") return prev;
|
|
27694
|
+
return fallback;
|
|
27695
|
+
}
|
|
27655
27696
|
function detectEmailFromAgyLogs(homeDir) {
|
|
27656
27697
|
const logDir = join(homeDir, ".gemini", "antigravity-cli", "log");
|
|
27657
27698
|
if (!existsSync(logDir)) return void 0;
|
|
@@ -27902,20 +27943,16 @@ var QuotaService = class {
|
|
|
27902
27943
|
const detected = detectEmailFromAgyLogs(home);
|
|
27903
27944
|
if (detected) email = detected;
|
|
27904
27945
|
}
|
|
27946
|
+
if (email && email !== account.email) this.pool.resetAccountIdentity(account.id, email);
|
|
27905
27947
|
const accessToken = await this.getValidAccessToken(account);
|
|
27906
|
-
if (!accessToken)
|
|
27907
|
-
if (email && email !== account.email) this.pool.updateAccountQuotas(account.id, account.quotas, email);
|
|
27908
|
-
return null;
|
|
27909
|
-
}
|
|
27948
|
+
if (!accessToken) return null;
|
|
27910
27949
|
const [summary, discovered] = await Promise.all([this.fetchQuotaSummary(accessToken, account.proxyUrl), this.fetchAvailableModels(accessToken, account.proxyUrl)]);
|
|
27911
27950
|
if (!email) {
|
|
27912
27951
|
const info = await this.fetchUserInfo(accessToken, account.proxyUrl);
|
|
27913
27952
|
if (info?.email) email = info.email;
|
|
27914
27953
|
}
|
|
27915
|
-
if (!summary && (!discovered || !discovered.models))
|
|
27916
|
-
|
|
27917
|
-
return null;
|
|
27918
|
-
}
|
|
27954
|
+
if (!summary && (!discovered || !discovered.models)) return null;
|
|
27955
|
+
if (account.authRequired) this.pool.clearAuthRequired(account.id);
|
|
27919
27956
|
const familyQuotas = {};
|
|
27920
27957
|
if (summary && Array.isArray(summary.groups)) for (const group of summary.groups) {
|
|
27921
27958
|
const dName = (group.displayName || "").toLowerCase();
|
|
@@ -27960,11 +27997,11 @@ var QuotaService = class {
|
|
|
27960
27997
|
remainingFraction: remaining,
|
|
27961
27998
|
resetTime
|
|
27962
27999
|
});
|
|
27963
|
-
if (!familyQuotas[fam]) familyQuotas[fam] = {
|
|
28000
|
+
if (!familyQuotas[fam]) familyQuotas[fam] = mergeFallbackFamilyQuota(account.quotas[fam], {
|
|
27964
28001
|
remainingFraction: remaining,
|
|
27965
28002
|
resetTime,
|
|
27966
28003
|
updatedAt: now
|
|
27967
|
-
};
|
|
28004
|
+
});
|
|
27968
28005
|
else if (familyQuotas[fam].remainingFraction === void 0) {
|
|
27969
28006
|
const curRemaining = familyQuotas[fam].remainingFraction ?? 1;
|
|
27970
28007
|
if (remaining < curRemaining) {
|
|
@@ -27988,9 +28025,25 @@ var QuotaService = class {
|
|
|
27988
28025
|
* auth-quarantined / in cooldown) so the poller never keeps knocking on
|
|
27989
28026
|
* Google endpoints for accounts already known to be limited. Manual force
|
|
27990
28027
|
* refresh from the UI refreshes everything.
|
|
28028
|
+
*
|
|
28029
|
+
* Before gating, a ZERO-NETWORK identity reconciliation runs for flagged
|
|
28030
|
+
* slots: an external `agy logout` + re-login writes the new email into the
|
|
28031
|
+
* newest CLI logs, and detecting that locally lets us reset the stale
|
|
28032
|
+
* quarantine/cooldowns so the slot rejoins polling — no extra request to
|
|
28033
|
+
* Google is made for this check (risk-control neutral).
|
|
27991
28034
|
*/
|
|
27992
28035
|
async refreshAllQuotas(force = false) {
|
|
27993
|
-
|
|
28036
|
+
let accounts = this.pool.getAccounts();
|
|
28037
|
+
if (!force) {
|
|
28038
|
+
const now = Date.now();
|
|
28039
|
+
for (const acc of accounts) {
|
|
28040
|
+
const flagged = acc.authRequired || Object.values(acc.cooldowns).some((cd) => cd && cd.cooldownUntil > now);
|
|
28041
|
+
if (!acc.systemHome || !flagged) continue;
|
|
28042
|
+
const detected = detectEmailFromAgyLogs(acc.systemHome || !acc.dir ? homedir() : acc.dir);
|
|
28043
|
+
if (detected && detected !== acc.email) this.pool.resetAccountIdentity(acc.id, detected);
|
|
28044
|
+
}
|
|
28045
|
+
accounts = accounts.filter(shouldPollAccount);
|
|
28046
|
+
}
|
|
27994
28047
|
await Promise.allSettled(accounts.map((acc) => this.refreshAccountQuota(acc, force)));
|
|
27995
28048
|
}
|
|
27996
28049
|
};
|
|
@@ -28737,6 +28790,7 @@ function apply(ctx, entryConfig = {}) {
|
|
|
28737
28790
|
const acc = pool.getAccount(id);
|
|
28738
28791
|
if (acc) await quota.refreshAccountQuota(acc, true);
|
|
28739
28792
|
} else await quota.refreshAllQuotas(true);
|
|
28793
|
+
catalog.forceRefresh().catch(() => void 0);
|
|
28740
28794
|
sendJson(res, 200, {
|
|
28741
28795
|
ok: true,
|
|
28742
28796
|
pool: pool.getPoolData()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agy-link",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.18",
|
|
4
4
|
"description": "Google Antigravity (agy CLI) models for DeepSeek Harness — stream Gemini/Claude/GPT-OSS subscriptions into DSH with thinking, tool activity, token usage and in-GUI Google OAuth login.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|