mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-08 16:02:20 +02:00
458 lines
18 KiB
Python
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
|