Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions packages/loopx-codex-provider-routing/NATIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 显示实际请求结果和上次
Expand Down
2 changes: 1 addition & 1 deletion packages/loopx-codex-provider-routing/extension.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema_version = "loopx_extension_manifest_v0"
id = "loopx-codex-provider-routing"
version = "0.15.6"
version = "0.15.7"
requires_loopx_api = ">=1,<2"
permissions = []

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

[project]
name = "loopx-codex-provider-routing"
version = "0.15.6"
version = "0.15.7"
description = "Native Codex account slots and exhausted-account handoff for LoopX"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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 = []
Expand Down
136 changes: 135 additions & 1 deletion tests/presentation/test_native_account_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -252,32 +253,36 @@ 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())
self.assertEqual(cfg['model_provider'], 'gateway')
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')
self.assertEqual(cfg['model_catalog_json'], 'models.json')
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()
Expand All @@ -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')
Expand Down Expand Up @@ -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__':
Expand Down
Loading