From e3abd27c8d12ca9c4fa380ab764deed1f740140e Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:28:29 +0800 Subject: [PATCH 01/15] feat(codex): add subscription-first OpenAI API fallback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../smoke/codex-account-clients-fixture.py | 35 ++- .../smoke/openai-key-settings-smoke.mjs | 129 ++++++++++ .../codex-account-settings.css | 7 + .../codex-account-settings.tsx | 15 +- .../openai-key-settings.tsx | 150 ++++++++++++ loopx/presentation/codex_accounts_api.py | 30 ++- loopx/presentation/configuration_api.py | 2 + .../extension.toml | 2 +- .../pyproject.toml | 2 +- .../smoke/native-policy.test.mjs | 12 + .../smoke/openai-policy.test.mjs | 18 ++ .../loopx_codex_provider_routing/__init__.py | 2 +- .../loopx_codex_provider_routing/native.py | 62 ++++- .../native_host.py | 12 + .../native_policy.ts | 17 +- .../openai_keys.py | 162 +++++++++++++ .../openai_policy.ts | 20 ++ .../openai_policy_bridge.ts | 8 + .../openai_relay.py | 223 ++++++++++++++++++ tests/presentation/test_openai_fallback.py | 92 ++++++++ tests/presentation/test_openai_keys.py | 154 ++++++++++++ tests/presentation/test_openai_relay.py | 134 +++++++++++ 22 files changed, 1264 insertions(+), 24 deletions(-) create mode 100644 apps/presentation/dashboard/smoke/openai-key-settings-smoke.mjs create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/openai-key-settings.tsx create mode 100644 packages/loopx-codex-provider-routing/smoke/openai-policy.test.mjs create mode 100644 packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_keys.py create mode 100644 packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy.ts create mode 100644 packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy_bridge.ts create mode 100644 packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_relay.py create mode 100644 tests/presentation/test_openai_fallback.py create mode 100644 tests/presentation/test_openai_keys.py create mode 100644 tests/presentation/test_openai_relay.py diff --git a/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py b/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py index 676ec5ed67..b615df0357 100644 --- a/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py +++ b/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py @@ -3,10 +3,12 @@ import importlib import json +import os from pathlib import Path import sys import tempfile import threading +import time repo = Path(__file__).resolve().parents[4] sys.path.insert(0, str(repo)) @@ -18,6 +20,9 @@ def main() -> None: native = importlib.import_module("loopx_codex_provider_routing.native") + keys_module = importlib.import_module("loopx_codex_provider_routing.openai_keys") + key_check = "models_readable" + native.NativeAccounts.api_keys = lambda self: keys_module.OpenAIKeys(self, checker=lambda _: key_check) native.discover = lambda: {"synthetic": True} clients = [ {"pid": 303, "kind": "unmanaged", "process": "virtual-machine", "restartable": False, @@ -31,12 +36,30 @@ def main() -> None: "restartable": True} for i in range(18)], ] mode, calls = "many", [] + api_fixture = os.environ.get("LOOPX_API_FALLBACK_FIXTURE") == "1" + active, selection, api_slot, recovered, now = "a", "synthetic", None, False, time.time() + if api_fixture: + relay_module = importlib.import_module("loopx_codex_provider_routing.openai_relay") + relay_module.ensure = lambda _: {"slot": "openai-api", "api_key": "synthetic-relay-token", "base_url": "http://127.0.0.1:32100/v1"} def transport(adapter, operation, **parameters): + nonlocal active, selection, api_slot calls.append(operation) if operation == "status": - return {"slots": ["a", "b", "c"], "active_slot": "a", "selection_revision": "synthetic", - "current_login_matches": True, "recovery_required": False} + return {"slots": ["a", "b", "c"], "active_slot": active, "selection_revision": selection, + "current_login_matches": True, "recovery_required": False, + "api_fallback_supported": api_fixture, "api_slot": api_slot} + if api_fixture and operation == "enroll_api": + api_slot = "openai-api" + return transport(adapter, "status") + if api_fixture and operation == "observe": + return {"observations": [{"slot": slot, "status": "ok", "observed_at": now, + "usage": {"rate_limit": {"primary_window": {"used_percent": 10 if recovered and slot == "b" else 100, + "reset_at": now + 3600}}, "credits": {"has_credits": False}}} for slot in parameters["slots"]]} + if api_fixture and operation == "switch": + assert parameters["expected_revision"] == selection + active, selection = parameters["slot"], selection + "-switched" + return transport(adapter, "status") | {"launch_failed": False} assert operation == "clients", "Reading settings cannot switch, signal, observe quota or recover" if mode == "unavailable": raise native.HostError("ownership_unverified") @@ -45,7 +68,7 @@ def transport(adapter, operation, **parameters): with tempfile.TemporaryDirectory(prefix="loopx-account-clients-") as directory: runtime = Path(directory).resolve() - provider = native.NativeAccounts(runtime, transport=transport) + provider = native.NativeAccounts(runtime, transport=transport, **({"clock": lambda: now} if api_fixture else {})) provider.configure(expected_revision=0, enabled=False, slots=["a", "b", "c"], restart_clients=True) before = provider.path.read_bytes() calls.clear() @@ -66,6 +89,12 @@ def transport(adapter, operation, **parameters): elif command in {"many", "sparse", "empty", "partial", "unavailable"}: mode = command print(json.dumps({"mode": mode}), flush=True) + elif command in {"key-models_readable", "key-authentication_failed", "key-permission_denied", "key-rate_limited", "key-unavailable"}: + key_check = command.removeprefix("key-") + print(json.dumps({"key_check": key_check}), flush=True) + elif command == "subscription-recovered" and api_fixture: + recovered, now = True, now + 301 + print(json.dumps({"subscription_recovered": True}), flush=True) else: raise ValueError("Unknown fixture command") finally: diff --git a/apps/presentation/dashboard/smoke/openai-key-settings-smoke.mjs b/apps/presentation/dashboard/smoke/openai-key-settings-smoke.mjs new file mode 100644 index 0000000000..3ffd7495d6 --- /dev/null +++ b/apps/presentation/dashboard/smoke/openai-key-settings-smoke.mjs @@ -0,0 +1,129 @@ +// Packaged settings, real HTTP and private store; synthetic OpenAI/native IO. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { createInterface } from "node:readline"; +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { resolveTestPython } from "../../../../scripts/test-python.mjs"; +import { cleanupBrowserSmoke, launchBrowser, loadPlaywright, waitForHttp } from "../../../../examples/dashboard-browser-smoke-support.mjs"; + +process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED = "1"; +process.env.LOOPX_API_FALLBACK_FIXTURE = "1"; +const { repoRoot, outputDir, port, startServer } = await import("../../../../examples/personal-workspace-browser/fixture.mjs"); +const { openWorkspacePage, fetchWorkspaceBackend } = await import("../../../../examples/personal-workspace-browser/scenario-context.mjs"); +const fixture = spawn(resolveTestPython({ repoRoot }), ["-u", "apps/presentation/dashboard/smoke/codex-account-clients-fixture.py"], + { cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] }); +const exited = once(fixture, "exit"); +let stderr = ""; +fixture.stderr.on("data", value => { stderr += String(value); }); +const iterator = createInterface({ input: fixture.stdout })[Symbol.asyncIterator](); +async function next() { + const line = await iterator.next(); + assert.equal(line.done, false, stderr); + return JSON.parse(line.value); +} +async function command(value) { fixture.stdin.write(`${value}\n`); return next(); } +let server, browser, workspace; +let loseNextAdd = false; +let addRequests = 0; +const syntheticKey = index => `sk-${"synthetic-only-".repeat(4)}${index}`; +try { + const { origin } = await next(); + server = await startServer(); + const url = `http://127.0.0.1:${port}/chat/?statusUrl=/status.json`; + await waitForHttp(url); + browser = await launchBrowser(loadPlaywright().chromium); + workspace = await openWorkspacePage({newPage: options => browser.newPage({locale: "zh-CN", ...options})}, url, { + beforeGoto(_api, page) { + return page.route("**/api/chat/codex-accounts**", async route => { + const request = route.request(); + const body = request.method() === "POST" ? request.postDataJSON() : null; + if (body?.operation === "keys-add") addRequests++; + const response = await fetchWorkspaceBackend(route, origin); + assert.ok(!(await response.text()).includes("synthetic-only-"), "No response may contain the complete key"); + if (body?.operation === "keys-add" && loseNextAdd) { + loseNextAdd = false; + await route.abort("failed"); + } else await route.fulfill({response}); + }); + }, + }); + const {page, api} = workspace; + await page.getByRole("button", {name: "设置", exact: true}).click(); + await page.locator(".personal-settings-tabs").getByRole("button", {name: "模型 Provider 配置", exact: true}).click(); + const panel = page.getByTestId("openai-key-settings"); + await panel.getByText("尚未添加 Key", {exact: true}).waitFor(); + await mkdir(outputDir, {recursive: true}); + await page.screenshot({path: resolve(outputDir, "openai-keys-empty-desktop.png"), animations: "disabled"}); + async function add(index, label) { + await panel.getByRole("button", {name: "添加 Key", exact: true}).click(); + await panel.getByLabel("名称", {exact: true}).fill(label); + await panel.getByLabel("API Key", {exact: true}).fill(syntheticKey(index)); + await panel.getByRole("button", {name: "保存 Key", exact: true}).click(); + await panel.getByText(label, {exact: true}).waitFor(); + if (await panel.locator('input[type="password"]').count()) { + assert.equal(await panel.locator('input[type="password"]').inputValue(), "", "A lost response must not retain a key draft"); + } + } + await add(1, "Primary project"); + await command("key-permission_denied"); + await panel.locator("article").first().getByRole("button", {name: "检查连接"}).click(); + await panel.getByText("无模型列表读取权限", {exact: true}).waitFor(); + await command("key-models_readable"); + await panel.locator("article").first().getByRole("button", {name: "检查连接"}).click(); + await panel.getByText("模型列表读取成功", {exact: true}).waitFor(); + await add(2, "第二个项目:用于检验很长的配置名称在窄屏上仍然完整可读"); + await command("key-rate_limited"); + await panel.locator("article").nth(1).getByRole("button", {name: "检查连接"}).click(); + await panel.getByText("检查被限流,余额仍未知", {exact: true}).waitFor(); + loseNextAdd = true; + await add(3, "Recovery project"); + await panel.getByRole("alert").waitFor(); + await panel.getByRole("button", {name: "取消", exact: true}).click(); + assert.equal(addRequests, 3, "A lost write response must be read back, never replayed"); + assert.equal(await panel.locator("article").count(), 3); + await page.screenshot({path: resolve(outputDir, "openai-keys-populated-desktop.png"), animations: "disabled"}); + const native = page.getByTestId("codex-account-settings"); + await native.getByRole("checkbox", {name: "额度耗尽后自动切换", exact: true}).click(); + await page.waitForFunction(() => document.querySelector('[data-testid="codex-account-settings"] input[type="checkbox"]')?.checked); + await panel.getByRole("checkbox", {name: "订阅耗尽后使用 API(按用量计费)", exact: true}).click(); + await page.waitForFunction(() => document.querySelector('[data-testid="openai-key-settings"] input[type="checkbox"]')?.checked); + await native.getByRole("button", {name: "检查额度", exact: true}).click(); + await native.locator("header").getByText(/当前 OPENAI-API/).waitFor(); + await panel.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "openai-fallback-active-desktop.png"), animations: "disabled"}); + await command("subscription-recovered"); + await native.getByRole("button", {name: "检查额度", exact: true}).click(); + await native.locator("header").getByText(/当前 B/).waitFor(); + await page.setViewportSize({width: 390, height: 844}); + await panel.scrollIntoViewIfNeeded(); + assert.ok(await page.locator("body").evaluate(body => body.scrollWidth <= innerWidth + 1)); + await page.screenshot({path: resolve(outputDir, "openai-keys-populated-narrow.png"), animations: "disabled"}); + await panel.getByRole("button", {name: "API Key 与额度", exact: true}).click(); + await page.getByText(/项目 Key 无法查询账单余额/).waitFor(); + await page.screenshot({path: resolve(outputDir, "openai-keys-help-narrow.png"), animations: "disabled"}); + await page.keyboard.press("Escape"); + await panel.locator("article").last().getByRole("button", {name: "移除", exact: true}).click(); + await page.waitForFunction(() => document.querySelectorAll('[data-testid="openai-key-settings"] article').length === 2); + await page.setViewportSize({width: 1512, height: 982}); + await page.locator(".personal-settings-tabs").getByRole("button", {name: "语言", exact: true}).click(); + await page.getByRole("radio", {name: "English", exact: true}).click(); + await page.locator(".personal-settings-tabs").getByRole("button", {name: "Model provider", exact: true}).click(); + await page.getByTestId("openai-key-settings").getByText("2 saved", {exact: true}).waitFor(); + const state = await command("inspect"); + assert.equal(state.state_unchanged, false, "Explicit handoff configuration must persist"); + assert.equal(state.watcher_started, true); + assert.equal(state.calls.filter(operation => operation === "switch").length, 2, "API fallback then recovered subscription return"); + assert.equal(api.turnRequests.length, 0); + // Deliberate lost-response network error is the only expected console error. + assert.ok(workspace.errors.every(error => error.includes("net::ERR_FAILED")), workspace.errors.join("\n")); + console.log(JSON.stringify({ok: true, boundary: "packaged UI / real HTTP, native policy and private store / synthetic process and OpenAI IO", added: 3, removed: 1, lostWriteReplayed: false, automaticSwitches: 2})); +} finally { + await workspace?.close(); + await browser?.close(); + fixture.stdin.end(); + if (fixture.exitCode === null) await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 5000))]); + if (fixture.exitCode === null) fixture.kill("SIGTERM"); + await cleanupBrowserSmoke({server, fixturePaths: []}); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.css b/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.css index 7dbbdb449a..b62d322964 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.css @@ -31,6 +31,13 @@ .codex-account-holders li > span { color: var(--pw-text); } .codex-account-holders .codex-account-actions { margin-top: 12px; } .personal-codex-accounts [role="alert"] { display: flex; gap: 8px; align-items: flex-start; color: var(--pw-red); } +.openai-key-settings { border-top: 1px solid var(--pw-line); padding-top: 24px; } +.openai-key-settings header > span { margin-left: auto; } +.openai-key-settings .codex-account-name { overflow-wrap: anywhere; } +.openai-key-settings form { display: grid; gap: 16px; max-width: 480px; } +.openai-key-settings form label { display: grid; gap: 8px; } +.openai-key-settings input { min-width: 0; min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); font: inherit; } +.openai-add-key, .openai-key-settings form > button { justify-self: start; } @media (max-width: 640px) { .codex-account-list { grid-template-columns: 1fr; gap: 20px; } .codex-account-list dl { grid-template-columns: 1fr 1fr; width: 100%; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx index 514f3e4909..7598bf2288 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx @@ -4,14 +4,16 @@ import { RefreshCw, CheckCircle2, Circle, AlertCircle } from "lucide-react"; import { requestJson } from "../../data/chat"; import { useWorkspaceI18n } from "./i18n"; import { ContextHelp } from "./context-help"; +import { OpenAIKeySettings } from "./openai-key-settings"; import "./codex-account-settings.css"; type AccountStatus = { ok: boolean; available: boolean; code?: string | null; revision?: number; 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; policy?: { enabled: boolean; slots: string[]; cooldown_seconds: number; restart_clients?: boolean; - force_quit_clients?: boolean }; + force_quit_clients?: boolean; api_fallback?: boolean }; 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; @@ -66,6 +68,8 @@ const copy = { clientsApp: "GPT 客户端", clientsService: "后台服务", clientsOther: "外部进程", clientsProcesses: "个进程", clientsDetail: "清单记录检查时的进程归属,切换时会重新核验。支持受控重启仍需已启用退出与重开权限;外部工作负载须先核实空闲及恢复条件。检查占用不读取凭证内容、不换号、不结束进程。", manager_unverified: "尚未核实管理器,保留占用等待", + api_active: "正在使用 OpenAI API,订阅恢复后优先返回", api_disabled_active: "API 接续已关闭,等待可用订阅", + api_owner_upgrade_required: "请更新原生槽位管理器以支持 API 接续", api_keys_missing: "请先添加 OpenAI API Key", api_relay_unavailable: "API 接续服务暂不可用,请重新检查", external_workload_unverified: "外部工作负载的空闲与恢复条件尚未核实", }, en: { @@ -101,6 +105,8 @@ const copy = { clientsApp: "GPT client", clientsService: "Background service", clientsOther: "External process", clientsProcesses: "processes", clientsDetail: "This inventory records process ownership at check time; switching revalidates it. Controlled restart still requires quit-and-reopen permission. External workloads need verified idle and recovery conditions. This check reads no credential contents, switches no account and ends no process.", manager_unverified: "Manager not verified; the holder remains a hold", + api_active: "Using OpenAI API; recovered subscriptions take priority", api_disabled_active: "API fallback is off; waiting for a subscription", + api_owner_upgrade_required: "Update the native slot manager for API fallback", api_keys_missing: "Add an OpenAI API key first", api_relay_unavailable: "API fallback transport unavailable; recheck", external_workload_unverified: "External workload idle and recovery conditions are unverified", }, }; @@ -171,7 +177,10 @@ export function CodexAccountSettings() { const appHolders = holders.filter(client => client.kind === "app" && client.restartable); const appProcesses = new Map(); for (const client of appHolders) appProcesses.set(client.process, [...(appProcesses.get(client.process) ?? []), client.pid]); - return
+ return <> void act("configure", { + expected_revision: status?.revision, enabled, slots: status?.slots ?? [], api_fallback: value, + })} />

