mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 08:22:19 +02:00
feat(provider): support multiple ChatGPT subscriptions with usage
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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 "<5H>" 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 <limit>" 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user