mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-05 12:42:20 +01:00
feat(provider): support multiple ChatGPT subscriptions with usage
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user