{c.title}

{status?.active_slot ? `${c.current} ${status.active_slot.toUpperCase()} · ` : ""}{enabled ? c.enabled : c.disabled}
{!status ?

{c.checking}

: null} {(problem && problem !== status?.action) || status?.available === false ?

{label(problem)}

: null} @@ -251,5 +260,5 @@ export function CodexAccountSettings() { restart_clients: restartClients, force_quit_clients: forceQuitClients, })}>{c.retry} : null} -
; +
; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/openai-key-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/openai-key-settings.tsx new file mode 100644 index 0000000000..4f9c0a71e7 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/openai-key-settings.tsx @@ -0,0 +1,150 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { z } from "zod"; +import { ChatApiError, requestJson } from "../../data/chat"; +import { ContextHelp } from "./context-help"; +import { useWorkspaceI18n } from "./i18n"; + +const statusSchema = z.object({ + ok: z.literal(true), available: z.literal(true), revision: z.number().int().nonnegative(), + balance_available: z.literal(false), + keys: z.array(z.object({ + id: z.string(), label: z.string(), masked_key: z.string(), + check_status: z.enum(["not_checked", "models_readable", "authentication_failed", "permission_denied", "rate_limited", "unavailable"]), + checked_at: z.number().nullable(), + runtime: z.object({ state: z.enum(["accepted", "quota_exhausted", "authentication_failed", "permission_denied", "rate_limited", "failed"]), + observed_at: z.number(), model: z.string(), remaining_requests: z.number().optional(), remaining_tokens: z.number().optional(), + }).nullable(), + })), +}); +type KeyStatus = z.infer; + +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: "原生槽位管理器需要更新,暂不能启用 API 接续", + lastRequest: "上次响应", accepted: "API 已接受请求", quota_exhausted: "API 额度不足", failed: "API 请求失败", tokens: "限流窗口 Token 余量", requests: "限流窗口请求余量", + apiPermission: "API 模型权限不足", apiLimited: "API 请求限流", + balance: "API 余额不可读取", billing: "查看 OpenAI 用量", add: "添加 Key", save: "保存 Key", cancel: "取消", + name: "名称", key: "API Key", check: "检查连接", remove: "移除", reload: "刷新列表", + loading: "正在读取 Key 配置", empty: "尚未添加 Key", failure: "配置暂时无法读取,请重试。", + uncertain: "操作结果未确认,已重新读取列表。请先核对结果再操作。", + last: "上次检查", not_checked: "未检查", models_readable: "模型列表读取成功", + authentication_failed: "认证失败,请移除并更新 Key", permission_denied: "无模型列表读取权限", + rate_limited: "检查被限流,余额仍未知", unavailable: "连接检查失败,可重试", + configuration_changed: "配置已在别处更新,列表已刷新,请核对后重试。", duplicate_api_key: "这把 Key 已经保存。", + invalid_api_key: "请提供有效的 OpenAI 项目 Key,不接受 Admin Key。", invalid_key_label: "名称应为 1–80 个字符,不能包含密钥或换行。", + 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: "The native slot manager needs an update before API fallback can be enabled", + 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", + name: "Name", key: "API key", check: "Check connection", remove: "Remove", reload: "Refresh list", + loading: "Reading key configuration", empty: "No keys added", failure: "Key configuration is unavailable. Retry the read.", + uncertain: "The operation result was not confirmed. The list was read again; inspect it before another operation.", + last: "Last checked", not_checked: "Not checked", models_readable: "Model list read successfully", + authentication_failed: "Authentication failed; remove and replace this key", permission_denied: "Model list read permission denied", + rate_limited: "Check rate limited; balance is still unknown", unavailable: "Connection check failed; retry is available", + configuration_changed: "Configuration changed elsewhere. The list was refreshed; inspect it before retrying.", duplicate_api_key: "This key is already saved.", + invalid_api_key: "Provide an OpenAI project key. Admin keys are not accepted.", invalid_key_label: "Use a name of 1–80 characters without keys or line breaks.", + key_limit_reached: "At most 16 keys can be saved.", key_not_found: "This key was removed. The list was refreshed.", + }, +}; + +export function OpenAIKeySettings({ automatic, supported, apiEnabled, onToggle, nativeBusy = false }: { + automatic: boolean; supported: boolean | null; apiEnabled: boolean; onToggle: (enabled: boolean) => void; nativeBusy?: boolean; +}) { + const { locale } = useWorkspaceI18n(); + const c = copy[locale]; + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [adding, setAdding] = useState(false); + const [name, setName] = useState(""); + const [key, setKey] = useState(""); + const generation = useRef(0); + const endpoint = "/api/chat/codex-accounts/keys"; + const read = useCallback(async (clearError = false) => { + const request = ++generation.current; + try { + const result = statusSchema.parse(await requestJson(endpoint)); + if (request === generation.current) { + setStatus(result); + setError(previous => clearError || previous === "read" ? null : previous); + } + } catch { if (request === generation.current) {setStatus(null); setError("read");} } + }, []); + useEffect(() => { void read(); }, [read]); + useEffect(() => { + if (busy) return; + const timer = window.setInterval(() => { if (!document.hidden) void read(); }, 5000); + return () => window.clearInterval(timer); + }, [busy, read]); + + async function act(operation: string, parameters: Record) { + if (!status) return; + generation.current += 1; + setBusy(true); + setError(null); + try { + const next = statusSchema.parse(await requestJson(endpoint, { + method: "POST", body: JSON.stringify({ operation, parameters: { expected_revision: status.revision, ...parameters } }), + })); + setStatus(next); + if (operation === "keys-add") { setAdding(false); setName(""); } + } catch (failure) { + // Read back a possibly completed write; never replay a lost response. + await read(); + const code = failure instanceof ChatApiError ? failure.payload.error_code : null; + setError(typeof code === "string" && ["configuration_changed", "duplicate_api_key", "invalid_api_key", "invalid_key_label", "key_limit_reached", "key_not_found"].includes(code) ? code : "write"); + } finally { setBusy(false); } + } + + return
+

{c.title}

{status ? `${status.keys.length} ${c.configured}` : c.loading} +

{c.detail}

+

{c.boundary}

