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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
.personal-codex-accounts header > span, .codex-account-effect, .personal-codex-accounts details, .codex-account-actions > span { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); }
.codex-account-enable { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 12px 0; border-top: 1px solid var(--pw-line); border-bottom: 1px solid var(--pw-line); }
.codex-account-enable input { accent-color: var(--pw-text); width: 18px; height: 18px; }
.codex-account-enable > label { display: flex; align-items: center; gap: 12px; flex: 1; }
.codex-account-enable .context-help-trigger { border: 0; background: transparent; padding: 0; width: 44px; }
.codex-account-state { display: flex; gap: 8px; align-items: center; font-size: var(--text-body); }
.codex-account-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.codex-account-list article { display: flex; flex-direction: column; align-items: flex-start; gap: 16px; padding-top: 16px; border-top: 2px solid var(--pw-line); min-width: 0; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { RefreshCw, CheckCircle2, Circle, AlertCircle } from "lucide-react";
import { requestJson } from "../../data/chat";
import { useWorkspaceI18n } from "./i18n";
import { ContextHelp } from "./context-help";
import "./codex-account-settings.css";

type AccountStatus = {
Expand All @@ -20,6 +21,7 @@ const copy = {
"zh-CN": {
title: "Codex 原生账号", enabled: "自动接续已启用", disabled: "自动接续已关闭",
enable: "额度耗尽后自动切换", refresh: "检查额度", retry: "重新连接",
selectionHelp: "自动选择顺序", selectionDetail: "优先选择剩余额度比例最多的可用账号;短、长周期取剩余较少的一项。平手从当前账号之后按已登记顺序选择。这里比较的是额度百分比,无法获知绝对剩余 token。当前账号可用时不会主动切换。",
effect: "仅使用下方已登记账号。额度耗尽后正常退出 GPT 专用客户端,保存登录态并切换,再重新打开原来的界面。",
effectWait: "仅使用下方已登记账号。额度耗尽后等待客户端关闭,再保存登录态、切换账号并重新打开。",
restart: "允许自动退出并重开 GPT 客户端", restarting: "正在退出 GPT、切换账号并重新打开",
Expand Down Expand Up @@ -47,6 +49,7 @@ const copy = {
en: {
title: "Native Codex accounts", enabled: "Automatic handoff enabled", disabled: "Automatic handoff off",
enable: "Switch automatically when quota runs out", refresh: "Check quota", retry: "Reconnect",
selectionHelp: "Automatic selection order", selectionDetail: "Prefer the available account with the most remaining allowance, using the lower remaining percentage across its reported windows. Ties follow the registered order after the current account. These percentages do not establish absolute tokens left. An available current account is not preempted.",
effect: "Use only the registered accounts below. After exhaustion, quit the dedicated GPT client normally, save the login, switch accounts, then reopen the same interface.",
effectWait: "Use only the registered accounts below. After exhaustion, wait for clients to close, save the login, switch accounts, then reopen the same interface.",
restart: "Allow automatic GPT client quit and restart", restarting: "Quitting GPT, switching accounts and reopening",
Expand Down Expand Up @@ -114,9 +117,11 @@ export function CodexAccountSettings() {
{(problem && problem !== status?.action) || status?.available === false ? <p role="alert">{label(problem)}</p> : null}
{status?.available && status.slots?.length ? <>
<p className="codex-account-effect">{restartClients ? forceQuitClients ? c.effectForce : c.effect : c.effectWait}</p>
<label className="codex-account-enable"><input type="checkbox" checked={enabled} disabled={busy}
<div className="codex-account-enable"><label><input type="checkbox" checked={enabled} disabled={busy}
onChange={event => void act("configure", { expected_revision: status.revision, enabled: event.target.checked,
slots: status.slots })} /><span>{c.enable}</span></label>
<ContextHelp label={c.selectionHelp}><p>{c.selectionDetail}</p></ContextHelp>
</div>
<label className="codex-account-enable"><input type="checkbox" checked={restartClients} disabled={busy}
onChange={event => void act("configure", { expected_revision: status.revision, enabled,
restart_clients: event.target.checked, force_quit_clients: event.target.checked && forceQuitClients,
Expand All @@ -133,15 +138,16 @@ export function CodexAccountSettings() {
{status.slots.map(slot => {
const row = status.accounts?.find(a => a.slot === slot);
// Expired observations remain unknown in the UI, just as in policy.
const fresh = row && Date.now() / 1000 - row.observed_at <= 120;
const age = row ? Date.now() / 1000 - row.observed_at : null;
const fresh = age != null && age >= 0 && age <= 120;
const current = status.active_slot === slot;
const remaining = (used: number | null | undefined) => fresh && used != null ? `${100 - used}%` : "—";
return <article className={current ? "is-current" : ""} key={slot}>
<div className="codex-account-name"><strong>{c.slot} {slot.toUpperCase()}</strong>
<span>{current ? c.current : status.target === slot ? c.next : label(row?.availability === "auth_required" ? "auth_required" : fresh ? row?.availability : "unknown")}</span></div>
<dl><div><dt>{c.primary}</dt><dd>{remaining(row?.primary_used)}</dd></div>
<div><dt>{c.secondary}</dt><dd>{remaining(row?.secondary_used)}</dd></div></dl>
{fresh && row.credits_available ? <span>{c.credits}</span> : null}
{fresh && row?.credits_available ? <span>{c.credits}</span> : null}
{!fresh ? <span>{c.unread}</span> : null}
{!current ? <button type="button" disabled={busy || Boolean(status.recovery_required) || (!status.policy?.slots.includes(slot) && Boolean(status.policy?.slots.length))}
onClick={() => void act("switch", { manual_slot: slot, expected_selection: status.selection_revision,
Expand Down
26 changes: 24 additions & 2 deletions packages/loopx-codex-provider-routing/NATIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,16 @@ remain unknown. Login errors, generic 429, query failure and measured exhaustion
are distinct. The observer never buys credits or uses reset coupons.

Only fresh active-account exhaustion starts automatic selection. Destinations
must be allowlisted and freshly available. Selection follows the configured
ring with a five-minute cooldown. An external native/manual choice wins and
must be allowlisted and freshly available. Automatic selection prefers the
largest remaining subscription percentage, taking the lower remaining value
across each account's reported short and long windows. For example, 99% short
and 5% long ranks below 80% short and 55% long. Equal values keep the configured
ring order after the active account; a single reported window uses that window.
These observations do not establish absolute remaining tokens or equal plan
capacities. Positive credits retain their existing availability semantics but
do not count as additional subscription percentage. An available current account
is never preempted. Switching retains the five-minute cooldown.
An external native/manual choice wins and
starts a cooldown. Busy clients defer; refused/timed-out clients and remaining
file owners pause further shutdowns. Changed clients and other repeated failures
stop after three attempts until an explicit check/reconnect. Successful quota
Expand All @@ -134,6 +142,20 @@ reads, not model probes. The managed extension's enable/disable controls its
permissionless qualification protocol; the native host has this separate
explicit opt-in and stop lifecycle.

Version 0.12.3 changes candidate ordering for an already enabled observer; it
does not enable handoff, client restart or force quit. The settings help explains
the percentage comparison on hover, keyboard focus or tap. The six-account
packaged views below use the production HTTP service, private state store and
TypeScript policy with synthetic quota and a simulated busy native transport.
They qualify presentation and selection readback, not a real credential swap:
B has 99% short-window but 5% long-window allowance; C has 80% and 55%, so C is
selected while A stays current until the existing owner admits switching.

![Remaining-first account selection](docs/assets/native-accounts-quota-desktop.png)

[Narrow account settings](docs/assets/native-accounts-quota-narrow.png) ·
[Selection help on demand](docs/assets/native-accounts-quota-help.png)

An intent is durable before the effect. A restart-authorized switch runs in a
separate POSIX session and writes a private completion receipt, so quitting the
calling Codex App does not kill the switch/restart or its result channel. A
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion packages/loopx-codex-provider-routing/extension.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema_version = "loopx_extension_manifest_v0"
id = "loopx-codex-provider-routing"
version = "0.12.2"
version = "0.12.3"
requires_loopx_api = ">=1,<2"
permissions = []

Expand Down
2 changes: 1 addition & 1 deletion packages/loopx-codex-provider-routing/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "loopx-codex-provider-routing"
version = "0.12.2"
version = "0.12.3"
description = "Native Codex account slots and exhausted-account handoff for LoopX"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,50 @@ test("only measured exhaustion switches; default-off preserves the selected acco
}
});

test("automatic handoff prefers more remaining allowance over the next ring slot", () => {
assert.equal(decide(input({ observations: [sample("a", 100), sample("b", 70), sample("c", 10)] })).target, "c");
});

test("the limiting reported window determines remaining allowance, not a short-window average", () => {
const windows = (slot, primary, secondary) => ({ slot, observed_at: now, status: "ok", usage: {
rateLimitsByLimitId: { codex: { primary: { usedPercent: primary, resetsAt: 2000 },
...(secondary == null ? {} : { secondary: { usedPercent: secondary, resetsAt: 9000 } }) } },
} });
// B has 99% short-window allowance but only 5% long-window allowance.
// C's limiting window has 55% left, so it can sustain more subscription work.
assert.equal(decide(input({ observations: [sample("a", 100), windows("b", 1, 95), windows("c", 20, 45)] })).target, "c");
// A provider reporting only one valid window remains eligible on that window.
assert.equal(decide(input({ observations: [sample("a", 100), windows("b", 20, 70), windows("c", 50, null)] })).target, "c");
});

test("equal remaining allowance preserves the ring after the active account", () => {
const result = decide(input({ active: "b", observations: [sample("a", 20), sample("b", 100), sample("c", 20)] }));
assert.equal(result.target, "c", "The tie follows C then A, not observation order or alphabetic order");
});

test("unknown, stale, future and disallowed quota never outrank a measured candidate", () => {
for (const candidate of [
{ ...sample("c", 0), observed_at: now - 121 },
{ ...sample("c", 0), observed_at: now + 1 },
{ slot: "c", observed_at: now, status: "unavailable" },
{ slot: "c", observed_at: now, status: "ok", usage: { rate_limit: { primary_window: { used_percent: null } } } },
]) assert.equal(decide(input({ observations: [sample("a", 100), sample("b", 70), candidate] })).target, "b");
assert.equal(decide(input({ policy: { enabled: true, slots: ["a", "b"], cooldown_seconds: 300 },
observations: [sample("a", 100), sample("b", 70), sample("c", 0)] })).target, "b");
});

test("credit-funded availability does not fabricate remaining subscription allowance", () => {
const creditFunded = sample("b", 100, { credits: { has_credits: true, balance: "20" } });
assert.equal(decide(input({ observations: [sample("a", 100), creditFunded, sample("c", 99)] })).target, "c");
assert.equal(decide(input({ observations: [sample("a", 100), creditFunded] })).target, "b",
"Credits retain their existing availability semantics when no fuller subscription is eligible");
});

test("quota priority neither preempts an available account nor overrides a manual choice", () => {
assert.equal(decide(input({ observations: [sample("a", 90), sample("b", 0), sample("c", 0)] })).action, "ready");
assert.equal(decide(input({ manual_slot: "b", observations: [sample("a", 100), sample("b", 95), sample("c", 0)] })).target, "b");
});

test("credits, null and multi-bucket facts retain their meaning", () => {
assert.equal(account(sample("a", 100, { credits: { has_credits: true, balance: "12.0" } }), now).availability, "available");
assert.equal(account({ ...sample("a"), usage: {} }, now).availability, "unknown");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__all__ = ["__version__"]

__version__ = "0.11.0"
__version__ = "0.12.3"
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,17 @@ export function decide(input: {
const allowed = input.policy.slots;
const index = allowed.indexOf(input.active);
const order = [...allowed.slice(index + 1), ...allowed.slice(0, index + 1)];
const target = (input.manual_slot ? [input.manual_slot] : order).find(slot =>
slot !== input.active && allowed.includes(slot) && (input.manual_slot
|| accounts.some(a => a.slot === slot && a.availability === "available")));
const candidates = order.flatMap(slot => {
const candidate = accounts.find(a => a.slot === slot && a.availability === "available");
const used = [candidate?.primary_used, candidate?.secondary_used].filter(number);
return slot !== input.active && candidate && used.length
? [{ slot, remaining: 100 - Math.max(...used) }] : [];
});
// The most constrained reported window limits subscription work. Stable sort
// retains the configured ring for ties; missing observations never rank.
const target = input.manual_slot
? [input.manual_slot].find(slot => slot !== input.active && allowed.includes(slot))
: candidates.sort((a, b) => b.remaining - a.remaining)[0]?.slot;
if (target) return output("switch", target);
const resets = accounts.filter(a => allowed.includes(a.slot) && a.availability === "exhausted")
.flatMap(a => a.reset_at && a.reset_at > input.now ? [a.reset_at] : []);
Expand Down
32 changes: 32 additions & 0 deletions tests/presentation/test_codex_accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,38 @@ def test_unknown_quota_is_not_exhaustion(native, tmp_path):
assert not [call for call in transport.calls if call[0] == "switch"]


def test_more_remaining_quota_survives_busy_deferral_and_selects_once(native, tmp_path):
class ThreeAccounts(NativeTransport):
def __call__(self, adapter, operation, **parameters):
if operation == "observe":
self.calls.append((operation, parameters))
return {"observations": [{"slot": slot, "observed_at": self.clock(), "status": "ok", "usage": {
"rateLimitsByLimitId": {"codex": {
"primary": {"usedPercent": primary, "resetsAt": 8000},
"secondary": {"usedPercent": secondary, "resetsAt": 9000},
"credits": {"hasCredits": False},
}}}} for slot, primary, secondary in [("a", 100, 20), ("b", 1, 95), ("c", 20, 45)]]}
value = super().__call__(adapter, operation, **parameters)
return value | {"slots": ["a", "b", "c"]} if operation == "status" else value

clock = Clock()
transport = ThreeAccounts(clock)
provider = native.NativeAccounts(tmp_path, transport=transport, clock=clock)
provider.configure(expected_revision=0, enabled=True, slots=["a", "b", "c"])
transport.busy = True
waiting = provider.tick()
assert waiting["action"] == "waiting_for_idle" and waiting["target"] == "c"
assert transport.active == "a"
assert json.loads(provider.path.read_text())["intent"]["target"] == "c"
transport.busy = False
clock.now += 61
assert provider.tick()["active_slot"] == "c"
clock.now += 301
assert provider.tick()["action"] == "ready"
assert [parameters["slot"] for operation, parameters in transport.calls if operation == "switch"] == ["c", "c"]
assert provider.path.stat().st_mode & 0o077 == 0


def test_busy_waits_then_switches_at_safe_boundary(native, tmp_path):
provider, transport, clock = configured(native, tmp_path)
transport.busy = True
Expand Down
Loading