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 @@ -12,9 +12,10 @@ type AccountStatus = {
active_slot?: string | null; selection_revision?: string; action?: string;
target?: string | null; recovery_required?: boolean; launch_failed?: boolean;
api_fallback_supported?: boolean; api_active?: boolean;
steward_api_role_supported?: boolean;
observer?: { state: string; checked_at: number | null; next_check_at: number | null };
policy?: { enabled: boolean; slots: string[]; cooldown_seconds: number; restart_clients?: boolean;
force_quit_clients?: boolean; api_fallback?: boolean };
force_quit_clients?: boolean; api_fallback?: boolean; steward_api_role?: "disabled" | "fallback" | "primary" };
slots?: string[]; last_checked_at?: number | null; last_switch_at?: number | null;
accounts?: Array<{ slot: string; availability: string; observed_at: number;
primary_used: number | null; secondary_used: number | null; reset_at: number | null;
Expand Down Expand Up @@ -174,6 +175,7 @@ export function CodexAccountSettings() {
method: "POST", body: JSON.stringify({ operation, parameters }),
});
setStatus(result);
if (operation === "configure") await read();
} catch { await read(); setError(true); }
finally { setBusy(false); void readInventory(); }
}
Expand All @@ -188,6 +190,11 @@ export function CodexAccountSettings() {
const appProcesses = new Map<string, number[]>();
for (const client of appHolders) appProcesses.set(client.process, [...(appProcesses.get(client.process) ?? []), client.pid]);
return <><OpenAIKeySettings automatic={enabled} supported={status ? status.api_fallback_supported === true : null}
stewardSupported={status?.steward_api_role_supported === true}
stewardRole={status?.policy?.steward_api_role ?? (status?.policy?.api_fallback ? "fallback" : "disabled")}
onStewardRole={value => void act("configure", {
expected_revision: status?.revision, enabled, slots: authorizedSlots, steward_api_role: value,
})}
apiEnabled={status?.policy?.api_fallback === true} nativeBusy={busy || ["checking", "restarting", "handoff_pending"].includes(status?.action ?? "")} onToggle={value => void act("configure", {
expected_revision: status?.revision, enabled, slots: authorizedSlots, api_fallback: value,
})} /><section className="personal-codex-accounts" data-testid="codex-account-settings" aria-busy={busy}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ type KeyStatus = z.infer<typeof statusSchema>;

const copy = {
"zh-CN": {
title: "OpenAI API Key", configured: "已保存", boundary: "订阅账号优先,全部耗尽后使用 API",
help: "API Key 与额度", detail: "密钥只保存在本机,不回显完整值。启用 API 接续后,只有订阅账号均被确认耗尽才使用 API;订阅恢复后在安全边界回到订阅。API 额度或权限错误会在输出前尝试下一把 Key,保留当前模型,不重放已开始输出的请求。检查连接只读取模型列表,不能证明可消费额度。项目 Key 无法查询账单余额,费用查询需另有 Admin Key。",
auto: "订阅耗尽后使用 API(按用量计费)", autoOff: "需先启用下方的订阅自动切换", ownerMissing: "LoopX 账号扩展需要更新,暂不能启用 API 接续",
title: "OpenAI API Key", configured: "已保存", boundary: "工作 Agent 与 Codex App:订阅优先,API 仅作兜底",
help: "API Key 与额度", detail: "密钥只保存在本机,不回显完整值。工作 Agent 只有在全部订阅被确认耗尽后才使用 API,额度未知时等待。管家可独立优先使用 API,复用同一 Key 池,不切换工作 Agent 或 Codex App 的登录;关闭管家 API 不影响工作 Agent 的兜底。管家选择跟随订阅兜底时,使用下方订阅自动切换的耗尽证明。策略在下一条未开始的消息生效,不重放已有请求。API 额度或权限错误只在输出前尝试下一把 Key,模型不变。检查连接只读模型列表,不能证明可消费额度;项目 Key 无法查询账单余额。",
auto: "工作 Agent 订阅耗尽后使用 API(按用量计费)", autoOff: "需先启用下方的订阅自动切换", ownerMissing: "LoopX 账号扩展需要更新,暂不能启用 API 接续",
steward: "管家使用 API", primary: "优先使用(按用量计费)", fallback: "跟随订阅兜底", disabled: "不使用",
lastRequest: "上次响应", accepted: "API 已接受请求", quota_exhausted: "API 额度不足", failed: "API 请求失败", tokens: "限流窗口 Token 余量", requests: "限流窗口请求余量",
apiPermission: "API 模型权限不足", apiLimited: "API 请求限流",
balance: "API 余额不可读取", billing: "查看 OpenAI 用量", add: "添加 Key", save: "保存 Key", cancel: "取消",
Expand All @@ -37,9 +38,10 @@ const copy = {
key_limit_reached: "最多保存 16 把 Key。", key_not_found: "这把 Key 已被移除,列表已刷新。",
},
en: {
title: "OpenAI API keys", configured: "saved", boundary: "Subscriptions first; API only after all subscriptions are exhausted",
help: "API keys and quota", detail: "Keys stay on this machine and are never returned in full. API fallback is admitted only after every subscription is confirmed exhausted. Recovered subscriptions take priority at a safe handoff boundary. API quota or permission errors can try the next key before output, preserving the model. Requests that started output are never replayed. Connection checks only read the model list and do not establish spendable quota. Project keys cannot query billing balance; the Costs API requires a separate Admin key.",
auto: "Use API after subscription exhaustion (usage based billing)", autoOff: "Enable subscription automatic handoff below first", ownerMissing: "Update the LoopX accounts extension before enabling API fallback",
title: "OpenAI API keys", configured: "saved", boundary: "Working Agents and Codex App: subscriptions first; API as fallback",
help: "API keys and quota", detail: "Keys stay on this machine and are never returned in full. Working Agents use API only after every subscription is confirmed exhausted; unknown allowance holds. The steward can independently prefer API through the same key pool without changing worker or Codex App login. Disabling steward API preserves worker fallback. Following subscription fallback uses the native account owner's exhaustion proof. Changes apply to the next unstarted message and never replay existing requests. API quota or permission errors may try the next key only before output, preserving the model. Connection checks read models, not spendable quota. Project keys cannot query billing balance.",
auto: "Use API after working Agent subscriptions are exhausted (usage based billing)", autoOff: "Enable subscription automatic handoff below first", ownerMissing: "Update the LoopX accounts extension before enabling API fallback",
steward: "Steward API use", primary: "Prefer API (usage based billing)", fallback: "Follow subscription fallback", disabled: "Disabled",
lastRequest: "Last response", accepted: "API accepted the request", quota_exhausted: "API quota exhausted", failed: "API request failed", tokens: "Rate-window tokens left", requests: "Rate-window requests left",
apiPermission: "API model permission denied", apiLimited: "API request rate limited",
balance: "API balance unavailable", billing: "View OpenAI usage", add: "Add key", save: "Save key", cancel: "Cancel",
Expand All @@ -55,8 +57,11 @@ const copy = {
},
};

export function OpenAIKeySettings({ automatic, supported, apiEnabled, onToggle, nativeBusy = false }: {
export function OpenAIKeySettings({ automatic, supported, apiEnabled, onToggle, nativeBusy = false,
stewardSupported = false, stewardRole = "disabled", onStewardRole }: {
automatic: boolean; supported: boolean | null; apiEnabled: boolean; onToggle: (enabled: boolean) => void; nativeBusy?: boolean;
stewardSupported?: boolean; stewardRole?: "disabled" | "fallback" | "primary";
onStewardRole?: (role: "disabled" | "fallback" | "primary") => void;
}) {
const { locale } = useWorkspaceI18n();
const c = copy[locale];
Expand Down Expand Up @@ -112,6 +117,13 @@ export function OpenAIKeySettings({ automatic, supported, apiEnabled, onToggle,
disabled={busy || nativeBusy || (!apiEnabled && (!automatic || !supported || !status?.keys.length))}
onChange={event => onToggle(event.target.checked)} /><span>{c.auto}</span></label>
{!apiEnabled && supported === false ? <p className="codex-account-effect">{c.ownerMissing}</p> : !apiEnabled && !automatic ? <p className="codex-account-effect">{c.autoOff}</p> : null}
{stewardSupported ? <label className="codex-account-enable"><span>{c.steward}</span>
<select aria-label={c.steward} value={stewardRole}
disabled={busy || nativeBusy || !status}
onChange={event => onStewardRole?.(event.target.value as "disabled" | "fallback" | "primary")}>
<option value="disabled">{c.disabled}</option><option value="fallback">{c.fallback}</option>
<option value="primary" disabled={!status?.keys.length}>{c.primary}</option>
</select></label> : null}
{status ? <>
<div className="codex-account-list">
{status.keys.map(row => <article key={row.id}>
Expand Down
8 changes: 4 additions & 4 deletions loopx/canary/module_metric_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@
"dict_any_count": 0
},
"loopx/control_plane/goals/goal_frontier/terminal.py": {
"any_count": 15,
"any_count": 22,
"dict_any_count": 0
},
"loopx/control_plane/work_items/interaction_contract.py": {
"any_count": 85,
"any_count": 107,
"dict_any_count": 0
},
"loopx/extensions/lark/goal_topic_runtime.py": {
Expand All @@ -90,7 +90,7 @@
"dict_any_count": 0
},
"loopx/pr_review.py": {
"any_count": 49,
"any_count": 50,
"dict_any_count": 0
},
"loopx/presentation/renderers/status_markdown.py": {
Expand All @@ -103,7 +103,7 @@
"lines": 1999
},
"loopx/slash_command_install.py": {
"any_count": 11,
"any_count": 12,
"dict_any_count": 0
},
"loopx/state_refresh.py": {
Expand Down
4 changes: 4 additions & 0 deletions loopx/capabilities/native_chat/codex_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ def start(
reasoning_effort: str | None = None,
service_tier: str | None = None,
dynamic_tools: list[dict[str, Any]] | None = None,
account_runtime_root: Path | None = None,
account_consumer: str = "worker",
) -> "CodexAppServerAdapter":
history_items = []
# Migration restores the full visible conversation in native history.
Expand Down Expand Up @@ -358,6 +360,8 @@ def start(
reasoning_effort=reasoning_effort,
host_config={"service_tier": service_tier} if service_tier else None,
dynamic_tools=dynamic_tools,
account_runtime_root=account_runtime_root,
account_consumer=account_consumer,
)
if not resume_thread_id:
try:
Expand Down
84 changes: 84 additions & 0 deletions loopx/capabilities/native_chat/codex_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,87 @@ def for_isolated_process(base_home: Path, isolated_home: Path, codex_bin: str) -
if (isolated_home / "auth.json").exists() or not (base_home / "auth.json").exists():
return None
return CodexHostModelAuth(base_home, codex_bin)


@dataclass(repr=False)
class CodexApiTransport:
"""Only a consumer-scoped loopback token reaches the model process."""
endpoint: str
token: str = field(repr=False)
provider: str = "loopx_account_api"
env_key: str = "LOOPX_MODEL_RELAY_TOKEN"

def configuration(self, current: dict[str, Any], effective: dict[str, Any] | None = None) -> dict[str, Any]:
# Native overlays merge maps, so a lower-layer alias must not supply
# another URL/header/env key beneath our model-only transport.
if self.provider in (effective or {}).get("model_providers", {}):
raise CodexHostAuthUnavailable()
environment = current.get("shell_environment_policy", {})
exclude = [*environment.get("exclude", [])]
if self.env_key not in exclude:
exclude.append(self.env_key)
return {**current, "model_provider": self.provider, "model_providers": {
**current.get("model_providers", {}), self.provider: {
"name": "LoopX account API", "base_url": self.endpoint,
"env_key": self.env_key, "wire_api": "responses",
"requires_openai_auth": False, "supports_websockets": False}},
"shell_environment_policy": {**environment,
"exclude": exclude}}


def native_api_transport(runtime_root: Path, base_home: Path, *, consumer: str,
model: str | None = None) -> CodexApiTransport | None:
try:
from loopx_codex_provider_routing.native import NativeAccounts
except ImportError:
return None # Optional provider absence preserves native authentication.
try:
prepare = getattr(NativeAccounts(runtime_root), 'model_transport', None)
endpoint = prepare(codex_home=base_home, consumer=consumer, model=model) if callable(prepare) else None
return CodexApiTransport(endpoint['base_url'], endpoint['api_key']) if endpoint else None
except Exception:
raise CodexHostAuthUnavailable() from None


def prepare_api_transport(runtime_root: Path | None, home: Path, *, isolated_home: Path | None,
eligible: bool, consumer: str, model: str | None) -> CodexApiTransport | None:
if (runtime_root is None or not eligible or isolated_home is not None
and ((isolated_home / "auth.json").exists() or (isolated_home / "auth.json").is_symlink())):
return None
try:
return native_api_transport(runtime_root, home, consumer=consumer, model=model)
except CodexHostAuthUnavailable:
from ...chat_agent import CodexChatAgentError
raise CodexChatAgentError("Native account API transport is unavailable.",
error_code="model_auth_unavailable", gate=None) from None


def authenticate_isolated(session, home: Path, isolated: Path, binary: str, *, api_transport) -> CodexHostModelAuth | None:
if api_transport is not None:
return None
try:
broker = for_isolated_process(home, isolated, binary)
if broker is not None:
login = session._request("account/login/start", {
"type": "chatgptAuthTokens", **broker.read()}, request_id=4)
if login.get("type") != "chatgptAuthTokens":
raise ValueError("unexpected native model authentication mode")
return broker
except Exception:
raise session._runtime_error(
"Trusted-host Codex model authentication is unavailable. "
"Restore the host account and retry this same Session.") from None


def conversation_account_epoch(runtime_root: Path, home: Path, **consumer) -> tuple[str, str] | None:
try:
from loopx_codex_provider_routing.native import NativeAccounts
owner = NativeAccounts(runtime_root)
return owner.conversation_epoch(codex_home=home,
**(consumer if callable(getattr(owner, 'model_transport', None)) else {}))
except ImportError:
return None
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Native account epoch observation held (%s)", type(exc).__name__)
return None
5 changes: 5 additions & 0 deletions loopx/capabilities/native_chat/codex_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ def shared_chatgpt_transport(host_config: Mapping[str, Any], native_config: Mapp
}}


def verify_conversation_provider(session, requested: str, observed: Mapping[str, Any]) -> None:
if requested and observed.get("modelProvider") != requested:
raise session._runtime_error("Codex did not apply the requested conversation provider.")


def disable_mcp_servers(config: Mapping[str, Any], host_config: Mapping[str, Any]) -> dict[str, Any]:
"""Disable private native tool sources, including project/managed layers.

Expand Down
Loading
Loading