Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ Cursor, Kiro, Zed Hosted AI and Nous Portal are login-only and have no API-key e
login-only too: `ocx login google-antigravity` signs in with your Google account over the Cloud Code
Assist wire, and the `google` preset beside it is the AI Studio Gemini API — a different product
reached with its own key, not a key mode for the same login.
The Antigravity Accounts tab shows an observed membership tier as a badge beside the account
label, using the upstream tier ID (for example `free-tier` or `gemini-pro`). No badge appears
when the plan is unknown; it does not affect routing or quota.

Gemini 4 Argon (`gemini-4-argon`) is listed on the `google` preset with low, medium (the
default), and high thinking levels. Google offers it on the paid Gemini API tier only, not the free
Expand Down
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 @@ -553,6 +553,7 @@ export default function ProviderAuthPanel({
<ul className="pwi-auth-list">
{accounts.map(account => {
const label = oauthAccountDisplayLabel(accounts, account, t);
const planLabel = item.name === "google-antigravity" ? account.plan : undefined;
const switching = switchingAccountId === account.id;
const pausing = pausingAccountId === account.id;
const healthStatus = account.health?.status;
Expand All @@ -567,11 +568,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 @@ -65,6 +65,7 @@ export type OAuthAccountRow = AccountQuotaReading & {
autoSwitchThresholdOverride?: number | null;
autoSwitchThreshold?: number;
effectiveAutoSwitchThreshold?: number;
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 @@ -27,6 +27,7 @@ export interface OAuthAccount extends AccountQuotaReading {
autoSwitchThreshold?: number;
effectiveAutoSwitchThreshold?: number;
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: var(--space-1-5); 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
47 changes: 47 additions & 0 deletions gui/tests/provider-quota-refresh-controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,50 @@ 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 badges expose canonical tier IDs across locales and preserve account controls", async () => {
win.localStorage.setItem("ocx-lang", "zh");
const item = { ...oauthItem, name: "google-antigravity" };
const calls: string[] = [];
const handlers = authHandlers({
onPauseAccount: (_provider, _account, paused) => { calls.push(paused ? "pause" : "resume"); },
onReauth: (_provider, id) => { calls.push(id!); },
});
const row = { id: "ag-1", active: true, paused: false };
const view = (plan?: string | null, paused = false, needsReauth = false, pausingAccountId: string | null = null) =>
<ProviderAuthPanel item={item} apiBase="" accounts={[{ ...row, plan, paused, needsReauth }]}
pausingAccountId={pausingAccountId} authHandlers={handlers} />;
const main = () => host.querySelector(".pwi-auth-row-main") as HTMLButtonElement;
const badge = () => main().querySelector(".pwi-auth-row-heading .badge");
const control = (text: string) => Array.from(host.querySelectorAll("button")).find(button => button.textContent?.trim() === text) as HTMLButtonElement;

for (const plan of ["free-tier", "gemini-pro", "gemini-ultra", "standard-tier"]) {
await render(view(plan));
expect(badge()?.textContent).toBe(plan);
expect(main().getAttribute("aria-label")).toContain(` — ${plan} — `);
expect(main().getAttribute("aria-current")).toBe("true");
expect(main().disabled).toBe(false);
}
await act(async () => { control("暂停")!.click(); });
expect(calls).toEqual(["pause"]);

await render(view("gemini-pro", true));
expect(badge()?.textContent).toBe("gemini-pro");
expect(main().disabled).toBe(true);
await act(async () => { control("恢复")!.click(); });
expect(calls).toEqual(["pause", "resume"]);

await render(view("gemini-pro", false, true));
expect(main().disabled).toBe(true);
await act(async () => { control("重新认证")!.click(); });
expect(calls).toEqual(["pause", "resume", "ag-1"]);

await render(view("gemini-pro", false, false, row.id));
expect(control("暂停")?.disabled).toBe(true);
expect(control("暂停")?.getAttribute("aria-busy")).toBe("true");
for (const plan of [null, undefined]) {
await render(view(plan));
expect(badge()).toBeNull();
expect(main().getAttribute("aria-label")).not.toMatch(/null|undefined|gemini-pro/);
}
});
2 changes: 1 addition & 1 deletion structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ per-request first-party callback reads that live object; a failed write leaves i
| Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=<id>` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. |
| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the nine operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. An omitted `inferContextWindow` likewise keeps the latest live same-name state after asynchronous validation, including `false` or a concurrent clear; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning`, `preserveResponsesReasoningContent` and `inferContextWindow` accept a boolean or `null` to clear them, rejecting other types. `GET /api/providers` exposes configured `inferContextWindow` as an optional boolean without substituting a default; its [custom-only, default-off inference policy](catalog-vendor-context.md) is unchanged. Custom Responses provider forms expose `preserveResponsesReasoningContent` as a default-off plaintext replay switch; unrelated settings saves preserve omission and curated/OpenAI destinations keep their existing defaults. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. |
| Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. Inventory already drifted from disk uses a detached policy projection: arrivals remain disabled in management rows without changing live config or disk. |
| OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. |
| OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. Google Antigravity account rows in `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` display the account-summary `plan` as a badge and include it in the account button’s accessible name. The value is the canonical upstream tier ID (for example `free-tier` or `gemini-pro`), preserved literally across locales rather than inferred as a product name. Absent plans render no badge; pause and reauthentication controls keep their existing behavior. |
| Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` instead requires an operator-paired session because it durably redirects local client traffic, and also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` and typed `joinDenied` causes only to GUI-session callers, distinguishing pairing, runtime-role and port refusals without changing the admin/CLI DTO, so the dashboard enables the Child role only when the join gates pass; `GET /api/link/status`, `POST /api/link/{id}/reconnect` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress, and reconnect restarts a Home-initiated link's tunnel now; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. |
| OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. |
| Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. |
Expand Down
Loading