feat(provider): support multiple ChatGPT subscriptions with usage

This commit is contained in:
Alexandre Teixeira
2026-09-22 13:12:19 +01:00
parent 45330097b8
commit ed7ccfd584
31 changed files with 3281 additions and 215 deletions
+63 -8
View File
@@ -1701,9 +1701,21 @@ def _clear_orphaned_session_endpoint(sess, owner: str | None = None) -> bool:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
bound_id = getattr(sess, "endpoint_id", None)
for ep in endpoints:
if bound_id and ep.id != bound_id:
continue
if _session_url_matches_endpoint(sess.endpoint_url or "", ep.base_url or ""):
return False
if bound_id:
# Keep the identity so re-enabling/reconnecting A can recover A.
# Returning True stops chat; B must never replace a missing A.
sess.headers = {}
stored = db.query(DBSession).filter(DBSession.id == sess.id, DBSession.owner == owner).first()
if stored is not None:
stored.headers = {}
db.commit()
return True
db_session = db.query(DBSession).filter(DBSession.id == sess.id).first()
if db_session:
db_session.endpoint_url = ""
@@ -1803,6 +1815,13 @@ def _first_image_attachment(chat_handler, att_ids: List[str], owner: str | None
return None
def _ts_or_zero(value) -> float:
try:
return float(value.timestamp()) if value else 0.0
except Exception:
return 0.0
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
"""Re-populate sess.model from the matching endpoint's cached models.
@@ -1835,10 +1854,19 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
for cand in endpoints:
if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
ep = cand
break
# Honour the session's exact endpoint binding first: two endpoints
# can share a provider URL (e.g. two ChatGPT Subscription accounts).
bound_id = getattr(sess, "endpoint_id", None) or None
if bound_id:
for cand in endpoints:
if cand.id == bound_id and _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
ep = cand
break
if ep is None and not bound_id:
for cand in sorted(endpoints, key=lambda row: (_ts_or_zero(getattr(row, "created_at", None)), str(row.id))):
if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
ep = cand
break
if not ep:
return False
if not is_chatgpt_subscription:
@@ -1938,6 +1966,7 @@ def _reconcile_selected_route_from_request(
endpoint_url = ""
headers = None
resolved_endpoint_id = None
if selected_endpoint_id or selected_endpoint_url:
try:
from src.auth_helpers import owner_filter
@@ -1949,7 +1978,15 @@ def _reconcile_selected_route_from_request(
q = q.filter(ModelEndpoint.id == selected_endpoint_id)
if owner:
q = owner_filter(q, ModelEndpoint, owner)
candidates = q.all() if selected_endpoint_url and not selected_endpoint_id else [q.first()]
if selected_endpoint_url and not selected_endpoint_id:
candidates = [row for row in q.all() if _session_url_matches_endpoint(selected_endpoint_url, row.base_url or "")]
bound_id = getattr(sess, "endpoint_id", None)
if bound_id:
candidates = [row for row in candidates if row.id == bound_id]
if len(candidates) != 1:
return False
else:
candidates = [q.first()]
ep = None
for cand in candidates:
if not cand:
@@ -1961,6 +1998,7 @@ def _reconcile_selected_route_from_request(
return False
endpoint_url = build_chat_url(normalize_base(ep.base_url or ""))
headers = build_headers(ep.api_key or "", ep.base_url or "") if ep.api_key else {}
resolved_endpoint_id = ep.id
finally:
db.close()
except Exception as e:
@@ -1975,18 +2013,29 @@ def _reconcile_selected_route_from_request(
and endpoint_url == (getattr(sess, "endpoint_url", "") or "")
)
headers_changed = dict(getattr(sess, "headers", None) or {}) != dict(headers or {})
if not route_changed and not headers_changed:
binding_changed = bool(
resolved_endpoint_id
and resolved_endpoint_id != (getattr(sess, "endpoint_id", None) or None)
)
if not route_changed and not headers_changed and not binding_changed:
return False
sess.model = selected_model
sess.endpoint_url = endpoint_url
sess.headers = headers or {}
if resolved_endpoint_id:
sess.endpoint_id = resolved_endpoint_id
elif route_changed:
# The route moved without an explicit endpoint id: drop a stale binding
# rather than keep pointing at an endpoint the session no longer uses.
sess.endpoint_id = None
db = SessionLocal()
try:
db_session = db.query(DBSession).filter(DBSession.id == session_id).first()
if db_session:
db_session.model = selected_model
db_session.endpoint_url = endpoint_url
db_session.endpoint_id = getattr(sess, "endpoint_id", None) or None
db_session.headers = sess.headers or {}
db_session.updated_at = datetime.utcnow()
db.commit()
@@ -2087,6 +2136,10 @@ def setup_chat_routes(
if not supports_user_thinking_toggle(sess.model):
thinking_mode = "off"
owner = effective_user(request)
_reconcile_selected_route_from_request(request, sess, session, {
"selected_model": sess.model,
"selected_endpoint_id": chat_request.selected_endpoint_id,
}, owner=owner)
if _clear_orphaned_session_endpoint(sess, owner=owner):
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
@@ -2102,6 +2155,8 @@ def setup_chat_routes(
if not (getattr(sess, "endpoint_url", "") or "").strip():
raise HTTPException(400, "Selected model endpoint is not configured")
resolve_session_auth(sess, session, owner=owner)
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
# non-streaming path can't be used to bypass).
_enforce_chat_privileges(request, sess)
@@ -2169,7 +2224,7 @@ def setup_chat_routes(
sess.headers,
owner=owner,
policy=foreground_policy,
selected_endpoint_id=chat_request.selected_endpoint_id,
selected_endpoint_id=chat_request.selected_endpoint_id or getattr(sess, "endpoint_id", None),
)
candidate_request_factory = None
selected_context_length = getattr(ctx, "context_length", 0)
@@ -3704,7 +3759,7 @@ def setup_chat_routes(
sess.headers,
owner=_user,
policy=_foreground_policy,
selected_endpoint_id=selected_endpoint_id,
selected_endpoint_id=selected_endpoint_id or getattr(sess, "endpoint_id", None),
)
_chat_request_factory = None
_selected_context_length = getattr(ctx, "context_length", 0)