diff --git a/core/database.py b/core/database.py
index d98360046..6addc95c4 100644
--- a/core/database.py
+++ b/core/database.py
@@ -203,6 +203,12 @@ class Session(TimestampMixin, Base):
# Organization
folder = Column(String, nullable=True, default=None)
cwd = Column(String, nullable=True, default=None)
+ # Registered ModelEndpoint this session is bound to. endpoint_url alone
+ # cannot distinguish two endpoints that share a provider URL but use
+ # different credentials (e.g. two ChatGPT Subscription accounts), so the
+ # exact endpoint id is remembered here. NULL = legacy session; the first
+ # deterministic, owner-scoped resolution persists a binding.
+ endpoint_id = Column(String, nullable=True, index=True)
# Headers stored as JSON
headers = Column(JSON, default=dict)
@@ -1472,6 +1478,19 @@ def _migrate_add_session_cwd_column():
except Exception:
pass
+def _migrate_add_session_endpoint_id_column():
+ """Add the nullable binding and index without rewriting existing sessions."""
+ with engine.begin() as connection:
+ schema = inspect(connection)
+ if not schema.has_table("sessions"):
+ return
+ columns = {column["name"] for column in schema.get_columns("sessions")}
+ if "endpoint_id" not in columns:
+ connection.execute(text("ALTER TABLE sessions ADD COLUMN endpoint_id VARCHAR"))
+ index = next(index for index in Session.__table__.indexes if index.name == "ix_sessions_endpoint_id")
+ index.create(bind=connection, checkfirst=True)
+
+
def _migrate_add_token_columns():
"""Add cumulative token tracking columns to sessions table."""
import sqlite3
@@ -2378,6 +2397,7 @@ def init_db():
_migrate_add_session_generation_settings_columns()
_migrate_add_folder_column()
_migrate_add_session_cwd_column()
+ _migrate_add_session_endpoint_id_column()
_migrate_add_token_columns()
_migrate_add_total_cost_usd()
_migrate_add_mode_column()
diff --git a/core/models.py b/core/models.py
index 851950713..a03e391fc 100644
--- a/core/models.py
+++ b/core/models.py
@@ -115,6 +115,10 @@ class Session:
temperature_override: Optional[float] = None
max_tokens_override: Optional[int] = None
cwd: Optional[str] = None
+ # Registered ModelEndpoint id this session is bound to (None = legacy /
+ # URL-matched). Lets two endpoints that share a provider URL but not
+ # credentials stay distinguishable.
+ endpoint_id: Optional[str] = None
def __post_init__(self):
if self.headers is None:
diff --git a/core/session_manager.py b/core/session_manager.py
index 6708fb69d..f7467eb04 100644
--- a/core/session_manager.py
+++ b/core/session_manager.py
@@ -157,6 +157,7 @@ class SessionManager:
temperature_override=getattr(db_session, "temperature_override", None),
max_tokens_override=getattr(db_session, "max_tokens_override", None),
cwd=getattr(db_session, "cwd", None) or None,
+ endpoint_id=getattr(db_session, "endpoint_id", None) or None,
)
session.message_count = getattr(db_session, "message_count", 0) or 0
return session
@@ -222,6 +223,7 @@ class SessionManager:
temperature_override=getattr(db_session, "temperature_override", None),
max_tokens_override=getattr(db_session, "max_tokens_override", None),
cwd=getattr(db_session, "cwd", None) or None,
+ endpoint_id=getattr(db_session, "endpoint_id", None) or None,
)
# The rows just loaded are the whole transcript, so they — not the
@@ -493,6 +495,7 @@ class SessionManager:
headers = {}
session.name = db_session.name
session.endpoint_url = db_session.endpoint_url or ""
+ session.endpoint_id = getattr(db_session, "endpoint_id", None) or None
session.model = db_session.model or ""
session.headers = headers or {}
session.rag = db_session.rag
@@ -563,9 +566,12 @@ class SessionManager:
owner: str = None,
cwd: str = None,
headers: Optional[Dict[str, str]] = None,
+ endpoint_id: Optional[str] = None,
) -> Session:
"""Create a new session and save to database."""
- session_headers = dict(headers or {})
+ from src.chatgpt_subscription import is_chatgpt_subscription_base
+ session_headers = {} if is_chatgpt_subscription_base(endpoint_url) else dict(headers or {})
+ endpoint_id = (endpoint_id or "").strip() or None
db = SessionLocal()
try:
db_session = DbSession(
@@ -577,6 +583,7 @@ class SessionManager:
headers=session_headers,
owner=owner,
cwd=cwd or None,
+ endpoint_id=endpoint_id,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
@@ -592,6 +599,7 @@ class SessionManager:
headers=session_headers,
owner=owner,
cwd=cwd or None,
+ endpoint_id=endpoint_id,
)
self.sessions[session_id] = session
diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py
index 35962dba5..96ac103d8 100644
--- a/routes/chat_helpers.py
+++ b/routes/chat_helpers.py
@@ -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]:
diff --git a/routes/chat_routes.py b/routes/chat_routes.py
index 5d58ef7fb..33e8d6bd3 100644
--- a/routes/chat_routes.py
+++ b/routes/chat_routes.py
@@ -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)
diff --git a/routes/chatgpt_subscription_routes.py b/routes/chatgpt_subscription_routes.py
index 9c695b371..a9a19b83a 100644
--- a/routes/chatgpt_subscription_routes.py
+++ b/routes/chatgpt_subscription_routes.py
@@ -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
diff --git a/routes/history/history_routes.py b/routes/history/history_routes.py
index 411a54cbd..26d3dca48 100644
--- a/routes/history/history_routes.py
+++ b/routes/history/history_routes.py
@@ -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,
diff --git a/routes/model_routes.py b/routes/model_routes.py
index 5238b1746..069aeb322 100644
--- a/routes/model_routes.py
+++ b/routes/model_routes.py
@@ -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)
diff --git a/routes/session_routes.py b/routes/session_routes.py
index 58e262696..3714a8347 100644
--- a/routes/session_routes.py
+++ b/routes/session_routes.py
@@ -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()
diff --git a/src/chatgpt_subscription.py b/src/chatgpt_subscription.py
index e65ccbc8d..0ee313b3e 100644
--- a/src/chatgpt_subscription.py
+++ b/src/chatgpt_subscription.py
@@ -9,10 +9,13 @@ from __future__ import annotations
import base64
import json
+import math
import os
+import re
import threading
import time
-from typing import Any, Dict, Optional
+import unicodedata
+from typing import Any, Dict, List, Optional
import httpx
from fastapi import HTTPException
@@ -22,6 +25,16 @@ DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL = (
or "https://chatgpt.com/backend-api/codex"
)
CHATGPT_SUBSCRIPTION_PROVIDER = "chatgpt-subscription"
+# Legacy single-account endpoint/auth name. Rows provisioned before
+# multi-account support keep this name and stay functional.
+CHATGPT_SUBSCRIPTION_LEGACY_NAME = "ChatGPT Subscription"
+CHATGPT_ACCOUNT_LABEL_MAX_LENGTH = 40
+# Read-only account usage (rate-limit windows) on the authenticated ChatGPT
+# backend. Mirrors openai/codex ``backend-client`` ``PathStyle::ChatGptApi``:
+# ``{base}/wham/usage`` where base is ``https://chatgpt.com/backend-api``.
+CHATGPT_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
+CHATGPT_USAGE_TIMEOUT_SECONDS = 8.0
+CHATGPT_USAGE_CACHE_TTL_SECONDS = 45.0
CHATGPT_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CHATGPT_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
CHATGPT_OAUTH_ISSUER = "https://auth.openai.com"
@@ -87,6 +100,53 @@ def chatgpt_headers(access_token: Optional[str]) -> Dict[str, str]:
return headers
+# ── Account labels ──────────────────────────────────────────────────────────
+
+_LABEL_WHITESPACE_RE = re.compile(r"\s+")
+
+
+def normalize_account_label(raw: Any) -> str:
+ """Return a trimmed, display-safe account label ("" when absent).
+
+ Labels are purely cosmetic: they never participate in authentication or
+ authorization (stable auth/endpoint ids do). Control characters are
+ stripped, whitespace collapsed and the length bounded so the label is safe
+ to echo in Settings, the model picker and provenance metadata.
+ """
+ if raw is None:
+ return ""
+ text = str(raw)
+ text = "".join(ch for ch in text if unicodedata.category(ch)[0] != "C")
+ text = _LABEL_WHITESPACE_RE.sub(" ", text).strip()
+ if len(text) > CHATGPT_ACCOUNT_LABEL_MAX_LENGTH:
+ raise ValueError(
+ f"Account label must be at most {CHATGPT_ACCOUNT_LABEL_MAX_LENGTH} characters."
+ )
+ return text
+
+
+def endpoint_name_for_label(label: str) -> str:
+ """User-visible endpoint name for a ChatGPT account label."""
+ label = (label or "").strip()
+ if not label:
+ return CHATGPT_SUBSCRIPTION_LEGACY_NAME
+ return f"ChatGPT · {label}"
+
+
+def account_label_from_name(name: Optional[str], fallback: str = "") -> str:
+ """Inverse of :func:`endpoint_name_for_label` for display purposes."""
+ text = (name or "").strip()
+ if text.startswith("ChatGPT · "):
+ return text[len("ChatGPT · "):].strip() or fallback
+ if text == CHATGPT_SUBSCRIPTION_LEGACY_NAME:
+ return fallback
+ return text or fallback
+
+
+def labels_conflict(a: str, b: str) -> bool:
+ return bool(a) and bool(b) and a.casefold() == b.casefold()
+
+
def fetch_available_models(access_token: str, timeout: float = 10.0) -> list[str]:
if not access_token:
return []
@@ -135,14 +195,8 @@ def _raise_for_oauth_response(response: httpx.Response, action: str) -> None:
err = payload.get("error") if isinstance(payload, dict) else None
if isinstance(err, dict):
code = str(err.get("code") or err.get("type") or "").strip()
- msg = err.get("message")
- if msg:
- message = f"ChatGPT Subscription {action} failed: {msg}"
elif isinstance(err, str):
code = err.strip()
- desc = payload.get("error_description") or payload.get("message")
- if desc:
- message = f"ChatGPT Subscription {action} failed: {desc}"
except Exception:
pass
if response.status_code == 429:
@@ -259,8 +313,7 @@ def resolve_runtime_credentials(auth_id: str, owner: Optional[str] = None, *, fo
ProviderAuthSession.id == auth_id,
ProviderAuthSession.provider == CHATGPT_SUBSCRIPTION_PROVIDER,
)
- if owner:
- q = q.filter(ProviderAuthSession.owner == owner)
+ q = q.filter(ProviderAuthSession.owner == owner)
row = q.first()
if row is None:
raise ChatGPTSubscriptionAuthNotFound("ChatGPT Subscription credentials were not found for this user.")
@@ -291,12 +344,359 @@ def resolve_runtime_credentials(auth_id: str, owner: Optional[str] = None, *, fo
db.close()
+def find_owned_auth_session(db, auth_id: str, owner: Optional[str]):
+ """Return the owner-scoped ChatGPT ProviderAuthSession row or None.
+
+ OAuth credentials belong to exactly one owner, including the legacy
+ anonymous owner. Labels are never used for lookup.
+ """
+ ProviderAuthSession, _SessionLocal, _now = _database_handles()
+ auth_id = (auth_id or "").strip()
+ if not auth_id:
+ return None
+ q = db.query(ProviderAuthSession).filter(
+ ProviderAuthSession.id == auth_id,
+ ProviderAuthSession.provider == CHATGPT_SUBSCRIPTION_PROVIDER,
+ )
+ return q.filter(ProviderAuthSession.owner == owner).first()
+
+
+def chatgpt_account_id_from_token(access_token: str) -> Optional[str]:
+ """Extract the ChatGPT account id claim from an access token, if any."""
+ try:
+ payload = _decode_jwt_payload(access_token)
+ except Exception:
+ return None
+ auth_claims = payload.get("https://api.openai.com/auth")
+ if isinstance(auth_claims, dict):
+ account_id = auth_claims.get("chatgpt_account_id")
+ if isinstance(account_id, str) and account_id.strip():
+ return account_id.strip()
+ return None
+
+
+def usage_request_headers(access_token: str) -> Dict[str, str]:
+ headers = {
+ "Accept": "application/json",
+ "User-Agent": "Odysseus ChatGPT Subscription",
+ "Authorization": f"Bearer {access_token}",
+ }
+ account_id = chatgpt_account_id_from_token(access_token)
+ if account_id:
+ headers["ChatGPT-Account-Id"] = account_id
+ return headers
+
+
+class ChatGPTUsageUnavailable(ChatGPTSubscriptionError):
+ """Usage telemetry could not be read; the model endpoint is unaffected."""
+
+ def __init__(self, reason: str, message: str, *, status_code: Optional[int] = None):
+ super().__init__(message)
+ self.reason = reason
+ self.status_code = status_code
+
+
+def _coerce_number(value: Any) -> Optional[float]:
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, (int, float, str)):
+ try:
+ number = float(value)
+ return number if math.isfinite(number) else None
+ except (ValueError, OverflowError):
+ return None
+ return None
+
+
+def _coerce_int(value: Any) -> Optional[int]:
+ number = _coerce_number(value)
+ if number is None:
+ return None
+ try:
+ return int(number)
+ except (OverflowError, ValueError):
+ return None
+
+
+def _optional_str(value: Any) -> Optional[str]:
+ if isinstance(value, str):
+ text = value.strip()
+ return text or None
+ return None
+
+
+def window_minutes_from_seconds(seconds: Any) -> Optional[int]:
+ """Codex-compatible ceil(seconds / 60); None for missing/non-positive."""
+ value = _coerce_int(seconds)
+ if value is None or value <= 0:
+ return None
+ return (value + 59) // 60
+
+
+def friendly_window_name(window_minutes: Optional[int]) -> str:
+ """Compact label derived from the actual window length (never assumed)."""
+ if not window_minutes or window_minutes <= 0:
+ return "LIMIT"
+ if window_minutes % (7 * 24 * 60) == 0:
+ weeks = window_minutes // (7 * 24 * 60)
+ return "WEEK" if weeks == 1 else f"{weeks}W"
+ if window_minutes % (24 * 60) == 0:
+ return f"{window_minutes // (24 * 60)}D"
+ if window_minutes % 60 == 0:
+ return f"{window_minutes // 60}H"
+ return f"{window_minutes}M"
+
+
+def normalize_usage_window(raw: Any, kind: str) -> Optional[Dict[str, Any]]:
+ """Normalize one ``primary_window``/``secondary_window`` snapshot.
+
+ Upstream fields (openai/codex ``RateLimitWindowSnapshot``): ``used_percent``,
+ ``limit_window_seconds``, ``reset_after_seconds``, ``reset_at``. Absent
+ values stay ``None``; nothing is invented.
+ """
+ if not isinstance(raw, dict):
+ return None
+ used_percent = _coerce_number(raw.get("used_percent"))
+ if used_percent is not None:
+ used_percent = max(0.0, min(100.0, used_percent))
+ window_minutes = window_minutes_from_seconds(raw.get("limit_window_seconds"))
+ if window_minutes is None:
+ window_minutes = _coerce_int(raw.get("window_minutes"))
+ if window_minutes is not None and window_minutes <= 0:
+ window_minutes = None
+ resets_at = _coerce_int(raw.get("reset_at"))
+ if resets_at is None:
+ resets_at = _coerce_int(raw.get("resets_at"))
+ if resets_at is not None and resets_at <= 0:
+ resets_at = None
+ reset_after_seconds = _coerce_int(raw.get("reset_after_seconds"))
+ if reset_after_seconds is not None and reset_after_seconds < 0:
+ reset_after_seconds = None
+ return {
+ "kind": kind,
+ "name": friendly_window_name(window_minutes),
+ "used_percent": used_percent,
+ "remaining_percent": (None if used_percent is None else round(100.0 - used_percent, 2)),
+ "window_minutes": window_minutes,
+ "resets_at": resets_at,
+ "reset_after_seconds": reset_after_seconds,
+ }
+
+
+def _normalize_rate_limit_details(raw: Any) -> Dict[str, Any]:
+ details = raw if isinstance(raw, dict) else {}
+ windows: List[Dict[str, Any]] = []
+ # Preserve new window kinds without assigning a duration to their names.
+ keys = ["primary_window", "secondary_window"]
+ keys.extend(key for key in details if key.endswith("_window") and key not in keys)
+ for key in keys:
+ window = normalize_usage_window(details.get(key), key[:-7])
+ if window is not None:
+ windows.append(window)
+ allowed = details.get("allowed")
+ limit_reached = details.get("limit_reached")
+ return {
+ "allowed": allowed if isinstance(allowed, bool) else None,
+ "limit_reached": limit_reached if isinstance(limit_reached, bool) else None,
+ "windows": windows,
+ }
+
+
+def normalize_usage_payload(payload: Any) -> Dict[str, Any]:
+ """Normalize a ``GET /wham/usage`` JSON body into Odysseus' safe contract.
+
+ Returns only non-credential fields. The main Codex limit is reported as
+ ``limit_id == "codex"`` (as openai/codex does); each entry of
+ ``additional_rate_limits`` becomes its own bucket keyed by
+ ``metered_feature``. Unknown fields are ignored, unknown buckets kept.
+ """
+ if not isinstance(payload, dict):
+ raise ChatGPTUsageUnavailable("malformed", "ChatGPT usage response was not a JSON object.")
+
+ limits: List[Dict[str, Any]] = []
+ main = _normalize_rate_limit_details(payload.get("rate_limit"))
+ limits.append({
+ "limit_id": "codex",
+ "limit_name": None,
+ "normal_model_slug": None,
+ "allowed": main["allowed"],
+ "limit_reached": main["limit_reached"],
+ "windows": main["windows"],
+ })
+
+ additional = payload.get("additional_rate_limits")
+ if isinstance(additional, list):
+ for entry in additional:
+ if not isinstance(entry, dict):
+ continue
+ details = _normalize_rate_limit_details(entry.get("rate_limit"))
+ limit_name = _optional_str(entry.get("limit_name"))
+ limit_id = _optional_str(entry.get("metered_feature")) or limit_name
+ if not limit_id and not details["windows"]:
+ continue
+ limits.append({
+ "limit_id": limit_id or "additional",
+ "limit_name": limit_name,
+ "normal_model_slug": _optional_str(entry.get("normal_model_slug")),
+ "allowed": details["allowed"],
+ "limit_reached": details["limit_reached"],
+ "windows": details["windows"],
+ })
+
+ reached = payload.get("rate_limit_reached_type")
+ if isinstance(reached, dict):
+ reached = _optional_str(reached.get("type") or reached.get("kind"))
+ else:
+ reached = _optional_str(reached)
+
+ ordinary_usage_allowed = main["allowed"]
+ return {
+ "account_id": _optional_str(payload.get("account_id")),
+ "plan_type": _optional_str(payload.get("plan_type")),
+ "ordinary_usage_allowed": ordinary_usage_allowed,
+ "rate_limit_reached_type": reached,
+ "limits": limits,
+ }
+
+
+def fetch_usage_payload(access_token: str, timeout: float = CHATGPT_USAGE_TIMEOUT_SECONDS) -> Dict[str, Any]:
+ """Read the raw usage JSON for one access token; classify failures."""
+ if not access_token:
+ raise ChatGPTUsageUnavailable("reauth", "ChatGPT Subscription has no access token.")
+ try:
+ response = httpx.get(CHATGPT_USAGE_URL, headers=usage_request_headers(access_token), timeout=timeout)
+ except httpx.TimeoutException as exc:
+ raise ChatGPTUsageUnavailable("timeout", "ChatGPT usage request timed out.") from exc
+ except httpx.HTTPError as exc:
+ raise ChatGPTUsageUnavailable("network", "ChatGPT usage request failed.") from exc
+ status = response.status_code
+ if status in (401, 403):
+ raise ChatGPTUsageUnavailable(
+ "reauth",
+ "ChatGPT rejected the usage request; the account may need reconnecting.",
+ status_code=status,
+ )
+ if status == 429:
+ raise ChatGPTUsageUnavailable("rate_limited", "ChatGPT usage is temporarily rate limited.", status_code=status)
+ if status >= 500:
+ raise ChatGPTUsageUnavailable("upstream", f"ChatGPT usage service returned HTTP {status}.", status_code=status)
+ if status != 200:
+ raise ChatGPTUsageUnavailable("upstream", f"ChatGPT usage request returned HTTP {status}.", status_code=status)
+ try:
+ data = response.json()
+ except Exception as exc:
+ raise ChatGPTUsageUnavailable("malformed", "ChatGPT usage response was not valid JSON.") from exc
+ if not isinstance(data, dict):
+ raise ChatGPTUsageUnavailable("malformed", "ChatGPT usage response was not a JSON object.")
+ return data
+
+
+class UsageCache:
+ """Short per-auth-session cache for normalized usage snapshots."""
+
+ def __init__(self, ttl_seconds: float = CHATGPT_USAGE_CACHE_TTL_SECONDS, time_func=time.monotonic, max_entries: int = 256):
+ self._ttl = float(ttl_seconds)
+ self._max_entries = max(1, int(max_entries))
+ self._time = time_func
+ self._entries: Dict[str, tuple] = {}
+ self._lock = threading.Lock()
+
+ def get(self, auth_id: str) -> Optional[Dict[str, Any]]:
+ now = float(self._time())
+ with self._lock:
+ entry = self._entries.get(auth_id)
+ if entry is None:
+ return None
+ stored_at, value = entry
+ if now - stored_at >= self._ttl:
+ self._entries.pop(auth_id, None)
+ return None
+ return json.loads(json.dumps(value))
+
+ def put(self, auth_id: str, value: Dict[str, Any]) -> None:
+ with self._lock:
+ now = float(self._time())
+ for key, (stored_at, _) in list(self._entries.items()):
+ if now - stored_at >= self._ttl:
+ self._entries.pop(key, None)
+ self._entries.pop(auth_id, None)
+ while len(self._entries) >= self._max_entries:
+ self._entries.pop(next(iter(self._entries)))
+ self._entries[auth_id] = (now, json.loads(json.dumps(value)))
+
+ def invalidate(self, auth_id: str) -> None:
+ with self._lock:
+ self._entries.pop(auth_id, None)
+
+ def clear(self) -> None:
+ with self._lock:
+ self._entries.clear()
+
+
+USAGE_CACHE = UsageCache()
+
+
+def get_account_usage(
+ auth_id: str,
+ owner: Optional[str] = None,
+ *,
+ force_refresh: bool = False,
+ cache: Optional[UsageCache] = None,
+) -> Dict[str, Any]:
+ """Return normalized usage for exactly one owner-scoped auth session.
+
+ The access token is resolved (and refreshed if needed) for that auth
+ session only. Results are cached per auth id; ``force_refresh`` bypasses
+ and replaces the cached entry. Raises :class:`ChatGPTUsageUnavailable` on
+ any read failure; callers must not treat that as an endpoint failure.
+ """
+ cache = USAGE_CACHE if cache is None else cache
+ auth_id = (auth_id or "").strip()
+ if not auth_id:
+ raise ChatGPTSubscriptionAuthNotFound("ChatGPT Subscription account was not found.")
+ # Authorize even cache hits: cached telemetry must not outlive ownership.
+ _Auth, SessionLocal, _now = _database_handles()
+ db = SessionLocal()
+ try:
+ if find_owned_auth_session(db, auth_id, owner) is None:
+ raise ChatGPTSubscriptionAuthNotFound("ChatGPT Subscription account was not found.")
+ finally:
+ db.close()
+ if not force_refresh:
+ cached = cache.get(auth_id)
+ if cached is not None:
+ cached["cached"] = True
+ return cached
+ else:
+ cache.invalidate(auth_id)
+ try:
+ creds = resolve_runtime_credentials(auth_id, owner=owner)
+ except ChatGPTSubscriptionAuthNotFound:
+ raise
+ except ChatGPTSubscriptionRateLimited as exc:
+ raise ChatGPTUsageUnavailable("rate_limited", str(exc), status_code=429) from exc
+ except ChatGPTSubscriptionReauthRequired as exc:
+ raise ChatGPTUsageUnavailable("reauth", str(exc), status_code=401) from exc
+ except ChatGPTSubscriptionError as exc:
+ raise ChatGPTUsageUnavailable("upstream", str(exc)) from exc
+ raw = fetch_usage_payload(creds.get("api_key") or "")
+ normalized = normalize_usage_payload(raw)
+ normalized["auth_id"] = auth_id
+ normalized["fetched_at"] = int(time.time())
+ cache.put(auth_id, normalized)
+ result = json.loads(json.dumps(normalized))
+ result["cached"] = False
+ return result
+
+
def to_http_exception(exc: Exception) -> HTTPException:
if isinstance(exc, ChatGPTSubscriptionRateLimited):
return HTTPException(429, str(exc))
if isinstance(exc, (ChatGPTSubscriptionReauthRequired, ChatGPTSubscriptionAuthNotFound)):
return HTTPException(401, f"{exc} Reconnect the provider.")
- return HTTPException(502, str(exc))
+ if isinstance(exc, (ChatGPTSubscriptionError, ValueError)):
+ return HTTPException(502, str(exc))
+ return HTTPException(502, "ChatGPT Subscription request failed.")
def build_responses_input(messages: list[dict]) -> list[dict]:
diff --git a/src/foreground_model_routing.py b/src/foreground_model_routing.py
index 76e254614..898aa2ca8 100644
--- a/src/foreground_model_routing.py
+++ b/src/foreground_model_routing.py
@@ -191,7 +191,16 @@ def build_foreground_route_descriptors(
owner=owner,
)
if selected is None:
- selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner)
+ if selected_endpoint_id:
+ # An explicit identity must never be relabelled as another account
+ # with the same URL/model (or even the same current bearer).
+ selected = {
+ "endpoint_id": None,
+ "endpoint_label": "Selected route",
+ "endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
+ }
+ else:
+ selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner)
primary = (endpoint_url, model, headers or {})
candidates = [primary]
descriptors = [selected]
diff --git a/src/llm_core.py b/src/llm_core.py
index 7b7298ad7..fa9cf045b 100644
--- a/src/llm_core.py
+++ b/src/llm_core.py
@@ -1496,6 +1496,33 @@ def _chatgpt_subscription_instructions(messages: List[Dict]) -> str:
return "You are a helpful AI assistant."
+# Provider-native agentic surfaces that must never be sent on the ChatGPT
+# Subscription route. ChatGPT provides model inference only; Odysseus is the
+# only agent (planning, tool selection/execution, filesystem, shell, browser,
+# MCP). Odysseus' own text tool protocol travels inside ``instructions``/``input``.
+CHATGPT_FORBIDDEN_PAYLOAD_KEYS = frozenset({
+ "tools",
+ "tool_choice",
+ "parallel_tool_calls",
+ "web_search",
+ "web_search_preview",
+ "file_search",
+ "computer",
+ "computer_use",
+ "computer_use_preview",
+ "shell",
+ "local_shell",
+ "code_interpreter",
+ "image_generation",
+ "mcp",
+ "function",
+ "functions",
+ "include",
+ "previous_response_id",
+ "background",
+})
+
+
def _build_chatgpt_responses_payload(
model: str,
messages: List[Dict],
@@ -1503,9 +1530,18 @@ def _build_chatgpt_responses_payload(
max_tokens: int,
*,
stream: bool = False,
+ tools: Optional[List[Dict]] = None,
+ **_ignored,
) -> Dict:
+ """Build the ChatGPT/Codex Responses request: model inference only.
+
+ ``tools`` (and any other provider-native tool declaration) is accepted for
+ signature compatibility with the other transports and deliberately
+ discarded. See :data:`CHATGPT_FORBIDDEN_PAYLOAD_KEYS`.
+ """
from src.chatgpt_subscription import build_responses_input
+ del tools, _ignored
conversation = [msg for msg in (messages or []) if (msg.get("role") or "") != "system"]
payload: Dict = {
"model": model,
@@ -1519,7 +1555,27 @@ def _build_chatgpt_responses_payload(
# ChatGPT Subscription Codex API does not support max_output_tokens —
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
# Do not include it in the payload.
- return payload
+ return _strip_chatgpt_native_tool_surfaces(payload)
+
+
+CHATGPT_ALLOWED_PAYLOAD_KEYS = frozenset({
+ "model", "instructions", "input", "stream", "store", "temperature",
+})
+
+
+def _strip_chatgpt_native_tool_surfaces(payload: Dict) -> Dict:
+ """Only explicitly approved inference fields may cross this boundary."""
+ return {key: value for key, value in payload.items() if key in CHATGPT_ALLOWED_PAYLOAD_KEYS}
+
+
+def _chatgpt_safe_error(message: str, headers: Dict) -> str:
+ """Upstream diagnostics must never echo the request bearer to the UI."""
+ result = str(message)
+ for key, value in (headers or {}).items():
+ if key.lower() in {"authorization", "x-api-key"} and value:
+ secret = str(value).removeprefix("Bearer ")
+ result = result.replace(str(value), "[redacted]").replace(secret, "[redacted]")
+ return result
def _format_chatgpt_subscription_error(status_code: int, text: str) -> str:
@@ -2977,8 +3033,8 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
_clear_host_dead(target_url)
if r.status_code != 200:
raw = (await r.aread()).decode(errors="replace")
- friendly = _format_chatgpt_subscription_error(r.status_code, raw)
- yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly, "raw": raw[:500]})}\n\n'
+ friendly = _format_chatgpt_subscription_error(r.status_code, _chatgpt_safe_error(raw, h))
+ yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly})}\n\n'
return
async for line in r.aiter_lines():
if not line:
@@ -3063,13 +3119,13 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
}
text = err.get("message") if isinstance(err, dict) else str(err or "ChatGPT Subscription request failed")
status = _provider_stream_error_status(err, default=400)
- yield f'event: error\ndata: {json.dumps({"status": status, "text": text})}\n\n'
+ yield f'event: error\ndata: {json.dumps({"status": status, "text": _chatgpt_safe_error(text, h)})}\n\n'
return
yield "data: [DONE]\n\n"
except (httpx.ConnectError, httpx.ConnectTimeout) as e:
_cooled = _mark_host_dead(target_url)
_tail = f" — host cooled for {DEAD_HOST_COOLDOWN:.0f}s" if _cooled else " — transient, will retry"
- logger.warning(f"ChatGPT Subscription stream connect to {target_url} failed: {e}{_tail}")
+ logger.warning("ChatGPT Subscription stream connect failed: %s%s", type(e).__name__, _tail)
yield f'event: error\ndata: {json.dumps({"error": f"Cannot reach {_host_key(target_url)}", "status": 503})}\n\n'
except httpx.ReadTimeout:
yield f'event: error\ndata: {json.dumps({"error": "Read timeout", "status": 504})}\n\n'
@@ -3082,8 +3138,8 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
except httpx.NetworkError:
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
except Exception as e:
- logger.error(f"ChatGPT Subscription stream error: {e}")
- yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
+ logger.error("ChatGPT Subscription stream error: %s", type(e).__name__)
+ yield f'event: error\ndata: {json.dumps({"error": "ChatGPT Subscription stream failed", "status": 502, "fallback_eligible": False})}\n\n'
return
# ── Native Ollama streaming ──
@@ -3099,7 +3155,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
if r.status_code != 200:
raw = (await r.aread()).decode(errors="replace")
friendly = _format_upstream_error(r.status_code, raw, target_url)
- yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly, "raw": raw[:500]})}\n\n'
+ yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly})}\n\n'
return
async for line in r.aiter_lines():
if not line:
@@ -3198,7 +3254,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
if r.status_code != 200:
raw = (await r.aread()).decode(errors="replace")
friendly = _format_upstream_error(r.status_code, raw, target_url)
- yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly, "raw": raw[:500]})}\n\n'
+ yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly})}\n\n'
return
async for line in r.aiter_lines():
# SSE allows "data:value" with no space after the colon
@@ -3382,7 +3438,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
if r.status_code != 200:
raw = (await r.aread()).decode(errors="replace")
friendly = _format_upstream_error(r.status_code, raw, target_url)
- yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly, "raw": raw[:500]})}\n\n'
+ yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly})}\n\n'
return
first_token_budget = _first_token_timeout(target_url, timeout)
diff --git a/static/app.js b/static/app.js
index 0a562ac73..f5014f29e 100644
--- a/static/app.js
+++ b/static/app.js
@@ -37,7 +37,7 @@ import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=2
import tasksModule from './js/tasks.js?v=20260914taskmodel1';
import calendarModule from './js/calendar.js?v=20260914emailsource11';
import notesModule from './js/notes.js?v=20260911notesselectioncancel1';
-import adminModule from './js/admin.js?v=20260914toolschemaprofiles1';
+import adminModule from './js/admin.js?v=20260921chatgptusage1';
import settingsModule from './js/settings.js?v=20260912writingstyle3';
// Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js';
diff --git a/static/index.html b/static/index.html
index 808f78340..1b87eff7f 100644
--- a/static/index.html
+++ b/static/index.html
@@ -246,7 +246,7 @@
real request is discarded and the font fetched a second time. -->
-
+
diff --git a/static/js/admin.js b/static/js/admin.js
index 0dccd8e68..e6098908f 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -6,6 +6,12 @@ import settingsModule from './settings.js?v=20260912writingstyle3';
import { providerLogo, providerLogoFromUrl } from './providers.js';
import { sortModelObjects } from './modelSort.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
+import {
+ accountTitle as chatgptAccountTitle,
+ buildUsageViewModel as buildChatgptUsageViewModel,
+ isChatgptSubscriptionEndpoint,
+ renderUsageCardHtml as renderChatgptUsageCardHtml,
+} from './chatgptSubscriptionUsage.js';
import { getSettings, getTools, invalidateSettings, invalidateTools } from './appConfig.js';
let initialized = false;
@@ -652,6 +658,7 @@ async function loadEndpoints() {
const keyLabel = ep.has_key
? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)')
: '';
+ const isChatgptAccount = isChatgptSubscriptionEndpoint(ep);
return `
@@ -661,7 +668,7 @@ async function loadEndpoints() {
` : ''}
${providerLogoFromUrl(ep.base_url) || ''}
-
${esc(ep.name)}
+
${esc(isChatgptAccount ? chatgptAccountTitle(ep) : ep.name)}
${ep.model_type === 'image' ? '
Image' : ''}
${kindLabel ? `
${esc(kindLabel)}` : ''}
${statusBadge}
@@ -675,6 +682,7 @@ async function loadEndpoints() {
${hasModels ? '
' : ''}`}
+ ${isChatgptAccount ? `` : ''}
${esc(ep.base_url)}${category === 'local' ? `
` : ''}${keyLabel}
${hasModels ? `` : ''}
`;
@@ -715,6 +723,12 @@ async function loadEndpoints() {
});
return out;
};
+ // One usage card per ChatGPT account: each fetch targets that card's own
+ // auth id, so account A's refresh can never repaint account B.
+ queryAll('[data-adm-chatgpt-usage-host]').forEach(host => {
+ host.addEventListener('click', (e) => e.stopPropagation());
+ _loadChatgptUsage(host, host.dataset.admChatgptUsageHost, host.dataset.chatgptEndpointId);
+ });
queryAll('[data-adm-toggle-ep]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
@@ -983,6 +997,133 @@ async function _saveEpModelState(epId, panel) {
} catch (e) { /* silent */ }
}
+// Render the shared "waiting for authorization" panel (code + manual link).
+// Built for both the Add Models flow and per-account Reconnect. Never opens a
+// tab automatically; the user clicks the Authorize link.
+function _renderDeviceAuthWaitPanel(status, providerKey, start, authUrl) {
+ if (!status) return;
+ status.className = '';
+ const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI';
+ const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...';
+ status.innerHTML =
+ '' +
+ '
' +
+ '' + esc(waitLabel) + '
' +
+ '
' +
+ 'Code' +
+ '' + esc(start.user_code) + '' +
+ '' +
+ '
' +
+ '
' + esc(authLabel) + ' ↗' +
+ '
';
+ const copyBtn = status.querySelector('.adm-device-auth-copy');
+ if (copyBtn) copyBtn.addEventListener('click', async () => {
+ const code = start.user_code || '';
+ let ok = false;
+ try {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(code);
+ ok = true;
+ }
+ } catch (e) {}
+ if (!ok) {
+ // navigator.clipboard is unavailable in non-secure contexts (HTTP
+ // self-host over a LAN IP), so fall back to execCommand('copy').
+ const ta = document.createElement('textarea');
+ ta.value = code;
+ ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
+ document.body.appendChild(ta);
+ ta.focus();
+ ta.select();
+ try { ta.setSelectionRange(0, code.length); } catch (e) {}
+ try { ok = document.execCommand('copy'); } catch (e) {}
+ ta.remove();
+ }
+ copyBtn.textContent = ok ? 'Copied' : 'Failed';
+ setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
+ });
+}
+
+// ── ChatGPT Subscription per-account usage + reconnect ─────────────────────
+// Each ChatGPT account (ProviderAuthSession) is addressed by its stable
+// auth id; the endpoint id pins which row the action came from. Usage is
+// read-only telemetry: a failed read never disables the endpoint.
+const _chatgptReconnectInflight = new Set();
+
+async function _loadChatgptUsage(container, authId, epId, { refresh = false } = {}) {
+ if (!container || !authId) return;
+ const url = '/api/chatgpt-subscription/accounts/' + encodeURIComponent(authId) + '/usage' + (refresh ? '?refresh=1' : '');
+ let payload = null;
+ try {
+ const res = await fetch(url, { credentials: 'same-origin' });
+ if (res.ok) {
+ try { payload = await res.json(); } catch (_) { payload = null; }
+ } else {
+ let reason = 'upstream';
+ if (res.status === 404) reason = 'upstream';
+ payload = { available: false, reason, account: { auth_id: authId } };
+ }
+ } catch (_) {
+ payload = { available: false, reason: 'network', account: { auth_id: authId } };
+ }
+ if (!payload || typeof payload !== 'object') payload = { available: false, reason: 'malformed', account: { auth_id: authId } };
+ const vm = buildChatgptUsageViewModel(payload);
+ vm.authId = authId;
+ container.innerHTML = renderChatgptUsageCardHtml(vm, { esc, endpointId: epId });
+ const refreshBtn = container.querySelector('[data-adm-chatgpt-usage-refresh]');
+ if (refreshBtn) refreshBtn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ refreshBtn.disabled = true;
+ refreshBtn.textContent = 'Refreshing...';
+ await _loadChatgptUsage(container, refreshBtn.dataset.admChatgptUsageRefresh, refreshBtn.dataset.chatgptEndpointId, { refresh: true });
+ });
+ const reconnectBtn = container.querySelector('[data-adm-chatgpt-reconnect]');
+ if (reconnectBtn) reconnectBtn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ await _reconnectChatgptAccount(container, reconnectBtn.dataset.admChatgptReconnect, reconnectBtn.dataset.chatgptEndpointId);
+ });
+}
+
+async function _reconnectChatgptAccount(container, authId, epId) {
+ if (!container || !authId) return;
+ if (_chatgptReconnectInflight.has(authId)) return;
+ _chatgptReconnectInflight.add(authId);
+ const status = document.createElement('div');
+ status.className = 'adm-chatgpt-reconnect-status';
+ const actions = container.querySelector('.adm-chatgpt-usage-actions');
+ if (actions) actions.replaceWith(status); else container.appendChild(status);
+ status.textContent = 'Starting ChatGPT sign-in...';
+ try {
+ // Reconnect targets exactly this auth session + endpoint. The backend
+ // re-checks ownership on start and on every poll.
+ const formData = new FormData();
+ formData.append('reconnect_auth_id', authId);
+ if (epId) formData.append('reconnect_endpoint_id', epId);
+ const result = await runProviderDeviceFlow('chatgpt-subscription', {
+ openWindow: () => {},
+ formData,
+ onStart: ({ start, authUrl }) => _renderDeviceAuthWaitPanel(status, 'chatgpt-subscription', start, authUrl),
+ });
+ if (result.status === 'authorized') {
+ status.className = 'admin-success';
+ status.textContent = 'Reconnected.';
+ await loadEndpoints();
+ return;
+ }
+ status.className = 'admin-error';
+ status.textContent = result.status === 'expired'
+ ? 'Authorization expired.'
+ : 'Authorization failed (' + (result.error || 'denied') + ').';
+ } catch (e) {
+ status.className = 'admin-error';
+ status.textContent = formatDeviceFlowError(e);
+ } finally {
+ _chatgptReconnectInflight.delete(authId);
+ // Re-render the card so Refresh/Reconnect buttons come back.
+ setTimeout(() => { _loadChatgptUsage(container, authId, epId); }, 1200);
+ }
+}
+
function initEndpointForm() {
const provider = el('adm-epProvider');
const urlInput = el('adm-epUrl');
@@ -997,6 +1138,8 @@ function initEndpointForm() {
const pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
const DEVICE_AUTH_PROVIDER_VALUES = new Set(Object.keys(PROVIDER_DEVICE_FLOWS));
let deviceAuthPolling = false;
+ // True while the URL box is repurposed as the ChatGPT account-label input.
+ let _chatgptLabelMode = false;
function _selectedProviderOption() {
return provider && provider.selectedOptions ? provider.selectedOptions[0] : null;
}
@@ -1018,11 +1161,22 @@ function initEndpointForm() {
const status = el('adm-deviceAuthStatus');
const msg = _endpointMsg('api');
if (deviceAuthConfig) {
- urlInput.value = '';
- urlInput.placeholder = deviceAuthProvider === 'copilot'
- ? 'GitHub Copilot uses GitHub account sign-in'
- : 'ChatGPT Subscription uses OpenAI account sign-in';
- urlInput.readOnly = true;
+ if (deviceAuthProvider === 'chatgpt-subscription') {
+ // The URL box doubles as the optional account label so several
+ // ChatGPT subscriptions stay distinguishable ("ChatGPT · codex00").
+ if (!_chatgptLabelMode) urlInput.value = '';
+ urlInput.placeholder = 'Account label, e.g. codex00 (optional)';
+ urlInput.readOnly = false;
+ urlInput.maxLength = 40;
+ urlInput.setAttribute('aria-label', 'ChatGPT account label (optional)');
+ _chatgptLabelMode = true;
+ } else {
+ _chatgptLabelMode = false;
+ urlInput.value = '';
+ urlInput.placeholder = 'GitHub Copilot uses GitHub account sign-in';
+ urlInput.readOnly = true;
+ urlInput.removeAttribute('maxlength');
+ }
if (apiKey) {
apiKey.value = '';
apiKey.placeholder = 'No API key needed';
@@ -1045,8 +1199,12 @@ function initEndpointForm() {
msg.className = '';
}
} else {
+ if (_chatgptLabelMode) urlInput.value = '';
+ _chatgptLabelMode = false;
urlInput.placeholder = 'Base URL or pick provider';
urlInput.readOnly = false;
+ urlInput.removeAttribute('maxlength');
+ urlInput.setAttribute('aria-label', 'Model endpoint URL');
if (apiKey) {
apiKey.placeholder = 'API key';
apiKey.disabled = false;
@@ -1130,6 +1288,8 @@ function initEndpointForm() {
_setApiFormForProvider();
});
urlInput.addEventListener('input', () => {
+ // Typing an account label must not flip the picker back to "Custom URL".
+ if (_isDeviceAuthSelected()) return;
if (provider.value && urlInput.value.trim() !== provider.value) {
provider.value = '';
if (kindSel) kindSel.value = 'api';
@@ -1372,58 +1532,30 @@ function initEndpointForm() {
status.textContent = `Starting ${config.label} sign-in...`;
try {
+ // New ChatGPT connections carry the optional account label. Only the
+ // label travels: the backend creates fresh auth/endpoint rows and never
+ // reuses another account's credentials.
+ const formData = new FormData();
+ if (providerKey === 'chatgpt-subscription' && _chatgptLabelMode) {
+ const label = (urlInput.value || '').trim();
+ if (label) formData.append('label', label);
+ }
const result = await runProviderDeviceFlow(providerKey, {
openWindow: () => {},
+ formData,
onStart: ({ start, authUrl }) => {
if (triggerEl) triggerEl.textContent = 'Waiting...';
- status.className = '';
- const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI';
- const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...';
- status.innerHTML =
- '' +
- '
' +
- '' + esc(waitLabel) + '
' +
- '
' +
- 'Code' +
- '' + esc(start.user_code) + '' +
- '' +
- '
' +
- '
' + esc(authLabel) + ' ↗' +
- '
';
- const copyBtn = status.querySelector('.adm-device-auth-copy');
- if (copyBtn) copyBtn.addEventListener('click', async () => {
- const code = start.user_code || '';
- let ok = false;
- try {
- if (navigator.clipboard && window.isSecureContext) {
- await navigator.clipboard.writeText(code);
- ok = true;
- }
- } catch (e) {}
- if (!ok) {
- // navigator.clipboard is unavailable in non-secure contexts (HTTP
- // self-host over a LAN IP), so fall back to execCommand('copy').
- const ta = document.createElement('textarea');
- ta.value = code;
- ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
- document.body.appendChild(ta);
- ta.focus();
- ta.select();
- try { ta.setSelectionRange(0, code.length); } catch (e) {}
- try { ok = document.execCommand('copy'); } catch (e) {}
- ta.remove();
- }
- copyBtn.textContent = ok ? 'Copied' : 'Failed';
- setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
- });
+ _renderDeviceAuthWaitPanel(status, providerKey, start, authUrl);
},
});
if (result.status === 'authorized') {
const endpoint = result.endpoint || {};
const n = ((endpoint && endpoint.models) || []).length;
status.className = 'admin-success';
- status.textContent = 'Connected - ' + n + ' ' + config.label + ' model' + (n !== 1 ? 's' : '') + ' available.';
+ const connectedName = endpoint && endpoint.name ? endpoint.name : config.label;
+ status.textContent = 'Connected ' + connectedName + ' - ' + n + ' model' + (n !== 1 ? 's' : '') + ' available.';
if (endpoint && endpoint.id) _recentlyAddedEpId = String(endpoint.id);
+ if (_chatgptLabelMode) urlInput.value = '';
await loadEndpoints();
await _selectAddedModelInChat(endpoint || {});
reset();
diff --git a/static/js/chat.js b/static/js/chat.js
index e8481d59e..cc261615b 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -19,7 +19,7 @@ import searchModule from './search.js';
import documentModule from './document.js?v=20260916docctx2';
import * as emailInbox from './emailInbox.js?v=20260914aireply4';
import codeRunnerModule from './codeRunner.js?v=20260831richtexttools91';
-import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260902tuiharness1';
+import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260921chatgptusage1';
import createResearchSynapse from './researchSynapse.js?v=20260910roundlabels2';
import { createStreamRenderer } from './streamingRenderer.js';
import { createTurnRendering, startsContinuationRound } from './turnRendering.js?v=20260910round1stable1';
diff --git a/static/js/chatgptSubscriptionUsage.js b/static/js/chatgptSubscriptionUsage.js
new file mode 100644
index 000000000..f96b6bf08
--- /dev/null
+++ b/static/js/chatgptSubscriptionUsage.js
@@ -0,0 +1,249 @@
+// DOM-free view-model + renderer for per-account ChatGPT Subscription usage.
+//
+// The backend (/api/chatgpt-subscription/accounts/{auth_id}/usage) returns a
+// normalized, credential-free payload. This module turns it into something the
+// Settings "Added Models" card can render, defensively: unknown buckets are
+// kept, missing windows are tolerated and nothing (reset times, percentages)
+// is ever invented.
+
+function _num(value) {
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
+ if (typeof value === 'string' && value.trim() !== '') {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : null;
+ }
+ return null;
+}
+
+function _clampPercent(value) {
+ const n = _num(value);
+ if (n === null) return null;
+ return Math.max(0, Math.min(100, n));
+}
+
+function _defaultEsc(text) {
+ return String(text == null ? '' : text)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+export function formatDuration(totalSeconds) {
+ const secs = _num(totalSeconds);
+ if (secs === null) return '';
+ if (secs <= 0) return 'now';
+ const days = Math.floor(secs / 86400);
+ const hours = Math.floor((secs % 86400) / 3600);
+ const minutes = Math.floor((secs % 3600) / 60);
+ if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
+ if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
+ if (minutes > 0) return `${minutes}m`;
+ return '<1m';
+}
+
+export function formatResetIn(resetsAt, nowSeconds) {
+ const at = _num(resetsAt);
+ if (at === null || at <= 0) return '';
+ const now = _num(nowSeconds);
+ if (now === null) return '';
+ const delta = at - now;
+ if (delta <= 0) return 'resets now';
+ return `resets in ${formatDuration(delta)}`;
+}
+
+export function formatPercent(value) {
+ const n = _clampPercent(value);
+ if (n === null) return '';
+ const rounded = Math.round(n);
+ return `${rounded}%`;
+}
+
+export function planDisplayName(planType) {
+ if (typeof planType !== 'string' || !planType.trim()) return '';
+ const key = planType.trim().toLowerCase();
+ const known = {
+ free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro', prolite: 'Pro Lite', team: 'Team',
+ business: 'Business', enterprise: 'Enterprise', edu: 'Edu', education: 'Education',
+ guest: 'Guest',
+ };
+ if (known[key]) return known[key];
+ return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+function _windowViewModel(win, nowSeconds) {
+ if (!win || typeof win !== 'object') return null;
+ const used = _clampPercent(win.used_percent);
+ const remaining = used === null ? _clampPercent(win.remaining_percent) : Math.round((100 - used) * 100) / 100;
+ const windowMinutes = _num(win.window_minutes);
+ const resetsAt = _num(win.resets_at);
+ return {
+ kind: typeof win.kind === 'string' ? win.kind : '',
+ name: typeof win.name === 'string' && win.name ? win.name : 'LIMIT',
+ usedPercent: used,
+ remainingPercent: remaining,
+ usedLabel: used === null ? 'usage unknown' : `${formatPercent(used)} used`,
+ remainingLabel: remaining === null ? '' : `${formatPercent(remaining)} remaining`,
+ windowMinutes,
+ resetsAt,
+ resetLabel: formatResetIn(resetsAt, nowSeconds),
+ exhausted: used !== null && used >= 100,
+ };
+}
+
+/**
+ * Build a render-ready view model from the backend usage payload.
+ *
+ * `payload` is the JSON body of the usage route. `nowSeconds` is injected so
+ * reset countdowns are deterministic in tests.
+ */
+export function buildUsageViewModel(payload, nowSeconds = Math.floor(Date.now() / 1000)) {
+ const account = (payload && typeof payload.account === 'object' && payload.account) || {};
+ const base = {
+ authId: typeof account.auth_id === 'string' ? account.auth_id : '',
+ label: typeof account.label === 'string' ? account.label : '',
+ name: typeof account.name === 'string' ? account.name : '',
+ };
+ if (!payload || typeof payload !== 'object' || payload.available !== true || !payload.usage || typeof payload.usage !== 'object') {
+ const reason = payload && typeof payload.reason === 'string' ? payload.reason : 'unavailable';
+ const messages = {
+ reauth: 'Usage unavailable — account may need reconnecting',
+ rate_limited: 'Usage temporarily unavailable (rate limited)',
+ timeout: 'Usage unavailable (timed out)',
+ network: 'Usage unavailable (network)',
+ malformed: 'Usage unavailable (unexpected response)',
+ upstream: 'Usage unavailable',
+ };
+ return {
+ ...base,
+ available: false,
+ reason,
+ reconnectSuggested: !!(payload && payload.reconnect_suggested),
+ message: messages[reason] || 'Usage unavailable',
+ plan: '',
+ limits: [],
+ };
+ }
+ const usage = payload.usage;
+ const limits = [];
+ const rawLimits = Array.isArray(usage.limits) ? usage.limits : [];
+ rawLimits.forEach((limit) => {
+ if (!limit || typeof limit !== 'object') return;
+ const windows = (Array.isArray(limit.windows) ? limit.windows : [])
+ .map((w) => _windowViewModel(w, nowSeconds))
+ .filter(Boolean);
+ const limitId = typeof limit.limit_id === 'string' ? limit.limit_id : '';
+ const limitName = typeof limit.limit_name === 'string' ? limit.limit_name : '';
+ limits.push({
+ limitId,
+ title: limitId === 'codex' ? '' : (limitName || limitId || 'Additional limit'),
+ modelSlug: typeof limit.normal_model_slug === 'string' ? limit.normal_model_slug : '',
+ limitReached: limit.limit_reached === true,
+ windows,
+ });
+ });
+ return {
+ ...base,
+ available: true,
+ reason: '',
+ reconnectSuggested: false,
+ message: '',
+ plan: planDisplayName(usage.plan_type),
+ ordinaryUsageAllowed: typeof usage.ordinary_usage_allowed === 'boolean' ? usage.ordinary_usage_allowed : null,
+ rateLimitReachedType: typeof usage.rate_limit_reached_type === 'string' ? usage.rate_limit_reached_type : '',
+ cached: payload.usage.cached === true,
+ fetchedAt: _num(usage.fetched_at),
+ limits,
+ hasWindows: limits.some((l) => l.windows.length > 0),
+ };
+}
+
+function _barHtml(win, esc) {
+ const used = win.usedPercent === null ? 0 : win.usedPercent;
+ const tone = win.usedPercent === null ? 'unknown' : (used >= 90 ? 'critical' : (used >= 70 ? 'warn' : 'ok'));
+ return (
+ `` +
+ `
` +
+ `${esc(win.name)}` +
+ `${esc(win.remainingLabel || win.usedLabel)}` +
+ `
` +
+ `
` +
+ `` +
+ `
` +
+ `
` +
+ `${esc(win.usedLabel)}` +
+ (win.resetLabel ? `${esc(win.resetLabel)}` : '') +
+ `
` +
+ `
`
+ );
+}
+
+/**
+ * Render the compact usage card body for one account. `esc` is the host
+ * page's HTML escaper (defaults to a local one). Buttons carry data
+ * attributes with the exact auth/endpoint ids so handlers target only this
+ * account.
+ */
+export function renderUsageCardHtml(viewModel, options = {}) {
+ const esc = typeof options.esc === 'function' ? options.esc : _defaultEsc;
+ const vm = viewModel || {};
+ const authId = esc(vm.authId || '');
+ const endpointId = esc(options.endpointId || '');
+ const buttons =
+ `` +
+ `` +
+ `` +
+ `
`;
+ if (!vm.available) {
+ return (
+ `` +
+ `
${esc(vm.message || 'Usage unavailable')}
` +
+ buttons +
+ `
`
+ );
+ }
+ const plan = vm.plan ? `${esc(vm.plan)}` : '';
+ const limitsHtml = (vm.limits || []).map((limit) => {
+ const title = limit.title
+ ? `${esc(limit.title)}${limit.modelSlug ? ` ${esc(limit.modelSlug)}` : ''}
`
+ : '';
+ return `${title}${limit.windows.length ? limit.windows.map((w) => _barHtml(w, esc)).join('') : '
No rate-limit windows reported
'}
`;
+ }).join('');
+ const blocked = vm.ordinaryUsageAllowed === false
+ ? `Usage currently blocked${vm.rateLimitReachedType ? ` (${esc(vm.rateLimitReachedType.replace(/_/g, ' '))})` : ''}
`
+ : '';
+ return (
+ `` +
+ (plan ? `
${plan}
` : '') +
+ blocked +
+ limitsHtml +
+ buttons +
+ `
`
+ );
+}
+
+/** True when an endpoint row from /api/model-endpoints is a ChatGPT account. */
+export function isChatgptSubscriptionEndpoint(ep) {
+ return !!(ep && ep.provider === 'chatgpt-subscription' && ep.provider_auth_id);
+}
+
+/** Owner-visible account title, e.g. "ChatGPT · codex00". */
+export function accountTitle(ep) {
+ if (!ep) return '';
+ if (ep.account_label) return `ChatGPT · ${ep.account_label}`;
+ return ep.name || 'ChatGPT Subscription';
+}
+
+export default {
+ buildUsageViewModel,
+ renderUsageCardHtml,
+ formatResetIn,
+ formatDuration,
+ formatPercent,
+ planDisplayName,
+ isChatgptSubscriptionEndpoint,
+ accountTitle,
+};
diff --git a/static/js/slashAutocomplete.js b/static/js/slashAutocomplete.js
index fb905eee9..3995fe354 100644
--- a/static/js/slashAutocomplete.js
+++ b/static/js/slashAutocomplete.js
@@ -2,7 +2,7 @@
// Lightweight popup that surfaces the existing /command registry as users
// type. Reads COMMANDS from slashCommands.js — no command logic lives here.
-import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js?v=20260902tuiharness1';
+import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js?v=20260921chatgptusage1';
const POPUP_ID = 'slash-autocomplete';
const MAX_VISIBLE = 14;
diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js
index ec4692aa9..d288cb3a1 100644
--- a/static/js/slashCommands.js
+++ b/static/js/slashCommands.js
@@ -5035,16 +5035,22 @@ function _clearSetupCommandInput() {
}
}
-async function _setupProviderDeviceFlow(providerKey) {
+async function _setupProviderDeviceFlow(providerKey, options = {}) {
_clearSetupGuideMessages();
const config = PROVIDER_DEVICE_FLOWS[providerKey];
if (!config) {
await _setupReply('Provider not recognised.');
return;
}
- await _setupReply(`Starting ${config.label} sign-in...`);
+ // `/setup chatgpt-subscription codex00` labels the new account so several
+ // ChatGPT subscriptions stay distinguishable. The label is cosmetic only.
+ const accountLabel = providerKey === 'chatgpt-subscription' ? String(options.label || '').trim().slice(0, 40) : '';
+ const formData = new FormData();
+ if (accountLabel) formData.append('label', accountLabel);
+ await _setupReply(`Starting ${config.label} sign-in${accountLabel ? ` for "${accountLabel}"` : ''}...`);
try {
const result = await runProviderDeviceFlow(providerKey, {
+ formData,
onStart: async ({ start, authUrl }) => {
const place = providerKey === 'copilot' ? 'GitHub' : 'OpenAI';
const action = providerKey === 'copilot' ? 'approve the request' : 'enter the code';
@@ -5067,7 +5073,8 @@ async function _setupProviderDeviceFlow(providerKey) {
});
if (result.status === 'authorized') {
const n = ((result.endpoint && result.endpoint.models) || []).length;
- await _setupReply(`Connected - ${n} ${config.label} model${n !== 1 ? 's' : ''} available.`);
+ const connectedName = (result.endpoint && result.endpoint.name) || config.label;
+ await _setupReply(`Connected ${connectedName} - ${n} model${n !== 1 ? 's' : ''} available.`);
if (modelsModule) modelsModule.refreshModels(true);
return;
}
@@ -5091,7 +5098,7 @@ async function _cmdSetup(args, ctx) {
const topicArgs = args.slice(1);
const deviceAuthProvider = _setupDeviceAuthProviderFromInput(topic);
if (deviceAuthProvider) {
- await _setupProviderDeviceFlow(deviceAuthProvider);
+ await _setupProviderDeviceFlow(deviceAuthProvider, { label: topicArgs.join(' ') });
return true;
}
const provider = _setupProviderFromInput(topic);
diff --git a/static/js/tourAutoplay.js b/static/js/tourAutoplay.js
index 528271e82..0d88cc186 100644
--- a/static/js/tourAutoplay.js
+++ b/static/js/tourAutoplay.js
@@ -8,7 +8,7 @@
// Mobile is excluded — tours position halos by rect math that doesn't fit
// the bottom-sheet layout cleanly.
-import { handleSlashCommand } from './slashCommands.js?v=20260902tuiharness1';
+import { handleSlashCommand } from './slashCommands.js?v=20260921chatgptusage1';
// Modal id → slash command to fire (without the leading "/"). Add to this
// map when a new feature picks up a `tour-*` command.
diff --git a/static/style.css b/static/style.css
index 7d0116f47..80cbe6654 100644
--- a/static/style.css
+++ b/static/style.css
@@ -49548,6 +49548,79 @@ body.theme-frosted .modal {
line-height: 1.4;
color: color-mix(in srgb, var(--fg) 45%, transparent);
}
+/* ChatGPT Subscription per-account usage card (Settings → Added Models) */
+.adm-chatgpt-usage-host {
+ margin: 4px 0 6px 22px;
+}
+.adm-chatgpt-usage {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px 10px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font-size: 11px;
+}
+.adm-chatgpt-usage-plan { display: flex; gap: 6px; align-items: center; }
+.adm-chatgpt-usage-limit { display: flex; flex-direction: column; gap: 6px; }
+.adm-chatgpt-usage-limit-title {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: color-mix(in srgb, var(--fg) 55%, transparent);
+}
+.adm-chatgpt-usage-model {
+ text-transform: none;
+ letter-spacing: 0;
+ font-family: var(--mono, ui-monospace, monospace);
+ opacity: 0.8;
+}
+.adm-chatgpt-usage-row { display: flex; flex-direction: column; gap: 3px; }
+.adm-chatgpt-usage-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+}
+.adm-chatgpt-usage-name {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ color: color-mix(in srgb, var(--fg) 70%, transparent);
+}
+.adm-chatgpt-usage-remaining { font-weight: 600; }
+.adm-chatgpt-usage-bar {
+ position: relative;
+ height: 6px;
+ border-radius: 3px;
+ overflow: hidden;
+ background: color-mix(in srgb, var(--fg) 12%, transparent);
+}
+.adm-chatgpt-usage-fill {
+ display: block;
+ height: 100%;
+ border-radius: 3px;
+ background: var(--accent);
+ transition: width 0.25s ease;
+}
+.adm-chatgpt-usage-warn .adm-chatgpt-usage-fill { background: #d99a2b; }
+.adm-chatgpt-usage-critical .adm-chatgpt-usage-fill { background: #d9534f; }
+.adm-chatgpt-usage-unknown .adm-chatgpt-usage-fill { background: color-mix(in srgb, var(--fg) 25%, transparent); }
+.adm-chatgpt-usage-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 10px;
+ color: color-mix(in srgb, var(--fg) 50%, transparent);
+}
+.adm-chatgpt-usage-status {
+ font-size: 11px;
+ color: color-mix(in srgb, var(--fg) 60%, transparent);
+}
+.adm-chatgpt-usage-blocked { color: #d9534f; }
+.adm-chatgpt-usage-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
+.adm-chatgpt-reconnect-status { font-size: 11px; }
/* Cookbook serve panel: Launch + ^ split button pair */
.hwfit-serve-launch-group {
display: inline-flex;
diff --git a/static/sw.js b/static/sw.js
index 8a61c7e40..89a5b1c89 100644
--- a/static/sw.js
+++ b/static/sw.js
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
-const CACHE_NAME = 'odysseus-v649-large-tool-synthesis-scroll';
+const CACHE_NAME = 'odysseus-v650-chatgpt-multi-account-usage';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -39,7 +39,7 @@ const KATEX_FONTS = [
// exact URL the browser requests, query string included.
const PRECACHE = [
'/',
- '/static/style.css?v=20260914pdfstrip1',
+ '/static/style.css?v=20260921chatgptusage1',
'/static/app.js?v=20260916autoscroll1',
'/static/js/storage.js',
'/static/js/appConfig.js',
@@ -75,9 +75,10 @@ const PRECACHE = [
'/static/js/theme.js?v=20260911organsrain1',
'/static/js/censor.js',
'/static/js/settings.js?v=20260912writingstyle3',
- '/static/js/admin.js?v=20260914toolschemaprofiles1',
+ '/static/js/admin.js?v=20260921chatgptusage1',
+ '/static/js/chatgptSubscriptionUsage.js',
'/static/js/init.js?v=20260829chatstyle12',
- '/static/js/slashCommands.js?v=20260902tuiharness1',
+ '/static/js/slashCommands.js?v=20260921chatgptusage1',
'/static/js/research/jobs.js?v=20260910researcherrorpersist1',
'/static/js/emailInbox.js?v=20260914aireply4',
'/static/js/emailLibrary/utils.js',
diff --git a/tests/test_admin_device_flow_static.py b/tests/test_admin_device_flow_static.py
index d6becde0d..9d3c4bf2b 100644
--- a/tests/test_admin_device_flow_static.py
+++ b/tests/test_admin_device_flow_static.py
@@ -58,14 +58,21 @@ def test_device_auth_selection_disables_and_dims_api_test_button():
def test_device_auth_keeps_manual_auth_button_without_auto_opening_tab():
+ # The waiting panel (code + manual Authorize link) is shared between the
+ # Add Models flow and per-account ChatGPT Reconnect.
+ panel_block = _between(_ADMIN, "function _renderDeviceAuthWaitPanel", "// ── ChatGPT Subscription per-account usage")
auth_block = _between(_ADMIN, "async function _startProviderDeviceAuth", "// Local \"Add\" button")
+ reconnect_block = _between(_ADMIN, "async function _reconnectChatgptAccount", "function initEndpointForm()")
- assert "Authorize with OpenAI" in auth_block
- assert "Authorize on GitHub" in auth_block
- assert "adm-copilot-panel" in auth_block
- assert "adm-device-auth-copy" in auth_block
+ assert "Authorize with OpenAI" in panel_block
+ assert "Authorize on GitHub" in panel_block
+ assert "adm-copilot-panel" in panel_block
+ assert "adm-device-auth-copy" in panel_block
+ assert "_renderDeviceAuthWaitPanel(status, providerKey, start, authUrl)" in auth_block
assert "openWindow: () => {}" in auth_block
assert "A new tab opened" not in auth_block
+ assert "openWindow: () => {}" in reconnect_block
+ assert "_renderDeviceAuthWaitPanel(status, 'chatgpt-subscription', start, authUrl)" in reconnect_block
def test_loud_oauth_copy_and_removed_button_hooks_do_not_return():
diff --git a/tests/test_chatgpt_session_binding.py b/tests/test_chatgpt_session_binding.py
new file mode 100644
index 000000000..a681cadf6
--- /dev/null
+++ b/tests/test_chatgpt_session_binding.py
@@ -0,0 +1,162 @@
+"""Persistent account routing must survive reloads and fail closed on deletion."""
+from types import SimpleNamespace
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy import create_engine, text, inspect
+from sqlalchemy.orm import sessionmaker
+
+import core.database as cdb
+import core.session_manager as sm
+import routes.chat_helpers as helpers
+import routes.chat_routes as chat
+import routes.session_routes as sessions
+from src import endpoint_resolver
+
+BASE = "https://chatgpt.com/backend-api/codex"
+
+
+@pytest.fixture
+def state(monkeypatch):
+ engine = create_engine("sqlite:///:memory:")
+ cdb.Base.metadata.create_all(engine)
+ factory = sessionmaker(bind=engine)
+ for module in (sm, helpers, chat, sessions, endpoint_resolver):
+ monkeypatch.setattr(module, "SessionLocal", factory)
+ with factory() as db:
+ for key, owner in (("a", "alice"), ("b", "alice"), ("c", "bob")):
+ db.add(cdb.ModelEndpoint(id=key, owner=owner, name=key, base_url=BASE,
+ provider_auth_id="auth-" + key, is_enabled=True,
+ cached_models='["gpt-5.5"]', supports_tools=False))
+ db.commit()
+ manager = sm.SessionManager.__new__(sm.SessionManager)
+ manager.sessions = {}
+ manager.upload_handler = None
+ monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime",
+ lambda ep, owner=None: (BASE, "secret-" + ep.id))
+ yield factory, manager
+ engine.dispose()
+
+
+def _session(manager, binding="b"):
+ return manager.create_session("s", "Chat", BASE + "/responses", "gpt-5.5",
+ owner="alice", endpoint_id=binding)
+
+
+def test_binding_survives_metadata_full_reload_and_sync(state):
+ factory, manager = state
+ _session(manager)
+ with factory() as db:
+ db.add(cdb.ChatMessage(id="message", session_id="s", role="user", content="Hello"))
+ db.commit()
+ row = db.get(cdb.Session, "s")
+ assert manager._db_to_session_meta(row).endpoint_id == "b"
+ assert manager._db_to_session(row, db).endpoint_id == "b"
+ row.endpoint_id = "a"
+ db.commit()
+ manager.sync_session_metadata("s")
+ assert manager.sessions["s"].endpoint_id == "a"
+
+
+@pytest.mark.parametrize("disabled", [True, False])
+def test_missing_or_disabled_bound_account_cannot_borrow_only_remaining_sibling(state, disabled):
+ factory, manager = state
+ sess = _session(manager)
+ with factory() as db:
+ endpoint = db.get(cdb.ModelEndpoint, "b")
+ if disabled:
+ endpoint.is_enabled = False
+ else:
+ db.delete(endpoint)
+ db.get(cdb.Session, "s").headers = {"Authorization": "Bearer stale-secret"}
+ db.commit()
+ sess.headers = {"Authorization": "Bearer stale-secret"}
+ helpers.resolve_session_auth(sess, "s", "alice")
+ assert sess.headers == {}
+ assert sess.endpoint_id == "b"
+ assert chat._clear_orphaned_session_endpoint(sess, "alice") is True
+ assert chat._recover_empty_session_model(sess, "s", "alice") is False
+ with factory() as db:
+ assert db.get(cdb.Session, "s").headers == {}
+ assert db.get(cdb.Session, "s").endpoint_id == "b"
+
+
+def test_legacy_binding_is_persisted_even_when_authentication_fails(state, monkeypatch):
+ factory, manager = state
+ sess = _session(manager, None)
+ def unavailable(*args, **kwargs):
+ raise RuntimeError("credentials unavailable")
+ monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", unavailable)
+ helpers.resolve_session_auth(sess, "s", "alice")
+ assert sess.endpoint_id == "a"
+ with factory() as db:
+ assert db.get(cdb.Session, "s").endpoint_id == "a"
+
+
+def test_explicit_selection_switches_same_url_and_model_binding(state):
+ factory, manager = state
+ sess = _session(manager)
+ assert chat._reconcile_selected_route_from_request(None, sess, "s", {
+ "selected_model": "gpt-5.5", "selected_endpoint_id": "a",
+ }, "alice")
+ assert sess.endpoint_id == "a"
+ with factory() as db:
+ assert db.get(cdb.Session, "s").endpoint_id == "a"
+ assert not chat._reconcile_selected_route_from_request(None, sess, "s", {
+ "selected_model": "gpt-5.5", "selected_endpoint_id": "c",
+ }, "alice")
+ assert sess.endpoint_id == "a"
+
+
+def test_url_only_model_change_retains_exact_binding(state):
+ factory, manager = state
+ sess = _session(manager)
+ assert chat._reconcile_selected_route_from_request(None, sess, "s", {
+ "selected_model": "another-model", "selected_endpoint_url": BASE,
+ }, "alice")
+ assert sess.endpoint_id == "b"
+
+
+def test_session_patch_and_unrelated_rename_preserve_binding(state, monkeypatch):
+ factory, manager = state
+ sess = _session(manager)
+ monkeypatch.setattr(sessions, "_verify_session_owner", lambda *args: None)
+ router = sessions.setup_session_routes(manager, {})
+ patch = [r.endpoint for r in router.routes if r.path == "/api/session/{sid}" and "PATCH" in r.methods][-1]
+ request = SimpleNamespace(state=SimpleNamespace(current_user="alice"))
+ kwargs = dict(request=request, sid="s", name=None, folder=None, cwd=None)
+ patch(**kwargs, model="gpt-5.5", endpoint_url=BASE, endpoint_id="a")
+ assert sess.endpoint_id == "a"
+ with factory() as db:
+ assert db.get(cdb.Session, "s").endpoint_id == "a"
+ with pytest.raises(HTTPException):
+ patch(**kwargs, model="gpt-5.5", endpoint_url=BASE, endpoint_id="c")
+ patch(**kwargs, model=None, endpoint_url=None, endpoint_id=None)
+ assert sess.endpoint_id == "a"
+
+
+def test_session_list_reports_bound_account_for_duplicate_model_and_url(state, monkeypatch):
+ factory, manager = state
+ sess = _session(manager)
+ manager.get_sessions_for_user = lambda *args, **kwargs: {"s": sess}
+ router = sessions.setup_session_routes(manager, {})
+ listing = [r.endpoint for r in router.routes if r.path == "/api/sessions" and "GET" in r.methods][-1]
+ rows = listing(SimpleNamespace(state=SimpleNamespace(current_user="alice"), query_params={}))
+ assert rows[0]["endpoint_id"] == "b"
+ assert rows[0]["endpoint_name"] == "b"
+
+
+def test_migration_is_additive_idempotent_and_repairs_missing_index(tmp_path, monkeypatch):
+ engine = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}")
+ with engine.begin() as db:
+ db.execute(text("CREATE TABLE sessions (id VARCHAR PRIMARY KEY, model VARCHAR)"))
+ db.execute(text("INSERT INTO sessions VALUES ('legacy', 'gpt-5.5')"))
+ monkeypatch.setattr(cdb, "engine", engine)
+ cdb._migrate_add_session_endpoint_id_column()
+ cdb._migrate_add_session_endpoint_id_column()
+ with engine.begin() as db:
+ assert db.execute(text("SELECT model, endpoint_id FROM sessions")).one() == ("gpt-5.5", None)
+ db.execute(text("DROP INDEX ix_sessions_endpoint_id"))
+ cdb._migrate_add_session_endpoint_id_column()
+ assert [idx["name"] for idx in inspect(engine).get_indexes("sessions")] == ["ix_sessions_endpoint_id"]
+ engine.dispose()
diff --git a/tests/test_chatgpt_subscription_device_flow_multi_account.py b/tests/test_chatgpt_subscription_device_flow_multi_account.py
new file mode 100644
index 000000000..1cd3183ce
--- /dev/null
+++ b/tests/test_chatgpt_subscription_device_flow_multi_account.py
@@ -0,0 +1,228 @@
+"""Device-flow start/poll semantics for multiple ChatGPT subscriptions.
+
+The pending device-flow state must carry the *intended operation* — create a
+new account, or reconnect one exact existing account — with owner validation,
+and must never carry access/refresh tokens.
+"""
+
+import types
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from core.database import Base, ModelEndpoint, ProviderAuthSession
+import routes.chatgpt_subscription_routes as csr
+from routes.device_flow import PendingDeviceFlowStore
+
+_BASE = "https://chatgpt.com/backend-api/codex"
+
+
+def _mem_db(monkeypatch):
+ engine = create_engine("sqlite:///:memory:")
+ Base.metadata.create_all(bind=engine)
+ TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
+ monkeypatch.setattr(csr, "SessionLocal", TestSessionLocal)
+ return TestSessionLocal
+
+
+def _request(user):
+ return types.SimpleNamespace(state=types.SimpleNamespace(current_user=user), app=None, headers={})
+
+
+def _seed_account(TestSessionLocal, owner, auth_id, ep_id, label="ChatGPT Subscription"):
+ db = TestSessionLocal()
+ try:
+ db.add(ProviderAuthSession(
+ id=auth_id, provider=csr.chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER, owner=owner,
+ label=label, base_url=_BASE, access_token="AT", refresh_token="RT", auth_mode="chatgpt",
+ ))
+ db.add(ModelEndpoint(id=ep_id, name=label, base_url=_BASE, provider_auth_id=auth_id, owner=owner))
+ db.commit()
+ finally:
+ db.close()
+
+
+def _fake_device_code(monkeypatch):
+ monkeypatch.setattr(
+ csr.chatgpt_subscription, "request_device_code",
+ lambda: {"device_auth_id": "dev-1", "user_code": "ABCD-EFGH", "interval": 3, "expires_in": 120},
+ )
+
+
+def test_start_carries_label_and_connect_mode_without_tokens(monkeypatch):
+ _mem_db(monkeypatch)
+ _fake_device_code(monkeypatch)
+ start = csr._start_device_flow(_request("alice"), {"label": " codex00 "})
+ assert start.pending == {
+ "device_auth_id": "dev-1",
+ "user_code": "ABCD-EFGH",
+ "owner": "alice",
+ "label": "codex00",
+ "reconnect_auth_id": None,
+ "reconnect_endpoint_id": None,
+ }
+ assert start.response["mode"] == "connect"
+ assert start.response["account_label"] == "codex00"
+ for value in start.pending.values():
+ assert "access_token" not in str(value) and "refresh_token" not in str(value)
+ assert "access_token" not in start.pending and "refresh_token" not in start.pending
+
+
+def test_start_rejects_overlong_or_duplicate_label(monkeypatch):
+ TestSessionLocal = _mem_db(monkeypatch)
+ _fake_device_code(monkeypatch)
+ with pytest.raises(HTTPException) as exc:
+ csr._start_device_flow(_request("alice"), {"label": "x" * 41})
+ assert exc.value.status_code == 400
+ _seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00")
+ with pytest.raises(HTTPException) as exc:
+ csr._start_device_flow(_request("alice"), {"label": "codex00"})
+ assert exc.value.status_code == 409
+
+
+def test_start_reconnect_carries_exact_target_ids(monkeypatch):
+ TestSessionLocal = _mem_db(monkeypatch)
+ _fake_device_code(monkeypatch)
+ _seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00")
+ start = csr._start_device_flow(
+ _request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"},
+ )
+ assert start.pending["reconnect_auth_id"] == "auth-a"
+ assert start.pending["reconnect_endpoint_id"] == "ep-a"
+ assert start.pending["owner"] == "alice"
+ assert start.response["mode"] == "reconnect"
+
+
+def test_start_reconnect_for_another_owner_is_rejected(monkeypatch):
+ TestSessionLocal = _mem_db(monkeypatch)
+ _fake_device_code(monkeypatch)
+ _seed_account(TestSessionLocal, "alice", "auth-a", "ep-a")
+ with pytest.raises(HTTPException) as exc:
+ csr._start_device_flow(_request("mallory"), {"reconnect_auth_id": "auth-a"})
+ assert exc.value.status_code == 404
+ # Mismatched endpoint id for a real auth id is also rejected.
+ _seed_account(TestSessionLocal, "alice", "auth-b", "ep-b")
+ with pytest.raises(HTTPException) as exc:
+ csr._start_device_flow(_request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-b"})
+ assert exc.value.status_code == 404
+
+
+def test_poll_provisions_with_pending_operation_and_owner(monkeypatch):
+ factory = _mem_db(monkeypatch)
+ _seed_account(factory, "alice", "auth-a", "ep-a")
+ monkeypatch.setattr(
+ csr.chatgpt_subscription, "poll_device_auth",
+ lambda device_auth_id, user_code: {"authorization_code": "code", "code_verifier": "ver"},
+ )
+ monkeypatch.setattr(
+ csr.chatgpt_subscription, "exchange_authorization_code",
+ lambda code, verifier: {"access_token": "AT", "refresh_token": "RT"},
+ )
+ seen = {}
+
+ def fake_provision(tokens, owner, **kwargs):
+ seen["tokens"] = tokens
+ seen["owner"] = owner
+ seen["kwargs"] = kwargs
+ return {"id": "ep-new", "name": "ChatGPT · codex00", "models": ["gpt-5.5"]}
+
+ monkeypatch.setattr(csr, "_provision_endpoint", fake_provision)
+ pending = {
+ "device_auth_id": "dev-1", "user_code": "X", "owner": "alice", "label": "codex00",
+ "reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a",
+ }
+ outcome = csr._poll_device_flow(_request("alice"), pending)
+ assert outcome.status == "authorized"
+ assert seen["owner"] == "alice"
+ assert seen["kwargs"] == {"label": "codex00", "reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"}
+
+
+def test_poll_by_a_different_user_is_rejected(monkeypatch):
+ _mem_db(monkeypatch)
+ called = {"n": 0}
+
+ def _never(*a, **k):
+ called["n"] += 1
+ return {}
+
+ monkeypatch.setattr(csr.chatgpt_subscription, "poll_device_auth", _never)
+ pending = {"device_auth_id": "dev-1", "user_code": "X", "owner": "alice", "label": "", "reconnect_auth_id": None, "reconnect_endpoint_id": None}
+ with pytest.raises(HTTPException) as exc:
+ csr._poll_device_flow(_request("mallory"), pending)
+ assert exc.value.status_code == 403
+ assert called["n"] == 0
+
+
+def test_expired_pending_flow_is_dropped_by_store():
+ clock = {"t": 1000.0}
+ store = PendingDeviceFlowStore(time_func=lambda: clock["t"])
+ poll_id = store.add({"owner": "alice", "reconnect_auth_id": "auth-a"}, interval=5, expires_in=60)
+ assert store.get_payload(poll_id)["reconnect_auth_id"] == "auth-a"
+ clock["t"] += 61
+ assert store.get_payload(poll_id) is None
+ clock["t"] += 3600
+ assert store.get_payload(poll_id) is None
+
+
+def test_poll_revalidates_reconnect_endpoint_after_start(monkeypatch):
+ factory = _mem_db(monkeypatch)
+ _fake_device_code(monkeypatch)
+ _seed_account(factory, "alice", "auth-a", "ep-a")
+ start = csr._start_device_flow(_request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"})
+ with factory() as db:
+ db.delete(db.get(ModelEndpoint, "ep-a"))
+ db.commit()
+ monkeypatch.setattr(csr.chatgpt_subscription, "poll_device_auth", lambda *args: pytest.fail("must reject before polling"))
+ with pytest.raises(HTTPException) as exc:
+ csr._poll_device_flow(_request("alice"), start.pending)
+ assert exc.value.status_code == 404
+
+
+def test_pending_payload_ignores_actual_secrets_and_allows_harmless_label(monkeypatch):
+ _mem_db(monkeypatch)
+ monkeypatch.setattr(csr.chatgpt_subscription, "request_device_code", lambda: {
+ "device_auth_id": "dev", "user_code": "code", "access_token": "SECRET-AT", "refresh_token": "SECRET-RT",
+ })
+ start = csr._start_device_flow(_request("alice"), {"label": "refresh_token"})
+ assert start.pending["label"] == "refresh_token"
+ for payload in (start.pending, start.response):
+ assert "access_token" not in payload and "refresh_token" not in payload
+ assert "SECRET-AT" not in str(payload) and "SECRET-RT" not in str(payload)
+
+
+def test_account_listing_and_usage_route_are_owner_scoped(monkeypatch):
+ TestSessionLocal = _mem_db(monkeypatch)
+ monkeypatch.setattr(csr, "require_admin", lambda request: None)
+ _seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00")
+ _seed_account(TestSessionLocal, "bob", "auth-b", "ep-b", label="ChatGPT · work")
+ router = csr.setup_chatgpt_subscription_routes()
+ handlers = {(r.path, tuple(sorted(r.methods))): r.endpoint for r in router.routes}
+ list_accounts = handlers[("/api/chatgpt-subscription/accounts", ("GET",))]
+ usage = handlers[("/api/chatgpt-subscription/accounts/{auth_id}/usage", ("GET",))]
+
+ alice_accounts = list_accounts(_request("alice"))
+ assert [a["auth_id"] for a in alice_accounts] == ["auth-a"]
+ assert alice_accounts[0]["label"] == "codex00"
+ assert alice_accounts[0]["endpoint_ids"] == ["ep-a"]
+ assert "access_token" not in str(alice_accounts) and "refresh_token" not in str(alice_accounts)
+ assert "AT" not in str(alice_accounts)
+
+ calls = []
+
+ def fake_usage(auth_id, owner=None, force_refresh=False, cache=None):
+ calls.append((auth_id, owner, force_refresh))
+ return {"plan_type": "plus", "limits": [], "auth_id": auth_id}
+
+ monkeypatch.setattr(csr.chatgpt_subscription, "get_account_usage", fake_usage)
+ ok = usage("auth-a", _request("alice"), refresh=True)
+ assert ok["available"] is True
+ assert ok["usage"]["auth_id"] == "auth-a"
+ assert calls == [("auth-a", "alice", True)]
+
+ # Bob's account is invisible to Alice; the usage helper is never invoked.
+ with pytest.raises(HTTPException) as exc:
+ usage("auth-b", _request("alice"))
+ assert exc.value.status_code == 404
+ assert len(calls) == 1
diff --git a/tests/test_chatgpt_subscription_routes.py b/tests/test_chatgpt_subscription_routes.py
index 8661efe37..3f35da339 100644
--- a/tests/test_chatgpt_subscription_routes.py
+++ b/tests/test_chatgpt_subscription_routes.py
@@ -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
diff --git a/tests/test_chatgpt_subscription_usage.py b/tests/test_chatgpt_subscription_usage.py
new file mode 100644
index 000000000..28e3dec0b
--- /dev/null
+++ b/tests/test_chatgpt_subscription_usage.py
@@ -0,0 +1,356 @@
+"""Read-only ChatGPT Subscription usage: contract, normalization, cache, errors.
+
+Contract mirrored from openai/codex ``codex-rs/backend-client`` (ChatGptApi
+path style): ``GET {backend-api}/wham/usage`` returning ``plan_type``,
+``rate_limit{primary_window,secondary_window}``, ``additional_rate_limits[]``,
+``rate_limit_reached_type`` and ``account_id``.
+"""
+
+import json
+
+import httpx
+import pytest
+
+from src import chatgpt_subscription as cs
+
+
+@pytest.fixture
+def owned_accounts(monkeypatch):
+ from sqlalchemy import create_engine
+ from sqlalchemy.orm import sessionmaker
+ import core.database as cdb
+ engine = create_engine("sqlite:///:memory:")
+ cdb.Base.metadata.create_all(engine)
+ factory = sessionmaker(bind=engine)
+ monkeypatch.setattr(cs, "_database_handles", lambda: (cdb.ProviderAuthSession, factory, cdb.utcnow_naive))
+ with factory() as db:
+ for auth_id in ("auth-a", "auth-b"):
+ db.add(cdb.ProviderAuthSession(id=auth_id, provider=cs.CHATGPT_SUBSCRIPTION_PROVIDER, owner="alice", base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL))
+ db.commit()
+ yield factory
+ engine.dispose()
+
+
+def _window(used, seconds, reset_at):
+ return {"used_percent": used, "limit_window_seconds": seconds, "reset_after_seconds": 10, "reset_at": reset_at}
+
+
+_FULL_PAYLOAD = {
+ "plan_type": "plus",
+ "account_id": "acct_123",
+ "user_id": "user_1",
+ "rate_limit": {
+ "allowed": True,
+ "limit_reached": False,
+ "primary_window": _window(71, 300 * 60, 1_800_000_000),
+ "secondary_window": _window(28, 7 * 24 * 3600, 1_800_400_000),
+ },
+ "additional_rate_limits": [
+ {
+ "limit_name": "GPT-5.5 Pro",
+ "metered_feature": "codex_pro",
+ "normal_model_slug": "gpt-5.5-pro",
+ "rate_limit": {"allowed": True, "limit_reached": False, "primary_window": _window(5, 3600, 1_800_001_000)},
+ },
+ {"limit_name": "future", "metered_feature": "codex_future", "rate_limit": None},
+ "garbage",
+ ],
+ "credits": {"has_credits": True, "unlimited": False, "balance": "9.99"},
+ "spend_control": {"reached": False},
+ "rate_limit_reached_type": None,
+ "rate_limit_upsell": {"title": "upgrade"},
+ "some_new_field": {"nested": [1, 2, 3]},
+}
+
+
+def test_usage_url_matches_codex_chatgpt_backend_contract():
+ assert cs.CHATGPT_USAGE_URL == "https://chatgpt.com/backend-api/wham/usage"
+
+
+def test_normalize_primary_secondary_and_additional_buckets():
+ out = cs.normalize_usage_payload(json.loads(json.dumps(_FULL_PAYLOAD)))
+ assert out["plan_type"] == "plus"
+ assert out["account_id"] == "acct_123"
+ assert out["ordinary_usage_allowed"] is True
+ assert out["rate_limit_reached_type"] is None
+ codex, pro, future = out["limits"]
+ assert codex["limit_id"] == "codex" and codex["limit_name"] is None
+ primary, secondary = codex["windows"]
+ assert primary == {
+ "kind": "primary", "name": "5H", "used_percent": 71.0, "remaining_percent": 29.0,
+ "window_minutes": 300, "resets_at": 1_800_000_000, "reset_after_seconds": 10,
+ }
+ assert secondary["kind"] == "secondary"
+ assert secondary["name"] == "WEEK"
+ assert secondary["window_minutes"] == 10080
+ assert secondary["used_percent"] == 28.0 and secondary["remaining_percent"] == 72.0
+ assert pro["limit_id"] == "codex_pro"
+ assert pro["limit_name"] == "GPT-5.5 Pro"
+ assert pro["normal_model_slug"] == "gpt-5.5-pro"
+ assert pro["windows"][0]["name"] == "1H"
+ assert pro["windows"][0]["used_percent"] == 5.0
+ # Unknown/empty additional bucket is kept (not dropped) but has no windows.
+ assert future["limit_id"] == "codex_future" and future["windows"] == []
+ # Unknown top-level fields and raw upstream metadata never leak through.
+ for forbidden in ("user_id", "credits", "spend_control", "rate_limit_upsell", "some_new_field"):
+ assert forbidden not in out
+
+
+def test_normalize_tolerates_absent_windows_and_unknown_reached_type():
+ out = cs.normalize_usage_payload({"plan_type": "pro", "rate_limit": None, "rate_limit_reached_type": {"type": "workspace_owner_credits_depleted"}})
+ assert out["plan_type"] == "pro"
+ assert out["ordinary_usage_allowed"] is None
+ assert out["rate_limit_reached_type"] == "workspace_owner_credits_depleted"
+ assert out["limits"] == [{
+ "limit_id": "codex", "limit_name": None, "normal_model_slug": None,
+ "allowed": None, "limit_reached": None, "windows": [],
+ }]
+ # Missing reset must stay absent — never invented.
+ out2 = cs.normalize_usage_payload({"plan_type": "free", "rate_limit": {"primary_window": {"used_percent": "12.5", "limit_window_seconds": 0}}})
+ win = out2["limits"][0]["windows"][0]
+ assert win["resets_at"] is None and win["window_minutes"] is None and win["name"] == "LIMIT"
+ assert win["used_percent"] == 12.5
+
+
+def test_normalize_clamps_percent_and_rejects_non_object():
+ out = cs.normalize_usage_payload({"rate_limit": {"primary_window": {"used_percent": 250, "limit_window_seconds": 90}}})
+ win = out["limits"][0]["windows"][0]
+ assert win["used_percent"] == 100.0 and win["remaining_percent"] == 0.0
+ assert win["window_minutes"] == 2 # ceil(90/60) like Codex
+ with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
+ cs.normalize_usage_payload(["not", "an", "object"])
+ assert exc.value.reason == "malformed"
+
+
+@pytest.mark.parametrize("minutes,name", [(300, "5H"), (10080, "WEEK"), (20160, "2W"), (1440, "1D"), (90, "90M"), (None, "LIMIT")])
+def test_friendly_window_names_derive_from_duration(minutes, name):
+ assert cs.friendly_window_name(minutes) == name
+
+
+def _fake_get(monkeypatch, *, status=200, body=b"{}", raise_exc=None, seen=None):
+ def fake_get(url, headers=None, timeout=None):
+ if seen is not None:
+ seen.append({"url": url, "headers": dict(headers or {}), "timeout": timeout})
+ if raise_exc is not None:
+ raise raise_exc
+ return httpx.Response(status, content=body, request=httpx.Request("GET", url))
+
+ monkeypatch.setattr(cs.httpx, "get", fake_get)
+
+
+def test_fetch_usage_uses_bearer_and_account_header_and_strict_timeout(monkeypatch):
+ seen = []
+ _fake_get(monkeypatch, body=json.dumps({"plan_type": "plus"}).encode(), seen=seen)
+ # A JWT whose auth claim carries the ChatGPT account id, as Codex reads it.
+ import base64
+ claims = base64.urlsafe_b64encode(json.dumps({"https://api.openai.com/auth": {"chatgpt_account_id": "acct_9"}}).encode()).rstrip(b"=").decode()
+ token = f"hdr.{claims}.sig"
+ data = cs.fetch_usage_payload(token)
+ assert data == {"plan_type": "plus"}
+ assert seen[0]["url"] == "https://chatgpt.com/backend-api/wham/usage"
+ assert seen[0]["headers"]["Authorization"] == f"Bearer {token}"
+ assert seen[0]["headers"]["ChatGPT-Account-Id"] == "acct_9"
+ assert 0 < seen[0]["timeout"] <= 10
+
+
+@pytest.mark.parametrize("status,reason", [(401, "reauth"), (403, "reauth"), (429, "rate_limited"), (500, "upstream"), (503, "upstream"), (418, "upstream")])
+def test_fetch_usage_classifies_http_failures(monkeypatch, status, reason):
+ _fake_get(monkeypatch, status=status, body=b"nope")
+ with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
+ cs.fetch_usage_payload("tok")
+ assert exc.value.reason == reason
+ assert exc.value.status_code == status
+
+
+def test_fetch_usage_handles_timeout_network_and_malformed_json(monkeypatch):
+ _fake_get(monkeypatch, raise_exc=httpx.ReadTimeout("slow"))
+ with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
+ cs.fetch_usage_payload("tok")
+ assert exc.value.reason == "timeout"
+ _fake_get(monkeypatch, raise_exc=httpx.ConnectError("down"))
+ with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
+ cs.fetch_usage_payload("tok")
+ assert exc.value.reason == "network"
+ _fake_get(monkeypatch, body=b"not json")
+ with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
+ cs.fetch_usage_payload("tok")
+ assert exc.value.reason == "malformed"
+ _fake_get(monkeypatch, body=b"[1,2]")
+ with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
+ cs.fetch_usage_payload("tok")
+ assert exc.value.reason == "malformed"
+
+
+def test_usage_cache_is_per_auth_session_and_expires():
+ clock = {"t": 100.0}
+ cache = cs.UsageCache(ttl_seconds=45, time_func=lambda: clock["t"])
+ cache.put("auth-a", {"plan_type": "plus"})
+ assert cache.get("auth-a") == {"plan_type": "plus"}
+ # Cache for A can never satisfy B.
+ assert cache.get("auth-b") is None
+ clock["t"] += 44
+ assert cache.get("auth-a") is not None
+ clock["t"] += 2
+ assert cache.get("auth-a") is None
+
+
+def test_cache_is_bounded_copies_values_and_cleans_expired_accounts():
+ clock = [0]
+ cache = cs.UsageCache(time_func=lambda: clock[0], max_entries=2)
+ original = {"limits": [{"used": 1}]}
+ cache.put("a", original)
+ original["limits"][0]["used"] = 99
+ assert cache.get("a")["limits"][0]["used"] == 1
+ cache.put("b", {})
+ cache.put("c", {})
+ assert cache.get("a") is None
+ clock[0] = 46
+ cache.put("d", {})
+ assert set(cache._entries) == {"d"}
+
+
+@pytest.mark.parametrize("value", [float("nan"), float("inf"), "NaN", "Infinity", True, {}])
+def test_non_finite_and_malformed_usage_never_invents_a_percentage(value):
+ out = cs.normalize_usage_window({"used_percent": value, "reset_at": value}, "primary")
+ assert out["used_percent"] is None
+ assert out["remaining_percent"] is None
+ assert out["resets_at"] is None
+ json.dumps(out, allow_nan=False)
+
+
+def test_future_window_kind_and_reached_kind_are_retained():
+ out = cs.normalize_usage_payload({"rate_limit": {"tertiary_window": _window(3, 7200, 100)},
+ "rate_limit_reached_type": {"kind": "future_limit"}})
+ assert out["limits"][0]["windows"][0]["kind"] == "tertiary"
+ assert out["limits"][0]["windows"][0]["window_minutes"] == 120
+ assert out["rate_limit_reached_type"] == "future_limit"
+
+
+def test_cache_hits_revalidate_owner_and_deleted_auth(owned_accounts):
+ from core.database import ProviderAuthSession
+ cache = cs.UsageCache()
+ cache.put("auth-a", {"plan_type": "plus"})
+ with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
+ cs.get_account_usage("auth-a", owner="mallory", cache=cache)
+ with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
+ cs.get_account_usage("auth-a", owner=None, cache=cache)
+ assert cs.get_account_usage("auth-a", owner="alice", cache=cache)["cached"] is True
+ with owned_accounts() as db:
+ db.delete(db.get(ProviderAuthSession, "auth-a"))
+ db.commit()
+ with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
+ cs.get_account_usage("auth-a", owner="alice", cache=cache)
+
+
+def test_oauth_errors_never_echo_upstream_credentials():
+ response = httpx.Response(401, json={"error": {"code": "invalid_token", "message": "SECRET-AT SECRET-RT"}})
+ with pytest.raises(cs.ChatGPTSubscriptionReauthRequired) as exc:
+ cs._raise_for_oauth_response(response, "token refresh")
+ assert "SECRET" not in str(exc.value)
+
+
+def test_get_account_usage_uses_exact_auth_session_cache_and_force_refresh(monkeypatch, owned_accounts):
+ clock = {"t": 1000.0}
+ cache = cs.UsageCache(ttl_seconds=45, time_func=lambda: clock["t"])
+ resolved = []
+
+ def fake_resolve(auth_id, owner=None, force_refresh=False):
+ resolved.append((auth_id, owner))
+ return {"api_key": f"token-for-{auth_id}", "base_url": cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL}
+
+ fetched = []
+
+ def fake_fetch(access_token, timeout=None):
+ fetched.append(access_token)
+ return {"plan_type": "plus" if access_token.endswith("auth-a") else "pro", "rate_limit": {"primary_window": _window(10, 300, 5)}}
+
+ monkeypatch.setattr(cs, "resolve_runtime_credentials", fake_resolve)
+ monkeypatch.setattr(cs, "fetch_usage_payload", fake_fetch)
+
+ a1 = cs.get_account_usage("auth-a", owner="alice", cache=cache)
+ assert a1["plan_type"] == "plus" and a1["cached"] is False and a1["auth_id"] == "auth-a"
+ assert resolved == [("auth-a", "alice")]
+ assert fetched == ["token-for-auth-a"]
+
+ a2 = cs.get_account_usage("auth-a", owner="alice", cache=cache)
+ assert a2["cached"] is True and a2["plan_type"] == "plus"
+ assert len(fetched) == 1 # served from cache
+
+ b1 = cs.get_account_usage("auth-b", owner="alice", cache=cache)
+ assert b1["plan_type"] == "pro" and b1["cached"] is False
+ assert fetched == ["token-for-auth-a", "token-for-auth-b"] # B never reuses A's cache or token
+
+ a3 = cs.get_account_usage("auth-a", owner="alice", force_refresh=True, cache=cache)
+ assert a3["cached"] is False
+ assert fetched == ["token-for-auth-a", "token-for-auth-b", "token-for-auth-a"]
+
+ # Nothing token-shaped in the returned structure.
+ dumped = json.dumps(a3)
+ assert "token-for" not in dumped and "refresh_token" not in dumped and "access_token" not in dumped
+
+
+def test_get_account_usage_maps_credential_failures_without_touching_endpoint(monkeypatch, owned_accounts):
+ cache = cs.UsageCache(ttl_seconds=45)
+
+ def boom(auth_id, owner=None, force_refresh=False):
+ raise cs.ChatGPTSubscriptionReauthRequired("expired")
+
+ monkeypatch.setattr(cs, "resolve_runtime_credentials", boom)
+ with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
+ cs.get_account_usage("auth-a", owner="alice", cache=cache)
+ assert exc.value.reason == "reauth"
+
+ def missing(auth_id, owner=None, force_refresh=False):
+ raise cs.ChatGPTSubscriptionAuthNotFound("gone")
+
+ monkeypatch.setattr(cs, "resolve_runtime_credentials", missing)
+ with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
+ cs.get_account_usage("auth-x", owner="alice", cache=cache)
+
+
+def test_usage_route_failure_is_reported_safely_and_endpoint_stays_enabled(monkeypatch):
+ import types
+ from sqlalchemy import create_engine
+ from sqlalchemy.orm import sessionmaker
+ from core.database import Base, ModelEndpoint, ProviderAuthSession
+ import routes.chatgpt_subscription_routes as csr
+
+ engine = create_engine("sqlite:///:memory:")
+ Base.metadata.create_all(bind=engine)
+ TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
+ monkeypatch.setattr(csr, "SessionLocal", TestSessionLocal)
+ monkeypatch.setattr(csr, "require_admin", lambda request: None)
+ db = TestSessionLocal()
+ try:
+ db.add(ProviderAuthSession(id="auth-a", provider=cs.CHATGPT_SUBSCRIPTION_PROVIDER, owner="alice", label="ChatGPT · codex00",
+ base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL, access_token="SECRET-AT", refresh_token="SECRET-RT"))
+ db.add(ModelEndpoint(id="ep-a", name="ChatGPT · codex00", base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL,
+ provider_auth_id="auth-a", owner="alice", is_enabled=True, supports_tools=False))
+ db.commit()
+ finally:
+ db.close()
+
+ def failing(auth_id, owner=None, force_refresh=False, cache=None):
+ raise cs.ChatGPTUsageUnavailable("reauth", "rejected", status_code=401)
+
+ monkeypatch.setattr(cs, "get_account_usage", failing)
+ router = csr.setup_chatgpt_subscription_routes()
+ usage = next(r.endpoint for r in router.routes if r.path.endswith("/usage"))
+ request = types.SimpleNamespace(state=types.SimpleNamespace(current_user="alice"), app=None, headers={})
+ payload = usage("auth-a", request)
+ assert payload["available"] is False
+ assert payload["reason"] == "reauth"
+ assert payload["reconnect_suggested"] is True
+ assert payload["account"]["auth_id"] == "auth-a"
+ dumped = json.dumps(payload)
+ assert "SECRET" not in dumped and "access_token" not in dumped and "refresh_token" not in dumped
+
+ db = TestSessionLocal()
+ try:
+ ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-a").first()
+ auth = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth-a").first()
+ # Usage failure != endpoint failure: nothing disabled, nothing destroyed.
+ assert ep.is_enabled is True
+ assert auth.refresh_token == "SECRET-RT"
+ finally:
+ db.close()
diff --git a/tests/test_chatgpt_subscription_usage_js.py b/tests/test_chatgpt_subscription_usage_js.py
new file mode 100644
index 000000000..8c4141eb7
--- /dev/null
+++ b/tests/test_chatgpt_subscription_usage_js.py
@@ -0,0 +1,267 @@
+"""Node-driven tests for the DOM-free ChatGPT usage card module + admin wiring."""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+_REPO = Path(__file__).resolve().parent.parent
+_MODULE = _REPO / "static" / "js" / "chatgptSubscriptionUsage.js"
+_ADMIN = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
+_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
+pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
+
+
+def _run_node(script: str):
+ proc = subprocess.run(
+ ["node", "--input-type=module"], input=script, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
+ )
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout.strip())
+
+
+_PAYLOAD_A = {
+ "available": True,
+ "account": {"auth_id": "auth-a", "label": "codex00", "name": "ChatGPT · codex00"},
+ "usage": {
+ "auth_id": "auth-a", "plan_type": "plus", "account_id": "acct_a", "ordinary_usage_allowed": True,
+ "rate_limit_reached_type": None, "fetched_at": 1_800_000_000, "cached": False,
+ "limits": [
+ {"limit_id": "codex", "limit_name": None, "normal_model_slug": None, "allowed": True, "limit_reached": False,
+ "windows": [
+ {"kind": "primary", "name": "5H", "used_percent": 71, "remaining_percent": 29, "window_minutes": 300, "resets_at": 1_800_000_000 + 2 * 3600 + 14 * 60, "reset_after_seconds": 8040},
+ {"kind": "secondary", "name": "WEEK", "used_percent": 28, "remaining_percent": 72, "window_minutes": 10080, "resets_at": 1_800_000_000 + 4 * 86400 + 18 * 3600, "reset_after_seconds": 1},
+ ]},
+ {"limit_id": "codex_pro", "limit_name": "GPT-5.5 Pro", "normal_model_slug": "gpt-5.5-pro", "allowed": True, "limit_reached": False,
+ "windows": [{"kind": "primary", "name": "1H", "used_percent": 5, "remaining_percent": 95, "window_minutes": 60, "resets_at": None, "reset_after_seconds": None}]},
+ {"limit_id": "mystery", "limit_name": "Mystery", "normal_model_slug": None, "allowed": None, "limit_reached": None, "windows": []},
+ ],
+ },
+}
+_PAYLOAD_B = {
+ "available": True,
+ "account": {"auth_id": "auth-b", "label": "codex01", "name": "ChatGPT · codex01"},
+ "usage": {"plan_type": "pro", "limits": [{"limit_id": "codex", "windows": [{"kind": "primary", "name": "5H", "used_percent": 100, "window_minutes": 300}]}]},
+}
+
+
+def test_view_model_normalizes_windows_and_reset_countdowns():
+ js = f"""
+ import {{ buildUsageViewModel }} from '{_MODULE.as_posix()}';
+ const vm = buildUsageViewModel({json.dumps(_PAYLOAD_A)}, 1800000000);
+ console.log(JSON.stringify(vm));
+ """
+ vm = _run_node(js)
+ assert vm["available"] is True
+ assert vm["authId"] == "auth-a"
+ assert vm["plan"] == "Plus"
+ codex, pro, mystery = vm["limits"]
+ assert codex["title"] == ""
+ primary, secondary = codex["windows"]
+ assert primary["name"] == "5H"
+ assert primary["usedLabel"] == "71% used"
+ assert primary["remainingLabel"] == "29% remaining"
+ assert primary["resetLabel"] == "resets in 2h 14m"
+ assert secondary["name"] == "WEEK"
+ assert secondary["remainingLabel"] == "72% remaining"
+ assert secondary["resetLabel"] == "resets in 4d 18h"
+ # Additional bucket is kept with its own title/model; missing reset is not invented.
+ assert pro["title"] == "GPT-5.5 Pro" and pro["modelSlug"] == "gpt-5.5-pro"
+ assert pro["windows"][0]["resetLabel"] == ""
+ assert mystery["windows"] == []
+
+
+def test_view_model_is_defensive_about_bad_values():
+ payload = {
+ "available": True,
+ "account": {"auth_id": "auth-x"},
+ "usage": {"plan_type": 42, "limits": [
+ {"limit_id": "codex", "windows": [{"kind": "primary", "used_percent": "abc", "window_minutes": "300", "resets_at": "soon"}, None, "str"]},
+ "garbage",
+ {"limit_id": "over", "windows": [{"used_percent": 250, "resets_at": 5}]},
+ ]},
+ }
+ js = f"""
+ import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
+ const vm = buildUsageViewModel({json.dumps(payload)}, 10);
+ const html = renderUsageCardHtml(vm, {{ endpointId: 'ep-x' }});
+ console.log(JSON.stringify({{ vm, html }}));
+ """
+ out = _run_node(js)
+ vm = out["vm"]
+ assert vm["plan"] == ""
+ codex, over = vm["limits"]
+ assert codex["windows"][0]["usedPercent"] is None
+ assert codex["windows"][0]["usedLabel"] == "usage unknown"
+ assert codex["windows"][0]["resetLabel"] == ""
+ assert over["windows"][0]["usedPercent"] == 100
+ assert over["windows"][0]["remainingPercent"] == 0
+ assert over["windows"][0]["resetLabel"] == "resets now"
+ assert 'aria-valuenow' not in out["html"].split('data-usage-limit="over"')[0]
+ assert 'aria-valuenow="100"' in out["html"]
+
+
+def test_unavailable_states_render_message_and_refresh_button():
+ cases = {
+ "reauth": {"available": False, "reason": "reauth", "reconnect_suggested": True, "account": {"auth_id": "auth-a"}},
+ "rate_limited": {"available": False, "reason": "rate_limited", "account": {"auth_id": "auth-a"}},
+ "timeout": {"available": False, "reason": "timeout", "account": {"auth_id": "auth-a"}},
+ "malformed": None,
+ "empty": {},
+ }
+ js = f"""
+ import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
+ const cases = {json.dumps(cases)};
+ const out = {{}};
+ for (const [k, payload] of Object.entries(cases)) {{
+ const vm = buildUsageViewModel(payload, 0);
+ out[k] = {{ vm, html: renderUsageCardHtml(vm, {{ endpointId: 'ep-a' }}) }};
+ }}
+ console.log(JSON.stringify(out));
+ """
+ out = _run_node(js)
+ assert out["reauth"]["vm"]["message"] == "Usage unavailable — account may need reconnecting"
+ assert out["reauth"]["vm"]["reconnectSuggested"] is True
+ assert "rate limited" in out["rate_limited"]["vm"]["message"]
+ assert "timed out" in out["timeout"]["vm"]["message"]
+ assert out["malformed"]["vm"]["available"] is False
+ assert out["empty"]["vm"]["message"] == "Usage unavailable"
+ for case in out.values():
+ assert "adm-chatgpt-usage-unavailable" in case["html"]
+ assert 'data-adm-chatgpt-usage-refresh=' in case["html"]
+ assert ">Refresh usage<" in case["html"]
+
+
+def test_two_account_cards_render_independently_with_exact_ids():
+ js = f"""
+ import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
+ const a = renderUsageCardHtml(buildUsageViewModel({json.dumps(_PAYLOAD_A)}, 1800000000), {{ endpointId: 'ep-a' }});
+ const b = renderUsageCardHtml(buildUsageViewModel({json.dumps(_PAYLOAD_B)}, 1800000000), {{ endpointId: 'ep-b' }});
+ console.log(JSON.stringify({{ a, b }}));
+ """
+ out = _run_node(js)
+ a, b = out["a"], out["b"]
+ assert 'data-adm-chatgpt-usage="auth-a"' in a and 'data-adm-chatgpt-usage="auth-b"' in b
+ assert 'data-adm-chatgpt-usage-refresh="auth-a" data-chatgpt-endpoint-id="ep-a"' in a
+ assert 'data-adm-chatgpt-reconnect="auth-a" data-chatgpt-endpoint-id="ep-a"' in a
+ assert 'data-adm-chatgpt-usage-refresh="auth-b" data-chatgpt-endpoint-id="ep-b"' in b
+ assert 'data-adm-chatgpt-reconnect="auth-b" data-chatgpt-endpoint-id="ep-b"' in b
+ assert "auth-b" not in a and "auth-a" not in b
+ assert ">Plus<" in a and ">Pro<" in b
+ assert "29% remaining" in a and "72% remaining" in a
+ assert "resets in 2h 14m" in a and "resets in 4d 18h" in a
+ assert "GPT-5.5 Pro" in a and "gpt-5.5-pro" in a
+ assert "0% remaining" in b and "adm-chatgpt-usage-critical" in b
+ assert a.count("adm-chatgpt-usage-row") == 3 # 5H + WEEK + additional bucket
+
+
+def test_rendered_html_escapes_and_contains_no_credentials():
+ payload = {
+ "available": True,
+ "account": {"auth_id": "auth-a", "label": "
"},
+ "usage": {"plan_type": "plus", "limits": [{"limit_id": "codex", "limit_name": "