Files
odysseus/routes/chatgpt_subscription_routes.py
T

458 lines
18 KiB
Python

"""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 Any, Dict, Optional
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 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 _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:
raise ValueError("ChatGPT token response was missing access_token or refresh_token")
base = chatgpt_subscription.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL
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 = 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=_new_id(db, ProviderAuthSession),
provider=_PROVIDER,
owner=owner,
label=chatgpt_subscription.endpoint_name_for_label(label),
base_url=base,
auth_mode="chatgpt",
)
db.add(auth)
auth.base_url = base
auth.access_token = access_token
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 = 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=_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)
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"
ep.model_refresh_mode = "manual"
ep.cached_models = json.dumps(models)
db.commit()
result = {
"id": ep.id,
"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
_invalidate_models_cache()
except Exception:
pass
return result
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:
raise chatgpt_subscription.to_http_exception(exc)
device_auth_id = data.get("device_auth_id")
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")
# 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=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:
# 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", 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.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.warning("ChatGPT Subscription endpoint provisioning failed: %s", type(exc).__name__)
raise chatgpt_subscription.to_http_exception(exc)
return DeviceFlowPoll.authorized(result)
err = data.get("error") or data.get("status")
if err in ("authorization_pending", "pending", None):
return DeviceFlowPoll.pending()
if err == "slow_down":
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("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():
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