feat(provider): support multiple ChatGPT subscriptions with usage

This commit is contained in:
Alexandre Teixeira
2026-09-22 13:12:19 +01:00
parent 45330097b8
commit ed7ccfd584
31 changed files with 3281 additions and 215 deletions
+11 -4
View File
@@ -58,14 +58,21 @@ def test_device_auth_selection_disables_and_dims_api_test_button():
def test_device_auth_keeps_manual_auth_button_without_auto_opening_tab():
# The waiting panel (code + manual Authorize link) is shared between the
# Add Models flow and per-account ChatGPT Reconnect.
panel_block = _between(_ADMIN, "function _renderDeviceAuthWaitPanel", "// ── ChatGPT Subscription per-account usage")
auth_block = _between(_ADMIN, "async function _startProviderDeviceAuth", "// Local \"Add\" button")
reconnect_block = _between(_ADMIN, "async function _reconnectChatgptAccount", "function initEndpointForm()")
assert "Authorize with OpenAI" in auth_block
assert "Authorize on GitHub" in auth_block
assert "adm-copilot-panel" in auth_block
assert "adm-device-auth-copy" in auth_block
assert "Authorize with OpenAI" in panel_block
assert "Authorize on GitHub" in panel_block
assert "adm-copilot-panel" in panel_block
assert "adm-device-auth-copy" in panel_block
assert "_renderDeviceAuthWaitPanel(status, providerKey, start, authUrl)" in auth_block
assert "openWindow: () => {}" in auth_block
assert "A new tab opened" not in auth_block
assert "openWindow: () => {}" in reconnect_block
assert "_renderDeviceAuthWaitPanel(status, 'chatgpt-subscription', start, authUrl)" in reconnect_block
def test_loud_oauth_copy_and_removed_button_hooks_do_not_return():
+162
View File
@@ -0,0 +1,162 @@
"""Persistent account routing must survive reloads and fail closed on deletion."""
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, text, inspect
from sqlalchemy.orm import sessionmaker
import core.database as cdb
import core.session_manager as sm
import routes.chat_helpers as helpers
import routes.chat_routes as chat
import routes.session_routes as sessions
from src import endpoint_resolver
BASE = "https://chatgpt.com/backend-api/codex"
@pytest.fixture
def state(monkeypatch):
engine = create_engine("sqlite:///:memory:")
cdb.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
for module in (sm, helpers, chat, sessions, endpoint_resolver):
monkeypatch.setattr(module, "SessionLocal", factory)
with factory() as db:
for key, owner in (("a", "alice"), ("b", "alice"), ("c", "bob")):
db.add(cdb.ModelEndpoint(id=key, owner=owner, name=key, base_url=BASE,
provider_auth_id="auth-" + key, is_enabled=True,
cached_models='["gpt-5.5"]', supports_tools=False))
db.commit()
manager = sm.SessionManager.__new__(sm.SessionManager)
manager.sessions = {}
manager.upload_handler = None
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime",
lambda ep, owner=None: (BASE, "secret-" + ep.id))
yield factory, manager
engine.dispose()
def _session(manager, binding="b"):
return manager.create_session("s", "Chat", BASE + "/responses", "gpt-5.5",
owner="alice", endpoint_id=binding)
def test_binding_survives_metadata_full_reload_and_sync(state):
factory, manager = state
_session(manager)
with factory() as db:
db.add(cdb.ChatMessage(id="message", session_id="s", role="user", content="Hello"))
db.commit()
row = db.get(cdb.Session, "s")
assert manager._db_to_session_meta(row).endpoint_id == "b"
assert manager._db_to_session(row, db).endpoint_id == "b"
row.endpoint_id = "a"
db.commit()
manager.sync_session_metadata("s")
assert manager.sessions["s"].endpoint_id == "a"
@pytest.mark.parametrize("disabled", [True, False])
def test_missing_or_disabled_bound_account_cannot_borrow_only_remaining_sibling(state, disabled):
factory, manager = state
sess = _session(manager)
with factory() as db:
endpoint = db.get(cdb.ModelEndpoint, "b")
if disabled:
endpoint.is_enabled = False
else:
db.delete(endpoint)
db.get(cdb.Session, "s").headers = {"Authorization": "Bearer stale-secret"}
db.commit()
sess.headers = {"Authorization": "Bearer stale-secret"}
helpers.resolve_session_auth(sess, "s", "alice")
assert sess.headers == {}
assert sess.endpoint_id == "b"
assert chat._clear_orphaned_session_endpoint(sess, "alice") is True
assert chat._recover_empty_session_model(sess, "s", "alice") is False
with factory() as db:
assert db.get(cdb.Session, "s").headers == {}
assert db.get(cdb.Session, "s").endpoint_id == "b"
def test_legacy_binding_is_persisted_even_when_authentication_fails(state, monkeypatch):
factory, manager = state
sess = _session(manager, None)
def unavailable(*args, **kwargs):
raise RuntimeError("credentials unavailable")
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", unavailable)
helpers.resolve_session_auth(sess, "s", "alice")
assert sess.endpoint_id == "a"
with factory() as db:
assert db.get(cdb.Session, "s").endpoint_id == "a"
def test_explicit_selection_switches_same_url_and_model_binding(state):
factory, manager = state
sess = _session(manager)
assert chat._reconcile_selected_route_from_request(None, sess, "s", {
"selected_model": "gpt-5.5", "selected_endpoint_id": "a",
}, "alice")
assert sess.endpoint_id == "a"
with factory() as db:
assert db.get(cdb.Session, "s").endpoint_id == "a"
assert not chat._reconcile_selected_route_from_request(None, sess, "s", {
"selected_model": "gpt-5.5", "selected_endpoint_id": "c",
}, "alice")
assert sess.endpoint_id == "a"
def test_url_only_model_change_retains_exact_binding(state):
factory, manager = state
sess = _session(manager)
assert chat._reconcile_selected_route_from_request(None, sess, "s", {
"selected_model": "another-model", "selected_endpoint_url": BASE,
}, "alice")
assert sess.endpoint_id == "b"
def test_session_patch_and_unrelated_rename_preserve_binding(state, monkeypatch):
factory, manager = state
sess = _session(manager)
monkeypatch.setattr(sessions, "_verify_session_owner", lambda *args: None)
router = sessions.setup_session_routes(manager, {})
patch = [r.endpoint for r in router.routes if r.path == "/api/session/{sid}" and "PATCH" in r.methods][-1]
request = SimpleNamespace(state=SimpleNamespace(current_user="alice"))
kwargs = dict(request=request, sid="s", name=None, folder=None, cwd=None)
patch(**kwargs, model="gpt-5.5", endpoint_url=BASE, endpoint_id="a")
assert sess.endpoint_id == "a"
with factory() as db:
assert db.get(cdb.Session, "s").endpoint_id == "a"
with pytest.raises(HTTPException):
patch(**kwargs, model="gpt-5.5", endpoint_url=BASE, endpoint_id="c")
patch(**kwargs, model=None, endpoint_url=None, endpoint_id=None)
assert sess.endpoint_id == "a"
def test_session_list_reports_bound_account_for_duplicate_model_and_url(state, monkeypatch):
factory, manager = state
sess = _session(manager)
manager.get_sessions_for_user = lambda *args, **kwargs: {"s": sess}
router = sessions.setup_session_routes(manager, {})
listing = [r.endpoint for r in router.routes if r.path == "/api/sessions" and "GET" in r.methods][-1]
rows = listing(SimpleNamespace(state=SimpleNamespace(current_user="alice"), query_params={}))
assert rows[0]["endpoint_id"] == "b"
assert rows[0]["endpoint_name"] == "b"
def test_migration_is_additive_idempotent_and_repairs_missing_index(tmp_path, monkeypatch):
engine = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}")
with engine.begin() as db:
db.execute(text("CREATE TABLE sessions (id VARCHAR PRIMARY KEY, model VARCHAR)"))
db.execute(text("INSERT INTO sessions VALUES ('legacy', 'gpt-5.5')"))
monkeypatch.setattr(cdb, "engine", engine)
cdb._migrate_add_session_endpoint_id_column()
cdb._migrate_add_session_endpoint_id_column()
with engine.begin() as db:
assert db.execute(text("SELECT model, endpoint_id FROM sessions")).one() == ("gpt-5.5", None)
db.execute(text("DROP INDEX ix_sessions_endpoint_id"))
cdb._migrate_add_session_endpoint_id_column()
assert [idx["name"] for idx in inspect(engine).get_indexes("sessions")] == ["ix_sessions_endpoint_id"]
engine.dispose()
@@ -0,0 +1,228 @@
"""Device-flow start/poll semantics for multiple ChatGPT subscriptions.
The pending device-flow state must carry the *intended operation* — create a
new account, or reconnect one exact existing account — with owner validation,
and must never carry access/refresh tokens.
"""
import types
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, ModelEndpoint, ProviderAuthSession
import routes.chatgpt_subscription_routes as csr
from routes.device_flow import PendingDeviceFlowStore
_BASE = "https://chatgpt.com/backend-api/codex"
def _mem_db(monkeypatch):
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
monkeypatch.setattr(csr, "SessionLocal", TestSessionLocal)
return TestSessionLocal
def _request(user):
return types.SimpleNamespace(state=types.SimpleNamespace(current_user=user), app=None, headers={})
def _seed_account(TestSessionLocal, owner, auth_id, ep_id, label="ChatGPT Subscription"):
db = TestSessionLocal()
try:
db.add(ProviderAuthSession(
id=auth_id, provider=csr.chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER, owner=owner,
label=label, base_url=_BASE, access_token="AT", refresh_token="RT", auth_mode="chatgpt",
))
db.add(ModelEndpoint(id=ep_id, name=label, base_url=_BASE, provider_auth_id=auth_id, owner=owner))
db.commit()
finally:
db.close()
def _fake_device_code(monkeypatch):
monkeypatch.setattr(
csr.chatgpt_subscription, "request_device_code",
lambda: {"device_auth_id": "dev-1", "user_code": "ABCD-EFGH", "interval": 3, "expires_in": 120},
)
def test_start_carries_label_and_connect_mode_without_tokens(monkeypatch):
_mem_db(monkeypatch)
_fake_device_code(monkeypatch)
start = csr._start_device_flow(_request("alice"), {"label": " codex00 "})
assert start.pending == {
"device_auth_id": "dev-1",
"user_code": "ABCD-EFGH",
"owner": "alice",
"label": "codex00",
"reconnect_auth_id": None,
"reconnect_endpoint_id": None,
}
assert start.response["mode"] == "connect"
assert start.response["account_label"] == "codex00"
for value in start.pending.values():
assert "access_token" not in str(value) and "refresh_token" not in str(value)
assert "access_token" not in start.pending and "refresh_token" not in start.pending
def test_start_rejects_overlong_or_duplicate_label(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_fake_device_code(monkeypatch)
with pytest.raises(HTTPException) as exc:
csr._start_device_flow(_request("alice"), {"label": "x" * 41})
assert exc.value.status_code == 400
_seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00")
with pytest.raises(HTTPException) as exc:
csr._start_device_flow(_request("alice"), {"label": "codex00"})
assert exc.value.status_code == 409
def test_start_reconnect_carries_exact_target_ids(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_fake_device_code(monkeypatch)
_seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00")
start = csr._start_device_flow(
_request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"},
)
assert start.pending["reconnect_auth_id"] == "auth-a"
assert start.pending["reconnect_endpoint_id"] == "ep-a"
assert start.pending["owner"] == "alice"
assert start.response["mode"] == "reconnect"
def test_start_reconnect_for_another_owner_is_rejected(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_fake_device_code(monkeypatch)
_seed_account(TestSessionLocal, "alice", "auth-a", "ep-a")
with pytest.raises(HTTPException) as exc:
csr._start_device_flow(_request("mallory"), {"reconnect_auth_id": "auth-a"})
assert exc.value.status_code == 404
# Mismatched endpoint id for a real auth id is also rejected.
_seed_account(TestSessionLocal, "alice", "auth-b", "ep-b")
with pytest.raises(HTTPException) as exc:
csr._start_device_flow(_request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-b"})
assert exc.value.status_code == 404
def test_poll_provisions_with_pending_operation_and_owner(monkeypatch):
factory = _mem_db(monkeypatch)
_seed_account(factory, "alice", "auth-a", "ep-a")
monkeypatch.setattr(
csr.chatgpt_subscription, "poll_device_auth",
lambda device_auth_id, user_code: {"authorization_code": "code", "code_verifier": "ver"},
)
monkeypatch.setattr(
csr.chatgpt_subscription, "exchange_authorization_code",
lambda code, verifier: {"access_token": "AT", "refresh_token": "RT"},
)
seen = {}
def fake_provision(tokens, owner, **kwargs):
seen["tokens"] = tokens
seen["owner"] = owner
seen["kwargs"] = kwargs
return {"id": "ep-new", "name": "ChatGPT · codex00", "models": ["gpt-5.5"]}
monkeypatch.setattr(csr, "_provision_endpoint", fake_provision)
pending = {
"device_auth_id": "dev-1", "user_code": "X", "owner": "alice", "label": "codex00",
"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a",
}
outcome = csr._poll_device_flow(_request("alice"), pending)
assert outcome.status == "authorized"
assert seen["owner"] == "alice"
assert seen["kwargs"] == {"label": "codex00", "reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"}
def test_poll_by_a_different_user_is_rejected(monkeypatch):
_mem_db(monkeypatch)
called = {"n": 0}
def _never(*a, **k):
called["n"] += 1
return {}
monkeypatch.setattr(csr.chatgpt_subscription, "poll_device_auth", _never)
pending = {"device_auth_id": "dev-1", "user_code": "X", "owner": "alice", "label": "", "reconnect_auth_id": None, "reconnect_endpoint_id": None}
with pytest.raises(HTTPException) as exc:
csr._poll_device_flow(_request("mallory"), pending)
assert exc.value.status_code == 403
assert called["n"] == 0
def test_expired_pending_flow_is_dropped_by_store():
clock = {"t": 1000.0}
store = PendingDeviceFlowStore(time_func=lambda: clock["t"])
poll_id = store.add({"owner": "alice", "reconnect_auth_id": "auth-a"}, interval=5, expires_in=60)
assert store.get_payload(poll_id)["reconnect_auth_id"] == "auth-a"
clock["t"] += 61
assert store.get_payload(poll_id) is None
clock["t"] += 3600
assert store.get_payload(poll_id) is None
def test_poll_revalidates_reconnect_endpoint_after_start(monkeypatch):
factory = _mem_db(monkeypatch)
_fake_device_code(monkeypatch)
_seed_account(factory, "alice", "auth-a", "ep-a")
start = csr._start_device_flow(_request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"})
with factory() as db:
db.delete(db.get(ModelEndpoint, "ep-a"))
db.commit()
monkeypatch.setattr(csr.chatgpt_subscription, "poll_device_auth", lambda *args: pytest.fail("must reject before polling"))
with pytest.raises(HTTPException) as exc:
csr._poll_device_flow(_request("alice"), start.pending)
assert exc.value.status_code == 404
def test_pending_payload_ignores_actual_secrets_and_allows_harmless_label(monkeypatch):
_mem_db(monkeypatch)
monkeypatch.setattr(csr.chatgpt_subscription, "request_device_code", lambda: {
"device_auth_id": "dev", "user_code": "code", "access_token": "SECRET-AT", "refresh_token": "SECRET-RT",
})
start = csr._start_device_flow(_request("alice"), {"label": "refresh_token"})
assert start.pending["label"] == "refresh_token"
for payload in (start.pending, start.response):
assert "access_token" not in payload and "refresh_token" not in payload
assert "SECRET-AT" not in str(payload) and "SECRET-RT" not in str(payload)
def test_account_listing_and_usage_route_are_owner_scoped(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(csr, "require_admin", lambda request: None)
_seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00")
_seed_account(TestSessionLocal, "bob", "auth-b", "ep-b", label="ChatGPT · work")
router = csr.setup_chatgpt_subscription_routes()
handlers = {(r.path, tuple(sorted(r.methods))): r.endpoint for r in router.routes}
list_accounts = handlers[("/api/chatgpt-subscription/accounts", ("GET",))]
usage = handlers[("/api/chatgpt-subscription/accounts/{auth_id}/usage", ("GET",))]
alice_accounts = list_accounts(_request("alice"))
assert [a["auth_id"] for a in alice_accounts] == ["auth-a"]
assert alice_accounts[0]["label"] == "codex00"
assert alice_accounts[0]["endpoint_ids"] == ["ep-a"]
assert "access_token" not in str(alice_accounts) and "refresh_token" not in str(alice_accounts)
assert "AT" not in str(alice_accounts)
calls = []
def fake_usage(auth_id, owner=None, force_refresh=False, cache=None):
calls.append((auth_id, owner, force_refresh))
return {"plan_type": "plus", "limits": [], "auth_id": auth_id}
monkeypatch.setattr(csr.chatgpt_subscription, "get_account_usage", fake_usage)
ok = usage("auth-a", _request("alice"), refresh=True)
assert ok["available"] is True
assert ok["usage"]["auth_id"] == "auth-a"
assert calls == [("auth-a", "alice", True)]
# Bob's account is invisible to Alice; the usage helper is never invoked.
with pytest.raises(HTTPException) as exc:
usage("auth-b", _request("alice"))
assert exc.value.status_code == 404
assert len(calls) == 1
+254 -14
View File
@@ -1,6 +1,7 @@
"""DB-backed ChatGPT Subscription endpoint provisioning tests."""
import json
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine
@@ -53,23 +54,163 @@ def test_provision_creates_owner_scoped_auth_session_and_endpoint(monkeypatch):
db.close()
def test_provision_refreshes_existing_auth_session_and_endpoint(monkeypatch):
def _connect(monkeypatch, owner, label="", models=("gpt-5.5",), tokens=None, **kwargs):
monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: list(models))
tokens = tokens or {"access_token": f"AT-{label or 'default'}", "refresh_token": f"RT-{label or 'default'}"}
return csr._provision_endpoint(tokens, owner, label=label, **kwargs)
def test_second_connection_creates_independent_auth_and_endpoint(monkeypatch):
"""Connecting a second subscription must never overwrite the first."""
TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: ["gpt-5.5"])
first = csr._provision_endpoint({"access_token": "OLD", "refresh_token": "OLD-RT"}, "bob")
second = csr._provision_endpoint({"access_token": "NEW", "refresh_token": "NEW-RT"}, "bob")
first = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"})
second = _connect(monkeypatch, "bob", label="codex01", tokens={"access_token": "B-AT", "refresh_token": "B-RT"})
assert first["id"] == second["id"]
assert first["id"] != second["id"]
assert first["provider_auth_id"] != second["provider_auth_id"]
assert first["name"] == "ChatGPT · codex00"
assert second["name"] == "ChatGPT · codex01"
assert second["reconnected"] is False
db = TestSessionLocal()
try:
auth_rows = db.query(ProviderAuthSession).filter(ProviderAuthSession.owner == "bob").all()
ep_rows = db.query(ModelEndpoint).filter(ModelEndpoint.owner == "bob").all()
assert len(auth_rows) == 1
assert len(ep_rows) == 1
assert auth_rows[0].access_token == "NEW"
assert auth_rows[0].refresh_token == "NEW-RT"
assert ep_rows[0].provider_auth_id == auth_rows[0].id
auth_rows = {a.id: a for a in db.query(ProviderAuthSession).filter(ProviderAuthSession.owner == "bob").all()}
ep_rows = {e.id: e for e in db.query(ModelEndpoint).filter(ModelEndpoint.owner == "bob").all()}
assert len(auth_rows) == 2
assert len(ep_rows) == 2
# Account A's credentials are untouched by connecting B.
assert auth_rows[first["provider_auth_id"]].access_token == "A-AT"
assert auth_rows[first["provider_auth_id"]].refresh_token == "A-RT"
assert auth_rows[second["provider_auth_id"]].access_token == "B-AT"
assert ep_rows[first["id"]].provider_auth_id == first["provider_auth_id"]
assert ep_rows[second["id"]].provider_auth_id == second["provider_auth_id"]
# Same base URL + same model on both routes is valid and intentional.
assert ep_rows[first["id"]].base_url == ep_rows[second["id"]].base_url
assert json.loads(ep_rows[first["id"]].cached_models) == json.loads(ep_rows[second["id"]].cached_models)
for ep in ep_rows.values():
assert ep.supports_tools is False
finally:
db.close()
def test_unlabelled_connections_get_distinct_default_names(monkeypatch):
_mem_db(monkeypatch)
first = _connect(monkeypatch, "bob")
second = _connect(monkeypatch, "bob")
third = _connect(monkeypatch, "bob")
# The first account keeps the legacy name; later ones are distinguishable.
assert first["name"] == "ChatGPT Subscription"
assert second["name"] == "ChatGPT · account 2"
assert third["name"] == "ChatGPT · account 3"
assert len({first["provider_auth_id"], second["provider_auth_id"], third["provider_auth_id"]}) == 3
def test_duplicate_label_for_same_owner_is_rejected_but_other_owner_ok(monkeypatch):
_mem_db(monkeypatch)
_connect(monkeypatch, "bob", label="codex00")
with pytest.raises(ValueError, match="already connected"):
_connect(monkeypatch, "bob", label="Codex00")
# Labels are owner scoped: another user may reuse the same label.
other = _connect(monkeypatch, "carol", label="codex00")
assert other["name"] == "ChatGPT · codex00"
def test_label_is_trimmed_bounded_and_control_chars_stripped(monkeypatch):
_mem_db(monkeypatch)
res = _connect(monkeypatch, "bob", label=" co\x00dex 00\t ")
assert res["account_label"] == "codex 00"
assert res["name"] == "ChatGPT · codex 00"
with pytest.raises(ValueError, match="at most 40"):
_connect(monkeypatch, "bob", label="x" * 41)
def test_reconnect_updates_only_the_targeted_account(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
a = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"})
b = _connect(monkeypatch, "bob", label="codex01", tokens={"access_token": "B-AT", "refresh_token": "B-RT"})
res = _connect(
monkeypatch, "bob", tokens={"access_token": "A-AT2", "refresh_token": "A-RT2"},
reconnect_auth_id=a["provider_auth_id"], reconnect_endpoint_id=a["id"],
)
assert res["reconnected"] is True
assert res["id"] == a["id"]
assert res["provider_auth_id"] == a["provider_auth_id"]
assert res["name"] == "ChatGPT · codex00" # label preserved on reconnect
db = TestSessionLocal()
try:
assert db.query(ProviderAuthSession).count() == 2
assert db.query(ModelEndpoint).count() == 2
auth_a = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first()
auth_b = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == b["provider_auth_id"]).first()
assert auth_a.access_token == "A-AT2" and auth_a.refresh_token == "A-RT2"
assert auth_b.access_token == "B-AT" and auth_b.refresh_token == "B-RT"
finally:
db.close()
# And the symmetric case: reconnecting B leaves A alone.
_connect(
monkeypatch, "bob", tokens={"access_token": "B-AT2", "refresh_token": "B-RT2"},
reconnect_auth_id=b["provider_auth_id"], reconnect_endpoint_id=b["id"],
)
db = TestSessionLocal()
try:
auth_a = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first()
auth_b = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == b["provider_auth_id"]).first()
assert auth_a.access_token == "A-AT2"
assert auth_b.access_token == "B-AT2"
finally:
db.close()
def test_reconnect_target_owned_by_another_user_is_rejected(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
a = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"})
with pytest.raises(csr.chatgpt_subscription.ChatGPTSubscriptionAuthNotFound):
_connect(
monkeypatch, "mallory", tokens={"access_token": "M-AT", "refresh_token": "M-RT"},
reconnect_auth_id=a["provider_auth_id"], reconnect_endpoint_id=a["id"],
)
db = TestSessionLocal()
try:
auth_a = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first()
assert auth_a.access_token == "A-AT"
assert db.query(ProviderAuthSession).count() == 1
assert db.query(ModelEndpoint).count() == 1
finally:
db.close()
def test_legacy_single_account_endpoint_can_be_reconnected_in_place(monkeypatch):
"""Rows provisioned before multi-account support keep working unchanged."""
TestSessionLocal = _mem_db(monkeypatch)
db = TestSessionLocal()
try:
db.add(ProviderAuthSession(
id="legacyauth", provider=csr.chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
owner="alice", label="ChatGPT Subscription", base_url="https://chatgpt.com/backend-api/codex",
access_token="OLD", refresh_token="OLD-RT", auth_mode="chatgpt",
))
db.add(ModelEndpoint(
id="legacyep", name="ChatGPT Subscription", base_url="https://chatgpt.com/backend-api/codex",
provider_auth_id="legacyauth", owner="alice", supports_tools=False,
))
db.commit()
finally:
db.close()
res = _connect(
monkeypatch, "alice", tokens={"access_token": "NEW", "refresh_token": "NEW-RT"},
reconnect_auth_id="legacyauth",
)
assert res["id"] == "legacyep"
assert res["name"] == "ChatGPT Subscription"
assert res["account_label"] == ""
db = TestSessionLocal()
try:
assert db.query(ProviderAuthSession).count() == 1
assert db.query(ProviderAuthSession).first().access_token == "NEW"
assert db.query(ModelEndpoint).count() == 1
finally:
db.close()
@@ -221,7 +362,7 @@ def test_delete_endpoint_route_revokes_orphaned_provider_auth(monkeypatch):
db.close()
delete_endpoint = _delete_route(monkeypatch, TestSessionLocal)
result = delete_endpoint("ep1", object())
result = delete_endpoint("ep1", SimpleNamespace(state=SimpleNamespace(current_user="alice")))
assert result["deleted"] is True
# The last (only) endpoint backed by auth1 is gone, so the route revokes it.
@@ -243,7 +384,7 @@ def test_delete_endpoint_route_keeps_auth_when_shared(monkeypatch):
db.close()
delete_endpoint = _delete_route(monkeypatch, TestSessionLocal)
result = delete_endpoint("ep1", object())
result = delete_endpoint("ep1", SimpleNamespace(state=SimpleNamespace(current_user="alice")))
assert result["deleted"] is True
# ep2 still references auth1, so deleting ep1 must NOT revoke the credentials.
@@ -278,3 +419,102 @@ def test_delete_orphaned_provider_auth_revokes_only_after_last_of_several(monkey
assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth1").first() is None
finally:
db.close()
def test_delete_account_a_preserves_account_b(monkeypatch):
"""Deleting one subscription clears only its own orphaned auth row."""
TestSessionLocal = _mem_db(monkeypatch)
a = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"})
b = _connect(monkeypatch, "bob", label="codex01", tokens={"access_token": "B-AT", "refresh_token": "B-RT"})
delete_endpoint = _delete_route(monkeypatch, TestSessionLocal)
result = delete_endpoint(a["id"], SimpleNamespace(state=SimpleNamespace(current_user="bob")))
assert result["deleted"] is True
assert result["cleared_provider_auth"] is True
db = TestSessionLocal()
try:
assert db.query(ModelEndpoint).filter(ModelEndpoint.id == a["id"]).first() is None
assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first() is None
ep_b = db.query(ModelEndpoint).filter(ModelEndpoint.id == b["id"]).first()
auth_b = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == b["provider_auth_id"]).first()
assert ep_b is not None and ep_b.is_enabled is True
assert auth_b is not None and auth_b.access_token == "B-AT" and auth_b.refresh_token == "B-RT"
finally:
db.close()
def test_manual_model_refresh_uses_the_endpoints_own_auth_session(monkeypatch):
"""Refreshing A's models resolves A's bearer and never touches B."""
import routes.model_routes as mr
import src.endpoint_resolver as endpoint_resolver
TestSessionLocal = _mem_db(monkeypatch)
a = _connect(monkeypatch, "bob", label="codex00", models=("gpt-5.5",))
b = _connect(monkeypatch, "bob", label="codex01", models=("gpt-5.5",))
monkeypatch.setattr(mr, "SessionLocal", TestSessionLocal)
monkeypatch.setattr(mr, "require_admin", lambda request: None)
resolved = []
def fake_runtime(ep, owner=None):
resolved.append((ep.id, ep.provider_auth_id, owner))
return (ep.base_url, f"bearer-{ep.provider_auth_id}")
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", fake_runtime)
probed = []
def fake_probe(base, api_key=None, timeout=5):
probed.append(api_key)
return ["gpt-5.5", "gpt-5.5-codex"] if api_key == f"bearer-{a['provider_auth_id']}" else []
monkeypatch.setattr(mr, "_probe_endpoint", fake_probe)
router = mr.setup_model_routes(model_discovery=None)
list_models = next(
r.endpoint for r in router.routes
if getattr(r, "path", "") == "/api/model-endpoints/{ep_id}/models" and "GET" in getattr(r, "methods", set())
)
class _Resp:
headers = {}
rows = list_models(a["id"], SimpleNamespace(state=SimpleNamespace(current_user="bob")), _Resp(), refresh=True)
assert resolved == [(a["id"], a["provider_auth_id"], "bob")]
assert probed == [f"bearer-{a['provider_auth_id']}"]
assert {r["id"] for r in rows} == {"gpt-5.5", "gpt-5.5-codex"}
db = TestSessionLocal()
try:
ep_a = db.query(ModelEndpoint).filter(ModelEndpoint.id == a["id"]).first()
ep_b = db.query(ModelEndpoint).filter(ModelEndpoint.id == b["id"]).first()
assert json.loads(ep_a.cached_models) == ["gpt-5.5", "gpt-5.5-codex"]
assert json.loads(ep_b.cached_models) == ["gpt-5.5"] # untouched
finally:
db.close()
def test_endpoint_listing_exposes_account_metadata_without_credentials(monkeypatch):
import routes.model_routes as mr
TestSessionLocal = _mem_db(monkeypatch)
a = _connect(monkeypatch, "bob", label="codex00")
_connect(monkeypatch, "bob", label="codex01")
monkeypatch.setattr(mr, "SessionLocal", TestSessionLocal)
monkeypatch.setattr(mr, "require_admin", lambda request: None)
router = mr.setup_model_routes(model_discovery=None)
list_endpoints = next(
r.endpoint for r in router.routes
if getattr(r, "path", "") == "/api/model-endpoints" and "GET" in getattr(r, "methods", set())
)
rows = list_endpoints(SimpleNamespace(state=SimpleNamespace(current_user="bob")))
by_id = {r["id"]: r for r in rows}
assert by_id[a["id"]]["provider"] == "chatgpt-subscription"
assert by_id[a["id"]]["provider_auth_id"] == a["provider_auth_id"]
assert by_id[a["id"]]["account_label"] == "codex00"
assert by_id[a["id"]]["supports_tools"] is False
assert by_id[a["id"]]["has_key"] is False
labels = sorted(r["account_label"] for r in rows)
assert labels == ["codex00", "codex01"]
dumped = json.dumps(rows)
assert "AT-" not in dumped and "RT-" not in dumped and "refresh_token" not in dumped
+356
View File
@@ -0,0 +1,356 @@
"""Read-only ChatGPT Subscription usage: contract, normalization, cache, errors.
Contract mirrored from openai/codex ``codex-rs/backend-client`` (ChatGptApi
path style): ``GET {backend-api}/wham/usage`` returning ``plan_type``,
``rate_limit{primary_window,secondary_window}``, ``additional_rate_limits[]``,
``rate_limit_reached_type`` and ``account_id``.
"""
import json
import httpx
import pytest
from src import chatgpt_subscription as cs
@pytest.fixture
def owned_accounts(monkeypatch):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import core.database as cdb
engine = create_engine("sqlite:///:memory:")
cdb.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
monkeypatch.setattr(cs, "_database_handles", lambda: (cdb.ProviderAuthSession, factory, cdb.utcnow_naive))
with factory() as db:
for auth_id in ("auth-a", "auth-b"):
db.add(cdb.ProviderAuthSession(id=auth_id, provider=cs.CHATGPT_SUBSCRIPTION_PROVIDER, owner="alice", base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL))
db.commit()
yield factory
engine.dispose()
def _window(used, seconds, reset_at):
return {"used_percent": used, "limit_window_seconds": seconds, "reset_after_seconds": 10, "reset_at": reset_at}
_FULL_PAYLOAD = {
"plan_type": "plus",
"account_id": "acct_123",
"user_id": "user_1",
"rate_limit": {
"allowed": True,
"limit_reached": False,
"primary_window": _window(71, 300 * 60, 1_800_000_000),
"secondary_window": _window(28, 7 * 24 * 3600, 1_800_400_000),
},
"additional_rate_limits": [
{
"limit_name": "GPT-5.5 Pro",
"metered_feature": "codex_pro",
"normal_model_slug": "gpt-5.5-pro",
"rate_limit": {"allowed": True, "limit_reached": False, "primary_window": _window(5, 3600, 1_800_001_000)},
},
{"limit_name": "future", "metered_feature": "codex_future", "rate_limit": None},
"garbage",
],
"credits": {"has_credits": True, "unlimited": False, "balance": "9.99"},
"spend_control": {"reached": False},
"rate_limit_reached_type": None,
"rate_limit_upsell": {"title": "upgrade"},
"some_new_field": {"nested": [1, 2, 3]},
}
def test_usage_url_matches_codex_chatgpt_backend_contract():
assert cs.CHATGPT_USAGE_URL == "https://chatgpt.com/backend-api/wham/usage"
def test_normalize_primary_secondary_and_additional_buckets():
out = cs.normalize_usage_payload(json.loads(json.dumps(_FULL_PAYLOAD)))
assert out["plan_type"] == "plus"
assert out["account_id"] == "acct_123"
assert out["ordinary_usage_allowed"] is True
assert out["rate_limit_reached_type"] is None
codex, pro, future = out["limits"]
assert codex["limit_id"] == "codex" and codex["limit_name"] is None
primary, secondary = codex["windows"]
assert primary == {
"kind": "primary", "name": "5H", "used_percent": 71.0, "remaining_percent": 29.0,
"window_minutes": 300, "resets_at": 1_800_000_000, "reset_after_seconds": 10,
}
assert secondary["kind"] == "secondary"
assert secondary["name"] == "WEEK"
assert secondary["window_minutes"] == 10080
assert secondary["used_percent"] == 28.0 and secondary["remaining_percent"] == 72.0
assert pro["limit_id"] == "codex_pro"
assert pro["limit_name"] == "GPT-5.5 Pro"
assert pro["normal_model_slug"] == "gpt-5.5-pro"
assert pro["windows"][0]["name"] == "1H"
assert pro["windows"][0]["used_percent"] == 5.0
# Unknown/empty additional bucket is kept (not dropped) but has no windows.
assert future["limit_id"] == "codex_future" and future["windows"] == []
# Unknown top-level fields and raw upstream metadata never leak through.
for forbidden in ("user_id", "credits", "spend_control", "rate_limit_upsell", "some_new_field"):
assert forbidden not in out
def test_normalize_tolerates_absent_windows_and_unknown_reached_type():
out = cs.normalize_usage_payload({"plan_type": "pro", "rate_limit": None, "rate_limit_reached_type": {"type": "workspace_owner_credits_depleted"}})
assert out["plan_type"] == "pro"
assert out["ordinary_usage_allowed"] is None
assert out["rate_limit_reached_type"] == "workspace_owner_credits_depleted"
assert out["limits"] == [{
"limit_id": "codex", "limit_name": None, "normal_model_slug": None,
"allowed": None, "limit_reached": None, "windows": [],
}]
# Missing reset must stay absent — never invented.
out2 = cs.normalize_usage_payload({"plan_type": "free", "rate_limit": {"primary_window": {"used_percent": "12.5", "limit_window_seconds": 0}}})
win = out2["limits"][0]["windows"][0]
assert win["resets_at"] is None and win["window_minutes"] is None and win["name"] == "LIMIT"
assert win["used_percent"] == 12.5
def test_normalize_clamps_percent_and_rejects_non_object():
out = cs.normalize_usage_payload({"rate_limit": {"primary_window": {"used_percent": 250, "limit_window_seconds": 90}}})
win = out["limits"][0]["windows"][0]
assert win["used_percent"] == 100.0 and win["remaining_percent"] == 0.0
assert win["window_minutes"] == 2 # ceil(90/60) like Codex
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.normalize_usage_payload(["not", "an", "object"])
assert exc.value.reason == "malformed"
@pytest.mark.parametrize("minutes,name", [(300, "5H"), (10080, "WEEK"), (20160, "2W"), (1440, "1D"), (90, "90M"), (None, "LIMIT")])
def test_friendly_window_names_derive_from_duration(minutes, name):
assert cs.friendly_window_name(minutes) == name
def _fake_get(monkeypatch, *, status=200, body=b"{}", raise_exc=None, seen=None):
def fake_get(url, headers=None, timeout=None):
if seen is not None:
seen.append({"url": url, "headers": dict(headers or {}), "timeout": timeout})
if raise_exc is not None:
raise raise_exc
return httpx.Response(status, content=body, request=httpx.Request("GET", url))
monkeypatch.setattr(cs.httpx, "get", fake_get)
def test_fetch_usage_uses_bearer_and_account_header_and_strict_timeout(monkeypatch):
seen = []
_fake_get(monkeypatch, body=json.dumps({"plan_type": "plus"}).encode(), seen=seen)
# A JWT whose auth claim carries the ChatGPT account id, as Codex reads it.
import base64
claims = base64.urlsafe_b64encode(json.dumps({"https://api.openai.com/auth": {"chatgpt_account_id": "acct_9"}}).encode()).rstrip(b"=").decode()
token = f"hdr.{claims}.sig"
data = cs.fetch_usage_payload(token)
assert data == {"plan_type": "plus"}
assert seen[0]["url"] == "https://chatgpt.com/backend-api/wham/usage"
assert seen[0]["headers"]["Authorization"] == f"Bearer {token}"
assert seen[0]["headers"]["ChatGPT-Account-Id"] == "acct_9"
assert 0 < seen[0]["timeout"] <= 10
@pytest.mark.parametrize("status,reason", [(401, "reauth"), (403, "reauth"), (429, "rate_limited"), (500, "upstream"), (503, "upstream"), (418, "upstream")])
def test_fetch_usage_classifies_http_failures(monkeypatch, status, reason):
_fake_get(monkeypatch, status=status, body=b"nope")
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.fetch_usage_payload("tok")
assert exc.value.reason == reason
assert exc.value.status_code == status
def test_fetch_usage_handles_timeout_network_and_malformed_json(monkeypatch):
_fake_get(monkeypatch, raise_exc=httpx.ReadTimeout("slow"))
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.fetch_usage_payload("tok")
assert exc.value.reason == "timeout"
_fake_get(monkeypatch, raise_exc=httpx.ConnectError("down"))
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.fetch_usage_payload("tok")
assert exc.value.reason == "network"
_fake_get(monkeypatch, body=b"<html>not json")
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.fetch_usage_payload("tok")
assert exc.value.reason == "malformed"
_fake_get(monkeypatch, body=b"[1,2]")
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.fetch_usage_payload("tok")
assert exc.value.reason == "malformed"
def test_usage_cache_is_per_auth_session_and_expires():
clock = {"t": 100.0}
cache = cs.UsageCache(ttl_seconds=45, time_func=lambda: clock["t"])
cache.put("auth-a", {"plan_type": "plus"})
assert cache.get("auth-a") == {"plan_type": "plus"}
# Cache for A can never satisfy B.
assert cache.get("auth-b") is None
clock["t"] += 44
assert cache.get("auth-a") is not None
clock["t"] += 2
assert cache.get("auth-a") is None
def test_cache_is_bounded_copies_values_and_cleans_expired_accounts():
clock = [0]
cache = cs.UsageCache(time_func=lambda: clock[0], max_entries=2)
original = {"limits": [{"used": 1}]}
cache.put("a", original)
original["limits"][0]["used"] = 99
assert cache.get("a")["limits"][0]["used"] == 1
cache.put("b", {})
cache.put("c", {})
assert cache.get("a") is None
clock[0] = 46
cache.put("d", {})
assert set(cache._entries) == {"d"}
@pytest.mark.parametrize("value", [float("nan"), float("inf"), "NaN", "Infinity", True, {}])
def test_non_finite_and_malformed_usage_never_invents_a_percentage(value):
out = cs.normalize_usage_window({"used_percent": value, "reset_at": value}, "primary")
assert out["used_percent"] is None
assert out["remaining_percent"] is None
assert out["resets_at"] is None
json.dumps(out, allow_nan=False)
def test_future_window_kind_and_reached_kind_are_retained():
out = cs.normalize_usage_payload({"rate_limit": {"tertiary_window": _window(3, 7200, 100)},
"rate_limit_reached_type": {"kind": "future_limit"}})
assert out["limits"][0]["windows"][0]["kind"] == "tertiary"
assert out["limits"][0]["windows"][0]["window_minutes"] == 120
assert out["rate_limit_reached_type"] == "future_limit"
def test_cache_hits_revalidate_owner_and_deleted_auth(owned_accounts):
from core.database import ProviderAuthSession
cache = cs.UsageCache()
cache.put("auth-a", {"plan_type": "plus"})
with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
cs.get_account_usage("auth-a", owner="mallory", cache=cache)
with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
cs.get_account_usage("auth-a", owner=None, cache=cache)
assert cs.get_account_usage("auth-a", owner="alice", cache=cache)["cached"] is True
with owned_accounts() as db:
db.delete(db.get(ProviderAuthSession, "auth-a"))
db.commit()
with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
cs.get_account_usage("auth-a", owner="alice", cache=cache)
def test_oauth_errors_never_echo_upstream_credentials():
response = httpx.Response(401, json={"error": {"code": "invalid_token", "message": "SECRET-AT SECRET-RT"}})
with pytest.raises(cs.ChatGPTSubscriptionReauthRequired) as exc:
cs._raise_for_oauth_response(response, "token refresh")
assert "SECRET" not in str(exc.value)
def test_get_account_usage_uses_exact_auth_session_cache_and_force_refresh(monkeypatch, owned_accounts):
clock = {"t": 1000.0}
cache = cs.UsageCache(ttl_seconds=45, time_func=lambda: clock["t"])
resolved = []
def fake_resolve(auth_id, owner=None, force_refresh=False):
resolved.append((auth_id, owner))
return {"api_key": f"token-for-{auth_id}", "base_url": cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL}
fetched = []
def fake_fetch(access_token, timeout=None):
fetched.append(access_token)
return {"plan_type": "plus" if access_token.endswith("auth-a") else "pro", "rate_limit": {"primary_window": _window(10, 300, 5)}}
monkeypatch.setattr(cs, "resolve_runtime_credentials", fake_resolve)
monkeypatch.setattr(cs, "fetch_usage_payload", fake_fetch)
a1 = cs.get_account_usage("auth-a", owner="alice", cache=cache)
assert a1["plan_type"] == "plus" and a1["cached"] is False and a1["auth_id"] == "auth-a"
assert resolved == [("auth-a", "alice")]
assert fetched == ["token-for-auth-a"]
a2 = cs.get_account_usage("auth-a", owner="alice", cache=cache)
assert a2["cached"] is True and a2["plan_type"] == "plus"
assert len(fetched) == 1 # served from cache
b1 = cs.get_account_usage("auth-b", owner="alice", cache=cache)
assert b1["plan_type"] == "pro" and b1["cached"] is False
assert fetched == ["token-for-auth-a", "token-for-auth-b"] # B never reuses A's cache or token
a3 = cs.get_account_usage("auth-a", owner="alice", force_refresh=True, cache=cache)
assert a3["cached"] is False
assert fetched == ["token-for-auth-a", "token-for-auth-b", "token-for-auth-a"]
# Nothing token-shaped in the returned structure.
dumped = json.dumps(a3)
assert "token-for" not in dumped and "refresh_token" not in dumped and "access_token" not in dumped
def test_get_account_usage_maps_credential_failures_without_touching_endpoint(monkeypatch, owned_accounts):
cache = cs.UsageCache(ttl_seconds=45)
def boom(auth_id, owner=None, force_refresh=False):
raise cs.ChatGPTSubscriptionReauthRequired("expired")
monkeypatch.setattr(cs, "resolve_runtime_credentials", boom)
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.get_account_usage("auth-a", owner="alice", cache=cache)
assert exc.value.reason == "reauth"
def missing(auth_id, owner=None, force_refresh=False):
raise cs.ChatGPTSubscriptionAuthNotFound("gone")
monkeypatch.setattr(cs, "resolve_runtime_credentials", missing)
with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
cs.get_account_usage("auth-x", owner="alice", cache=cache)
def test_usage_route_failure_is_reported_safely_and_endpoint_stays_enabled(monkeypatch):
import types
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, ModelEndpoint, ProviderAuthSession
import routes.chatgpt_subscription_routes as csr
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
monkeypatch.setattr(csr, "SessionLocal", TestSessionLocal)
monkeypatch.setattr(csr, "require_admin", lambda request: None)
db = TestSessionLocal()
try:
db.add(ProviderAuthSession(id="auth-a", provider=cs.CHATGPT_SUBSCRIPTION_PROVIDER, owner="alice", label="ChatGPT · codex00",
base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL, access_token="SECRET-AT", refresh_token="SECRET-RT"))
db.add(ModelEndpoint(id="ep-a", name="ChatGPT · codex00", base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL,
provider_auth_id="auth-a", owner="alice", is_enabled=True, supports_tools=False))
db.commit()
finally:
db.close()
def failing(auth_id, owner=None, force_refresh=False, cache=None):
raise cs.ChatGPTUsageUnavailable("reauth", "rejected", status_code=401)
monkeypatch.setattr(cs, "get_account_usage", failing)
router = csr.setup_chatgpt_subscription_routes()
usage = next(r.endpoint for r in router.routes if r.path.endswith("/usage"))
request = types.SimpleNamespace(state=types.SimpleNamespace(current_user="alice"), app=None, headers={})
payload = usage("auth-a", request)
assert payload["available"] is False
assert payload["reason"] == "reauth"
assert payload["reconnect_suggested"] is True
assert payload["account"]["auth_id"] == "auth-a"
dumped = json.dumps(payload)
assert "SECRET" not in dumped and "access_token" not in dumped and "refresh_token" not in dumped
db = TestSessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-a").first()
auth = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth-a").first()
# Usage failure != endpoint failure: nothing disabled, nothing destroyed.
assert ep.is_enabled is True
assert auth.refresh_token == "SECRET-RT"
finally:
db.close()
+267
View File
@@ -0,0 +1,267 @@
"""Node-driven tests for the DOM-free ChatGPT usage card module + admin wiring."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_MODULE = _REPO / "static" / "js" / "chatgptSubscriptionUsage.js"
_ADMIN = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
def _run_node(script: str):
proc = subprocess.run(
["node", "--input-type=module"], input=script, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
_PAYLOAD_A = {
"available": True,
"account": {"auth_id": "auth-a", "label": "codex00", "name": "ChatGPT · codex00"},
"usage": {
"auth_id": "auth-a", "plan_type": "plus", "account_id": "acct_a", "ordinary_usage_allowed": True,
"rate_limit_reached_type": None, "fetched_at": 1_800_000_000, "cached": False,
"limits": [
{"limit_id": "codex", "limit_name": None, "normal_model_slug": None, "allowed": True, "limit_reached": False,
"windows": [
{"kind": "primary", "name": "5H", "used_percent": 71, "remaining_percent": 29, "window_minutes": 300, "resets_at": 1_800_000_000 + 2 * 3600 + 14 * 60, "reset_after_seconds": 8040},
{"kind": "secondary", "name": "WEEK", "used_percent": 28, "remaining_percent": 72, "window_minutes": 10080, "resets_at": 1_800_000_000 + 4 * 86400 + 18 * 3600, "reset_after_seconds": 1},
]},
{"limit_id": "codex_pro", "limit_name": "GPT-5.5 Pro", "normal_model_slug": "gpt-5.5-pro", "allowed": True, "limit_reached": False,
"windows": [{"kind": "primary", "name": "1H", "used_percent": 5, "remaining_percent": 95, "window_minutes": 60, "resets_at": None, "reset_after_seconds": None}]},
{"limit_id": "mystery", "limit_name": "Mystery", "normal_model_slug": None, "allowed": None, "limit_reached": None, "windows": []},
],
},
}
_PAYLOAD_B = {
"available": True,
"account": {"auth_id": "auth-b", "label": "codex01", "name": "ChatGPT · codex01"},
"usage": {"plan_type": "pro", "limits": [{"limit_id": "codex", "windows": [{"kind": "primary", "name": "5H", "used_percent": 100, "window_minutes": 300}]}]},
}
def test_view_model_normalizes_windows_and_reset_countdowns():
js = f"""
import {{ buildUsageViewModel }} from '{_MODULE.as_posix()}';
const vm = buildUsageViewModel({json.dumps(_PAYLOAD_A)}, 1800000000);
console.log(JSON.stringify(vm));
"""
vm = _run_node(js)
assert vm["available"] is True
assert vm["authId"] == "auth-a"
assert vm["plan"] == "Plus"
codex, pro, mystery = vm["limits"]
assert codex["title"] == ""
primary, secondary = codex["windows"]
assert primary["name"] == "5H"
assert primary["usedLabel"] == "71% used"
assert primary["remainingLabel"] == "29% remaining"
assert primary["resetLabel"] == "resets in 2h 14m"
assert secondary["name"] == "WEEK"
assert secondary["remainingLabel"] == "72% remaining"
assert secondary["resetLabel"] == "resets in 4d 18h"
# Additional bucket is kept with its own title/model; missing reset is not invented.
assert pro["title"] == "GPT-5.5 Pro" and pro["modelSlug"] == "gpt-5.5-pro"
assert pro["windows"][0]["resetLabel"] == ""
assert mystery["windows"] == []
def test_view_model_is_defensive_about_bad_values():
payload = {
"available": True,
"account": {"auth_id": "auth-x"},
"usage": {"plan_type": 42, "limits": [
{"limit_id": "codex", "windows": [{"kind": "primary", "used_percent": "abc", "window_minutes": "300", "resets_at": "soon"}, None, "str"]},
"garbage",
{"limit_id": "over", "windows": [{"used_percent": 250, "resets_at": 5}]},
]},
}
js = f"""
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
const vm = buildUsageViewModel({json.dumps(payload)}, 10);
const html = renderUsageCardHtml(vm, {{ endpointId: 'ep-x' }});
console.log(JSON.stringify({{ vm, html }}));
"""
out = _run_node(js)
vm = out["vm"]
assert vm["plan"] == ""
codex, over = vm["limits"]
assert codex["windows"][0]["usedPercent"] is None
assert codex["windows"][0]["usedLabel"] == "usage unknown"
assert codex["windows"][0]["resetLabel"] == ""
assert over["windows"][0]["usedPercent"] == 100
assert over["windows"][0]["remainingPercent"] == 0
assert over["windows"][0]["resetLabel"] == "resets now"
assert 'aria-valuenow' not in out["html"].split('data-usage-limit="over"')[0]
assert 'aria-valuenow="100"' in out["html"]
def test_unavailable_states_render_message_and_refresh_button():
cases = {
"reauth": {"available": False, "reason": "reauth", "reconnect_suggested": True, "account": {"auth_id": "auth-a"}},
"rate_limited": {"available": False, "reason": "rate_limited", "account": {"auth_id": "auth-a"}},
"timeout": {"available": False, "reason": "timeout", "account": {"auth_id": "auth-a"}},
"malformed": None,
"empty": {},
}
js = f"""
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
const cases = {json.dumps(cases)};
const out = {{}};
for (const [k, payload] of Object.entries(cases)) {{
const vm = buildUsageViewModel(payload, 0);
out[k] = {{ vm, html: renderUsageCardHtml(vm, {{ endpointId: 'ep-a' }}) }};
}}
console.log(JSON.stringify(out));
"""
out = _run_node(js)
assert out["reauth"]["vm"]["message"] == "Usage unavailable — account may need reconnecting"
assert out["reauth"]["vm"]["reconnectSuggested"] is True
assert "rate limited" in out["rate_limited"]["vm"]["message"]
assert "timed out" in out["timeout"]["vm"]["message"]
assert out["malformed"]["vm"]["available"] is False
assert out["empty"]["vm"]["message"] == "Usage unavailable"
for case in out.values():
assert "adm-chatgpt-usage-unavailable" in case["html"]
assert 'data-adm-chatgpt-usage-refresh=' in case["html"]
assert ">Refresh usage<" in case["html"]
def test_two_account_cards_render_independently_with_exact_ids():
js = f"""
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
const a = renderUsageCardHtml(buildUsageViewModel({json.dumps(_PAYLOAD_A)}, 1800000000), {{ endpointId: 'ep-a' }});
const b = renderUsageCardHtml(buildUsageViewModel({json.dumps(_PAYLOAD_B)}, 1800000000), {{ endpointId: 'ep-b' }});
console.log(JSON.stringify({{ a, b }}));
"""
out = _run_node(js)
a, b = out["a"], out["b"]
assert 'data-adm-chatgpt-usage="auth-a"' in a and 'data-adm-chatgpt-usage="auth-b"' in b
assert 'data-adm-chatgpt-usage-refresh="auth-a" data-chatgpt-endpoint-id="ep-a"' in a
assert 'data-adm-chatgpt-reconnect="auth-a" data-chatgpt-endpoint-id="ep-a"' in a
assert 'data-adm-chatgpt-usage-refresh="auth-b" data-chatgpt-endpoint-id="ep-b"' in b
assert 'data-adm-chatgpt-reconnect="auth-b" data-chatgpt-endpoint-id="ep-b"' in b
assert "auth-b" not in a and "auth-a" not in b
assert ">Plus<" in a and ">Pro<" in b
assert "29% remaining" in a and "72% remaining" in a
assert "resets in 2h 14m" in a and "resets in 4d 18h" in a
assert "GPT-5.5 Pro" in a and "gpt-5.5-pro" in a
assert "0% remaining" in b and "adm-chatgpt-usage-critical" in b
assert a.count("adm-chatgpt-usage-row") == 3 # 5H + WEEK + additional bucket
def test_rendered_html_escapes_and_contains_no_credentials():
payload = {
"available": True,
"account": {"auth_id": "auth-a", "label": "<img src=x onerror=alert(1)>"},
"usage": {"plan_type": "<b>plus</b>", "limits": [{"limit_id": "codex", "limit_name": "<script>", "windows": [{"kind": "primary", "name": "<5H>", "used_percent": 10}]}],
"access_token": "SHOULD-NOT-BE-HERE"},
}
js = f"""
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
const html = renderUsageCardHtml(buildUsageViewModel({json.dumps(payload)}, 0), {{ endpointId: 'ep-a' }});
console.log(JSON.stringify({{ html }}));
"""
html = _run_node(js)["html"]
assert "<script>" not in html and "<img" not in html and "<b>plus" not in html
assert "&lt;5H&gt;" in html
assert "SHOULD-NOT-BE-HERE" not in html
assert "Bearer" not in html and "access_token" not in html and "refresh_token" not in html
def test_admin_wires_per_account_usage_and_reconnect_by_exact_ids():
load_block = _ADMIN[_ADMIN.index("async function loadEndpoints()"):_ADMIN.index("async function _refreshAfterEndpointChange")] if _ADMIN.index("async function loadEndpoints()") < _ADMIN.index("async function _refreshAfterEndpointChange") else _ADMIN[_ADMIN.index("async function loadEndpoints()"):]
assert "isChatgptSubscriptionEndpoint(ep)" in load_block
assert 'data-adm-chatgpt-usage-host="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"' in load_block
assert "_loadChatgptUsage(host, host.dataset.admChatgptUsageHost, host.dataset.chatgptEndpointId)" in load_block
usage_block = _ADMIN[_ADMIN.index("async function _loadChatgptUsage"):_ADMIN.index("function initEndpointForm()")]
assert "/api/chatgpt-subscription/accounts/' + encodeURIComponent(authId) + '/usage'" in usage_block
assert "refresh ? '?refresh=1' : ''" in usage_block
assert "refreshBtn.dataset.admChatgptUsageRefresh" in usage_block
assert "reconnectBtn.dataset.admChatgptReconnect" in usage_block
assert "formData.append('reconnect_auth_id', authId)" in usage_block
assert "formData.append('reconnect_endpoint_id', epId)" in usage_block
# The browser only ever talks to Odysseus, never to OpenAI directly.
assert "chatgpt.com" not in usage_block
assert "wham/usage" not in usage_block
def test_admin_add_flow_sends_optional_account_label():
form_block = _ADMIN[_ADMIN.index("function _setApiFormForProvider()"):_ADMIN.index("function _renderPickerMenu()")]
assert "Account label, e.g. codex00 (optional)" in form_block
assert "_chatgptLabelMode = true" in form_block
start_block = _ADMIN[_ADMIN.index("async function _startProviderDeviceAuth"):_ADMIN.index('// Local "Add" button')]
assert "formData.append('label', label)" in start_block
assert "formData," in start_block
assert ".adm-chatgpt-usage-bar" in _STYLE and ".adm-chatgpt-usage-fill" in _STYLE
def test_unknown_limits_without_windows_remain_visible():
payload = {"available": True, "account": {"auth_id": "a"}, "usage": {
"limits": [{"limit_id": "future", "limit_name": "Future <limit>", "windows": []}],
}}
out = _run_node(f"""
import {{ buildUsageViewModel, renderUsageCardHtml, formatResetIn }} from '{_MODULE.as_posix()}';
console.log(JSON.stringify({{
html: renderUsageCardHtml(buildUsageViewModel({json.dumps(payload)})),
reset: formatResetIn(0, 100),
}}));
""")
assert "Future &lt;limit&gt;" in out["html"]
assert 'data-usage-limit="future"' in out["html"]
assert "No rate-limit windows reported" in out["html"]
assert out["reset"] == ""
def test_refresh_and_reconnect_handlers_target_only_the_clicked_account():
# Execute the real admin handlers with small DOM doubles. This checks the
# actions themselves, beyond checking renderer attributes or source text.
out = _run_node(f"""
import fs from 'node:fs';
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
const source = fs.readFileSync('{(_MODULE.parent / 'admin.js').as_posix()}', 'utf8');
const start = source.indexOf('const _chatgptReconnectInflight');
const end = source.indexOf('function initEndpointForm()', start);
const urls = [], operations = [];
const makeButton = () => ({{ dataset: {{}}, addEventListener(_, fn) {{ this.click = fn; }} }});
function card(id) {{
const refresh = makeButton(), reconnect = makeButton();
for (const button of [refresh, reconnect]) button.dataset = {{
admChatgptUsageRefresh: id, admChatgptReconnect: id, chatgptEndpointId: 'ep-' + id,
}};
return {{ innerHTML: '', refresh, reconnect, querySelector(sel) {{
if (sel.includes('usage-refresh')) return refresh;
if (sel.includes('chatgpt-reconnect')) return reconnect;
return {{ replaceWith() {{}} }};
}} }};
}}
const handlers = new Function('fetch', 'buildChatgptUsageViewModel', 'renderChatgptUsageCardHtml',
'esc', 'runProviderDeviceFlow', 'document', 'loadEndpoints', 'setTimeout',
source.slice(start, end) + '; return {{ load: _loadChatgptUsage }};'
)(
async url => {{ urls.push(url); return {{ ok: true, json: async () => ({{available: true, usage: {{limits: []}}}}) }}; }},
buildUsageViewModel, renderUsageCardHtml, x => String(x),
async (provider, options) => {{ operations.push(Object.fromEntries(options.formData)); return {{ status: 'authorized' }}; }},
{{ createElement: () => ({{}}) }}, async () => {{}}, () => {{}}
);
const a = card('a'), b = card('b');
await handlers.load(a, 'a', 'ep-a'); await handlers.load(b, 'b', 'ep-b');
const before = b.innerHTML;
await a.refresh.click({{stopPropagation() {{}}}});
await a.reconnect.click({{stopPropagation() {{}}}});
console.log(JSON.stringify({{ urls, operations, bUnchanged: b.innerHTML === before }}));
""")
assert out["urls"] == [
"/api/chatgpt-subscription/accounts/a/usage",
"/api/chatgpt-subscription/accounts/b/usage",
"/api/chatgpt-subscription/accounts/a/usage?refresh=1",
]
assert out["operations"] == [{"reconnect_auth_id": "a", "reconnect_endpoint_id": "ep-a"}]
assert out["bUnchanged"] is True
+210
View File
@@ -0,0 +1,210 @@
"""ChatGPT Subscription is model inference only — Odysseus is the only agent.
These regressions pin the zero-native-tool invariant relied on by the Odysseus
dogfood benchmark: the ChatGPT/Codex Responses request never declares
provider-native tools, and the stream handler never executes provider-side
tool calls. Odysseus' own text tool protocol travels as plain instructions/
input and is parsed and executed by Odysseus.
"""
import asyncio
import json
import pytest
from src import llm_core
from src import chatgpt_subscription
_URL = "https://chatgpt.com/backend-api/codex"
_TOOLS = [
{"type": "function", "function": {"name": "run_shell", "description": "Run a shell command", "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}}},
{"type": "web_search_preview"},
{"type": "computer_use_preview", "display_width": 1024, "display_height": 768, "environment": "browser"},
{"type": "file_search", "vector_store_ids": ["vs_1"]},
{"type": "local_shell"},
{"type": "mcp", "server_label": "fs", "server_url": "http://localhost/mcp"},
]
_MESSAGES = [
{"role": "system", "content": "You are Odysseus. Use <tool>read_file</tool> protocol when needed."},
{"role": "user", "content": "List the repo"},
{"role": "assistant", "content": "<tool>read_file</tool>"},
{"role": "tool", "content": "README.md"},
]
_ALLOWED_KEYS = {"model", "instructions", "input", "stream", "store", "temperature"}
def test_responses_payload_has_no_native_tool_surfaces():
payload = llm_core._build_chatgpt_responses_payload("gpt-5.5", _MESSAGES, 0.7, 4096, stream=True)
assert set(payload) <= _ALLOWED_KEYS
assert payload["model"] == "gpt-5.5"
assert payload["stream"] is True
assert payload["store"] is False
assert "tools" not in payload
assert "tool_choice" not in payload
for key in llm_core.CHATGPT_FORBIDDEN_PAYLOAD_KEYS:
assert key not in payload
# Odysseus protocol text is preserved as plain instructions/input.
assert "<tool>read_file</tool>" in payload["instructions"]
roles = [item["role"] for item in payload["input"]]
assert roles == ["user", "assistant", "user"] # tool results become user input text
assert "system" not in roles
def test_upper_level_tools_argument_is_discarded_by_chatgpt_builder():
payload = llm_core._build_chatgpt_responses_payload(
"gpt-5.5", _MESSAGES, 0.7, 4096, stream=True, tools=_TOOLS, tool_choice="required", parallel_tool_calls=True,
)
assert set(payload) <= _ALLOWED_KEYS
serialized = json.dumps(payload)
assert "run_shell" not in serialized
assert "web_search" not in serialized
assert "computer_use" not in serialized
assert "file_search" not in serialized
assert "local_shell" not in serialized
assert '"mcp"' not in serialized
def test_strip_helper_removes_any_native_tool_key_added_later():
payload = {"model": "m", "input": [], "tools": _TOOLS, "tool_choice": "auto", "web_search": {}, "shell": {}, "computer": {}, "future_agent_surface": {}}
stripped = llm_core._strip_chatgpt_native_tool_surfaces(payload)
assert set(stripped) == {"model", "input"}
class _Resp:
def __init__(self, lines):
self._lines = lines
self.status_code = 200
async def aiter_lines(self):
for line in self._lines:
yield line
async def aread(self):
return b""
class _Ctx:
def __init__(self, lines):
self._lines = lines
async def __aenter__(self):
return _Resp(self._lines)
async def __aexit__(self, *args):
return False
class _CapturingClient:
def __init__(self, lines):
self._lines = lines
self.requests = []
def stream(self, method, url, **kwargs):
self.requests.append({"method": method, "url": url, "json": kwargs.get("json"), "headers": kwargs.get("headers")})
return _Ctx(self._lines)
def _stream_with_tools(monkeypatch, lines):
client = _CapturingClient(lines)
monkeypatch.setattr(llm_core, "_get_http_client", lambda: client)
monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *a, **k: None)
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a, **k: None)
async def run():
return [
chunk
async for chunk in llm_core._stream_llm_inner(
_URL + "/responses",
"gpt-5.5",
_MESSAGES,
headers={"Authorization": "Bearer test"},
tools=_TOOLS,
)
]
return client, asyncio.run(run())
def test_stream_transport_never_serializes_tools_even_when_passed(monkeypatch):
lines = [
"data: " + json.dumps({"type": "response.output_text.delta", "delta": "<tool>read_file</tool>"}),
"data: " + json.dumps({"type": "response.completed", "response": {"usage": {"input_tokens": 1, "output_tokens": 1}}}),
]
client, chunks = _stream_with_tools(monkeypatch, lines)
assert len(client.requests) == 1
sent = client.requests[0]
assert sent["url"] == _URL + "/responses"
assert set(sent["json"]) <= _ALLOWED_KEYS
assert "tools" not in sent["json"] and "tool_choice" not in sent["json"]
assert "run_shell" not in json.dumps(sent["json"])
# The Odysseus protocol tag is streamed back verbatim for Odysseus to parse.
deltas = [json.loads(c[6:])["delta"] for c in chunks if c.startswith("data: ") and '"delta"' in c]
assert deltas == ["<tool>read_file</tool>"]
def test_stream_handler_ignores_provider_side_tool_call_events(monkeypatch):
"""A provider-emitted function_call is never executed nor surfaced as a tool_call."""
lines = [
"data: " + json.dumps({"type": "response.output_item.added", "item": {"type": "function_call", "name": "run_shell", "call_id": "c1"}}),
"data: " + json.dumps({"type": "response.function_call_arguments.delta", "delta": '{"cmd": "rm -rf /"}'}),
"data: " + json.dumps({"type": "response.function_call_arguments.done", "arguments": '{"cmd": "rm -rf /"}'}),
"data: " + json.dumps({"type": "response.output_text.delta", "delta": "done"}),
"data: " + json.dumps({"type": "response.completed", "response": {"usage": {"input_tokens": 1, "output_tokens": 1}}}),
]
client, chunks = _stream_with_tools(monkeypatch, lines)
joined = "".join(chunks)
assert "tool_calls" not in joined
assert "rm -rf" not in joined
assert "run_shell" not in joined
deltas = [json.loads(c[6:])["delta"] for c in chunks if c.startswith("data: ") and '"delta"' in c]
assert deltas == ["done"]
assert chunks[-1] == "data: [DONE]\n\n"
def test_stream_error_cannot_echo_the_request_bearer(monkeypatch):
_client, chunks = _stream_with_tools(monkeypatch, [
'data: ' + json.dumps({"type": "error", "message": "rejected Bearer test", "status": 401}),
])
assert "Bearer test" not in "".join(chunks)
assert "[redacted]" in "".join(chunks)
def test_model_probe_never_posts_provider_tools(monkeypatch):
import routes.model_routes as models
monkeypatch.setattr(models.httpx, "post", lambda *args, **kwargs: pytest.fail("ChatGPT is discovery-only"))
assert models._probe_single_model(_URL, "secret", "gpt-5.5", with_tools=True)["skipped"] is True
def test_provisioned_endpoint_supports_tools_false(monkeypatch):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, ModelEndpoint
import routes.chatgpt_subscription_routes as csr
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
monkeypatch.setattr(csr, "SessionLocal", TestSessionLocal)
monkeypatch.setattr(chatgpt_subscription, "fetch_available_models", lambda token: ["gpt-5.5"])
a = csr._provision_endpoint({"access_token": "A", "refresh_token": "RA"}, "alice", label="codex00")
b = csr._provision_endpoint({"access_token": "B", "refresh_token": "RB"}, "alice", label="codex01")
# Reconnect must not flip the flag either.
csr._provision_endpoint({"access_token": "A2", "refresh_token": "RA2"}, "alice", reconnect_auth_id=a["provider_auth_id"])
db = TestSessionLocal()
try:
for ep_id in (a["id"], b["id"]):
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
assert ep.supports_tools is False
finally:
db.close()
def test_responses_input_never_carries_tool_call_structures():
items = chatgpt_subscription.build_responses_input([
{"role": "assistant", "content": None, "tool_calls": [{"id": "x", "function": {"name": "run_shell", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "x", "content": "output"},
])
for item in items:
assert set(item) == {"role", "content"}
assert "tool_calls" not in json.dumps(item)
+4 -1
View File
@@ -40,8 +40,9 @@ class _FakeSessionManager:
return self.sessions[session_id]
def create_session(self, session_id=None, name=None, endpoint_url=None,
model=None, rag=False, owner=None):
model=None, rag=False, owner=None, endpoint_id=None):
self.created = _FakeSession(name=name, owner=owner)
self.created.endpoint_id = endpoint_id
return self.created
def save_sessions(self):
@@ -59,6 +60,7 @@ def test_fork_does_not_corrupt_source_message_metadata(monkeypatch):
monkeypatch.setattr(mod, "_verify_session_owner", lambda *a, **k: None)
source = _FakeSession(name="Original", owner="alice")
source.endpoint_id = "chatgpt-account-b"
source.history = [
ChatMessage("user", "hi", {"_db_id": "src-0"}),
ChatMessage("assistant", "yo", {"_db_id": "src-1"}),
@@ -78,6 +80,7 @@ def test_fork_does_not_corrupt_source_message_metadata(monkeypatch):
assert result["status"] == "ok"
assert result["kept"] == 2
assert sm.created.endpoint_id == "chatgpt-account-b"
# The forked session got its own metadata dicts...
new_session = sm.created
+170
View File
@@ -163,3 +163,173 @@ def test_chatgpt_subscription_clears_previously_persisted_bearer(monkeypatch):
)
finally:
db.close()
# ── Multi-account: exact endpoint → exact auth session ──────────────────────
def _seed_two_accounts(TestSessionLocal, owner="alice"):
import datetime as _dt
db = TestSessionLocal()
try:
older = _dt.datetime(2026, 1, 1)
newer = _dt.datetime(2026, 6, 1)
db.add(ModelEndpoint(
id="ep-a", name="ChatGPT · codex00", base_url=_CODEX_BASE, provider_auth_id="auth-a",
owner=owner, is_enabled=True, api_key=None, created_at=older, updated_at=older,
))
db.add(ModelEndpoint(
id="ep-b", name="ChatGPT · codex01", base_url=_CODEX_BASE, provider_auth_id="auth-b",
owner=owner, is_enabled=True, api_key=None, created_at=newer, updated_at=newer,
))
db.commit()
finally:
db.close()
def _patch_runtime(monkeypatch, seen):
def fake(ep, owner=None):
seen.append((ep.id, ep.provider_auth_id, owner))
return (_CODEX_BASE, f"bearer-for-{ep.provider_auth_id}")
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", fake)
def test_bound_session_uses_exactly_its_own_account_even_when_urls_match(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
db.add(DbSession(id="sess-b", name="chat", endpoint_url=_CODEX_BASE + "/responses",
model="gpt-5.5", owner="alice", headers={}, endpoint_id="ep-b"))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-b", endpoint_url=_CODEX_BASE + "/responses", model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-b")
chat_helpers.resolve_session_auth(sess, "sess-b", owner="alice")
# Account B (the newer row) is used because the session is bound to ep-b,
# even though ep-a shares the same URL and would otherwise sort first.
assert seen == [("ep-b", "auth-b", "alice")]
assert sess.headers["Authorization"] == "Bearer bearer-for-auth-b"
db = TestSessionLocal()
try:
row = db.query(DbSession).filter(DbSession.id == "sess-b").first()
assert row.endpoint_id == "ep-b"
assert not any(k.lower() == "authorization" for k in (row.headers or {}))
finally:
db.close()
def test_legacy_unbound_session_picks_oldest_account_and_gets_bound(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
db.add(DbSession(id="sess-legacy", name="chat", endpoint_url=_CODEX_BASE,
model="gpt-5.5", owner="alice", headers={}))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-legacy", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id=None)
chat_helpers.resolve_session_auth(sess, "sess-legacy", owner="alice")
# Deterministic: the oldest endpoint is the one that existed when the
# legacy session was created; the choice is persisted so it never drifts.
assert seen == [("ep-a", "auth-a", "alice")]
assert sess.endpoint_id == "ep-a"
db = TestSessionLocal()
try:
row = db.query(DbSession).filter(DbSession.id == "sess-legacy").first()
assert row.endpoint_id == "ep-a"
finally:
db.close()
# A second resolve honours the persisted binding (no re-derivation drift).
seen.clear()
sess2 = types.SimpleNamespace(id="sess-legacy", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-a")
chat_helpers.resolve_session_auth(sess2, "sess-legacy", owner="alice")
assert seen == [("ep-a", "auth-a", "alice")]
def test_bound_session_never_borrows_a_sibling_account_when_its_endpoint_is_gone(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
# Two more siblings share the URL, but the bound endpoint is disabled.
ep_b = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-b").first()
ep_b.is_enabled = False
db.add(ModelEndpoint(id="ep-c", name="ChatGPT · codex02", base_url=_CODEX_BASE, provider_auth_id="auth-c",
owner="alice", is_enabled=True, api_key=None))
db.add(DbSession(id="sess-b", name="chat", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-b"))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-b", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-b")
chat_helpers.resolve_session_auth(sess, "sess-b", owner="alice")
# Ambiguous → no silent account switch, no bearer resolved.
assert seen == []
assert not any(k.lower() == "authorization" for k in sess.headers)
def test_other_owner_cannot_resolve_through_alices_accounts(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal, owner="alice")
db = TestSessionLocal()
try:
db.add(DbSession(id="sess-m", name="chat", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="mallory", headers={}, endpoint_id="ep-a"))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-m", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="mallory", headers={}, endpoint_id="ep-a")
chat_helpers.resolve_session_auth(sess, "sess-m", owner="mallory")
assert seen == []
assert sess.headers == {}
def test_route_descriptors_distinguish_same_model_on_two_accounts(monkeypatch):
"""Same base URL + same model on A and B are two routes, not duplicates."""
TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(endpoint_resolver, "SessionLocal", TestSessionLocal)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
import json as _json
for ep in db.query(ModelEndpoint).all():
ep.cached_models = _json.dumps(["gpt-5.5"])
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
route_a = endpoint_resolver.resolve_endpoint_by_id("ep-a", "gpt-5.5", owner="alice", require_exact_model=True)
route_b = endpoint_resolver.resolve_endpoint_by_id("ep-b", "gpt-5.5", owner="alice", require_exact_model=True)
assert route_a is not None and route_b is not None
assert route_a[0] == route_b[0] and route_a[1] == route_b[1] # same URL + model
assert route_a[2]["Authorization"] != route_b[2]["Authorization"] # different accounts
assert route_a != route_b
desc_a = endpoint_resolver.resolve_route_descriptor_by_id("ep-a", route_a[0], "gpt-5.5", route_a[2], owner="alice")
desc_b = endpoint_resolver.resolve_route_descriptor_by_id("ep-b", route_b[0], "gpt-5.5", route_b[2], owner="alice")
assert desc_a["endpoint_id"] == "ep-a" and desc_a["endpoint_label"] == "ChatGPT · codex00"
assert desc_b["endpoint_id"] == "ep-b" and desc_b["endpoint_label"] == "ChatGPT · codex01"
# Provenance for B's route must not be attributed to A.
assert endpoint_resolver.resolve_route_descriptor_by_id("ep-a", route_b[0], "gpt-5.5", route_b[2], owner="alice") is None
for desc in (desc_a, desc_b):
assert "Authorization" not in str(desc) and "bearer-for" not in str(desc)