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
+84 -34
View File
@@ -794,6 +794,52 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
return False
def _endpoint_created_sort_key(ep) -> tuple:
created = getattr(ep, "created_at", None)
try:
ts = float(created.timestamp()) if created else 0.0
except Exception:
ts = 0.0
return (ts, str(getattr(ep, "id", "") or ""))
def _select_session_endpoint(sess, target_url: str, endpoints) -> tuple:
"""Pick the endpoint a session should use for credential resolution.
Two endpoints may share one provider URL but not credentials (e.g. two
ChatGPT Subscription accounts), so an explicit ``sess.endpoint_id`` binding
wins whenever it still matches the session URL. Without a binding the
oldest URL-matching endpoint is chosen deterministically and persisted.
Returns ``(endpoint, bound_by_fallback)``; ``bound_by_fallback`` is True
when the choice came from URL matching and may be persisted as a binding.
"""
matching = [ep for ep in endpoints if _session_url_matches_endpoint(target_url, getattr(ep, "base_url", "") or "")]
if not matching:
return None, False
bound_id = getattr(sess, "endpoint_id", None) or None
if bound_id:
for ep in matching:
if str(ep.id) == str(bound_id):
sess.endpoint_id = ep.id
return ep, False
# The bound endpoint is gone or disabled. Never silently borrow another
# endpoint's credentials when several routes share this URL.
return None, False
matching.sort(key=_endpoint_created_sort_key)
chosen = matching[0]
if len(matching) > 1:
logger.warning(
"Session %s has no endpoint binding and %d endpoints share its URL; using oldest endpoint %s",
getattr(sess, "id", "?"), len(matching), chosen.id,
)
try:
sess.endpoint_id = chosen.id
except Exception:
pass
return chosen, True
def _has_auth_keys(headers) -> bool:
"""True if a headers dict carries an Authorization/x-api-key entry."""
return isinstance(headers, dict) and any(
@@ -803,6 +849,7 @@ def _has_auth_keys(headers) -> bool:
def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
owner = owner or getattr(sess, "owner", None)
try:
from src.chatgpt_subscription import is_chatgpt_subscription_base
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "")
@@ -811,11 +858,22 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
has_auth = _has_auth_keys(sess.headers)
if has_auth and not is_chatgpt_subscription:
return
if is_chatgpt_subscription:
# Never reuse a stale bearer after deletion, disablement or failed refresh.
sess.headers = {}
try:
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
db = SessionLocal()
try:
stored_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
stored_q = stored_q.filter(DBSession.owner == owner)
if is_chatgpt_subscription:
stored = stored_q.first()
if stored is not None and _has_auth_keys(stored.headers):
stored_q.update({"headers": {}})
db.commit()
target_url = getattr(sess, "endpoint_url", "") or ""
if not target_url:
return
@@ -826,44 +884,36 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
# with similar endpoint URLs can borrow each other's API key.
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
for ep in q.all():
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
continue
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception as e:
logger.warning("Failed to resolve provider auth for session %s: %s", session_id, e)
return
if not api_key:
# No usable key (e.g. ChatGPT Subscription needs re-auth).
return
sess.headers = build_headers(api_key, base)
if is_chatgpt_subscription:
# The bearer is short-lived and re-resolved per request, so it
# stays request-local and is never written to the plaintext
# sessions.headers column. Proactively strip any bearer an
# older code path may have persisted so it does not linger.
stale_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
stale_q = stale_q.filter(DBSession.owner == owner)
stored = stale_q.first()
if stored is not None and _has_auth_keys(stored.headers):
stale_q.update({"headers": {}})
db.commit()
logger.info(f"Cleared persisted ChatGPT Subscription bearer from session {session_id}")
logger.debug(f"Resolved request-local ChatGPT Subscription auth for session {session_id}")
return
update_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
update_q = update_q.filter(DBSession.owner == owner)
update_q.update({"headers": sess.headers})
db.commit()
logger.info(f"Resolved and persisted auth headers for session {session_id} from endpoint {ep.name}")
ep, bound_here = _select_session_endpoint(sess, target_url, q.all())
if ep is None:
return
if bound_here:
# Bind before authentication, including failed/expired credentials.
stored_q.filter(DBSession.endpoint_id == None).update({"endpoint_id": ep.id})
db.commit()
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception as e:
logger.warning("Failed to resolve provider auth for session %s: %s", session_id, type(e).__name__)
return
if not api_key:
# No usable key (e.g. ChatGPT Subscription needs re-auth).
return
sess.headers = build_headers(api_key, base)
if is_chatgpt_subscription:
# Request-local only; persistence was cleaned before resolution.
return
update_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
update_q = update_q.filter(DBSession.owner == owner)
update_q.update({"headers": sess.headers})
db.commit()
logger.info(f"Resolved and persisted auth headers for session {session_id} from endpoint {ep.name}")
return
finally:
db.close()
except Exception as e:
logger.warning(f"Failed to resolve session headers: {e}")
logger.warning("Failed to resolve session headers: %s", type(e).__name__)
def _match_cached_model_id(requested: str, models) -> Optional[str]:
+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)
+334 -47
View File
@@ -1,28 +1,123 @@
"""ChatGPT Subscription device-flow setup routes."""
"""ChatGPT Subscription device-flow setup, multi-account and usage routes.
One Odysseus owner may connect several independent ChatGPT subscriptions. Each
connection is its own ``ProviderAuthSession`` + ``ModelEndpoint`` pair; the
endpoint id decides which account a request is billed to. Labels are cosmetic
only — stable ids drive lookup, reconnect, deletion and usage reads.
"""
import json
import logging
import uuid
from typing import Dict, Optional
from typing import Any, Dict, Optional
from fastapi import HTTPException, Request
from fastapi import HTTPException, Request, Response
from core.database import ModelEndpoint, ProviderAuthSession, SessionLocal, utcnow_naive
from core.middleware import require_admin
from routes.device_flow import (
DeviceFlowPoll,
DeviceFlowStart,
PendingDeviceFlowStore,
create_device_flow_router,
)
from src.auth_helpers import get_current_user
from src.auth_helpers import effective_user, get_current_user
from src import chatgpt_subscription
logger = logging.getLogger(__name__)
_DEVICE_FLOW_STORE = PendingDeviceFlowStore()
_PROVIDER = chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER
def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
def _owner_scope(query, model_cls, owner: Optional[str]):
return query.filter(model_cls.owner == owner)
def _owner_chatgpt_auths(db, owner: Optional[str]):
q = db.query(ProviderAuthSession).filter(ProviderAuthSession.provider == _PROVIDER)
return _owner_scope(q, ProviderAuthSession, owner).order_by(ProviderAuthSession.created_at).all()
def _endpoints_for_auth(db, auth_id: str):
return db.query(ModelEndpoint).filter(ModelEndpoint.provider_auth_id == auth_id).all()
def _display_label(auth, ep) -> str:
"""Display label from persisted metadata (never from credentials)."""
label = chatgpt_subscription.account_label_from_name(getattr(auth, "label", None))
if not label and ep is not None:
label = chatgpt_subscription.account_label_from_name(getattr(ep, "name", None))
return label
def _assert_label_available(db, owner: Optional[str], label: str, *, exclude_auth_id: Optional[str] = None) -> None:
"""Reject a label already used by another ChatGPT account of this owner."""
if not label:
return
for auth in _owner_chatgpt_auths(db, owner):
if exclude_auth_id and auth.id == exclude_auth_id:
continue
existing = _display_label(auth, None)
if not existing:
for ep in _endpoints_for_auth(db, auth.id):
existing = _display_label(auth, ep)
if existing:
break
if chatgpt_subscription.labels_conflict(existing, label):
raise ValueError(f"A ChatGPT subscription labelled '{label}' is already connected.")
def _default_new_label(db, owner: Optional[str]) -> str:
"""Label for a new connection when the user did not supply one.
The first account keeps the legacy unlabelled name so existing single
account setups look unchanged; later accounts get a distinguishable
``account N`` label that is unique for this owner.
"""
existing = _owner_chatgpt_auths(db, owner)
if not existing:
return ""
taken = set()
for auth in existing:
label = _display_label(auth, None)
if not label:
for ep in _endpoints_for_auth(db, auth.id):
label = _display_label(auth, ep)
if label:
break
if label:
taken.add(label.casefold())
n = len(existing) + 1
while f"account {n}".casefold() in taken:
n += 1
return f"account {n}"
def _new_id(db, model_cls) -> str:
for _ in range(8):
candidate = str(uuid.uuid4())[:8]
if db.query(model_cls).filter(model_cls.id == candidate).first() is None:
return candidate
return uuid.uuid4().hex[:12]
def _provision_endpoint(
tokens: Dict,
owner: Optional[str],
*,
label: str = "",
reconnect_auth_id: Optional[str] = None,
reconnect_endpoint_id: Optional[str] = None,
) -> Dict:
"""Create a new ChatGPT account (auth + endpoint) or refresh exactly one.
Without ``reconnect_auth_id`` a brand-new ``ProviderAuthSession`` and
``ModelEndpoint`` are created even when the owner already has other ChatGPT
subscriptions. With it, only that owner-scoped auth row (and its endpoint)
is updated; every other account is left untouched.
"""
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
if not access_token or not refresh_token:
@@ -32,22 +127,28 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
models = chatgpt_subscription.fetch_available_models(access_token)
if not models:
raise ValueError("ChatGPT Subscription connected, but no usable Codex models were discovered for this account.")
label = chatgpt_subscription.normalize_account_label(label)
db = SessionLocal()
try:
auth = (
db.query(ProviderAuthSession)
.filter(
ProviderAuthSession.provider == chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
ProviderAuthSession.owner == owner,
)
.first()
)
if auth is None:
auth = None
if reconnect_auth_id:
auth = chatgpt_subscription.find_owned_auth_session(db, reconnect_auth_id, owner)
if auth is None:
raise chatgpt_subscription.ChatGPTSubscriptionAuthNotFound(
"The ChatGPT subscription being reconnected no longer exists for this user."
)
# A reconnect keeps the existing label unless a new one was given.
if label:
_assert_label_available(db, owner, label, exclude_auth_id=auth.id)
else:
if not label:
label = _default_new_label(db, owner)
_assert_label_available(db, owner, label)
auth = ProviderAuthSession(
id=str(uuid.uuid4())[:8],
provider=chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
id=_new_id(db, ProviderAuthSession),
provider=_PROVIDER,
owner=owner,
label="ChatGPT Subscription",
label=chatgpt_subscription.endpoint_name_for_label(label),
base_url=base,
auth_mode="chatgpt",
)
@@ -57,31 +158,39 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
auth.refresh_token = refresh_token
auth.last_refresh = utcnow_naive()
auth.auth_mode = "chatgpt"
if label:
auth.label = chatgpt_subscription.endpoint_name_for_label(label)
ep = (
db.query(ModelEndpoint)
.filter(
ModelEndpoint.base_url == base,
ModelEndpoint.provider_auth_id == auth.id,
ModelEndpoint.owner == owner,
)
.first()
)
ep = None
if reconnect_auth_id:
ep_q = db.query(ModelEndpoint).filter(ModelEndpoint.provider_auth_id == auth.id)
ep_q = _owner_scope(ep_q, ModelEndpoint, owner)
if reconnect_endpoint_id:
ep = ep_q.filter(ModelEndpoint.id == reconnect_endpoint_id).first()
if ep is None:
raise chatgpt_subscription.ChatGPTSubscriptionAuthNotFound(
"The ChatGPT subscription endpoint no longer exists for this user."
)
else:
ep = ep_q.order_by(ModelEndpoint.created_at).first()
if ep is None:
ep = ModelEndpoint(
id=str(uuid.uuid4())[:8],
name="ChatGPT Subscription",
id=_new_id(db, ModelEndpoint),
name=chatgpt_subscription.endpoint_name_for_label(label),
base_url=base,
model_type="llm",
endpoint_kind="api",
owner=owner,
)
db.add(ep)
ep.name = "ChatGPT Subscription"
if label or not (ep.name or "").strip():
ep.name = chatgpt_subscription.endpoint_name_for_label(label)
ep.base_url = base
ep.api_key = None
ep.provider_auth_id = auth.id
ep.is_enabled = True
# ChatGPT provides inference only. Odysseus is the only agent: no
# provider-native tool schemas are ever sent on this route.
ep.supports_tools = False
ep.model_type = "llm"
ep.endpoint_kind = "api"
@@ -93,10 +202,14 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
"name": ep.name,
"base_url": ep.base_url,
"models": models,
"provider_auth_id": auth.id,
"account_label": _display_label(auth, ep),
"reconnected": bool(reconnect_auth_id),
}
finally:
db.close()
chatgpt_subscription.USAGE_CACHE.invalidate(result["provider_auth_id"])
try:
from routes.model_routes import _invalidate_models_cache
@@ -106,7 +219,53 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
return result
def _start_device_flow(request: Request, _form) -> DeviceFlowStart:
def _form_value(form, key: str) -> str:
try:
value = form.get(key) if form is not None else None
except Exception:
value = None
return str(value).strip() if value is not None else ""
def _start_device_flow(request: Request, form) -> DeviceFlowStart:
owner = effective_user(request) or None
try:
label = chatgpt_subscription.normalize_account_label(_form_value(form, "label"))
except ValueError as exc:
raise HTTPException(400, str(exc))
reconnect_auth_id = _form_value(form, "reconnect_auth_id") or None
reconnect_endpoint_id = _form_value(form, "reconnect_endpoint_id") or None
if reconnect_endpoint_id and not reconnect_auth_id:
raise HTTPException(400, "Reconnect requires an account id")
# Validate the intended operation up front so the user is not sent through
# OAuth for a request that can never be provisioned.
db = SessionLocal()
try:
if reconnect_auth_id:
auth = chatgpt_subscription.find_owned_auth_session(db, reconnect_auth_id, owner)
if auth is None:
raise HTTPException(404, "ChatGPT subscription account not found")
if reconnect_endpoint_id:
ep_q = db.query(ModelEndpoint).filter(
ModelEndpoint.id == reconnect_endpoint_id,
ModelEndpoint.provider_auth_id == auth.id,
)
if _owner_scope(ep_q, ModelEndpoint, owner).first() is None:
raise HTTPException(404, "ChatGPT subscription endpoint not found")
if label:
try:
_assert_label_available(db, owner, label, exclude_auth_id=auth.id)
except ValueError as exc:
raise HTTPException(409, str(exc))
else:
try:
_assert_label_available(db, owner, label)
except ValueError as exc:
raise HTTPException(409, str(exc))
finally:
db.close()
try:
data = chatgpt_subscription.request_device_code()
except Exception as exc:
@@ -116,37 +275,82 @@ def _start_device_flow(request: Request, _form) -> DeviceFlowStart:
user_code = data.get("user_code")
if not device_auth_id or not user_code:
raise HTTPException(502, "ChatGPT did not return a complete device code")
verification_uri = data.get("verification_uri") or f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device"
# Never pass an arbitrary upstream URL into an authorization link.
from urllib.parse import urlsplit
fallback_uri = f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device"
verification_uri = data.get("verification_uri") or fallback_uri
try:
parsed = urlsplit(verification_uri)
if parsed.scheme != "https" or parsed.netloc != "auth.openai.com":
verification_uri = fallback_uri
except (TypeError, ValueError):
verification_uri = fallback_uri
# The pending payload carries only what provisioning needs: the device
# handle, the owner and the intended operation. No access/refresh tokens.
pending: Dict[str, Any] = {
"device_auth_id": device_auth_id,
"user_code": user_code,
"owner": owner,
"label": label,
"reconnect_auth_id": reconnect_auth_id,
"reconnect_endpoint_id": reconnect_endpoint_id,
}
response: Dict[str, Any] = {
"user_code": user_code,
"verification_uri": verification_uri,
"mode": "reconnect" if reconnect_auth_id else "connect",
}
if label:
response["account_label"] = label
return DeviceFlowStart(
pending={
"device_auth_id": device_auth_id,
"user_code": user_code,
"owner": get_current_user(request) or None,
},
response={
"user_code": user_code,
"verification_uri": verification_uri,
},
pending=pending,
response=response,
interval=int(data.get("interval") or 5),
expires_in=int(data.get("expires_in") or 900),
)
def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
def _poll_device_flow(request: Request, pending: Dict) -> DeviceFlowPoll:
# The poller must be the same user who started the flow: a poll id is not
# a bearer for provisioning into someone else's account list.
current_owner = effective_user(request) or None
if (pending.get("owner") or None) != current_owner:
raise HTTPException(403, "This sign-in belongs to another user")
if pending.get("reconnect_auth_id"):
db = SessionLocal()
try:
auth = chatgpt_subscription.find_owned_auth_session(db, pending["reconnect_auth_id"], current_owner)
if auth is None:
raise HTTPException(404, "ChatGPT subscription account not found")
if pending.get("reconnect_endpoint_id"):
ep = _owner_scope(db.query(ModelEndpoint).filter(
ModelEndpoint.id == pending["reconnect_endpoint_id"],
ModelEndpoint.provider_auth_id == auth.id,
), ModelEndpoint, current_owner).first()
if ep is None:
raise HTTPException(404, "ChatGPT subscription endpoint not found")
finally:
db.close()
try:
data = chatgpt_subscription.poll_device_auth(pending["device_auth_id"], pending["user_code"])
except Exception as exc:
logger.debug("ChatGPT device poll failed: %s", exc)
return DeviceFlowPoll.pending(str(exc))
logger.debug("ChatGPT device poll failed: %s", type(exc).__name__)
return DeviceFlowPoll.pending("Sign-in status temporarily unavailable")
authorization_code = data.get("authorization_code")
code_verifier = data.get("code_verifier")
if authorization_code and code_verifier:
try:
tokens = chatgpt_subscription.exchange_authorization_code(authorization_code, code_verifier)
result = _provision_endpoint(tokens, pending["owner"])
result = _provision_endpoint(
tokens,
pending.get("owner"),
label=pending.get("label") or "",
reconnect_auth_id=pending.get("reconnect_auth_id") or None,
reconnect_endpoint_id=pending.get("reconnect_endpoint_id") or None,
)
except Exception as exc:
logger.exception("ChatGPT Subscription endpoint provisioning failed")
logger.warning("ChatGPT Subscription endpoint provisioning failed: %s", type(exc).__name__)
raise chatgpt_subscription.to_http_exception(exc)
return DeviceFlowPoll.authorized(result)
@@ -157,14 +361,97 @@ def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None)
if err in ("expired_token", "access_denied", "denied"):
return DeviceFlowPoll.failed(err)
return DeviceFlowPoll.pending(err or "unknown")
return DeviceFlowPoll.pending("unknown")
# ── Account listing / usage ─────────────────────────────────────────────────
def _account_summary(auth, endpoints) -> Dict[str, Any]:
ep = endpoints[0] if endpoints else None
return {
"auth_id": auth.id,
"label": _display_label(auth, ep),
"name": (ep.name if ep is not None else None) or auth.label or chatgpt_subscription.CHATGPT_SUBSCRIPTION_LEGACY_NAME,
"endpoint_ids": [row.id for row in endpoints],
"connected_at": auth.created_at.isoformat() if getattr(auth, "created_at", None) else None,
"last_refresh": auth.last_refresh.isoformat() if getattr(auth, "last_refresh", None) else None,
"connected": bool(auth.refresh_token),
}
def usage_error_payload(exc: chatgpt_subscription.ChatGPTUsageUnavailable) -> Dict[str, Any]:
"""Safe, credential-free payload for a failed usage read."""
return {
"available": False,
"reason": exc.reason,
"message": str(exc),
"status_code": exc.status_code,
"reconnect_suggested": exc.reason == "reauth",
}
def _load_owned_account(request: Request, auth_id: str):
"""Admin gate + owner scope for account-level operations."""
require_admin(request)
owner = effective_user(request) or get_current_user(request) or None
db = SessionLocal()
try:
auth = chatgpt_subscription.find_owned_auth_session(db, auth_id, owner)
if auth is None:
raise HTTPException(404, "ChatGPT subscription account not found")
endpoints = _endpoints_for_auth(db, auth.id)
return owner, _account_summary(auth, endpoints)
finally:
db.close()
def setup_chatgpt_subscription_routes():
return create_device_flow_router(
router = create_device_flow_router(
prefix="/api/chatgpt-subscription",
tags=["chatgpt-subscription"],
store=_DEVICE_FLOW_STORE,
start_flow=_start_device_flow,
poll_flow=_poll_device_flow,
)
@router.get("/accounts")
def list_accounts(request: Request):
require_admin(request)
owner = effective_user(request) or get_current_user(request) or None
db = SessionLocal()
try:
return [
_account_summary(auth, _endpoints_for_auth(db, auth.id))
for auth in _owner_chatgpt_auths(db, owner)
]
finally:
db.close()
@router.get("/accounts/{auth_id}/usage")
def account_usage(auth_id: str, request: Request, refresh: bool = False, response: Response = None):
"""Read-only, owner-scoped usage for exactly one ChatGPT account.
Failures here are telemetry failures only: the model endpoint is never
disabled, credentials are never destroyed and no reconnect is started.
"""
if response is not None:
response.headers["Cache-Control"] = "no-store"
owner, account = _load_owned_account(request, auth_id)
try:
usage = chatgpt_subscription.get_account_usage(account["auth_id"], owner=owner, force_refresh=refresh)
except chatgpt_subscription.ChatGPTSubscriptionAuthNotFound:
raise HTTPException(404, "ChatGPT subscription account not found")
except chatgpt_subscription.ChatGPTUsageUnavailable as exc:
payload = usage_error_payload(exc)
payload["account"] = account
return payload
except Exception as exc: # pragma: no cover - defensive
logger.warning("ChatGPT usage read failed for auth %s: %s", auth_id, type(exc).__name__)
payload = usage_error_payload(
chatgpt_subscription.ChatGPTUsageUnavailable("upstream", "ChatGPT usage is unavailable.")
)
payload["account"] = account
return payload
return {"available": True, "account": account, "usage": usage}
return router
+4 -2
View File
@@ -315,6 +315,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
"history": history_dict,
"model": db_session.model,
"endpoint_url": db_session.endpoint_url,
"endpoint_id": getattr(db_session, "endpoint_id", None),
"name": db_session.name,
"offset": page_offset,
"limit": page_limit,
@@ -378,6 +379,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
"history": history_dict,
"model": session.model,
"endpoint_url": session.endpoint_url,
"endpoint_id": getattr(session, "endpoint_id", None),
"name": session.name,
}
@@ -802,6 +804,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
model=source.model,
rag=False,
owner=getattr(source, 'owner', None),
endpoint_id=getattr(source, 'endpoint_id', None),
)
# Copy messages up to keep_count
@@ -820,8 +823,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
except Exception:
logger.debug("session_created event dispatch failed", exc_info=True)
from src.model_profiles import supports_user_thinking_toggle
thinking_supported = supports_user_thinking_toggle(session.model)
return {
"status": "ok",
"id": new_id,
@@ -882,6 +883,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
"session_id": session_id,
"model": session.model,
"endpoint_url": session.endpoint_url,
"endpoint_id": getattr(session, "endpoint_id", None),
"used_tokens": used,
"context_length": ctx_len,
"context_percent": pct,
+77 -20
View File
@@ -695,6 +695,11 @@ def _delete_orphaned_provider_auth(db, auth_id: Optional[str], exclude_ep_id: Op
if auth_row is None:
return False
db.delete(auth_row)
try:
from src.chatgpt_subscription import USAGE_CACHE
USAGE_CACHE.invalidate(auth_id)
except Exception:
pass
return True
@@ -738,7 +743,7 @@ def _resolve_probe_key(ep) -> Optional[str]:
_base, key = resolve_endpoint_runtime(ep, owner=getattr(ep, "owner", None))
return key
except Exception as exc:
logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), exc)
logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), type(exc).__name__)
return None
@@ -1410,6 +1415,35 @@ def _picker_models_for_endpoint(ep, base_url: str, kind: str):
), pinned
def _chatgpt_endpoint_visible(ep: Any, request: Request) -> bool:
from src.chatgpt_subscription import is_chatgpt_subscription_base
if not is_chatgpt_subscription_base(getattr(ep, "base_url", "") or ""):
return True
try:
user = effective_user(request)
except AttributeError:
user = getattr(getattr(request, "state", None), "current_user", None)
return (getattr(ep, "owner", None) or None) == (user or None)
def _provider_account_metadata(ep: Any) -> Dict[str, Any]:
"""Non-secret account metadata for session-backed provider endpoints."""
auth_id = getattr(ep, "provider_auth_id", None)
if not auth_id:
return {"provider_auth_id": None, "provider": None, "account_label": None}
base = getattr(ep, "base_url", "") or ""
provider = None
account_label = None
try:
from src.chatgpt_subscription import account_label_from_name, is_chatgpt_subscription_base
if is_chatgpt_subscription_base(base):
provider = "chatgpt-subscription"
account_label = account_label_from_name(getattr(ep, "name", None)) or None
except Exception:
provider = None
return {"provider_auth_id": auth_id, "provider": provider, "account_label": account_label}
def _api_key_fingerprint(api_key: Optional[str]) -> str:
"""Stable, non-secret label for distinguishing same-URL credentials."""
key = (api_key or "").strip()
@@ -1444,8 +1478,10 @@ def setup_model_routes(model_discovery):
_REFRESH_FAILURE_BASE = 300.0
_REFRESH_FAILURE_MAX = 3600.0
def _refresh_key(base: str, api_key: Optional[str]) -> str:
return f"{base.rstrip('/')}\x00{api_key or ''}"
def _refresh_key(base: str, api_key: Optional[str], provider_auth_id: Optional[str] = None) -> str:
# Session-backed endpoints carry no static key; include their auth id
# so two accounts on one provider URL never share refresh state.
return f"{base.rstrip('/')}\x00{api_key or ''}\x00{provider_auth_id or ''}"
def _ts(value: Any) -> float:
try:
@@ -1466,7 +1502,7 @@ def setup_model_routes(model_discovery):
category = _classify_endpoint(base, kind)
mode = _endpoint_refresh_mode(ep, kind)
cached = _cached_model_ids(ep)
key = _refresh_key(base, getattr(ep, "api_key", None))
key = _refresh_key(base, getattr(ep, "api_key", None), getattr(ep, "provider_auth_id", None))
state = _refresh_state.get(key, {})
info = {
@@ -1528,9 +1564,10 @@ def setup_model_routes(model_discovery):
ok, info = _should_refresh_endpoint(ep, now, force=force)
if not ok:
continue
credential = _resolve_probe_key(ep)
groups.setdefault(info["key"], {
"base": info["base"],
"api_key": info["api_key"],
"api_key": credential,
"timeout": info["timeout"],
"endpoint_ids": [],
})["endpoint_ids"].append(info["id"])
@@ -1606,6 +1643,9 @@ def setup_model_routes(model_discovery):
db.close()
for ep in endpoints:
from src.chatgpt_subscription import is_chatgpt_subscription_base
if is_chatgpt_subscription_base(ep.base_url or "") and (ep.owner or None) != (owner or None):
continue
base = _normalize_base(ep.base_url)
provider = _safe_detect_provider(base)
ep_model_type = getattr(ep, "model_type", None) or "llm"
@@ -1858,8 +1898,8 @@ def setup_model_routes(model_discovery):
# Cache endpoint lookups
if ep_id and ep_id not in endpoints_cache:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if ep:
endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": ep.api_key}
if ep and _chatgpt_endpoint_visible(ep, request):
endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": _resolve_probe_key(ep)}
ep_data = endpoints_cache.get(ep_id)
if not ep_data:
# Try to find by base_url from the model's endpoint field
@@ -1894,11 +1934,13 @@ def setup_model_routes(model_discovery):
# Detach from session
ep_data = []
for ep in endpoints:
if not _chatgpt_endpoint_visible(ep, request):
continue
ep_data.append({
"id": ep.id,
"name": ep.name,
"base_url": ep.base_url,
"api_key": ep.api_key,
"api_key": _resolve_probe_key(ep),
})
finally:
db.close()
@@ -1983,6 +2025,8 @@ def setup_model_routes(model_discovery):
results = []
upgraded_legacy_pins = False
for r in rows:
if not _chatgpt_endpoint_visible(r, request):
continue
all_models = _cached_model_ids(r)
hidden = _hidden_model_ids(r)
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
@@ -2022,6 +2066,7 @@ def setup_model_routes(model_discovery):
"model_refresh_mode": _endpoint_refresh_mode(r, kind),
"model_refresh_interval": getattr(r, "model_refresh_interval", None),
"model_refresh_timeout": getattr(r, "model_refresh_timeout", None),
**_provider_account_metadata(r),
})
if upgraded_legacy_pins:
db.commit()
@@ -2096,6 +2141,8 @@ def setup_model_routes(model_discovery):
existing = None
_empty_key_existing = None
for _candidate in _same_url_rows:
if getattr(_candidate, "provider_auth_id", None):
continue # OAuth account mutations require its explicit identity.
_candidate_key = (getattr(_candidate, "api_key", None) or "").strip()
if _candidate_key == _incoming_api_key:
existing = _candidate
@@ -2192,6 +2239,9 @@ def setup_model_routes(model_discovery):
try:
_st_raw = (supports_tools or "").strip().lower()
_st = True if _st_raw in ("true", "1", "yes") else (False if _st_raw in ("false", "0", "no") else None)
from src.chatgpt_subscription import is_chatgpt_subscription_base
if is_chatgpt_subscription_base(base_url):
_st = False
_pinned = _normalize_model_ids(pinned_models)
# Stamp owner so the picker only shows this endpoint to the admin
# who added it. Pass `shared=true` to mark it null-owner (visible
@@ -2305,9 +2355,9 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep:
if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found")
ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": ep.api_key}
ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": _resolve_probe_key(ep)}
finally:
db.close()
@@ -2361,7 +2411,7 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep:
if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found")
hidden = _hidden_model_ids(ep)
all_models = _cached_model_ids(ep)
@@ -2372,7 +2422,7 @@ def setup_model_routes(model_discovery):
category = _classify_endpoint(base, kind)
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
try:
probed = _probe_endpoint(base, ep.api_key, timeout=timeout)
probed = _probe_endpoint(base, _resolve_probe_key(ep), timeout=timeout)
except Exception as exc:
logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc)
probed = []
@@ -2416,7 +2466,7 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep:
if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found")
body = await request.json()
if not isinstance(body, dict):
@@ -2573,7 +2623,7 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep:
if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found")
if body:
if "supports_tools" in body:
@@ -2627,6 +2677,9 @@ def setup_model_routes(model_discovery):
ep.base_url = _new_base
else:
ep.is_enabled = not ep.is_enabled
from src.chatgpt_subscription import is_chatgpt_subscription_base
if is_chatgpt_subscription_base(ep.base_url or ""):
ep.supports_tools = False
db.commit()
_invalidate_models_cache()
_local_probe_cache["data"] = None
@@ -2684,7 +2737,7 @@ def setup_model_routes(model_discovery):
}
return sess in variants or sess.startswith(base + "/")
def _clear_sessions_for_endpoint(db, base_url: str) -> int:
def _clear_sessions_for_endpoint(db, base_url: str, endpoint_id: str, owner) -> int:
"""Drop stored auth for sessions using an endpoint being deleted.
Keep the session's endpoint URL and model intact. If the admin is
@@ -2694,15 +2747,17 @@ def setup_model_routes(model_discovery):
matching enabled endpoint exists.
"""
cleared = 0
rows = db.query(DbSession).filter(DbSession.endpoint_url.isnot(None)).all()
rows = db.query(DbSession).filter(DbSession.endpoint_url.isnot(None), DbSession.owner == owner).all()
for row in rows:
if getattr(row, "endpoint_id", None) not in (None, endpoint_id):
continue
if _session_uses_endpoint_url(row.endpoint_url or "", base_url):
row.headers = {}
row.updated_at = datetime.utcnow()
cleared += 1
return cleared
def _clear_loaded_sessions_for_endpoint(base_url: str) -> int:
def _clear_loaded_sessions_for_endpoint(base_url: str, endpoint_id: str, owner) -> int:
try:
from src.ai_interaction import get_session_manager
manager = get_session_manager()
@@ -2713,6 +2768,8 @@ def setup_model_routes(model_discovery):
cleared = 0
try:
for sess in list(getattr(manager, "sessions", {}).values()):
if getattr(sess, "owner", None) != owner or getattr(sess, "endpoint_id", None) not in (None, endpoint_id):
continue
if _session_uses_endpoint_url(getattr(sess, "endpoint_url", "") or "", base_url):
sess.headers = {}
cleared += 1
@@ -2732,13 +2789,13 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep:
if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found")
# Clean up any settings that reference this endpoint
cleared = _clear_settings_for_endpoint(ep_id)
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url)
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url)
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url, ep.id, ep.owner)
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url, ep.id, ep.owner)
auth_id = getattr(ep, "provider_auth_id", None)
db.delete(ep)
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
+16 -3
View File
@@ -455,13 +455,17 @@ def setup_session_routes(
else (s.created_at.isoformat() if s.created_at else None))
)
matches = endpoint_routes.get((s.endpoint_url or '').rstrip('/'), [])
selected_endpoint = matches[0] if len(matches) == 1 else None
bound_id = getattr(s, "endpoint_id", None)
selected_endpoint = (
next((ep for ep in matches if ep.id == bound_id), None)
if bound_id else (matches[0] if len(matches) == 1 else None)
)
sessions.append({
"id": s.id,
"name": s.name,
"model": _public_model(s.name, s.model),
"endpoint_url": s.endpoint_url,
"endpoint_id": selected_endpoint.id if selected_endpoint else None,
"endpoint_id": bound_id or (selected_endpoint.id if selected_endpoint else None),
"endpoint_name": selected_endpoint.name if selected_endpoint else None,
"rag": s.rag,
"archived": s.archived,
@@ -590,6 +594,7 @@ def setup_session_routes(
rag=str(rag).lower() == "true" if rag else False,
owner=user,
cwd=cwd or None,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
)
# Set auth headers for custom API-key endpoints
resolved_key = request_api_key
@@ -597,7 +602,8 @@ def setup_session_routes(
if not resolved_key and endpoint_api_key:
resolved_key = endpoint_api_key
resolved_base = endpoint_base_url
if resolved_key:
from src.chatgpt_subscription import is_chatgpt_subscription_base
if resolved_key and not is_chatgpt_subscription_base(endpoint_url):
from src.endpoint_resolver import build_headers
session.headers = build_headers(resolved_key, resolved_base)
_persist_session_headers(sid, session.headers)
@@ -685,8 +691,14 @@ def setup_session_routes(
endpoint_url = build_chat_url(normalize_base(endpoint_base_url))
finally:
_db.close()
previous_url = session.endpoint_url
session.model = model
session.endpoint_url = endpoint_url
# A registered endpoint id pins the exact route; a raw URL switch
# (admin only) clears any previous binding.
session.endpoint_id = (endpoint_id or "").strip() or (
getattr(session, "endpoint_id", None) if endpoint_url == previous_url else None
)
# Update auth headers from the endpoint's stored API key
if endpoint_api_key:
from src.endpoint_resolver import build_headers
@@ -700,6 +712,7 @@ def setup_session_routes(
if db_session:
db_session.model = model
db_session.endpoint_url = endpoint_url
db_session.endpoint_id = session.endpoint_id
db_session.headers = session.headers or {}
db_session.updated_at = utcnow_naive()
db.commit()