feat(provider): support multiple ChatGPT subscriptions with usage

This commit is contained in:
Alexandre Teixeira
2026-09-22 13:12:19 +01:00
parent 45330097b8
commit ed7ccfd584
31 changed files with 3281 additions and 215 deletions
+20
View File
@@ -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()
+4
View File
@@ -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:
+9 -1
View File
@@ -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
+84 -34
View File
@@ -794,6 +794,52 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
return False
def _endpoint_created_sort_key(ep) -> tuple:
created = getattr(ep, "created_at", None)
try:
ts = float(created.timestamp()) if created else 0.0
except Exception:
ts = 0.0
return (ts, str(getattr(ep, "id", "") or ""))
def _select_session_endpoint(sess, target_url: str, endpoints) -> tuple:
"""Pick the endpoint a session should use for credential resolution.
Two endpoints may share one provider URL but not credentials (e.g. two
ChatGPT Subscription accounts), so an explicit ``sess.endpoint_id`` binding
wins whenever it still matches the session URL. Without a binding the
oldest URL-matching endpoint is chosen deterministically and persisted.
Returns ``(endpoint, bound_by_fallback)``; ``bound_by_fallback`` is True
when the choice came from URL matching and may be persisted as a binding.
"""
matching = [ep for ep in endpoints if _session_url_matches_endpoint(target_url, getattr(ep, "base_url", "") or "")]
if not matching:
return None, False
bound_id = getattr(sess, "endpoint_id", None) or None
if bound_id:
for ep in matching:
if str(ep.id) == str(bound_id):
sess.endpoint_id = ep.id
return ep, False
# The bound endpoint is gone or disabled. Never silently borrow another
# endpoint's credentials when several routes share this URL.
return None, False
matching.sort(key=_endpoint_created_sort_key)
chosen = matching[0]
if len(matching) > 1:
logger.warning(
"Session %s has no endpoint binding and %d endpoints share its URL; using oldest endpoint %s",
getattr(sess, "id", "?"), len(matching), chosen.id,
)
try:
sess.endpoint_id = chosen.id
except Exception:
pass
return chosen, True
def _has_auth_keys(headers) -> bool:
"""True if a headers dict carries an Authorization/x-api-key entry."""
return isinstance(headers, dict) and any(
@@ -803,6 +849,7 @@ def _has_auth_keys(headers) -> bool:
def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
owner = owner or getattr(sess, "owner", None)
try:
from src.chatgpt_subscription import is_chatgpt_subscription_base
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "")
@@ -811,11 +858,22 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
has_auth = _has_auth_keys(sess.headers)
if has_auth and not is_chatgpt_subscription:
return
if is_chatgpt_subscription:
# Never reuse a stale bearer after deletion, disablement or failed refresh.
sess.headers = {}
try:
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
db = SessionLocal()
try:
stored_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
stored_q = stored_q.filter(DBSession.owner == owner)
if is_chatgpt_subscription:
stored = stored_q.first()
if stored is not None and _has_auth_keys(stored.headers):
stored_q.update({"headers": {}})
db.commit()
target_url = getattr(sess, "endpoint_url", "") or ""
if not target_url:
return
@@ -826,44 +884,36 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
# with similar endpoint URLs can borrow each other's API key.
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
for ep in q.all():
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
continue
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception as e:
logger.warning("Failed to resolve provider auth for session %s: %s", session_id, e)
return
if not api_key:
# No usable key (e.g. ChatGPT Subscription needs re-auth).
return
sess.headers = build_headers(api_key, base)
if is_chatgpt_subscription:
# The bearer is short-lived and re-resolved per request, so it
# stays request-local and is never written to the plaintext
# sessions.headers column. Proactively strip any bearer an
# older code path may have persisted so it does not linger.
stale_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
stale_q = stale_q.filter(DBSession.owner == owner)
stored = stale_q.first()
if stored is not None and _has_auth_keys(stored.headers):
stale_q.update({"headers": {}})
db.commit()
logger.info(f"Cleared persisted ChatGPT Subscription bearer from session {session_id}")
logger.debug(f"Resolved request-local ChatGPT Subscription auth for session {session_id}")
return
update_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
update_q = update_q.filter(DBSession.owner == owner)
update_q.update({"headers": sess.headers})
db.commit()
logger.info(f"Resolved and persisted auth headers for session {session_id} from endpoint {ep.name}")
ep, bound_here = _select_session_endpoint(sess, target_url, q.all())
if ep is None:
return
if bound_here:
# Bind before authentication, including failed/expired credentials.
stored_q.filter(DBSession.endpoint_id == None).update({"endpoint_id": ep.id})
db.commit()
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception as e:
logger.warning("Failed to resolve provider auth for session %s: %s", session_id, type(e).__name__)
return
if not api_key:
# No usable key (e.g. ChatGPT Subscription needs re-auth).
return
sess.headers = build_headers(api_key, base)
if is_chatgpt_subscription:
# Request-local only; persistence was cleaned before resolution.
return
update_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
update_q = update_q.filter(DBSession.owner == owner)
update_q.update({"headers": sess.headers})
db.commit()
logger.info(f"Resolved and persisted auth headers for session {session_id} from endpoint {ep.name}")
return
finally:
db.close()
except Exception as e:
logger.warning(f"Failed to resolve session headers: {e}")
logger.warning("Failed to resolve session headers: %s", type(e).__name__)
def _match_cached_model_id(requested: str, models) -> Optional[str]:
+63 -8
View File
@@ -1701,9 +1701,21 @@ def _clear_orphaned_session_endpoint(sess, owner: str | None = None) -> bool:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
bound_id = getattr(sess, "endpoint_id", None)
for ep in endpoints:
if bound_id and ep.id != bound_id:
continue
if _session_url_matches_endpoint(sess.endpoint_url or "", ep.base_url or ""):
return False
if bound_id:
# Keep the identity so re-enabling/reconnecting A can recover A.
# Returning True stops chat; B must never replace a missing A.
sess.headers = {}
stored = db.query(DBSession).filter(DBSession.id == sess.id, DBSession.owner == owner).first()
if stored is not None:
stored.headers = {}
db.commit()
return True
db_session = db.query(DBSession).filter(DBSession.id == sess.id).first()
if db_session:
db_session.endpoint_url = ""
@@ -1803,6 +1815,13 @@ def _first_image_attachment(chat_handler, att_ids: List[str], owner: str | None
return None
def _ts_or_zero(value) -> float:
try:
return float(value.timestamp()) if value else 0.0
except Exception:
return 0.0
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
"""Re-populate sess.model from the matching endpoint's cached models.
@@ -1835,10 +1854,19 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
for cand in endpoints:
if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
ep = cand
break
# Honour the session's exact endpoint binding first: two endpoints
# can share a provider URL (e.g. two ChatGPT Subscription accounts).
bound_id = getattr(sess, "endpoint_id", None) or None
if bound_id:
for cand in endpoints:
if cand.id == bound_id and _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
ep = cand
break
if ep is None and not bound_id:
for cand in sorted(endpoints, key=lambda row: (_ts_or_zero(getattr(row, "created_at", None)), str(row.id))):
if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
ep = cand
break
if not ep:
return False
if not is_chatgpt_subscription:
@@ -1938,6 +1966,7 @@ def _reconcile_selected_route_from_request(
endpoint_url = ""
headers = None
resolved_endpoint_id = None
if selected_endpoint_id or selected_endpoint_url:
try:
from src.auth_helpers import owner_filter
@@ -1949,7 +1978,15 @@ def _reconcile_selected_route_from_request(
q = q.filter(ModelEndpoint.id == selected_endpoint_id)
if owner:
q = owner_filter(q, ModelEndpoint, owner)
candidates = q.all() if selected_endpoint_url and not selected_endpoint_id else [q.first()]
if selected_endpoint_url and not selected_endpoint_id:
candidates = [row for row in q.all() if _session_url_matches_endpoint(selected_endpoint_url, row.base_url or "")]
bound_id = getattr(sess, "endpoint_id", None)
if bound_id:
candidates = [row for row in candidates if row.id == bound_id]
if len(candidates) != 1:
return False
else:
candidates = [q.first()]
ep = None
for cand in candidates:
if not cand:
@@ -1961,6 +1998,7 @@ def _reconcile_selected_route_from_request(
return False
endpoint_url = build_chat_url(normalize_base(ep.base_url or ""))
headers = build_headers(ep.api_key or "", ep.base_url or "") if ep.api_key else {}
resolved_endpoint_id = ep.id
finally:
db.close()
except Exception as e:
@@ -1975,18 +2013,29 @@ def _reconcile_selected_route_from_request(
and endpoint_url == (getattr(sess, "endpoint_url", "") or "")
)
headers_changed = dict(getattr(sess, "headers", None) or {}) != dict(headers or {})
if not route_changed and not headers_changed:
binding_changed = bool(
resolved_endpoint_id
and resolved_endpoint_id != (getattr(sess, "endpoint_id", None) or None)
)
if not route_changed and not headers_changed and not binding_changed:
return False
sess.model = selected_model
sess.endpoint_url = endpoint_url
sess.headers = headers or {}
if resolved_endpoint_id:
sess.endpoint_id = resolved_endpoint_id
elif route_changed:
# The route moved without an explicit endpoint id: drop a stale binding
# rather than keep pointing at an endpoint the session no longer uses.
sess.endpoint_id = None
db = SessionLocal()
try:
db_session = db.query(DBSession).filter(DBSession.id == session_id).first()
if db_session:
db_session.model = selected_model
db_session.endpoint_url = endpoint_url
db_session.endpoint_id = getattr(sess, "endpoint_id", None) or None
db_session.headers = sess.headers or {}
db_session.updated_at = datetime.utcnow()
db.commit()
@@ -2087,6 +2136,10 @@ def setup_chat_routes(
if not supports_user_thinking_toggle(sess.model):
thinking_mode = "off"
owner = effective_user(request)
_reconcile_selected_route_from_request(request, sess, session, {
"selected_model": sess.model,
"selected_endpoint_id": chat_request.selected_endpoint_id,
}, owner=owner)
if _clear_orphaned_session_endpoint(sess, owner=owner):
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
@@ -2102,6 +2155,8 @@ def setup_chat_routes(
if not (getattr(sess, "endpoint_url", "") or "").strip():
raise HTTPException(400, "Selected model endpoint is not configured")
resolve_session_auth(sess, session, owner=owner)
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
# non-streaming path can't be used to bypass).
_enforce_chat_privileges(request, sess)
@@ -2169,7 +2224,7 @@ def setup_chat_routes(
sess.headers,
owner=owner,
policy=foreground_policy,
selected_endpoint_id=chat_request.selected_endpoint_id,
selected_endpoint_id=chat_request.selected_endpoint_id or getattr(sess, "endpoint_id", None),
)
candidate_request_factory = None
selected_context_length = getattr(ctx, "context_length", 0)
@@ -3704,7 +3759,7 @@ def setup_chat_routes(
sess.headers,
owner=_user,
policy=_foreground_policy,
selected_endpoint_id=selected_endpoint_id,
selected_endpoint_id=selected_endpoint_id or getattr(sess, "endpoint_id", None),
)
_chat_request_factory = None
_selected_context_length = getattr(ctx, "context_length", 0)
+334 -47
View File
@@ -1,28 +1,123 @@
"""ChatGPT Subscription device-flow setup routes."""
"""ChatGPT Subscription device-flow setup, multi-account and usage routes.
One Odysseus owner may connect several independent ChatGPT subscriptions. Each
connection is its own ``ProviderAuthSession`` + ``ModelEndpoint`` pair; the
endpoint id decides which account a request is billed to. Labels are cosmetic
only — stable ids drive lookup, reconnect, deletion and usage reads.
"""
import json
import logging
import uuid
from typing import Dict, Optional
from typing import Any, Dict, Optional
from fastapi import HTTPException, Request
from fastapi import HTTPException, Request, Response
from core.database import ModelEndpoint, ProviderAuthSession, SessionLocal, utcnow_naive
from core.middleware import require_admin
from routes.device_flow import (
DeviceFlowPoll,
DeviceFlowStart,
PendingDeviceFlowStore,
create_device_flow_router,
)
from src.auth_helpers import get_current_user
from src.auth_helpers import effective_user, get_current_user
from src import chatgpt_subscription
logger = logging.getLogger(__name__)
_DEVICE_FLOW_STORE = PendingDeviceFlowStore()
_PROVIDER = chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER
def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
def _owner_scope(query, model_cls, owner: Optional[str]):
return query.filter(model_cls.owner == owner)
def _owner_chatgpt_auths(db, owner: Optional[str]):
q = db.query(ProviderAuthSession).filter(ProviderAuthSession.provider == _PROVIDER)
return _owner_scope(q, ProviderAuthSession, owner).order_by(ProviderAuthSession.created_at).all()
def _endpoints_for_auth(db, auth_id: str):
return db.query(ModelEndpoint).filter(ModelEndpoint.provider_auth_id == auth_id).all()
def _display_label(auth, ep) -> str:
"""Display label from persisted metadata (never from credentials)."""
label = chatgpt_subscription.account_label_from_name(getattr(auth, "label", None))
if not label and ep is not None:
label = chatgpt_subscription.account_label_from_name(getattr(ep, "name", None))
return label
def _assert_label_available(db, owner: Optional[str], label: str, *, exclude_auth_id: Optional[str] = None) -> None:
"""Reject a label already used by another ChatGPT account of this owner."""
if not label:
return
for auth in _owner_chatgpt_auths(db, owner):
if exclude_auth_id and auth.id == exclude_auth_id:
continue
existing = _display_label(auth, None)
if not existing:
for ep in _endpoints_for_auth(db, auth.id):
existing = _display_label(auth, ep)
if existing:
break
if chatgpt_subscription.labels_conflict(existing, label):
raise ValueError(f"A ChatGPT subscription labelled '{label}' is already connected.")
def _default_new_label(db, owner: Optional[str]) -> str:
"""Label for a new connection when the user did not supply one.
The first account keeps the legacy unlabelled name so existing single
account setups look unchanged; later accounts get a distinguishable
``account N`` label that is unique for this owner.
"""
existing = _owner_chatgpt_auths(db, owner)
if not existing:
return ""
taken = set()
for auth in existing:
label = _display_label(auth, None)
if not label:
for ep in _endpoints_for_auth(db, auth.id):
label = _display_label(auth, ep)
if label:
break
if label:
taken.add(label.casefold())
n = len(existing) + 1
while f"account {n}".casefold() in taken:
n += 1
return f"account {n}"
def _new_id(db, model_cls) -> str:
for _ in range(8):
candidate = str(uuid.uuid4())[:8]
if db.query(model_cls).filter(model_cls.id == candidate).first() is None:
return candidate
return uuid.uuid4().hex[:12]
def _provision_endpoint(
tokens: Dict,
owner: Optional[str],
*,
label: str = "",
reconnect_auth_id: Optional[str] = None,
reconnect_endpoint_id: Optional[str] = None,
) -> Dict:
"""Create a new ChatGPT account (auth + endpoint) or refresh exactly one.
Without ``reconnect_auth_id`` a brand-new ``ProviderAuthSession`` and
``ModelEndpoint`` are created even when the owner already has other ChatGPT
subscriptions. With it, only that owner-scoped auth row (and its endpoint)
is updated; every other account is left untouched.
"""
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
if not access_token or not refresh_token:
@@ -32,22 +127,28 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
models = chatgpt_subscription.fetch_available_models(access_token)
if not models:
raise ValueError("ChatGPT Subscription connected, but no usable Codex models were discovered for this account.")
label = chatgpt_subscription.normalize_account_label(label)
db = SessionLocal()
try:
auth = (
db.query(ProviderAuthSession)
.filter(
ProviderAuthSession.provider == chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
ProviderAuthSession.owner == owner,
)
.first()
)
if auth is None:
auth = None
if reconnect_auth_id:
auth = chatgpt_subscription.find_owned_auth_session(db, reconnect_auth_id, owner)
if auth is None:
raise chatgpt_subscription.ChatGPTSubscriptionAuthNotFound(
"The ChatGPT subscription being reconnected no longer exists for this user."
)
# A reconnect keeps the existing label unless a new one was given.
if label:
_assert_label_available(db, owner, label, exclude_auth_id=auth.id)
else:
if not label:
label = _default_new_label(db, owner)
_assert_label_available(db, owner, label)
auth = ProviderAuthSession(
id=str(uuid.uuid4())[:8],
provider=chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
id=_new_id(db, ProviderAuthSession),
provider=_PROVIDER,
owner=owner,
label="ChatGPT Subscription",
label=chatgpt_subscription.endpoint_name_for_label(label),
base_url=base,
auth_mode="chatgpt",
)
@@ -57,31 +158,39 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
auth.refresh_token = refresh_token
auth.last_refresh = utcnow_naive()
auth.auth_mode = "chatgpt"
if label:
auth.label = chatgpt_subscription.endpoint_name_for_label(label)
ep = (
db.query(ModelEndpoint)
.filter(
ModelEndpoint.base_url == base,
ModelEndpoint.provider_auth_id == auth.id,
ModelEndpoint.owner == owner,
)
.first()
)
ep = None
if reconnect_auth_id:
ep_q = db.query(ModelEndpoint).filter(ModelEndpoint.provider_auth_id == auth.id)
ep_q = _owner_scope(ep_q, ModelEndpoint, owner)
if reconnect_endpoint_id:
ep = ep_q.filter(ModelEndpoint.id == reconnect_endpoint_id).first()
if ep is None:
raise chatgpt_subscription.ChatGPTSubscriptionAuthNotFound(
"The ChatGPT subscription endpoint no longer exists for this user."
)
else:
ep = ep_q.order_by(ModelEndpoint.created_at).first()
if ep is None:
ep = ModelEndpoint(
id=str(uuid.uuid4())[:8],
name="ChatGPT Subscription",
id=_new_id(db, ModelEndpoint),
name=chatgpt_subscription.endpoint_name_for_label(label),
base_url=base,
model_type="llm",
endpoint_kind="api",
owner=owner,
)
db.add(ep)
ep.name = "ChatGPT Subscription"
if label or not (ep.name or "").strip():
ep.name = chatgpt_subscription.endpoint_name_for_label(label)
ep.base_url = base
ep.api_key = None
ep.provider_auth_id = auth.id
ep.is_enabled = True
# ChatGPT provides inference only. Odysseus is the only agent: no
# provider-native tool schemas are ever sent on this route.
ep.supports_tools = False
ep.model_type = "llm"
ep.endpoint_kind = "api"
@@ -93,10 +202,14 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
"name": ep.name,
"base_url": ep.base_url,
"models": models,
"provider_auth_id": auth.id,
"account_label": _display_label(auth, ep),
"reconnected": bool(reconnect_auth_id),
}
finally:
db.close()
chatgpt_subscription.USAGE_CACHE.invalidate(result["provider_auth_id"])
try:
from routes.model_routes import _invalidate_models_cache
@@ -106,7 +219,53 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
return result
def _start_device_flow(request: Request, _form) -> DeviceFlowStart:
def _form_value(form, key: str) -> str:
try:
value = form.get(key) if form is not None else None
except Exception:
value = None
return str(value).strip() if value is not None else ""
def _start_device_flow(request: Request, form) -> DeviceFlowStart:
owner = effective_user(request) or None
try:
label = chatgpt_subscription.normalize_account_label(_form_value(form, "label"))
except ValueError as exc:
raise HTTPException(400, str(exc))
reconnect_auth_id = _form_value(form, "reconnect_auth_id") or None
reconnect_endpoint_id = _form_value(form, "reconnect_endpoint_id") or None
if reconnect_endpoint_id and not reconnect_auth_id:
raise HTTPException(400, "Reconnect requires an account id")
# Validate the intended operation up front so the user is not sent through
# OAuth for a request that can never be provisioned.
db = SessionLocal()
try:
if reconnect_auth_id:
auth = chatgpt_subscription.find_owned_auth_session(db, reconnect_auth_id, owner)
if auth is None:
raise HTTPException(404, "ChatGPT subscription account not found")
if reconnect_endpoint_id:
ep_q = db.query(ModelEndpoint).filter(
ModelEndpoint.id == reconnect_endpoint_id,
ModelEndpoint.provider_auth_id == auth.id,
)
if _owner_scope(ep_q, ModelEndpoint, owner).first() is None:
raise HTTPException(404, "ChatGPT subscription endpoint not found")
if label:
try:
_assert_label_available(db, owner, label, exclude_auth_id=auth.id)
except ValueError as exc:
raise HTTPException(409, str(exc))
else:
try:
_assert_label_available(db, owner, label)
except ValueError as exc:
raise HTTPException(409, str(exc))
finally:
db.close()
try:
data = chatgpt_subscription.request_device_code()
except Exception as exc:
@@ -116,37 +275,82 @@ def _start_device_flow(request: Request, _form) -> DeviceFlowStart:
user_code = data.get("user_code")
if not device_auth_id or not user_code:
raise HTTPException(502, "ChatGPT did not return a complete device code")
verification_uri = data.get("verification_uri") or f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device"
# Never pass an arbitrary upstream URL into an authorization link.
from urllib.parse import urlsplit
fallback_uri = f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device"
verification_uri = data.get("verification_uri") or fallback_uri
try:
parsed = urlsplit(verification_uri)
if parsed.scheme != "https" or parsed.netloc != "auth.openai.com":
verification_uri = fallback_uri
except (TypeError, ValueError):
verification_uri = fallback_uri
# The pending payload carries only what provisioning needs: the device
# handle, the owner and the intended operation. No access/refresh tokens.
pending: Dict[str, Any] = {
"device_auth_id": device_auth_id,
"user_code": user_code,
"owner": owner,
"label": label,
"reconnect_auth_id": reconnect_auth_id,
"reconnect_endpoint_id": reconnect_endpoint_id,
}
response: Dict[str, Any] = {
"user_code": user_code,
"verification_uri": verification_uri,
"mode": "reconnect" if reconnect_auth_id else "connect",
}
if label:
response["account_label"] = label
return DeviceFlowStart(
pending={
"device_auth_id": device_auth_id,
"user_code": user_code,
"owner": get_current_user(request) or None,
},
response={
"user_code": user_code,
"verification_uri": verification_uri,
},
pending=pending,
response=response,
interval=int(data.get("interval") or 5),
expires_in=int(data.get("expires_in") or 900),
)
def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
def _poll_device_flow(request: Request, pending: Dict) -> DeviceFlowPoll:
# The poller must be the same user who started the flow: a poll id is not
# a bearer for provisioning into someone else's account list.
current_owner = effective_user(request) or None
if (pending.get("owner") or None) != current_owner:
raise HTTPException(403, "This sign-in belongs to another user")
if pending.get("reconnect_auth_id"):
db = SessionLocal()
try:
auth = chatgpt_subscription.find_owned_auth_session(db, pending["reconnect_auth_id"], current_owner)
if auth is None:
raise HTTPException(404, "ChatGPT subscription account not found")
if pending.get("reconnect_endpoint_id"):
ep = _owner_scope(db.query(ModelEndpoint).filter(
ModelEndpoint.id == pending["reconnect_endpoint_id"],
ModelEndpoint.provider_auth_id == auth.id,
), ModelEndpoint, current_owner).first()
if ep is None:
raise HTTPException(404, "ChatGPT subscription endpoint not found")
finally:
db.close()
try:
data = chatgpt_subscription.poll_device_auth(pending["device_auth_id"], pending["user_code"])
except Exception as exc:
logger.debug("ChatGPT device poll failed: %s", exc)
return DeviceFlowPoll.pending(str(exc))
logger.debug("ChatGPT device poll failed: %s", type(exc).__name__)
return DeviceFlowPoll.pending("Sign-in status temporarily unavailable")
authorization_code = data.get("authorization_code")
code_verifier = data.get("code_verifier")
if authorization_code and code_verifier:
try:
tokens = chatgpt_subscription.exchange_authorization_code(authorization_code, code_verifier)
result = _provision_endpoint(tokens, pending["owner"])
result = _provision_endpoint(
tokens,
pending.get("owner"),
label=pending.get("label") or "",
reconnect_auth_id=pending.get("reconnect_auth_id") or None,
reconnect_endpoint_id=pending.get("reconnect_endpoint_id") or None,
)
except Exception as exc:
logger.exception("ChatGPT Subscription endpoint provisioning failed")
logger.warning("ChatGPT Subscription endpoint provisioning failed: %s", type(exc).__name__)
raise chatgpt_subscription.to_http_exception(exc)
return DeviceFlowPoll.authorized(result)
@@ -157,14 +361,97 @@ def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None)
if err in ("expired_token", "access_denied", "denied"):
return DeviceFlowPoll.failed(err)
return DeviceFlowPoll.pending(err or "unknown")
return DeviceFlowPoll.pending("unknown")
# ── Account listing / usage ─────────────────────────────────────────────────
def _account_summary(auth, endpoints) -> Dict[str, Any]:
ep = endpoints[0] if endpoints else None
return {
"auth_id": auth.id,
"label": _display_label(auth, ep),
"name": (ep.name if ep is not None else None) or auth.label or chatgpt_subscription.CHATGPT_SUBSCRIPTION_LEGACY_NAME,
"endpoint_ids": [row.id for row in endpoints],
"connected_at": auth.created_at.isoformat() if getattr(auth, "created_at", None) else None,
"last_refresh": auth.last_refresh.isoformat() if getattr(auth, "last_refresh", None) else None,
"connected": bool(auth.refresh_token),
}
def usage_error_payload(exc: chatgpt_subscription.ChatGPTUsageUnavailable) -> Dict[str, Any]:
"""Safe, credential-free payload for a failed usage read."""
return {
"available": False,
"reason": exc.reason,
"message": str(exc),
"status_code": exc.status_code,
"reconnect_suggested": exc.reason == "reauth",
}
def _load_owned_account(request: Request, auth_id: str):
"""Admin gate + owner scope for account-level operations."""
require_admin(request)
owner = effective_user(request) or get_current_user(request) or None
db = SessionLocal()
try:
auth = chatgpt_subscription.find_owned_auth_session(db, auth_id, owner)
if auth is None:
raise HTTPException(404, "ChatGPT subscription account not found")
endpoints = _endpoints_for_auth(db, auth.id)
return owner, _account_summary(auth, endpoints)
finally:
db.close()
def setup_chatgpt_subscription_routes():
return create_device_flow_router(
router = create_device_flow_router(
prefix="/api/chatgpt-subscription",
tags=["chatgpt-subscription"],
store=_DEVICE_FLOW_STORE,
start_flow=_start_device_flow,
poll_flow=_poll_device_flow,
)
@router.get("/accounts")
def list_accounts(request: Request):
require_admin(request)
owner = effective_user(request) or get_current_user(request) or None
db = SessionLocal()
try:
return [
_account_summary(auth, _endpoints_for_auth(db, auth.id))
for auth in _owner_chatgpt_auths(db, owner)
]
finally:
db.close()
@router.get("/accounts/{auth_id}/usage")
def account_usage(auth_id: str, request: Request, refresh: bool = False, response: Response = None):
"""Read-only, owner-scoped usage for exactly one ChatGPT account.
Failures here are telemetry failures only: the model endpoint is never
disabled, credentials are never destroyed and no reconnect is started.
"""
if response is not None:
response.headers["Cache-Control"] = "no-store"
owner, account = _load_owned_account(request, auth_id)
try:
usage = chatgpt_subscription.get_account_usage(account["auth_id"], owner=owner, force_refresh=refresh)
except chatgpt_subscription.ChatGPTSubscriptionAuthNotFound:
raise HTTPException(404, "ChatGPT subscription account not found")
except chatgpt_subscription.ChatGPTUsageUnavailable as exc:
payload = usage_error_payload(exc)
payload["account"] = account
return payload
except Exception as exc: # pragma: no cover - defensive
logger.warning("ChatGPT usage read failed for auth %s: %s", auth_id, type(exc).__name__)
payload = usage_error_payload(
chatgpt_subscription.ChatGPTUsageUnavailable("upstream", "ChatGPT usage is unavailable.")
)
payload["account"] = account
return payload
return {"available": True, "account": account, "usage": usage}
return router
+4 -2
View File
@@ -315,6 +315,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
"history": history_dict,
"model": db_session.model,
"endpoint_url": db_session.endpoint_url,
"endpoint_id": getattr(db_session, "endpoint_id", None),
"name": db_session.name,
"offset": page_offset,
"limit": page_limit,
@@ -378,6 +379,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
"history": history_dict,
"model": session.model,
"endpoint_url": session.endpoint_url,
"endpoint_id": getattr(session, "endpoint_id", None),
"name": session.name,
}
@@ -802,6 +804,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
model=source.model,
rag=False,
owner=getattr(source, 'owner', None),
endpoint_id=getattr(source, 'endpoint_id', None),
)
# Copy messages up to keep_count
@@ -820,8 +823,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
except Exception:
logger.debug("session_created event dispatch failed", exc_info=True)
from src.model_profiles import supports_user_thinking_toggle
thinking_supported = supports_user_thinking_toggle(session.model)
return {
"status": "ok",
"id": new_id,
@@ -882,6 +883,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
"session_id": session_id,
"model": session.model,
"endpoint_url": session.endpoint_url,
"endpoint_id": getattr(session, "endpoint_id", None),
"used_tokens": used,
"context_length": ctx_len,
"context_percent": pct,
+77 -20
View File
@@ -695,6 +695,11 @@ def _delete_orphaned_provider_auth(db, auth_id: Optional[str], exclude_ep_id: Op
if auth_row is None:
return False
db.delete(auth_row)
try:
from src.chatgpt_subscription import USAGE_CACHE
USAGE_CACHE.invalidate(auth_id)
except Exception:
pass
return True
@@ -738,7 +743,7 @@ def _resolve_probe_key(ep) -> Optional[str]:
_base, key = resolve_endpoint_runtime(ep, owner=getattr(ep, "owner", None))
return key
except Exception as exc:
logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), exc)
logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), type(exc).__name__)
return None
@@ -1410,6 +1415,35 @@ def _picker_models_for_endpoint(ep, base_url: str, kind: str):
), pinned
def _chatgpt_endpoint_visible(ep: Any, request: Request) -> bool:
from src.chatgpt_subscription import is_chatgpt_subscription_base
if not is_chatgpt_subscription_base(getattr(ep, "base_url", "") or ""):
return True
try:
user = effective_user(request)
except AttributeError:
user = getattr(getattr(request, "state", None), "current_user", None)
return (getattr(ep, "owner", None) or None) == (user or None)
def _provider_account_metadata(ep: Any) -> Dict[str, Any]:
"""Non-secret account metadata for session-backed provider endpoints."""
auth_id = getattr(ep, "provider_auth_id", None)
if not auth_id:
return {"provider_auth_id": None, "provider": None, "account_label": None}
base = getattr(ep, "base_url", "") or ""
provider = None
account_label = None
try:
from src.chatgpt_subscription import account_label_from_name, is_chatgpt_subscription_base
if is_chatgpt_subscription_base(base):
provider = "chatgpt-subscription"
account_label = account_label_from_name(getattr(ep, "name", None)) or None
except Exception:
provider = None
return {"provider_auth_id": auth_id, "provider": provider, "account_label": account_label}
def _api_key_fingerprint(api_key: Optional[str]) -> str:
"""Stable, non-secret label for distinguishing same-URL credentials."""
key = (api_key or "").strip()
@@ -1444,8 +1478,10 @@ def setup_model_routes(model_discovery):
_REFRESH_FAILURE_BASE = 300.0
_REFRESH_FAILURE_MAX = 3600.0
def _refresh_key(base: str, api_key: Optional[str]) -> str:
return f"{base.rstrip('/')}\x00{api_key or ''}"
def _refresh_key(base: str, api_key: Optional[str], provider_auth_id: Optional[str] = None) -> str:
# Session-backed endpoints carry no static key; include their auth id
# so two accounts on one provider URL never share refresh state.
return f"{base.rstrip('/')}\x00{api_key or ''}\x00{provider_auth_id or ''}"
def _ts(value: Any) -> float:
try:
@@ -1466,7 +1502,7 @@ def setup_model_routes(model_discovery):
category = _classify_endpoint(base, kind)
mode = _endpoint_refresh_mode(ep, kind)
cached = _cached_model_ids(ep)
key = _refresh_key(base, getattr(ep, "api_key", None))
key = _refresh_key(base, getattr(ep, "api_key", None), getattr(ep, "provider_auth_id", None))
state = _refresh_state.get(key, {})
info = {
@@ -1528,9 +1564,10 @@ def setup_model_routes(model_discovery):
ok, info = _should_refresh_endpoint(ep, now, force=force)
if not ok:
continue
credential = _resolve_probe_key(ep)
groups.setdefault(info["key"], {
"base": info["base"],
"api_key": info["api_key"],
"api_key": credential,
"timeout": info["timeout"],
"endpoint_ids": [],
})["endpoint_ids"].append(info["id"])
@@ -1606,6 +1643,9 @@ def setup_model_routes(model_discovery):
db.close()
for ep in endpoints:
from src.chatgpt_subscription import is_chatgpt_subscription_base
if is_chatgpt_subscription_base(ep.base_url or "") and (ep.owner or None) != (owner or None):
continue
base = _normalize_base(ep.base_url)
provider = _safe_detect_provider(base)
ep_model_type = getattr(ep, "model_type", None) or "llm"
@@ -1858,8 +1898,8 @@ def setup_model_routes(model_discovery):
# Cache endpoint lookups
if ep_id and ep_id not in endpoints_cache:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if ep:
endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": ep.api_key}
if ep and _chatgpt_endpoint_visible(ep, request):
endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": _resolve_probe_key(ep)}
ep_data = endpoints_cache.get(ep_id)
if not ep_data:
# Try to find by base_url from the model's endpoint field
@@ -1894,11 +1934,13 @@ def setup_model_routes(model_discovery):
# Detach from session
ep_data = []
for ep in endpoints:
if not _chatgpt_endpoint_visible(ep, request):
continue
ep_data.append({
"id": ep.id,
"name": ep.name,
"base_url": ep.base_url,
"api_key": ep.api_key,
"api_key": _resolve_probe_key(ep),
})
finally:
db.close()
@@ -1983,6 +2025,8 @@ def setup_model_routes(model_discovery):
results = []
upgraded_legacy_pins = False
for r in rows:
if not _chatgpt_endpoint_visible(r, request):
continue
all_models = _cached_model_ids(r)
hidden = _hidden_model_ids(r)
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
@@ -2022,6 +2066,7 @@ def setup_model_routes(model_discovery):
"model_refresh_mode": _endpoint_refresh_mode(r, kind),
"model_refresh_interval": getattr(r, "model_refresh_interval", None),
"model_refresh_timeout": getattr(r, "model_refresh_timeout", None),
**_provider_account_metadata(r),
})
if upgraded_legacy_pins:
db.commit()
@@ -2096,6 +2141,8 @@ def setup_model_routes(model_discovery):
existing = None
_empty_key_existing = None
for _candidate in _same_url_rows:
if getattr(_candidate, "provider_auth_id", None):
continue # OAuth account mutations require its explicit identity.
_candidate_key = (getattr(_candidate, "api_key", None) or "").strip()
if _candidate_key == _incoming_api_key:
existing = _candidate
@@ -2192,6 +2239,9 @@ def setup_model_routes(model_discovery):
try:
_st_raw = (supports_tools or "").strip().lower()
_st = True if _st_raw in ("true", "1", "yes") else (False if _st_raw in ("false", "0", "no") else None)
from src.chatgpt_subscription import is_chatgpt_subscription_base
if is_chatgpt_subscription_base(base_url):
_st = False
_pinned = _normalize_model_ids(pinned_models)
# Stamp owner so the picker only shows this endpoint to the admin
# who added it. Pass `shared=true` to mark it null-owner (visible
@@ -2305,9 +2355,9 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep:
if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found")
ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": ep.api_key}
ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": _resolve_probe_key(ep)}
finally:
db.close()
@@ -2361,7 +2411,7 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep:
if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found")
hidden = _hidden_model_ids(ep)
all_models = _cached_model_ids(ep)
@@ -2372,7 +2422,7 @@ def setup_model_routes(model_discovery):
category = _classify_endpoint(base, kind)
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
try:
probed = _probe_endpoint(base, ep.api_key, timeout=timeout)
probed = _probe_endpoint(base, _resolve_probe_key(ep), timeout=timeout)
except Exception as exc:
logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc)
probed = []
@@ -2416,7 +2466,7 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep:
if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found")
body = await request.json()
if not isinstance(body, dict):
@@ -2573,7 +2623,7 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep:
if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found")
if body:
if "supports_tools" in body:
@@ -2627,6 +2677,9 @@ def setup_model_routes(model_discovery):
ep.base_url = _new_base
else:
ep.is_enabled = not ep.is_enabled
from src.chatgpt_subscription import is_chatgpt_subscription_base
if is_chatgpt_subscription_base(ep.base_url or ""):
ep.supports_tools = False
db.commit()
_invalidate_models_cache()
_local_probe_cache["data"] = None
@@ -2684,7 +2737,7 @@ def setup_model_routes(model_discovery):
}
return sess in variants or sess.startswith(base + "/")
def _clear_sessions_for_endpoint(db, base_url: str) -> int:
def _clear_sessions_for_endpoint(db, base_url: str, endpoint_id: str, owner) -> int:
"""Drop stored auth for sessions using an endpoint being deleted.
Keep the session's endpoint URL and model intact. If the admin is
@@ -2694,15 +2747,17 @@ def setup_model_routes(model_discovery):
matching enabled endpoint exists.
"""
cleared = 0
rows = db.query(DbSession).filter(DbSession.endpoint_url.isnot(None)).all()
rows = db.query(DbSession).filter(DbSession.endpoint_url.isnot(None), DbSession.owner == owner).all()
for row in rows:
if getattr(row, "endpoint_id", None) not in (None, endpoint_id):
continue
if _session_uses_endpoint_url(row.endpoint_url or "", base_url):
row.headers = {}
row.updated_at = datetime.utcnow()
cleared += 1
return cleared
def _clear_loaded_sessions_for_endpoint(base_url: str) -> int:
def _clear_loaded_sessions_for_endpoint(base_url: str, endpoint_id: str, owner) -> int:
try:
from src.ai_interaction import get_session_manager
manager = get_session_manager()
@@ -2713,6 +2768,8 @@ def setup_model_routes(model_discovery):
cleared = 0
try:
for sess in list(getattr(manager, "sessions", {}).values()):
if getattr(sess, "owner", None) != owner or getattr(sess, "endpoint_id", None) not in (None, endpoint_id):
continue
if _session_uses_endpoint_url(getattr(sess, "endpoint_url", "") or "", base_url):
sess.headers = {}
cleared += 1
@@ -2732,13 +2789,13 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep:
if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found")
# Clean up any settings that reference this endpoint
cleared = _clear_settings_for_endpoint(ep_id)
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url)
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url)
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url, ep.id, ep.owner)
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url, ep.id, ep.owner)
auth_id = getattr(ep, "provider_auth_id", None)
db.delete(ep)
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
+16 -3
View File
@@ -455,13 +455,17 @@ def setup_session_routes(
else (s.created_at.isoformat() if s.created_at else None))
)
matches = endpoint_routes.get((s.endpoint_url or '').rstrip('/'), [])
selected_endpoint = matches[0] if len(matches) == 1 else None
bound_id = getattr(s, "endpoint_id", None)
selected_endpoint = (
next((ep for ep in matches if ep.id == bound_id), None)
if bound_id else (matches[0] if len(matches) == 1 else None)
)
sessions.append({
"id": s.id,
"name": s.name,
"model": _public_model(s.name, s.model),
"endpoint_url": s.endpoint_url,
"endpoint_id": selected_endpoint.id if selected_endpoint else None,
"endpoint_id": bound_id or (selected_endpoint.id if selected_endpoint else None),
"endpoint_name": selected_endpoint.name if selected_endpoint else None,
"rag": s.rag,
"archived": s.archived,
@@ -590,6 +594,7 @@ def setup_session_routes(
rag=str(rag).lower() == "true" if rag else False,
owner=user,
cwd=cwd or None,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
)
# Set auth headers for custom API-key endpoints
resolved_key = request_api_key
@@ -597,7 +602,8 @@ def setup_session_routes(
if not resolved_key and endpoint_api_key:
resolved_key = endpoint_api_key
resolved_base = endpoint_base_url
if resolved_key:
from src.chatgpt_subscription import is_chatgpt_subscription_base
if resolved_key and not is_chatgpt_subscription_base(endpoint_url):
from src.endpoint_resolver import build_headers
session.headers = build_headers(resolved_key, resolved_base)
_persist_session_headers(sid, session.headers)
@@ -685,8 +691,14 @@ def setup_session_routes(
endpoint_url = build_chat_url(normalize_base(endpoint_base_url))
finally:
_db.close()
previous_url = session.endpoint_url
session.model = model
session.endpoint_url = endpoint_url
# A registered endpoint id pins the exact route; a raw URL switch
# (admin only) clears any previous binding.
session.endpoint_id = (endpoint_id or "").strip() or (
getattr(session, "endpoint_id", None) if endpoint_url == previous_url else None
)
# Update auth headers from the endpoint's stored API key
if endpoint_api_key:
from src.endpoint_resolver import build_headers
@@ -700,6 +712,7 @@ def setup_session_routes(
if db_session:
db_session.model = model
db_session.endpoint_url = endpoint_url
db_session.endpoint_id = session.endpoint_id
db_session.headers = session.headers or {}
db_session.updated_at = utcnow_naive()
db.commit()
+410 -10
View File
@@ -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]:
+10 -1
View File
@@ -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]
+66 -10
View File
@@ -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)
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -246,7 +246,7 @@
real request is discarded and the font fetched a second time. -->
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/style.css?v=20260914taskbutton1">
<link rel="stylesheet" href="/static/style.css?v=20260921chatgptusage1">
<link rel="modulepreload" href="/static/app.js?v=20260916autoscroll1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
+179 -47
View File
@@ -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 `
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
@@ -661,7 +668,7 @@ async function loadEndpoints() {
<span class="adm-check-dot adm-endpoint-select-dot" aria-hidden="true"></span>
</label>` : ''}
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
<span class="admin-user-name">${esc(ep.name)}</span>
<span class="admin-user-name">${esc(isChatgptAccount ? chatgptAccountTitle(ep) : ep.name)}</span>
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
${statusBadge}
@@ -675,6 +682,7 @@ async function loadEndpoints() {
${hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : ''}`}
</div>
</div>
${isChatgptAccount ? `<div class="adm-chatgpt-usage-host" data-adm-chatgpt-usage-host="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"><div class="adm-chatgpt-usage adm-chatgpt-usage-loading"><div class="adm-chatgpt-usage-status">Loading usage...</div></div></div>` : ''}
<div class="admin-ep-detail">${esc(ep.base_url)}${category === 'local' ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${keyLabel}</div>
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
</div>`;
@@ -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 =
'<div class="adm-copilot-panel">' +
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
'<span>' + esc(waitLabel) + '</span></div>' +
'<div class="adm-copilot-coderow">' +
'<span class="adm-copilot-code-label">Code</span>' +
'<code class="adm-copilot-code">' + esc(start.user_code) + '</code>' +
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
'</div>' +
'<a class="admin-btn-add adm-copilot-auth" href="' + esc(authUrl || '') + '" target="_blank" rel="noopener">' + esc(authLabel) + ' ↗</a>' +
'</div>';
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 =
'<div class="adm-copilot-panel">' +
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
'<span>' + esc(waitLabel) + '</span></div>' +
'<div class="adm-copilot-coderow">' +
'<span class="adm-copilot-code-label">Code</span>' +
'<code class="adm-copilot-code">' + esc(start.user_code) + '</code>' +
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
'</div>' +
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl || '') + '" target="_blank" rel="noopener">' + esc(authLabel) + ' ↗</a>' +
'</div>';
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();
+1 -1
View File
@@ -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';
+249
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 (
`<div class="adm-chatgpt-usage-row" data-usage-window="${esc(win.kind || win.name)}">` +
`<div class="adm-chatgpt-usage-head">` +
`<span class="adm-chatgpt-usage-name">${esc(win.name)}</span>` +
`<span class="adm-chatgpt-usage-remaining">${esc(win.remainingLabel || win.usedLabel)}</span>` +
`</div>` +
`<div class="adm-chatgpt-usage-bar adm-chatgpt-usage-${tone}" role="progressbar" aria-valuemin="0" aria-valuemax="100"` +
(win.usedPercent === null ? '' : ` aria-valuenow="${Math.round(used)}"`) +
` aria-label="${esc(win.name)} ${esc(win.usedLabel)}">` +
`<span class="adm-chatgpt-usage-fill" style="width:${Math.max(0, Math.min(100, used)).toFixed(0)}%"></span>` +
`</div>` +
`<div class="adm-chatgpt-usage-meta">` +
`<span>${esc(win.usedLabel)}</span>` +
(win.resetLabel ? `<span>${esc(win.resetLabel)}</span>` : '') +
`</div>` +
`</div>`
);
}
/**
* 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 =
`<div class="adm-chatgpt-usage-actions">` +
`<button type="button" class="admin-btn-sm" data-adm-chatgpt-usage-refresh="${authId}" data-chatgpt-endpoint-id="${endpointId}">Refresh usage</button>` +
`<button type="button" class="admin-btn-sm" data-adm-chatgpt-reconnect="${authId}" data-chatgpt-endpoint-id="${endpointId}">Reconnect</button>` +
`</div>`;
if (!vm.available) {
return (
`<div class="adm-chatgpt-usage adm-chatgpt-usage-unavailable" data-adm-chatgpt-usage="${authId}">` +
`<div class="adm-chatgpt-usage-status">${esc(vm.message || 'Usage unavailable')}</div>` +
buttons +
`</div>`
);
}
const plan = vm.plan ? `<span class="admin-badge adm-chatgpt-plan">${esc(vm.plan)}</span>` : '';
const limitsHtml = (vm.limits || []).map((limit) => {
const title = limit.title
? `<div class="adm-chatgpt-usage-limit-title">${esc(limit.title)}${limit.modelSlug ? ` <span class="adm-chatgpt-usage-model">${esc(limit.modelSlug)}</span>` : ''}</div>`
: '';
return `<div class="adm-chatgpt-usage-limit" data-usage-limit="${esc(limit.limitId)}">${title}${limit.windows.length ? limit.windows.map((w) => _barHtml(w, esc)).join('') : '<div class="adm-chatgpt-usage-status">No rate-limit windows reported</div>'}</div>`;
}).join('');
const blocked = vm.ordinaryUsageAllowed === false
? `<div class="adm-chatgpt-usage-status adm-chatgpt-usage-blocked">Usage currently blocked${vm.rateLimitReachedType ? ` (${esc(vm.rateLimitReachedType.replace(/_/g, ' '))})` : ''}</div>`
: '';
return (
`<div class="adm-chatgpt-usage" data-adm-chatgpt-usage="${authId}">` +
(plan ? `<div class="adm-chatgpt-usage-plan">${plan}</div>` : '') +
blocked +
limitsHtml +
buttons +
`</div>`
);
}
/** 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,
};
+1 -1
View File
@@ -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;
+11 -4
View File
@@ -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);
+1 -1
View File
@@ -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.
+73
View File
@@ -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;
+5 -4
View File
@@ -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',
+11 -4
View File
@@ -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():
+162
View File
@@ -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()
@@ -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
+254 -14
View File
@@ -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
+356
View File
@@ -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"<html>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()
+267
View File
@@ -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": "<img src=x onerror=alert(1)>"},
"usage": {"plan_type": "<b>plus</b>", "limits": [{"limit_id": "codex", "limit_name": "<script>", "windows": [{"kind": "primary", "name": "<5H>", "used_percent": 10}]}],
"access_token": "SHOULD-NOT-BE-HERE"},
}
js = f"""
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
const html = renderUsageCardHtml(buildUsageViewModel({json.dumps(payload)}, 0), {{ endpointId: 'ep-a' }});
console.log(JSON.stringify({{ html }}));
"""
html = _run_node(js)["html"]
assert "<script>" not in html and "<img" not in html and "<b>plus" not in html
assert "&lt;5H&gt;" in html
assert "SHOULD-NOT-BE-HERE" not in html
assert "Bearer" not in html and "access_token" not in html and "refresh_token" not in html
def test_admin_wires_per_account_usage_and_reconnect_by_exact_ids():
load_block = _ADMIN[_ADMIN.index("async function loadEndpoints()"):_ADMIN.index("async function _refreshAfterEndpointChange")] if _ADMIN.index("async function loadEndpoints()") < _ADMIN.index("async function _refreshAfterEndpointChange") else _ADMIN[_ADMIN.index("async function loadEndpoints()"):]
assert "isChatgptSubscriptionEndpoint(ep)" in load_block
assert 'data-adm-chatgpt-usage-host="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"' in load_block
assert "_loadChatgptUsage(host, host.dataset.admChatgptUsageHost, host.dataset.chatgptEndpointId)" in load_block
usage_block = _ADMIN[_ADMIN.index("async function _loadChatgptUsage"):_ADMIN.index("function initEndpointForm()")]
assert "/api/chatgpt-subscription/accounts/' + encodeURIComponent(authId) + '/usage'" in usage_block
assert "refresh ? '?refresh=1' : ''" in usage_block
assert "refreshBtn.dataset.admChatgptUsageRefresh" in usage_block
assert "reconnectBtn.dataset.admChatgptReconnect" in usage_block
assert "formData.append('reconnect_auth_id', authId)" in usage_block
assert "formData.append('reconnect_endpoint_id', epId)" in usage_block
# The browser only ever talks to Odysseus, never to OpenAI directly.
assert "chatgpt.com" not in usage_block
assert "wham/usage" not in usage_block
def test_admin_add_flow_sends_optional_account_label():
form_block = _ADMIN[_ADMIN.index("function _setApiFormForProvider()"):_ADMIN.index("function _renderPickerMenu()")]
assert "Account label, e.g. codex00 (optional)" in form_block
assert "_chatgptLabelMode = true" in form_block
start_block = _ADMIN[_ADMIN.index("async function _startProviderDeviceAuth"):_ADMIN.index('// Local "Add" button')]
assert "formData.append('label', label)" in start_block
assert "formData," in start_block
assert ".adm-chatgpt-usage-bar" in _STYLE and ".adm-chatgpt-usage-fill" in _STYLE
def test_unknown_limits_without_windows_remain_visible():
payload = {"available": True, "account": {"auth_id": "a"}, "usage": {
"limits": [{"limit_id": "future", "limit_name": "Future <limit>", "windows": []}],
}}
out = _run_node(f"""
import {{ buildUsageViewModel, renderUsageCardHtml, formatResetIn }} from '{_MODULE.as_posix()}';
console.log(JSON.stringify({{
html: renderUsageCardHtml(buildUsageViewModel({json.dumps(payload)})),
reset: formatResetIn(0, 100),
}}));
""")
assert "Future &lt;limit&gt;" in out["html"]
assert 'data-usage-limit="future"' in out["html"]
assert "No rate-limit windows reported" in out["html"]
assert out["reset"] == ""
def test_refresh_and_reconnect_handlers_target_only_the_clicked_account():
# Execute the real admin handlers with small DOM doubles. This checks the
# actions themselves, beyond checking renderer attributes or source text.
out = _run_node(f"""
import fs from 'node:fs';
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
const source = fs.readFileSync('{(_MODULE.parent / 'admin.js').as_posix()}', 'utf8');
const start = source.indexOf('const _chatgptReconnectInflight');
const end = source.indexOf('function initEndpointForm()', start);
const urls = [], operations = [];
const makeButton = () => ({{ dataset: {{}}, addEventListener(_, fn) {{ this.click = fn; }} }});
function card(id) {{
const refresh = makeButton(), reconnect = makeButton();
for (const button of [refresh, reconnect]) button.dataset = {{
admChatgptUsageRefresh: id, admChatgptReconnect: id, chatgptEndpointId: 'ep-' + id,
}};
return {{ innerHTML: '', refresh, reconnect, querySelector(sel) {{
if (sel.includes('usage-refresh')) return refresh;
if (sel.includes('chatgpt-reconnect')) return reconnect;
return {{ replaceWith() {{}} }};
}} }};
}}
const handlers = new Function('fetch', 'buildChatgptUsageViewModel', 'renderChatgptUsageCardHtml',
'esc', 'runProviderDeviceFlow', 'document', 'loadEndpoints', 'setTimeout',
source.slice(start, end) + '; return {{ load: _loadChatgptUsage }};'
)(
async url => {{ urls.push(url); return {{ ok: true, json: async () => ({{available: true, usage: {{limits: []}}}}) }}; }},
buildUsageViewModel, renderUsageCardHtml, x => String(x),
async (provider, options) => {{ operations.push(Object.fromEntries(options.formData)); return {{ status: 'authorized' }}; }},
{{ createElement: () => ({{}}) }}, async () => {{}}, () => {{}}
);
const a = card('a'), b = card('b');
await handlers.load(a, 'a', 'ep-a'); await handlers.load(b, 'b', 'ep-b');
const before = b.innerHTML;
await a.refresh.click({{stopPropagation() {{}}}});
await a.reconnect.click({{stopPropagation() {{}}}});
console.log(JSON.stringify({{ urls, operations, bUnchanged: b.innerHTML === before }}));
""")
assert out["urls"] == [
"/api/chatgpt-subscription/accounts/a/usage",
"/api/chatgpt-subscription/accounts/b/usage",
"/api/chatgpt-subscription/accounts/a/usage?refresh=1",
]
assert out["operations"] == [{"reconnect_auth_id": "a", "reconnect_endpoint_id": "ep-a"}]
assert out["bUnchanged"] is True
+210
View File
@@ -0,0 +1,210 @@
"""ChatGPT Subscription is model inference only — Odysseus is the only agent.
These regressions pin the zero-native-tool invariant relied on by the Odysseus
dogfood benchmark: the ChatGPT/Codex Responses request never declares
provider-native tools, and the stream handler never executes provider-side
tool calls. Odysseus' own text tool protocol travels as plain instructions/
input and is parsed and executed by Odysseus.
"""
import asyncio
import json
import pytest
from src import llm_core
from src import chatgpt_subscription
_URL = "https://chatgpt.com/backend-api/codex"
_TOOLS = [
{"type": "function", "function": {"name": "run_shell", "description": "Run a shell command", "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}}},
{"type": "web_search_preview"},
{"type": "computer_use_preview", "display_width": 1024, "display_height": 768, "environment": "browser"},
{"type": "file_search", "vector_store_ids": ["vs_1"]},
{"type": "local_shell"},
{"type": "mcp", "server_label": "fs", "server_url": "http://localhost/mcp"},
]
_MESSAGES = [
{"role": "system", "content": "You are Odysseus. Use <tool>read_file</tool> protocol when needed."},
{"role": "user", "content": "List the repo"},
{"role": "assistant", "content": "<tool>read_file</tool>"},
{"role": "tool", "content": "README.md"},
]
_ALLOWED_KEYS = {"model", "instructions", "input", "stream", "store", "temperature"}
def test_responses_payload_has_no_native_tool_surfaces():
payload = llm_core._build_chatgpt_responses_payload("gpt-5.5", _MESSAGES, 0.7, 4096, stream=True)
assert set(payload) <= _ALLOWED_KEYS
assert payload["model"] == "gpt-5.5"
assert payload["stream"] is True
assert payload["store"] is False
assert "tools" not in payload
assert "tool_choice" not in payload
for key in llm_core.CHATGPT_FORBIDDEN_PAYLOAD_KEYS:
assert key not in payload
# Odysseus protocol text is preserved as plain instructions/input.
assert "<tool>read_file</tool>" in payload["instructions"]
roles = [item["role"] for item in payload["input"]]
assert roles == ["user", "assistant", "user"] # tool results become user input text
assert "system" not in roles
def test_upper_level_tools_argument_is_discarded_by_chatgpt_builder():
payload = llm_core._build_chatgpt_responses_payload(
"gpt-5.5", _MESSAGES, 0.7, 4096, stream=True, tools=_TOOLS, tool_choice="required", parallel_tool_calls=True,
)
assert set(payload) <= _ALLOWED_KEYS
serialized = json.dumps(payload)
assert "run_shell" not in serialized
assert "web_search" not in serialized
assert "computer_use" not in serialized
assert "file_search" not in serialized
assert "local_shell" not in serialized
assert '"mcp"' not in serialized
def test_strip_helper_removes_any_native_tool_key_added_later():
payload = {"model": "m", "input": [], "tools": _TOOLS, "tool_choice": "auto", "web_search": {}, "shell": {}, "computer": {}, "future_agent_surface": {}}
stripped = llm_core._strip_chatgpt_native_tool_surfaces(payload)
assert set(stripped) == {"model", "input"}
class _Resp:
def __init__(self, lines):
self._lines = lines
self.status_code = 200
async def aiter_lines(self):
for line in self._lines:
yield line
async def aread(self):
return b""
class _Ctx:
def __init__(self, lines):
self._lines = lines
async def __aenter__(self):
return _Resp(self._lines)
async def __aexit__(self, *args):
return False
class _CapturingClient:
def __init__(self, lines):
self._lines = lines
self.requests = []
def stream(self, method, url, **kwargs):
self.requests.append({"method": method, "url": url, "json": kwargs.get("json"), "headers": kwargs.get("headers")})
return _Ctx(self._lines)
def _stream_with_tools(monkeypatch, lines):
client = _CapturingClient(lines)
monkeypatch.setattr(llm_core, "_get_http_client", lambda: client)
monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *a, **k: None)
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a, **k: None)
async def run():
return [
chunk
async for chunk in llm_core._stream_llm_inner(
_URL + "/responses",
"gpt-5.5",
_MESSAGES,
headers={"Authorization": "Bearer test"},
tools=_TOOLS,
)
]
return client, asyncio.run(run())
def test_stream_transport_never_serializes_tools_even_when_passed(monkeypatch):
lines = [
"data: " + json.dumps({"type": "response.output_text.delta", "delta": "<tool>read_file</tool>"}),
"data: " + json.dumps({"type": "response.completed", "response": {"usage": {"input_tokens": 1, "output_tokens": 1}}}),
]
client, chunks = _stream_with_tools(monkeypatch, lines)
assert len(client.requests) == 1
sent = client.requests[0]
assert sent["url"] == _URL + "/responses"
assert set(sent["json"]) <= _ALLOWED_KEYS
assert "tools" not in sent["json"] and "tool_choice" not in sent["json"]
assert "run_shell" not in json.dumps(sent["json"])
# The Odysseus protocol tag is streamed back verbatim for Odysseus to parse.
deltas = [json.loads(c[6:])["delta"] for c in chunks if c.startswith("data: ") and '"delta"' in c]
assert deltas == ["<tool>read_file</tool>"]
def test_stream_handler_ignores_provider_side_tool_call_events(monkeypatch):
"""A provider-emitted function_call is never executed nor surfaced as a tool_call."""
lines = [
"data: " + json.dumps({"type": "response.output_item.added", "item": {"type": "function_call", "name": "run_shell", "call_id": "c1"}}),
"data: " + json.dumps({"type": "response.function_call_arguments.delta", "delta": '{"cmd": "rm -rf /"}'}),
"data: " + json.dumps({"type": "response.function_call_arguments.done", "arguments": '{"cmd": "rm -rf /"}'}),
"data: " + json.dumps({"type": "response.output_text.delta", "delta": "done"}),
"data: " + json.dumps({"type": "response.completed", "response": {"usage": {"input_tokens": 1, "output_tokens": 1}}}),
]
client, chunks = _stream_with_tools(monkeypatch, lines)
joined = "".join(chunks)
assert "tool_calls" not in joined
assert "rm -rf" not in joined
assert "run_shell" not in joined
deltas = [json.loads(c[6:])["delta"] for c in chunks if c.startswith("data: ") and '"delta"' in c]
assert deltas == ["done"]
assert chunks[-1] == "data: [DONE]\n\n"
def test_stream_error_cannot_echo_the_request_bearer(monkeypatch):
_client, chunks = _stream_with_tools(monkeypatch, [
'data: ' + json.dumps({"type": "error", "message": "rejected Bearer test", "status": 401}),
])
assert "Bearer test" not in "".join(chunks)
assert "[redacted]" in "".join(chunks)
def test_model_probe_never_posts_provider_tools(monkeypatch):
import routes.model_routes as models
monkeypatch.setattr(models.httpx, "post", lambda *args, **kwargs: pytest.fail("ChatGPT is discovery-only"))
assert models._probe_single_model(_URL, "secret", "gpt-5.5", with_tools=True)["skipped"] is True
def test_provisioned_endpoint_supports_tools_false(monkeypatch):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, ModelEndpoint
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(chatgpt_subscription, "fetch_available_models", lambda token: ["gpt-5.5"])
a = csr._provision_endpoint({"access_token": "A", "refresh_token": "RA"}, "alice", label="codex00")
b = csr._provision_endpoint({"access_token": "B", "refresh_token": "RB"}, "alice", label="codex01")
# Reconnect must not flip the flag either.
csr._provision_endpoint({"access_token": "A2", "refresh_token": "RA2"}, "alice", reconnect_auth_id=a["provider_auth_id"])
db = TestSessionLocal()
try:
for ep_id in (a["id"], b["id"]):
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
assert ep.supports_tools is False
finally:
db.close()
def test_responses_input_never_carries_tool_call_structures():
items = chatgpt_subscription.build_responses_input([
{"role": "assistant", "content": None, "tool_calls": [{"id": "x", "function": {"name": "run_shell", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "x", "content": "output"},
])
for item in items:
assert set(item) == {"role", "content"}
assert "tool_calls" not in json.dumps(item)
+4 -1
View File
@@ -40,8 +40,9 @@ class _FakeSessionManager:
return self.sessions[session_id]
def create_session(self, session_id=None, name=None, endpoint_url=None,
model=None, rag=False, owner=None):
model=None, rag=False, owner=None, endpoint_id=None):
self.created = _FakeSession(name=name, owner=owner)
self.created.endpoint_id = endpoint_id
return self.created
def save_sessions(self):
@@ -59,6 +60,7 @@ def test_fork_does_not_corrupt_source_message_metadata(monkeypatch):
monkeypatch.setattr(mod, "_verify_session_owner", lambda *a, **k: None)
source = _FakeSession(name="Original", owner="alice")
source.endpoint_id = "chatgpt-account-b"
source.history = [
ChatMessage("user", "hi", {"_db_id": "src-0"}),
ChatMessage("assistant", "yo", {"_db_id": "src-1"}),
@@ -78,6 +80,7 @@ def test_fork_does_not_corrupt_source_message_metadata(monkeypatch):
assert result["status"] == "ok"
assert result["kept"] == 2
assert sm.created.endpoint_id == "chatgpt-account-b"
# The forked session got its own metadata dicts...
new_session = sm.created
+170
View File
@@ -163,3 +163,173 @@ def test_chatgpt_subscription_clears_previously_persisted_bearer(monkeypatch):
)
finally:
db.close()
# ── Multi-account: exact endpoint → exact auth session ──────────────────────
def _seed_two_accounts(TestSessionLocal, owner="alice"):
import datetime as _dt
db = TestSessionLocal()
try:
older = _dt.datetime(2026, 1, 1)
newer = _dt.datetime(2026, 6, 1)
db.add(ModelEndpoint(
id="ep-a", name="ChatGPT · codex00", base_url=_CODEX_BASE, provider_auth_id="auth-a",
owner=owner, is_enabled=True, api_key=None, created_at=older, updated_at=older,
))
db.add(ModelEndpoint(
id="ep-b", name="ChatGPT · codex01", base_url=_CODEX_BASE, provider_auth_id="auth-b",
owner=owner, is_enabled=True, api_key=None, created_at=newer, updated_at=newer,
))
db.commit()
finally:
db.close()
def _patch_runtime(monkeypatch, seen):
def fake(ep, owner=None):
seen.append((ep.id, ep.provider_auth_id, owner))
return (_CODEX_BASE, f"bearer-for-{ep.provider_auth_id}")
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", fake)
def test_bound_session_uses_exactly_its_own_account_even_when_urls_match(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
db.add(DbSession(id="sess-b", name="chat", endpoint_url=_CODEX_BASE + "/responses",
model="gpt-5.5", owner="alice", headers={}, endpoint_id="ep-b"))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-b", endpoint_url=_CODEX_BASE + "/responses", model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-b")
chat_helpers.resolve_session_auth(sess, "sess-b", owner="alice")
# Account B (the newer row) is used because the session is bound to ep-b,
# even though ep-a shares the same URL and would otherwise sort first.
assert seen == [("ep-b", "auth-b", "alice")]
assert sess.headers["Authorization"] == "Bearer bearer-for-auth-b"
db = TestSessionLocal()
try:
row = db.query(DbSession).filter(DbSession.id == "sess-b").first()
assert row.endpoint_id == "ep-b"
assert not any(k.lower() == "authorization" for k in (row.headers or {}))
finally:
db.close()
def test_legacy_unbound_session_picks_oldest_account_and_gets_bound(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
db.add(DbSession(id="sess-legacy", name="chat", endpoint_url=_CODEX_BASE,
model="gpt-5.5", owner="alice", headers={}))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-legacy", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id=None)
chat_helpers.resolve_session_auth(sess, "sess-legacy", owner="alice")
# Deterministic: the oldest endpoint is the one that existed when the
# legacy session was created; the choice is persisted so it never drifts.
assert seen == [("ep-a", "auth-a", "alice")]
assert sess.endpoint_id == "ep-a"
db = TestSessionLocal()
try:
row = db.query(DbSession).filter(DbSession.id == "sess-legacy").first()
assert row.endpoint_id == "ep-a"
finally:
db.close()
# A second resolve honours the persisted binding (no re-derivation drift).
seen.clear()
sess2 = types.SimpleNamespace(id="sess-legacy", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-a")
chat_helpers.resolve_session_auth(sess2, "sess-legacy", owner="alice")
assert seen == [("ep-a", "auth-a", "alice")]
def test_bound_session_never_borrows_a_sibling_account_when_its_endpoint_is_gone(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
# Two more siblings share the URL, but the bound endpoint is disabled.
ep_b = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-b").first()
ep_b.is_enabled = False
db.add(ModelEndpoint(id="ep-c", name="ChatGPT · codex02", base_url=_CODEX_BASE, provider_auth_id="auth-c",
owner="alice", is_enabled=True, api_key=None))
db.add(DbSession(id="sess-b", name="chat", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-b"))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-b", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-b")
chat_helpers.resolve_session_auth(sess, "sess-b", owner="alice")
# Ambiguous → no silent account switch, no bearer resolved.
assert seen == []
assert not any(k.lower() == "authorization" for k in sess.headers)
def test_other_owner_cannot_resolve_through_alices_accounts(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal, owner="alice")
db = TestSessionLocal()
try:
db.add(DbSession(id="sess-m", name="chat", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="mallory", headers={}, endpoint_id="ep-a"))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-m", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="mallory", headers={}, endpoint_id="ep-a")
chat_helpers.resolve_session_auth(sess, "sess-m", owner="mallory")
assert seen == []
assert sess.headers == {}
def test_route_descriptors_distinguish_same_model_on_two_accounts(monkeypatch):
"""Same base URL + same model on A and B are two routes, not duplicates."""
TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(endpoint_resolver, "SessionLocal", TestSessionLocal)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
import json as _json
for ep in db.query(ModelEndpoint).all():
ep.cached_models = _json.dumps(["gpt-5.5"])
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
route_a = endpoint_resolver.resolve_endpoint_by_id("ep-a", "gpt-5.5", owner="alice", require_exact_model=True)
route_b = endpoint_resolver.resolve_endpoint_by_id("ep-b", "gpt-5.5", owner="alice", require_exact_model=True)
assert route_a is not None and route_b is not None
assert route_a[0] == route_b[0] and route_a[1] == route_b[1] # same URL + model
assert route_a[2]["Authorization"] != route_b[2]["Authorization"] # different accounts
assert route_a != route_b
desc_a = endpoint_resolver.resolve_route_descriptor_by_id("ep-a", route_a[0], "gpt-5.5", route_a[2], owner="alice")
desc_b = endpoint_resolver.resolve_route_descriptor_by_id("ep-b", route_b[0], "gpt-5.5", route_b[2], owner="alice")
assert desc_a["endpoint_id"] == "ep-a" and desc_a["endpoint_label"] == "ChatGPT · codex00"
assert desc_b["endpoint_id"] == "ep-b" and desc_b["endpoint_label"] == "ChatGPT · codex01"
# Provenance for B's route must not be attributed to A.
assert endpoint_resolver.resolve_route_descriptor_by_id("ep-a", route_b[0], "gpt-5.5", route_b[2], owner="alice") is None
for desc in (desc_a, desc_b):
assert "Authorization" not in str(desc) and "bearer-for" not in str(desc)