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 7aa8c133dd..0df572b0d9 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 @@ -12,9 +12,10 @@ type AccountStatus = { active_slot?: string | null; selection_revision?: string; action?: string; target?: string | null; recovery_required?: boolean; launch_failed?: boolean; api_fallback_supported?: boolean; api_active?: boolean; + steward_api_role_supported?: boolean; observer?: { state: string; checked_at: number | null; next_check_at: number | null }; policy?: { enabled: boolean; slots: string[]; cooldown_seconds: number; restart_clients?: boolean; - force_quit_clients?: boolean; api_fallback?: boolean }; + force_quit_clients?: boolean; api_fallback?: boolean; steward_api_role?: "disabled" | "fallback" | "primary" }; slots?: string[]; last_checked_at?: number | null; last_switch_at?: number | null; accounts?: Array<{ slot: string; availability: string; observed_at: number; primary_used: number | null; secondary_used: number | null; reset_at: number | null; @@ -174,6 +175,7 @@ export function CodexAccountSettings() { method: "POST", body: JSON.stringify({ operation, parameters }), }); setStatus(result); + if (operation === "configure") await read(); } catch { await read(); setError(true); } finally { setBusy(false); void readInventory(); } } @@ -188,6 +190,11 @@ export function CodexAccountSettings() { const appProcesses = new Map(); for (const client of appHolders) appProcesses.set(client.process, [...(appProcesses.get(client.process) ?? []), client.pid]); return <> void act("configure", { + expected_revision: status?.revision, enabled, slots: authorizedSlots, steward_api_role: value, + })} apiEnabled={status?.policy?.api_fallback === true} nativeBusy={busy || ["checking", "restarting", "handoff_pending"].includes(status?.action ?? "")} onToggle={value => void act("configure", { expected_revision: status?.revision, enabled, slots: authorizedSlots, api_fallback: value, })} />
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 index 164b992a23..bcb48ba1a1 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/openai-key-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/openai-key-settings.tsx @@ -20,9 +20,10 @@ 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: "LoopX 账号扩展需要更新,暂不能启用 API 接续", + title: "OpenAI API Key", configured: "已保存", boundary: "工作 Agent 与 Codex App:订阅优先,API 仅作兜底", + help: "API Key 与额度", detail: "密钥只保存在本机,不回显完整值。工作 Agent 只有在全部订阅被确认耗尽后才使用 API,额度未知时等待。管家可独立优先使用 API,复用同一 Key 池,不切换工作 Agent 或 Codex App 的登录;关闭管家 API 不影响工作 Agent 的兜底。管家选择跟随订阅兜底时,使用下方订阅自动切换的耗尽证明。策略在下一条未开始的消息生效,不重放已有请求。API 额度或权限错误只在输出前尝试下一把 Key,模型不变。检查连接只读模型列表,不能证明可消费额度;项目 Key 无法查询账单余额。", + auto: "工作 Agent 订阅耗尽后使用 API(按用量计费)", autoOff: "需先启用下方的订阅自动切换", ownerMissing: "LoopX 账号扩展需要更新,暂不能启用 API 接续", + steward: "管家使用 API", primary: "优先使用(按用量计费)", fallback: "跟随订阅兜底", disabled: "不使用", lastRequest: "上次响应", accepted: "API 已接受请求", quota_exhausted: "API 额度不足", failed: "API 请求失败", tokens: "限流窗口 Token 余量", requests: "限流窗口请求余量", apiPermission: "API 模型权限不足", apiLimited: "API 请求限流", balance: "API 余额不可读取", billing: "查看 OpenAI 用量", add: "添加 Key", save: "保存 Key", cancel: "取消", @@ -37,9 +38,10 @@ const copy = { key_limit_reached: "最多保存 16 把 Key。", key_not_found: "这把 Key 已被移除,列表已刷新。", }, en: { - title: "OpenAI API keys", configured: "saved", boundary: "Subscriptions first; API only after all subscriptions are exhausted", - help: "API keys and quota", detail: "Keys stay on this machine and are never returned in full. API fallback is admitted only after every subscription is confirmed exhausted. Recovered subscriptions take priority at a safe handoff boundary. API quota or permission errors can try the next key before output, preserving the model. Requests that started output are never replayed. Connection checks only read the model list and do not establish spendable quota. Project keys cannot query billing balance; the Costs API requires a separate Admin key.", - auto: "Use API after subscription exhaustion (usage based billing)", autoOff: "Enable subscription automatic handoff below first", ownerMissing: "Update the LoopX accounts extension before enabling API fallback", + title: "OpenAI API keys", configured: "saved", boundary: "Working Agents and Codex App: subscriptions first; API as fallback", + help: "API keys and quota", detail: "Keys stay on this machine and are never returned in full. Working Agents use API only after every subscription is confirmed exhausted; unknown allowance holds. The steward can independently prefer API through the same key pool without changing worker or Codex App login. Disabling steward API preserves worker fallback. Following subscription fallback uses the native account owner's exhaustion proof. Changes apply to the next unstarted message and never replay existing requests. API quota or permission errors may try the next key only before output, preserving the model. Connection checks read models, not spendable quota. Project keys cannot query billing balance.", + auto: "Use API after working Agent subscriptions are exhausted (usage based billing)", autoOff: "Enable subscription automatic handoff below first", ownerMissing: "Update the LoopX accounts extension before enabling API fallback", + steward: "Steward API use", primary: "Prefer API (usage based billing)", fallback: "Follow subscription fallback", disabled: "Disabled", lastRequest: "Last response", accepted: "API accepted the request", quota_exhausted: "API quota exhausted", failed: "API request failed", tokens: "Rate-window tokens left", requests: "Rate-window requests left", apiPermission: "API model permission denied", apiLimited: "API request rate limited", balance: "API balance unavailable", billing: "View OpenAI usage", add: "Add key", save: "Save key", cancel: "Cancel", @@ -55,8 +57,11 @@ const copy = { }, }; -export function OpenAIKeySettings({ automatic, supported, apiEnabled, onToggle, nativeBusy = false }: { +export function OpenAIKeySettings({ automatic, supported, apiEnabled, onToggle, nativeBusy = false, + stewardSupported = false, stewardRole = "disabled", onStewardRole }: { automatic: boolean; supported: boolean | null; apiEnabled: boolean; onToggle: (enabled: boolean) => void; nativeBusy?: boolean; + stewardSupported?: boolean; stewardRole?: "disabled" | "fallback" | "primary"; + onStewardRole?: (role: "disabled" | "fallback" | "primary") => void; }) { const { locale } = useWorkspaceI18n(); const c = copy[locale]; @@ -112,6 +117,13 @@ export function OpenAIKeySettings({ automatic, supported, apiEnabled, onToggle, disabled={busy || nativeBusy || (!apiEnabled && (!automatic || !supported || !status?.keys.length))} onChange={event => onToggle(event.target.checked)} />{c.auto} {!apiEnabled && supported === false ?

{c.ownerMissing}

: !apiEnabled && !automatic ?

{c.autoOff}

: null} + {stewardSupported ? : null} {status ? <>
{status.keys.map(row =>
diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index 51ded7160f..a0515d3871 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -59,11 +59,11 @@ "dict_any_count": 0 }, "loopx/control_plane/goals/goal_frontier/terminal.py": { - "any_count": 15, + "any_count": 22, "dict_any_count": 0 }, "loopx/control_plane/work_items/interaction_contract.py": { - "any_count": 85, + "any_count": 107, "dict_any_count": 0 }, "loopx/extensions/lark/goal_topic_runtime.py": { @@ -90,7 +90,7 @@ "dict_any_count": 0 }, "loopx/pr_review.py": { - "any_count": 49, + "any_count": 50, "dict_any_count": 0 }, "loopx/presentation/renderers/status_markdown.py": { @@ -103,7 +103,7 @@ "lines": 1999 }, "loopx/slash_command_install.py": { - "any_count": 11, + "any_count": 12, "dict_any_count": 0 }, "loopx/state_refresh.py": { diff --git a/loopx/capabilities/native_chat/codex_adapter.py b/loopx/capabilities/native_chat/codex_adapter.py index 1bed7bd812..f9a89e80fc 100644 --- a/loopx/capabilities/native_chat/codex_adapter.py +++ b/loopx/capabilities/native_chat/codex_adapter.py @@ -321,6 +321,8 @@ def start( reasoning_effort: str | None = None, service_tier: str | None = None, dynamic_tools: list[dict[str, Any]] | None = None, + account_runtime_root: Path | None = None, + account_consumer: str = "worker", ) -> "CodexAppServerAdapter": history_items = [] # Migration restores the full visible conversation in native history. @@ -358,6 +360,8 @@ def start( reasoning_effort=reasoning_effort, host_config={"service_tier": service_tier} if service_tier else None, dynamic_tools=dynamic_tools, + account_runtime_root=account_runtime_root, + account_consumer=account_consumer, ) if not resume_thread_id: try: diff --git a/loopx/capabilities/native_chat/codex_auth.py b/loopx/capabilities/native_chat/codex_auth.py index adc6b33725..08380ebe93 100644 --- a/loopx/capabilities/native_chat/codex_auth.py +++ b/loopx/capabilities/native_chat/codex_auth.py @@ -117,3 +117,87 @@ def for_isolated_process(base_home: Path, isolated_home: Path, codex_bin: str) - if (isolated_home / "auth.json").exists() or not (base_home / "auth.json").exists(): return None return CodexHostModelAuth(base_home, codex_bin) + + +@dataclass(repr=False) +class CodexApiTransport: + """Only a consumer-scoped loopback token reaches the model process.""" + endpoint: str + token: str = field(repr=False) + provider: str = "loopx_account_api" + env_key: str = "LOOPX_MODEL_RELAY_TOKEN" + + def configuration(self, current: dict[str, Any], effective: dict[str, Any] | None = None) -> dict[str, Any]: + # Native overlays merge maps, so a lower-layer alias must not supply + # another URL/header/env key beneath our model-only transport. + if self.provider in (effective or {}).get("model_providers", {}): + raise CodexHostAuthUnavailable() + environment = current.get("shell_environment_policy", {}) + exclude = [*environment.get("exclude", [])] + if self.env_key not in exclude: + exclude.append(self.env_key) + return {**current, "model_provider": self.provider, "model_providers": { + **current.get("model_providers", {}), self.provider: { + "name": "LoopX account API", "base_url": self.endpoint, + "env_key": self.env_key, "wire_api": "responses", + "requires_openai_auth": False, "supports_websockets": False}}, + "shell_environment_policy": {**environment, + "exclude": exclude}} + + +def native_api_transport(runtime_root: Path, base_home: Path, *, consumer: str, + model: str | None = None) -> CodexApiTransport | None: + try: + from loopx_codex_provider_routing.native import NativeAccounts + except ImportError: + return None # Optional provider absence preserves native authentication. + try: + prepare = getattr(NativeAccounts(runtime_root), 'model_transport', None) + endpoint = prepare(codex_home=base_home, consumer=consumer, model=model) if callable(prepare) else None + return CodexApiTransport(endpoint['base_url'], endpoint['api_key']) if endpoint else None + except Exception: + raise CodexHostAuthUnavailable() from None + + +def prepare_api_transport(runtime_root: Path | None, home: Path, *, isolated_home: Path | None, + eligible: bool, consumer: str, model: str | None) -> CodexApiTransport | None: + if (runtime_root is None or not eligible or isolated_home is not None + and ((isolated_home / "auth.json").exists() or (isolated_home / "auth.json").is_symlink())): + return None + try: + return native_api_transport(runtime_root, home, consumer=consumer, model=model) + except CodexHostAuthUnavailable: + from ...chat_agent import CodexChatAgentError + raise CodexChatAgentError("Native account API transport is unavailable.", + error_code="model_auth_unavailable", gate=None) from None + + +def authenticate_isolated(session, home: Path, isolated: Path, binary: str, *, api_transport) -> CodexHostModelAuth | None: + if api_transport is not None: + return None + try: + broker = for_isolated_process(home, isolated, binary) + if broker is not None: + login = session._request("account/login/start", { + "type": "chatgptAuthTokens", **broker.read()}, request_id=4) + if login.get("type") != "chatgptAuthTokens": + raise ValueError("unexpected native model authentication mode") + return broker + except Exception: + raise session._runtime_error( + "Trusted-host Codex model authentication is unavailable. " + "Restore the host account and retry this same Session.") from None + + +def conversation_account_epoch(runtime_root: Path, home: Path, **consumer) -> tuple[str, str] | None: + try: + from loopx_codex_provider_routing.native import NativeAccounts + owner = NativeAccounts(runtime_root) + return owner.conversation_epoch(codex_home=home, + **(consumer if callable(getattr(owner, 'model_transport', None)) else {})) + except ImportError: + return None + except Exception as exc: + import logging + logging.getLogger(__name__).warning("Native account epoch observation held (%s)", type(exc).__name__) + return None diff --git a/loopx/capabilities/native_chat/codex_context.py b/loopx/capabilities/native_chat/codex_context.py index f416b273a7..66d398e5db 100644 --- a/loopx/capabilities/native_chat/codex_context.py +++ b/loopx/capabilities/native_chat/codex_context.py @@ -134,6 +134,11 @@ def shared_chatgpt_transport(host_config: Mapping[str, Any], native_config: Mapp }} +def verify_conversation_provider(session, requested: str, observed: Mapping[str, Any]) -> None: + if requested and observed.get("modelProvider") != requested: + raise session._runtime_error("Codex did not apply the requested conversation provider.") + + def disable_mcp_servers(config: Mapping[str, Any], host_config: Mapping[str, Any]) -> dict[str, Any]: """Disable private native tool sources, including project/managed layers. diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 99a8a2bba3..1d5a46c2d8 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -745,6 +745,8 @@ def start( dynamic_tools: list[dict[str, Any]] | None = None, host_config: dict[str, Any] | None = None, launch_guard: Callable[[], AbstractContextManager[None]] | None = None, + account_runtime_root: Path | None = None, + account_consumer: str = "worker", _compatibility_catalog_path: Path | None = None, ) -> "CodexChatAgentSession": resolved = shutil.which(codex_bin) @@ -850,6 +852,15 @@ def start( runtime_env.get("LOOPX_CHAT_CODEX_MODEL_PROVIDER", "").strip() if not execution_mode else "" ) + account_base_config = host_config + from .capabilities.native_chat.codex_auth import prepare_api_transport, authenticate_isolated + api_transport = prepare_api_transport(account_runtime_root, base_home, + isolated_home=runtime_home if permissions_profile else None, + eligible=not execution_mode and not provider_override and not (host_config or {}).get("model_provider"), + consumer=account_consumer, model=model) + if api_transport is not None: + runtime_env[api_transport.env_key] = api_transport.token + host_config, provider_override = api_transport.configuration(host_config or {}), api_transport.provider command = [resolved, "app-server"] if permissions_profile: # Keep this native store independent even when the account's @@ -939,19 +950,7 @@ def start( ) session._notify("initialized", {}) if permissions_profile: - from .capabilities.native_chat.codex_auth import for_isolated_process - try: - session._host_model_auth = for_isolated_process(base_home, runtime_home, resolved) - if session._host_model_auth is not None: - credentials = session._host_model_auth.read() - login = session._request("account/login/start", { - "type": "chatgptAuthTokens", **credentials}, request_id=4) - if login.get("type") != "chatgptAuthTokens": - raise ValueError("unexpected native model authentication mode") - except Exception: - raise session._runtime_error( - "Trusted-host Codex model authentication is unavailable. " - "Restore the host account and retry this same Session.") from None + session._host_model_auth = authenticate_isolated(session, base_home, runtime_home, resolved, api_transport=api_transport) read_project_defaults = project_context is not None and bool(resume_thread_id) and ( model is None or reasoning_effort is None ) @@ -961,7 +960,7 @@ def start( # copying auth, restarting clients or replacing the original thread. follow_native_provider = bool(resume_thread_id and not execution_mode and (not permissions_profile or metadata_review_context is not None) and not provider_override) - read_native_config = read_project_defaults or bool(permissions_profile) or owner_git_write or follow_native_provider + read_native_config = read_project_defaults or bool(permissions_profile) or owner_git_write or follow_native_provider or api_transport is not None if read_native_config: # Resume otherwise inherits the old thread's model/effort, even # when the owner's effective workspace configuration has changed. @@ -974,6 +973,8 @@ def start( effective = config_result.get("config", {}) if not isinstance(effective, dict): raise session._runtime_error("Codex returned an invalid project configuration.") + if api_transport is not None: + host_config = api_transport.configuration(host_config or {}, effective) if owner_git_write: # Preserve the native workspace policy (including network # and temporary-directory settings). A separate named @@ -1061,10 +1062,7 @@ def start( }, request_id=2, ) - if provider_override and thread_result.get("modelProvider") != provider_override: - raise session._runtime_error( - "Codex did not apply the requested conversation provider." - ) + codex_context.verify_conversation_provider(session, provider_override, thread_result) if model and thread_result.get("model") not in {None, model}: raise CodexChatAgentError( "Codex did not apply the requested conversation model.", @@ -1151,8 +1149,10 @@ def start( model=model, reasoning_effort=reasoning_effort, dynamic_tools=dynamic_tools, - host_config=host_config, + host_config=account_base_config if api_transport is not None else host_config, launch_guard=launch_guard, + account_runtime_root=account_runtime_root, + account_consumer=account_consumer, _compatibility_catalog_path=catalog_path, ) except Exception: diff --git a/loopx/chat_manager.py b/loopx/chat_manager.py index 406703179a..8e64824679 100644 --- a/loopx/chat_manager.py +++ b/loopx/chat_manager.py @@ -958,32 +958,40 @@ def manager_session_quota_route(controller: Any, session: Mapping[str, Any]) -> return None native = session if session.get("agent_id") == "antigravity" else next((row for row in reversed(session.get("upstream_context_history") or []) if row.get("agent_id") == "antigravity"), None) - if native is None: - return None try: from .capabilities.native_chat.antigravity_adapter import AntigravityAdapter, ANTIGRAVITY_DEFAULT_MODEL from loopx_codex_provider_routing.native import NativeAccounts - capacity = AntigravityAdapter.metadata_review_capacity( - cascade_id=str(native.get("upstream_thread_id") or native.get("thread_id")), - model_id=str(native.get("agent_model_id") or ANTIGRAVITY_DEFAULT_MODEL)) - route = NativeAccounts(controller.coordination_runtime_root).steward_route( - codex_home=controller.codex_home, primary="antigravity", + environment = dict(operator_credential_resolution(controller)["environ"]) + codex_environment = {key: value for key, value in environment.items() + if key not in (MANAGER_MODEL_ENV_VAR, MANAGER_REASONING_EFFORT_ENV_VAR)} + accounts = NativeAccounts(controller.coordination_runtime_root) + route_inputs = dict(codex_home=controller.codex_home, primary="antigravity", pool=_manager_eligible_endpoints(defaults), current=str(session["agent_id"]), - primary_capacity=capacity, blocked_primary=session.get("manager_quota_block")) + blocked_primary=session.get("manager_quota_block")) + if callable(getattr(accounts, "model_transport", None)): + route_inputs["model"] = manager_model_resolution(codex_environment, endpoint="codex", machine_defaults=defaults)[0] + capacity = None + # Explicit API primary does not depend on an unrelated subscription + # transport being up. The same owner holds when that role is absent. + route = accounts.steward_route(**route_inputs, primary_capacity=None) + if route.get("endpoint") is None and native: + capacity = AntigravityAdapter.metadata_review_capacity( + cascade_id=str(native.get("upstream_thread_id") or native.get("thread_id")), + model_id=str(native.get("agent_model_id") or ANTIGRAVITY_DEFAULT_MODEL)) + route = accounts.steward_route(**route_inputs, primary_capacity=capacity) endpoint = route.get("endpoint") if endpoint is None: return None capabilities = controller.capabilities() if not any(row.get("agent_id") == endpoint and row.get("available") is True for row in capabilities): return None - environment = dict(operator_credential_resolution(controller)["environ"]) if endpoint != "antigravity": environment.pop("LOOPX_MANAGER_MODEL", None) environment.pop("LOOPX_MANAGER_REASONING_EFFORT", None) allocation = manager_executor_allocation(controller, machine_defaults=defaults, environ=environment, endpoint_capabilities=[{**row, "available": row.get("agent_id") == endpoint} for row in capabilities]) return {"allocation": allocation, "blocked_primary": route.get("blocked_primary"), - "observed_at": capacity["observed_at"]} + "observed_at": capacity["observed_at"] if capacity else None} except Exception as exc: import logging logging.getLogger(__name__).warning("Steward quota observation held (%s)", type(exc).__name__) diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index e77e631b23..f7bd44af85 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -416,6 +416,8 @@ def _start_adapter( from .chat_loopx_mode import TOOL as COLLABORATION_TOOL return CodexAppServerAdapter.start( codex_bin=self.codex_bin, + account_runtime_root=self.coordination_runtime_root, + account_consumer="steward" if manager_profile is not None or metadata_review_context is not None else "worker", codex_home=codex_home_override or self.codex_home, work_dir=Path(metadata_review_context["workspace_path"]) if metadata_review_context else work_dir, goal_id=None if metadata_review_context else goal_id, @@ -862,13 +864,10 @@ def check_route_configuration(): if (selected_agent == "codex" and active and active_id == accepted_turn_id and active.get("status") == "queued" and not active.get("upstream_turn_id") and active.get("origin") != "native_loopx"): - try: - from loopx_codex_provider_routing.native import NativeAccounts - native_account_epoch = NativeAccounts(self.coordination_runtime_root).conversation_epoch(codex_home=selected_codex_home) - except ImportError: - pass # Optional account automation is absent, not a Chat failure. - except Exception as exc: - logging.getLogger(__name__).warning("Native account epoch observation held (%s)", type(exc).__name__) + from .capabilities.native_chat.codex_auth import conversation_account_epoch + native_account_epoch = conversation_account_epoch(self.coordination_runtime_root, selected_codex_home, + **({"consumer": "steward", "model": (model_allocation or {}).get("model") or session.get("agent_model_id")} + if manager_runtime is not None else {})) native_quota_refresh = bool(active and active_id == accepted_turn_id and active.get("status") == "queued" and active.get("origin") == "native_loopx" and active.get("loopx_execution") is True diff --git a/loopx/presentation/codex_accounts_api.py b/loopx/presentation/codex_accounts_api.py index cdc1492898..064b6f3f19 100644 --- a/loopx/presentation/codex_accounts_api.py +++ b/loopx/presentation/codex_accounts_api.py @@ -171,7 +171,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", "api_fallback"}, + fields = {"configure": {"expected_revision", "enabled", "slots", "reconnect", "restart_clients", "force_quit_clients", "api_fallback", "steward_api_role"}, "enroll-current": {"expected_revision", "expected_selection"}, "switch": {"manual_slot", "expected_selection", "operation_id"}, "recover": {"expected_revision", "expected_selection"}, "rollback": {"expected_revision", "expected_selection"}, diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index 6d553982e7..346b58e18d 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -132,6 +132,39 @@ An invalid or unreadable slot is not skipped to manufacture a unique match. This reconciliation never rewrites credentials or configuration, and does not prove that an already running client has reloaded its login. +Version 0.15.0 separates a consumer's API role from its model choice. Working +Agents and Codex App retain subscription-first ordering: `api_fallback` permits +API only after every allowed subscription has fresh measured exhaustion. +Unknown allowance never authorizes paid fallback. The account settings also +offer `steward_api_role`: `primary` prefers the existing API key pool for the +steward, `fallback` follows subscription fallback, and `disabled` excludes API +from the steward. An absent field preserves the prior fallback configuration. +Steward API primary does not require enabling subscription automation. + +The steward uses a separate authenticated loopback route and a process-only +token excluded from shell tool environments. Upstream keys stay in the same private +key owner. Thread configuration selects this model transport without rewriting +the desktop login or copying credentials/history into project stores. Explicit +native provider overrides and independently authenticated project stores retain +their chosen transport. Changes apply between Turns; they authorize neither +redispatch nor replay of an interrupted request. A model-list check is not proof +of inference allowance. Known quota/authentication/permission/rate-limit +rejections may try another key before output; unknown network effects or an +already started stream never replay. API primary unavailability allows the +existing endpoint pool to consider its subscription route; it does not move a +worker to API. The API role is local vocabulary owned by this optional account +provider, not a new Core execution authority or Goal state. + +In **Settings → Model Provider configuration**, keep the working-Agent checkbox +for subscription exhaustion. Use **Steward API use → Prefer API** for an +independent API primary; **Disabled** revokes it, including after the last key +is removed. The saved selector reads back the choice. These canonical guide +illustrations use synthetic accounts / 当前操作指南使用合成账号数据: + +![Independent account priorities, desktop / 独立账号优先级,桌面](docs/assets/account-workload-primary-desktop.png) + +![Independent account priorities, narrow / 独立账号优先级,窄屏](docs/assets/account-workload-primary-narrow.png) + ## Prerequisites and activation Install in the same Python environment as LoopX, then restart its App/Chat service: diff --git a/packages/loopx-codex-provider-routing/docs/assets/account-workload-primary-desktop.png b/packages/loopx-codex-provider-routing/docs/assets/account-workload-primary-desktop.png new file mode 100644 index 0000000000..fe44f06e05 Binary files /dev/null and b/packages/loopx-codex-provider-routing/docs/assets/account-workload-primary-desktop.png differ diff --git a/packages/loopx-codex-provider-routing/docs/assets/account-workload-primary-narrow.png b/packages/loopx-codex-provider-routing/docs/assets/account-workload-primary-narrow.png new file mode 100644 index 0000000000..0f58783996 Binary files /dev/null and b/packages/loopx-codex-provider-routing/docs/assets/account-workload-primary-narrow.png differ diff --git a/packages/loopx-codex-provider-routing/extension.toml b/packages/loopx-codex-provider-routing/extension.toml index d83b59522b..1f07436e1d 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.14.1" +version = "0.15.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 1a1b45731c..4f16d7b8d8 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.14.1" +version = "0.15.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 9b886cf95f..17428be2dd 100644 --- a/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs +++ b/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs @@ -1,8 +1,34 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { account, decide, observerHealth } from "../src/loopx_codex_provider_routing/native_policy.ts"; +import { account, apiRole, decide, modelRoute, observerHealth, stewardRoute } from "../src/loopx_codex_provider_routing/native_policy.ts"; const now = 1000; +test("consumer API roles never make a working Agent API primary", () => { + const policy = {enabled: false, slots: [], cooldown_seconds: 300, api_fallback: false, steward_api_role: "primary"}; + const native = {policy, active_slot: "a", api_active: false}; + assert.equal(apiRole(policy, "worker"), "disabled"); + assert.equal(apiRole({...policy, api_fallback: true}, "worker"), "fallback"); + assert.equal(apiRole(policy, "unrecognized-consumer"), "disabled"); + assert.equal(apiRole({...policy, api_fallback: true, steward_api_role: "corrupt-role"}, "steward"), "disabled"); + assert.equal(modelRoute({consumer: "worker", native, api_ready: true}).api, false); + assert.equal(modelRoute({consumer: "steward", native, api_ready: true}).api, true); + assert.equal(modelRoute({consumer: "steward", native, api_ready: false}).api, false); + assert.equal(stewardRoute({now, primary: "antigravity", pool: ["antigravity", "codex"], + current: "antigravity", native, api_ready: true, primary_capacity: null}).endpoint, "codex"); + assert.equal(stewardRoute({now, primary: "antigravity", pool: ["antigravity"], + current: "antigravity", native, api_ready: true, primary_capacity: null}).endpoint, null); + assert.equal(stewardRoute({now, primary: "antigravity", pool: ["antigravity", "codex"], + current: "antigravity", native, api_ready: true, primary_capacity: null, blocked_primary: {corrupt: true}}).endpoint, null); + for (const role of ["disabled", "fallback", "corrupt-role"]) { + assert.equal(modelRoute({consumer: "steward", native: {...native, policy: {...policy, steward_api_role: role}}, api_ready: true}).api, false); + } + // Legacy fallback remains the account owner's already admitted route. + const fallback = {...native, policy: {...policy, enabled: true, api_fallback: true, steward_api_role: "fallback"}, + api_active: true, login_matches: true, recovery_required: false, action: "api_active"}; + assert.equal(modelRoute({consumer: "steward", native: fallback, api_ready: true}).api, true); + assert.equal(modelRoute({consumer: "steward", native: {...fallback, recovery_required: true}, api_ready: true}).api, false); + assert.equal(modelRoute({consumer: "steward", native: {...fallback, policy: {...fallback.policy, steward_api_role: "disabled"}}, api_ready: true}).api, false); +}); test("observer health requires a held owner and a fresh matching build heartbeat", () => { const healthy = {now, owner_locked: true, build: "current", heartbeat: {checked_at: now, deadline_at: now + 75, next_check_at: now + 60, 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 75c5fcca09..d7750a2343 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 @@ -170,6 +170,7 @@ def _projection(self, state: dict, native: dict | None = None) -> dict: "observer": self.observer_health(state["policy"]["enabled"]), "active_slot": native.get("active_slot"), "api_fallback_supported": native.get("api_fallback_supported", False), + "steward_api_role_supported": True, "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"), @@ -217,7 +218,7 @@ def status(self) -> dict: native = self.transport(adapter, "status") return self._projection(state, native) - def conversation_epoch(self, *, codex_home: Path) -> tuple[str, str] | None: + def conversation_epoch(self, *, codex_home: Path, consumer: str = "worker", model: str | None = None) -> tuple[str, str] | None: """An existing Chat process observes the native owner's login revision. The caller may reopen its idle adapter, never restart the account owner @@ -225,8 +226,12 @@ def conversation_epoch(self, *, codex_home: Path) -> tuple[str, str] | None: """ state = self._load() adapter = state.get("adapter") - if (state["policy"].get("enabled") is not True or not adapter - or Path(adapter.get("profile", {}).get("home", "")).resolve() != codex_home.resolve()): + if adapter and Path(adapter.get("profile", {}).get("home", "")).resolve() != codex_home.resolve(): + return None + if consumer == "steward" and state["policy"].get("steward_api_role") == "primary" and policy({"operation": "api-role", "policy": state["policy"], + "consumer": consumer})["role"] == "primary": + return (f'steward-api:{state["revision"]}:{self.api_keys().status()["revision"]}:{bool(self.api_keys().candidates(model or ""))}', "openai-api") + if state["policy"].get("enabled") is not True or not adapter: return None native = self.transport(adapter, "status") if (native.get("current_login_matches") is not True or native.get("recovery_required") @@ -236,12 +241,20 @@ def conversation_epoch(self, *, codex_home: Path) -> tuple[str, str] | None: return (revision, slot) if isinstance(revision, str) and revision and isinstance(slot, str) and slot else None def steward_route(self, *, codex_home: Path, primary: str, pool: list[str], current: str, - primary_capacity: dict | None, blocked_primary: dict | None = None) -> dict: + primary_capacity: dict | None, blocked_primary: dict | None = None, model: str | None = None) -> dict: """Observe the existing account owner; never start a second watcher.""" state = self._load() adapter = state.get("adapter") - if (state["policy"].get("enabled") is not True or not adapter - or Path(adapter.get("profile", {}).get("home", "")).resolve() != codex_home.resolve()): + if adapter and Path(adapter.get("profile", {}).get("home", "")).resolve() != codex_home.resolve(): + return {"endpoint": None, "blocked_primary": blocked_primary} + api_ready = bool(self.api_keys().candidates(model or "")) if state["policy"].get("steward_api_role") == "primary" else False + if api_ready: + return policy({"operation": "steward-route", "now": self.clock(), "primary": primary, + "pool": pool, "current": current, "native": self._projection(state), "api_ready": True, + "primary_capacity": primary_capacity, "blocked_primary": blocked_primary}) + if primary_capacity is None: + return {"endpoint": None, "blocked_primary": blocked_primary} + if state["policy"].get("enabled") is not True or not adapter: return {"endpoint": None, "blocked_primary": blocked_primary} native = self.transport(adapter, "status") return policy({"operation": "steward-route", "now": self.clock(), @@ -249,6 +262,33 @@ def steward_route(self, *, codex_home: Path, primary: str, pool: list[str], curr "native": self._projection(state, native), "primary_capacity": primary_capacity, "blocked_primary": blocked_primary}) + def model_transport(self, *, codex_home: Path, consumer: str, model: str | None = None) -> dict | None: + """A model-only local relay; never select the desktop login or copy keys.""" + state = self._load() + adapter = state.get("adapter") + if adapter and Path(adapter.get("profile", {}).get("home", "")).resolve() != codex_home.resolve(): + return None + if (state["policy"].get("enabled") is not True + and not (consumer == "steward" and state["policy"].get("steward_api_role") == "primary")): + return None + role = policy({"operation": "api-role", "policy": state["policy"], "consumer": consumer})["role"] + if role == "primary": + native = self._projection(state) + else: + if state["policy"].get("enabled") is not True or not adapter: + return None + native = self._projection(state, self.transport(adapter, "status")) + route = policy({"operation": "model-route", "consumer": consumer, "native": native, + "api_ready": bool(self.api_keys().candidates(model or "")) if role == "primary" else False}) + if not route["api"]: + if native.get("api_active") and consumer == "steward" and role == "disabled": + raise HostError("steward_api_disabled") + if role == "primary" and native.get("api_active"): + raise HostError("api_keys_unavailable") + return None + from .openai_relay import ensure + return ensure(self, consumer=consumer) + def clients(self) -> dict: state = self._load() return self.transport(state.get("adapter") or discover(), "clients") @@ -267,7 +307,7 @@ def enroll_current(self, *, expected_revision: int, expected_selection: str) -> 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, - api_fallback: bool | None = None) -> dict: + api_fallback: bool | None = None, steward_api_role: str | 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: @@ -276,6 +316,8 @@ def configure(self, *, expected_revision: int, enabled: bool, slots: list[str], raise HostError("invalid_policy") if api_fallback is not None and type(api_fallback) is not bool: raise HostError("invalid_policy") + if steward_api_role is not None and steward_api_role not in ("disabled", "fallback", "primary"): + 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. @@ -288,6 +330,9 @@ def configure(self, *, expected_revision: int, enabled: bool, slots: list[str], raise HostError("handoff_pending") # Disabling works offline and never needs credentials or a client. use_api = api_fallback if api_fallback is not None else state["policy"].get("api_fallback", False) + use_steward = steward_api_role if steward_api_role is not None else state["policy"].get("steward_api_role") + if steward_api_role == "primary" and not self.api_keys().status()["keys"]: + raise HostError("api_keys_missing") 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") @@ -307,6 +352,8 @@ def configure(self, *, expected_revision: int, enabled: bool, slots: list[str], 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) + if use_steward is not None: + state["policy"]["steward_api_role"] = use_steward atomic(self.path, state) return self._projection(state) 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 ac99fbf06e..f5a540d660 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 @@ -17,8 +17,30 @@ export type Account = { secondary_used: number | null; credits_available: boolean | null; }; +export type ApiRole = "disabled" | "fallback" | "primary"; export type Policy = { enabled: boolean; slots: string[]; cooldown_seconds: number; - restart_clients?: boolean; force_quit_clients?: boolean; api_fallback?: boolean }; + restart_clients?: boolean; force_quit_clients?: boolean; api_fallback?: boolean; + steward_api_role?: ApiRole }; + +/** Billing priority belongs to the consumer, not the model or credential. */ +export function apiRole(policy: Policy | undefined, consumer: string): ApiRole { + if (!policy) return "disabled"; + if (consumer === "worker") return policy.api_fallback === true ? "fallback" : "disabled"; + if (consumer !== "steward") return "disabled"; + const role = policy.steward_api_role; + if (role === undefined) return policy.api_fallback === true ? "fallback" : "disabled"; + return role === "primary" || role === "fallback" ? role : "disabled"; +} + +export function modelRoute(input: {consumer: string; native: Record; api_ready: boolean}) { + const native = input.native; + const role = apiRole(native.policy, input.consumer); + const primary = input.consumer === "steward" && role === "primary" && input.api_ready; + const fallback = role === "fallback" && native.policy.enabled === true + && native.policy.api_fallback === true && native.api_active === true + && native.login_matches === true && native.recovery_required === false && native.action === "api_active"; + return {role, api: primary || fallback}; +} /** The steward's endpoint pool is separate from the native account ring. * Selection observes allowance only; it never changes a login or retries work. @@ -27,6 +49,7 @@ export function stewardRoute(input: { now: number; primary: string; pool: string[]; current: string; native: Record; primary_capacity: Record | null; blocked_primary?: Record | null; + api_ready?: boolean; }) { const no = {endpoint: null, blocked_primary: input.blocked_primary ?? null}; if (!Number.isFinite(input.now) || !["antigravity", "codex"].includes(input.current) @@ -41,10 +64,12 @@ export function stewardRoute(input: { && typeof row.execution_id === "string" && row.execution_id.length > 0 && typeof row.quota_exhausted === "boolean" && typeof row.normal_completion === "boolean" && row.quota_exhausted !== row.normal_completion; + if (input.blocked_primary != null && !validCapacity(input.blocked_primary)) return no; + if (modelRoute({consumer: "steward", native: input.native, api_ready: input.api_ready === true}).api + && apiRole(input.native.policy, "steward") === "primary") return {endpoint: "codex", blocked_primary: input.blocked_primary ?? null}; const capacity = input.primary_capacity; if (!validCapacity(capacity) || !capacity || input.now < capacity.observed_at - || input.now - capacity.observed_at > 120 - || input.blocked_primary != null && !validCapacity(input.blocked_primary)) return no; + || input.now - capacity.observed_at > 120) return no; let blocked = input.blocked_primary ?? null; if (capacity.quota_exhausted && (!blocked || blocked.execution_id !== capacity.execution_id)) blocked = capacity; const restored = blocked && (blocked.account_ref !== capacity.account_ref @@ -63,7 +88,7 @@ export function stewardRoute(input: { && current.observed_at <= input.now && input.now - current.observed_at <= 120; const usable = native.policy?.enabled === true && native.login_matches === true && native.recovery_required === false && (native.api_active === true - ? native.policy.api_fallback === true && native.action === "api_active" + ? modelRoute({consumer: "steward", native, api_ready: input.api_ready === true}).api : native.action === "ready" && fresh && current.availability === "available"); return {endpoint: usable ? "codex" : null, blocked_primary: blocked}; } diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy_bridge.ts b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy_bridge.ts index 9665bc3960..45b73b92ac 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy_bridge.ts +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy_bridge.ts @@ -1,4 +1,4 @@ -import { decide, observerHealth, stewardRoute } from "./native_policy.ts"; +import { apiRole, decide, modelRoute, observerHealth, stewardRoute } from "./native_policy.ts"; let raw = ""; for await (const chunk of process.stdin) { raw += chunk; @@ -6,4 +6,6 @@ for await (const chunk of process.stdin) { } const input = JSON.parse(raw); process.stdout.write(JSON.stringify(input.operation === "observer-health" ? observerHealth(input) + : input.operation === "api-role" ? {role: apiRole(input.policy, input.consumer)} + : input.operation === "model-route" ? modelRoute(input) : input.operation === "steward-route" ? stewardRoute(input) : decide(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 index 10a0744787..b026a05a6e 100644 --- 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 @@ -34,16 +34,27 @@ def route(owner: NativeAccounts) -> dict: 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"]}) +def consumer_token(value: dict, consumer: str) -> str: + token = value.get("steward_token" if consumer == "steward" else "token") + if not isinstance(token, str) or not token or consumer == "steward" and token == value.get("token"): + raise HostError("api_relay_upgrade_required") + return token + + +def healthy(value: dict, consumer: str = "worker") -> bool: + prefix = "/steward" if consumer == "steward" else "" try: + request = urllib.request.Request(f"http://127.0.0.1:{value['port']}{prefix}/health", headers={ + "Authorization": "Bearer " + consumer_token(value, consumer)}) 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: +def ensure(owner: NativeAccounts, *, consumer: str = "worker") -> dict: + if consumer not in ("worker", "steward"): + raise HostError("invalid_policy") with owner._lock(name="api-relay-state.lock", wait_seconds=5): path = owner.root / "api-relay.json" if not path.exists(): @@ -52,6 +63,11 @@ def ensure(owner: NativeAccounts) -> dict: port = sock.getsockname()[1] atomic(path, {"schema_version": SCHEMA, "port": port, "token": "sk-loopx-relay-" + secrets.token_urlsafe(32)}) value = route(owner) + if consumer == "steward" and "steward_token" not in value: + # Independent random credentials: a worker token must not be able + # to derive the steward's API-primary transport credential. + value["steward_token"] = "sk-loopx-relay-" + secrets.token_urlsafe(32) + atomic(path, value) 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", @@ -63,7 +79,11 @@ def ensure(owner: NativeAccounts) -> dict: 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"} + if consumer == "steward" and not healthy(value, consumer): + raise HostError("api_relay_upgrade_required") + prefix = "/steward" if consumer == "steward" else "" + return {"slot": API_SLOT, "api_key": consumer_token(value, consumer), + "base_url": f"http://127.0.0.1:{value['port']}{prefix}/v1"} class RelayServer(ThreadingHTTPServer): @@ -71,7 +91,7 @@ class RelayServer(ThreadingHTTPServer): allow_reuse_address = True def __init__(self, owner: NativeAccounts, value: dict, *, opener=None): - self.owner, self.token = owner, value["token"] + self.owner, self.value = owner, value self.opener = opener or urllib.request.build_opener(NoRedirect()) super().__init__(("127.0.0.1", value["port"]), RelayHandler) @@ -92,13 +112,23 @@ def _json(self, status: int, code: str): 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) + self.consumer = "steward" if self.path.startswith("/steward/") else "worker" + try: + return not self.headers.get("Origin") and hmac.compare_digest( + self.headers.get("Authorization", ""), "Bearer " + consumer_token(self.server.value, self.consumer)) + except HostError: + return False def _admitted(self): state = self.server.owner._load() policy = state["policy"] native = state.get("native", {}) + if self.consumer == "steward": + role = policy.get("steward_api_role") + if role == "primary": + return True + if role == "disabled": + return False 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)) @@ -107,7 +137,7 @@ def do_GET(self): if not self._authorized(): self._json(401, "relay_auth_required") return - if self.path == "/health": + if self.path in ("/health", "/steward/health"): self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", "11") @@ -120,7 +150,8 @@ 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"): + upstream_path = self.path.removeprefix("/steward") if self.consumer == "steward" else self.path + if upstream_path not in ("/v1/responses", "/v1/responses/compact"): self._json(404, "unsupported_route") return if not self._admitted(): @@ -150,7 +181,7 @@ def do_POST(self): 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={ + request = urllib.request.Request("https://api.openai.com" + upstream_path, data=body, headers={ "Authorization": "Bearer " + current["api_key"], "Content-Type": "application/json", "Accept": self.headers.get("Accept", "text/event-stream"), }, method="POST") @@ -209,7 +240,8 @@ def main(): 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")): + if time.monotonic() - started > 10 and policy.get("steward_api_role") != "primary" and ( + not policy.get("enabled") or not policy.get("api_fallback")): break finally: # Drain already admitted requests; disabling never replays them. diff --git a/tests/test_chat_account_workloads.py b/tests/test_chat_account_workloads.py new file mode 100644 index 0000000000..452b10aeea --- /dev/null +++ b/tests/test_chat_account_workloads.py @@ -0,0 +1,196 @@ +"""Consumer policy through the actual private owner, relay HTTP and Chat startup.""" +import importlib +import io +import json +from pathlib import Path +import threading +import urllib.error +import urllib.request + +import pytest + +from loopx.capabilities.native_chat.codex_auth import CodexApiTransport, CodexHostAuthUnavailable + + +@pytest.fixture +def account_owner(tmp_path, monkeypatch): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / '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') + calls = [] + owner = native.NativeAccounts(tmp_path, transport=lambda *_a, **_k: calls.append('native') or { + 'slots': ['a'], 'active_slot': 'a', 'current_login_matches': True, 'recovery_required': False, + 'api_fallback_supported': True}, clock=lambda: 1000) + owner.api_keys().add(expected_revision=0, label='Synthetic', api_key='sk-' + 'synthetic-only-key-' * 3) + return owner, native, relay, calls + + +def test_api_primary_is_independent_and_preserved_by_worker_configuration(account_owner): + owner, native, _, calls = account_owner + before = owner.api_keys().path.read_bytes() + result = owner.configure(expected_revision=0, enabled=False, slots=[], steward_api_role='primary') + assert result['policy']['steward_api_role'] == 'primary' and not result['policy'].get('api_fallback') + assert calls == [] and owner.api_keys().path.read_bytes() == before + result = owner.configure(expected_revision=1, enabled=False, slots=[], api_fallback=False) + assert result['policy']['steward_api_role'] == 'primary' + assert owner.steward_route(codex_home=Path('/synthetic-home'), primary='antigravity', + pool=['antigravity', 'codex'], current='antigravity', primary_capacity=None)['endpoint'] == 'codex' + assert owner.model_transport(codex_home=Path('/synthetic-home'), consumer='worker') is None + assert calls == [] + with pytest.raises(Exception, match='invalid_policy'): + owner.configure(expected_revision=2, enabled=False, slots=[], steward_api_role='arbitrary') + assert native.private_read(owner.path)['revision'] == 2 + + +def test_api_primary_cannot_replace_a_different_bound_native_profile(account_owner): + owner, native, _, calls = account_owner + owner.configure(expected_revision=0, enabled=False, slots=[], steward_api_role='primary') + state = native.private_read(owner.path) + state['adapter'] = {'profile': {'home': '/synthetic-registered-home'}} + native.atomic(owner.path, state) + before = owner.path.read_bytes() + other = Path('/synthetic-other-home') + assert owner.model_transport(codex_home=other, consumer='steward') is None + assert owner.conversation_epoch(codex_home=other, consumer='steward') is None + assert owner.steward_route(codex_home=other, primary='antigravity', pool=['antigravity', 'codex'], + current='antigravity', primary_capacity=None)['endpoint'] is None + assert calls == [] and owner.path.read_bytes() == before + + +def test_disabled_account_automation_starts_no_policy_or_native_process(account_owner, monkeypatch): + owner, native, _, calls = account_owner + monkeypatch.setattr(native, 'policy', lambda *_: pytest.fail('Disabled observation must start no policy process')) + home = Path('/synthetic-home') + for consumer in ('worker', 'steward'): + assert owner.model_transport(codex_home=home, consumer=consumer) is None + assert owner.conversation_epoch(codex_home=home, consumer=consumer) is None + assert owner.steward_route(codex_home=home, primary='antigravity', pool=['antigravity', 'codex'], + current='antigravity', primary_capacity=None)['endpoint'] is None + assert calls == [] and not owner.path.exists() + + +def test_older_optional_provider_preserves_its_native_auth_and_epoch(tmp_path, monkeypatch): + import sys + from types import ModuleType + from loopx.capabilities.native_chat import codex_auth + module = ModuleType('loopx_codex_provider_routing.native') + calls = [] + class LegacyOwner: + def __init__(self, root): assert root == tmp_path + def conversation_epoch(self, *, codex_home): + calls.append(codex_home) + return ('legacy-revision', 'subscription-a') + module.NativeAccounts = LegacyOwner + monkeypatch.setitem(sys.modules, module.__name__, module) + assert codex_auth.native_api_transport(tmp_path, tmp_path, consumer='steward') is None + assert codex_auth.conversation_account_epoch(tmp_path, tmp_path, consumer='steward', model='fixture') == ('legacy-revision', 'subscription-a') + assert calls == [tmp_path] + + +def test_steward_and_worker_tokens_cannot_cross_billing_routes(account_owner): + owner, native, relay, _ = account_owner + owner.configure(expected_revision=0, enabled=False, slots=[], steward_api_role='primary') + upstream = [] + class Response(io.BytesIO): + status = 200 + headers = {'Content-Type': 'text/event-stream'} + class Upstream: + def open(self, request, timeout): + upstream.append(request) + assert request.full_url == 'https://api.openai.com/v1/responses' + return Response(b'data: {"type":"response.completed"}\n\n') + token = 'synthetic-worker-loopback-token' + value = {'port': 0, 'token': token, 'steward_token': 'independent-synthetic-steward-token'} + server = relay.RelayServer(owner, value, opener=Upstream()) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + def post(path, bearer): + request = urllib.request.Request(f'http://127.0.0.1:{server.server_port}{path}', + data=json.dumps({'model': 'synthetic-model', 'input': 'fixture', 'stream': True}).encode(), + headers={'Authorization': 'Bearer ' + bearer, 'Content-Type': 'application/json'}) + try: + with urllib.request.urlopen(request, timeout=5) as response: + response.read() + return response.status + except urllib.error.HTTPError as error: + error.read() + return error.code + try: + steward_token = relay.consumer_token(value, 'steward') + assert steward_token != token + assert post('/steward/v1/responses', token) == 401 + assert post('/v1/responses', steward_token) == 401 + assert post('/v1/responses', token) == 503 # Worker's subscription policy remains off. + assert not upstream + assert post('/steward/v1/responses', steward_token) == 200 and len(upstream) == 1 + state = native.private_read(owner.path) + state['policy']['steward_api_role'] = 'disabled' + native.atomic(owner.path, state) + assert post('/steward/v1/responses', steward_token) == 503 and len(upstream) == 1 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def test_model_transport_hides_token_from_config_and_rejects_native_alias_collision(): + transport = CodexApiTransport('http://127.0.0.1:32100/steward/v1', 'synthetic-secret') + original = {'shell_environment_policy': {'inherit': 'all', 'exclude': ['OTHER_SECRET'], 'set': {'SAFE': 'yes'}}} + config = transport.configuration(original) + assert 'synthetic-secret' not in repr(transport) + json.dumps(config) + assert config['shell_environment_policy']['set'] == {'SAFE': 'yes'} + assert transport.env_key in config['shell_environment_policy']['exclude'] + assert config['model_providers'][transport.provider]['env_key'] == transport.env_key + assert original['shell_environment_policy']['exclude'] == ['OTHER_SECRET'] + with pytest.raises(CodexHostAuthUnavailable): + transport.configuration(original, {'model_providers': {transport.provider: {'base_url': 'https://untrusted.invalid'}}}) + + +def test_known_api_rejection_returns_primary_to_existing_subscription_pool(account_owner): + owner, native, _, _ = account_owner + owner.configure(expected_revision=0, enabled=False, slots=[], steward_api_role='primary') + key_id = owner.api_keys().status()['keys'][0]['id'] + owner.api_keys().record(key_id, status=429, code='insufficient_quota', model='synthetic-model', headers={}) + assert owner.steward_route(codex_home=Path('/synthetic-home'), primary='antigravity', + pool=['antigravity', 'codex'], current='antigravity', primary_capacity=None)['endpoint'] is None + # Native owner remains selected; no credential or native switch was attempted. + assert owner.model_transport(codex_home=Path('/synthetic-home'), consumer='steward', model='synthetic-model') is None + assert native.private_read(owner.path)['policy']['steward_api_role'] == 'primary' + + +@pytest.mark.parametrize('resume', [False, True]) +def test_chat_start_uses_model_only_api_and_preserves_original_thread_and_project_store(tmp_path, monkeypatch, resume): + from loopx import chat_agent + from loopx.capabilities.native_chat import codex_auth + from loopx.capabilities.native_chat.project_context import ChatProjectContexts + from tests.test_chat_agent import _FakeAppServerProcess, _stub_app_server_process + + home, workspace = tmp_path / 'host', tmp_path / 'workspace' + workspace.mkdir() + context = ChatProjectContexts([workspace], filesystem_scope='workspace_only').available()[0] + transport = CodexApiTransport('http://127.0.0.1:32100/steward/v1', 'synthetic-secret') + observed = [] + monkeypatch.setattr(codex_auth, 'native_api_transport', lambda root, base, **kw: + observed.append((root, base, kw)) or transport) + process = _FakeAppServerProcess(thread_response={'thread': {'id': 'original-native-thread'}, + 'modelProvider': transport.provider, 'activePermissionProfile': {'id': 'loopx_workspace_only_write'}, + 'runtimeWorkspaceRoots': [str(workspace)]}) + launches = [] + _stub_app_server_process(monkeypatch, process, launched=launches) + session = chat_agent.CodexChatAgentSession.start(codex_bin='codex', codex_home=home, + work_dir=workspace, goal_id=None, objective='fixture', project_context=context, + account_runtime_root=tmp_path / 'runtime', account_consumer='steward', + resume_thread_id='original-native-thread' if resume else None) + try: + packets = [json.loads(line) for line in process.stdin.getvalue().splitlines()] + thread = next(p for p in packets if p.get('method') in ('thread/start', 'thread/resume')) + assert not any(p.get('method') == 'account/login/start' for p in packets) + assert thread['params']['modelProvider'] == transport.provider + assert transport.env_key in thread['params']['config']['shell_environment_policy']['exclude'] + assert observed[0][2]['consumer'] == 'steward' + assert launches[0]['env'][transport.env_key] == 'synthetic-secret' + assert 'synthetic-secret' not in process.stdin.getvalue() + assert session.thread_id == 'original-native-thread' + assert not list(workspace.rglob('auth.json')) and not (home / 'auth.json').exists() + finally: + session.close() diff --git a/tests/test_chat_manager_model_adoption.py b/tests/test_chat_manager_model_adoption.py index 1745a8b74d..00eaa67ec7 100644 --- a/tests/test_chat_manager_model_adoption.py +++ b/tests/test_chat_manager_model_adoption.py @@ -476,7 +476,10 @@ def test_native_account_epoch_reopens_only_unstarted_codex_adapter(conversation, class Accounts: def __init__(self, path): assert path == runtime.coordination_runtime_root - def conversation_epoch(self, *, codex_home): + def model_transport(self, **_parameters): + return None + def conversation_epoch(self, *, codex_home, consumer="worker", model=None): + assert consumer == "steward" observed.append(codex_home) return tuple(epoch) module.NativeAccounts = Accounts