diff --git a/core/database.py b/core/database.py index d98360046..6addc95c4 100644 --- a/core/database.py +++ b/core/database.py @@ -203,6 +203,12 @@ class Session(TimestampMixin, Base): # Organization folder = Column(String, nullable=True, default=None) cwd = Column(String, nullable=True, default=None) + # Registered ModelEndpoint this session is bound to. endpoint_url alone + # cannot distinguish two endpoints that share a provider URL but use + # different credentials (e.g. two ChatGPT Subscription accounts), so the + # exact endpoint id is remembered here. NULL = legacy session; the first + # deterministic, owner-scoped resolution persists a binding. + endpoint_id = Column(String, nullable=True, index=True) # Headers stored as JSON headers = Column(JSON, default=dict) @@ -1472,6 +1478,19 @@ def _migrate_add_session_cwd_column(): except Exception: pass +def _migrate_add_session_endpoint_id_column(): + """Add the nullable binding and index without rewriting existing sessions.""" + with engine.begin() as connection: + schema = inspect(connection) + if not schema.has_table("sessions"): + return + columns = {column["name"] for column in schema.get_columns("sessions")} + if "endpoint_id" not in columns: + connection.execute(text("ALTER TABLE sessions ADD COLUMN endpoint_id VARCHAR")) + index = next(index for index in Session.__table__.indexes if index.name == "ix_sessions_endpoint_id") + index.create(bind=connection, checkfirst=True) + + def _migrate_add_token_columns(): """Add cumulative token tracking columns to sessions table.""" import sqlite3 @@ -2378,6 +2397,7 @@ def init_db(): _migrate_add_session_generation_settings_columns() _migrate_add_folder_column() _migrate_add_session_cwd_column() + _migrate_add_session_endpoint_id_column() _migrate_add_token_columns() _migrate_add_total_cost_usd() _migrate_add_mode_column() diff --git a/core/models.py b/core/models.py index 851950713..a03e391fc 100644 --- a/core/models.py +++ b/core/models.py @@ -115,6 +115,10 @@ class Session: temperature_override: Optional[float] = None max_tokens_override: Optional[int] = None cwd: Optional[str] = None + # Registered ModelEndpoint id this session is bound to (None = legacy / + # URL-matched). Lets two endpoints that share a provider URL but not + # credentials stay distinguishable. + endpoint_id: Optional[str] = None def __post_init__(self): if self.headers is None: diff --git a/core/session_manager.py b/core/session_manager.py index 6708fb69d..f7467eb04 100644 --- a/core/session_manager.py +++ b/core/session_manager.py @@ -157,6 +157,7 @@ class SessionManager: temperature_override=getattr(db_session, "temperature_override", None), max_tokens_override=getattr(db_session, "max_tokens_override", None), cwd=getattr(db_session, "cwd", None) or None, + endpoint_id=getattr(db_session, "endpoint_id", None) or None, ) session.message_count = getattr(db_session, "message_count", 0) or 0 return session @@ -222,6 +223,7 @@ class SessionManager: temperature_override=getattr(db_session, "temperature_override", None), max_tokens_override=getattr(db_session, "max_tokens_override", None), cwd=getattr(db_session, "cwd", None) or None, + endpoint_id=getattr(db_session, "endpoint_id", None) or None, ) # The rows just loaded are the whole transcript, so they — not the @@ -493,6 +495,7 @@ class SessionManager: headers = {} session.name = db_session.name session.endpoint_url = db_session.endpoint_url or "" + session.endpoint_id = getattr(db_session, "endpoint_id", None) or None session.model = db_session.model or "" session.headers = headers or {} session.rag = db_session.rag @@ -563,9 +566,12 @@ class SessionManager: owner: str = None, cwd: str = None, headers: Optional[Dict[str, str]] = None, + endpoint_id: Optional[str] = None, ) -> Session: """Create a new session and save to database.""" - session_headers = dict(headers or {}) + from src.chatgpt_subscription import is_chatgpt_subscription_base + session_headers = {} if is_chatgpt_subscription_base(endpoint_url) else dict(headers or {}) + endpoint_id = (endpoint_id or "").strip() or None db = SessionLocal() try: db_session = DbSession( @@ -577,6 +583,7 @@ class SessionManager: headers=session_headers, owner=owner, cwd=cwd or None, + endpoint_id=endpoint_id, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc) ) @@ -592,6 +599,7 @@ class SessionManager: headers=session_headers, owner=owner, cwd=cwd or None, + endpoint_id=endpoint_id, ) self.sessions[session_id] = session diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index 35962dba5..96ac103d8 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -794,6 +794,52 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool: return False +def _endpoint_created_sort_key(ep) -> tuple: + created = getattr(ep, "created_at", None) + try: + ts = float(created.timestamp()) if created else 0.0 + except Exception: + ts = 0.0 + return (ts, str(getattr(ep, "id", "") or "")) + + +def _select_session_endpoint(sess, target_url: str, endpoints) -> tuple: + """Pick the endpoint a session should use for credential resolution. + + Two endpoints may share one provider URL but not credentials (e.g. two + ChatGPT Subscription accounts), so an explicit ``sess.endpoint_id`` binding + wins whenever it still matches the session URL. Without a binding the + oldest URL-matching endpoint is chosen deterministically and persisted. + + Returns ``(endpoint, bound_by_fallback)``; ``bound_by_fallback`` is True + when the choice came from URL matching and may be persisted as a binding. + """ + matching = [ep for ep in endpoints if _session_url_matches_endpoint(target_url, getattr(ep, "base_url", "") or "")] + if not matching: + return None, False + bound_id = getattr(sess, "endpoint_id", None) or None + if bound_id: + for ep in matching: + if str(ep.id) == str(bound_id): + sess.endpoint_id = ep.id + return ep, False + # The bound endpoint is gone or disabled. Never silently borrow another + # endpoint's credentials when several routes share this URL. + return None, False + matching.sort(key=_endpoint_created_sort_key) + chosen = matching[0] + if len(matching) > 1: + logger.warning( + "Session %s has no endpoint binding and %d endpoints share its URL; using oldest endpoint %s", + getattr(sess, "id", "?"), len(matching), chosen.id, + ) + try: + sess.endpoint_id = chosen.id + except Exception: + pass + return chosen, True + + def _has_auth_keys(headers) -> bool: """True if a headers dict carries an Authorization/x-api-key entry.""" return isinstance(headers, dict) and any( @@ -803,6 +849,7 @@ def _has_auth_keys(headers) -> bool: def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None): """Ensure session has auth headers — resolve from endpoint DB if missing.""" + owner = owner or getattr(sess, "owner", None) try: from src.chatgpt_subscription import is_chatgpt_subscription_base is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "") @@ -811,11 +858,22 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None): has_auth = _has_auth_keys(sess.headers) if has_auth and not is_chatgpt_subscription: return + if is_chatgpt_subscription: + # Never reuse a stale bearer after deletion, disablement or failed refresh. + sess.headers = {} try: from src.endpoint_resolver import build_headers, resolve_endpoint_runtime db = SessionLocal() try: + stored_q = db.query(DBSession).filter(DBSession.id == session_id) + if owner: + stored_q = stored_q.filter(DBSession.owner == owner) + if is_chatgpt_subscription: + stored = stored_q.first() + if stored is not None and _has_auth_keys(stored.headers): + stored_q.update({"headers": {}}) + db.commit() target_url = getattr(sess, "endpoint_url", "") or "" if not target_url: return @@ -826,44 +884,36 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None): # with similar endpoint URLs can borrow each other's API key. from src.auth_helpers import owner_filter q = owner_filter(q, ModelEndpoint, owner) - for ep in q.all(): - if not _session_url_matches_endpoint(target_url, ep.base_url or ""): - continue - try: - base, api_key = resolve_endpoint_runtime(ep, owner=owner) - except Exception as e: - logger.warning("Failed to resolve provider auth for session %s: %s", session_id, e) - return - if not api_key: - # No usable key (e.g. ChatGPT Subscription needs re-auth). - return - sess.headers = build_headers(api_key, base) - if is_chatgpt_subscription: - # The bearer is short-lived and re-resolved per request, so it - # stays request-local and is never written to the plaintext - # sessions.headers column. Proactively strip any bearer an - # older code path may have persisted so it does not linger. - stale_q = db.query(DBSession).filter(DBSession.id == session_id) - if owner: - stale_q = stale_q.filter(DBSession.owner == owner) - stored = stale_q.first() - if stored is not None and _has_auth_keys(stored.headers): - stale_q.update({"headers": {}}) - db.commit() - logger.info(f"Cleared persisted ChatGPT Subscription bearer from session {session_id}") - logger.debug(f"Resolved request-local ChatGPT Subscription auth for session {session_id}") - return - update_q = db.query(DBSession).filter(DBSession.id == session_id) - if owner: - update_q = update_q.filter(DBSession.owner == owner) - update_q.update({"headers": sess.headers}) - db.commit() - logger.info(f"Resolved and persisted auth headers for session {session_id} from endpoint {ep.name}") + ep, bound_here = _select_session_endpoint(sess, target_url, q.all()) + if ep is None: return + if bound_here: + # Bind before authentication, including failed/expired credentials. + stored_q.filter(DBSession.endpoint_id == None).update({"endpoint_id": ep.id}) + db.commit() + try: + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + except Exception as e: + logger.warning("Failed to resolve provider auth for session %s: %s", session_id, type(e).__name__) + return + if not api_key: + # No usable key (e.g. ChatGPT Subscription needs re-auth). + return + sess.headers = build_headers(api_key, base) + if is_chatgpt_subscription: + # Request-local only; persistence was cleaned before resolution. + return + update_q = db.query(DBSession).filter(DBSession.id == session_id) + if owner: + update_q = update_q.filter(DBSession.owner == owner) + update_q.update({"headers": sess.headers}) + db.commit() + logger.info(f"Resolved and persisted auth headers for session {session_id} from endpoint {ep.name}") + return finally: db.close() except Exception as e: - logger.warning(f"Failed to resolve session headers: {e}") + logger.warning("Failed to resolve session headers: %s", type(e).__name__) def _match_cached_model_id(requested: str, models) -> Optional[str]: diff --git a/routes/chat_routes.py b/routes/chat_routes.py index 5d58ef7fb..6294d3adb 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -1701,9 +1701,21 @@ def _clear_orphaned_session_endpoint(sess, owner: str | None = None) -> bool: from src.auth_helpers import owner_filter q = owner_filter(q, ModelEndpoint, owner) endpoints = q.all() + bound_id = getattr(sess, "endpoint_id", None) for ep in endpoints: + if bound_id and ep.id != bound_id: + continue if _session_url_matches_endpoint(sess.endpoint_url or "", ep.base_url or ""): return False + if bound_id: + # Keep the identity so re-enabling/reconnecting A can recover A. + # Returning True stops chat; B must never replace a missing A. + sess.headers = {} + stored = db.query(DBSession).filter(DBSession.id == sess.id, DBSession.owner == owner).first() + if stored is not None: + stored.headers = {} + db.commit() + return True db_session = db.query(DBSession).filter(DBSession.id == sess.id).first() if db_session: db_session.endpoint_url = "" @@ -1803,6 +1815,13 @@ def _first_image_attachment(chat_handler, att_ids: List[str], owner: str | None return None +def _ts_or_zero(value) -> float: + try: + return float(value.timestamp()) if value else 0.0 + except Exception: + return 0.0 + + def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool: """Re-populate sess.model from the matching endpoint's cached models. @@ -1835,10 +1854,19 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None from src.auth_helpers import owner_filter q = owner_filter(q, ModelEndpoint, owner) endpoints = q.all() - for cand in endpoints: - if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""): - ep = cand - break + # Honour the session's exact endpoint binding first: two endpoints + # can share a provider URL (e.g. two ChatGPT Subscription accounts). + bound_id = getattr(sess, "endpoint_id", None) or None + if bound_id: + for cand in endpoints: + if cand.id == bound_id and _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""): + ep = cand + break + if ep is None and not bound_id: + for cand in sorted(endpoints, key=lambda row: (_ts_or_zero(getattr(row, "created_at", None)), str(row.id))): + if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""): + ep = cand + break if not ep: return False if not is_chatgpt_subscription: @@ -1938,6 +1966,7 @@ def _reconcile_selected_route_from_request( endpoint_url = "" headers = None + resolved_endpoint_id = None if selected_endpoint_id or selected_endpoint_url: try: from src.auth_helpers import owner_filter @@ -1949,7 +1978,15 @@ def _reconcile_selected_route_from_request( q = q.filter(ModelEndpoint.id == selected_endpoint_id) if owner: q = owner_filter(q, ModelEndpoint, owner) - candidates = q.all() if selected_endpoint_url and not selected_endpoint_id else [q.first()] + if selected_endpoint_url and not selected_endpoint_id: + candidates = [row for row in q.all() if _session_url_matches_endpoint(selected_endpoint_url, row.base_url or "")] + bound_id = getattr(sess, "endpoint_id", None) + if bound_id: + candidates = [row for row in candidates if row.id == bound_id] + if len(candidates) != 1: + return False + else: + candidates = [q.first()] ep = None for cand in candidates: if not cand: @@ -1961,6 +1998,7 @@ def _reconcile_selected_route_from_request( return False endpoint_url = build_chat_url(normalize_base(ep.base_url or "")) headers = build_headers(ep.api_key or "", ep.base_url or "") if ep.api_key else {} + resolved_endpoint_id = ep.id finally: db.close() except Exception as e: @@ -1975,18 +2013,29 @@ def _reconcile_selected_route_from_request( and endpoint_url == (getattr(sess, "endpoint_url", "") or "") ) headers_changed = dict(getattr(sess, "headers", None) or {}) != dict(headers or {}) - if not route_changed and not headers_changed: + binding_changed = bool( + resolved_endpoint_id + and resolved_endpoint_id != (getattr(sess, "endpoint_id", None) or None) + ) + if not route_changed and not headers_changed and not binding_changed: return False sess.model = selected_model sess.endpoint_url = endpoint_url sess.headers = headers or {} + if resolved_endpoint_id: + sess.endpoint_id = resolved_endpoint_id + elif route_changed: + # The route moved without an explicit endpoint id: drop a stale binding + # rather than keep pointing at an endpoint the session no longer uses. + sess.endpoint_id = None db = SessionLocal() try: db_session = db.query(DBSession).filter(DBSession.id == session_id).first() if db_session: db_session.model = selected_model db_session.endpoint_url = endpoint_url + db_session.endpoint_id = getattr(sess, "endpoint_id", None) or None db_session.headers = sess.headers or {} db_session.updated_at = datetime.utcnow() db.commit() @@ -2086,7 +2135,19 @@ def setup_chat_routes( from src.model_profiles import supports_user_thinking_toggle if not supports_user_thinking_toggle(sess.model): thinking_mode = "off" + reasoning_effort = None + req_effort = getattr(chat_request, "reasoning_effort", None) + if req_effort: + reasoning_effort = str(req_effort).strip().lower() + elif session_mode.startswith("effort:"): + reasoning_effort = session_mode[7:].strip() + from src.chatgpt_subscription import validate_reasoning_effort + reasoning_effort = validate_reasoning_effort(sess.model, reasoning_effort) 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 +2163,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 +2232,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) @@ -2198,6 +2261,7 @@ def setup_chat_routes( prompt_type=preset_id, session_id=session, thinking_mode=thinking_mode, + reasoning_effort=reasoning_effort, ) actual_index = _candidate_index(foreground_candidates, actual_candidate) apply_compaction_state( @@ -2296,6 +2360,8 @@ def setup_chat_routes( compare_mode = str(form_data.get("compare_mode", "")).lower() == "true" thinking_mode = str(form_data.get("thinking_mode") or "").strip().lower() thinking_mode = thinking_mode if thinking_mode in {"on", "off"} else None + raw_effort = str(form_data.get("reasoning_effort") or (body or {}).get("reasoning_effort") or "").strip().lower() + reasoning_effort = raw_effort if raw_effort else None temperature_override = None raw_temperature = form_data.get("temperature") if raw_temperature not in (None, ""): @@ -2565,6 +2631,10 @@ def setup_chat_routes( from src.model_profiles import supports_user_thinking_toggle if not supports_user_thinking_toggle(sess.model): thinking_mode = "off" + if reasoning_effort is None and session_mode.startswith("effort:"): + reasoning_effort = session_mode[7:].strip() + from src.chatgpt_subscription import validate_reasoning_effort + reasoning_effort = validate_reasoning_effort(sess.model, reasoning_effort) if getattr(sess, "temperature_override", None) is not None: temperature_override = float(sess.temperature_override) # A resumed session may omit workspace/cwd from the new request. @@ -3704,7 +3774,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) @@ -3870,6 +3940,7 @@ def setup_chat_routes( candidate_request_factory=_chat_request_factory, candidate_route_descriptors=_foreground_route_descriptors, thinking_mode=thinking_mode, + reasoning_effort=reasoning_effort, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: @@ -4313,6 +4384,7 @@ def setup_chat_routes( exact_approval=exact_tool_approval, client_runtime_context=client_runtime_context, thinking_mode=thinking_mode, + reasoning_effort=reasoning_effort, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: diff --git a/routes/chatgpt_subscription_routes.py b/routes/chatgpt_subscription_routes.py index 9c695b371..a9a19b83a 100644 --- a/routes/chatgpt_subscription_routes.py +++ b/routes/chatgpt_subscription_routes.py @@ -1,28 +1,123 @@ -"""ChatGPT Subscription device-flow setup routes.""" +"""ChatGPT Subscription device-flow setup, multi-account and usage routes. + +One Odysseus owner may connect several independent ChatGPT subscriptions. Each +connection is its own ``ProviderAuthSession`` + ``ModelEndpoint`` pair; the +endpoint id decides which account a request is billed to. Labels are cosmetic +only — stable ids drive lookup, reconnect, deletion and usage reads. +""" import json import logging import uuid -from typing import Dict, Optional +from typing import Any, Dict, Optional -from fastapi import HTTPException, Request +from fastapi import HTTPException, Request, Response from core.database import ModelEndpoint, ProviderAuthSession, SessionLocal, utcnow_naive +from core.middleware import require_admin from routes.device_flow import ( DeviceFlowPoll, DeviceFlowStart, PendingDeviceFlowStore, create_device_flow_router, ) -from src.auth_helpers import get_current_user +from src.auth_helpers import effective_user, get_current_user from src import chatgpt_subscription logger = logging.getLogger(__name__) _DEVICE_FLOW_STORE = PendingDeviceFlowStore() +_PROVIDER = chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER -def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict: + +def _owner_scope(query, model_cls, owner: Optional[str]): + return query.filter(model_cls.owner == owner) + + +def _owner_chatgpt_auths(db, owner: Optional[str]): + q = db.query(ProviderAuthSession).filter(ProviderAuthSession.provider == _PROVIDER) + return _owner_scope(q, ProviderAuthSession, owner).order_by(ProviderAuthSession.created_at).all() + + +def _endpoints_for_auth(db, auth_id: str): + return db.query(ModelEndpoint).filter(ModelEndpoint.provider_auth_id == auth_id).all() + + +def _display_label(auth, ep) -> str: + """Display label from persisted metadata (never from credentials).""" + label = chatgpt_subscription.account_label_from_name(getattr(auth, "label", None)) + if not label and ep is not None: + label = chatgpt_subscription.account_label_from_name(getattr(ep, "name", None)) + return label + + +def _assert_label_available(db, owner: Optional[str], label: str, *, exclude_auth_id: Optional[str] = None) -> None: + """Reject a label already used by another ChatGPT account of this owner.""" + if not label: + return + for auth in _owner_chatgpt_auths(db, owner): + if exclude_auth_id and auth.id == exclude_auth_id: + continue + existing = _display_label(auth, None) + if not existing: + for ep in _endpoints_for_auth(db, auth.id): + existing = _display_label(auth, ep) + if existing: + break + if chatgpt_subscription.labels_conflict(existing, label): + raise ValueError(f"A ChatGPT subscription labelled '{label}' is already connected.") + + +def _default_new_label(db, owner: Optional[str]) -> str: + """Label for a new connection when the user did not supply one. + + The first account keeps the legacy unlabelled name so existing single + account setups look unchanged; later accounts get a distinguishable + ``account N`` label that is unique for this owner. + """ + existing = _owner_chatgpt_auths(db, owner) + if not existing: + return "" + taken = set() + for auth in existing: + label = _display_label(auth, None) + if not label: + for ep in _endpoints_for_auth(db, auth.id): + label = _display_label(auth, ep) + if label: + break + if label: + taken.add(label.casefold()) + n = len(existing) + 1 + while f"account {n}".casefold() in taken: + n += 1 + return f"account {n}" + + +def _new_id(db, model_cls) -> str: + for _ in range(8): + candidate = str(uuid.uuid4())[:8] + if db.query(model_cls).filter(model_cls.id == candidate).first() is None: + return candidate + return uuid.uuid4().hex[:12] + + +def _provision_endpoint( + tokens: Dict, + owner: Optional[str], + *, + label: str = "", + reconnect_auth_id: Optional[str] = None, + reconnect_endpoint_id: Optional[str] = None, +) -> Dict: + """Create a new ChatGPT account (auth + endpoint) or refresh exactly one. + + Without ``reconnect_auth_id`` a brand-new ``ProviderAuthSession`` and + ``ModelEndpoint`` are created even when the owner already has other ChatGPT + subscriptions. With it, only that owner-scoped auth row (and its endpoint) + is updated; every other account is left untouched. + """ access_token = tokens.get("access_token") refresh_token = tokens.get("refresh_token") if not access_token or not refresh_token: @@ -32,22 +127,28 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict: models = chatgpt_subscription.fetch_available_models(access_token) if not models: raise ValueError("ChatGPT Subscription connected, but no usable Codex models were discovered for this account.") + label = chatgpt_subscription.normalize_account_label(label) db = SessionLocal() try: - auth = ( - db.query(ProviderAuthSession) - .filter( - ProviderAuthSession.provider == chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER, - ProviderAuthSession.owner == owner, - ) - .first() - ) - if auth is None: + auth = None + if reconnect_auth_id: + auth = chatgpt_subscription.find_owned_auth_session(db, reconnect_auth_id, owner) + if auth is None: + raise chatgpt_subscription.ChatGPTSubscriptionAuthNotFound( + "The ChatGPT subscription being reconnected no longer exists for this user." + ) + # A reconnect keeps the existing label unless a new one was given. + if label: + _assert_label_available(db, owner, label, exclude_auth_id=auth.id) + else: + if not label: + label = _default_new_label(db, owner) + _assert_label_available(db, owner, label) auth = ProviderAuthSession( - id=str(uuid.uuid4())[:8], - provider=chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER, + id=_new_id(db, ProviderAuthSession), + provider=_PROVIDER, owner=owner, - label="ChatGPT Subscription", + label=chatgpt_subscription.endpoint_name_for_label(label), base_url=base, auth_mode="chatgpt", ) @@ -57,31 +158,39 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict: auth.refresh_token = refresh_token auth.last_refresh = utcnow_naive() auth.auth_mode = "chatgpt" + if label: + auth.label = chatgpt_subscription.endpoint_name_for_label(label) - ep = ( - db.query(ModelEndpoint) - .filter( - ModelEndpoint.base_url == base, - ModelEndpoint.provider_auth_id == auth.id, - ModelEndpoint.owner == owner, - ) - .first() - ) + ep = None + if reconnect_auth_id: + ep_q = db.query(ModelEndpoint).filter(ModelEndpoint.provider_auth_id == auth.id) + ep_q = _owner_scope(ep_q, ModelEndpoint, owner) + if reconnect_endpoint_id: + ep = ep_q.filter(ModelEndpoint.id == reconnect_endpoint_id).first() + if ep is None: + raise chatgpt_subscription.ChatGPTSubscriptionAuthNotFound( + "The ChatGPT subscription endpoint no longer exists for this user." + ) + else: + ep = ep_q.order_by(ModelEndpoint.created_at).first() if ep is None: ep = ModelEndpoint( - id=str(uuid.uuid4())[:8], - name="ChatGPT Subscription", + id=_new_id(db, ModelEndpoint), + name=chatgpt_subscription.endpoint_name_for_label(label), base_url=base, model_type="llm", endpoint_kind="api", owner=owner, ) db.add(ep) - ep.name = "ChatGPT Subscription" + if label or not (ep.name or "").strip(): + ep.name = chatgpt_subscription.endpoint_name_for_label(label) ep.base_url = base ep.api_key = None ep.provider_auth_id = auth.id ep.is_enabled = True + # ChatGPT provides inference only. Odysseus is the only agent: no + # provider-native tool schemas are ever sent on this route. ep.supports_tools = False ep.model_type = "llm" ep.endpoint_kind = "api" @@ -93,10 +202,14 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict: "name": ep.name, "base_url": ep.base_url, "models": models, + "provider_auth_id": auth.id, + "account_label": _display_label(auth, ep), + "reconnected": bool(reconnect_auth_id), } finally: db.close() + chatgpt_subscription.USAGE_CACHE.invalidate(result["provider_auth_id"]) try: from routes.model_routes import _invalidate_models_cache @@ -106,7 +219,53 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict: return result -def _start_device_flow(request: Request, _form) -> DeviceFlowStart: +def _form_value(form, key: str) -> str: + try: + value = form.get(key) if form is not None else None + except Exception: + value = None + return str(value).strip() if value is not None else "" + + +def _start_device_flow(request: Request, form) -> DeviceFlowStart: + owner = effective_user(request) or None + try: + label = chatgpt_subscription.normalize_account_label(_form_value(form, "label")) + except ValueError as exc: + raise HTTPException(400, str(exc)) + reconnect_auth_id = _form_value(form, "reconnect_auth_id") or None + reconnect_endpoint_id = _form_value(form, "reconnect_endpoint_id") or None + if reconnect_endpoint_id and not reconnect_auth_id: + raise HTTPException(400, "Reconnect requires an account id") + + # Validate the intended operation up front so the user is not sent through + # OAuth for a request that can never be provisioned. + db = SessionLocal() + try: + if reconnect_auth_id: + auth = chatgpt_subscription.find_owned_auth_session(db, reconnect_auth_id, owner) + if auth is None: + raise HTTPException(404, "ChatGPT subscription account not found") + if reconnect_endpoint_id: + ep_q = db.query(ModelEndpoint).filter( + ModelEndpoint.id == reconnect_endpoint_id, + ModelEndpoint.provider_auth_id == auth.id, + ) + if _owner_scope(ep_q, ModelEndpoint, owner).first() is None: + raise HTTPException(404, "ChatGPT subscription endpoint not found") + if label: + try: + _assert_label_available(db, owner, label, exclude_auth_id=auth.id) + except ValueError as exc: + raise HTTPException(409, str(exc)) + else: + try: + _assert_label_available(db, owner, label) + except ValueError as exc: + raise HTTPException(409, str(exc)) + finally: + db.close() + try: data = chatgpt_subscription.request_device_code() except Exception as exc: @@ -116,37 +275,82 @@ def _start_device_flow(request: Request, _form) -> DeviceFlowStart: user_code = data.get("user_code") if not device_auth_id or not user_code: raise HTTPException(502, "ChatGPT did not return a complete device code") - verification_uri = data.get("verification_uri") or f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device" + # Never pass an arbitrary upstream URL into an authorization link. + from urllib.parse import urlsplit + fallback_uri = f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device" + verification_uri = data.get("verification_uri") or fallback_uri + try: + parsed = urlsplit(verification_uri) + if parsed.scheme != "https" or parsed.netloc != "auth.openai.com": + verification_uri = fallback_uri + except (TypeError, ValueError): + verification_uri = fallback_uri + # The pending payload carries only what provisioning needs: the device + # handle, the owner and the intended operation. No access/refresh tokens. + pending: Dict[str, Any] = { + "device_auth_id": device_auth_id, + "user_code": user_code, + "owner": owner, + "label": label, + "reconnect_auth_id": reconnect_auth_id, + "reconnect_endpoint_id": reconnect_endpoint_id, + } + response: Dict[str, Any] = { + "user_code": user_code, + "verification_uri": verification_uri, + "mode": "reconnect" if reconnect_auth_id else "connect", + } + if label: + response["account_label"] = label return DeviceFlowStart( - pending={ - "device_auth_id": device_auth_id, - "user_code": user_code, - "owner": get_current_user(request) or None, - }, - response={ - "user_code": user_code, - "verification_uri": verification_uri, - }, + pending=pending, + response=response, interval=int(data.get("interval") or 5), expires_in=int(data.get("expires_in") or 900), ) -def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll: +def _poll_device_flow(request: Request, pending: Dict) -> DeviceFlowPoll: + # The poller must be the same user who started the flow: a poll id is not + # a bearer for provisioning into someone else's account list. + current_owner = effective_user(request) or None + if (pending.get("owner") or None) != current_owner: + raise HTTPException(403, "This sign-in belongs to another user") + if pending.get("reconnect_auth_id"): + db = SessionLocal() + try: + auth = chatgpt_subscription.find_owned_auth_session(db, pending["reconnect_auth_id"], current_owner) + if auth is None: + raise HTTPException(404, "ChatGPT subscription account not found") + if pending.get("reconnect_endpoint_id"): + ep = _owner_scope(db.query(ModelEndpoint).filter( + ModelEndpoint.id == pending["reconnect_endpoint_id"], + ModelEndpoint.provider_auth_id == auth.id, + ), ModelEndpoint, current_owner).first() + if ep is None: + raise HTTPException(404, "ChatGPT subscription endpoint not found") + finally: + db.close() try: data = chatgpt_subscription.poll_device_auth(pending["device_auth_id"], pending["user_code"]) except Exception as exc: - logger.debug("ChatGPT device poll failed: %s", exc) - return DeviceFlowPoll.pending(str(exc)) + logger.debug("ChatGPT device poll failed: %s", type(exc).__name__) + return DeviceFlowPoll.pending("Sign-in status temporarily unavailable") authorization_code = data.get("authorization_code") code_verifier = data.get("code_verifier") if authorization_code and code_verifier: try: tokens = chatgpt_subscription.exchange_authorization_code(authorization_code, code_verifier) - result = _provision_endpoint(tokens, pending["owner"]) + result = _provision_endpoint( + tokens, + pending.get("owner"), + label=pending.get("label") or "", + reconnect_auth_id=pending.get("reconnect_auth_id") or None, + reconnect_endpoint_id=pending.get("reconnect_endpoint_id") or None, + ) except Exception as exc: - logger.exception("ChatGPT Subscription endpoint provisioning failed") + logger.warning("ChatGPT Subscription endpoint provisioning failed: %s", type(exc).__name__) raise chatgpt_subscription.to_http_exception(exc) return DeviceFlowPoll.authorized(result) @@ -157,14 +361,97 @@ def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll: return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None) if err in ("expired_token", "access_denied", "denied"): return DeviceFlowPoll.failed(err) - return DeviceFlowPoll.pending(err or "unknown") + return DeviceFlowPoll.pending("unknown") + + +# ── Account listing / usage ───────────────────────────────────────────────── + +def _account_summary(auth, endpoints) -> Dict[str, Any]: + ep = endpoints[0] if endpoints else None + return { + "auth_id": auth.id, + "label": _display_label(auth, ep), + "name": (ep.name if ep is not None else None) or auth.label or chatgpt_subscription.CHATGPT_SUBSCRIPTION_LEGACY_NAME, + "endpoint_ids": [row.id for row in endpoints], + "connected_at": auth.created_at.isoformat() if getattr(auth, "created_at", None) else None, + "last_refresh": auth.last_refresh.isoformat() if getattr(auth, "last_refresh", None) else None, + "connected": bool(auth.refresh_token), + } + + +def usage_error_payload(exc: chatgpt_subscription.ChatGPTUsageUnavailable) -> Dict[str, Any]: + """Safe, credential-free payload for a failed usage read.""" + return { + "available": False, + "reason": exc.reason, + "message": str(exc), + "status_code": exc.status_code, + "reconnect_suggested": exc.reason == "reauth", + } + + +def _load_owned_account(request: Request, auth_id: str): + """Admin gate + owner scope for account-level operations.""" + require_admin(request) + owner = effective_user(request) or get_current_user(request) or None + db = SessionLocal() + try: + auth = chatgpt_subscription.find_owned_auth_session(db, auth_id, owner) + if auth is None: + raise HTTPException(404, "ChatGPT subscription account not found") + endpoints = _endpoints_for_auth(db, auth.id) + return owner, _account_summary(auth, endpoints) + finally: + db.close() def setup_chatgpt_subscription_routes(): - return create_device_flow_router( + router = create_device_flow_router( prefix="/api/chatgpt-subscription", tags=["chatgpt-subscription"], store=_DEVICE_FLOW_STORE, start_flow=_start_device_flow, poll_flow=_poll_device_flow, ) + + @router.get("/accounts") + def list_accounts(request: Request): + require_admin(request) + owner = effective_user(request) or get_current_user(request) or None + db = SessionLocal() + try: + return [ + _account_summary(auth, _endpoints_for_auth(db, auth.id)) + for auth in _owner_chatgpt_auths(db, owner) + ] + finally: + db.close() + + @router.get("/accounts/{auth_id}/usage") + def account_usage(auth_id: str, request: Request, refresh: bool = False, response: Response = None): + """Read-only, owner-scoped usage for exactly one ChatGPT account. + + Failures here are telemetry failures only: the model endpoint is never + disabled, credentials are never destroyed and no reconnect is started. + """ + if response is not None: + response.headers["Cache-Control"] = "no-store" + owner, account = _load_owned_account(request, auth_id) + try: + usage = chatgpt_subscription.get_account_usage(account["auth_id"], owner=owner, force_refresh=refresh) + except chatgpt_subscription.ChatGPTSubscriptionAuthNotFound: + raise HTTPException(404, "ChatGPT subscription account not found") + except chatgpt_subscription.ChatGPTUsageUnavailable as exc: + payload = usage_error_payload(exc) + payload["account"] = account + return payload + except Exception as exc: # pragma: no cover - defensive + logger.warning("ChatGPT usage read failed for auth %s: %s", auth_id, type(exc).__name__) + payload = usage_error_payload( + chatgpt_subscription.ChatGPTUsageUnavailable("upstream", "ChatGPT usage is unavailable.") + ) + payload["account"] = account + return payload + return {"available": True, "account": account, "usage": usage} + + return router diff --git a/routes/history/history_routes.py b/routes/history/history_routes.py index 411a54cbd..64ccb3dea 100644 --- a/routes/history/history_routes.py +++ b/routes/history/history_routes.py @@ -315,6 +315,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: "history": history_dict, "model": db_session.model, "endpoint_url": db_session.endpoint_url, + "endpoint_id": getattr(db_session, "endpoint_id", None), "name": db_session.name, "offset": page_offset, "limit": page_limit, @@ -378,6 +379,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: "history": history_dict, "model": session.model, "endpoint_url": session.endpoint_url, + "endpoint_id": getattr(session, "endpoint_id", None), "name": session.name, } @@ -802,6 +804,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: model=source.model, rag=False, owner=getattr(source, 'owner', None), + endpoint_id=getattr(source, 'endpoint_id', None), ) # Copy messages up to keep_count @@ -820,8 +823,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: except Exception: logger.debug("session_created event dispatch failed", exc_info=True) - from src.model_profiles import supports_user_thinking_toggle - thinking_supported = supports_user_thinking_toggle(session.model) return { "status": "ok", "id": new_id, @@ -882,6 +883,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: "session_id": session_id, "model": session.model, "endpoint_url": session.endpoint_url, + "endpoint_id": getattr(session, "endpoint_id", None), "used_tokens": used, "context_length": ctx_len, "context_percent": pct, @@ -1022,16 +1024,49 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: body = await request.json() except KeyError: raise HTTPException(404, "Session not found") - mode = str(body.get("thinking_mode") or "").lower() - if mode not in {"", "on", "off"}: - raise HTTPException(400, "Invalid thinking mode") - from src.model_profiles import supports_user_thinking_toggle - if not supports_user_thinking_toggle(session.model): - mode = "off" - temperature = body.get("temperature_override") - temperature = None if temperature in (None, "") else max(0.0, min(2.0, float(temperature))) - max_tokens = body.get("max_tokens_override") - max_tokens = None if max_tokens in (None, "", 0) else max(256, min(32768, int(max_tokens))) + mode = getattr(session, "thinking_mode", "off") or "off" + raw_effort = body.get("reasoning_effort") + if raw_effort is not None: + clean_effort = str(raw_effort).strip().lower() + if clean_effort in {"", "default"}: + mode = "off" + else: + from src.chatgpt_subscription import get_chatgpt_model_metadata + meta = get_chatgpt_model_metadata(session.model) + if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]: + mode = f"effort:{clean_effort}" + else: + mode = "off" + elif "thinking_mode" in body: + raw_mode = str(body.get("thinking_mode") or "").strip().lower() + if raw_mode.startswith("effort:"): + clean_effort = raw_mode[7:].strip() + from src.chatgpt_subscription import get_chatgpt_model_metadata + meta = get_chatgpt_model_metadata(session.model) + if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]: + mode = f"effort:{clean_effort}" + else: + mode = "off" + elif raw_mode in {"", "on", "off"}: + mode = raw_mode + from src.model_profiles import supports_user_thinking_toggle + if not supports_user_thinking_toggle(session.model): + mode = "off" + else: + raise HTTPException(400, "Invalid thinking mode") + + if "temperature_override" in body: + temperature = body.get("temperature_override") + temperature = None if temperature in (None, "") else max(0.0, min(2.0, float(temperature))) + else: + temperature = getattr(session, "temperature_override", None) + + if "max_tokens_override" in body: + max_tokens = body.get("max_tokens_override") + max_tokens = None if max_tokens in (None, "", 0) else max(256, min(32768, int(max_tokens))) + else: + max_tokens = getattr(session, "max_tokens_override", None) + db = SessionLocal() try: row = db.query(DbSession).filter(DbSession.id == session_id).first() @@ -1040,7 +1075,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: row.thinking_mode, row.temperature_override, row.max_tokens_override = mode, temperature, max_tokens db.commit() session.thinking_mode, session.temperature_override, session.max_tokens_override = mode, temperature, max_tokens - return {"status": "success", "thinking_mode": mode, "temperature_override": temperature, "max_tokens_override": max_tokens} + resp_effort = mode[7:] if mode.startswith("effort:") else ("default" if mode in {"", "off"} else None) + return { + "status": "success", + "thinking_mode": mode, + "reasoning_effort": resp_effort, + "temperature_override": temperature, + "max_tokens_override": max_tokens, + } finally: db.close() diff --git a/routes/model_routes.py b/routes/model_routes.py index 5238b1746..398f4d703 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -9,6 +9,7 @@ import ipaddress import socket import time as _time import logging +import threading import httpx from datetime import datetime from typing import List, Dict, Any, Optional @@ -17,6 +18,9 @@ from fastapi import APIRouter, HTTPException, Form, Query, Body, Request, Respon from pydantic import BaseModel from fastapi.responses import StreamingResponse from core.database import SessionLocal, ModelEndpoint, Session as DbSession + +_featherless_search_cache: Dict[tuple, tuple[float, Dict[str, Any]]] = {} +_featherless_search_cache_lock = threading.Lock() try: from core.log_safety import redact_url as _redact_url_for_log except ModuleNotFoundError: @@ -695,6 +699,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 +747,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 @@ -849,6 +858,8 @@ def _effective_endpoint_kind(ep: Any, base_url: str) -> str: kind = _endpoint_kind(ep) if kind != "auto": return kind + if _host_match(base_url, "featherless.ai"): + return "api" if getattr(ep, "api_key", None) and not _is_ollama_base(base_url): try: path = (urlparse(base_url).path or "").rstrip("/") @@ -1010,6 +1021,8 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis if api_key: return fetch_available_models(api_key, timeout=timeout) return [] + if provider == "featherless" or _host_match(base, "featherless.ai"): + return [] if _is_google_api_base(base): try: models = _probe_google_models(base, api_key, timeout=timeout) @@ -1160,6 +1173,31 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> last_error: Optional[str] = None + if _host_match(base, "featherless.ai") or _safe_detect_provider(base) == "featherless": + plan_base = base if base.endswith("/v1") else f"{base}/v1" + plan_url = f"{plan_base}/plan" + try: + r = httpx.get(plan_url, headers=headers, timeout=timeout, verify=llm_verify()) + result = _result_from_response(r) + if result["reachable"]: + return result + if r.status_code in (401, 403): + return {"reachable": False, "status_code": r.status_code, "error": "Featherless API key invalid or unauthorized"} + except Exception as e: + last_error = str(e)[:120] + + try: + models_url = f"{plan_base}/models?available_on_current_plan=true&status=active&conversational=true&page=1&per_page=1" + r = httpx.get(models_url, headers=headers, timeout=timeout, verify=llm_verify()) + result = _result_from_response(r) + if result["reachable"]: + return result + if r.status_code in (401, 403): + return {"reachable": False, "status_code": r.status_code, "error": "Featherless API key invalid or unauthorized"} + return result + except Exception as e: + return {"reachable": False, "status_code": None, "error": str(e)[:120]} + try: if looks_like_ollama: root = base @@ -1410,6 +1448,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 +1511,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 +1535,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 = { @@ -1481,6 +1550,8 @@ def setup_model_routes(model_discovery): } if not base: return False, info + if _host_match(base, "featherless.ai") or _safe_detect_provider(base) == "featherless": + return False, info if state.get("inflight"): return False, info if mode in ("manual", "disabled") and not force: @@ -1528,9 +1599,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 +1678,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" @@ -1623,7 +1698,12 @@ def setup_model_routes(model_discovery): for m in pinned: if m not in curated: curated.append(m) - extra = [m for m in extra if m not in pinned] + models_metadata = {} + from src.chatgpt_subscription import get_chatgpt_model_metadata + for mid in list(curated) + list(extra): + meta = get_chatgpt_model_metadata(mid) + if meta: + models_metadata[mid] = meta items.append({ "host": "custom", "port": 0, @@ -1637,6 +1717,7 @@ def setup_model_routes(model_discovery): "category": category, "endpoint_kind": kind, "model_type": ep_model_type, + "models_metadata": models_metadata, }) else: # Endpoint unreachable but still show it greyed out @@ -1858,8 +1939,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 +1975,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 +2066,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)) @@ -1996,9 +2081,10 @@ def setup_model_routes(model_discovery): if _picker_requires_pinning(base, kind) and pinned and not _has_explicit_pinned_models(r): r.pinned_models = json.dumps(pinned) upgraded_legacy_pins = True - model_inventory_count = len(_merge_model_ids(all_models, pinned)) + is_featherless = _host_match(base, "featherless.ai") or _safe_detect_provider(base) == "featherless" + model_inventory_count = len(pinned) if is_featherless else len(_merge_model_ids(all_models, pinned)) picker_requires_pinning = _picker_requires_pinning(base, kind) - status = "online" if (all_models or visible or pinned) else ("empty" if r.is_enabled else "offline") + status = "online" if (all_models or visible or pinned or (is_featherless and r.is_enabled)) else ("empty" if r.is_enabled else "offline") results.append({ "id": r.id, "name": r.name, @@ -2022,6 +2108,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() @@ -2063,11 +2150,19 @@ def setup_model_routes(model_discovery): # keep those container-local when the frontend marks them as such. base_url = _rewrite_loopback_for_docker(base_url, container_local=_truthy(container_local)) + is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless" # Auto-generate name from URL if not provided if not name.strip(): - name = base_url.replace("http://", "").replace("https://", "").split("/")[0] + if is_featherless: + name = "Featherless.ai" + else: + name = base_url.replace("http://", "").replace("https://", "").split("/")[0] requested_kind = _normalize_endpoint_kind(endpoint_kind) + if is_featherless and requested_kind == "auto": + requested_kind = "api" + if is_featherless and not pinned_models.strip(): + pinned_models = "[]" refresh_mode = _normalize_endpoint_refresh_mode(model_refresh_mode, requested_kind, base_url) refresh_interval = _parse_positive_int(model_refresh_interval, minimum=30, maximum=86400) refresh_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60) @@ -2096,6 +2191,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 @@ -2159,6 +2256,8 @@ def setup_model_routes(model_discovery): existing_models = _cached_model_ids(existing) _existing_pinned = _normalize_model_ids(getattr(existing, "pinned_models", None)) existing_kind = _effective_endpoint_kind(existing, existing.base_url) + is_existing_featherless = _host_match(existing.base_url, "featherless.ai") or _safe_detect_provider(existing.base_url) == "featherless" + existing_status = "online" if (existing.is_enabled and is_existing_featherless) else ("online" if (existing_models or _existing_pinned) else ("empty" if existing.is_enabled else "offline")) return { "id": existing.id, "name": existing.name, @@ -2171,8 +2270,8 @@ def setup_model_routes(model_discovery): existing.pinned_models, ), "pinned_models": _existing_pinned, - "online": True, - "status": "online", + "online": existing_status != "offline", + "status": existing_status, "existing": True, "endpoint_kind": existing_kind, "category": _classify_endpoint(existing.base_url, existing_kind), @@ -2184,7 +2283,7 @@ def setup_model_routes(model_discovery): ping = {"reachable": False, "error": None} if (should_probe or requested_kind in ("api", "proxy")) and not model_ids: ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=min(explicit_timeout, 10.0)) - if require_model_list and not model_ids: + if require_model_list and not model_ids and not is_featherless: raise HTTPException(400, _model_endpoint_error_message(base_url, ping)) ep_id = str(uuid.uuid4())[:8] @@ -2192,6 +2291,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 @@ -2211,8 +2313,8 @@ def setup_model_routes(model_discovery): model_refresh_mode=refresh_mode, model_refresh_interval=refresh_interval, model_refresh_timeout=refresh_timeout, - cached_models=json.dumps(model_ids) if model_ids else None, - pinned_models=json.dumps(_pinned) if _pinned else None, + cached_models=None if is_featherless else (json.dumps(model_ids) if model_ids else None), + pinned_models=json.dumps(_pinned) if (is_featherless or _pinned) else None, supports_tools=_st, owner=_owner_val, ) @@ -2252,6 +2354,8 @@ def setup_model_routes(model_discovery): db.close() # Return immediately — probing happens via the separate /probe SSE endpoint + is_online = bool(model_ids) or bool(_pinned) or bool(ping.get("reachable")) or (is_featherless and ping.get("reachable")) + is_status = "online" if (model_ids or _pinned or (is_featherless and ping.get("reachable"))) else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")) return { "id": ep_id, "name": name.strip(), @@ -2260,8 +2364,8 @@ def setup_model_routes(model_discovery): "api_key_fingerprint": _api_key_fingerprint(api_key), "models": _merge_model_ids(model_ids, _pinned), "pinned_models": _pinned, - "online": bool(model_ids) or bool(_pinned) or bool(ping.get("reachable")), - "status": "online" if (model_ids or _pinned) else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")), + "online": is_online, + "status": is_status, "ping_error": ping.get("error") if ping else None, "endpoint_kind": requested_kind, "category": _classify_endpoint(base_url, requested_kind), @@ -2283,14 +2387,19 @@ def setup_model_routes(model_discovery): base_url = resolve_url(base_url) base_url = _rewrite_loopback_for_docker(base_url) requested_kind = _normalize_endpoint_kind(endpoint_kind) + is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless" + if is_featherless and requested_kind == "auto": + requested_kind = "api" configured_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60) probe_timeout = _explicit_model_list_timeout(base_url, requested_kind, configured_timeout) models = _probe_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout) ping = {"reachable": True, "error": None} if models else _ping_endpoint(base_url, api_key.strip() or None, timeout=min(probe_timeout, 10.0)) + is_online = bool(models) or bool(ping.get("reachable")) or (is_featherless and ping.get("reachable")) + is_status = "online" if (models or (is_featherless and ping.get("reachable"))) else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")) return { "base_url": base_url, - "online": bool(models) or bool(ping.get("reachable")), - "status": "online" if models else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")), + "online": is_online, + "status": is_status, "ping_error": ping.get("error") if ping else None, "models": models, "count": len(models), @@ -2305,9 +2414,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 +2470,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 +2481,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 +2525,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): @@ -2475,6 +2584,149 @@ def setup_model_routes(model_discovery): finally: db.close() + @router.get("/model-endpoints/{ep_id}/catalog-search") + async def search_endpoint_catalog( + ep_id: str, + request: Request, + q: str = Query(..., min_length=2, max_length=100), + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=100), + ): + """Search catalog for large-inventory providers like Featherless.""" + require_admin(request) + q_clean = q.strip() + if len(q_clean) < 2: + raise HTTPException(400, "Search query must be at least 2 characters") + + db = SessionLocal() + try: + ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first() + if not ep or not _chatgpt_endpoint_visible(ep, request): + raise HTTPException(404, "Endpoint not found") + base = _normalize_base(ep.base_url) + is_featherless = _host_match(base, "featherless.ai") or _safe_detect_provider(base) == "featherless" + if not is_featherless: + raise HTTPException(400, "Catalog search is only supported for Featherless endpoints") + api_key = _resolve_probe_key(ep) or (ep.api_key.strip() if getattr(ep, "api_key", None) else None) + if not api_key: + raise HTTPException(400, "Featherless endpoint has no API key configured") + finally: + db.close() + + try: + page = max(int(page or 1), 1) + except Exception: + page = 1 + try: + per_page = min(max(int(per_page or 50), 1), 100) + except Exception: + per_page = 50 + + # In-memory cache check + cache_key = (ep_id, q_clean.lower(), page, per_page) + now = _time.time() + with _featherless_search_cache_lock: + cached_entry = _featherless_search_cache.get(cache_key) + if cached_entry: + ts, cached_data = cached_entry + if now - ts < 45.0: + return cached_data + else: + _featherless_search_cache.pop(cache_key, None) + + # Build upstream URL and params + models_url = f"{base}/models" if base.endswith("/v1") else f"{base.rstrip('/')}/v1/models" + params = { + "q": q_clean, + "available_on_current_plan": "true", + "status": "active", + "conversational": "true", + "page": page, + "per_page": per_page, + } + headers = { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + } + + try: + async with httpx.AsyncClient(timeout=10.0, verify=llm_verify()) as client: + r = await client.get(models_url, params=params, headers=headers) + if r.status_code in (401, 403): + raise HTTPException(r.status_code, "Featherless API key invalid or unauthorized") + if r.status_code == 429: + raise HTTPException(429, "Featherless rate limit exceeded; please try again shortly") + if r.status_code >= 500: + raise HTTPException(502, f"Featherless upstream error: HTTP {r.status_code}") + if r.status_code >= 400: + raise HTTPException(r.status_code, f"Featherless API error: HTTP {r.status_code}") + data = r.json() + except httpx.HTTPStatusError as exc: + code = exc.response.status_code if exc.response is not None else 502 + if code in (401, 403): + raise HTTPException(code, "Featherless API key invalid or unauthorized") + if code == 429: + raise HTTPException(429, "Featherless rate limit exceeded; please try again shortly") + raise HTTPException(502 if code >= 500 else code, f"Featherless API error: HTTP {code}") + except httpx.TimeoutException: + raise HTTPException(504, "Featherless search request timed out") + except HTTPException: + raise + except Exception as exc: + logger.warning("Featherless catalog search failed: %s", exc) + raise HTTPException(502, f"Failed to connect to Featherless: {str(exc)[:120]}") + + raw_items = data.get("data") if isinstance(data, dict) else (data if isinstance(data, list) else []) + normalized_items = [] + for m in (raw_items or []): + if not isinstance(m, dict): + continue + m_id = m.get("id") + if not m_id or not isinstance(m_id, str): + continue + normalized_items.append({ + "id": m_id, + "name": m.get("name") or m_id, + "context_length": m.get("context_length"), + "max_completion_tokens": m.get("max_completion_tokens"), + "is_gated": bool(m.get("is_gated", False)), + "available_on_current_plan": bool(m.get("available_on_current_plan", True)), + }) + + total_val = None + if isinstance(data, dict): + for k in ("total", "count", "total_count"): + v = data.get(k) + if isinstance(v, (int, float)) and not isinstance(v, bool) and v >= 0: + total_val = int(v) + break + + if total_val is not None: + has_more = (page * per_page) < total_val and len(normalized_items) > 0 + else: + has_more = len(normalized_items) == per_page + + result = { + "items": normalized_items, + "page": page, + "per_page": per_page, + "has_more": has_more, + } + if total_val is not None: + result["total"] = total_val + + with _featherless_search_cache_lock: + if len(_featherless_search_cache) >= 200: + expired_keys = [k for k, (t, _) in _featherless_search_cache.items() if now - t >= 45.0] + for k in expired_keys: + _featherless_search_cache.pop(k, None) + if len(_featherless_search_cache) >= 200: + oldest_key = min(_featherless_search_cache.keys(), key=lambda k: _featherless_search_cache[k][0]) + _featherless_search_cache.pop(oldest_key, None) + _featherless_search_cache[cache_key] = (now, result) + + return result + @router.get("/default-chat") def get_default_chat(request: Request): # SECURITY: resolve the default endpoint + model from the CALLER's @@ -2573,7 +2825,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 +2879,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 +2939,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 +2949,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 +2970,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 +2991,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) @@ -2781,4 +3040,6 @@ def setup_model_routes(model_discovery): _save_settings(settings) return {"ok": True, "disabled": body.disabled} + router._should_refresh_endpoint = _should_refresh_endpoint + router._search_endpoint_catalog = search_endpoint_catalog return router diff --git a/routes/session_routes.py b/routes/session_routes.py index 58e262696..d6c1e8b42 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -455,13 +455,17 @@ def setup_session_routes( else (s.created_at.isoformat() if s.created_at else None)) ) matches = endpoint_routes.get((s.endpoint_url or '').rstrip('/'), []) - selected_endpoint = matches[0] if len(matches) == 1 else None + bound_id = getattr(s, "endpoint_id", None) + selected_endpoint = ( + next((ep for ep in matches if ep.id == bound_id), None) + if bound_id else (matches[0] if len(matches) == 1 else None) + ) sessions.append({ "id": s.id, "name": s.name, "model": _public_model(s.name, s.model), "endpoint_url": s.endpoint_url, - "endpoint_id": selected_endpoint.id if selected_endpoint else None, + "endpoint_id": bound_id or (selected_endpoint.id if selected_endpoint else None), "endpoint_name": selected_endpoint.name if selected_endpoint else None, "rag": s.rag, "archived": s.archived, @@ -590,6 +594,7 @@ def setup_session_routes( rag=str(rag).lower() == "true" if rag else False, owner=user, cwd=cwd or None, + endpoint_id=endpoint_id.strip() if endpoint_id else None, ) # Set auth headers for custom API-key endpoints resolved_key = request_api_key @@ -597,7 +602,8 @@ def setup_session_routes( if not resolved_key and endpoint_api_key: resolved_key = endpoint_api_key resolved_base = endpoint_base_url - if resolved_key: + from src.chatgpt_subscription import is_chatgpt_subscription_base + if resolved_key and not is_chatgpt_subscription_base(endpoint_url): from src.endpoint_resolver import build_headers session.headers = build_headers(resolved_key, resolved_base) _persist_session_headers(sid, session.headers) @@ -685,14 +691,26 @@ 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 session.headers = build_headers(endpoint_api_key, endpoint_base_url) else: session.headers = {} + if getattr(session, "thinking_mode", "").startswith("effort:"): + current_effort = session.thinking_mode[7:] + from src.chatgpt_subscription import get_chatgpt_model_metadata + meta = get_chatgpt_model_metadata(model) + if not meta or current_effort not in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]: + session.thinking_mode = "off" # Persist to DB db = SessionLocal() try: @@ -700,7 +718,9 @@ 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.thinking_mode = getattr(session, "thinking_mode", "off") or "off" db_session.updated_at = utcnow_naive() db.commit() finally: diff --git a/src/agent_loop.py b/src/agent_loop.py index 0af073fbc..f503d7e78 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -20364,6 +20364,7 @@ async def stream_agent_loop( force_textual_tool_transport: bool = False, thinking_mode: Optional[str] = None, suppress_skills: bool = False, + reasoning_effort: Optional[str] = None, ) -> AsyncGenerator[str, None]: """Streaming agent loop generator. @@ -21475,6 +21476,8 @@ async def stream_agent_loop( timeout=int(get_setting("agent_stream_timeout_seconds", 300) or 300), session_id=session_id, workload=workload, + thinking_mode=thinking_mode, + reasoning_effort=reasoning_effort, fallback_statuses=fallback_statuses, fallback_on_empty=fallback_on_empty, candidate_request_factory=_direct_candidate_request, @@ -25480,6 +25483,7 @@ async def stream_agent_loop( else _requested_temperature ), "thinking_mode": state.get("thinking_mode"), + "reasoning_effort": reasoning_effort, }, } @@ -26172,6 +26176,8 @@ async def stream_agent_loop( timeout=agent_stream_timeout, session_id=session_id, workload=workload, + thinking_mode=thinking_mode, + reasoning_effort=reasoning_effort, fallback_statuses=fallback_statuses, fallback_on_empty=fallback_on_empty, candidate_request_factory=_candidate_request, diff --git a/src/chatgpt_subscription.py b/src/chatgpt_subscription.py index e65ccbc8d..3a8cb682a 100644 --- a/src/chatgpt_subscription.py +++ b/src/chatgpt_subscription.py @@ -9,10 +9,13 @@ from __future__ import annotations import base64 import json +import math import os +import re import threading import time -from typing import Any, Dict, Optional +import unicodedata +from typing import Any, Dict, List, Optional import httpx from fastapi import HTTPException @@ -22,6 +25,16 @@ DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL = ( or "https://chatgpt.com/backend-api/codex" ) CHATGPT_SUBSCRIPTION_PROVIDER = "chatgpt-subscription" +# Legacy single-account endpoint/auth name. Rows provisioned before +# multi-account support keep this name and stay functional. +CHATGPT_SUBSCRIPTION_LEGACY_NAME = "ChatGPT Subscription" +CHATGPT_ACCOUNT_LABEL_MAX_LENGTH = 40 +# Read-only account usage (rate-limit windows) on the authenticated ChatGPT +# backend. Mirrors openai/codex ``backend-client`` ``PathStyle::ChatGptApi``: +# ``{base}/wham/usage`` where base is ``https://chatgpt.com/backend-api``. +CHATGPT_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" +CHATGPT_USAGE_TIMEOUT_SECONDS = 8.0 +CHATGPT_USAGE_CACHE_TTL_SECONDS = 45.0 CHATGPT_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" CHATGPT_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" CHATGPT_OAUTH_ISSUER = "https://auth.openai.com" @@ -87,6 +100,146 @@ 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() + + +KNOWN_CODEX_REASONING_LEVELS = [ + "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "persistent" +] + +STANDARD_CODEX_REASONING_LEVELS = [ + "low", "medium", "high", "xhigh", "max", "ultra" +] + +DEFAULT_CHATGPT_MODEL_CATALOG: dict[str, dict[str, Any]] = { + "gpt-6-astra": { + "default_reasoning_level": "low", + "supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max", "ultra"], + }, + "gpt-5.6-sol": { + "default_reasoning_level": "low", + "supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max", "ultra"], + }, + "gpt-5.6-terra": { + "default_reasoning_level": "medium", + "supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max", "ultra"], + }, + "gpt-5.6-luna": { + "default_reasoning_level": "medium", + "supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max", "ultra"], + }, + "gpt-5.5": { + "default_reasoning_level": "medium", + "supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max", "ultra"], + }, + "gpt-5.4": { + "default_reasoning_level": "medium", + "supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max", "ultra"], + }, + "codex-auto-review": { + "default_reasoning_level": "medium", + "supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max", "ultra"], + }, +} + +# Runtime cache of model metadata (updated dynamically whenever models are fetched) +CHATGPT_MODEL_CATALOG_CACHE: dict[str, dict[str, Any]] = dict(DEFAULT_CHATGPT_MODEL_CATALOG) + + +def _extract_reasoning_levels(item: dict) -> list[str]: + raw_levels = item.get("supported_reasoning_levels") or item.get("supportedReasoningEfforts") + if not isinstance(raw_levels, list): + return [] + levels: list[str] = [] + for entry in raw_levels: + if isinstance(entry, dict): + effort = entry.get("effort") or entry.get("level") or entry.get("name") + if effort and isinstance(effort, str): + levels.append(effort.strip().lower()) + elif isinstance(entry, str) and entry.strip(): + levels.append(entry.strip().lower()) + return levels + + +def get_chatgpt_model_metadata(slug: str) -> Optional[dict[str, Any]]: + slug = (slug or "").strip() + if not slug: + return None + if slug in CHATGPT_MODEL_CATALOG_CACHE: + return dict(CHATGPT_MODEL_CATALOG_CACHE[slug]) + for k, v in CHATGPT_MODEL_CATALOG_CACHE.items(): + if k.casefold() == slug.casefold(): + return dict(v) + slug_lower = slug.lower() + if any(pat in slug_lower for pat in ("gpt-6", "gpt-5.6", "gpt-5.5", "gpt-5.4", "codex")): + return { + "default_reasoning_level": "medium", + "supported_reasoning_levels": list(STANDARD_CODEX_REASONING_LEVELS), + } + return None + + +def validate_reasoning_effort(model: str, effort: Optional[str]) -> Optional[str]: + """Validate reasoning effort against model's advertised levels. + Returns None if default/empty/unsupported (fail-safe to omitting override).""" + if not effort: + return None + effort_clean = str(effort).strip().lower() + if effort_clean in {"", "default"}: + return None + meta = get_chatgpt_model_metadata(model) + if not meta: + return None + supported = [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])] + if effort_clean in supported: + return effort_clean + return None + + def fetch_available_models(access_token: str, timeout: float = 10.0) -> list[str]: if not access_token: return [] @@ -109,12 +262,20 @@ def fetch_available_models(access_token: str, timeout: float = 10.0) -> list[str slug = item.get("slug") if not isinstance(slug, str) or not slug.strip(): continue + slug_clean = slug.strip() visibility = item.get("visibility", "") if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}: continue + levels = _extract_reasoning_levels(item) + default_lvl = item.get("default_reasoning_level") or item.get("defaultReasoningEffort") + if levels: + CHATGPT_MODEL_CATALOG_CACHE[slug_clean] = { + "default_reasoning_level": str(default_lvl).strip().lower() if default_lvl else (levels[0] if levels else "medium"), + "supported_reasoning_levels": levels, + } priority = item.get("priority") rank = int(priority) if isinstance(priority, (int, float)) else 10_000 - sortable.append((rank, slug.strip())) + sortable.append((rank, slug_clean)) sortable.sort(key=lambda item: (item[0], item[1])) ordered: list[str] = [] seen: set[str] = set() @@ -135,14 +296,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 +414,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 +445,359 @@ def resolve_runtime_credentials(auth_id: str, owner: Optional[str] = None, *, fo db.close() +def find_owned_auth_session(db, auth_id: str, owner: Optional[str]): + """Return the owner-scoped ChatGPT ProviderAuthSession row or None. + + OAuth credentials belong to exactly one owner, including the legacy + anonymous owner. Labels are never used for lookup. + """ + ProviderAuthSession, _SessionLocal, _now = _database_handles() + auth_id = (auth_id or "").strip() + if not auth_id: + return None + q = db.query(ProviderAuthSession).filter( + ProviderAuthSession.id == auth_id, + ProviderAuthSession.provider == CHATGPT_SUBSCRIPTION_PROVIDER, + ) + return q.filter(ProviderAuthSession.owner == owner).first() + + +def chatgpt_account_id_from_token(access_token: str) -> Optional[str]: + """Extract the ChatGPT account id claim from an access token, if any.""" + try: + payload = _decode_jwt_payload(access_token) + except Exception: + return None + auth_claims = payload.get("https://api.openai.com/auth") + if isinstance(auth_claims, dict): + account_id = auth_claims.get("chatgpt_account_id") + if isinstance(account_id, str) and account_id.strip(): + return account_id.strip() + return None + + +def usage_request_headers(access_token: str) -> Dict[str, str]: + headers = { + "Accept": "application/json", + "User-Agent": "Odysseus ChatGPT Subscription", + "Authorization": f"Bearer {access_token}", + } + account_id = chatgpt_account_id_from_token(access_token) + if account_id: + headers["ChatGPT-Account-Id"] = account_id + return headers + + +class ChatGPTUsageUnavailable(ChatGPTSubscriptionError): + """Usage telemetry could not be read; the model endpoint is unaffected.""" + + def __init__(self, reason: str, message: str, *, status_code: Optional[int] = None): + super().__init__(message) + self.reason = reason + self.status_code = status_code + + +def _coerce_number(value: Any) -> Optional[float]: + if isinstance(value, bool): + return None + if isinstance(value, (int, float, str)): + try: + number = float(value) + return number if math.isfinite(number) else None + except (ValueError, OverflowError): + return None + return None + + +def _coerce_int(value: Any) -> Optional[int]: + number = _coerce_number(value) + if number is None: + return None + try: + return int(number) + except (OverflowError, ValueError): + return None + + +def _optional_str(value: Any) -> Optional[str]: + if isinstance(value, str): + text = value.strip() + return text or None + return None + + +def window_minutes_from_seconds(seconds: Any) -> Optional[int]: + """Codex-compatible ceil(seconds / 60); None for missing/non-positive.""" + value = _coerce_int(seconds) + if value is None or value <= 0: + return None + return (value + 59) // 60 + + +def friendly_window_name(window_minutes: Optional[int]) -> str: + """Compact label derived from the actual window length (never assumed).""" + if not window_minutes or window_minutes <= 0: + return "LIMIT" + if window_minutes % (7 * 24 * 60) == 0: + weeks = window_minutes // (7 * 24 * 60) + return "WEEK" if weeks == 1 else f"{weeks}W" + if window_minutes % (24 * 60) == 0: + return f"{window_minutes // (24 * 60)}D" + if window_minutes % 60 == 0: + return f"{window_minutes // 60}H" + return f"{window_minutes}M" + + +def normalize_usage_window(raw: Any, kind: str) -> Optional[Dict[str, Any]]: + """Normalize one ``primary_window``/``secondary_window`` snapshot. + + Upstream fields (openai/codex ``RateLimitWindowSnapshot``): ``used_percent``, + ``limit_window_seconds``, ``reset_after_seconds``, ``reset_at``. Absent + values stay ``None``; nothing is invented. + """ + if not isinstance(raw, dict): + return None + used_percent = _coerce_number(raw.get("used_percent")) + if used_percent is not None: + used_percent = max(0.0, min(100.0, used_percent)) + window_minutes = window_minutes_from_seconds(raw.get("limit_window_seconds")) + if window_minutes is None: + window_minutes = _coerce_int(raw.get("window_minutes")) + if window_minutes is not None and window_minutes <= 0: + window_minutes = None + resets_at = _coerce_int(raw.get("reset_at")) + if resets_at is None: + resets_at = _coerce_int(raw.get("resets_at")) + if resets_at is not None and resets_at <= 0: + resets_at = None + reset_after_seconds = _coerce_int(raw.get("reset_after_seconds")) + if reset_after_seconds is not None and reset_after_seconds < 0: + reset_after_seconds = None + return { + "kind": kind, + "name": friendly_window_name(window_minutes), + "used_percent": used_percent, + "remaining_percent": (None if used_percent is None else round(100.0 - used_percent, 2)), + "window_minutes": window_minutes, + "resets_at": resets_at, + "reset_after_seconds": reset_after_seconds, + } + + +def _normalize_rate_limit_details(raw: Any) -> Dict[str, Any]: + details = raw if isinstance(raw, dict) else {} + windows: List[Dict[str, Any]] = [] + # Preserve new window kinds without assigning a duration to their names. + keys = ["primary_window", "secondary_window"] + keys.extend(key for key in details if key.endswith("_window") and key not in keys) + for key in keys: + window = normalize_usage_window(details.get(key), key[:-7]) + if window is not None: + windows.append(window) + allowed = details.get("allowed") + limit_reached = details.get("limit_reached") + return { + "allowed": allowed if isinstance(allowed, bool) else None, + "limit_reached": limit_reached if isinstance(limit_reached, bool) else None, + "windows": windows, + } + + +def normalize_usage_payload(payload: Any) -> Dict[str, Any]: + """Normalize a ``GET /wham/usage`` JSON body into Odysseus' safe contract. + + Returns only non-credential fields. The main Codex limit is reported as + ``limit_id == "codex"`` (as openai/codex does); each entry of + ``additional_rate_limits`` becomes its own bucket keyed by + ``metered_feature``. Unknown fields are ignored, unknown buckets kept. + """ + if not isinstance(payload, dict): + raise ChatGPTUsageUnavailable("malformed", "ChatGPT usage response was not a JSON object.") + + limits: List[Dict[str, Any]] = [] + main = _normalize_rate_limit_details(payload.get("rate_limit")) + limits.append({ + "limit_id": "codex", + "limit_name": None, + "normal_model_slug": None, + "allowed": main["allowed"], + "limit_reached": main["limit_reached"], + "windows": main["windows"], + }) + + additional = payload.get("additional_rate_limits") + if isinstance(additional, list): + for entry in additional: + if not isinstance(entry, dict): + continue + details = _normalize_rate_limit_details(entry.get("rate_limit")) + limit_name = _optional_str(entry.get("limit_name")) + limit_id = _optional_str(entry.get("metered_feature")) or limit_name + if not limit_id and not details["windows"]: + continue + limits.append({ + "limit_id": limit_id or "additional", + "limit_name": limit_name, + "normal_model_slug": _optional_str(entry.get("normal_model_slug")), + "allowed": details["allowed"], + "limit_reached": details["limit_reached"], + "windows": details["windows"], + }) + + reached = payload.get("rate_limit_reached_type") + if isinstance(reached, dict): + reached = _optional_str(reached.get("type") or reached.get("kind")) + else: + reached = _optional_str(reached) + + ordinary_usage_allowed = main["allowed"] + return { + "account_id": _optional_str(payload.get("account_id")), + "plan_type": _optional_str(payload.get("plan_type")), + "ordinary_usage_allowed": ordinary_usage_allowed, + "rate_limit_reached_type": reached, + "limits": limits, + } + + +def fetch_usage_payload(access_token: str, timeout: float = CHATGPT_USAGE_TIMEOUT_SECONDS) -> Dict[str, Any]: + """Read the raw usage JSON for one access token; classify failures.""" + if not access_token: + raise ChatGPTUsageUnavailable("reauth", "ChatGPT Subscription has no access token.") + try: + response = httpx.get(CHATGPT_USAGE_URL, headers=usage_request_headers(access_token), timeout=timeout) + except httpx.TimeoutException as exc: + raise ChatGPTUsageUnavailable("timeout", "ChatGPT usage request timed out.") from exc + except httpx.HTTPError as exc: + raise ChatGPTUsageUnavailable("network", "ChatGPT usage request failed.") from exc + status = response.status_code + if status in (401, 403): + raise ChatGPTUsageUnavailable( + "reauth", + "ChatGPT rejected the usage request; the account may need reconnecting.", + status_code=status, + ) + if status == 429: + raise ChatGPTUsageUnavailable("rate_limited", "ChatGPT usage is temporarily rate limited.", status_code=status) + if status >= 500: + raise ChatGPTUsageUnavailable("upstream", f"ChatGPT usage service returned HTTP {status}.", status_code=status) + if status != 200: + raise ChatGPTUsageUnavailable("upstream", f"ChatGPT usage request returned HTTP {status}.", status_code=status) + try: + data = response.json() + except Exception as exc: + raise ChatGPTUsageUnavailable("malformed", "ChatGPT usage response was not valid JSON.") from exc + if not isinstance(data, dict): + raise ChatGPTUsageUnavailable("malformed", "ChatGPT usage response was not a JSON object.") + return data + + +class UsageCache: + """Short per-auth-session cache for normalized usage snapshots.""" + + def __init__(self, ttl_seconds: float = CHATGPT_USAGE_CACHE_TTL_SECONDS, time_func=time.monotonic, max_entries: int = 256): + self._ttl = float(ttl_seconds) + self._max_entries = max(1, int(max_entries)) + self._time = time_func + self._entries: Dict[str, tuple] = {} + self._lock = threading.Lock() + + def get(self, auth_id: str) -> Optional[Dict[str, Any]]: + now = float(self._time()) + with self._lock: + entry = self._entries.get(auth_id) + if entry is None: + return None + stored_at, value = entry + if now - stored_at >= self._ttl: + self._entries.pop(auth_id, None) + return None + return json.loads(json.dumps(value)) + + def put(self, auth_id: str, value: Dict[str, Any]) -> None: + with self._lock: + now = float(self._time()) + for key, (stored_at, _) in list(self._entries.items()): + if now - stored_at >= self._ttl: + self._entries.pop(key, None) + self._entries.pop(auth_id, None) + while len(self._entries) >= self._max_entries: + self._entries.pop(next(iter(self._entries))) + self._entries[auth_id] = (now, json.loads(json.dumps(value))) + + def invalidate(self, auth_id: str) -> None: + with self._lock: + self._entries.pop(auth_id, None) + + def clear(self) -> None: + with self._lock: + self._entries.clear() + + +USAGE_CACHE = UsageCache() + + +def get_account_usage( + auth_id: str, + owner: Optional[str] = None, + *, + force_refresh: bool = False, + cache: Optional[UsageCache] = None, +) -> Dict[str, Any]: + """Return normalized usage for exactly one owner-scoped auth session. + + The access token is resolved (and refreshed if needed) for that auth + session only. Results are cached per auth id; ``force_refresh`` bypasses + and replaces the cached entry. Raises :class:`ChatGPTUsageUnavailable` on + any read failure; callers must not treat that as an endpoint failure. + """ + cache = USAGE_CACHE if cache is None else cache + auth_id = (auth_id or "").strip() + if not auth_id: + raise ChatGPTSubscriptionAuthNotFound("ChatGPT Subscription account was not found.") + # Authorize even cache hits: cached telemetry must not outlive ownership. + _Auth, SessionLocal, _now = _database_handles() + db = SessionLocal() + try: + if find_owned_auth_session(db, auth_id, owner) is None: + raise ChatGPTSubscriptionAuthNotFound("ChatGPT Subscription account was not found.") + finally: + db.close() + if not force_refresh: + cached = cache.get(auth_id) + if cached is not None: + cached["cached"] = True + return cached + else: + cache.invalidate(auth_id) + try: + creds = resolve_runtime_credentials(auth_id, owner=owner) + except ChatGPTSubscriptionAuthNotFound: + raise + except ChatGPTSubscriptionRateLimited as exc: + raise ChatGPTUsageUnavailable("rate_limited", str(exc), status_code=429) from exc + except ChatGPTSubscriptionReauthRequired as exc: + raise ChatGPTUsageUnavailable("reauth", str(exc), status_code=401) from exc + except ChatGPTSubscriptionError as exc: + raise ChatGPTUsageUnavailable("upstream", str(exc)) from exc + raw = fetch_usage_payload(creds.get("api_key") or "") + normalized = normalize_usage_payload(raw) + normalized["auth_id"] = auth_id + normalized["fetched_at"] = int(time.time()) + cache.put(auth_id, normalized) + result = json.loads(json.dumps(normalized)) + result["cached"] = False + return result + + def to_http_exception(exc: Exception) -> HTTPException: if isinstance(exc, ChatGPTSubscriptionRateLimited): return HTTPException(429, str(exc)) if isinstance(exc, (ChatGPTSubscriptionReauthRequired, ChatGPTSubscriptionAuthNotFound)): return HTTPException(401, f"{exc} Reconnect the provider.") - return HTTPException(502, str(exc)) + if isinstance(exc, (ChatGPTSubscriptionError, ValueError)): + return HTTPException(502, str(exc)) + return HTTPException(502, "ChatGPT Subscription request failed.") def build_responses_input(messages: list[dict]) -> list[dict]: diff --git a/src/foreground_model_routing.py b/src/foreground_model_routing.py index 76e254614..898aa2ca8 100644 --- a/src/foreground_model_routing.py +++ b/src/foreground_model_routing.py @@ -191,7 +191,16 @@ def build_foreground_route_descriptors( owner=owner, ) if selected is None: - selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner) + if selected_endpoint_id: + # An explicit identity must never be relabelled as another account + # with the same URL/model (or even the same current bearer). + selected = { + "endpoint_id": None, + "endpoint_label": "Selected route", + "endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url), + } + else: + selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner) primary = (endpoint_url, model, headers or {}) candidates = [primary] descriptors = [selected] diff --git a/src/llm_core.py b/src/llm_core.py index 7b7298ad7..0908b56bb 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -254,7 +254,8 @@ def _cache_header_identity(headers) -> str: def _get_cache_key(url: str, model: str, messages: List[Dict], temperature: float, max_tokens: int, headers=None, - thinking_mode: Optional[str] = None) -> str: + thinking_mode: Optional[str] = None, + reasoning_effort: Optional[str] = None) -> str: """Generate a cache key partitioned by endpoint and credential identity.""" hashable_messages = [] for msg in messages: @@ -268,6 +269,7 @@ def _get_cache_key(url: str, model: str, messages: List[Dict], 'temp': temperature, 'max_tokens': max_tokens, 'thinking_mode': _normalize_thinking_mode(thinking_mode), + 'reasoning_effort': str(reasoning_effort or "").strip().lower(), # Never put credentials in a cache key or loggable cache payload. The # digest only prevents responses from one configured account/route # being returned under another route with the same URL and model. @@ -1097,6 +1099,8 @@ def _detect_provider(url: str) -> str: from src.copilot import is_copilot_base if is_copilot_base(url): return "copilot" + if _host_match(url, "featherless.ai"): + return "featherless" if _host_match(url, "cerebras.ai"): return "cerebras" if _host_match(url, "mistral.ai"): @@ -1328,6 +1332,7 @@ def _provider_label(url: str) -> str: if is_chatgpt_subscription_base(url): return "ChatGPT Subscription" from src.copilot import is_copilot_base if is_copilot_base(url): return "GitHub Copilot" + if _host_match(url, "featherless.ai"): return "Featherless.ai" if _host_match(url, "cerebras.ai"): return "cerebras" if _host_match(url, "mistral.ai"): return "Mistral" @@ -1496,6 +1501,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 +1535,19 @@ def _build_chatgpt_responses_payload( max_tokens: int, *, stream: bool = False, + tools: Optional[List[Dict]] = None, + reasoning_effort: Optional[str] = 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 +1561,29 @@ 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 + if reasoning_effort and str(reasoning_effort).strip().lower() not in {"", "default"}: + payload["reasoning"] = {"effort": str(reasoning_effort).strip().lower()} + return _strip_chatgpt_native_tool_surfaces(payload) + + +CHATGPT_ALLOWED_PAYLOAD_KEYS = frozenset({ + "model", "instructions", "input", "stream", "store", "temperature", "reasoning", +}) + + +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: @@ -2519,6 +2583,7 @@ async def llm_call_async( availability_only_transport: bool = False, return_model_metadata: bool = False, thinking_mode: Optional[str] = None, + reasoning_effort: Optional[str] = None, ) -> str | tuple[str, str]: """Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging.""" provider = _detect_provider(url) @@ -2557,7 +2622,7 @@ async def llm_call_async( cache_key = _get_cache_key( url, model, messages_copy, temperature, max_tokens, headers=headers, - thinking_mode=thinking_mode, + thinking_mode=thinking_mode, reasoning_effort=reasoning_effort, ) cached_response = _get_cached_response(cache_key) if cached_response: @@ -2581,6 +2646,8 @@ async def llm_call_async( headers=headers, timeout=timeout, workload=workload, + thinking_mode=thinking_mode, + reasoning_effort=reasoning_effort, ): event_is_error = False for line in str(chunk).splitlines(): @@ -2835,7 +2902,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None, tools: Optional[List[Dict]] = None, session_id: Optional[str] = None, tool_choice_none: bool = False, workload: str = "foreground", - thinking_mode: Optional[str] = None): + thinking_mode: Optional[str] = None, reasoning_effort: Optional[str] = None): target_url = _stream_target_url(url) async with _local_model_slot(target_url, model, workload): async for chunk in _stream_llm_inner( @@ -2851,6 +2918,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl session_id=session_id, tool_choice_none=tool_choice_none, thinking_mode=thinking_mode, + reasoning_effort=reasoning_effort, ): yield chunk @@ -2860,6 +2928,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None, tools: Optional[List[Dict]] = None, session_id: Optional[str] = None, tool_choice_none: bool = False, thinking_mode: Optional[str] = None, + reasoning_effort: Optional[str] = None, _retry_silent_local: bool = True): """Stream LLM responses with improved error handling. @@ -2902,7 +2971,10 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat elif provider == "chatgpt-subscription": target_url = _normalize_chatgpt_subscription_url(url) h = _provider_headers(provider, headers) - payload = _build_chatgpt_responses_payload(model, messages_copy, temperature, max_tokens, stream=True) + payload = _build_chatgpt_responses_payload( + model, messages_copy, temperature, max_tokens, stream=True, + reasoning_effort=reasoning_effort, + ) else: target_url = _normalize_openai_chat_url(url) payload = { @@ -2977,8 +3049,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 +3135,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 +3154,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 +3171,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 +3270,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 +3454,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat if r.status_code != 200: raw = (await r.aread()).decode(errors="replace") friendly = _format_upstream_error(r.status_code, raw, target_url) - yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly, "raw": raw[:500]})}\n\n' + yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly})}\n\n' return first_token_budget = _first_token_timeout(target_url, timeout) diff --git a/src/request_models.py b/src/request_models.py index 5b2b5928e..dde1c59aa 100644 --- a/src/request_models.py +++ b/src/request_models.py @@ -13,6 +13,7 @@ class ChatRequest(BaseModel): time_filter: Optional[str] = Field(default=None, description="Time filter for search") preset_id: Optional[str] = Field(default=None, description="Preset identifier") selected_endpoint_id: Optional[str] = Field(default=None, description="Selected model endpoint ID") + reasoning_effort: Optional[str] = Field(default=None, description="Reasoning effort override") @field_validator('message') @classmethod diff --git a/static/app.js b/static/app.js index 0a562ac73..8c04bfd47 100644 --- a/static/app.js +++ b/static/app.js @@ -37,7 +37,7 @@ import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=2 import tasksModule from './js/tasks.js?v=20260914taskmodel1'; import calendarModule from './js/calendar.js?v=20260914emailsource11'; import notesModule from './js/notes.js?v=20260911notesselectioncancel1'; -import adminModule from './js/admin.js?v=20260914toolschemaprofiles1'; +import adminModule from './js/admin.js?v=20260921chatgptusage2'; import settingsModule from './js/settings.js?v=20260912writingstyle3'; // Eagerly bind unified minimize/restore behavior across all tool modals. import './js/modalManager.js'; diff --git a/static/index.html b/static/index.html index 808f78340..e54df3675 100644 --- a/static/index.html +++ b/static/index.html @@ -246,8 +246,8 @@ real request is discarded and the font fetched a second time. --> - - + + @@ -1195,6 +1195,14 @@
+ +
@@ -2306,6 +2314,7 @@ + @@ -2623,7 +2632,7 @@ - + diff --git a/static/js/admin.js b/static/js/admin.js index 0dccd8e68..3e3463e9e 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -6,6 +6,12 @@ import settingsModule from './settings.js?v=20260912writingstyle3'; import { providerLogo, providerLogoFromUrl } from './providers.js'; import { sortModelObjects } from './modelSort.js'; import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js'; +import { + accountTitle as chatgptAccountTitle, + buildUsageViewModel as buildChatgptUsageViewModel, + isChatgptSubscriptionEndpoint, + renderUsageCardHtml as renderChatgptUsageCardHtml, +} from './chatgptSubscriptionUsage.js'; import { getSettings, getTools, invalidateSettings, invalidateTools } from './appConfig.js'; let initialized = false; @@ -593,6 +599,380 @@ function _ensureEndpointBulkControls() { _updateEndpointBulkControls(); } +function shouldDisplayEndpointBaseUrl(ep) { + if (!ep || !ep.base_url) return false; + if (isChatgptSubscriptionEndpoint(ep)) return false; + if (isFeatherlessEndpoint(ep)) return false; + return true; +} + +function isFeatherlessEndpoint(ep) { + if (!ep) return false; + if (ep.provider === 'featherless') return true; + const url = String(ep.base_url || '').toLowerCase(); + try { + const host = new URL(url).hostname; + return host === 'api.featherless.ai' || host.endsWith('.featherless.ai'); + } catch (_) { + return url.includes('api.featherless.ai'); + } +} + +function endpointDetailHtml(ep, category) { + const showUrl = shouldDisplayEndpointBaseUrl(ep); + const copyBtn = (showUrl && category === 'local') + ? `` + : ''; + const keyLabel = ep.has_key + ? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)') + : ''; + const parts = []; + if (showUrl) { + parts.push(esc(ep.base_url) + copyBtn); + if (keyLabel) parts.push(keyLabel); + } else if (keyLabel) { + parts.push(keyLabel.trim()); + } + if (!parts.length) return ''; + return `
${parts.join('')}
`; +} + +function renderFeatherlessPanel(panel, ep, row) { + const epId = ep.id; + const initialPinned = Array.isArray(ep.pinned_models) + ? ep.pinned_models + : (typeof ep.pinned_models === 'string' ? JSON.parse(ep.pinned_models || '[]') : []); + const enabledSet = new Set(initialPinned); + const toolModes = typeof ep.model_tool_modes === 'object' && ep.model_tool_modes !== null + ? { ...ep.model_tool_modes } + : {}; + panel.dataset.pickerMode = 'pinned'; + + panel.innerHTML = `
+ Featherless Catalog +
+
+ +
+
+ Enabled models (${enabledSet.size}) +
+
+
+
+
+ Search results +
+
+ Type at least 2 characters to search over 20,000+ models. +
+ +
+
`; + + const searchInput = panel.querySelector('.featherless-search-input'); + const spinnerHost = panel.querySelector('.featherless-spinner-host'); + const enabledListEl = panel.querySelector('.featherless-enabled-list'); + const enabledCountSpan = panel.querySelector('.featherless-enabled-count'); + const resultsList = panel.querySelector('.featherless-results-list'); + const paginationHost = panel.querySelector('.featherless-pagination'); + const loadMoreBtn = panel.querySelector('.featherless-load-more'); + + const showSpinner = () => { if (spinnerHost) spinnerHost.style.display = 'inline-flex'; }; + const hideSpinner = () => { if (spinnerHost) spinnerHost.style.display = 'none'; }; + + const formatTokens = (tokens) => { + if (!tokens || typeof tokens !== 'number') return ''; + if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}M`; + if (tokens >= 1000) return `${Math.round(tokens / 1024)}k`; + return String(tokens); + }; + + const updateHeaderCount = () => { + const countBadge = row ? row.querySelector(`[data-adm-ep-models-count="${epId}"]`) : null; + if (countBadge) { + countBadge.textContent = `${enabledSet.size} models enabled`; + } + if (enabledCountSpan) { + enabledCountSpan.textContent = String(enabledSet.size); + } + ep.pinned_models = Array.from(enabledSet); + }; + + const saveState = async () => { + try { + await fetch(`/api/model-endpoints/${epId}/models`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + pinned_models: Array.from(enabledSet), + model_tool_modes: toolModes, + }), + }); + if (typeof _refreshAfterEndpointChange === 'function') { + _refreshAfterEndpointChange(); + } + } catch (err) { + console.error('Failed to save Featherless model state', err); + } + }; + + const syncSearchCheckboxes = () => { + resultsList.querySelectorAll('input[data-featherless-search-id]').forEach(cb => { + const id = cb.dataset.featherlessSearchId; + cb.checked = enabledSet.has(id); + }); + }; + + const renderEnabledList = () => { + if (!enabledListEl) return; + if (enabledSet.size === 0) { + enabledListEl.innerHTML = 'No models enabled. Search below to add models.'; + return; + } + const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b)); + enabledListEl.innerHTML = sortedIds.map(id => { + const displayName = id.split('/').pop() || id; + const mode = ['none', 'compact', 'full'].includes(String(toolModes[id] || '').toLowerCase()) + ? String(toolModes[id]).toLowerCase() + : ''; + return `
+ +
+ Tools + +
+
`; + }).join(''); + + enabledListEl.querySelectorAll('input[data-featherless-enabled-id]').forEach(cb => { + cb.addEventListener('change', () => { + const id = cb.dataset.featherlessEnabledId; + if (!cb.checked) { + enabledSet.delete(id); + updateHeaderCount(); + renderEnabledList(); + syncSearchCheckboxes(); + saveState(); + } + }); + }); + + enabledListEl.querySelectorAll('.adm-model-tool-mode').forEach(sel => { + sel.addEventListener('change', () => { + const id = sel.dataset.epModelId; + const val = String(sel.value || '').toLowerCase(); + if (val) toolModes[id] = val; + else delete toolModes[id]; + saveState(); + }); + }); + }; + + renderEnabledList(); + + let currentQuery = ''; + let currentPage = 1; + let searchAbortController = null; + let searchTimeout = null; + let isSearching = false; + + const renderSearchResults = (items, append = false, hasMore = false) => { + if (!append) { + resultsList.innerHTML = ''; + } + if (!items || items.length === 0) { + if (!append) { + resultsList.innerHTML = 'No models found matching your search.'; + } + if (paginationHost) paginationHost.style.display = 'none'; + return; + } + + const itemsHtml = items.map(item => { + const isChecked = enabledSet.has(item.id); + const displayName = item.name || item.id; + const ctx = item.context_length ? `${formatTokens(item.context_length)} ctx` : ''; + return `
+ +
`; + }).join(''); + + if (append) { + resultsList.insertAdjacentHTML('beforeend', itemsHtml); + } else { + resultsList.innerHTML = itemsHtml; + } + + resultsList.querySelectorAll('input[data-featherless-search-id]').forEach(cb => { + if (cb.dataset.listenerAttached) return; + cb.dataset.listenerAttached = '1'; + cb.addEventListener('change', () => { + const id = cb.dataset.featherlessSearchId; + if (cb.checked) { + enabledSet.add(id); + } else { + enabledSet.delete(id); + } + updateHeaderCount(); + renderEnabledList(); + syncSearchCheckboxes(); + saveState(); + }); + }); + + if (paginationHost) { + paginationHost.style.display = hasMore ? '' : 'none'; + } + }; + + if (searchInput) { + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + } + }); + + searchInput.addEventListener('input', () => { + if (searchTimeout) clearTimeout(searchTimeout); + searchTimeout = setTimeout(async () => { + const q = searchInput.value.trim(); + if (q.length < 2) { + if (searchAbortController) searchAbortController.abort(); + hideSpinner(); + resultsList.innerHTML = 'Type at least 2 characters to search over 20,000+ models.'; + if (paginationHost) paginationHost.style.display = 'none'; + return; + } + + if (searchAbortController) { + searchAbortController.abort(); + } + searchAbortController = new AbortController(); + currentQuery = q; + currentPage = 1; + showSpinner(); + + try { + const res = await fetch(`/api/model-endpoints/${epId}/catalog-search?q=${encodeURIComponent(q)}&page=1&per_page=50`, { + credentials: 'same-origin', + signal: searchAbortController.signal, + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.detail || `HTTP ${res.status}`); + } + const data = await res.json(); + renderSearchResults(data.items, false, data.has_more); + } catch (err) { + if (err.name === 'AbortError') return; + resultsList.innerHTML = `Search failed: ${esc(err.message)}`; + if (paginationHost) paginationHost.style.display = 'none'; + } finally { + hideSpinner(); + } + }, 250); + }); + } + + if (loadMoreBtn) { + loadMoreBtn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!currentQuery || isSearching) return; + isSearching = true; + loadMoreBtn.disabled = true; + loadMoreBtn.textContent = 'Loading...'; + currentPage += 1; + + try { + const res = await fetch(`/api/model-endpoints/${epId}/catalog-search?q=${encodeURIComponent(currentQuery)}&page=${currentPage}&per_page=50`, { + credentials: 'same-origin', + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.detail || `HTTP ${res.status}`); + } + const data = await res.json(); + renderSearchResults(data.items, true, data.has_more); + } catch (err) { + if (typeof uiModule !== 'undefined' && uiModule?.showToast) { + uiModule.showToast(`Failed to load more models: ${err.message}`, 4000); + } + } finally { + isSearching = false; + loadMoreBtn.disabled = false; + loadMoreBtn.textContent = 'Load more'; + } + }); + } +} + +// ChatGPT per-endpoint usage panel expanded state persistence. +// Preserves only endpoint/auth identifiers, never tokens, secrets, or labels. +const CHATGPT_USAGE_EXPANDED_KEY = 'odysseus-chatgpt-usage-expanded'; + +function _loadExpandedUsageEndpoints() { + try { + const raw = localStorage.getItem(CHATGPT_USAGE_EXPANDED_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return new Set(parsed.filter(x => typeof x === 'string' && x.length > 0)); + } + } catch (_) {} + return new Set(); +} + +function _saveExpandedUsageEndpoints(set) { + try { + const arr = Array.from(set).filter(x => typeof x === 'string' && x.length > 0); + localStorage.setItem(CHATGPT_USAGE_EXPANDED_KEY, JSON.stringify(arr)); + } catch (_) {} +} + +function _isChatgptUsageExpanded(endpointId, authId) { + const set = _loadExpandedUsageEndpoints(); + if (endpointId != null && set.has(String(endpointId))) return true; + if (authId != null && set.has(String(authId))) return true; + return false; +} + +function _setChatgptUsageExpanded(endpointId, authId, expanded) { + const set = _loadExpandedUsageEndpoints(); + const epKey = endpointId != null ? String(endpointId) : null; + const authKey = authId != null ? String(authId) : null; + if (expanded) { + if (epKey) set.add(epKey); + if (authKey) set.add(authKey); + } else { + if (epKey) set.delete(epKey); + if (authKey) set.delete(authKey); + } + _saveExpandedUsageEndpoints(set); +} + async function loadEndpoints() { const listLocal = el('adm-epList-local'); const listApi = el('adm-epList-api'); @@ -640,18 +1020,24 @@ async function loadEndpoints() { // `ep.models` is the *visible* set — when every model is hidden it's // empty, but we still need to render the expand panel so the user can // un-hide them. Gate on the total instead. - const hasModels = ep.online && totalCount > 0; + const isChatgptAccount = isChatgptSubscriptionEndpoint(ep); + const isFeatherless = isFeatherlessEndpoint(ep); + const hasModels = ep.online && (totalCount > 0 || isFeatherless); + const countText = (isChatgptAccount || isFeatherless) + ? `${visibleCount} models enabled` + : `${visibleCount}/${totalCount} models enabled`; const statusBadge = ep.status === 'empty' ? 'no models' : ep.online - ? `${visibleCount}/${totalCount} models enabled` + ? `${countText}` : 'offline'; const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : ''; const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api'); const kindLabel = ep.endpoint_kind && ep.endpoint_kind !== 'auto' ? ep.endpoint_kind.toUpperCase() : ''; - const keyLabel = ep.has_key - ? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)') - : ''; + const isUsageExpanded = isChatgptAccount && _isChatgptUsageExpanded(ep.id, ep.provider_auth_id); + const epTitle = isChatgptAccount + ? chatgptAccountTitle(ep) + : (isFeatherless && (!ep.name || ep.name === 'api.featherless.ai') ? 'Featherless.ai' : ep.name); return `
@@ -661,7 +1047,7 @@ async function loadEndpoints() { ` : ''} - ${esc(ep.name)} + ${esc(epTitle)} ${ep.model_type === 'image' ? 'Image' : ''} ${kindLabel ? `${esc(kindLabel)}` : ''} ${statusBadge} @@ -675,7 +1061,15 @@ async function loadEndpoints() { ${hasModels ? '' : ''}`}
-
${esc(ep.base_url)}${category === 'local' ? `` : ''}${keyLabel}
+ ${isChatgptAccount ? ` +
+ + +
+ ` : ''} + ${endpointDetailHtml(ep, category)} ${hasModels ? `` : ''}
`; }); @@ -715,6 +1109,66 @@ 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()); + const epId = host.dataset.chatgptEndpointId; + const authId = host.dataset.admChatgptUsageHost; + if (_isChatgptUsageExpanded(epId, authId)) { + _loadChatgptUsage(host, host.dataset.admChatgptUsageHost, host.dataset.chatgptEndpointId); + } + }); + queryAll('[data-adm-chatgpt-usage-toggle]').forEach(toggleBtn => { + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const epId = toggleBtn.dataset.chatgptEndpointId; + const authId = toggleBtn.dataset.admChatgptUsageToggle; + const row = toggleBtn.closest('.admin-user-row'); + const host = row ? row.querySelector('[data-adm-chatgpt-usage-host]') : null; + if (!host) return; + const isHidden = host.classList.contains('hidden') || host.style.display === 'none'; + if (isHidden) { + host.classList.remove('hidden'); + host.style.display = ''; + toggleBtn.setAttribute('aria-expanded', 'true'); + const chevron = toggleBtn.querySelector('.adm-chatgpt-usage-chevron'); + if (chevron) chevron.textContent = '▴'; + _setChatgptUsageExpanded(epId, authId, true); + if (!host.dataset.usageLoaded) { + _loadChatgptUsage(host, authId, epId); + } + } else { + host.classList.add('hidden'); + host.style.display = 'none'; + toggleBtn.setAttribute('aria-expanded', 'false'); + const chevron = toggleBtn.querySelector('.adm-chatgpt-usage-chevron'); + if (chevron) chevron.textContent = '▾'; + _setChatgptUsageExpanded(epId, authId, false); + } + }); + }); + queryAll('.adm-chatgpt-controls [data-adm-chatgpt-reconnect]').forEach(reconnectBtn => { + reconnectBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + const epId = reconnectBtn.dataset.chatgptEndpointId; + const authId = reconnectBtn.dataset.admChatgptReconnect; + const row = reconnectBtn.closest('.admin-user-row'); + const host = row ? row.querySelector('[data-adm-chatgpt-usage-host]') : null; + if (host) { + host.classList.remove('hidden'); + host.style.display = ''; + const toggleBtn = row ? row.querySelector('[data-adm-chatgpt-usage-toggle]') : null; + if (toggleBtn) { + toggleBtn.setAttribute('aria-expanded', 'true'); + const chevron = toggleBtn.querySelector('.adm-chatgpt-usage-chevron'); + if (chevron) chevron.textContent = '▴'; + } + _setChatgptUsageExpanded(epId, authId, true); + await _reconnectChatgptAccount(host, authId, epId); + } + }); + }); queryAll('[data-adm-toggle-ep]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); @@ -799,7 +1253,7 @@ async function loadEndpoints() { // Don't let interactions inside the expanded panel re-fire the // expand/collapse handler — the search box was getting closed // because clicking it bubbled up to here. - if (e.target.closest('.admin-btn-sm, .admin-btn-delete, .mcp-tools-list, .mcp-tools-header, .mcp-tools-search, input, select, label')) return; + if (e.target.closest('.admin-btn-sm, .admin-btn-delete, .mcp-tools-list, .mcp-tools-header, .mcp-tools-search, input, select, label, button, .featherless-panel, .featherless-search-bar, [data-ep-model-row]')) return; const epId = header.dataset.admEpHeader; const panel = row.querySelector(`[data-adm-ep-models-panel="${epId}"]`); if (!panel) return; @@ -812,6 +1266,11 @@ async function loadEndpoints() { } if (!_modelsLoaded && isOpen) { _modelsLoaded = true; + const ep = data.find(x => String(x.id) === String(epId)); + if (ep && isFeatherlessEndpoint(ep)) { + renderFeatherlessPanel(panel, ep, row); + return; + } // Our shared whirlpool spinner (consistent with the rest of the app). panel.innerHTML = ''; let _modelsSpin = null; @@ -874,19 +1333,21 @@ async function loadEndpoints() { const mode = ['none', 'compact', 'full'].includes(String(m.tool_mode || '').toLowerCase()) ? String(m.tool_mode).toLowerCase() : ''; - return `
-
'; @@ -983,6 +1444,135 @@ async function _saveEpModelState(epId, panel) { } catch (e) { /* silent */ } } +// Render the shared "waiting for authorization" panel (code + manual link). +// Built for both the Add Models flow and per-account Reconnect. Never opens a +// tab automatically; the user clicks the Authorize link. +function _renderDeviceAuthWaitPanel(status, providerKey, start, authUrl) { + if (!status) return; + status.className = ''; + const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI'; + const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...'; + status.innerHTML = + '
' + + '
' + + '' + esc(waitLabel) + '
' + + '
' + + 'Code' + + '' + esc(start.user_code) + '' + + '' + + '
' + + '' + esc(authLabel) + ' ↗' + + '
'; + const copyBtn = status.querySelector('.adm-device-auth-copy'); + if (copyBtn) copyBtn.addEventListener('click', async () => { + const code = start.user_code || ''; + let ok = false; + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(code); + ok = true; + } + } catch (e) {} + if (!ok) { + // navigator.clipboard is unavailable in non-secure contexts (HTTP + // self-host over a LAN IP), so fall back to execCommand('copy'). + const ta = document.createElement('textarea'); + ta.value = code; + ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { ta.setSelectionRange(0, code.length); } catch (e) {} + try { ok = document.execCommand('copy'); } catch (e) {} + ta.remove(); + } + copyBtn.textContent = ok ? 'Copied' : 'Failed'; + setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); + }); +} + +// ── ChatGPT Subscription per-account usage + reconnect ───────────────────── +// Each ChatGPT account (ProviderAuthSession) is addressed by its stable +// auth id; the endpoint id pins which row the action came from. Usage is +// read-only telemetry: a failed read never disables the endpoint. +const _chatgptReconnectInflight = new Set(); + +async function _loadChatgptUsage(container, authId, epId, { refresh = false } = {}) { + if (!container || !authId) return; + const url = '/api/chatgpt-subscription/accounts/' + encodeURIComponent(authId) + '/usage' + (refresh ? '?refresh=1' : ''); + let payload = null; + try { + const res = await fetch(url, { credentials: 'same-origin' }); + if (res.ok) { + try { payload = await res.json(); } catch (_) { payload = null; } + } else { + let reason = 'upstream'; + if (res.status === 404) reason = 'upstream'; + payload = { available: false, reason, account: { auth_id: authId } }; + } + } catch (_) { + payload = { available: false, reason: 'network', account: { auth_id: authId } }; + } + if (!payload || typeof payload !== 'object') payload = { available: false, reason: 'malformed', account: { auth_id: authId } }; + const vm = buildChatgptUsageViewModel(payload); + vm.authId = authId; + if (!container.dataset) container.dataset = {}; + container.dataset.usageLoaded = '1'; + container.innerHTML = renderChatgptUsageCardHtml(vm, { esc, endpointId: epId, includeReconnect: false }); + 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 +1587,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 +1610,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 +1648,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 +1737,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'; @@ -1197,6 +1806,12 @@ function initEndpointForm() { } function _renderEndpointTestResult(msg, res, d) { + const isFeatherless = d && d.base_url && /featherless\.ai/i.test(d.base_url); + if (res.ok && isFeatherless && d.online) { + msg.textContent = 'Online — Featherless.ai catalog ready (search to enable models)'; + msg.className = 'admin-success'; + return; + } if (res.ok && d.status === 'empty') { msg.textContent = 'Online — no models found'; msg.className = 'admin-success'; @@ -1318,9 +1933,13 @@ function initEndpointForm() { await loadEndpoints(); await _selectAddedModelInChat(d); const goLink = ' Added Models →'; + const isFeatherless = d && d.base_url && /featherless\.ai/i.test(d.base_url); if (!d.online) { msg.innerHTML = 'Added (endpoint offline — will retry on next load)' + goLink; msg.className = 'admin-error'; + } else if (isFeatherless) { + msg.innerHTML = 'Added Featherless.ai — search catalog to enable models' + goLink; + msg.className = 'admin-success'; } else if (d.status === 'empty') { msg.innerHTML = 'Added — endpoint reachable, no models found' + goLink; msg.className = 'admin-success'; @@ -1372,58 +1991,30 @@ function initEndpointForm() { status.textContent = `Starting ${config.label} sign-in...`; try { + // New ChatGPT connections carry the optional account label. Only the + // label travels: the backend creates fresh auth/endpoint rows and never + // reuses another account's credentials. + const formData = new FormData(); + if (providerKey === 'chatgpt-subscription' && _chatgptLabelMode) { + const label = (urlInput.value || '').trim(); + if (label) formData.append('label', label); + } const result = await runProviderDeviceFlow(providerKey, { openWindow: () => {}, + formData, onStart: ({ start, authUrl }) => { if (triggerEl) triggerEl.textContent = 'Waiting...'; - status.className = ''; - const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI'; - const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...'; - status.innerHTML = - '
' + - '
' + - '' + esc(waitLabel) + '
' + - '
' + - 'Code' + - '' + esc(start.user_code) + '' + - '' + - '
' + - '' + esc(authLabel) + ' ↗' + - '
'; - const copyBtn = status.querySelector('.adm-device-auth-copy'); - if (copyBtn) copyBtn.addEventListener('click', async () => { - const code = start.user_code || ''; - let ok = false; - try { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(code); - ok = true; - } - } catch (e) {} - if (!ok) { - // navigator.clipboard is unavailable in non-secure contexts (HTTP - // self-host over a LAN IP), so fall back to execCommand('copy'). - const ta = document.createElement('textarea'); - ta.value = code; - ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;'; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - try { ta.setSelectionRange(0, code.length); } catch (e) {} - try { ok = document.execCommand('copy'); } catch (e) {} - ta.remove(); - } - copyBtn.textContent = ok ? 'Copied' : 'Failed'; - setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); - }); + _renderDeviceAuthWaitPanel(status, providerKey, start, authUrl); }, }); if (result.status === 'authorized') { const endpoint = result.endpoint || {}; const n = ((endpoint && endpoint.models) || []).length; status.className = 'admin-success'; - status.textContent = 'Connected - ' + n + ' ' + config.label + ' model' + (n !== 1 ? 's' : '') + ' available.'; + const connectedName = endpoint && endpoint.name ? endpoint.name : config.label; + status.textContent = 'Connected ' + connectedName + ' - ' + n + ' model' + (n !== 1 ? 's' : '') + ' available.'; if (endpoint && endpoint.id) _recentlyAddedEpId = String(endpoint.id); + if (_chatgptLabelMode) urlInput.value = ''; await loadEndpoints(); await _selectAddedModelInChat(endpoint || {}); reset(); @@ -3525,5 +4116,7 @@ export function close() { settingsModule.close(); } +export { shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml, renderFeatherlessPanel }; + const adminModule = { open, close, _initData, get _initialized() { return initialized; } }; export default adminModule; diff --git a/static/js/chat.js b/static/js/chat.js index e8481d59e..ffab94526 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -19,7 +19,7 @@ import searchModule from './search.js'; import documentModule from './document.js?v=20260916docctx2'; import * as emailInbox from './emailInbox.js?v=20260914aireply4'; import codeRunnerModule from './codeRunner.js?v=20260831richtexttools91'; -import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260902tuiharness1'; +import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260921chatgptusage1'; import createResearchSynapse from './researchSynapse.js?v=20260910roundlabels2'; import { createStreamRenderer } from './streamingRenderer.js'; import { createTurnRendering, startsContinuationRound } from './turnRendering.js?v=20260910round1stable1'; @@ -2575,6 +2575,12 @@ import { invalidateSettings } from './appConfig.js'; if (presetsModule.getSelectedPreset()) { fd.append('preset_id', presetsModule.getSelectedPreset()); } + try { + const effort = window.__odysseusGetReasoningEffort ? window.__odysseusGetReasoningEffort() : null; + if (effort) { + fd.append('reasoning_effort', effort); + } + } catch (_) {} // Superseded during preflight (uploads, document saves): a newer send diff --git a/static/js/chatgptSubscriptionUsage.js b/static/js/chatgptSubscriptionUsage.js new file mode 100644 index 000000000..326218fb7 --- /dev/null +++ b/static/js/chatgptSubscriptionUsage.js @@ -0,0 +1,250 @@ +// DOM-free view-model + renderer for per-account ChatGPT Subscription usage. +// +// The backend (/api/chatgpt-subscription/accounts/{auth_id}/usage) returns a +// normalized, credential-free payload. This module turns it into something the +// Settings "Added Models" card can render, defensively: unknown buckets are +// kept, missing windows are tolerated and nothing (reset times, percentages) +// is ever invented. + +function _num(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function _clampPercent(value) { + const n = _num(value); + if (n === null) return null; + return Math.max(0, Math.min(100, n)); +} + +function _defaultEsc(text) { + return String(text == null ? '' : text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function formatDuration(totalSeconds) { + const secs = _num(totalSeconds); + if (secs === null) return ''; + if (secs <= 0) return 'now'; + const days = Math.floor(secs / 86400); + const hours = Math.floor((secs % 86400) / 3600); + const minutes = Math.floor((secs % 3600) / 60); + if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`; + if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + if (minutes > 0) return `${minutes}m`; + return '<1m'; +} + +export function formatResetIn(resetsAt, nowSeconds) { + const at = _num(resetsAt); + if (at === null || at <= 0) return ''; + const now = _num(nowSeconds); + if (now === null) return ''; + const delta = at - now; + if (delta <= 0) return 'resets now'; + return `resets in ${formatDuration(delta)}`; +} + +export function formatPercent(value) { + const n = _clampPercent(value); + if (n === null) return ''; + const rounded = Math.round(n); + return `${rounded}%`; +} + +export function planDisplayName(planType) { + if (typeof planType !== 'string' || !planType.trim()) return ''; + const key = planType.trim().toLowerCase(); + const known = { + free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro', prolite: 'Pro Lite', team: 'Team', + business: 'Business', enterprise: 'Enterprise', edu: 'Edu', education: 'Education', + guest: 'Guest', + }; + if (known[key]) return known[key]; + return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function _windowViewModel(win, nowSeconds) { + if (!win || typeof win !== 'object') return null; + const used = _clampPercent(win.used_percent); + const remaining = used === null ? _clampPercent(win.remaining_percent) : Math.round((100 - used) * 100) / 100; + const windowMinutes = _num(win.window_minutes); + const resetsAt = _num(win.resets_at); + return { + kind: typeof win.kind === 'string' ? win.kind : '', + name: typeof win.name === 'string' && win.name ? win.name : 'LIMIT', + usedPercent: used, + remainingPercent: remaining, + usedLabel: used === null ? 'usage unknown' : `${formatPercent(used)} used`, + remainingLabel: remaining === null ? '' : `${formatPercent(remaining)} remaining`, + windowMinutes, + resetsAt, + resetLabel: formatResetIn(resetsAt, nowSeconds), + exhausted: used !== null && used >= 100, + }; +} + +/** + * Build a render-ready view model from the backend usage payload. + * + * `payload` is the JSON body of the usage route. `nowSeconds` is injected so + * reset countdowns are deterministic in tests. + */ +export function buildUsageViewModel(payload, nowSeconds = Math.floor(Date.now() / 1000)) { + const account = (payload && typeof payload.account === 'object' && payload.account) || {}; + const base = { + authId: typeof account.auth_id === 'string' ? account.auth_id : '', + label: typeof account.label === 'string' ? account.label : '', + name: typeof account.name === 'string' ? account.name : '', + }; + if (!payload || typeof payload !== 'object' || payload.available !== true || !payload.usage || typeof payload.usage !== 'object') { + const reason = payload && typeof payload.reason === 'string' ? payload.reason : 'unavailable'; + const messages = { + reauth: 'Usage unavailable — account may need reconnecting', + rate_limited: 'Usage temporarily unavailable (rate limited)', + timeout: 'Usage unavailable (timed out)', + network: 'Usage unavailable (network)', + malformed: 'Usage unavailable (unexpected response)', + upstream: 'Usage unavailable', + }; + return { + ...base, + available: false, + reason, + reconnectSuggested: !!(payload && payload.reconnect_suggested), + message: messages[reason] || 'Usage unavailable', + plan: '', + limits: [], + }; + } + const usage = payload.usage; + const limits = []; + const rawLimits = Array.isArray(usage.limits) ? usage.limits : []; + rawLimits.forEach((limit) => { + if (!limit || typeof limit !== 'object') return; + const windows = (Array.isArray(limit.windows) ? limit.windows : []) + .map((w) => _windowViewModel(w, nowSeconds)) + .filter(Boolean); + const limitId = typeof limit.limit_id === 'string' ? limit.limit_id : ''; + const limitName = typeof limit.limit_name === 'string' ? limit.limit_name : ''; + limits.push({ + limitId, + title: limitId === 'codex' ? '' : (limitName || limitId || 'Additional limit'), + modelSlug: typeof limit.normal_model_slug === 'string' ? limit.normal_model_slug : '', + limitReached: limit.limit_reached === true, + windows, + }); + }); + return { + ...base, + available: true, + reason: '', + reconnectSuggested: false, + message: '', + plan: planDisplayName(usage.plan_type), + ordinaryUsageAllowed: typeof usage.ordinary_usage_allowed === 'boolean' ? usage.ordinary_usage_allowed : null, + rateLimitReachedType: typeof usage.rate_limit_reached_type === 'string' ? usage.rate_limit_reached_type : '', + cached: payload.usage.cached === true, + fetchedAt: _num(usage.fetched_at), + limits, + hasWindows: limits.some((l) => l.windows.length > 0), + }; +} + +function _barHtml(win, esc) { + const used = win.usedPercent === null ? 0 : win.usedPercent; + const tone = win.usedPercent === null ? 'unknown' : (used >= 90 ? 'critical' : (used >= 70 ? 'warn' : 'ok')); + return ( + `
` + + `
` + + `${esc(win.name)}` + + `${esc(win.remainingLabel || win.usedLabel)}` + + `
` + + `
` + + `` + + `
` + + `
` + + `${esc(win.usedLabel)}` + + (win.resetLabel ? `${esc(win.resetLabel)}` : '') + + `
` + + `
` + ); +} + +/** + * Render the compact usage card body for one account. `esc` is the host + * page's HTML escaper (defaults to a local one). Buttons carry data + * attributes with the exact auth/endpoint ids so handlers target only this + * account. + */ +export function renderUsageCardHtml(viewModel, options = {}) { + const esc = typeof options.esc === 'function' ? options.esc : _defaultEsc; + const vm = viewModel || {}; + const authId = esc(vm.authId || ''); + const endpointId = esc(options.endpointId || ''); + const includeReconnect = options.includeReconnect !== false; + const buttons = + `
` + + `` + + (includeReconnect ? `` : '') + + `
`; + if (!vm.available) { + return ( + `
` + + `
${esc(vm.message || 'Usage unavailable')}
` + + buttons + + `
` + ); + } + const plan = vm.plan ? `${esc(vm.plan)}` : ''; + const limitsHtml = (vm.limits || []).map((limit) => { + const title = limit.title + ? `
${esc(limit.title)}${limit.modelSlug ? ` ${esc(limit.modelSlug)}` : ''}
` + : ''; + return `
${title}${limit.windows.length ? limit.windows.map((w) => _barHtml(w, esc)).join('') : '
No rate-limit windows reported
'}
`; + }).join(''); + const blocked = vm.ordinaryUsageAllowed === false + ? `
Usage currently blocked${vm.rateLimitReachedType ? ` (${esc(vm.rateLimitReachedType.replace(/_/g, ' '))})` : ''}
` + : ''; + return ( + `
` + + (plan ? `
${plan}
` : '') + + blocked + + limitsHtml + + buttons + + `
` + ); +} + +/** True when an endpoint row from /api/model-endpoints is a ChatGPT account. */ +export function isChatgptSubscriptionEndpoint(ep) { + return !!(ep && ep.provider === 'chatgpt-subscription' && ep.provider_auth_id); +} + +/** Owner-visible account title, e.g. "ChatGPT · codex00". */ +export function accountTitle(ep) { + if (!ep) return ''; + if (ep.account_label) return `ChatGPT · ${ep.account_label}`; + return ep.name || 'ChatGPT Subscription'; +} + +export default { + buildUsageViewModel, + renderUsageCardHtml, + formatResetIn, + formatDuration, + formatPercent, + planDisplayName, + isChatgptSubscriptionEndpoint, + accountTitle, +}; diff --git a/static/js/modelPicker.js b/static/js/modelPicker.js index e76da0f40..937693bde 100644 --- a/static/js/modelPicker.js +++ b/static/js/modelPicker.js @@ -202,6 +202,7 @@ function _initModelPickerDropdown() { const listEl = document.getElementById('model-picker-list'); const searchRow = menu ? menu.querySelector('.model-picker-search-row') : null; const refreshBtn = document.getElementById('model-picker-refresh-btn'); + _initReasoningEffort(); if (!wrap || !btn || !menu || !search || !listEl) return; if (wrap.dataset.modelPickerBound === '1') return; wrap.dataset.modelPickerBound = '1'; @@ -294,8 +295,10 @@ function _initModelPickerDropdown() { // A registered route is a user choice, including local routes using // identical weights with different harness profiles. Never collapse // distinct endpoints just because their model IDs match. - const seenKey = _pickerModelKey({ endpointId: item.endpoint_id, - url: item.url, epName: item.endpoint_name, mid }); + const isApiEndpoint = item.category && item.category !== 'local'; + const seenKey = isApiEndpoint + ? `${item.endpoint_id || item.url || item.endpoint_name || 'api'}::${mid}` + : _pickerModelKey({ endpointId: item.endpoint_id, url: item.url, epName: item.endpoint_name, mid }); // const seenKey = _pickerModelKey( if (seen.has(seenKey)) return; seen.add(seenKey); result.push({ @@ -306,6 +309,7 @@ function _initModelPickerDropdown() { endpointId: item.endpoint_id, epName: item.endpoint_name || '', category: item.category || '', + modelsMetadata: item.models_metadata || {}, providerText: [ item.endpoint_name || '', item.category || '', @@ -402,18 +406,28 @@ function _initModelPickerDropdown() { 'bytedance-seed': 'bytedance', '~anthropic': 'anthropic', '~google': 'google', '~moonshotai': 'moonshotai', '~openai': 'openai', }; - function _providerDisplayName(slug) { - return _PROVIDER_NAMES[slug] || slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' '); - } + const _endpointGroupNames = new Map(); function _providerGroupKey(m) { - if (m && m.category && m.category !== 'local' && m.epName) { + if (!m) return 'other'; + // Grouping must be keyed on endpoint_id, falling back to url or epName + const gid = m.endpointId || m.url || (m.category && m.category !== 'local' && m.epName ? m.epName : ''); + if (gid) { + const gname = m.epName || (m.endpointId ? m.endpointId : (m.url || 'Other Models')); + _endpointGroupNames.set(gid, gname); + return `~endpoint:${gid}`; + } + if (m.category && m.category !== 'local' && m.epName) { + _endpointGroupNames.set(m.epName, m.epName); return `~endpoint:${m.epName}`; } return _providerSlug((m && m.mid) || ''); } - function _providerGroupName(key) { - if (String(key || '').startsWith('~endpoint:')) return String(key).slice('~endpoint:'.length); - return _providerDisplayName(key); + function _providerGroupName(provider) { + if (String(provider || '').startsWith('~endpoint:')) { + const raw = String(provider).slice('~endpoint:'.length); + return _endpointGroupNames.get(raw) || raw; + } + return _providerDisplayName(provider); } function _providerSlug(mid) { const slash = mid.indexOf('/'); @@ -464,7 +478,7 @@ function _initModelPickerDropdown() { empty.textContent = text; listEl.appendChild(empty); } - function _addRow(m) { + function _addRow(m, { inGroup = false } = {}) { const row = document.createElement('div'); row.className = 'model-switch-item'; if (m.stale) { @@ -494,7 +508,8 @@ function _initModelPickerDropdown() { const epSpan = document.createElement('span'); epSpan.className = 'model-switch-ep'; // Don't show endpoint name if it matches the model name (local self-hosted) - const _epDisplay = m.epName && !m.display.toLowerCase().includes(m.epName.toLowerCase().split('/').pop()) ? m.epName : ''; + // or if it's already rendered under its endpoint group header + const _epDisplay = (!inGroup && m.epName && !m.display.toLowerCase().includes(m.epName.toLowerCase().split('/').pop())) ? m.epName : ''; epSpan.textContent = _epDisplay; row.appendChild(epSpan); @@ -537,32 +552,83 @@ function _initModelPickerDropdown() { listEl.appendChild(row); } - // ── Search mode: flat, filtered results across the whole catalog ── + function _renderGroup(provider, models, { isSearch = false } = {}) { + if (!models || !models.length) return; + const isCollapsed = !isSearch && _collapsedProviders.has(provider); + const header = document.createElement('div'); + header.className = 'mp-provider-header'; + header.innerHTML = + ``; + const nameSpan = document.createElement('span'); + nameSpan.className = 'mp-provider-name'; + nameSpan.textContent = _providerGroupName(provider); + header.appendChild(nameSpan); + const countSpan = document.createElement('span'); + countSpan.className = 'mp-provider-count'; + countSpan.textContent = `${models.length} model${models.length === 1 ? '' : 's'}`; + header.appendChild(countSpan); + + header.addEventListener('click', (e) => { + e.stopPropagation(); + if (_collapsedProviders.has(provider)) { + _collapsedProviders.delete(provider); + _justExpandedProvider = provider; + } else { + _collapsedProviders.add(provider); + _justExpandedProvider = null; + } + _saveList('odysseus-model-collapsed', [..._collapsedProviders]); + const st = listEl.scrollTop; + _populate(search ? search.value : ''); + listEl.scrollTop = st; + }); + listEl.appendChild(header); + + if (!isCollapsed) { + const group = document.createElement('div'); + group.className = 'mp-provider-group' + (_justExpandedProvider === provider ? ' mp-just-expanded' : ''); + models.forEach(m => { + _addRow(m, { inGroup: true }); + // Move the just-appended row into the group container + group.appendChild(listEl.lastElementChild); + }); + listEl.appendChild(group); + if (_justExpandedProvider === provider) _justExpandedProvider = null; + } + } + + // ── Search mode: grouped, filtered results across the whole catalog ── if (q) { const matches = all.filter(m => { - const provName = _providerDisplayName(_providerSlug(m.mid)).toLowerCase(); - return [m.mid, m.display, m.epName, m.providerText, provName] + const groupKey = _providerGroupKey(m); + const groupName = _providerGroupName(groupKey).toLowerCase(); + return [m.mid, m.display, m.epName, m.providerText, groupName] .filter(Boolean).join(' ').toLowerCase().includes(q); }); - if (matches.length === 0) _addEmpty('No matching models'); - else matches.forEach(_addRow); + if (matches.length === 0) { + _addEmpty('No matching models'); + } else { + const groups = new Map(); + matches.forEach(m => { + const key = _providerGroupKey(m); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(m); + }); + const sorted = [...groups.keys()].sort((a, b) => + _providerGroupName(a).localeCompare(_providerGroupName(b))); + sorted.forEach(provider => { + _renderGroup(provider, groups.get(provider), { isSearch: true }); + }); + } return; } // ── Browse mode: Favorites (manual) + Recent (auto), with dedupe. ── - // Rules: - // 1. Never list the same model twice in the dropdown. Favorites - // win over Recent (if you favorited it, that's where it - // belongs — Recent shouldn't show it again as duplicate). - // 2. Small catalogs (≤ BROWSE_ALL_LIMIT total) skip the Recent - // section entirely — when there's only ~10 models, the whole - // list fits below as "All models" and a separate Recent - // section just duplicates rows. const shown = new Set(); const favModels = favs.map(id => byKey.get(id) || byId.get(id)).filter(Boolean); if (favModels.length) { _addSection('Favorites'); - favModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); }); + favModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m, { inGroup: false }); }); } // Recent: only render when the catalog is big enough that surfacing // a recency shortlist is actually useful, AND only models that @@ -575,66 +641,24 @@ function _initModelPickerDropdown() { .slice(0, RECENT_MAX); if (recentModels.length) { _addSection('Recent'); - recentModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); }); + recentModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m, { inGroup: false }); }); } } - // Small catalogs: still list everything so users aren't forced to search. - if (all.length <= BROWSE_ALL_LIMIT) { - const rest = all.filter(m => !shown.has(_pickerModelKey(m))); - if (rest.length) { - if (shown.size) _addSection('All models'); - rest.forEach(_addRow); - } - } else { - // Large catalog: show provider groups with collapsible sections. - const rest = all.filter(m => !shown.has(_pickerModelKey(m))); - const groups = new Map(); - rest.forEach(m => { - const slug = _providerGroupKey(m); - if (!groups.has(slug)) groups.set(slug, []); - groups.get(slug).push(m); - }); - const sorted = [...groups.keys()].sort((a, b) => - _providerGroupName(a).localeCompare(_providerGroupName(b))); + // Provider / endpoint groups with collapsible sections. + const rest = all.filter(m => !shown.has(_pickerModelKey(m))); + const groups = new Map(); + rest.forEach(m => { + const key = _providerGroupKey(m); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(m); + }); + const sorted = [...groups.keys()].sort((a, b) => + _providerGroupName(a).localeCompare(_providerGroupName(b))); - sorted.forEach(provider => { - const models = groups.get(provider); - const isCollapsed = _collapsedProviders.has(provider); - const header = document.createElement('div'); - header.className = 'mp-provider-header'; - header.innerHTML = - `` - + `${_providerGroupName(provider)}` - + `${models.length}`; - header.addEventListener('click', (e) => { - e.stopPropagation(); - if (_collapsedProviders.has(provider)) { - _collapsedProviders.delete(provider); - _justExpandedProvider = provider; - } else { - _collapsedProviders.add(provider); - _justExpandedProvider = null; - } - _saveList('odysseus-model-collapsed', [..._collapsedProviders]); - const st = listEl.scrollTop; - _populate(''); - listEl.scrollTop = st; - }); - listEl.appendChild(header); - if (!isCollapsed) { - const group = document.createElement('div'); - group.className = 'mp-provider-group' + (_justExpandedProvider === provider ? ' mp-just-expanded' : ''); - models.forEach(m => { - _addRow(m); - // Move the just-appended row into the group container - group.appendChild(listEl.lastElementChild); - }); - listEl.appendChild(group); - if (_justExpandedProvider === provider) _justExpandedProvider = null; - } - }); - } + sorted.forEach(provider => { + _renderGroup(provider, groups.get(provider), { isSearch: false }); + }); } async function _pick(m) { @@ -964,4 +988,175 @@ export function updateModelPicker() { } else { label.textContent = displayName; } + _updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint); +} + +// ── Reasoning effort control for models supporting reasoning levels ── +let _reasoningEffortBound = false; +let _pendingReasoningEffort = null; + +export function getSelectedReasoningEffort() { + if (!_deps) return _pendingReasoningEffort; + const currentSessionId = _deps.getCurrentSessionId ? _deps.getCurrentSessionId() : null; + if (!currentSessionId) return _pendingReasoningEffort; + const sessions = _deps.getSessions ? _deps.getSessions() : []; + const s = sessions.find(x => x.id === currentSessionId); + const mode = s?.thinking_mode || ''; + if (mode.startsWith('effort:')) { + return mode.slice('effort:'.length).trim().toLowerCase(); + } + return null; +} +try { window.__odysseusGetReasoningEffort = getSelectedReasoningEffort; } catch (_) {} + +function _initReasoningEffort() { + if (_reasoningEffortBound) return; + const wrap = document.getElementById('reasoning-effort-wrap'); + const btn = document.getElementById('reasoning-effort-btn'); + const menu = document.getElementById('reasoning-effort-menu'); + if (!wrap || !btn || !menu) return; + _reasoningEffortBound = true; + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const isHidden = menu.classList.contains('hidden'); + if (isHidden) { + menu.classList.remove('hidden'); + btn.setAttribute('aria-expanded', 'true'); + } else { + menu.classList.add('hidden'); + btn.setAttribute('aria-expanded', 'false'); + } + }); + + document.addEventListener('click', (e) => { + if (!wrap.contains(e.target) && !menu.classList.contains('hidden')) { + menu.classList.add('hidden'); + btn.setAttribute('aria-expanded', 'false'); + } + }); +} + +function _findModelMetadata(modelId, selectedEndpoint) { + if (selectedEndpoint?.models_metadata?.[modelId]) { + return selectedEndpoint.models_metadata[modelId]; + } + const routeItems = window.modelsModule?.getCachedItems?.() || []; + for (const ep of routeItems) { + if (ep.models_metadata && ep.models_metadata[modelId]) { + return ep.models_metadata[modelId]; + } + } + return null; +} + +async function _updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint) { + _initReasoningEffort(); + const wrap = document.getElementById('reasoning-effort-wrap'); + const btn = document.getElementById('reasoning-effort-btn'); + const currentSpan = document.getElementById('reasoning-effort-current'); + const menu = document.getElementById('reasoning-effort-menu'); + if (!wrap || !btn || !currentSpan || !menu) return; + + if (!modelId) { + wrap.style.display = 'none'; + return; + } + + const metadata = _findModelMetadata(modelId, selectedEndpoint); + const levels = metadata?.supported_reasoning_levels; + if (!Array.isArray(levels) || levels.length === 0) { + wrap.style.display = 'none'; + return; + } + + wrap.style.display = 'inline-flex'; + + const supportedEffortNames = levels.map(l => (typeof l === 'string' ? l : l.effort).toLowerCase()); + let activeLevel = 'default'; + const sessionMode = s?.thinking_mode || ''; + + if (s && s.id) { + if (sessionMode.startsWith('effort:')) { + const parsed = sessionMode.slice('effort:'.length).trim().toLowerCase(); + if (supportedEffortNames.includes(parsed)) { + activeLevel = parsed; + } else { + activeLevel = 'default'; + s.thinking_mode = 'off'; + try { + fetch(`${API_BASE}/api/session/${encodeURIComponent(s.id)}/generation-settings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ thinking_mode: 'off', reasoning_effort: null }), + }).catch(() => {}); + } catch (_) {} + } + } + } else if (_pendingReasoningEffort) { + if (supportedEffortNames.includes(_pendingReasoningEffort)) { + activeLevel = _pendingReasoningEffort; + } else { + activeLevel = 'default'; + _pendingReasoningEffort = null; + } + } + + currentSpan.textContent = activeLevel === 'default' ? 'Default' : (activeLevel.charAt(0).toUpperCase() + activeLevel.slice(1)); + btn.title = 'Reasoning effort'; + + menu.innerHTML = ''; + const options = [{ effort: 'default', label: 'Default', desc: `Model default (${metadata.default_reasoning_level || 'standard'})` }]; + for (const l of levels) { + const eff = (typeof l === 'string' ? l : l.effort).toLowerCase(); + const desc = (typeof l === 'object' && l.description) ? l.description : ''; + options.push({ effort: eff, label: eff.charAt(0).toUpperCase() + eff.slice(1), desc }); + } + + for (const opt of options) { + const optBtn = document.createElement('button'); + optBtn.type = 'button'; + optBtn.className = 'reasoning-effort-option' + (opt.effort === activeLevel ? ' active' : ''); + optBtn.setAttribute('role', 'option'); + optBtn.setAttribute('aria-selected', opt.effort === activeLevel ? 'true' : 'false'); + if (opt.desc) optBtn.title = opt.desc; + + const lbl = document.createElement('span'); + lbl.textContent = opt.label; + optBtn.appendChild(lbl); + + if (opt.effort === activeLevel) { + const check = document.createElement('span'); + check.textContent = '✓'; + check.style.fontSize = '11px'; + optBtn.appendChild(check); + } + + optBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + menu.classList.add('hidden'); + btn.setAttribute('aria-expanded', 'false'); + const newEffort = opt.effort; + const thinkingModeVal = newEffort === 'default' ? 'off' : `effort:${newEffort}`; + const effortVal = newEffort === 'default' ? null : newEffort; + + if (s && s.id) { + s.thinking_mode = thinkingModeVal; + try { + await fetch(`${API_BASE}/api/session/${encodeURIComponent(s.id)}/generation-settings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ thinking_mode: thinkingModeVal, reasoning_effort: effortVal }), + }); + } catch (_) {} + } else { + _pendingReasoningEffort = effortVal; + } + _updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint); + }); + + menu.appendChild(optBtn); + } } diff --git a/static/js/providers.js b/static/js/providers.js index 54556faeb..c3c3c3638 100644 --- a/static/js/providers.js +++ b/static/js/providers.js @@ -87,6 +87,9 @@ const _PROVIDERS = [ // NVIDIA / Nemotron (official Simple Icons) [/nvidia|nemotron/i, ''], + // Featherless AI (feather icon) + [/featherless/i, + ''], ]; // Returns an SVG string for the given model ID, or null if no match @@ -107,6 +110,7 @@ export function providerLogo(modelId) { const _ENDPOINT_LABELS = [ [/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"], [/(^|\.)chatgpt\.com$/i, "ChatGPT Subscription"], + [/(^|\.)featherless\.ai$/i, "Featherless.ai"], [/(^|\.)openrouter\.ai$/i, "OpenRouter"], [/(^|\.)anthropic\.com$/i, "Anthropic"], [/(^|\.)openai\.com$/i, "OpenAI"], diff --git a/static/js/slashAutocomplete.js b/static/js/slashAutocomplete.js index fb905eee9..3995fe354 100644 --- a/static/js/slashAutocomplete.js +++ b/static/js/slashAutocomplete.js @@ -2,7 +2,7 @@ // Lightweight popup that surfaces the existing /command registry as users // type. Reads COMMANDS from slashCommands.js — no command logic lives here. -import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js?v=20260902tuiharness1'; +import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js?v=20260921chatgptusage1'; const POPUP_ID = 'slash-autocomplete'; const MAX_VISIBLE = 14; diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js index ec4692aa9..d288cb3a1 100644 --- a/static/js/slashCommands.js +++ b/static/js/slashCommands.js @@ -5035,16 +5035,22 @@ function _clearSetupCommandInput() { } } -async function _setupProviderDeviceFlow(providerKey) { +async function _setupProviderDeviceFlow(providerKey, options = {}) { _clearSetupGuideMessages(); const config = PROVIDER_DEVICE_FLOWS[providerKey]; if (!config) { await _setupReply('Provider not recognised.'); return; } - await _setupReply(`Starting ${config.label} sign-in...`); + // `/setup chatgpt-subscription codex00` labels the new account so several + // ChatGPT subscriptions stay distinguishable. The label is cosmetic only. + const accountLabel = providerKey === 'chatgpt-subscription' ? String(options.label || '').trim().slice(0, 40) : ''; + const formData = new FormData(); + if (accountLabel) formData.append('label', accountLabel); + await _setupReply(`Starting ${config.label} sign-in${accountLabel ? ` for "${accountLabel}"` : ''}...`); try { const result = await runProviderDeviceFlow(providerKey, { + formData, onStart: async ({ start, authUrl }) => { const place = providerKey === 'copilot' ? 'GitHub' : 'OpenAI'; const action = providerKey === 'copilot' ? 'approve the request' : 'enter the code'; @@ -5067,7 +5073,8 @@ async function _setupProviderDeviceFlow(providerKey) { }); if (result.status === 'authorized') { const n = ((result.endpoint && result.endpoint.models) || []).length; - await _setupReply(`Connected - ${n} ${config.label} model${n !== 1 ? 's' : ''} available.`); + const connectedName = (result.endpoint && result.endpoint.name) || config.label; + await _setupReply(`Connected ${connectedName} - ${n} model${n !== 1 ? 's' : ''} available.`); if (modelsModule) modelsModule.refreshModels(true); return; } @@ -5091,7 +5098,7 @@ async function _cmdSetup(args, ctx) { const topicArgs = args.slice(1); const deviceAuthProvider = _setupDeviceAuthProviderFromInput(topic); if (deviceAuthProvider) { - await _setupProviderDeviceFlow(deviceAuthProvider); + await _setupProviderDeviceFlow(deviceAuthProvider, { label: topicArgs.join(' ') }); return true; } const provider = _setupProviderFromInput(topic); diff --git a/static/js/tourAutoplay.js b/static/js/tourAutoplay.js index 528271e82..0d88cc186 100644 --- a/static/js/tourAutoplay.js +++ b/static/js/tourAutoplay.js @@ -8,7 +8,7 @@ // Mobile is excluded — tours position halos by rect math that doesn't fit // the bottom-sheet layout cleanly. -import { handleSlashCommand } from './slashCommands.js?v=20260902tuiharness1'; +import { handleSlashCommand } from './slashCommands.js?v=20260921chatgptusage1'; // Modal id → slash command to fire (without the leading "/"). Add to this // map when a new feature picks up a `tour-*` command. diff --git a/static/style.css b/static/style.css index 7d0116f47..7c0ff6016 100644 --- a/static/style.css +++ b/static/style.css @@ -2788,6 +2788,86 @@ body.bg-pattern-ascii-fireflies { align-items: center; flex-shrink: 0; } + /* Reasoning effort pill / dropdown in composer */ + .reasoning-effort-wrap { + position: relative; + display: inline-flex; + align-items: center; + } + .reasoning-effort-btn { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--fg) 4%, transparent); + color: var(--fg); + font-size: 11.5px; + font-weight: 500; + cursor: pointer; + user-select: none; + transition: background 0.15s, border-color 0.15s; + } + .reasoning-effort-btn:hover { + background: color-mix(in srgb, var(--fg) 8%, transparent); + border-color: color-mix(in srgb, var(--fg) 20%, transparent); + } + .reasoning-effort-chevron { + opacity: 0.5; + transition: transform 0.15s; + flex-shrink: 0; + } + .reasoning-effort-btn[aria-expanded="true"] .reasoning-effort-chevron { + transform: rotate(180deg); + } + .reasoning-effort-menu { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + min-width: 140px; + background: var(--bg-surface, var(--bg)); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + padding: 4px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 2px; + } + .reasoning-effort-menu.hidden { + display: none !important; + } + .reasoning-effort-option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-radius: 5px; + font-size: 12px; + color: var(--fg); + cursor: pointer; + background: transparent; + border: none; + text-align: left; + width: 100%; + transition: background 0.12s; + } + .reasoning-effort-option:hover { + background: color-mix(in srgb, var(--fg) 8%, transparent); + } + .reasoning-effort-option.active { + color: var(--accent); + font-weight: 600; + } + @container chatbar (max-width: 480px) { + .reasoning-effort-prefix { display: none; } + } + @media (max-width: 480px) { + .reasoning-effort-prefix { display: none; } + } .input-icon-btn { background: none; border: none; @@ -17874,6 +17954,69 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn { transform-origin: center; transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); } +.adm-model-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 2px 4px; + box-sizing: border-box; + width: 100%; +} +.adm-model-label { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 auto; + min-width: 0; + cursor: pointer; +} +.adm-model-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.adm-model-tools-col { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + margin-left: auto; +} +.adm-model-tools-label { + font-size: 10px; + opacity: 0.45; + flex-shrink: 0; + user-select: none; +} +.adm-model-tool-mode, +.admin-tools-select { + height: 24px; + min-height: 24px; + line-height: 22px; + font-size: 11px; + padding: 0 18px 0 6px; + width: 124px; + max-width: 130px; + flex-shrink: 0; + border-radius: 4px; + border: 1px solid var(--border); + background-color: var(--select-bg); + color: var(--select-fg); + box-sizing: border-box; + vertical-align: middle; +} +@media (max-width: 480px) { + .adm-model-tool-mode, + .admin-tools-select { + width: 105px; + max-width: 110px; + font-size: 10px; + padding: 0 14px 0 4px; + } +} .adm-model-row:hover .adm-check-dot { border-color: var(--accent, var(--red)); transform: scale(1.15); @@ -49548,6 +49691,92 @@ 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-controls { + display: flex; + justify-content: flex-end; + gap: 6px; + align-items: center; + margin: 4px 0 2px auto; +} +.adm-chatgpt-usage-chevron { + font-size: 10px; + line-height: 1; + display: inline-block; + margin-left: 2px; +} +.adm-chatgpt-usage-host { + margin: 4px 0 6px 0; +} +.adm-chatgpt-usage { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 11px; +} +.adm-chatgpt-usage-plan { display: flex; gap: 6px; align-items: center; } +.adm-chatgpt-usage-limit { display: flex; flex-direction: column; gap: 6px; } +.adm-chatgpt-usage-limit-title { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: color-mix(in srgb, var(--fg) 55%, transparent); +} +.adm-chatgpt-usage-model { + text-transform: none; + letter-spacing: 0; + font-family: var(--mono, ui-monospace, monospace); + opacity: 0.8; +} +.adm-chatgpt-usage-row { display: flex; flex-direction: column; gap: 3px; } +.adm-chatgpt-usage-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} +.adm-chatgpt-usage-name { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + color: color-mix(in srgb, var(--fg) 70%, transparent); +} +.adm-chatgpt-usage-remaining { font-weight: 600; } +.adm-chatgpt-usage-bar { + position: relative; + height: 6px; + border-radius: 3px; + overflow: hidden; + background: color-mix(in srgb, var(--fg) 12%, transparent); +} +.adm-chatgpt-usage-fill { + display: block; + height: 100%; + border-radius: 3px; + background: var(--accent); + transition: width 0.25s ease; +} +.adm-chatgpt-usage-warn .adm-chatgpt-usage-fill { background: #d99a2b; } +.adm-chatgpt-usage-critical .adm-chatgpt-usage-fill { background: #d9534f; } +.adm-chatgpt-usage-unknown .adm-chatgpt-usage-fill { background: color-mix(in srgb, var(--fg) 25%, transparent); } +.adm-chatgpt-usage-meta { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 10px; + color: color-mix(in srgb, var(--fg) 50%, transparent); +} +.adm-chatgpt-usage-status { + font-size: 11px; + color: color-mix(in srgb, var(--fg) 60%, transparent); +} +.adm-chatgpt-usage-blocked { color: #d9534f; } +.adm-chatgpt-usage-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; } +.adm-chatgpt-reconnect-status { font-size: 11px; } /* Cookbook serve panel: Launch + ^ split button pair */ .hwfit-serve-launch-group { display: inline-flex; diff --git a/static/sw.js b/static/sw.js index 8a61c7e40..97c35236c 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,7 +7,7 @@ // - Other static assets (images/fonts/libs): cache-first with bg refresh. // - API / non-GET: never cached. // Bump CACHE_NAME whenever the precache list or SW logic changes. -const CACHE_NAME = 'odysseus-v649-large-tool-synthesis-scroll'; +const CACHE_NAME = 'odysseus-v651-chatgpt-usage-collapse'; // KaTeX resolves these from its own stylesheet, so caching the CSS without them // gives offline math fallback glyphs instead of proper typesetting. @@ -39,8 +39,8 @@ const KATEX_FONTS = [ // exact URL the browser requests, query string included. const PRECACHE = [ '/', - '/static/style.css?v=20260914pdfstrip1', - '/static/app.js?v=20260916autoscroll1', + '/static/style.css?v=20260921chatgptusage2', + '/static/app.js?v=20260921chatgptusage2', '/static/js/storage.js', '/static/js/appConfig.js', '/static/js/ui.js?v=20260916largetoolscroll1', @@ -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=20260921chatgptusage2', + '/static/js/chatgptSubscriptionUsage.js', '/static/js/init.js?v=20260829chatstyle12', - '/static/js/slashCommands.js?v=20260902tuiharness1', + '/static/js/slashCommands.js?v=20260921chatgptusage1', '/static/js/research/jobs.js?v=20260910researcherrorpersist1', '/static/js/emailInbox.js?v=20260914aireply4', '/static/js/emailLibrary/utils.js', diff --git a/tests/test_admin_device_flow_static.py b/tests/test_admin_device_flow_static.py index d6becde0d..9d3c4bf2b 100644 --- a/tests/test_admin_device_flow_static.py +++ b/tests/test_admin_device_flow_static.py @@ -58,14 +58,21 @@ def test_device_auth_selection_disables_and_dims_api_test_button(): def test_device_auth_keeps_manual_auth_button_without_auto_opening_tab(): + # The waiting panel (code + manual Authorize link) is shared between the + # Add Models flow and per-account ChatGPT Reconnect. + panel_block = _between(_ADMIN, "function _renderDeviceAuthWaitPanel", "// ── ChatGPT Subscription per-account usage") auth_block = _between(_ADMIN, "async function _startProviderDeviceAuth", "// Local \"Add\" button") + reconnect_block = _between(_ADMIN, "async function _reconnectChatgptAccount", "function initEndpointForm()") - assert "Authorize with OpenAI" in auth_block - assert "Authorize on GitHub" in auth_block - assert "adm-copilot-panel" in auth_block - assert "adm-device-auth-copy" in auth_block + assert "Authorize with OpenAI" in panel_block + assert "Authorize on GitHub" in panel_block + assert "adm-copilot-panel" in panel_block + assert "adm-device-auth-copy" in panel_block + assert "_renderDeviceAuthWaitPanel(status, providerKey, start, authUrl)" in auth_block assert "openWindow: () => {}" in auth_block assert "A new tab opened" not in auth_block + assert "openWindow: () => {}" in reconnect_block + assert "_renderDeviceAuthWaitPanel(status, 'chatgpt-subscription', start, authUrl)" in reconnect_block def test_loud_oauth_copy_and_removed_button_hooks_do_not_return(): diff --git a/tests/test_chatgpt_reasoning_and_grouping.py b/tests/test_chatgpt_reasoning_and_grouping.py new file mode 100644 index 000000000..9c36ad474 --- /dev/null +++ b/tests/test_chatgpt_reasoning_and_grouping.py @@ -0,0 +1,226 @@ +"""Tests for Model Picker Endpoint Grouping and ChatGPT Reasoning Effort Control. + +Covers: +1. Endpoint/account grouping in modelPicker.js (keyed on endpoint_id, distinct routes, collapsible headers, search auto-expansion, keyboard nav). +2. ChatGPT reasoning effort (validation, catalog metadata, session thinking_mode persistence, payload shaping with zero provider tools). +3. Composer UI elements and styles. +""" + +import json +from pathlib import Path +import subprocess +import pytest + +from src import chatgpt_subscription, llm_core + +ROOT = Path(__file__).parents[1] + + +# ============================================================ +# PART 1: FRONTEND MODEL PICKER GROUPING & UI +# ============================================================ + +def test_model_picker_endpoint_grouping_logic_in_node(): + """Verify in node that modelPicker groups models by endpoint_id and maintains distinct routes.""" + source = (ROOT / "static/js/modelPicker.js").read_text(encoding="utf-8") + key_start = source.index("function _pickerModelKey(m)") + key_end = source.index("// ── Shared keyboard nav") + key_helper = source[key_start:key_end] + + group_start = source.index("const _endpointGroupNames =") + group_end = source.index("const _collapsedProviders =") + group_snippet = source[group_start:group_end] + + test_js = f""" + const _PROVIDER_NAMES = {{}}; + const _PROVIDER_ALIAS = {{}}; + {key_helper} + {group_snippet} + + const codex00_model = {{ + mid: 'gpt-5.5', + display: 'gpt-5.5', + endpointId: 'chatgpt-codex00', + epName: 'ChatGPT · codex00', + category: 'chatgpt_subscription', + url: 'https://chatgpt.com/backend-api/codex' + }}; + + const codex01_model = {{ + mid: 'gpt-5.5', + display: 'gpt-5.5', + endpointId: 'chatgpt-codex01', + epName: 'ChatGPT · codex01', + category: 'chatgpt_subscription', + url: 'https://chatgpt.com/backend-api/codex' + }}; + + const k0 = _pickerModelKey(codex00_model); + const k1 = _pickerModelKey(codex01_model); + if (k0 === k1) throw new Error('Same model across accounts must have distinct route keys'); + + const g0 = _providerGroupKey(codex00_model); + const g1 = _providerGroupKey(codex01_model); + if (g0 === g1) throw new Error('Groups must be separate per account endpoint'); + + const name0 = _providerGroupName(g0); + const name1 = _providerGroupName(g1); + if (name0 !== 'ChatGPT · codex00') throw new Error('Unexpected group name for codex00: ' + name0); + if (name1 !== 'ChatGPT · codex01') throw new Error('Unexpected group name for codex01: ' + name1); + + console.log(JSON.stringify({{ k0, k1, g0, g1, name0, name1 }})); + """ + proc = subprocess.run(["node", "-e", test_js], check=True, capture_output=True, text=True) + res = json.loads(proc.stdout) + assert res["k0"] != res["k1"] + assert res["g0"] != res["g1"] + assert res["name0"] == "ChatGPT · codex00" + assert res["name1"] == "ChatGPT · codex01" + + +def test_model_picker_source_invariants(): + """Verify modelPicker.js contains required UI grouping structures.""" + src = (ROOT / "static/js/modelPicker.js").read_text(encoding="utf-8") + # Grouping keyed on endpoint_id + assert "m.endpointId || m.url" in src + # Header class and chevron + assert "mp-provider-header" in src + assert "mp-provider-chevron" in src + assert "mp-provider-name" in src + assert "mp-provider-count" in src + # Group container + assert "mp-provider-group" in src + # Search mode groups matches + assert "isSearch" in src + # TextContent used to prevent XSS + assert "nameSpan.textContent = _providerGroupName(provider)" in src + # Collapsed persistence key + assert "odysseus-model-collapsed" in src + + +def test_composer_reasoning_effort_ui_markup(): + """Verify static/index.html and static/style.css include reasoning effort controls.""" + html = (ROOT / "static/index.html").read_text(encoding="utf-8") + css = (ROOT / "static/style.css").read_text(encoding="utf-8") + # HTML elements + assert 'id="reasoning-effort-wrap"' in html + assert 'id="reasoning-effort-btn"' in html + assert 'id="reasoning-effort-current"' in html + assert 'id="reasoning-effort-menu"' in html + assert 'title="Reasoning effort"' in html + assert 'class="reasoning-effort-prefix">Effort: ' in html + # CSS classes + assert ".reasoning-effort-wrap" in css + assert ".reasoning-effort-btn" in css + assert ".reasoning-effort-menu" in css + assert ".reasoning-effort-option" in css + # Responsive hide of prefix + assert ".reasoning-effort-prefix { display: none; }" in css + + +def test_chat_submit_includes_reasoning_effort(): + """Verify static/js/chat.js sends reasoning_effort when active.""" + src = (ROOT / "static/js/chat.js").read_text(encoding="utf-8") + assert "window.__odysseusGetReasoningEffort" in src + assert "fd.append('reasoning_effort', effort)" in src + + +# ============================================================ +# PART 2: BACKEND REASONING EFFORT CATALOG & VALIDATION +# ============================================================ + +def test_chatgpt_reasoning_effort_validation(): + """Test validate_reasoning_effort against catalog definitions.""" + # gpt-6-astra supports low, medium, high, xhigh, max, ultra + assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "high") == "high" + assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "LOW") == "low" + assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "medium") == "medium" + assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "xhigh") == "xhigh" + assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "unsupported_level") is None + assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "default") is None + assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", None) is None + + # Model metadata catalog + meta_astra = chatgpt_subscription.get_chatgpt_model_metadata("gpt-6-astra") + assert meta_astra["default_reasoning_level"] == "low" + levels = meta_astra["supported_reasoning_levels"] + assert "low" in levels + assert "medium" in levels + assert "high" in levels + + meta_55 = chatgpt_subscription.get_chatgpt_model_metadata("gpt-5.5") + assert meta_55["default_reasoning_level"] == "medium" + + +def test_models_metadata_attached_for_chatgpt_catalog(): + """Verify get_chatgpt_model_metadata populates metadata for ChatGPT models.""" + from src.chatgpt_subscription import get_chatgpt_model_metadata + models = ["gpt-6-astra", "gpt-5.5", "gpt-5.6-sol", "llama3"] + models_metadata = {} + for mid in models: + meta = get_chatgpt_model_metadata(mid) + if meta: + models_metadata[mid] = meta + assert "gpt-6-astra" in models_metadata + assert "gpt-5.5" in models_metadata + assert "gpt-5.6-sol" in models_metadata + assert "llama3" not in models_metadata + assert models_metadata["gpt-6-astra"]["default_reasoning_level"] == "low" + assert "high" in models_metadata["gpt-6-astra"]["supported_reasoning_levels"] + + +def test_model_routes_contains_metadata_population(): + """Verify routes/model_routes.py populates models_metadata.""" + src = (ROOT / "routes/model_routes.py").read_text(encoding="utf-8") + assert "from src.chatgpt_subscription import get_chatgpt_model_metadata" in src + assert "models_metadata[mid] = meta" in src + assert '"models_metadata": models_metadata' in src + + +def test_history_and_chat_routes_support_reasoning_effort(): + """Verify history and chat routes support reasoning_effort and thinking_mode persistence.""" + hist_src = (ROOT / "routes/history/history_routes.py").read_text(encoding="utf-8") + assert "reasoning_effort" in hist_src + assert 'mode = f"effort:{clean_effort}"' in hist_src + + chat_src = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8") + assert "validate_reasoning_effort(sess.model, reasoning_effort)" in chat_src + assert "session_mode.startswith(\"effort:\")" in chat_src + + sess_src = (ROOT / "routes/session_routes.py").read_text(encoding="utf-8") + assert "session.thinking_mode = \"off\"" in sess_src + + +def test_responses_payload_zero_tools_with_reasoning(): + """Verify that reasoning effort does NOT permit any native tool surfaces.""" + forbidden_tools = [ + {"type": "function", "function": {"name": "test"}}, + {"type": "web_search_preview"}, + ] + payload = llm_core._build_chatgpt_responses_payload( + model="gpt-6-astra", + messages=[{"role": "user", "content": "hello"}], + temperature=0.7, + max_tokens=4096, + stream=True, + reasoning_effort="high", + tools=forbidden_tools, + ) + assert payload["reasoning"] == {"effort": "high"} + assert "tools" not in payload + assert "tool_choice" not in payload + for k in llm_core.CHATGPT_FORBIDDEN_PAYLOAD_KEYS: + assert k not in payload + assert set(payload.keys()) <= llm_core.CHATGPT_ALLOWED_PAYLOAD_KEYS + + +def test_session_model_change_revalidates_effort_logic(): + """Switching to an unsupported model resets effort to off.""" + meta_astra = chatgpt_subscription.get_chatgpt_model_metadata("gpt-6-astra") + supported = [lvl.lower() for lvl in meta_astra.get("supported_reasoning_levels", [])] + + current_effort = "high" + assert current_effort in supported # retained for gpt-6-astra + + meta_local = chatgpt_subscription.get_chatgpt_model_metadata("llama3.2") + assert meta_local is None # unsupported -> resets to off diff --git a/tests/test_chatgpt_session_binding.py b/tests/test_chatgpt_session_binding.py new file mode 100644 index 000000000..a681cadf6 --- /dev/null +++ b/tests/test_chatgpt_session_binding.py @@ -0,0 +1,162 @@ +"""Persistent account routing must survive reloads and fail closed on deletion.""" +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from sqlalchemy import create_engine, text, inspect +from sqlalchemy.orm import sessionmaker + +import core.database as cdb +import core.session_manager as sm +import routes.chat_helpers as helpers +import routes.chat_routes as chat +import routes.session_routes as sessions +from src import endpoint_resolver + +BASE = "https://chatgpt.com/backend-api/codex" + + +@pytest.fixture +def state(monkeypatch): + engine = create_engine("sqlite:///:memory:") + cdb.Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine) + for module in (sm, helpers, chat, sessions, endpoint_resolver): + monkeypatch.setattr(module, "SessionLocal", factory) + with factory() as db: + for key, owner in (("a", "alice"), ("b", "alice"), ("c", "bob")): + db.add(cdb.ModelEndpoint(id=key, owner=owner, name=key, base_url=BASE, + provider_auth_id="auth-" + key, is_enabled=True, + cached_models='["gpt-5.5"]', supports_tools=False)) + db.commit() + manager = sm.SessionManager.__new__(sm.SessionManager) + manager.sessions = {} + manager.upload_handler = None + monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", + lambda ep, owner=None: (BASE, "secret-" + ep.id)) + yield factory, manager + engine.dispose() + + +def _session(manager, binding="b"): + return manager.create_session("s", "Chat", BASE + "/responses", "gpt-5.5", + owner="alice", endpoint_id=binding) + + +def test_binding_survives_metadata_full_reload_and_sync(state): + factory, manager = state + _session(manager) + with factory() as db: + db.add(cdb.ChatMessage(id="message", session_id="s", role="user", content="Hello")) + db.commit() + row = db.get(cdb.Session, "s") + assert manager._db_to_session_meta(row).endpoint_id == "b" + assert manager._db_to_session(row, db).endpoint_id == "b" + row.endpoint_id = "a" + db.commit() + manager.sync_session_metadata("s") + assert manager.sessions["s"].endpoint_id == "a" + + +@pytest.mark.parametrize("disabled", [True, False]) +def test_missing_or_disabled_bound_account_cannot_borrow_only_remaining_sibling(state, disabled): + factory, manager = state + sess = _session(manager) + with factory() as db: + endpoint = db.get(cdb.ModelEndpoint, "b") + if disabled: + endpoint.is_enabled = False + else: + db.delete(endpoint) + db.get(cdb.Session, "s").headers = {"Authorization": "Bearer stale-secret"} + db.commit() + sess.headers = {"Authorization": "Bearer stale-secret"} + helpers.resolve_session_auth(sess, "s", "alice") + assert sess.headers == {} + assert sess.endpoint_id == "b" + assert chat._clear_orphaned_session_endpoint(sess, "alice") is True + assert chat._recover_empty_session_model(sess, "s", "alice") is False + with factory() as db: + assert db.get(cdb.Session, "s").headers == {} + assert db.get(cdb.Session, "s").endpoint_id == "b" + + +def test_legacy_binding_is_persisted_even_when_authentication_fails(state, monkeypatch): + factory, manager = state + sess = _session(manager, None) + def unavailable(*args, **kwargs): + raise RuntimeError("credentials unavailable") + monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", unavailable) + helpers.resolve_session_auth(sess, "s", "alice") + assert sess.endpoint_id == "a" + with factory() as db: + assert db.get(cdb.Session, "s").endpoint_id == "a" + + +def test_explicit_selection_switches_same_url_and_model_binding(state): + factory, manager = state + sess = _session(manager) + assert chat._reconcile_selected_route_from_request(None, sess, "s", { + "selected_model": "gpt-5.5", "selected_endpoint_id": "a", + }, "alice") + assert sess.endpoint_id == "a" + with factory() as db: + assert db.get(cdb.Session, "s").endpoint_id == "a" + assert not chat._reconcile_selected_route_from_request(None, sess, "s", { + "selected_model": "gpt-5.5", "selected_endpoint_id": "c", + }, "alice") + assert sess.endpoint_id == "a" + + +def test_url_only_model_change_retains_exact_binding(state): + factory, manager = state + sess = _session(manager) + assert chat._reconcile_selected_route_from_request(None, sess, "s", { + "selected_model": "another-model", "selected_endpoint_url": BASE, + }, "alice") + assert sess.endpoint_id == "b" + + +def test_session_patch_and_unrelated_rename_preserve_binding(state, monkeypatch): + factory, manager = state + sess = _session(manager) + monkeypatch.setattr(sessions, "_verify_session_owner", lambda *args: None) + router = sessions.setup_session_routes(manager, {}) + patch = [r.endpoint for r in router.routes if r.path == "/api/session/{sid}" and "PATCH" in r.methods][-1] + request = SimpleNamespace(state=SimpleNamespace(current_user="alice")) + kwargs = dict(request=request, sid="s", name=None, folder=None, cwd=None) + patch(**kwargs, model="gpt-5.5", endpoint_url=BASE, endpoint_id="a") + assert sess.endpoint_id == "a" + with factory() as db: + assert db.get(cdb.Session, "s").endpoint_id == "a" + with pytest.raises(HTTPException): + patch(**kwargs, model="gpt-5.5", endpoint_url=BASE, endpoint_id="c") + patch(**kwargs, model=None, endpoint_url=None, endpoint_id=None) + assert sess.endpoint_id == "a" + + +def test_session_list_reports_bound_account_for_duplicate_model_and_url(state, monkeypatch): + factory, manager = state + sess = _session(manager) + manager.get_sessions_for_user = lambda *args, **kwargs: {"s": sess} + router = sessions.setup_session_routes(manager, {}) + listing = [r.endpoint for r in router.routes if r.path == "/api/sessions" and "GET" in r.methods][-1] + rows = listing(SimpleNamespace(state=SimpleNamespace(current_user="alice"), query_params={})) + assert rows[0]["endpoint_id"] == "b" + assert rows[0]["endpoint_name"] == "b" + + +def test_migration_is_additive_idempotent_and_repairs_missing_index(tmp_path, monkeypatch): + engine = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}") + with engine.begin() as db: + db.execute(text("CREATE TABLE sessions (id VARCHAR PRIMARY KEY, model VARCHAR)")) + db.execute(text("INSERT INTO sessions VALUES ('legacy', 'gpt-5.5')")) + monkeypatch.setattr(cdb, "engine", engine) + cdb._migrate_add_session_endpoint_id_column() + cdb._migrate_add_session_endpoint_id_column() + with engine.begin() as db: + assert db.execute(text("SELECT model, endpoint_id FROM sessions")).one() == ("gpt-5.5", None) + db.execute(text("DROP INDEX ix_sessions_endpoint_id")) + cdb._migrate_add_session_endpoint_id_column() + assert [idx["name"] for idx in inspect(engine).get_indexes("sessions")] == ["ix_sessions_endpoint_id"] + engine.dispose() diff --git a/tests/test_chatgpt_subscription_device_flow_multi_account.py b/tests/test_chatgpt_subscription_device_flow_multi_account.py new file mode 100644 index 000000000..1cd3183ce --- /dev/null +++ b/tests/test_chatgpt_subscription_device_flow_multi_account.py @@ -0,0 +1,228 @@ +"""Device-flow start/poll semantics for multiple ChatGPT subscriptions. + +The pending device-flow state must carry the *intended operation* — create a +new account, or reconnect one exact existing account — with owner validation, +and must never carry access/refresh tokens. +""" + +import types + +import pytest +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from core.database import Base, ModelEndpoint, ProviderAuthSession +import routes.chatgpt_subscription_routes as csr +from routes.device_flow import PendingDeviceFlowStore + +_BASE = "https://chatgpt.com/backend-api/codex" + + +def _mem_db(monkeypatch): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=engine) + TestSessionLocal = sessionmaker(bind=engine, autoflush=False) + monkeypatch.setattr(csr, "SessionLocal", TestSessionLocal) + return TestSessionLocal + + +def _request(user): + return types.SimpleNamespace(state=types.SimpleNamespace(current_user=user), app=None, headers={}) + + +def _seed_account(TestSessionLocal, owner, auth_id, ep_id, label="ChatGPT Subscription"): + db = TestSessionLocal() + try: + db.add(ProviderAuthSession( + id=auth_id, provider=csr.chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER, owner=owner, + label=label, base_url=_BASE, access_token="AT", refresh_token="RT", auth_mode="chatgpt", + )) + db.add(ModelEndpoint(id=ep_id, name=label, base_url=_BASE, provider_auth_id=auth_id, owner=owner)) + db.commit() + finally: + db.close() + + +def _fake_device_code(monkeypatch): + monkeypatch.setattr( + csr.chatgpt_subscription, "request_device_code", + lambda: {"device_auth_id": "dev-1", "user_code": "ABCD-EFGH", "interval": 3, "expires_in": 120}, + ) + + +def test_start_carries_label_and_connect_mode_without_tokens(monkeypatch): + _mem_db(monkeypatch) + _fake_device_code(monkeypatch) + start = csr._start_device_flow(_request("alice"), {"label": " codex00 "}) + assert start.pending == { + "device_auth_id": "dev-1", + "user_code": "ABCD-EFGH", + "owner": "alice", + "label": "codex00", + "reconnect_auth_id": None, + "reconnect_endpoint_id": None, + } + assert start.response["mode"] == "connect" + assert start.response["account_label"] == "codex00" + for value in start.pending.values(): + assert "access_token" not in str(value) and "refresh_token" not in str(value) + assert "access_token" not in start.pending and "refresh_token" not in start.pending + + +def test_start_rejects_overlong_or_duplicate_label(monkeypatch): + TestSessionLocal = _mem_db(monkeypatch) + _fake_device_code(monkeypatch) + with pytest.raises(HTTPException) as exc: + csr._start_device_flow(_request("alice"), {"label": "x" * 41}) + assert exc.value.status_code == 400 + _seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00") + with pytest.raises(HTTPException) as exc: + csr._start_device_flow(_request("alice"), {"label": "codex00"}) + assert exc.value.status_code == 409 + + +def test_start_reconnect_carries_exact_target_ids(monkeypatch): + TestSessionLocal = _mem_db(monkeypatch) + _fake_device_code(monkeypatch) + _seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00") + start = csr._start_device_flow( + _request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"}, + ) + assert start.pending["reconnect_auth_id"] == "auth-a" + assert start.pending["reconnect_endpoint_id"] == "ep-a" + assert start.pending["owner"] == "alice" + assert start.response["mode"] == "reconnect" + + +def test_start_reconnect_for_another_owner_is_rejected(monkeypatch): + TestSessionLocal = _mem_db(monkeypatch) + _fake_device_code(monkeypatch) + _seed_account(TestSessionLocal, "alice", "auth-a", "ep-a") + with pytest.raises(HTTPException) as exc: + csr._start_device_flow(_request("mallory"), {"reconnect_auth_id": "auth-a"}) + assert exc.value.status_code == 404 + # Mismatched endpoint id for a real auth id is also rejected. + _seed_account(TestSessionLocal, "alice", "auth-b", "ep-b") + with pytest.raises(HTTPException) as exc: + csr._start_device_flow(_request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-b"}) + assert exc.value.status_code == 404 + + +def test_poll_provisions_with_pending_operation_and_owner(monkeypatch): + factory = _mem_db(monkeypatch) + _seed_account(factory, "alice", "auth-a", "ep-a") + monkeypatch.setattr( + csr.chatgpt_subscription, "poll_device_auth", + lambda device_auth_id, user_code: {"authorization_code": "code", "code_verifier": "ver"}, + ) + monkeypatch.setattr( + csr.chatgpt_subscription, "exchange_authorization_code", + lambda code, verifier: {"access_token": "AT", "refresh_token": "RT"}, + ) + seen = {} + + def fake_provision(tokens, owner, **kwargs): + seen["tokens"] = tokens + seen["owner"] = owner + seen["kwargs"] = kwargs + return {"id": "ep-new", "name": "ChatGPT · codex00", "models": ["gpt-5.5"]} + + monkeypatch.setattr(csr, "_provision_endpoint", fake_provision) + pending = { + "device_auth_id": "dev-1", "user_code": "X", "owner": "alice", "label": "codex00", + "reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a", + } + outcome = csr._poll_device_flow(_request("alice"), pending) + assert outcome.status == "authorized" + assert seen["owner"] == "alice" + assert seen["kwargs"] == {"label": "codex00", "reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"} + + +def test_poll_by_a_different_user_is_rejected(monkeypatch): + _mem_db(monkeypatch) + called = {"n": 0} + + def _never(*a, **k): + called["n"] += 1 + return {} + + monkeypatch.setattr(csr.chatgpt_subscription, "poll_device_auth", _never) + pending = {"device_auth_id": "dev-1", "user_code": "X", "owner": "alice", "label": "", "reconnect_auth_id": None, "reconnect_endpoint_id": None} + with pytest.raises(HTTPException) as exc: + csr._poll_device_flow(_request("mallory"), pending) + assert exc.value.status_code == 403 + assert called["n"] == 0 + + +def test_expired_pending_flow_is_dropped_by_store(): + clock = {"t": 1000.0} + store = PendingDeviceFlowStore(time_func=lambda: clock["t"]) + poll_id = store.add({"owner": "alice", "reconnect_auth_id": "auth-a"}, interval=5, expires_in=60) + assert store.get_payload(poll_id)["reconnect_auth_id"] == "auth-a" + clock["t"] += 61 + assert store.get_payload(poll_id) is None + clock["t"] += 3600 + assert store.get_payload(poll_id) is None + + +def test_poll_revalidates_reconnect_endpoint_after_start(monkeypatch): + factory = _mem_db(monkeypatch) + _fake_device_code(monkeypatch) + _seed_account(factory, "alice", "auth-a", "ep-a") + start = csr._start_device_flow(_request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"}) + with factory() as db: + db.delete(db.get(ModelEndpoint, "ep-a")) + db.commit() + monkeypatch.setattr(csr.chatgpt_subscription, "poll_device_auth", lambda *args: pytest.fail("must reject before polling")) + with pytest.raises(HTTPException) as exc: + csr._poll_device_flow(_request("alice"), start.pending) + assert exc.value.status_code == 404 + + +def test_pending_payload_ignores_actual_secrets_and_allows_harmless_label(monkeypatch): + _mem_db(monkeypatch) + monkeypatch.setattr(csr.chatgpt_subscription, "request_device_code", lambda: { + "device_auth_id": "dev", "user_code": "code", "access_token": "SECRET-AT", "refresh_token": "SECRET-RT", + }) + start = csr._start_device_flow(_request("alice"), {"label": "refresh_token"}) + assert start.pending["label"] == "refresh_token" + for payload in (start.pending, start.response): + assert "access_token" not in payload and "refresh_token" not in payload + assert "SECRET-AT" not in str(payload) and "SECRET-RT" not in str(payload) + + +def test_account_listing_and_usage_route_are_owner_scoped(monkeypatch): + TestSessionLocal = _mem_db(monkeypatch) + monkeypatch.setattr(csr, "require_admin", lambda request: None) + _seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00") + _seed_account(TestSessionLocal, "bob", "auth-b", "ep-b", label="ChatGPT · work") + router = csr.setup_chatgpt_subscription_routes() + handlers = {(r.path, tuple(sorted(r.methods))): r.endpoint for r in router.routes} + list_accounts = handlers[("/api/chatgpt-subscription/accounts", ("GET",))] + usage = handlers[("/api/chatgpt-subscription/accounts/{auth_id}/usage", ("GET",))] + + alice_accounts = list_accounts(_request("alice")) + assert [a["auth_id"] for a in alice_accounts] == ["auth-a"] + assert alice_accounts[0]["label"] == "codex00" + assert alice_accounts[0]["endpoint_ids"] == ["ep-a"] + assert "access_token" not in str(alice_accounts) and "refresh_token" not in str(alice_accounts) + assert "AT" not in str(alice_accounts) + + calls = [] + + def fake_usage(auth_id, owner=None, force_refresh=False, cache=None): + calls.append((auth_id, owner, force_refresh)) + return {"plan_type": "plus", "limits": [], "auth_id": auth_id} + + monkeypatch.setattr(csr.chatgpt_subscription, "get_account_usage", fake_usage) + ok = usage("auth-a", _request("alice"), refresh=True) + assert ok["available"] is True + assert ok["usage"]["auth_id"] == "auth-a" + assert calls == [("auth-a", "alice", True)] + + # Bob's account is invisible to Alice; the usage helper is never invoked. + with pytest.raises(HTTPException) as exc: + usage("auth-b", _request("alice")) + assert exc.value.status_code == 404 + assert len(calls) == 1 diff --git a/tests/test_chatgpt_subscription_routes.py b/tests/test_chatgpt_subscription_routes.py index 8661efe37..3f35da339 100644 --- a/tests/test_chatgpt_subscription_routes.py +++ b/tests/test_chatgpt_subscription_routes.py @@ -1,6 +1,7 @@ """DB-backed ChatGPT Subscription endpoint provisioning tests.""" import json +from types import SimpleNamespace import pytest from sqlalchemy import create_engine @@ -53,23 +54,163 @@ def test_provision_creates_owner_scoped_auth_session_and_endpoint(monkeypatch): db.close() -def test_provision_refreshes_existing_auth_session_and_endpoint(monkeypatch): +def _connect(monkeypatch, owner, label="", models=("gpt-5.5",), tokens=None, **kwargs): + monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: list(models)) + tokens = tokens or {"access_token": f"AT-{label or 'default'}", "refresh_token": f"RT-{label or 'default'}"} + return csr._provision_endpoint(tokens, owner, label=label, **kwargs) + + +def test_second_connection_creates_independent_auth_and_endpoint(monkeypatch): + """Connecting a second subscription must never overwrite the first.""" TestSessionLocal = _mem_db(monkeypatch) - monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: ["gpt-5.5"]) - first = csr._provision_endpoint({"access_token": "OLD", "refresh_token": "OLD-RT"}, "bob") - second = csr._provision_endpoint({"access_token": "NEW", "refresh_token": "NEW-RT"}, "bob") + first = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"}) + second = _connect(monkeypatch, "bob", label="codex01", tokens={"access_token": "B-AT", "refresh_token": "B-RT"}) - assert first["id"] == second["id"] + assert first["id"] != second["id"] + assert first["provider_auth_id"] != second["provider_auth_id"] + assert first["name"] == "ChatGPT · codex00" + assert second["name"] == "ChatGPT · codex01" + assert second["reconnected"] is False db = TestSessionLocal() try: - auth_rows = db.query(ProviderAuthSession).filter(ProviderAuthSession.owner == "bob").all() - ep_rows = db.query(ModelEndpoint).filter(ModelEndpoint.owner == "bob").all() - assert len(auth_rows) == 1 - assert len(ep_rows) == 1 - assert auth_rows[0].access_token == "NEW" - assert auth_rows[0].refresh_token == "NEW-RT" - assert ep_rows[0].provider_auth_id == auth_rows[0].id + auth_rows = {a.id: a for a in db.query(ProviderAuthSession).filter(ProviderAuthSession.owner == "bob").all()} + ep_rows = {e.id: e for e in db.query(ModelEndpoint).filter(ModelEndpoint.owner == "bob").all()} + assert len(auth_rows) == 2 + assert len(ep_rows) == 2 + # Account A's credentials are untouched by connecting B. + assert auth_rows[first["provider_auth_id"]].access_token == "A-AT" + assert auth_rows[first["provider_auth_id"]].refresh_token == "A-RT" + assert auth_rows[second["provider_auth_id"]].access_token == "B-AT" + assert ep_rows[first["id"]].provider_auth_id == first["provider_auth_id"] + assert ep_rows[second["id"]].provider_auth_id == second["provider_auth_id"] + # Same base URL + same model on both routes is valid and intentional. + assert ep_rows[first["id"]].base_url == ep_rows[second["id"]].base_url + assert json.loads(ep_rows[first["id"]].cached_models) == json.loads(ep_rows[second["id"]].cached_models) + for ep in ep_rows.values(): + assert ep.supports_tools is False + finally: + db.close() + + +def test_unlabelled_connections_get_distinct_default_names(monkeypatch): + _mem_db(monkeypatch) + first = _connect(monkeypatch, "bob") + second = _connect(monkeypatch, "bob") + third = _connect(monkeypatch, "bob") + # The first account keeps the legacy name; later ones are distinguishable. + assert first["name"] == "ChatGPT Subscription" + assert second["name"] == "ChatGPT · account 2" + assert third["name"] == "ChatGPT · account 3" + assert len({first["provider_auth_id"], second["provider_auth_id"], third["provider_auth_id"]}) == 3 + + +def test_duplicate_label_for_same_owner_is_rejected_but_other_owner_ok(monkeypatch): + _mem_db(monkeypatch) + _connect(monkeypatch, "bob", label="codex00") + with pytest.raises(ValueError, match="already connected"): + _connect(monkeypatch, "bob", label="Codex00") + # Labels are owner scoped: another user may reuse the same label. + other = _connect(monkeypatch, "carol", label="codex00") + assert other["name"] == "ChatGPT · codex00" + + +def test_label_is_trimmed_bounded_and_control_chars_stripped(monkeypatch): + _mem_db(monkeypatch) + res = _connect(monkeypatch, "bob", label=" co\x00dex 00\t ") + assert res["account_label"] == "codex 00" + assert res["name"] == "ChatGPT · codex 00" + with pytest.raises(ValueError, match="at most 40"): + _connect(monkeypatch, "bob", label="x" * 41) + + +def test_reconnect_updates_only_the_targeted_account(monkeypatch): + TestSessionLocal = _mem_db(monkeypatch) + a = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"}) + b = _connect(monkeypatch, "bob", label="codex01", tokens={"access_token": "B-AT", "refresh_token": "B-RT"}) + + res = _connect( + monkeypatch, "bob", tokens={"access_token": "A-AT2", "refresh_token": "A-RT2"}, + reconnect_auth_id=a["provider_auth_id"], reconnect_endpoint_id=a["id"], + ) + assert res["reconnected"] is True + assert res["id"] == a["id"] + assert res["provider_auth_id"] == a["provider_auth_id"] + assert res["name"] == "ChatGPT · codex00" # label preserved on reconnect + db = TestSessionLocal() + try: + assert db.query(ProviderAuthSession).count() == 2 + assert db.query(ModelEndpoint).count() == 2 + auth_a = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first() + auth_b = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == b["provider_auth_id"]).first() + assert auth_a.access_token == "A-AT2" and auth_a.refresh_token == "A-RT2" + assert auth_b.access_token == "B-AT" and auth_b.refresh_token == "B-RT" + finally: + db.close() + + # And the symmetric case: reconnecting B leaves A alone. + _connect( + monkeypatch, "bob", tokens={"access_token": "B-AT2", "refresh_token": "B-RT2"}, + reconnect_auth_id=b["provider_auth_id"], reconnect_endpoint_id=b["id"], + ) + db = TestSessionLocal() + try: + auth_a = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first() + auth_b = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == b["provider_auth_id"]).first() + assert auth_a.access_token == "A-AT2" + assert auth_b.access_token == "B-AT2" + finally: + db.close() + + +def test_reconnect_target_owned_by_another_user_is_rejected(monkeypatch): + TestSessionLocal = _mem_db(monkeypatch) + a = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"}) + with pytest.raises(csr.chatgpt_subscription.ChatGPTSubscriptionAuthNotFound): + _connect( + monkeypatch, "mallory", tokens={"access_token": "M-AT", "refresh_token": "M-RT"}, + reconnect_auth_id=a["provider_auth_id"], reconnect_endpoint_id=a["id"], + ) + db = TestSessionLocal() + try: + auth_a = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first() + assert auth_a.access_token == "A-AT" + assert db.query(ProviderAuthSession).count() == 1 + assert db.query(ModelEndpoint).count() == 1 + finally: + db.close() + + +def test_legacy_single_account_endpoint_can_be_reconnected_in_place(monkeypatch): + """Rows provisioned before multi-account support keep working unchanged.""" + TestSessionLocal = _mem_db(monkeypatch) + db = TestSessionLocal() + try: + db.add(ProviderAuthSession( + id="legacyauth", provider=csr.chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER, + owner="alice", label="ChatGPT Subscription", base_url="https://chatgpt.com/backend-api/codex", + access_token="OLD", refresh_token="OLD-RT", auth_mode="chatgpt", + )) + db.add(ModelEndpoint( + id="legacyep", name="ChatGPT Subscription", base_url="https://chatgpt.com/backend-api/codex", + provider_auth_id="legacyauth", owner="alice", supports_tools=False, + )) + db.commit() + finally: + db.close() + + res = _connect( + monkeypatch, "alice", tokens={"access_token": "NEW", "refresh_token": "NEW-RT"}, + reconnect_auth_id="legacyauth", + ) + assert res["id"] == "legacyep" + assert res["name"] == "ChatGPT Subscription" + assert res["account_label"] == "" + db = TestSessionLocal() + try: + assert db.query(ProviderAuthSession).count() == 1 + assert db.query(ProviderAuthSession).first().access_token == "NEW" + assert db.query(ModelEndpoint).count() == 1 finally: db.close() @@ -221,7 +362,7 @@ def test_delete_endpoint_route_revokes_orphaned_provider_auth(monkeypatch): db.close() delete_endpoint = _delete_route(monkeypatch, TestSessionLocal) - result = delete_endpoint("ep1", object()) + result = delete_endpoint("ep1", SimpleNamespace(state=SimpleNamespace(current_user="alice"))) assert result["deleted"] is True # The last (only) endpoint backed by auth1 is gone, so the route revokes it. @@ -243,7 +384,7 @@ def test_delete_endpoint_route_keeps_auth_when_shared(monkeypatch): db.close() delete_endpoint = _delete_route(monkeypatch, TestSessionLocal) - result = delete_endpoint("ep1", object()) + result = delete_endpoint("ep1", SimpleNamespace(state=SimpleNamespace(current_user="alice"))) assert result["deleted"] is True # ep2 still references auth1, so deleting ep1 must NOT revoke the credentials. @@ -278,3 +419,102 @@ def test_delete_orphaned_provider_auth_revokes_only_after_last_of_several(monkey assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth1").first() is None finally: db.close() + + +def test_delete_account_a_preserves_account_b(monkeypatch): + """Deleting one subscription clears only its own orphaned auth row.""" + TestSessionLocal = _mem_db(monkeypatch) + a = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"}) + b = _connect(monkeypatch, "bob", label="codex01", tokens={"access_token": "B-AT", "refresh_token": "B-RT"}) + + delete_endpoint = _delete_route(monkeypatch, TestSessionLocal) + result = delete_endpoint(a["id"], SimpleNamespace(state=SimpleNamespace(current_user="bob"))) + assert result["deleted"] is True + assert result["cleared_provider_auth"] is True + + db = TestSessionLocal() + try: + assert db.query(ModelEndpoint).filter(ModelEndpoint.id == a["id"]).first() is None + assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first() is None + ep_b = db.query(ModelEndpoint).filter(ModelEndpoint.id == b["id"]).first() + auth_b = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == b["provider_auth_id"]).first() + assert ep_b is not None and ep_b.is_enabled is True + assert auth_b is not None and auth_b.access_token == "B-AT" and auth_b.refresh_token == "B-RT" + finally: + db.close() + + +def test_manual_model_refresh_uses_the_endpoints_own_auth_session(monkeypatch): + """Refreshing A's models resolves A's bearer and never touches B.""" + import routes.model_routes as mr + import src.endpoint_resolver as endpoint_resolver + + TestSessionLocal = _mem_db(monkeypatch) + a = _connect(monkeypatch, "bob", label="codex00", models=("gpt-5.5",)) + b = _connect(monkeypatch, "bob", label="codex01", models=("gpt-5.5",)) + monkeypatch.setattr(mr, "SessionLocal", TestSessionLocal) + monkeypatch.setattr(mr, "require_admin", lambda request: None) + + resolved = [] + + def fake_runtime(ep, owner=None): + resolved.append((ep.id, ep.provider_auth_id, owner)) + return (ep.base_url, f"bearer-{ep.provider_auth_id}") + + monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", fake_runtime) + probed = [] + + def fake_probe(base, api_key=None, timeout=5): + probed.append(api_key) + return ["gpt-5.5", "gpt-5.5-codex"] if api_key == f"bearer-{a['provider_auth_id']}" else [] + + monkeypatch.setattr(mr, "_probe_endpoint", fake_probe) + + router = mr.setup_model_routes(model_discovery=None) + list_models = next( + r.endpoint for r in router.routes + if getattr(r, "path", "") == "/api/model-endpoints/{ep_id}/models" and "GET" in getattr(r, "methods", set()) + ) + + class _Resp: + headers = {} + + rows = list_models(a["id"], SimpleNamespace(state=SimpleNamespace(current_user="bob")), _Resp(), refresh=True) + assert resolved == [(a["id"], a["provider_auth_id"], "bob")] + assert probed == [f"bearer-{a['provider_auth_id']}"] + assert {r["id"] for r in rows} == {"gpt-5.5", "gpt-5.5-codex"} + + db = TestSessionLocal() + try: + ep_a = db.query(ModelEndpoint).filter(ModelEndpoint.id == a["id"]).first() + ep_b = db.query(ModelEndpoint).filter(ModelEndpoint.id == b["id"]).first() + assert json.loads(ep_a.cached_models) == ["gpt-5.5", "gpt-5.5-codex"] + assert json.loads(ep_b.cached_models) == ["gpt-5.5"] # untouched + finally: + db.close() + + +def test_endpoint_listing_exposes_account_metadata_without_credentials(monkeypatch): + import routes.model_routes as mr + + TestSessionLocal = _mem_db(monkeypatch) + a = _connect(monkeypatch, "bob", label="codex00") + _connect(monkeypatch, "bob", label="codex01") + monkeypatch.setattr(mr, "SessionLocal", TestSessionLocal) + monkeypatch.setattr(mr, "require_admin", lambda request: None) + router = mr.setup_model_routes(model_discovery=None) + list_endpoints = next( + r.endpoint for r in router.routes + if getattr(r, "path", "") == "/api/model-endpoints" and "GET" in getattr(r, "methods", set()) + ) + rows = list_endpoints(SimpleNamespace(state=SimpleNamespace(current_user="bob"))) + by_id = {r["id"]: r for r in rows} + assert by_id[a["id"]]["provider"] == "chatgpt-subscription" + assert by_id[a["id"]]["provider_auth_id"] == a["provider_auth_id"] + assert by_id[a["id"]]["account_label"] == "codex00" + assert by_id[a["id"]]["supports_tools"] is False + assert by_id[a["id"]]["has_key"] is False + labels = sorted(r["account_label"] for r in rows) + assert labels == ["codex00", "codex01"] + dumped = json.dumps(rows) + assert "AT-" not in dumped and "RT-" not in dumped and "refresh_token" not in dumped diff --git a/tests/test_chatgpt_subscription_usage.py b/tests/test_chatgpt_subscription_usage.py new file mode 100644 index 000000000..28e3dec0b --- /dev/null +++ b/tests/test_chatgpt_subscription_usage.py @@ -0,0 +1,356 @@ +"""Read-only ChatGPT Subscription usage: contract, normalization, cache, errors. + +Contract mirrored from openai/codex ``codex-rs/backend-client`` (ChatGptApi +path style): ``GET {backend-api}/wham/usage`` returning ``plan_type``, +``rate_limit{primary_window,secondary_window}``, ``additional_rate_limits[]``, +``rate_limit_reached_type`` and ``account_id``. +""" + +import json + +import httpx +import pytest + +from src import chatgpt_subscription as cs + + +@pytest.fixture +def owned_accounts(monkeypatch): + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + import core.database as cdb + engine = create_engine("sqlite:///:memory:") + cdb.Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine) + monkeypatch.setattr(cs, "_database_handles", lambda: (cdb.ProviderAuthSession, factory, cdb.utcnow_naive)) + with factory() as db: + for auth_id in ("auth-a", "auth-b"): + db.add(cdb.ProviderAuthSession(id=auth_id, provider=cs.CHATGPT_SUBSCRIPTION_PROVIDER, owner="alice", base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL)) + db.commit() + yield factory + engine.dispose() + + +def _window(used, seconds, reset_at): + return {"used_percent": used, "limit_window_seconds": seconds, "reset_after_seconds": 10, "reset_at": reset_at} + + +_FULL_PAYLOAD = { + "plan_type": "plus", + "account_id": "acct_123", + "user_id": "user_1", + "rate_limit": { + "allowed": True, + "limit_reached": False, + "primary_window": _window(71, 300 * 60, 1_800_000_000), + "secondary_window": _window(28, 7 * 24 * 3600, 1_800_400_000), + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.5 Pro", + "metered_feature": "codex_pro", + "normal_model_slug": "gpt-5.5-pro", + "rate_limit": {"allowed": True, "limit_reached": False, "primary_window": _window(5, 3600, 1_800_001_000)}, + }, + {"limit_name": "future", "metered_feature": "codex_future", "rate_limit": None}, + "garbage", + ], + "credits": {"has_credits": True, "unlimited": False, "balance": "9.99"}, + "spend_control": {"reached": False}, + "rate_limit_reached_type": None, + "rate_limit_upsell": {"title": "upgrade"}, + "some_new_field": {"nested": [1, 2, 3]}, +} + + +def test_usage_url_matches_codex_chatgpt_backend_contract(): + assert cs.CHATGPT_USAGE_URL == "https://chatgpt.com/backend-api/wham/usage" + + +def test_normalize_primary_secondary_and_additional_buckets(): + out = cs.normalize_usage_payload(json.loads(json.dumps(_FULL_PAYLOAD))) + assert out["plan_type"] == "plus" + assert out["account_id"] == "acct_123" + assert out["ordinary_usage_allowed"] is True + assert out["rate_limit_reached_type"] is None + codex, pro, future = out["limits"] + assert codex["limit_id"] == "codex" and codex["limit_name"] is None + primary, secondary = codex["windows"] + assert primary == { + "kind": "primary", "name": "5H", "used_percent": 71.0, "remaining_percent": 29.0, + "window_minutes": 300, "resets_at": 1_800_000_000, "reset_after_seconds": 10, + } + assert secondary["kind"] == "secondary" + assert secondary["name"] == "WEEK" + assert secondary["window_minutes"] == 10080 + assert secondary["used_percent"] == 28.0 and secondary["remaining_percent"] == 72.0 + assert pro["limit_id"] == "codex_pro" + assert pro["limit_name"] == "GPT-5.5 Pro" + assert pro["normal_model_slug"] == "gpt-5.5-pro" + assert pro["windows"][0]["name"] == "1H" + assert pro["windows"][0]["used_percent"] == 5.0 + # Unknown/empty additional bucket is kept (not dropped) but has no windows. + assert future["limit_id"] == "codex_future" and future["windows"] == [] + # Unknown top-level fields and raw upstream metadata never leak through. + for forbidden in ("user_id", "credits", "spend_control", "rate_limit_upsell", "some_new_field"): + assert forbidden not in out + + +def test_normalize_tolerates_absent_windows_and_unknown_reached_type(): + out = cs.normalize_usage_payload({"plan_type": "pro", "rate_limit": None, "rate_limit_reached_type": {"type": "workspace_owner_credits_depleted"}}) + assert out["plan_type"] == "pro" + assert out["ordinary_usage_allowed"] is None + assert out["rate_limit_reached_type"] == "workspace_owner_credits_depleted" + assert out["limits"] == [{ + "limit_id": "codex", "limit_name": None, "normal_model_slug": None, + "allowed": None, "limit_reached": None, "windows": [], + }] + # Missing reset must stay absent — never invented. + out2 = cs.normalize_usage_payload({"plan_type": "free", "rate_limit": {"primary_window": {"used_percent": "12.5", "limit_window_seconds": 0}}}) + win = out2["limits"][0]["windows"][0] + assert win["resets_at"] is None and win["window_minutes"] is None and win["name"] == "LIMIT" + assert win["used_percent"] == 12.5 + + +def test_normalize_clamps_percent_and_rejects_non_object(): + out = cs.normalize_usage_payload({"rate_limit": {"primary_window": {"used_percent": 250, "limit_window_seconds": 90}}}) + win = out["limits"][0]["windows"][0] + assert win["used_percent"] == 100.0 and win["remaining_percent"] == 0.0 + assert win["window_minutes"] == 2 # ceil(90/60) like Codex + with pytest.raises(cs.ChatGPTUsageUnavailable) as exc: + cs.normalize_usage_payload(["not", "an", "object"]) + assert exc.value.reason == "malformed" + + +@pytest.mark.parametrize("minutes,name", [(300, "5H"), (10080, "WEEK"), (20160, "2W"), (1440, "1D"), (90, "90M"), (None, "LIMIT")]) +def test_friendly_window_names_derive_from_duration(minutes, name): + assert cs.friendly_window_name(minutes) == name + + +def _fake_get(monkeypatch, *, status=200, body=b"{}", raise_exc=None, seen=None): + def fake_get(url, headers=None, timeout=None): + if seen is not None: + seen.append({"url": url, "headers": dict(headers or {}), "timeout": timeout}) + if raise_exc is not None: + raise raise_exc + return httpx.Response(status, content=body, request=httpx.Request("GET", url)) + + monkeypatch.setattr(cs.httpx, "get", fake_get) + + +def test_fetch_usage_uses_bearer_and_account_header_and_strict_timeout(monkeypatch): + seen = [] + _fake_get(monkeypatch, body=json.dumps({"plan_type": "plus"}).encode(), seen=seen) + # A JWT whose auth claim carries the ChatGPT account id, as Codex reads it. + import base64 + claims = base64.urlsafe_b64encode(json.dumps({"https://api.openai.com/auth": {"chatgpt_account_id": "acct_9"}}).encode()).rstrip(b"=").decode() + token = f"hdr.{claims}.sig" + data = cs.fetch_usage_payload(token) + assert data == {"plan_type": "plus"} + assert seen[0]["url"] == "https://chatgpt.com/backend-api/wham/usage" + assert seen[0]["headers"]["Authorization"] == f"Bearer {token}" + assert seen[0]["headers"]["ChatGPT-Account-Id"] == "acct_9" + assert 0 < seen[0]["timeout"] <= 10 + + +@pytest.mark.parametrize("status,reason", [(401, "reauth"), (403, "reauth"), (429, "rate_limited"), (500, "upstream"), (503, "upstream"), (418, "upstream")]) +def test_fetch_usage_classifies_http_failures(monkeypatch, status, reason): + _fake_get(monkeypatch, status=status, body=b"nope") + with pytest.raises(cs.ChatGPTUsageUnavailable) as exc: + cs.fetch_usage_payload("tok") + assert exc.value.reason == reason + assert exc.value.status_code == status + + +def test_fetch_usage_handles_timeout_network_and_malformed_json(monkeypatch): + _fake_get(monkeypatch, raise_exc=httpx.ReadTimeout("slow")) + with pytest.raises(cs.ChatGPTUsageUnavailable) as exc: + cs.fetch_usage_payload("tok") + assert exc.value.reason == "timeout" + _fake_get(monkeypatch, raise_exc=httpx.ConnectError("down")) + with pytest.raises(cs.ChatGPTUsageUnavailable) as exc: + cs.fetch_usage_payload("tok") + assert exc.value.reason == "network" + _fake_get(monkeypatch, body=b"not json") + with pytest.raises(cs.ChatGPTUsageUnavailable) as exc: + cs.fetch_usage_payload("tok") + assert exc.value.reason == "malformed" + _fake_get(monkeypatch, body=b"[1,2]") + with pytest.raises(cs.ChatGPTUsageUnavailable) as exc: + cs.fetch_usage_payload("tok") + assert exc.value.reason == "malformed" + + +def test_usage_cache_is_per_auth_session_and_expires(): + clock = {"t": 100.0} + cache = cs.UsageCache(ttl_seconds=45, time_func=lambda: clock["t"]) + cache.put("auth-a", {"plan_type": "plus"}) + assert cache.get("auth-a") == {"plan_type": "plus"} + # Cache for A can never satisfy B. + assert cache.get("auth-b") is None + clock["t"] += 44 + assert cache.get("auth-a") is not None + clock["t"] += 2 + assert cache.get("auth-a") is None + + +def test_cache_is_bounded_copies_values_and_cleans_expired_accounts(): + clock = [0] + cache = cs.UsageCache(time_func=lambda: clock[0], max_entries=2) + original = {"limits": [{"used": 1}]} + cache.put("a", original) + original["limits"][0]["used"] = 99 + assert cache.get("a")["limits"][0]["used"] == 1 + cache.put("b", {}) + cache.put("c", {}) + assert cache.get("a") is None + clock[0] = 46 + cache.put("d", {}) + assert set(cache._entries) == {"d"} + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), "NaN", "Infinity", True, {}]) +def test_non_finite_and_malformed_usage_never_invents_a_percentage(value): + out = cs.normalize_usage_window({"used_percent": value, "reset_at": value}, "primary") + assert out["used_percent"] is None + assert out["remaining_percent"] is None + assert out["resets_at"] is None + json.dumps(out, allow_nan=False) + + +def test_future_window_kind_and_reached_kind_are_retained(): + out = cs.normalize_usage_payload({"rate_limit": {"tertiary_window": _window(3, 7200, 100)}, + "rate_limit_reached_type": {"kind": "future_limit"}}) + assert out["limits"][0]["windows"][0]["kind"] == "tertiary" + assert out["limits"][0]["windows"][0]["window_minutes"] == 120 + assert out["rate_limit_reached_type"] == "future_limit" + + +def test_cache_hits_revalidate_owner_and_deleted_auth(owned_accounts): + from core.database import ProviderAuthSession + cache = cs.UsageCache() + cache.put("auth-a", {"plan_type": "plus"}) + with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound): + cs.get_account_usage("auth-a", owner="mallory", cache=cache) + with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound): + cs.get_account_usage("auth-a", owner=None, cache=cache) + assert cs.get_account_usage("auth-a", owner="alice", cache=cache)["cached"] is True + with owned_accounts() as db: + db.delete(db.get(ProviderAuthSession, "auth-a")) + db.commit() + with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound): + cs.get_account_usage("auth-a", owner="alice", cache=cache) + + +def test_oauth_errors_never_echo_upstream_credentials(): + response = httpx.Response(401, json={"error": {"code": "invalid_token", "message": "SECRET-AT SECRET-RT"}}) + with pytest.raises(cs.ChatGPTSubscriptionReauthRequired) as exc: + cs._raise_for_oauth_response(response, "token refresh") + assert "SECRET" not in str(exc.value) + + +def test_get_account_usage_uses_exact_auth_session_cache_and_force_refresh(monkeypatch, owned_accounts): + clock = {"t": 1000.0} + cache = cs.UsageCache(ttl_seconds=45, time_func=lambda: clock["t"]) + resolved = [] + + def fake_resolve(auth_id, owner=None, force_refresh=False): + resolved.append((auth_id, owner)) + return {"api_key": f"token-for-{auth_id}", "base_url": cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL} + + fetched = [] + + def fake_fetch(access_token, timeout=None): + fetched.append(access_token) + return {"plan_type": "plus" if access_token.endswith("auth-a") else "pro", "rate_limit": {"primary_window": _window(10, 300, 5)}} + + monkeypatch.setattr(cs, "resolve_runtime_credentials", fake_resolve) + monkeypatch.setattr(cs, "fetch_usage_payload", fake_fetch) + + a1 = cs.get_account_usage("auth-a", owner="alice", cache=cache) + assert a1["plan_type"] == "plus" and a1["cached"] is False and a1["auth_id"] == "auth-a" + assert resolved == [("auth-a", "alice")] + assert fetched == ["token-for-auth-a"] + + a2 = cs.get_account_usage("auth-a", owner="alice", cache=cache) + assert a2["cached"] is True and a2["plan_type"] == "plus" + assert len(fetched) == 1 # served from cache + + b1 = cs.get_account_usage("auth-b", owner="alice", cache=cache) + assert b1["plan_type"] == "pro" and b1["cached"] is False + assert fetched == ["token-for-auth-a", "token-for-auth-b"] # B never reuses A's cache or token + + a3 = cs.get_account_usage("auth-a", owner="alice", force_refresh=True, cache=cache) + assert a3["cached"] is False + assert fetched == ["token-for-auth-a", "token-for-auth-b", "token-for-auth-a"] + + # Nothing token-shaped in the returned structure. + dumped = json.dumps(a3) + assert "token-for" not in dumped and "refresh_token" not in dumped and "access_token" not in dumped + + +def test_get_account_usage_maps_credential_failures_without_touching_endpoint(monkeypatch, owned_accounts): + cache = cs.UsageCache(ttl_seconds=45) + + def boom(auth_id, owner=None, force_refresh=False): + raise cs.ChatGPTSubscriptionReauthRequired("expired") + + monkeypatch.setattr(cs, "resolve_runtime_credentials", boom) + with pytest.raises(cs.ChatGPTUsageUnavailable) as exc: + cs.get_account_usage("auth-a", owner="alice", cache=cache) + assert exc.value.reason == "reauth" + + def missing(auth_id, owner=None, force_refresh=False): + raise cs.ChatGPTSubscriptionAuthNotFound("gone") + + monkeypatch.setattr(cs, "resolve_runtime_credentials", missing) + with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound): + cs.get_account_usage("auth-x", owner="alice", cache=cache) + + +def test_usage_route_failure_is_reported_safely_and_endpoint_stays_enabled(monkeypatch): + import types + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from core.database import Base, ModelEndpoint, ProviderAuthSession + import routes.chatgpt_subscription_routes as csr + + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=engine) + TestSessionLocal = sessionmaker(bind=engine, autoflush=False) + monkeypatch.setattr(csr, "SessionLocal", TestSessionLocal) + monkeypatch.setattr(csr, "require_admin", lambda request: None) + db = TestSessionLocal() + try: + db.add(ProviderAuthSession(id="auth-a", provider=cs.CHATGPT_SUBSCRIPTION_PROVIDER, owner="alice", label="ChatGPT · codex00", + base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL, access_token="SECRET-AT", refresh_token="SECRET-RT")) + db.add(ModelEndpoint(id="ep-a", name="ChatGPT · codex00", base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL, + provider_auth_id="auth-a", owner="alice", is_enabled=True, supports_tools=False)) + db.commit() + finally: + db.close() + + def failing(auth_id, owner=None, force_refresh=False, cache=None): + raise cs.ChatGPTUsageUnavailable("reauth", "rejected", status_code=401) + + monkeypatch.setattr(cs, "get_account_usage", failing) + router = csr.setup_chatgpt_subscription_routes() + usage = next(r.endpoint for r in router.routes if r.path.endswith("/usage")) + request = types.SimpleNamespace(state=types.SimpleNamespace(current_user="alice"), app=None, headers={}) + payload = usage("auth-a", request) + assert payload["available"] is False + assert payload["reason"] == "reauth" + assert payload["reconnect_suggested"] is True + assert payload["account"]["auth_id"] == "auth-a" + dumped = json.dumps(payload) + assert "SECRET" not in dumped and "access_token" not in dumped and "refresh_token" not in dumped + + db = TestSessionLocal() + try: + ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-a").first() + auth = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth-a").first() + # Usage failure != endpoint failure: nothing disabled, nothing destroyed. + assert ep.is_enabled is True + assert auth.refresh_token == "SECRET-RT" + finally: + db.close() diff --git a/tests/test_chatgpt_subscription_usage_js.py b/tests/test_chatgpt_subscription_usage_js.py new file mode 100644 index 000000000..401cef6de --- /dev/null +++ b/tests/test_chatgpt_subscription_usage_js.py @@ -0,0 +1,574 @@ +"""Node-driven tests for the DOM-free ChatGPT usage card module + admin wiring.""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_MODULE = _REPO / "static" / "js" / "chatgptSubscriptionUsage.js" +_ADMIN = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8") +_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8") +pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH") + + +def _run_node(script: str): + proc = subprocess.run( + ["node", "--input-type=module"], input=script, capture_output=True, text=True, cwd=str(_REPO), timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip()) + + +_PAYLOAD_A = { + "available": True, + "account": {"auth_id": "auth-a", "label": "codex00", "name": "ChatGPT · codex00"}, + "usage": { + "auth_id": "auth-a", "plan_type": "plus", "account_id": "acct_a", "ordinary_usage_allowed": True, + "rate_limit_reached_type": None, "fetched_at": 1_800_000_000, "cached": False, + "limits": [ + {"limit_id": "codex", "limit_name": None, "normal_model_slug": None, "allowed": True, "limit_reached": False, + "windows": [ + {"kind": "primary", "name": "5H", "used_percent": 71, "remaining_percent": 29, "window_minutes": 300, "resets_at": 1_800_000_000 + 2 * 3600 + 14 * 60, "reset_after_seconds": 8040}, + {"kind": "secondary", "name": "WEEK", "used_percent": 28, "remaining_percent": 72, "window_minutes": 10080, "resets_at": 1_800_000_000 + 4 * 86400 + 18 * 3600, "reset_after_seconds": 1}, + ]}, + {"limit_id": "codex_pro", "limit_name": "GPT-5.5 Pro", "normal_model_slug": "gpt-5.5-pro", "allowed": True, "limit_reached": False, + "windows": [{"kind": "primary", "name": "1H", "used_percent": 5, "remaining_percent": 95, "window_minutes": 60, "resets_at": None, "reset_after_seconds": None}]}, + {"limit_id": "mystery", "limit_name": "Mystery", "normal_model_slug": None, "allowed": None, "limit_reached": None, "windows": []}, + ], + }, +} +_PAYLOAD_B = { + "available": True, + "account": {"auth_id": "auth-b", "label": "codex01", "name": "ChatGPT · codex01"}, + "usage": {"plan_type": "pro", "limits": [{"limit_id": "codex", "windows": [{"kind": "primary", "name": "5H", "used_percent": 100, "window_minutes": 300}]}]}, +} + + +def test_view_model_normalizes_windows_and_reset_countdowns(): + js = f""" + import {{ buildUsageViewModel }} from '{_MODULE.as_posix()}'; + const vm = buildUsageViewModel({json.dumps(_PAYLOAD_A)}, 1800000000); + console.log(JSON.stringify(vm)); + """ + vm = _run_node(js) + assert vm["available"] is True + assert vm["authId"] == "auth-a" + assert vm["plan"] == "Plus" + codex, pro, mystery = vm["limits"] + assert codex["title"] == "" + primary, secondary = codex["windows"] + assert primary["name"] == "5H" + assert primary["usedLabel"] == "71% used" + assert primary["remainingLabel"] == "29% remaining" + assert primary["resetLabel"] == "resets in 2h 14m" + assert secondary["name"] == "WEEK" + assert secondary["remainingLabel"] == "72% remaining" + assert secondary["resetLabel"] == "resets in 4d 18h" + # Additional bucket is kept with its own title/model; missing reset is not invented. + assert pro["title"] == "GPT-5.5 Pro" and pro["modelSlug"] == "gpt-5.5-pro" + assert pro["windows"][0]["resetLabel"] == "" + assert mystery["windows"] == [] + + +def test_view_model_is_defensive_about_bad_values(): + payload = { + "available": True, + "account": {"auth_id": "auth-x"}, + "usage": {"plan_type": 42, "limits": [ + {"limit_id": "codex", "windows": [{"kind": "primary", "used_percent": "abc", "window_minutes": "300", "resets_at": "soon"}, None, "str"]}, + "garbage", + {"limit_id": "over", "windows": [{"used_percent": 250, "resets_at": 5}]}, + ]}, + } + js = f""" + import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}'; + const vm = buildUsageViewModel({json.dumps(payload)}, 10); + const html = renderUsageCardHtml(vm, {{ endpointId: 'ep-x' }}); + console.log(JSON.stringify({{ vm, html }})); + """ + out = _run_node(js) + vm = out["vm"] + assert vm["plan"] == "" + codex, over = vm["limits"] + assert codex["windows"][0]["usedPercent"] is None + assert codex["windows"][0]["usedLabel"] == "usage unknown" + assert codex["windows"][0]["resetLabel"] == "" + assert over["windows"][0]["usedPercent"] == 100 + assert over["windows"][0]["remainingPercent"] == 0 + assert over["windows"][0]["resetLabel"] == "resets now" + assert 'aria-valuenow' not in out["html"].split('data-usage-limit="over"')[0] + assert 'aria-valuenow="100"' in out["html"] + + +def test_unavailable_states_render_message_and_refresh_button(): + cases = { + "reauth": {"available": False, "reason": "reauth", "reconnect_suggested": True, "account": {"auth_id": "auth-a"}}, + "rate_limited": {"available": False, "reason": "rate_limited", "account": {"auth_id": "auth-a"}}, + "timeout": {"available": False, "reason": "timeout", "account": {"auth_id": "auth-a"}}, + "malformed": None, + "empty": {}, + } + js = f""" + import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}'; + const cases = {json.dumps(cases)}; + const out = {{}}; + for (const [k, payload] of Object.entries(cases)) {{ + const vm = buildUsageViewModel(payload, 0); + out[k] = {{ vm, html: renderUsageCardHtml(vm, {{ endpointId: 'ep-a' }}) }}; + }} + console.log(JSON.stringify(out)); + """ + out = _run_node(js) + assert out["reauth"]["vm"]["message"] == "Usage unavailable — account may need reconnecting" + assert out["reauth"]["vm"]["reconnectSuggested"] is True + assert "rate limited" in out["rate_limited"]["vm"]["message"] + assert "timed out" in out["timeout"]["vm"]["message"] + assert out["malformed"]["vm"]["available"] is False + assert out["empty"]["vm"]["message"] == "Usage unavailable" + for case in out.values(): + assert "adm-chatgpt-usage-unavailable" in case["html"] + assert 'data-adm-chatgpt-usage-refresh=' in case["html"] + assert ">Refresh usage<" in case["html"] + + +def test_two_account_cards_render_independently_with_exact_ids(): + js = f""" + import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}'; + const a = renderUsageCardHtml(buildUsageViewModel({json.dumps(_PAYLOAD_A)}, 1800000000), {{ endpointId: 'ep-a' }}); + const b = renderUsageCardHtml(buildUsageViewModel({json.dumps(_PAYLOAD_B)}, 1800000000), {{ endpointId: 'ep-b' }}); + console.log(JSON.stringify({{ a, b }})); + """ + out = _run_node(js) + a, b = out["a"], out["b"] + assert 'data-adm-chatgpt-usage="auth-a"' in a and 'data-adm-chatgpt-usage="auth-b"' in b + assert 'data-adm-chatgpt-usage-refresh="auth-a" data-chatgpt-endpoint-id="ep-a"' in a + assert 'data-adm-chatgpt-reconnect="auth-a" data-chatgpt-endpoint-id="ep-a"' in a + assert 'data-adm-chatgpt-usage-refresh="auth-b" data-chatgpt-endpoint-id="ep-b"' in b + assert 'data-adm-chatgpt-reconnect="auth-b" data-chatgpt-endpoint-id="ep-b"' in b + assert "auth-b" not in a and "auth-a" not in b + assert ">Plus<" in a and ">Pro<" in b + assert "29% remaining" in a and "72% remaining" in a + assert "resets in 2h 14m" in a and "resets in 4d 18h" in a + assert "GPT-5.5 Pro" in a and "gpt-5.5-pro" in a + assert "0% remaining" in b and "adm-chatgpt-usage-critical" in b + assert a.count("adm-chatgpt-usage-row") == 3 # 5H + WEEK + additional bucket + + +def test_rendered_html_escapes_and_contains_no_credentials(): + payload = { + "available": True, + "account": {"auth_id": "auth-a", "label": ""}, + "usage": {"plan_type": "plus", "limits": [{"limit_id": "codex", "limit_name": "