Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions gui/src/components/provider-workspace/ProviderAuthPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ export default function ProviderAuthPanel({
<ul className="pwi-auth-list">
{accounts.map(account => {
const label = oauthAccountDisplayLabel(accounts, account, t);
const planLabel = account.plan === "Free" ? t("modal.badge.free") : account.plan;
const switching = switchingAccountId === account.id;
const pausing = pausingAccountId === account.id;
const healthStatus = account.health?.status;
Expand All @@ -550,11 +551,11 @@ export default function ProviderAuthPanel({
<button type="button" className="pwi-auth-row-main"
onClick={() => { if (!account.active && !account.paused && !showReauth && !inCooldown && !switchingAccountId && !pausingAccountId) void authHandlers.onSwitchAccount(item.name, account); }}
aria-current={account.active ? "true" : undefined}
aria-label={`${label}${account.active ? ` — ${t("pws.accountCurrent")}` : ""}`}
aria-label={`${label}${planLabel ? ` — ${planLabel}` : ""}${account.active ? ` — ${t("pws.accountCurrent")}` : ""}`}
disabled={Boolean(account.paused || showReauth || inCooldown || switchingAccountId || pausingAccountId)}>
<span className={`pwi-auth-dot ${showReauth ? "pwi-auth-dot--warn" : account.active && !account.paused ? "pwi-auth-dot--ok" : "pwi-auth-dot--off"}`} aria-hidden="true" />
<span className="pwi-auth-row-copy">
<span className="pwi-auth-row-label">{label}</span>
<span className="pwi-auth-row-heading"><span className="pwi-auth-row-label">{label}</span>{planLabel && <span className="badge badge-green">{planLabel}</span>}</span>
<span className="pwi-auth-row-secondary">{[account.email, `${t("prov.accountId")}: ${maskedId}`].filter(Boolean).join(" · ")}</span>
{healthSummary && (
<span className="pwi-auth-row-secondary faint">{healthSummary}</span>
Expand Down
1 change: 1 addition & 0 deletions gui/src/components/provider-workspace/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type OAuthAccountRow = AccountQuotaReading & {
autoSelectable?: boolean;
skipReason?: "needs_reauth" | "paused" | "suspended" | "cooldown" | "quota_exhausted";
paused?: boolean;
plan?: string | null;
health?: { status: OAuthAccountHealthStatus; reason?: string; until?: string };
healthLabel?: string;
healthSummary?: string;
Expand Down
1 change: 1 addition & 0 deletions gui/src/hooks/useProviderAccountPools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface OAuthAccount extends AccountQuotaReading {
skipReason?: "needs_reauth" | "paused" | "suspended" | "cooldown" | "quota_exhausted";
paused?: boolean;
expiresAt?: number;
plan?: string | null;
health?: { status: "healthy" | "cooldown" | "reauth_required" | "warning"; reason?: string; until?: string };
healthLabel?: string;
healthSummary?: string;
Expand Down
1 change: 1 addition & 0 deletions gui/src/styles/provider-workspace-settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
.pwi-auth-row-main:disabled { cursor: default; opacity: 0.72; }
.pwi-auth-row-main:focus-visible,
.pwi-auth-row-remove:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; }
.pwi-auth-row-heading { display: flex; align-items: center; gap: 6px; max-width: 100%; min-width: 0; flex-wrap: wrap; }
.pwi-auth-row-label { font-size: var(--text-control); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pwi-auth-row-remove {
background: none; border: none; cursor: pointer; padding: 2px;
Expand Down
40 changes: 40 additions & 0 deletions gui/tests/provider-quota-refresh-controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,43 @@ test("changing active account discards the previous refresh feedback", async ()
expect(host.textContent).not.toContain("Quota check completed");
expect(findButton("Refresh quotas")?.disabled).toBe(false);
});

test("Antigravity plan badge localizes Free, preserves provider names, and leaves pause controls intact", async () => {
win.localStorage.setItem("ocx-lang", "zh");
const item = { ...oauthItem, name: "google-antigravity" };
const handlers = authHandlers({ onPauseAccount: () => {} });
const row = { id: "ag-1", active: true, paused: false };
const view = (plan: string | null, paused = false, pausingAccountId: string | null = null) =>
<ProviderAuthPanel item={item} apiBase="" accounts={[{ ...row, plan, paused }]}
pausingAccountId={pausingAccountId} authHandlers={handlers} />;
const main = () => host.querySelector(".pwi-auth-row-main") as HTMLButtonElement;
const badge = () => main().querySelector(".pwi-auth-row-copy .badge");

await render(view("Free"));
expect(badge()?.textContent).toBe("免费");
expect(main().getAttribute("aria-label")).toContain("免费");
expect(main().getAttribute("aria-label")).toContain("当前");

await render(view("Google AI Pro"));
expect(badge()?.textContent).toBe("Google AI Pro");
expect(main().getAttribute("aria-label")).toContain("Google AI Pro");

await render(view("Google AI Ultra", true));
expect(badge()?.textContent).toBe("Google AI Ultra");
expect(main().disabled).toBe(true);
expect(findButton("恢复")).not.toBeNull();

await render(view("Google AI Pro", false, row.id));
expect(badge()?.textContent).toBe("Google AI Pro");
expect(main().disabled).toBe(true);
const pause = findButton("暂停");
expect(pause?.disabled).toBe(true);
expect(pause?.getAttribute("aria-busy")).toBe("true");

await render(view(null));
expect(badge()).toBeNull();
expect(main().closest(".pwi-auth-acct")?.textContent).not.toMatch(/null/i);
expect(main().getAttribute("aria-label")).not.toMatch(/null/i);
expect(main().getAttribute("aria-label")).not.toContain("Google AI");
expect(main().getAttribute("aria-label")).not.toContain("免费");
});
63 changes: 53 additions & 10 deletions src/oauth/google-antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,43 @@ function extractProjectId(data: Record<string, unknown> | undefined): string | u
return undefined;
}

async function loadCodeAssistProject(accessToken: string, signal?: AbortSignal): Promise<string | undefined> {
/**
* Subscription tier reported by a loadCodeAssist `paidTier`: `free-tier` → "Free", an explicit
* `Google AI <name>` is carried verbatim, everything else (missing, malformed, unrecognised) is a
* truthful null — never a guess from `currentTier`, which is the bare product name even for free.
*/
function extractPaidTierPlan(data: Record<string, unknown>): string | null {
const paidTier = data.paidTier;
if (!paidTier || typeof paidTier !== "object") return null;
const tier = paidTier as { id?: unknown; name?: unknown };
if (tier.id === "free-tier") return "Free";
if (typeof tier.name !== "string" || /[\x00-\x1f\x7f-\x9f]/.test(tier.name)) return null;
const name = tier.name.trim();
return name.length <= 128 && /^Google AI [^\s\x00-\x1f\x7f-\x9f][^\x00-\x1f\x7f-\x9f]*$/.test(name) ? name : null;
}

/**
* loadCodeAssist discovery for an access token. A plan/project observation is returned only when
* the call succeeded and produced a parseable body. A fetch rejection propagates (so project
* discovery does not onboard); non-2xx and JSON parse failure yield no observation.
*/
async function loadCodeAssistDiscovery(accessToken: string, signal?: AbortSignal): Promise<{ projectId?: string; plan?: string | null }> {
const response = await fetch(`${PROD_API}/${API_VERSION}:loadCodeAssist`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json", "User-Agent": antigravityUserAgent() },
body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }),
signal: requestSignal(signal),
});
if (!response.ok) return undefined;
return extractProjectId((await response.json().catch(() => undefined)) as Record<string, unknown> | undefined);
if (!response.ok) return {};
let data: unknown;
try {
data = await response.json();
} catch {
return {};
}
if (!data || typeof data !== "object" || Array.isArray(data)) return { plan: null };
const projectId = extractProjectId(data as Record<string, unknown>);
return { ...(projectId ? { projectId } : {}), plan: extractPaidTierPlan(data as Record<string, unknown>) };
}

async function onboardProject(accessToken: string, signal?: AbortSignal): Promise<string | undefined> {
Expand Down Expand Up @@ -136,9 +164,23 @@ async function onboardProject(accessToken: string, signal?: AbortSignal): Promis
return undefined;
}

/**
* Discover the CCA project and reported subscription tier for an access token
* (loadCodeAssist → onboardUser fallback). The tier observation comes only from a successful,
* parseable loadCodeAssist response; onboarding itself adds no tier observation.
* Onboarding is attempted only when loadCodeAssist yielded no project, and its failure never
* discards a tier observation loadCodeAssist already made.
*/
export async function discoverAntigravityAccount(accessToken: string, signal?: AbortSignal): Promise<{ projectId?: string; plan?: string | null }> {
const discovery = await loadCodeAssistDiscovery(accessToken, signal);
if (discovery.projectId) return discovery;
const onboarded = await onboardProject(accessToken, signal).catch(() => undefined);
return { ...discovery, ...(onboarded ? { projectId: onboarded } : {}) };
}

/** Discover the CCA project for an access token (loadCodeAssist → onboardUser fallback). */
export async function discoverAntigravityProject(accessToken: string, signal?: AbortSignal): Promise<string | undefined> {
return (await loadCodeAssistProject(accessToken, signal)) ?? (await onboardProject(accessToken, signal));
return (await discoverAntigravityAccount(accessToken, signal)).projectId;
}

function credentialsFromPayload(payload: GoogleTokenPayload, refreshFallback = ""): OAuthCredentials {
Expand Down Expand Up @@ -207,13 +249,13 @@ class AntigravityOAuthFlow extends OAuthCallbackFlow {
}, this.ctrl.signal);
const creds = credentialsFromPayload(payload);
this.ctrl.onProgress?.("Discovering Cloud Code Assist project");
const projectId = await discoverAntigravityProject(creds.access, this.ctrl.signal);
if (!projectId) {
const discovery = await discoverAntigravityAccount(creds.access, this.ctrl.signal);
if (!discovery.projectId) {
// Fail the login rather than persisting a credential that every request would reject for a
// missing CCA project — otherwise status shows "logged in" while all calls fail closed.
throw new Error("Antigravity login could not discover a Cloud Code Assist project for this account. Ensure the account has Antigravity/Cloud Code Assist access and try again.");
}
return { ...creds, projectId };
return { ...creds, ...discovery };
}
}

Expand All @@ -230,9 +272,10 @@ export async function refreshAntigravityToken(refreshToken: string, signal?: Abo
refresh_token: refreshToken,
}, signal);
const creds = credentialsFromPayload(payload, refreshToken);
// Re-discover the project on refresh so a newly-onboarded account fills in projectId.
const projectId = await discoverAntigravityProject(creds.access, signal).catch(() => undefined);
return projectId ? { ...creds, projectId } : creds;
// Re-discover on refresh so a newly-onboarded account fills in projectId and a reported tier is
// refreshed. A fetch rejection must not fail token refresh or clear a previous plan.
const discovery = await discoverAntigravityAccount(creds.access, signal).catch(() => ({}));
return { ...creds, ...discovery };
}

/**
Expand Down
27 changes: 15 additions & 12 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede
source: fresh.source === "local-cli" ? "local-cli"
: previous.source === "local-cli" ? "oauth" : fresh.source ?? previous.source ?? "oauth",
...(fresh.projectId === undefined && previous.projectId ? { projectId: previous.projectId } : {}),
...(fresh.plan === undefined && previous.plan !== undefined ? { plan: previous.plan } : {}),
...(fresh.apiBaseUrl === undefined && previous.apiBaseUrl ? { apiBaseUrl: previous.apiBaseUrl } : {}),
...(fresh.email === undefined && previous.email ? { email: previous.email } : {}),
...(fresh.accountId === undefined && previous.accountId ? { accountId: previous.accountId } : {}),
Expand Down Expand Up @@ -1785,18 +1786,19 @@ export interface OAuthAccountSummary {
needsReauth?: boolean;
expiresAt?: number;
/**
* Subscription tier, mirroring the field the OpenAI/Codex provider reports, so a consumer
* weighting a multi-account pool by seat size needs no per-provider branching (#3777).
* Display-only subscription tier, mirroring the field the OpenAI/Codex provider reports.
*
* Always present and explicitly `null` when the tier is unknown. The distinction matters:
* an ABSENT key means the proxy is too old to report a tier at all, while `null` means this
* version looked and upstream did not say. Omitting it would make those indistinguishable and
* version has no recognized tier to display. Omitting it would make those indistinguishable and
* invite a consumer to assume a tier.
*
* Every OAuth provider reports `null` today. Anthropic's `/api/oauth/usage` returns quota
* buckets only — `five_hour`, `seven_day`, the model-scoped weekly windows and `limits[]` —
* and carries no subscription/tier field, and its token response carries none either. See
* `fetchAnthropicUsageQuota` in `src/providers/quota.ts`.
* Google Antigravity can report a plan from a loadCodeAssist `paidTier` (see
* `extractPaidTierPlan` in `src/oauth/google-antigravity.ts`); every other provider reports
* `null`. Anthropic's `/api/oauth/usage` returns quota buckets only — `five_hour`, `seven_day`,
* the model-scoped weekly windows and `limits[]` — and carries no subscription/tier field, and
* its token response carries none either. See `fetchAnthropicUsageQuota` in
* `src/providers/quota.ts`.
*/
plan: string | null;
}
Expand All @@ -1820,11 +1822,12 @@ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn:
active: a.id === set.activeAccountId,
...(a.needsReauth ? { needsReauth: true } : {}),
expiresAt: a.credential.expires,
// Explicitly null rather than omitted — see OAuthAccountSummary.plan. No OAuth provider
// exposes a subscription tier today, so there is nothing truthful to put here; deriving one
// from quota percentages is not possible, because they are normalized per account and a
// half-consumed small seat is indistinguishable from a half-consumed large one.
plan: null,
// Explicitly null when the credential carries no observed tier — see OAuthAccountSummary.plan.
// Only Google Antigravity reports a paidTier today; other providers have nothing truthful to
// put here, and deriving one from quota percentages is not possible, because they are
// normalized per account and a half-consumed small seat is indistinguishable from a
// half-consumed large one.
plan: a.credential.plan ?? null,
}));

// A stored credential counts as "logged in" when it exists and is not marked for
Expand Down
16 changes: 14 additions & 2 deletions src/oauth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,12 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
if (typeof candidate.accountId === "string" && candidate.accountId.length > 0) normalized.accountId = candidate.accountId;
if (isCredentialSource(candidate.source)) normalized.source = candidate.source;
if (typeof candidate.projectId === "string" && candidate.projectId.length > 0) normalized.projectId = candidate.projectId;
if (candidate.plan !== undefined) {
if (typeof candidate.plan === "string" && !/[\x00-\x1f\x7f-\x9f]/.test(candidate.plan)) {
const plan = candidate.plan.trim();
normalized.plan = plan.length > 0 && plan.length <= 128 ? plan : null;
} else normalized.plan = null;
}
if (typeof candidate.apiBaseUrl === "string" && candidate.apiBaseUrl.length > 0) {
// Persist only allowlisted origins; drop anything else so auth.json cannot
// become an SSRF springboard across reloads. Copilot and Devin are the two
Expand Down Expand Up @@ -849,6 +855,12 @@ export interface OAuthCredentialWriteReceipt {
previousAccount: ProviderAccount | undefined;
}

function retainUnobservedPlan(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials {
return fresh.plan === undefined && previous.plan !== undefined
? { ...fresh, plan: previous.plan }
: fresh;
}

export async function saveCredentialWithReceipt(
provider: string,
cred: OAuthCredentials,
Expand All @@ -873,7 +885,7 @@ export async function saveCredentialWithReceipt(
} else if (identity) {
const existing = set.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity);
if (existing) {
existing.credential = safe;
existing.credential = retainUnobservedPlan(safe, existing.credential);
delete existing.needsReauth;
if (existing.paused !== true) set.activeAccountId = existing.id;
accountId = existing.id;
Expand Down Expand Up @@ -1140,7 +1152,7 @@ export async function saveAccountCredential(
const set = store[provider];
const account = set?.accounts.find(a => a.id === accountId);
if (!set || !account) return;
account.credential = safe;
account.credential = retainUnobservedPlan(safe, account.credential);
if (opts.rotateLoginId) account.loginId = randomUUID();
delete account.needsReauth;
// A pause that found no usable fallback leaves the active id on a paused row. Once this
Expand Down
6 changes: 6 additions & 0 deletions src/oauth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ export type OAuthCredentials = {
source?: OAuthCredentialSource;
/** Google Antigravity (Cloud Code Assist) discovered project id; injected into the CCA envelope. */
projectId?: string;
/**
* Google Antigravity subscription tier from loadCodeAssist `paidTier`; display-only, never used
* for routing or selection. Absent = no observation this round (retain previous); `null` = an
* observed "no recognized plan"; a string = the tier to show.
*/
plan?: string | null;
/**
* GitHub Copilot allowlisted API origin from token `endpoints.api` (HTTPS `*.githubcopilot.com` only).
* Never reuse for Antigravity projectId; validated on write and again at request time.
Expand Down
Loading
Loading