diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index 45044194c8..52170eca18 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -190,6 +190,15 @@ subscription is selected again; when none is available, status reports `all_unavailable` instead of claiming `api_active`. `api_fallback` stays enabled through this transition. +Version 0.15.7 keeps subscription and managed API-fallback routes on Codex's +built-in `openai` provider identity. Managed API fallback changes only +`openai_base_url` to the loopback relay, migrating legacy relay provider IDs +(such as `openai_retry20`) when selected so built-in sessions retain the same +provider binding across fallback transitions. API slots bound to an explicit +custom Responses gateway preserve and restore their selected gateway provider +and model catalog across account switching, including gateways that share the +loopback relay endpoint. + **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, @@ -218,9 +227,12 @@ incomplete credential transaction. 空闲、重启、冷却和恢复边界返回。扩展内置唯一原生账号 owner,dotfiles 只导入并映射 旧配置;LoopX 不依赖 dotfiles。API 上游 Key 留在扩展私有存储,原生槽位只保存本地 中转凭据,不绕过原事务或复制会话历史。 -顶层 Responses API 网关可以直接启动并参与账号切换。API 槽位记录所选网关,重新选中时 -恢复该 provider 与模型目录;订阅槽位改用内置 `openai`,并移除受管的 API 地址,避免把 -订阅凭据发往网关。自定义 provider 不得覆盖内置 `openai`,profile 覆写仍不参与账号事务。 +顶层 Responses API 网关可以直接启动并参与账号切换。绑定显式自定义网关的 API 槽位记录 +所选网关,切走并重新选中时恢复该 provider 与模型目录,共用中转地址的网关也保留原绑定。 +受管 API 接续(含 `openai_retry20` 等已知旧中转 provider 的槽位)与订阅槽位共用内置 +`openai` 标识,通过 `openai_base_url` 切换本地中转,使既有会话在账号与受管 fallback +间恢复时保持同一 provider 绑定。订阅槽位使用内置 `openai` 并移除受管的 API 地址,避免 +把订阅凭据发往网关。自定义 provider 不得覆盖内置 `openai`,profile 覆写仍不参与账号事务。 Key 轮换依据输出前收到的明确额度、认证、权限或限流错误,每次请求每把 Key 最多 尝试一次;网络效果不明、普通请求错误和已开始输出的流不会重放,不替换模型,也不做 后台付费探测。关闭后停止新请求并等待已接收请求结束。App 显示实际请求结果和上次 diff --git a/packages/loopx-codex-provider-routing/extension.toml b/packages/loopx-codex-provider-routing/extension.toml index edffb50b28..d99c6dd847 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.15.6" +version = "0.15.7" 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 0bdbb3850d..b675c9268f 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.15.6" +version = "0.15.7" 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/src/loopx_codex_provider_routing/native-extension.toml b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native-extension.toml index 9a2a390589..59e8df3cd0 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native-extension.toml +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native-extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-native-account-provider" -version = "0.15.6" +version = "0.15.7" requires_loopx_api = ">=1,<2" permissions = ["model-provider.local-read", "model-provider.transport-manage"] diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py index c877f87c6a..5f02594e2c 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py @@ -18,6 +18,12 @@ import uuid API_KEY_SLOTS_VERSION = 1 +LEGACY_RELAY_PROVIDERS = frozenset({ + 'openai_retry20', + 'loopx_http', + 'loopx_account_api', + 'loopx_host_chatgpt_http', +}) def api_endpoint(value): @@ -200,6 +206,24 @@ def routed_auth(raw, provider): return encoded(obj) +def normalize_relay_provider(provider): + """Use the built-in provider for known legacy relay IDs.""" + if not provider or provider in LEGACY_RELAY_PROVIDERS: + return 'openai' + return provider + + +def routed_api_auth(raw, current_provider, *, outgoing=False): + """Preserve explicit custom gateways while migrating legacy relay providers to openai.""" + if auth_kind(raw) != 'api': + return raw + obj = json.loads(raw) + provider = obj.get('loopx_model_provider') or current_provider + if outgoing and current_provider != 'openai': + provider = current_provider + return routed_auth(raw, normalize_relay_provider(provider)) + + def toml_string(value): if not isinstance(value, str) or any(ord(c) < 0x20 or ord(c) == 0x7f for c in value): raise ValueError('Invalid TOML setting value') @@ -228,12 +252,12 @@ def file_config(raw, auth=None): identity(auth) auth_obj = json.loads(auth) endpoint = api_endpoint(auth_obj['loopx_api_base_url']) - target_provider = auth_obj.get('loopx_model_provider', 'openai') + target_provider = normalize_relay_provider(auth_obj.get('loopx_model_provider')) candidate = dict(cfg, model_provider=target_provider) provider_route(candidate) - # Subscription slots use the built-in provider. API slots restore the - # provider bound to that credential, so a gateway request never inherits a - # ChatGPT login and a subscription never inherits a relay endpoint. + # Subscription and managed relay slots use the built-in provider identity so + # existing Codex sessions stay resumable across account/fallback transitions. + # Explicit custom Responses gateways restore their bound provider definition. section = re.search(r'^\s*\[', text, re.M) head, tail = (text[:section.start()], text[section.start():]) if section else (text, '') settings = [('cli_auth_credentials_store', 'file'), ('forced_login_method', mode)] @@ -487,11 +511,13 @@ def status(self, *, adopt_current=True): def enroll_api(self, name, *, api_key, base_url): """Enroll an explicit local relay credential without selecting it.""" + endpoint = api_endpoint(base_url) provider = 'openai' if self.config.exists(): - provider = provider_route(tomllib.loads(self.config.read_text())) + cfg = tomllib.loads(self.config.read_text()) + provider = normalize_relay_provider(provider_route(cfg)) raw = encoded({'auth_mode': 'apikey', 'OPENAI_API_KEY': api_key, - 'loopx_api_base_url': api_endpoint(base_url), + 'loopx_api_base_url': endpoint, 'loopx_model_provider': provider}) ident = identity(raw) target = self.slot(name) @@ -559,9 +585,7 @@ def switch(self, name, dry_run=False): raw = read_private(target) config_raw = self.config.read_bytes() if self.config.exists() else b'' current_provider = provider_route(tomllib.loads(config_raw.decode()) if config_raw else {}) - if auth_kind(raw) == 'api' and not json.loads(raw).get('loopx_model_provider'): - provider = current_provider if current_provider != 'openai' else 'openai' - raw = routed_auth(raw, provider) + raw = routed_api_auth(raw, current_provider) ident = identity(raw) converted = file_config(config_raw, raw) state, outgoing = self.selected() @@ -575,11 +599,9 @@ def switch(self, name, dry_run=False): raw = outgoing if state and state['slot'] == name else read_private(target) config_raw = self.config.read_bytes() if self.config.exists() else b'' current_provider = provider_route(tomllib.loads(config_raw.decode()) if config_raw else {}) - if state and state['slot'] == 'openai-api' and auth_kind(outgoing) == 'api': - outgoing = routed_auth(outgoing, current_provider) - if auth_kind(raw) == 'api' and not json.loads(raw).get('loopx_model_provider'): - provider = current_provider if current_provider != 'openai' else 'openai' - raw = routed_auth(raw, provider) + if state and auth_kind(outgoing) == 'api': + outgoing = routed_api_auth(outgoing, current_provider, outgoing=True) + raw = routed_api_auth(raw, current_provider) ident = identity(raw) converted = file_config(config_raw, raw) updates = [] diff --git a/tests/presentation/test_native_account_store.py b/tests/presentation/test_native_account_store.py index 17025e4fb8..4337934a44 100644 --- a/tests/presentation/test_native_account_store.py +++ b/tests/presentation/test_native_account_store.py @@ -99,6 +99,7 @@ def test_api_switch_and_subscription_return_reuse_transaction_recovery(self): self.assertEqual(cfg['forced_login_method'], 'api') self.assertEqual(cfg['openai_base_url'], 'http://127.0.0.1:32100/v1') self.assertEqual(self.manager.selected()[0]['slot'], 'api') + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'openai') self.manager.recover(rollback=True) self.assertEqual(self.manager.auth.read_bytes(), self.a) self.assertEqual(self.manager.config.read_bytes(), original) @@ -252,13 +253,14 @@ def test_gateway_route_is_bound_to_api_and_subscriptions_use_builtin_openai(self gateway = ('# kept\nmodel="gateway-model"\nmodel_provider="gateway"\n' 'model_catalog_json="models.json"\n' '[model_providers.gateway]\nname="Gateway"\n' - 'base_url="https://gateway.example/v1"\n' + 'base_url="http://127.0.0.1:32100/v1"\n' 'wire_api="responses"\n' 'requires_openai_auth=true\n' '[mcp_servers.demo]\ncommand="demo"\n') self.manager.config.write_text(gateway) self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', base_url='http://127.0.0.1:32100/v1') + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'gateway') self.current() self.manager.switch('api') cfg = tomllib.loads(self.manager.config.read_text()) @@ -266,6 +268,7 @@ def test_gateway_route_is_bound_to_api_and_subscriptions_use_builtin_openai(self self.assertEqual(cfg['model_catalog_json'], 'models.json') self.assertEqual(cfg['forced_login_method'], 'api') self.assertNotIn('openai_base_url', cfg) + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'gateway') self.manager.switch('a') cfg = tomllib.loads(self.manager.config.read_text()) self.assertEqual(cfg['model_provider'], 'openai') @@ -273,11 +276,13 @@ def test_gateway_route_is_bound_to_api_and_subscriptions_use_builtin_openai(self self.assertEqual(cfg['forced_login_method'], 'chatgpt') self.assertNotIn('openai_base_url', cfg) self.assertEqual(cfg['mcp_servers']['demo']['command'], 'demo') + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'gateway') self.manager.switch('api') cfg = tomllib.loads(self.manager.config.read_text()) self.assertEqual(cfg['model_provider'], 'gateway') self.assertEqual(cfg['forced_login_method'], 'api') self.assertNotIn('openai_base_url', cfg) + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'gateway') def test_legacy_api_slot_inherits_a_stale_selected_gateway(self): self.current() @@ -297,6 +302,35 @@ def test_legacy_api_slot_inherits_a_stale_selected_gateway(self): self.assertEqual(bound['loopx_model_provider'], 'gateway') self.assertEqual(cfg['model_provider'], 'gateway') self.assertEqual(cfg['model_catalog_json'], 'models.json') + self.manager.switch('a') + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'gateway') + + def test_legacy_api_slot_provider_is_migrated_to_builtin_openai(self): + self.current() + self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', + base_url='http://127.0.0.1:32100/v1') + for legacy_provider, base_url in [ + ('openai_retry20', 'https://gateway.example/v1'), + ('loopx_account_api', 'http://127.0.0.1:32100/v1'), + ]: + with self.subTest(legacy_provider=legacy_provider): + legacy = json.loads(self.manager.slot('api').read_text()) + legacy['loopx_model_provider'] = legacy_provider + accounts.atomic(self.manager.slot('api'), accounts.encoded(legacy)) + gateway = (f'model="gateway-model"\nmodel_provider="{legacy_provider}"\n' + 'model_catalog_json="models.json"\n' + f'[model_providers.{legacy_provider}]\nname="OpenAI"\n' + f'base_url="{base_url}"\nwire_api="responses"\n') + self.manager.config.write_text(gateway) + self.manager.switch('api') + bound = json.loads(self.manager.slot('api').read_text()) + cfg = tomllib.loads(self.manager.config.read_text()) + self.assertEqual(bound['loopx_model_provider'], 'openai') + self.assertEqual(cfg['model_provider'], 'openai') + self.assertEqual(cfg['openai_base_url'], 'http://127.0.0.1:32100/v1') + self.assertEqual(cfg['model_catalog_json'], 'models.json') + self.manager.switch('a') + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'openai') def test_quoted_and_backslash_gateway_providers_survive_switch_and_independent_readback(self): host_module = importlib.import_module('loopx_codex_provider_routing.native_host') @@ -671,6 +705,106 @@ def test_explicit_read_only_service_exception_does_not_exempt_writers(self): with self.assertRaises(ValueError): apps.account_idle(self.home, self.spec, [77]) + def test_managed_fallback_and_custom_gateway_roundtrip_readback(self): + config_text = ( + 'model="fixture"\n' + 'model_provider="openai"\n' + 'model_catalog_json="models.json"\n' + '[model_providers.openai_retry20]\n' + 'name="OpenAI"\n' + 'base_url="http://127.0.0.1:32100/v1"\n' + 'wire_api="responses"\n' + '[model_providers.review_gateway]\n' + 'name="OpenAI"\n' + 'base_url="https://gateway.example/v1"\n' + 'wire_api="responses"\n' + 'http_headers={"X-Route"="gateway"}\n' + 'query_params={route="explicit"}\n' + ) + # 1. Subscription session continues through managed fallback (legacy relay provider migrated to openai). + self.current() + self.manager.config.write_text(config_text.replace('model_provider="openai"', 'model_provider="openai_retry20"')) + self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', + base_url='http://127.0.0.1:32100/v1') + legacy = json.loads(self.manager.slot('api').read_text()) + legacy['loopx_model_provider'] = 'openai_retry20' + accounts.atomic(self.manager.slot('api'), accounts.encoded(legacy)) + self.manager.switch('api') + cfg = tomllib.loads(self.manager.config.read_text()) + self.assertEqual(cfg['model_provider'], 'openai') + self.assertEqual(cfg['openai_base_url'], 'http://127.0.0.1:32100/v1') + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'openai') + self.manager.switch('a') + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'openai') + + # 2. Explicit custom gateway session switches away to subscription and back to original gateway. + custom = json.loads(self.manager.slot('api').read_text()) + custom['loopx_model_provider'] = 'review_gateway' + accounts.atomic(self.manager.slot('api'), accounts.encoded(custom)) + self.manager.switch('api') + cfg = tomllib.loads(self.manager.config.read_text()) + self.assertEqual(cfg['model_provider'], 'review_gateway') + self.assertNotIn('openai_base_url', cfg) + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'review_gateway') + self.manager.config.write_text(self.manager.config.read_text().replace( + 'https://gateway.example/v1', 'http://127.0.0.1:32100/v1')) + gateway = tomllib.loads(self.manager.config.read_text())['model_providers']['review_gateway'] + self.manager.switch('a') + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'review_gateway') + self.manager.switch('api') + cfg = tomllib.loads(self.manager.config.read_text()) + self.assertEqual(cfg['model_provider'], 'review_gateway') + self.assertEqual(cfg['model_catalog_json'], 'models.json') + self.assertEqual(cfg['model_providers']['review_gateway'], gateway) + self.assertNotIn('openai_base_url', cfg) + self.assertEqual(json.loads(self.manager.slot('api').read_text())['loopx_model_provider'], 'review_gateway') + + @unittest.skipUnless(os.environ.get('LOOPX_TEST_CODEX_BIN'), 'native Codex binary required') + def test_native_codex_resumes_gateway_with_shared_relay_endpoint(self): + from tests.control_plane.codex_checkpoint_fixture import local_inference_provider + + with local_inference_provider() as (relay, calls, requests): + proxy = relay.removesuffix('/v1') + env = { + 'PATH': os.environ.get('PATH', os.defpath), + 'HOME': str(self.home), + 'CODEX_HOME': str(self.spec['home']), + 'XDG_CONFIG_HOME': str(self.home / '.config'), + 'HTTP_PROXY': proxy, + 'HTTPS_PROXY': proxy, + 'NO_PROXY': '127.0.0.1', + } + + def run(*args): + completed = subprocess.run( + [os.environ['LOOPX_TEST_CODEX_BIN'], 'exec', *args, '--json', + '--skip-git-repo-check', '-c', 'analytics.enabled=false', + '-c', 'check_for_update_on_startup=false', '-c', 'sandbox_mode="read-only"'], + cwd=self.home, env=env, capture_output=True, text=True, timeout=30, + ) + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + events = [json.loads(line) for line in completed.stdout.splitlines()] + return next(event['thread_id'] for event in events if event['type'] == 'thread.started') + + self.current() + gateway = f'{proxy}/custom/v1' + self.manager.config.write_text( + 'model="gpt-5.4"\nmodel_provider="explicit_gateway"\n' + '[model_providers.explicit_gateway]\nname="Fixture Gateway"\n' + f'base_url="{gateway}"\nwire_api="responses"\nrequires_openai_auth=true\n' + 'http_headers={"X-Route"="gateway"}\nquery_params={route="explicit"}\n' + ) + self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', base_url=relay) + self.manager.switch('api') + thread_id = run('First synthetic gateway turn.') + self.manager.config.write_text(self.manager.config.read_text().replace(gateway, relay)) + self.manager.switch('a') + self.manager.switch('api') + + self.assertEqual(run('resume', thread_id, 'Second synthetic gateway turn.'), thread_id) + self.assertEqual(calls, ['/custom/v1/responses?route=explicit', '/v1/responses?route=explicit']) + self.assertIn('First synthetic gateway turn.', json.dumps(requests[1]['input'])) + if __name__ == '__main__':