+ + {supported === false ?

{c.ownerMissing}

: !automatic ?

{c.autoOff}

: null} + {status ? <> +
+ {status.keys.map(row =>
+
{row.label}{row.masked_key}
+ {c[row.check_status]} + {row.runtime ? <>{row.runtime.state === "permission_denied" ? c.apiPermission : row.runtime.state === "rate_limited" ? c.apiLimited : c[row.runtime.state]} · {row.runtime.model} + {row.runtime.remaining_requests != null || row.runtime.remaining_tokens != null ? + {row.runtime.remaining_requests != null ? `${c.requests} ${row.runtime.remaining_requests}` : ""} + {row.runtime.remaining_tokens != null ? ` · ${c.tokens} ${row.runtime.remaining_tokens}` : ""} : null} + {c.lastRequest} {new Date(row.runtime.observed_at * 1000).toLocaleString(locale)} : null} + {row.checked_at != null ? {c.last} {new Date(row.checked_at * 1000).toLocaleString(locale)} : null} +
+ + +
+
)} +
+ {!status.keys.length ?

{c.empty}

: null} + {adding ?
{ + event.preventDefault(); + const submitted = key; + setKey(""); + void act("keys-add", { label: name, api_key: submitted }); + }}> + + +
+
+
: } +
{c.balance}{c.billing}
+ : null} + {error ? <>

{error === "write" ? c.uncertain : error === "read" ? c.failure : c[error as keyof typeof c]}

+ : null} +
; +} diff --git a/loopx/presentation/codex_accounts_api.py b/loopx/presentation/codex_accounts_api.py index 15a8cbdfaa..83b5a2909e 100644 --- a/loopx/presentation/codex_accounts_api.py +++ b/loopx/presentation/codex_accounts_api.py @@ -38,7 +38,11 @@ def call(self, operation: str, parameters: dict[str, Any]) -> dict[str, Any]: if self.provider is None: return {"ok": True, "available": False, "code": self.error} try: - if operation == "status": + if operation.startswith("keys-"): + if operation not in {"keys-status", "keys-add", "keys-remove", "keys-check"}: + raise ValueError("Unsupported key operation") + value = getattr(self.provider.api_keys(), operation.removeprefix("keys-"))(**parameters) + elif operation == "status": value = self.provider.status() elif operation == "clients": value = self.provider.clients() @@ -72,7 +76,8 @@ def call(self, operation: str, parameters: dict[str, Any]) -> dict[str, Any]: code = getattr(error, "code", "native_unavailable") cached = {} try: - cached = self.provider._projection(self.provider._load()) + cached = (self.provider.api_keys().status() if operation.startswith("keys-") + else self.provider._projection(self.provider._load())) except Exception: pass return {**cached, "ok": False, "available": True, "code": code, "error_code": code} @@ -97,6 +102,25 @@ def _codex_accounts_read(self) -> None: def _codex_account_clients(self) -> None: cast(_HTTP, self)._send_json(self.server.codex_account_service.call("clients", {})) + def _codex_api_keys_read(self) -> None: + cast(_HTTP, self)._send_json(self.server.codex_account_service.call("keys-status", {})) + + def _codex_api_keys_write(self) -> None: + http = cast(_HTTP, self) + try: + body = http._read_json() + fields = {"keys-add": {"expected_revision", "label", "api_key"}, + "keys-remove": {"expected_revision", "key_id"}, + "keys-check": {"expected_revision", "key_id"}} + if (set(body) != {"operation", "parameters"} or not isinstance(body["operation"], str) + or body["operation"] not in fields or not isinstance(body["parameters"], dict) + or set(body["parameters"]) != fields[body["operation"]]): + raise ValueError("Invalid key operation") + result = self.server.codex_account_service.call(body["operation"], body["parameters"]) + except (TypeError, ValueError, KeyError): + result = {"ok": False, "code": "invalid_request"} + http._send_json(result, status=200 if result["ok"] else 409) + def _codex_accounts_write(self) -> None: http = cast(_HTTP, self) try: @@ -104,7 +128,7 @@ def _codex_accounts_write(self) -> None: if set(body) != {"operation", "parameters"} or not isinstance(body["parameters"], dict): raise ValueError("Invalid account operation") operation, parameters = body["operation"], body["parameters"] - fields = {"configure": {"expected_revision", "enabled", "slots", "reconnect", "restart_clients", "force_quit_clients"}, + fields = {"configure": {"expected_revision", "enabled", "slots", "reconnect", "restart_clients", "force_quit_clients", "api_fallback"}, "switch": {"manual_slot", "expected_selection", "operation_id"}, "recover": {"expected_revision", "expected_selection"}, "rollback": {"expected_revision", "expected_selection"}, "reopen": {"expected_selection"}, "refresh": set()} diff --git a/loopx/presentation/configuration_api.py b/loopx/presentation/configuration_api.py index 3551e7016e..16fb61f1eb 100644 --- a/loopx/presentation/configuration_api.py +++ b/loopx/presentation/configuration_api.py @@ -47,6 +47,7 @@ def _configuration_get_routes(self) -> dict[str, Callable[[], None]]: f"{media_api.CONTENT_MEDIA_PATH}/file": self._content_media_file, accounts_api.CODEX_ACCOUNTS_PATH: self._codex_accounts_read, f"{accounts_api.CODEX_ACCOUNTS_PATH}/clients": self._codex_account_clients, + f"{accounts_api.CODEX_ACCOUNTS_PATH}/keys": self._codex_api_keys_read, attached_api.CODEX_ATTACHED_PATH: self._codex_attached_read, attached_api.CODEX_ATTACHED_LIVE_PATH: self._codex_attached_live, request_api.REQUEST_CONTROL_PATH: self._request_control_read, @@ -68,6 +69,7 @@ def _configuration_post_routes(self) -> dict[str, Callable[[], None]]: return { media_api.CONTENT_MEDIA_PATH: self._content_media_request, accounts_api.CODEX_ACCOUNTS_PATH: self._codex_accounts_write, + f"{accounts_api.CODEX_ACCOUNTS_PATH}/keys": self._codex_api_keys_write, context_api.DECISION_CONTEXT_PATH: self._decision_context_request, attached_api.CODEX_ATTACHED_PATH: self._codex_attached_send, attached_api.CODEX_ATTACHED_OPEN_PATH: self._codex_attached_open, diff --git a/packages/loopx-codex-provider-routing/extension.toml b/packages/loopx-codex-provider-routing/extension.toml index 638f1656e2..0c23d2da8b 100644 --- a/packages/loopx-codex-provider-routing/extension.toml +++ b/packages/loopx-codex-provider-routing/extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-codex-provider-routing" -version = "0.12.11" +version = "0.13.0" requires_loopx_api = ">=1,<2" permissions = [] diff --git a/packages/loopx-codex-provider-routing/pyproject.toml b/packages/loopx-codex-provider-routing/pyproject.toml index 55406ab9bf..09776eb447 100644 --- a/packages/loopx-codex-provider-routing/pyproject.toml +++ b/packages/loopx-codex-provider-routing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "loopx-codex-provider-routing" -version = "0.12.11" +version = "0.13.0" description = "Native Codex account slots and exhausted-account handoff for LoopX" readme = "README.md" requires-python = ">=3.11" diff --git a/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs b/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs index ad21b9d9a0..3e10a6d48d 100644 --- a/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs +++ b/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs @@ -103,3 +103,15 @@ test("normal client restart needs its own permission and the quota proof expires assert.equal(decide(input({ policy: allowed, manual_slot: "b" })).valid_until, null, "An explicit sign-in choice is not an automatic quota decision"); }); + +test("API is opt-in and strictly after every measured subscription; recovery returns to subscriptions", () => { + const api = {slot: "openai-api", ready: true}; + const enabled = {enabled: true, slots: ["a", "b", "c"], cooldown_seconds: 300, api_fallback: true}; + const exhausted = [sample("a", 100), sample("b", 100), sample("c", 100)]; + assert.equal(decide(input({policy: enabled, api})).target, "b", "API never outranks an available subscription"); + assert.equal(decide(input({policy: enabled, api, observations: exhausted})).target, api.slot); + assert.equal(decide(input({api, observations: exhausted})).target, null, "Absent opt-in preserves subscription-only behavior"); + assert.equal(decide(input({policy: enabled, api, observations: [sample("a", 100), sample("b", 100), {...sample("c", 100), observed_at: now - 121}]})).target, null); + assert.equal(decide(input({policy: enabled, api, active: api.slot, observations: exhausted})).action, "api_active"); + assert.equal(decide(input({policy: enabled, api, active: api.slot, observations: [sample("a", 80), sample("b", 20), sample("c", 100)]})).target, "b"); +}); diff --git a/packages/loopx-codex-provider-routing/smoke/openai-policy.test.mjs b/packages/loopx-codex-provider-routing/smoke/openai-policy.test.mjs new file mode 100644 index 0000000000..fe707c82d5 --- /dev/null +++ b/packages/loopx-codex-provider-routing/smoke/openai-policy.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {apiKeys, apiOutcome} from "../src/loopx_codex_provider_routing/openai_policy.ts"; + +test("known quota rejection rotates a key; unknown effects and ordinary request failures do not", () => { + const input={now:1000,status:429,code:"insufficient_quota",retry_after:null,model:"selected-model"}; + assert.equal(apiOutcome(input).state,"quota_exhausted"); + assert.equal(apiOutcome(input).retry_next_key,true); + for(const status of [400,408,500,502,503]) assert.equal(apiOutcome({...input,status}).retry_next_key,false); + assert.equal(apiOutcome({...input,status:200}).state,"accepted","HTTP success is not completed inference"); + assert.equal(apiOutcome({...input,code:"rate_limit_exceeded",retry_after:60}).blocked_until,1060); + assert.equal(apiOutcome({...input,code:"rate_limit_exceeded",retry_after:7200}).blocked_until,8200,"Never shorten an upstream minimum wait"); +}); +test("cooldowns and model-scoped permission failures retain key order without making a balance claim", () => { + const keys=[{id:"a",blocked_until:1100},{id:"b",blocked_until:1100,blocked_model:"other-model"},{id:"c"}]; + assert.deepEqual(apiKeys({now:1000,model:"selected-model",keys}),["b","c"]); + assert.deepEqual(apiKeys({now:1100,model:"selected-model",keys}),["a","b","c"]); +}); diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py index e8886a6460..aefe126ed3 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.12.11" +__version__ = "0.13.0" diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py index 681ed36fc0..cb713aa3ce 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py @@ -23,13 +23,13 @@ SCHEMA = "codex_native_accounts_v1" -def private_read(path: Path) -> dict: +def private_read(path: Path, *, schema: str = SCHEMA) -> dict: if any(p.is_symlink() for p in (path, *path.parents)): raise HostError("state_untrusted") if path.stat().st_mode & 0o077 or path.stat().st_uid != os.getuid(): raise HostError("state_untrusted") value = json.loads(path.read_text()) - if value.get("schema_version") != SCHEMA: + if value.get("schema_version") != schema: raise HostError("state_incompatible") return value @@ -117,6 +117,11 @@ def __init__(self, runtime_root: Path, *, transport=host, clock=time.time): self.transport, self.clock = transport, clock self.switch_allowed = lambda: True + def api_keys(self): + # Configuration works without discovering or touching a native login. + from .openai_keys import OpenAIKeys + return OpenAIKeys(self) + def _load(self) -> dict: if self.path.exists(): return private_read(self.path) @@ -156,6 +161,8 @@ def _projection(self, state: dict, native: dict | None = None) -> dict: return {"schema_version": SCHEMA, "available": True, "revision": state["revision"], "policy": state["policy"], "slots": native.get("slots", []), "active_slot": native.get("active_slot"), + "api_fallback_supported": native.get("api_fallback_supported", False), + "api_active": native.get("api_slot") is not None and native.get("active_slot") == native.get("api_slot"), "selection_revision": native.get("selection_revision"), "login_matches": native.get("current_login_matches"), "recovery_required": native.get("recovery_required", False) or state.get("action") == "handoff_interrupted", @@ -178,13 +185,16 @@ def clients(self) -> dict: return self.transport(state.get("adapter") or discover(), "clients") def configure(self, *, expected_revision: int, enabled: bool, slots: list[str], reconnect: bool = False, - restart_clients: bool | None = None, force_quit_clients: bool | None = None) -> dict: + restart_clients: bool | None = None, force_quit_clients: bool | None = None, + api_fallback: bool | None = None) -> dict: if type(enabled) is not bool or not isinstance(slots, list) or len(slots) > 16 or len(slots) != len(set(slots)): raise HostError("invalid_policy") if restart_clients is not None and type(restart_clients) is not bool: raise HostError("invalid_policy") if force_quit_clients is not None and type(force_quit_clients) is not bool: raise HostError("invalid_policy") + if api_fallback is not None and type(api_fallback) is not bool: + raise HostError("invalid_policy") # Stop requests fence new effects at the Watcher first, then persist # after an already dispatched atomic switch finishes. Other mutations # retain their immediate conflict behavior. @@ -196,7 +206,8 @@ def configure(self, *, expected_revision: int, enabled: bool, slots: list[str], if pending.get("handoff_receipt") and enabled: raise HostError("handoff_pending") # Disabling works offline and never needs credentials or a client. - if enabled or reconnect: + use_api = api_fallback if api_fallback is not None else state["policy"].get("api_fallback", False) + if enabled or reconnect or api_fallback is True: adapter = discover() if reconnect or "adapter" not in state else state["adapter"] native = self.transport(adapter, "status") if reconnect and not slots and not enabled: @@ -204,17 +215,34 @@ def configure(self, *, expected_revision: int, enabled: bool, slots: list[str], if not slots or not set(slots) <= set(native["slots"]): raise HostError("invalid_slots") state.update(adapter=adapter, native=native) + if enabled and use_api: + self._validate_api(native) state.update(policy={"enabled": enabled, "slots": slots, "cooldown_seconds": 300, "restart_clients": restart_clients if restart_clients is not None else state["policy"].get("restart_clients", False), "force_quit_clients": force_quit_clients if force_quit_clients is not None - else state["policy"].get("force_quit_clients", False)}, + else state["policy"].get("force_quit_clients", False), + **({"api_fallback": use_api} if api_fallback is not None or "api_fallback" in state["policy"] else {})}, revision=state["revision"] + 1, retry_at=None, code=None, intent=pending if pending.get("handoff_receipt") else None, action="checking" if enabled else "disabled", failure_count=0) atomic(self.path, state) return self._projection(state) + def _validate_api(self, native: dict) -> None: + if native.get("api_fallback_supported") is not True: + raise HostError("api_owner_upgrade_required") + if not self.api_keys().status()["keys"]: + raise HostError("api_keys_missing") + + def _prepare_api(self, state: dict, native: dict) -> dict: + self._validate_api(native) + from .openai_relay import ensure + endpoint = ensure(self) + if native.get("api_slot") is None: + return self.transport(state["adapter"], "enroll_api", **endpoint) + return native + def tick(self, *, refresh: bool = False, manual_slot: str | None = None, expected_selection: str | None = None, operation_id: str | None = None) -> dict: if manual_slot is not None and (not expected_selection or not isinstance(operation_id, str) or not 1 <= len(operation_id) <= 128): @@ -298,7 +326,13 @@ def tick(self, *, refresh: bool = False, manual_slot: str | None = None, state["failure_count"] = 0 # Inspect the active account even when it is not an authorized # destination. Candidates stay strictly inside the allowlist. + api = None + if state["policy"].get("api_fallback") or native.get("api_slot"): + ready = bool(state["policy"].get("api_fallback") and native.get("api_fallback_supported") + and self.api_keys().status()["keys"]) + api = {"slot": native.get("api_slot") or "openai-api", "ready": ready} slots = list(dict.fromkeys([native["active_slot"], *state["policy"]["slots"]])) + slots = [slot for slot in slots if slot != native.get("api_slot")] failures = {} if refresh or manual_slot else { row["slot"]: {"revision": row["credential_revision"], "observed_at": row["observed_at"]} for row in state.get("observations", []) @@ -311,6 +345,7 @@ def tick(self, *, refresh: bool = False, manual_slot: str | None = None, decision = policy({"now": self.clock(), "policy": state["policy"], "active": native["active_slot"], "observations": public_observations, "last_switch_at": state["last_switch_at"], "recovery_required": False, "login_matches": True, + **({"api": api} if api else {}), **({"manual_slot": manual_slot} if manual_slot else {})}) state.update(observations=observed["observations"], accounts=decision["accounts"], last_checked_at=self.clock(), action=decision["action"], target=decision["target"], @@ -318,6 +353,9 @@ def tick(self, *, refresh: bool = False, manual_slot: str | None = None, if decision["action"] == "switch": if not manual_slot and not self.switch_allowed(): raise HostError("observer_stopped") + if api and decision["target"] == api["slot"]: + native = self._prepare_api(state, native) + state["native"] = native # Durable before the effect, with source selection and target. # Native owner rechecks the same fence inside its transaction. state["intent"] = {"id": operation_id or uuid.uuid4().hex, @@ -348,11 +386,13 @@ def tick(self, *, refresh: bool = False, manual_slot: str | None = None, target=None, retry_at=self.clock() + 300, launch_failed=result["launch_failed"], failure_count=0) if operation_id: state["last_operation_id"] = operation_id + if decision["action"] == "api_active" and api and api["ready"]: + self._prepare_api(state, native) except (HostError, OSError, ValueError, subprocess.SubprocessError) as error: code = error.code if isinstance(error, HostError) else "native_unavailable" # Unrelated/remaining owners still require a safe-boundary wait. # Other repeated failures stop automatic attempts until Refresh. - failures = state.get("failure_count", 0) + (code not in ("waiting_for_idle", "other_clients_busy", "handoff_pending")) + failures = state.get("failure_count", 0) + (code not in ("waiting_for_idle", "other_clients_busy", "handoff_pending", "api_relay_unavailable")) if code in ("client_quit_refused", "client_quit_timeout"): failures = 3 # Do not repeatedly close/reopen a refused client. state.update(action="waiting_for_idle" if code == "other_clients_busy" else code if code in ("waiting_for_idle", "selection_changed", "adapter_changed", "client_quit_refused", "client_quit_timeout", "client_changed", "handoff_pending") else "unavailable", @@ -448,7 +488,8 @@ def close(self): def main() -> int: parser = argparse.ArgumentParser(prog="loopx-codex-accounts") - parser.add_argument("operation", choices=("status", "clients", "configure", "refresh", "switch", "recover", "rollback", "reopen", "watch")) + parser.add_argument("operation", choices=("status", "clients", "configure", "refresh", "switch", "recover", "rollback", "reopen", "watch", + "keys-status", "keys-add", "keys-remove", "keys-check")) parser.add_argument("--runtime-root", type=Path, default=Path.home() / ".loopx") args = parser.parse_args() provider = NativeAccounts(args.runtime_root) @@ -460,8 +501,11 @@ def main() -> int: except KeyboardInterrupt: watcher.close() return 0 - request = {} if args.operation in ("status", "clients", "refresh") else json.load(sys.stdin) - if args.operation == "status": + request = {} if args.operation in ("status", "clients", "refresh", "keys-status") else json.load(sys.stdin) + if args.operation.startswith("keys-"): + operation = args.operation.removeprefix("keys-") + result = getattr(provider.api_keys(), operation)(**request) + elif args.operation == "status": result = provider.status() elif args.operation == "clients": result = provider.clients() diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py index 21bdaf746f..fbf17291ef 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py @@ -181,11 +181,21 @@ def status(self) -> dict: result = {key: value[key] for key in ( "slots", "active_slot", "current_login_matches", "recovery_required" )} | {"selection_revision": self.revision()} + kinds = value.get("slot_kinds", {}) + result["slots"] = [slot for slot in result["slots"] if kinds.get(slot) != "api"] + result["api_slot"] = "openai-api" if kinds.get("openai-api") == "api" else None + result["api_fallback_supported"] = getattr(self.accounts, "API_KEY_SLOTS_VERSION", 0) == 1 if sys.platform == "darwin": exe = self.apps.executable(self.apps.app_bundle(self.spec, "gpt", self.home)) result["client_running"] = self.apps.running(exe, self.spec) is not None return result + def enroll_api(self, slot: str, api_key: str, base_url: str) -> dict: + if slot != "openai-api" or getattr(self.accounts, "API_KEY_SLOTS_VERSION", 0) != 1: + raise HostError("api_owner_upgrade_required") + self.manager.enroll_api(slot, api_key=api_key, base_url=base_url) + return self.status() + def close_client(self, expected: str, *, timeout: float = 25, force: bool = False, inspected: tuple | None = None) -> bool: exe = self.apps.executable(self.apps.app_bundle(self.spec, "gpt", self.home)) @@ -1072,6 +1082,8 @@ def main() -> int: value = host.clients() elif operation == "observe": value = host.observe(request["slots"], request["expected_revision"], request.get("auth_failures")) + elif operation == "enroll_api": + value = host.enroll_api(request["slot"], request["api_key"], request["base_url"]) elif operation == "switch": value = host.switch(request["slot"], request["expected_revision"], reopen=request["reopen"], restart_clients=request.get("restart_clients", False), diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts index 13b7c2da51..32ff5d21b1 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts @@ -18,7 +18,7 @@ export type Account = { credits_available: boolean | null; }; export type Policy = { enabled: boolean; slots: string[]; cooldown_seconds: number; - restart_clients?: boolean; force_quit_clients?: boolean }; + restart_clients?: boolean; force_quit_clients?: boolean; api_fallback?: boolean }; const number = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); const percent = (v: unknown): number | null => number(v) && v >= 0 && v <= 100 ? v : null; @@ -69,21 +69,25 @@ export function decide(input: { now: number; policy: Policy; active: string | null; observations: Observation[]; last_switch_at: number | null; recovery_required: boolean; login_matches: boolean; manual_slot?: string; + api?: { slot: string; ready: boolean }; }) { const accounts = input.observations.map(o => account(o, input.now)); + const onApi = input.api?.slot === input.active; const output = (action: string, target: string | null = null, retry_at: number | null = null) => ({ action, target, retry_at, accounts, restart_clients: action === "switch" && input.policy.restart_clients === true, force_quit_clients: action === "switch" && input.policy.restart_clients === true && input.policy.force_quit_clients === true, valid_until: action === "switch" && !input.manual_slot - ? Math.min(...accounts.filter(a => a.slot === input.active || a.slot === target).map(a => a.observed_at + 120)) + ? Math.min(...accounts.filter(a => target === input.api?.slot + ? a.slot === input.active || input.policy.slots.includes(a.slot) + : a.slot === input.active || a.slot === target).map(a => a.observed_at + 120)) : null }); if (input.recovery_required) return output("recovery_required"); if (!input.login_matches || !input.active) return output("selection_changed"); if (!input.manual_slot && !input.policy.enabled) return output("disabled"); const current = accounts.find(a => a.slot === input.active); - if (!input.manual_slot && current?.availability !== "exhausted") { + if (!input.manual_slot && !onApi && current?.availability !== "exhausted") { return output(current?.availability === "available" ? "ready" : current?.availability === "auth_required" ? "auth_required" : "quota_unknown", null, input.now + 60); } @@ -104,6 +108,13 @@ export function decide(input: { ? [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); + if (!input.manual_slot && onApi) return output(input.policy.api_fallback ? "api_active" : "api_disabled_active", null, input.now + 60); + // A failed/stale subscription read is not permission to incur API costs. + // Every authorized subscription must have fresh, measured exhaustion. + if (!input.manual_slot && input.policy.api_fallback === true && input.api?.ready + && allowed.length > 0 && allowed.every(slot => accounts.find(a => a.slot === slot)?.availability === "exhausted")) { + return output("switch", input.api.slot); + } 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] : []); const uncertain = accounts.some(a => allowed.includes(a.slot) && a.availability === "unknown"); diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_keys.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_keys.py new file mode 100644 index 0000000000..3d587e2321 --- /dev/null +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_keys.py @@ -0,0 +1,162 @@ +"""Private OpenAI keys and content-free observations for the opt-in API route.""" +from __future__ import annotations + +import json +import math +from email.utils import parsedate_to_datetime +import re +import urllib.error +import urllib.request +import uuid +import shutil +import subprocess +from pathlib import Path + +from .native import atomic, private_read +from .native_host import HostError + +SCHEMA = "codex_openai_api_keys_v1" + + +def api_policy(value: dict): + node = shutil.which("node") + if not node: + raise HostError("node_unavailable") + result = subprocess.run([node, "--no-warnings", "--experimental-strip-types", + str(Path(__file__).with_name("openai_policy_bridge.ts"))], + input=json.dumps(value), capture_output=True, text=True, timeout=10) + if result.returncode: + raise HostError("policy_unavailable") + return json.loads(result.stdout) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): + raise HostError("key_check_unavailable") + + +def check_models(api_key: str) -> str: + """No prompts, billing probes, redirects, retries or raw response storage.""" + request = urllib.request.Request("https://api.openai.com/v1/models", headers={ + "Authorization": "Bearer " + api_key, "Accept": "application/json", + }) + try: + with urllib.request.build_opener(NoRedirect()).open(request, timeout=10) as response: + encoded = response.read(1048577) + if len(encoded) > 1048576: + return "unavailable" + value = json.loads(encoded) + return "models_readable" if isinstance(value, dict) and isinstance(value.get("data"), list) else "unavailable" + except urllib.error.HTTPError as error: + error.close() + return {401: "authentication_failed", 403: "permission_denied", 429: "rate_limited"}.get(error.code, "unavailable") + except (HostError, OSError, ValueError): + return "unavailable" + + +class OpenAIKeys: + def __init__(self, owner, *, checker=check_models): + self.owner, self.checker = owner, checker + self.path = owner.root / "openai-keys.json" + + def _load(self) -> dict: + return private_read(self.path, schema=SCHEMA) if self.path.exists() else { + "schema_version": SCHEMA, "revision": 0, "keys": [], + } + + @staticmethod + def _projection(state: dict) -> dict: + return {"schema_version": SCHEMA, "available": True, "revision": state["revision"], + "balance_available": False, + "keys": [{"id": row["id"], "label": row["label"], + "masked_key": "••••" + row["api_key"][-4:], + "check_status": row.get("check_status", "not_checked"), + "checked_at": row.get("checked_at"), + "runtime": row.get("runtime")} + for row in state["keys"]]} + + def status(self) -> dict: + # Default-off read neither creates state nor contacts a provider. + return self._projection(self._load()) + + def _revision(self, expected_revision: int) -> dict: + state = self._load() + if type(expected_revision) is not int or state["revision"] != expected_revision: + raise HostError("configuration_changed") + return state + + def add(self, *, expected_revision: int, label: str, api_key: str) -> dict: + if (not isinstance(api_key, str) or not re.fullmatch(r"sk-[A-Za-z0-9_-]{20,509}", api_key) + or api_key.startswith("sk-admin-")): + raise HostError("invalid_api_key") + if (not isinstance(label, str) or not 1 <= len(label.strip()) <= 80 + or any(ord(c) < 32 for c in label) or "sk-" in label): + raise HostError("invalid_key_label") + with self.owner._lock(name="openai-keys.lock"): + state = self._revision(expected_revision) + if any(row["api_key"] == api_key for row in state["keys"]): + raise HostError("duplicate_api_key") + if len(state["keys"]) >= 16: + raise HostError("key_limit_reached") + state["keys"].append({"id": uuid.uuid4().hex, "label": label.strip(), "api_key": api_key}) + state["revision"] += 1 + atomic(self.path, state) + return self._projection(state) + + def _row(self, state: dict, key_id: str) -> dict: + if not isinstance(key_id, str): + raise HostError("key_not_found") + row = next((row for row in state["keys"] if row["id"] == key_id), None) + if row is None: + raise HostError("key_not_found") + return row + + def remove(self, *, expected_revision: int, key_id: str) -> dict: + with self.owner._lock(name="openai-keys.lock"): + state = self._revision(expected_revision) + state["keys"].remove(self._row(state, key_id)) + state["revision"] += 1 + atomic(self.path, state) + return self._projection(state) + + def check(self, *, expected_revision: int, key_id: str) -> dict: + with self.owner._lock(name="openai-keys.lock"): + state = self._revision(expected_revision) + row = self._row(state, key_id) + row.update(check_status=self.checker(row["api_key"]), checked_at=self.owner.clock()) + state["revision"] += 1 + atomic(self.path, state) + return self._projection(state) + + def candidates(self, model: str) -> list[dict]: + state = self._load() + ids = api_policy({"operation": "candidates", "now": self.owner.clock(), "model": model, + "keys": [{"id": row["id"], **{k: row.get("runtime", {}).get(k) + for k in ("blocked_until", "blocked_model")}} for row in state["keys"]]}) + return [row for row in state["keys"] if row["id"] in ids] + + def record(self, key_id: str, *, status: int, code: str | None, model: str, headers: dict) -> dict: + try: + retry = float(headers.get("retry-after", "")) + retry = retry if math.isfinite(retry) else None + except (TypeError, ValueError): + try: + retry = max(1, parsedate_to_datetime(headers.get("retry-after", "")).timestamp() - self.owner.clock()) + except (TypeError, ValueError, OverflowError): + retry = None + outcome = api_policy({"operation": "outcome", "now": self.owner.clock(), "status": status, + "code": code, "retry_after": retry, "model": model}) + with self.owner._lock(name="openai-keys.lock", wait_seconds=15): + state = self._load() + row = next((row for row in state["keys"] if row["id"] == key_id), None) + if row is not None: + runtime = {k: outcome[k] for k in ("state", "blocked_until", "blocked_model")} + runtime.update(observed_at=self.owner.clock(), model=model) + for field in ("requests", "tokens"): + raw = headers.get("x-ratelimit-remaining-" + field, "") + if isinstance(raw, str) and raw.isdigit() and len(raw) <= 15: + runtime["remaining_" + field] = int(raw) + row["runtime"] = runtime + # Runtime observations do not invalidate a configuration edit. + atomic(self.path, state) + return outcome diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy.ts b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy.ts new file mode 100644 index 0000000000..8da07a547c --- /dev/null +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy.ts @@ -0,0 +1,20 @@ +/** OpenAI transport outcomes. No credentials, request contents or IO. */ +export type KeyRuntime = { id: string; blocked_until?: number; blocked_model?: string | null }; +export function apiKeys(input: { now: number; model: string; keys: KeyRuntime[] }) { + return input.keys.filter(key => !(typeof key.blocked_until === "number" + && key.blocked_until > input.now && (!key.blocked_model || key.blocked_model === input.model))).map(key => key.id); +} +export function apiOutcome(input: { now: number; status: number; code: string | null; retry_after: number | null; model: string }) { + const quota = input.status === 429 && ["insufficient_quota", "organization_spend_limit_exceeded", + "project_spend_limit_exceeded", "organization_usage_limit_exceeded"].includes(input.code ?? ""); + const authentication = input.status === 401; + const permission = input.status === 403; + const limited = input.status === 429; + const delay = typeof input.retry_after === "number" && Number.isFinite(input.retry_after) + ? Math.max(1, input.retry_after) : quota || authentication ? 300 : 30; + return { state: quota ? "quota_exhausted" : authentication ? "authentication_failed" : permission + ? "permission_denied" : limited ? "rate_limited" : input.status >= 200 && input.status < 300 ? "accepted" : "failed", + retry_next_key: quota || authentication || permission || limited, + blocked_until: quota || authentication || permission || limited ? input.now + delay : 0, + blocked_model: permission ? input.model : null }; +} diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy_bridge.ts b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy_bridge.ts new file mode 100644 index 0000000000..54609e9995 --- /dev/null +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy_bridge.ts @@ -0,0 +1,8 @@ +import { apiKeys, apiOutcome } from "./openai_policy.ts"; +let raw = ""; +for await (const chunk of process.stdin) { + raw += chunk; + if (raw.length > 131072) throw Error("Policy request exceeds limit"); +} +const input = JSON.parse(raw); +process.stdout.write(JSON.stringify(input.operation === "candidates" ? apiKeys(input) : apiOutcome(input))); diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_relay.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_relay.py new file mode 100644 index 0000000000..31515d9866 --- /dev/null +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_relay.py @@ -0,0 +1,223 @@ +"""Loopback Responses transport for the explicitly enabled API fallback. + +Native login transactions remain in Personal Apps. This transport holds only +upstream API keys, never subscription credentials, prompts or response logs. +""" +from __future__ import annotations + +import argparse +import hmac +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import secrets +import re +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request + +from .native import NativeAccounts, atomic, private_read +from .native_host import HostError +from .openai_keys import NoRedirect + +SCHEMA = "codex_openai_api_relay_v1" +API_SLOT = "openai-api" + + +def route(owner: NativeAccounts) -> dict: + value = private_read(owner.root / "api-relay.json", schema=SCHEMA) + if type(value.get("port")) is not int or not 1024 <= value["port"] <= 65535 or not isinstance(value.get("token"), str) or len(value["token"]) < 32: + raise HostError("api_relay_unavailable") + return value + + +def healthy(value: dict) -> bool: + request = urllib.request.Request(f"http://127.0.0.1:{value['port']}/health", headers={"Authorization": "Bearer " + value["token"]}) + try: + with urllib.request.build_opener(NoRedirect()).open(request, timeout=1) as response: + return response.read(128) == b'{"ok":true}' + except (OSError, HostError): + return False + + +def ensure(owner: NativeAccounts) -> dict: + with owner._lock(name="api-relay-state.lock", wait_seconds=5): + path = owner.root / "api-relay.json" + if not path.exists(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + atomic(path, {"schema_version": SCHEMA, "port": port, "token": "sk-loopx-relay-" + secrets.token_urlsafe(32)}) + value = route(owner) + if not healthy(value): + # Independent of a Chat/App process that the handoff may restart. + subprocess.Popen([sys.executable, "-m", "loopx_codex_provider_routing.openai_relay", + "--runtime-root", str(owner.root.parents[1])], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not healthy(value): + time.sleep(.1) + if not healthy(value): + raise HostError("api_relay_unavailable") + return {"slot": API_SLOT, "api_key": value["token"], "base_url": f"http://127.0.0.1:{value['port']}/v1"} + + +class RelayServer(ThreadingHTTPServer): + daemon_threads = False + allow_reuse_address = True + + def __init__(self, owner: NativeAccounts, value: dict, *, opener=None): + self.owner, self.token = owner, value["token"] + self.opener = opener or urllib.request.build_opener(NoRedirect()) + super().__init__(("127.0.0.1", value["port"]), RelayHandler) + + +class RelayHandler(BaseHTTPRequestHandler): + server: RelayServer + + def log_message(self, *_args): + pass + + def _json(self, status: int, code: str): + data = json.dumps({"error": {"type": "api_fallback_error", "code": code, + "message": "OpenAI API fallback request could not complete."}}).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _authorized(self): + return not self.headers.get("Origin") and hmac.compare_digest( + self.headers.get("Authorization", ""), "Bearer " + self.server.token) + + def _admitted(self): + state = self.server.owner._load() + policy = state["policy"] + native = state.get("native", {}) + selecting_api = state.get("intent", {}).get("target") == API_SLOT if state.get("intent") else False + return (policy.get("enabled") is True and policy.get("api_fallback") is True + and (native.get("active_slot") == API_SLOT or selecting_api)) + + def do_GET(self): + if not self._authorized(): + self._json(401, "relay_auth_required") + return + if self.path == "/health": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", "11") + self.end_headers() + self.wfile.write(b'{"ok":true}') + else: + self._json(404, "unsupported_route") + + def do_POST(self): + if not self._authorized(): + self._json(401, "relay_auth_required") + return + if self.path not in ("/v1/responses", "/v1/responses/compact"): + self._json(404, "unsupported_route") + return + if not self._admitted(): + self._json(503, "api_fallback_disabled") + return + try: + length = int(self.headers.get("Content-Length", "0")) + if not 1 <= length <= 32 * 1024 * 1024: + raise ValueError() + body = self.rfile.read(length) + value = json.loads(body) + model = value["model"] + if not isinstance(model, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,160}", model) or model.startswith("sk-"): + raise ValueError() + except (TypeError, ValueError, KeyError): + self._json(400, "invalid_request") + return + keys = self.server.owner.api_keys() + candidates = keys.candidates(model) + last_status, last_code = 429, "api_keys_unavailable" + for row in candidates: + # Removing/disabling configuration before dispatch wins. A request + # already admitted upstream is never cancelled or replayed here. + if not self._admitted(): + self._json(503, "api_fallback_disabled") + return + current = next((r for r in keys._load()["keys"] if r["id"] == row["id"]), None) + if current is None: + continue + request = urllib.request.Request("https://api.openai.com" + self.path, data=body, headers={ + "Authorization": "Bearer " + current["api_key"], "Content-Type": "application/json", + "Accept": self.headers.get("Accept", "text/event-stream"), + }, method="POST") + try: + response = self.server.opener.open(request, timeout=300) + except urllib.error.HTTPError as error: + encoded = error.read(65537) + try: + code = json.loads(encoded).get("error", {}).get("code") if len(encoded) <= 65536 else None + code = code if isinstance(code, str) and code.isascii() and code.replace("_", "").isalnum() else None + except (TypeError, ValueError, AttributeError): + code = None + headers = {k.lower(): v for k, v in error.headers.items()} + status = error.code + error.close() + outcome = keys.record(row["id"], status=status, code=code, model=model, headers=headers) + last_status, last_code = status, code or "upstream_rejected" + if outcome["retry_next_key"]: + continue + self._json(status, last_code) + return + except (OSError, HostError): + # Unknown upstream effects never authorize trying another key. + self._json(502, "upstream_unavailable") + return + with response: + headers = {k.lower(): v for k, v in response.headers.items()} + keys.record(row["id"], status=response.status, code=None, model=model, headers=headers) + self.send_response(response.status) + self.send_header("Content-Type", headers.get("content-type", "text/event-stream")) + self.send_header("Cache-Control", "no-store") + self.end_headers() + # Once headers/output are admitted, disconnects and SSE errors + # end this request. No key rotation, synthetic completion or job replay. + try: + read = getattr(response, "read1", response.read) + while chunk := read(8192): + self.wfile.write(chunk) + self.wfile.flush() + except (OSError, TimeoutError): + self.close_connection = True + return + self._json(last_status, last_code) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--runtime-root", type=Path, required=True) + owner = NativeAccounts(parser.parse_args().runtime_root) + try: + with owner._lock(name="api-relay.lock"): + server = RelayServer(owner, route(owner)) + server.timeout = 5 + started = time.monotonic() + try: + while True: + server.handle_request() + policy = owner._load()["policy"] + if time.monotonic() - started > 10 and (not policy.get("enabled") or not policy.get("api_fallback")): + break + finally: + # Drain already admitted requests; disabling never replays them. + server.server_close() + except (BlockingIOError, OSError, HostError): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/presentation/test_openai_fallback.py b/tests/presentation/test_openai_fallback.py new file mode 100644 index 0000000000..32b49ee495 --- /dev/null +++ b/tests/presentation/test_openai_fallback.py @@ -0,0 +1,92 @@ +"""Native policy/store transaction integration; no installed login or model IO.""" +import importlib +import os +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Native policy uses POSIX locks") + + +@pytest.fixture +def setup(monkeypatch, tmp_path): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "packages/loopx-codex-provider-routing/src")) + native = importlib.import_module("loopx_codex_provider_routing.native") + relay = importlib.import_module("loopx_codex_provider_routing.openai_relay") + monkeypatch.setattr(native, "discover", lambda: {"synthetic": True}) + state = {"active": "a", "revision": "initial", "api_slot": None, "supported": True, + "now": 1000, "b_used": 100, "b_status": "ok", "calls": [], "relay_starts": 0} + def ensure(_owner): + state["relay_starts"] += 1 + return {"slot": "openai-api", "api_key": "synthetic-loopback-token", "base_url": "http://127.0.0.1:32100/v1"} + monkeypatch.setattr(relay, "ensure", ensure) + def transport(adapter, operation, **parameters): + state["calls"].append(operation) + if operation == "status": + return {"slots": ["a", "b"], "active_slot": state["active"], "selection_revision": state["revision"], + "current_login_matches": True, "recovery_required": False, + "api_slot": state["api_slot"], "api_fallback_supported": state["supported"]} + if operation == "enroll_api": + assert parameters["slot"] == "openai-api" + state["api_slot"] = "openai-api" + return transport(adapter, "status") + if operation == "observe": + assert "openai-api" not in parameters["slots"], "API keys never enter the subscription quota reader" + return {"observations": [{"slot": slot, "observed_at": state["now"], + "status": state["b_status"] if slot == "b" else "ok", "usage": { + "rate_limit": {"primary_window": {"used_percent": 100 if slot == "a" else state["b_used"], "reset_at": 9000}}, + "credits": {"has_credits": False}}} for slot in parameters["slots"]]} + if operation == "switch": + assert parameters["expected_revision"] == state["revision"] + state["active"] = parameters["slot"] + state["revision"] += "-switched" + return transport(adapter, "status") | {"launch_failed": False} + raise AssertionError(operation) + provider = native.NativeAccounts(tmp_path, transport=transport, clock=lambda: state["now"]) + provider.api_keys().add(expected_revision=0, label="Fallback", api_key="sk-" + "synthetic-key-" * 3) + return provider, state + + +def test_automatic_api_handoff_then_recovered_subscription_adoption(setup): + provider, state = setup + provider.configure(expected_revision=0, enabled=True, slots=["a", "b"], api_fallback=True) + assert state["active"] == "a", "Enabling fallback never switches by itself" + result = provider.tick(refresh=True) + assert result["active_slot"] == "openai-api" and result["api_active"] is True + assert result["policy"]["slots"] == ["a", "b"], "The paid route is a separate explicit permission" + state.update(now=1301, b_used=10) + result = provider.tick(refresh=True) + assert result["active_slot"] == "b" and result["api_active"] is False + assert state["calls"].count("switch") == 2 + + +def test_available_or_unknown_subscription_never_starts_a_paid_handoff(setup): + provider, state = setup + provider.configure(expected_revision=0, enabled=True, slots=["a", "b"], api_fallback=True) + for status, used in [("ok", 50), ("unavailable", 100)]: + state.update(b_status=status, b_used=used) + if status == "ok": + # Available B must win over API. + assert provider.tick(refresh=True)["active_slot"] == "b" + else: + # Simulate an external native return; preserve its selection fence. + state.update(active="a", revision="manual-return") + provider.tick(refresh=True) + state["now"] += 301 + assert provider.tick(refresh=True)["active_slot"] == "a" + assert state["calls"].count("switch") == 1 + + +def test_saved_keys_do_not_opt_existing_subscription_policies_into_paid_usage(setup): + provider, state = setup + provider.configure(expected_revision=0, enabled=True, slots=["a", "b"]) + assert provider.tick(refresh=True)["action"] == "all_unavailable" + assert state["relay_starts"] == 0 and "enroll_api" not in state["calls"] and "switch" not in state["calls"] + + +def test_old_owner_cannot_be_bypassed_by_fallback_configuration(setup): + provider, state = setup + state["supported"] = False + with pytest.raises(Exception, match="api_owner_upgrade_required"): + provider.configure(expected_revision=0, enabled=True, slots=["a", "b"], api_fallback=True) + assert not provider.path.exists() and state["relay_starts"] == 0 diff --git a/tests/presentation/test_openai_keys.py b/tests/presentation/test_openai_keys.py new file mode 100644 index 0000000000..75f0131309 --- /dev/null +++ b/tests/presentation/test_openai_keys.py @@ -0,0 +1,154 @@ +"""Key configuration through the shipped store, CLI and loopback HTTP owner.""" +import importlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import urllib.error +import urllib.request + +import pytest + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="The private provider store uses POSIX locks") +KEY = "sk-" + "synthetic-key-" * 4 + + +@pytest.fixture +def owner(monkeypatch, tmp_path): + source = Path(__file__).resolve().parents[2] / "packages/loopx-codex-provider-routing/src" + monkeypatch.syspath_prepend(str(source)) + native = importlib.import_module("loopx_codex_provider_routing.native") + def forbidden(*_args, **_kwargs): + raise AssertionError("Key configuration cannot access native account IO") + monkeypatch.setattr(native, "discover", forbidden) + return native.NativeAccounts(tmp_path, transport=forbidden) + + +def test_configuration_is_private_revision_fenced_and_never_activates(owner): + keys = owner.api_keys() + assert keys.status()["keys"] == [] + assert not owner.root.exists() + result = keys.add(expected_revision=0, label="Primary", api_key=KEY) + assert KEY not in json.dumps(result) + assert result["balance_available"] is False + assert result["keys"][0]["check_status"] == "not_checked" + assert keys.path.stat().st_mode & 0o777 == 0o600 + assert owner.root.stat().st_mode & 0o777 == 0o700 + assert not owner.path.exists(), "Native policy and account selection stay untouched" + with pytest.raises(Exception, match="configuration_changed"): + keys.remove(expected_revision=0, key_id=result["keys"][0]["id"]) + with pytest.raises(Exception, match="duplicate_api_key"): + keys.add(expected_revision=1, label="Duplicate", api_key=KEY) + assert owner.api_keys().status() == result, "A fresh instance reads the same saved config" + keys.remove(expected_revision=1, key_id=result["keys"][0]["id"]) + assert keys.status()["keys"] == [] + + +@pytest.mark.parametrize("label,key,code", [ + ("Name", "not-a-key", "invalid_api_key"), + ("Name", "sk-admin-" + "a" * 32, "invalid_api_key"), + (KEY, KEY, "invalid_key_label"), + ("Name\nInjected", KEY, "invalid_key_label"), +]) +def test_bad_inputs_never_create_a_store(owner, label, key, code): + with pytest.raises(Exception, match=code): + owner.api_keys().add(expected_revision=0, label=label, api_key=key) + assert not owner.root.exists() + + +def test_symlink_and_public_permissions_are_refused(owner, tmp_path): + keys = owner.api_keys() + keys.add(expected_revision=0, label="Primary", api_key=KEY) + keys.path.chmod(0o644) + with pytest.raises(Exception, match="state_untrusted"): + keys.status() + keys.path.chmod(0o600) + target = tmp_path / "target.json" + keys.path.rename(target) + keys.path.symlink_to(target) + with pytest.raises(Exception, match="state_untrusted"): + keys.status() + + +@pytest.mark.parametrize("http_status,expected", [(200, "models_readable"), (401, "authentication_failed"), + (403, "permission_denied"), (429, "rate_limited"), (503, "unavailable")]) +def test_model_listing_does_not_claim_billing_quota(owner, monkeypatch, http_status, expected): + module = importlib.import_module("loopx_codex_provider_routing.openai_keys") + seen = [] + class Opener: + def open(self, request, timeout): + seen.append(request) + assert timeout == 10 and request.get_method() == "GET" + assert request.full_url == "https://api.openai.com/v1/models" + if http_status != 200: + raise urllib.error.HTTPError(request.full_url, http_status, "private provider text", {}, io.BytesIO(KEY.encode())) + return io.BytesIO(b'{"data":[{"id":"synthetic-model"}]}') + monkeypatch.setattr(module.urllib.request, "build_opener", lambda *_: Opener()) + keys = owner.api_keys() + saved = keys.add(expected_revision=0, label="Primary", api_key=KEY) + result = keys.check(expected_revision=1, key_id=saved["keys"][0]["id"]) + assert len(seen) == 1 and result["keys"][0]["check_status"] == expected + assert result["balance_available"] is False + assert KEY not in json.dumps(result) and "private provider text" not in json.dumps(result) + + +def test_transport_failure_and_redirect_keep_the_key_without_guessing_quota(owner, monkeypatch): + module = importlib.import_module("loopx_codex_provider_routing.openai_keys") + class Offline: + def open(self, *_args, **_kwargs): + raise OSError("private network detail") + monkeypatch.setattr(module.urllib.request, "build_opener", lambda *_: Offline()) + assert module.check_models(KEY) == "unavailable" + with pytest.raises(Exception, match="key_check_unavailable"): + module.NoRedirect().redirect_request(None, None, None, None, None, None) + + +def test_http_owner_add_read_remove_and_bad_origin(owner): + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + from loopx.presentation.codex_accounts_api import CodexAccountService + service = CodexAccountService(owner.root.parents[1]) + service.provider = owner + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.verbose = False + server.codex_account_service = service + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://127.0.0.1:{server.server_port}/api/chat/codex-accounts/keys" + def call(body=None, origin=None): + data = json.dumps(body).encode() if body else None + request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json", **({"Origin": origin} if origin else {})}) + with urllib.request.urlopen(request, timeout=5) as response: + return json.load(response) + try: + assert call()["keys"] == [] and not owner.root.exists() + added = call({"operation": "keys-add", "parameters": {"expected_revision": 0, "label": "Primary", "api_key": KEY}}) + assert KEY not in json.dumps(added) and call() == added + with pytest.raises(urllib.error.HTTPError) as error: + call({"operation": "keys-add", "parameters": {}}, origin="https://outside.example") + assert error.value.code == 403 + for operation, parameters in [("keys-check", {"expected_revision": 1, "key_id": added["keys"][0]["id"], "base_url": "https://outside.example"}), + ("switch", {"manual_slot": "a"})]: + with pytest.raises(urllib.error.HTTPError) as error: + call({"operation": operation, "parameters": parameters}) + assert error.value.code == 409 + removed = call({"operation": "keys-remove", "parameters": {"expected_revision": 1, "key_id": added["keys"][0]["id"]}}) + assert removed["keys"] == [] and not owner.path.exists() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + service.close() + + +def test_cli_takes_secrets_only_on_stdin(owner, tmp_path): + command = [sys.executable, "-m", "loopx_codex_provider_routing.native"] + arguments = ["--runtime-root", str(tmp_path)] + result = subprocess.run([*command, "keys-add", *arguments], input=json.dumps({"expected_revision": 0, "label": "Primary", "api_key": KEY}), + text=True, capture_output=True, check=True, timeout=10) + assert KEY not in result.stdout + result.stderr + assert len(json.loads(result.stdout)["keys"]) == 1 + read = subprocess.run([*command, "keys-status", *arguments], text=True, capture_output=True, check=True, timeout=10) + assert json.loads(read.stdout)["keys"][0]["label"] == "Primary" diff --git a/tests/presentation/test_openai_relay.py b/tests/presentation/test_openai_relay.py new file mode 100644 index 0000000000..54f0be22ae --- /dev/null +++ b/tests/presentation/test_openai_relay.py @@ -0,0 +1,134 @@ +"""Real loopback HTTP and private store, with synthetic upstream transport.""" +import importlib +import io +import json +import os +from pathlib import Path +import threading +import urllib.error +import urllib.request + +import pytest + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Native policy uses POSIX locks") + + +class Response(io.BytesIO): + status = 200 + headers = {"Content-Type": "text/event-stream", "x-ratelimit-remaining-tokens": "1234"} + + +@pytest.fixture +def relay(monkeypatch, tmp_path): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "packages/loopx-codex-provider-routing/src")) + native = importlib.import_module("loopx_codex_provider_routing.native") + module = importlib.import_module("loopx_codex_provider_routing.openai_relay") + owner = native.NativeAccounts(tmp_path, clock=lambda: 1000) + keys = owner.api_keys() + for index in range(2): + keys.add(expected_revision=index, label=f"Key {index}", api_key="sk-" + "synthetic-key-" * 3 + str(index)) + state = owner._load() + state["policy"].update(enabled=True, api_fallback=True) + state["native"] = {"active_slot": "openai-api"} + native.atomic(owner.path, state) + calls, outcomes = [], [] + class Upstream: + def open(self, request, timeout): + calls.append(request) + assert request.full_url == "https://api.openai.com/v1/responses" + assert timeout == 300 + outcome = outcomes.pop(0) + if isinstance(outcome, BaseException): + raise outcome + return outcome + server = module.RelayServer(owner, {"port": 0, "token": "synthetic-loopback-token"}, opener=Upstream()) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + body = {"model": "unchanged-selected-model", "input": "synthetic prompt", "stream": True, "tools": []} + def post(token="synthetic-loopback-token", origin=None): + headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"} + if origin: + headers["Origin"] = origin + request = urllib.request.Request(f"http://127.0.0.1:{server.server_port}/v1/responses", data=json.dumps(body).encode(), headers=headers) + try: + with urllib.request.urlopen(request, timeout=10) as response: + return response.status, response.read() + except urllib.error.HTTPError as error: + return error.code, error.read() + yield owner, keys, calls, outcomes, post, body + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def rejected(status=429, code="insufficient_quota"): + return urllib.error.HTTPError("https://api.openai.com/v1/responses", status, "private upstream text", {}, + io.BytesIO(json.dumps({"error": {"code": code, "message": "private upstream text"}}).encode())) + + +def test_real_http_rotates_only_rejected_quota_and_preserves_model_body_and_stream(relay): + owner, keys, calls, outcomes, post, body = relay + stream = b'data: {"type":"response.completed","response":{"status":"completed"}}\n\n' + outcomes.extend([rejected(), Response(stream)]) + status, result = post() + assert status == 200 and result == stream and len(calls) == 2 + assert calls[0].data == calls[1].data == json.dumps(body).encode() + assert calls[0].get_header("Authorization") != calls[1].get_header("Authorization") + rows = keys.status()["keys"] + assert rows[0]["runtime"]["state"] == "quota_exhausted" + assert rows[1]["runtime"]["remaining_tokens"] == 1234 + assert keys.status()["balance_available"] is False + assert "synthetic prompt" not in keys.path.read_text() + + +def test_network_unknown_effects_never_try_another_key(relay): + _, keys, calls, outcomes, post, _ = relay + outcomes.append(OSError("connection lost after dispatch")) + status, _ = post() + assert status == 502 and len(calls) == 1 + assert all(row["runtime"] is None for row in keys.status()["keys"]) + + +def test_started_stream_never_replays_or_synthesizes_completion(relay): + _, _, calls, outcomes, post, _ = relay + class Interrupted(Response): + def read1(self, _size): + if self.tell(): + raise OSError("upstream disconnected") + return self.read() + partial = b'data: {"type":"response.created"}\n\n' + outcomes.append(Interrupted(partial)) + status, result = post() + assert status == 200 and result == partial and len(calls) == 1 + assert b"response.completed" not in result + + +def test_pool_exhaustion_is_bounded_and_does_not_probe_in_background(relay): + _, _, calls, outcomes, post, _ = relay + outcomes.extend([rejected(), rejected()]) + status, result = post() + assert status == 429 and len(calls) == 2 + assert b"private upstream text" not in result + status, _ = post() + assert status == 429 and len(calls) == 2, "Cooling keys must not be retried on each request" + + +def test_default_off_origin_and_token_guards_precede_upstream_access(relay): + owner, _, calls, _, post, _ = relay + assert post(token="other-token")[0] == 401 + assert post(origin="https://outside.example")[0] == 401 + native = importlib.import_module("loopx_codex_provider_routing.native") + state = owner._load() + state["policy"]["api_fallback"] = False + native.atomic(owner.path, state) + assert post()[0] == 503 + assert calls == [] + + +def test_enabled_api_pool_cannot_outrank_an_active_subscription(relay): + owner, _, calls, _, post, _ = relay + native = importlib.import_module("loopx_codex_provider_routing.native") + state = owner._load() + state["native"]["active_slot"] = "subscription-a" + native.atomic(owner.path, state) + assert post()[0] == 503 and calls == [] From 5a3878d4f9b1d8435a70cc7935dd14579e47aae8 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:28:30 +0800 Subject: [PATCH 02/15] docs(codex): explain API fallback activation and quota readback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../loopx-codex-provider-routing/NATIVE.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index 752e2ee537..1b003406b4 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -15,6 +15,75 @@ selects/reopens when the native owner admits it. Enable automatic handoff and The page reads back accounts, windows, waiting cause and recovery. Tokens and identity claims stay private. +### OpenAI API fallback + +Version 0.13.0 adds **OpenAI API keys** on the same Model provider page. Add a +named project key, read its masked suffix, explicitly check connection, or remove +it. Keys remain in a separate private mode-0600 file under the same runtime owner; +browser/CLI responses never return their complete values. Adding a key alone does +not enable an observer, change the native login or authorize paid model calls. + +Enable subscription automatic handoff, then **Use API after subscription exhaustion +(usage based billing)**. API fallback is a separate, default-off permission. +Every authorized subscription must have fresh measured exhaustion before the +native policy selects API. A healthy subscription always ranks first; an unknown +or failed subscription check cannot opt a request into paid use. While API is +active, a recovered subscription wins at the existing idle/restart boundary and +switch cooldown. Model settings and history stay with the same GPT route. + +The native Personal Apps account owner must support `API_KEY_SLOTS_VERSION = 1`. +It enrolls one loopback relay credential and reuses its original launch/account +locks, idle checks, credential/config transaction, readback and rollback. Real +OpenAI keys stay in this extension, outside the native account vault. An older +owner reports `api_owner_upgrade_required`; it is never monkey-patched or bypassed. +The native owner uses the official `openai_base_url` override on the built-in +`openai` provider and removes that override on subscription return. The provider +identity stays stable for existing conversations; other custom providers remain +outside this owner. + +A local authenticated transport sends the original selected model and Responses +body to OpenAI. An explicit upstream quota rejection, 401, 403 or temporary 429 can +try the next configured key before output. Each key is attempted at most once per +request, with typed cooldowns; model permission failures are scoped to that model. +Unknown network effects, ordinary request/server failures and any started stream +are not retried on another key. No job replay, model substitution or synthetic +completion is added. There are no background paid probes. The transport survives +the Chat/App restart used for native handoff; disabling stops new admissions and +drains already accepted requests before its process exits. With the policy off, +an existing subscription is never routed through this transport. + +**Check connection** only requests the official +[`GET /v1/models`](https://developers.openai.com/api/reference/resources/models/methods/list). +It makes no inference call and does not establish spendable quota. Its 401, 403, +429 and network states remain distinct. A models permission denial does not prove +inference is unavailable. The App shows per-key actual request outcomes, model, +last observation time and the last reported rate-window requests/tokens left when +OpenAI supplies those headers. These are observations of a rate window, not a +monthly allowance or account balance. OpenAI's +[`Costs` endpoint](https://developers.openai.com/api/reference/resources/admin/subresources/organization/subresources/usage/methods/costs) +requires an Admin API key; project keys do not expose an authoritative billing +balance. The page retains **API balance unavailable** and links to OpenAI usage. + +The same private store is available through `loopx-codex-accounts keys-status`, +`keys-add`, `keys-remove` and `keys-check`. Mutations accept JSON on stdin with +`expected_revision` from `keys-status`. `keys-add` takes `label` and `api_key`; +`keys-remove` and `keys-check` take the returned `key_id`. Never pass a key as a +command-line argument. Native `configure` accepts `api_fallback: true` alongside +its existing policy fields. A stale revision refuses a mutation; re-read before +retrying. Removing a saved key does not revoke it at OpenAI. Disable API fallback +to stop new API requests; select an available subscription through the existing +native handoff controls. The current native recovery/rollback owner retains any +incomplete credential transaction. + +0.13.0 在同一设置中提供 Key 配置、脱敏回读、只读检查和默认关闭的 API 接续开关。 +订阅始终优先:所有授权订阅均被新鲜观测确认为耗尽后才进入 API;订阅恢复后沿用原有 +空闲、重启、冷却和恢复边界返回。原生账号 owner 需要支持 API 槽位版本 1,API 上游 +Key 留在扩展私有存储,原生槽位只保存本地中转凭据,不绕过原事务或复制会话历史。 +Key 轮换依据输出前收到的明确额度、认证、权限或限流错误,每次请求每把 Key 最多 +尝试一次;网络效果不明、普通请求错误和已开始输出的流不会重放,不替换模型,也不做 +后台付费探测。关闭后停止新请求并等待已接收请求结束。App 显示实际请求结果和上次 +限流窗口余量;项目 Key 的账单余额保持未知,连接检查成功也不代表还有可消费额度。 + **An open native Codex client is an occupied owner, even between turns.** With restart permission, the provider requests a normal AppKit quit of only the inspected GPT instance, waits for exit and native idle, saves/switches the login, From 7af6c4da3d00a5ff21f25e23a7452c64d077ad6d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:03:13 +0800 Subject: [PATCH 03/15] refactor(codex): own native account transactions in the optional package Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../smoke/codex-account-clients-fixture.py | 20 +- .../smoke/openai-key-settings-smoke.mjs | 17 +- .../codex-account-settings.tsx | 14 +- .../openai-key-settings.tsx | 4 +- loopx/presentation/codex_accounts_api.py | 3 + .../loopx_codex_provider_routing/native.py | 16 +- .../native_account_store.py | 441 ++++++++++++++++++ .../native_app.py | 262 +++++++++++ .../native_host.py | 55 ++- tests/presentation/test_codex_accounts.py | 3 +- .../presentation/test_native_account_store.py | 432 +++++++++++++++++ .../test_native_accounts_install.py | 96 ++++ tests/presentation/test_native_app.py | 127 +++++ 13 files changed, 1457 insertions(+), 33 deletions(-) create mode 100644 packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py create mode 100644 packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_app.py create mode 100644 tests/presentation/test_native_account_store.py create mode 100644 tests/presentation/test_native_accounts_install.py create mode 100644 tests/presentation/test_native_app.py diff --git a/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py b/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py index b615df0357..852c3e473c 100644 --- a/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py +++ b/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py @@ -38,17 +38,24 @@ def main() -> None: mode, calls = "many", [] api_fixture = os.environ.get("LOOPX_API_FALLBACK_FIXTURE") == "1" active, selection, api_slot, recovered, now = "a", "synthetic", None, False, time.time() + slots, registration_busy = ["a", "b", "c"], False if api_fixture: relay_module = importlib.import_module("loopx_codex_provider_routing.openai_relay") relay_module.ensure = lambda _: {"slot": "openai-api", "api_key": "synthetic-relay-token", "base_url": "http://127.0.0.1:32100/v1"} def transport(adapter, operation, **parameters): - nonlocal active, selection, api_slot + nonlocal active, selection, api_slot, slots calls.append(operation) if operation == "status": - return {"slots": ["a", "b", "c"], "active_slot": active, "selection_revision": selection, + return {"slots": slots, "active_slot": active, "selection_revision": selection, "current_login_matches": True, "recovery_required": False, "api_fallback_supported": api_fixture, "api_slot": api_slot} + if operation == "enroll_current": + assert parameters["expected_revision"] == selection and slots == [] + if registration_busy: + raise native.HostError("waiting_for_idle") + slots, active, selection = ["primary"], "primary", selection + "-registered" + return transport(adapter, "status") if api_fixture and operation == "enroll_api": api_slot = "openai-api" return transport(adapter, "status") @@ -95,6 +102,15 @@ def transport(adapter, operation, **parameters): elif command == "subscription-recovered" and api_fixture: recovered, now = True, now + 301 print(json.dumps({"subscription_recovered": True}), flush=True) + elif command == "unregistered": + slots, active, selection, registration_busy = [], None, "unregistered", True + result = service.call("configure", {"expected_revision": provider.status()["revision"], + "enabled": False, "slots": [], "api_fallback": False}) + assert result["ok"] + print(json.dumps({"unregistered": True}), flush=True) + elif command == "registration-ready": + registration_busy = False + print(json.dumps({"registration_ready": True}), flush=True) else: raise ValueError("Unknown fixture command") finally: diff --git a/apps/presentation/dashboard/smoke/openai-key-settings-smoke.mjs b/apps/presentation/dashboard/smoke/openai-key-settings-smoke.mjs index 3ffd7495d6..ffe8032bc9 100644 --- a/apps/presentation/dashboard/smoke/openai-key-settings-smoke.mjs +++ b/apps/presentation/dashboard/smoke/openai-key-settings-smoke.mjs @@ -116,9 +116,20 @@ try { assert.equal(state.watcher_started, true); assert.equal(state.calls.filter(operation => operation === "switch").length, 2, "API fallback then recovered subscription return"); assert.equal(api.turnRequests.length, 0); - // Deliberate lost-response network error is the only expected console error. - assert.ok(workspace.errors.every(error => error.includes("net::ERR_FAILED")), workspace.errors.join("\n")); - console.log(JSON.stringify({ok: true, boundary: "packaged UI / real HTTP, native policy and private store / synthetic process and OpenAI IO", added: 3, removed: 1, lostWriteReplayed: false, automaticSwitches: 2})); + await command("unregistered"); + const register = native.getByRole("button", {name: "Register current Codex login", exact: true}); + await register.waitFor(); + await register.click(); + await native.getByRole("alert").waitFor(); + await command("registration-ready"); + await register.click(); + await native.locator("header").getByText(/Current PRIMARY/).waitFor(); + assert.equal(await native.getByRole("checkbox", {name: "Switch automatically when quota runs out", exact: true}).isChecked(), false); + assert.equal(await panel.getByRole("checkbox", {name: "Use API after subscription exhaustion (usage based billing)", exact: true}).isChecked(), false); + // The lost write response and occupied-login registration are deliberate failures. + assert.equal(workspace.errors.filter(error => error.includes("status of 409")).length, 1); + assert.ok(workspace.errors.every(error => error.includes("net::ERR_FAILED") || error.includes("status of 409")), workspace.errors.join("\n")); + console.log(JSON.stringify({ok: true, boundary: "packaged UI / real HTTP, native policy and private store / synthetic process and OpenAI IO", added: 3, removed: 1, lostWriteReplayed: false, automaticSwitches: 2, registrationRecovery: true})); } finally { await workspace?.close(); await browser?.close(); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx index 7598bf2288..b284ddd09d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx @@ -37,6 +37,7 @@ type ClientInventory = z.infer; const copy = { "zh-CN": { title: "Codex 原生账号", enabled: "自动接续已启用", disabled: "自动接续已关闭", + enroll: "登记当前 Codex 登录", enrollHint: "关闭使用此登录的 Codex 客户端后登记;保留原来的模型设置和会话。", native_login_required: "请先在原生 Codex 中使用 ChatGPT 完成登录。", enable: "额度耗尽后自动切换", refresh: "检查额度", retry: "重新连接", selectionHelp: "自动选择顺序", selectionDetail: "优先选择剩余额度比例最多的可用账号;短、长周期取剩余较少的一项。平手从当前账号之后按已登记顺序选择。这里比较的是额度百分比,无法获知绝对剩余 token。当前账号可用时不会主动切换。", effect: "仅使用下方已登记账号。额度耗尽后正常退出 GPT 专用客户端,保存登录态并切换,再重新打开原来的界面。", @@ -58,7 +59,7 @@ const copy = { unknown: "额度未知", credits: "另有可用额度", help: "运行与恢复说明", last: "上次检查", detail: "检测不会调用模型、购买额度或使用重置券。401 登录失效先由原生客户端刷新;其他账号可通过官方 CLI 单独刷新。查询失败不会被判为额度耗尽。自动退出仅针对 GPT 专用实例;启用强制关闭后,正常退出失败会自动结束同一个已核验实例。其他 CLI/文件占用仍会暂停切换。独立进程完成切换和重开,即使发起切换的 Codex 已退出。切换保留模型设置和会话历史,不重放已提交工作。自动观察随 LoopX 服务运行。", extension_uninstalled: "尚未安装 Codex 账号扩展。安装 loopx-codex-provider-routing 后重新启动 LoopX 服务。", - adapter_unavailable: "未发现已安装的 Personal Apps 槽位管理器和 Codex CLI。", + adapter_unavailable: "未发现官方 Codex CLI,请安装后重试。", native_unavailable: "无法连接原生槽位管理器,请检查本机安装后重试。", launchFailed: "账号已切换,但界面未能重新打开。可以从原来的 GPT 入口打开;请勿重复切换。", failure: "操作未完成。刷新状态后可以重试。", recoveryHint: "恢复会还原未完成事务;撤销会还原最近一次切换。两者都需要客户端关闭,且不会覆盖后续登录修改。", @@ -69,11 +70,12 @@ const copy = { clientsDetail: "清单记录检查时的进程归属,切换时会重新核验。支持受控重启仍需已启用退出与重开权限;外部工作负载须先核实空闲及恢复条件。检查占用不读取凭证内容、不换号、不结束进程。", manager_unverified: "尚未核实管理器,保留占用等待", api_active: "正在使用 OpenAI API,订阅恢复后优先返回", api_disabled_active: "API 接续已关闭,等待可用订阅", - api_owner_upgrade_required: "请更新原生槽位管理器以支持 API 接续", api_keys_missing: "请先添加 OpenAI API Key", api_relay_unavailable: "API 接续服务暂不可用,请重新检查", + api_owner_upgrade_required: "请更新 LoopX 账号扩展以支持 API 接续", api_keys_missing: "请先添加 OpenAI API Key", api_relay_unavailable: "API 接续服务暂不可用,请重新检查", external_workload_unverified: "外部工作负载的空闲与恢复条件尚未核实", }, en: { title: "Native Codex accounts", enabled: "Automatic handoff enabled", disabled: "Automatic handoff off", + enroll: "Register current Codex login", enrollHint: "Close Codex clients using this login before registering. Existing model settings and history stay in place.", native_login_required: "Sign in with ChatGPT in native Codex first.", 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.", @@ -95,7 +97,7 @@ const copy = { unknown: "Quota unknown", credits: "Additional allowance available", help: "Operation and recovery", last: "Last checked", detail: "Checks do not call a model, buy credits or use reset coupons. Native Codex refreshes an expired active login; other accounts can refresh separately through the official CLI. A failed check never means exhausted quota. Automatic quit targets only the dedicated GPT instance. With force quit enabled, failed normal quit ends that same verified instance automatically. Another CLI/file owner still pauses switching. An independent process completes switching and reopening even when the calling Codex exits. Model settings and history stay intact; submitted work is never replayed. Observation runs with the LoopX service.", extension_uninstalled: "Install the optional loopx-codex-provider-routing package, then restart the LoopX service.", - adapter_unavailable: "The installed Personal Apps slot manager and Codex CLI were not found.", + adapter_unavailable: "The official Codex CLI was not found. Install it and retry.", native_unavailable: "Cannot connect to the native slot manager. Check the local installation and retry.", launchFailed: "The account switched, but the interface could not reopen. Open the original GPT entry; do not repeat the switch.", failure: "The operation did not finish. Refresh its status before retrying.", recoveryHint: "Recovery restores an interrupted transaction; undo restores the last switch. Both require closed clients and refuse to overwrite later login changes.", @@ -106,7 +108,7 @@ const copy = { clientsDetail: "This inventory records process ownership at check time; switching revalidates it. Controlled restart still requires quit-and-reopen permission. External workloads need verified idle and recovery conditions. This check reads no credential contents, switches no account and ends no process.", manager_unverified: "Manager not verified; the holder remains a hold", api_active: "Using OpenAI API; recovered subscriptions take priority", api_disabled_active: "API fallback is off; waiting for a subscription", - api_owner_upgrade_required: "Update the native slot manager for API fallback", api_keys_missing: "Add an OpenAI API key first", api_relay_unavailable: "API fallback transport unavailable; recheck", + api_owner_upgrade_required: "Update the LoopX accounts extension for API fallback", api_keys_missing: "Add an OpenAI API key first", api_relay_unavailable: "API fallback transport unavailable; recheck", external_workload_unverified: "External workload idle and recovery conditions are unverified", }, }; @@ -184,6 +186,10 @@ export function CodexAccountSettings() {

{c.title}

{status?.active_slot ? `${c.current} ${status.active_slot.toUpperCase()} · ` : ""}{enabled ? c.enabled : c.disabled}
{!status ?

{c.checking}

: null} {(problem && problem !== status?.action) || status?.available === false ?

{label(problem)}

: null} + {status?.available && !status.slots?.length ? <>

{c.enrollHint}

+ : null} {status?.available && status.slots?.length ? <>

{restartClients ? forceQuitClients ? c.effectForce : c.effect : c.effectWait}