Merge pull request #6 from pewdiepie-archdaemon/pr/alteixeira20/subscription-provider-ux

feat(provider): improve subscription UX and lazy discovery
This commit is contained in:
Alexandre Teixeira
2026-09-22 14:02:55 +01:00
committed by GitHub
38 changed files with 6048 additions and 345 deletions
+20
View File
@@ -203,6 +203,12 @@ class Session(TimestampMixin, Base):
# Organization
folder = Column(String, nullable=True, default=None)
cwd = Column(String, nullable=True, default=None)
# Registered ModelEndpoint this session is bound to. endpoint_url alone
# cannot distinguish two endpoints that share a provider URL but use
# different credentials (e.g. two ChatGPT Subscription accounts), so the
# exact endpoint id is remembered here. NULL = legacy session; the first
# deterministic, owner-scoped resolution persists a binding.
endpoint_id = Column(String, nullable=True, index=True)
# Headers stored as JSON
headers = Column(JSON, default=dict)
@@ -1472,6 +1478,19 @@ def _migrate_add_session_cwd_column():
except Exception:
pass
def _migrate_add_session_endpoint_id_column():
"""Add the nullable binding and index without rewriting existing sessions."""
with engine.begin() as connection:
schema = inspect(connection)
if not schema.has_table("sessions"):
return
columns = {column["name"] for column in schema.get_columns("sessions")}
if "endpoint_id" not in columns:
connection.execute(text("ALTER TABLE sessions ADD COLUMN endpoint_id VARCHAR"))
index = next(index for index in Session.__table__.indexes if index.name == "ix_sessions_endpoint_id")
index.create(bind=connection, checkfirst=True)
def _migrate_add_token_columns():
"""Add cumulative token tracking columns to sessions table."""
import sqlite3
@@ -2378,6 +2397,7 @@ def init_db():
_migrate_add_session_generation_settings_columns()
_migrate_add_folder_column()
_migrate_add_session_cwd_column()
_migrate_add_session_endpoint_id_column()
_migrate_add_token_columns()
_migrate_add_total_cost_usd()
_migrate_add_mode_column()
+4
View File
@@ -115,6 +115,10 @@ class Session:
temperature_override: Optional[float] = None
max_tokens_override: Optional[int] = None
cwd: Optional[str] = None
# Registered ModelEndpoint id this session is bound to (None = legacy /
# URL-matched). Lets two endpoints that share a provider URL but not
# credentials stay distinguishable.
endpoint_id: Optional[str] = None
def __post_init__(self):
if self.headers is None:
+9 -1
View File
@@ -157,6 +157,7 @@ class SessionManager:
temperature_override=getattr(db_session, "temperature_override", None),
max_tokens_override=getattr(db_session, "max_tokens_override", None),
cwd=getattr(db_session, "cwd", None) or None,
endpoint_id=getattr(db_session, "endpoint_id", None) or None,
)
session.message_count = getattr(db_session, "message_count", 0) or 0
return session
@@ -222,6 +223,7 @@ class SessionManager:
temperature_override=getattr(db_session, "temperature_override", None),
max_tokens_override=getattr(db_session, "max_tokens_override", None),
cwd=getattr(db_session, "cwd", None) or None,
endpoint_id=getattr(db_session, "endpoint_id", None) or None,
)
# The rows just loaded are the whole transcript, so they — not the
@@ -493,6 +495,7 @@ class SessionManager:
headers = {}
session.name = db_session.name
session.endpoint_url = db_session.endpoint_url or ""
session.endpoint_id = getattr(db_session, "endpoint_id", None) or None
session.model = db_session.model or ""
session.headers = headers or {}
session.rag = db_session.rag
@@ -563,9 +566,12 @@ class SessionManager:
owner: str = None,
cwd: str = None,
headers: Optional[Dict[str, str]] = None,
endpoint_id: Optional[str] = None,
) -> Session:
"""Create a new session and save to database."""
session_headers = dict(headers or {})
from src.chatgpt_subscription import is_chatgpt_subscription_base
session_headers = {} if is_chatgpt_subscription_base(endpoint_url) else dict(headers or {})
endpoint_id = (endpoint_id or "").strip() or None
db = SessionLocal()
try:
db_session = DbSession(
@@ -577,6 +583,7 @@ class SessionManager:
headers=session_headers,
owner=owner,
cwd=cwd or None,
endpoint_id=endpoint_id,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
@@ -592,6 +599,7 @@ class SessionManager:
headers=session_headers,
owner=owner,
cwd=cwd or None,
endpoint_id=endpoint_id,
)
self.sessions[session_id] = session
+84 -34
View File
@@ -794,6 +794,52 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
return False
def _endpoint_created_sort_key(ep) -> tuple:
created = getattr(ep, "created_at", None)
try:
ts = float(created.timestamp()) if created else 0.0
except Exception:
ts = 0.0
return (ts, str(getattr(ep, "id", "") or ""))
def _select_session_endpoint(sess, target_url: str, endpoints) -> tuple:
"""Pick the endpoint a session should use for credential resolution.
Two endpoints may share one provider URL but not credentials (e.g. two
ChatGPT Subscription accounts), so an explicit ``sess.endpoint_id`` binding
wins whenever it still matches the session URL. Without a binding the
oldest URL-matching endpoint is chosen deterministically and persisted.
Returns ``(endpoint, bound_by_fallback)``; ``bound_by_fallback`` is True
when the choice came from URL matching and may be persisted as a binding.
"""
matching = [ep for ep in endpoints if _session_url_matches_endpoint(target_url, getattr(ep, "base_url", "") or "")]
if not matching:
return None, False
bound_id = getattr(sess, "endpoint_id", None) or None
if bound_id:
for ep in matching:
if str(ep.id) == str(bound_id):
sess.endpoint_id = ep.id
return ep, False
# The bound endpoint is gone or disabled. Never silently borrow another
# endpoint's credentials when several routes share this URL.
return None, False
matching.sort(key=_endpoint_created_sort_key)
chosen = matching[0]
if len(matching) > 1:
logger.warning(
"Session %s has no endpoint binding and %d endpoints share its URL; using oldest endpoint %s",
getattr(sess, "id", "?"), len(matching), chosen.id,
)
try:
sess.endpoint_id = chosen.id
except Exception:
pass
return chosen, True
def _has_auth_keys(headers) -> bool:
"""True if a headers dict carries an Authorization/x-api-key entry."""
return isinstance(headers, dict) and any(
@@ -803,6 +849,7 @@ def _has_auth_keys(headers) -> bool:
def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
owner = owner or getattr(sess, "owner", None)
try:
from src.chatgpt_subscription import is_chatgpt_subscription_base
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "")
@@ -811,11 +858,22 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
has_auth = _has_auth_keys(sess.headers)
if has_auth and not is_chatgpt_subscription:
return
if is_chatgpt_subscription:
# Never reuse a stale bearer after deletion, disablement or failed refresh.
sess.headers = {}
try:
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
db = SessionLocal()
try:
stored_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
stored_q = stored_q.filter(DBSession.owner == owner)
if is_chatgpt_subscription:
stored = stored_q.first()
if stored is not None and _has_auth_keys(stored.headers):
stored_q.update({"headers": {}})
db.commit()
target_url = getattr(sess, "endpoint_url", "") or ""
if not target_url:
return
@@ -826,44 +884,36 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
# with similar endpoint URLs can borrow each other's API key.
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
for ep in q.all():
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
continue
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception as e:
logger.warning("Failed to resolve provider auth for session %s: %s", session_id, e)
return
if not api_key:
# No usable key (e.g. ChatGPT Subscription needs re-auth).
return
sess.headers = build_headers(api_key, base)
if is_chatgpt_subscription:
# The bearer is short-lived and re-resolved per request, so it
# stays request-local and is never written to the plaintext
# sessions.headers column. Proactively strip any bearer an
# older code path may have persisted so it does not linger.
stale_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
stale_q = stale_q.filter(DBSession.owner == owner)
stored = stale_q.first()
if stored is not None and _has_auth_keys(stored.headers):
stale_q.update({"headers": {}})
db.commit()
logger.info(f"Cleared persisted ChatGPT Subscription bearer from session {session_id}")
logger.debug(f"Resolved request-local ChatGPT Subscription auth for session {session_id}")
return
update_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
update_q = update_q.filter(DBSession.owner == owner)
update_q.update({"headers": sess.headers})
db.commit()
logger.info(f"Resolved and persisted auth headers for session {session_id} from endpoint {ep.name}")
ep, bound_here = _select_session_endpoint(sess, target_url, q.all())
if ep is None:
return
if bound_here:
# Bind before authentication, including failed/expired credentials.
stored_q.filter(DBSession.endpoint_id == None).update({"endpoint_id": ep.id})
db.commit()
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception as e:
logger.warning("Failed to resolve provider auth for session %s: %s", session_id, type(e).__name__)
return
if not api_key:
# No usable key (e.g. ChatGPT Subscription needs re-auth).
return
sess.headers = build_headers(api_key, base)
if is_chatgpt_subscription:
# Request-local only; persistence was cleaned before resolution.
return
update_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
update_q = update_q.filter(DBSession.owner == owner)
update_q.update({"headers": sess.headers})
db.commit()
logger.info(f"Resolved and persisted auth headers for session {session_id} from endpoint {ep.name}")
return
finally:
db.close()
except Exception as e:
logger.warning(f"Failed to resolve session headers: {e}")
logger.warning("Failed to resolve session headers: %s", type(e).__name__)
def _match_cached_model_id(requested: str, models) -> Optional[str]:
+80 -8
View File
@@ -1701,9 +1701,21 @@ def _clear_orphaned_session_endpoint(sess, owner: str | None = None) -> bool:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
bound_id = getattr(sess, "endpoint_id", None)
for ep in endpoints:
if bound_id and ep.id != bound_id:
continue
if _session_url_matches_endpoint(sess.endpoint_url or "", ep.base_url or ""):
return False
if bound_id:
# Keep the identity so re-enabling/reconnecting A can recover A.
# Returning True stops chat; B must never replace a missing A.
sess.headers = {}
stored = db.query(DBSession).filter(DBSession.id == sess.id, DBSession.owner == owner).first()
if stored is not None:
stored.headers = {}
db.commit()
return True
db_session = db.query(DBSession).filter(DBSession.id == sess.id).first()
if db_session:
db_session.endpoint_url = ""
@@ -1803,6 +1815,13 @@ def _first_image_attachment(chat_handler, att_ids: List[str], owner: str | None
return None
def _ts_or_zero(value) -> float:
try:
return float(value.timestamp()) if value else 0.0
except Exception:
return 0.0
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
"""Re-populate sess.model from the matching endpoint's cached models.
@@ -1835,10 +1854,19 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
for cand in endpoints:
if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
ep = cand
break
# Honour the session's exact endpoint binding first: two endpoints
# can share a provider URL (e.g. two ChatGPT Subscription accounts).
bound_id = getattr(sess, "endpoint_id", None) or None
if bound_id:
for cand in endpoints:
if cand.id == bound_id and _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
ep = cand
break
if ep is None and not bound_id:
for cand in sorted(endpoints, key=lambda row: (_ts_or_zero(getattr(row, "created_at", None)), str(row.id))):
if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
ep = cand
break
if not ep:
return False
if not is_chatgpt_subscription:
@@ -1938,6 +1966,7 @@ def _reconcile_selected_route_from_request(
endpoint_url = ""
headers = None
resolved_endpoint_id = None
if selected_endpoint_id or selected_endpoint_url:
try:
from src.auth_helpers import owner_filter
@@ -1949,7 +1978,15 @@ def _reconcile_selected_route_from_request(
q = q.filter(ModelEndpoint.id == selected_endpoint_id)
if owner:
q = owner_filter(q, ModelEndpoint, owner)
candidates = q.all() if selected_endpoint_url and not selected_endpoint_id else [q.first()]
if selected_endpoint_url and not selected_endpoint_id:
candidates = [row for row in q.all() if _session_url_matches_endpoint(selected_endpoint_url, row.base_url or "")]
bound_id = getattr(sess, "endpoint_id", None)
if bound_id:
candidates = [row for row in candidates if row.id == bound_id]
if len(candidates) != 1:
return False
else:
candidates = [q.first()]
ep = None
for cand in candidates:
if not cand:
@@ -1961,6 +1998,7 @@ def _reconcile_selected_route_from_request(
return False
endpoint_url = build_chat_url(normalize_base(ep.base_url or ""))
headers = build_headers(ep.api_key or "", ep.base_url or "") if ep.api_key else {}
resolved_endpoint_id = ep.id
finally:
db.close()
except Exception as e:
@@ -1975,18 +2013,29 @@ def _reconcile_selected_route_from_request(
and endpoint_url == (getattr(sess, "endpoint_url", "") or "")
)
headers_changed = dict(getattr(sess, "headers", None) or {}) != dict(headers or {})
if not route_changed and not headers_changed:
binding_changed = bool(
resolved_endpoint_id
and resolved_endpoint_id != (getattr(sess, "endpoint_id", None) or None)
)
if not route_changed and not headers_changed and not binding_changed:
return False
sess.model = selected_model
sess.endpoint_url = endpoint_url
sess.headers = headers or {}
if resolved_endpoint_id:
sess.endpoint_id = resolved_endpoint_id
elif route_changed:
# The route moved without an explicit endpoint id: drop a stale binding
# rather than keep pointing at an endpoint the session no longer uses.
sess.endpoint_id = None
db = SessionLocal()
try:
db_session = db.query(DBSession).filter(DBSession.id == session_id).first()
if db_session:
db_session.model = selected_model
db_session.endpoint_url = endpoint_url
db_session.endpoint_id = getattr(sess, "endpoint_id", None) or None
db_session.headers = sess.headers or {}
db_session.updated_at = datetime.utcnow()
db.commit()
@@ -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:
+334 -47
View File
@@ -1,28 +1,123 @@
"""ChatGPT Subscription device-flow setup routes."""
"""ChatGPT Subscription device-flow setup, multi-account and usage routes.
One Odysseus owner may connect several independent ChatGPT subscriptions. Each
connection is its own ``ProviderAuthSession`` + ``ModelEndpoint`` pair; the
endpoint id decides which account a request is billed to. Labels are cosmetic
only — stable ids drive lookup, reconnect, deletion and usage reads.
"""
import json
import logging
import uuid
from typing import Dict, Optional
from typing import Any, Dict, Optional
from fastapi import HTTPException, Request
from fastapi import HTTPException, Request, Response
from core.database import ModelEndpoint, ProviderAuthSession, SessionLocal, utcnow_naive
from core.middleware import require_admin
from routes.device_flow import (
DeviceFlowPoll,
DeviceFlowStart,
PendingDeviceFlowStore,
create_device_flow_router,
)
from src.auth_helpers import get_current_user
from src.auth_helpers import effective_user, get_current_user
from src import chatgpt_subscription
logger = logging.getLogger(__name__)
_DEVICE_FLOW_STORE = PendingDeviceFlowStore()
_PROVIDER = chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER
def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
def _owner_scope(query, model_cls, owner: Optional[str]):
return query.filter(model_cls.owner == owner)
def _owner_chatgpt_auths(db, owner: Optional[str]):
q = db.query(ProviderAuthSession).filter(ProviderAuthSession.provider == _PROVIDER)
return _owner_scope(q, ProviderAuthSession, owner).order_by(ProviderAuthSession.created_at).all()
def _endpoints_for_auth(db, auth_id: str):
return db.query(ModelEndpoint).filter(ModelEndpoint.provider_auth_id == auth_id).all()
def _display_label(auth, ep) -> str:
"""Display label from persisted metadata (never from credentials)."""
label = chatgpt_subscription.account_label_from_name(getattr(auth, "label", None))
if not label and ep is not None:
label = chatgpt_subscription.account_label_from_name(getattr(ep, "name", None))
return label
def _assert_label_available(db, owner: Optional[str], label: str, *, exclude_auth_id: Optional[str] = None) -> None:
"""Reject a label already used by another ChatGPT account of this owner."""
if not label:
return
for auth in _owner_chatgpt_auths(db, owner):
if exclude_auth_id and auth.id == exclude_auth_id:
continue
existing = _display_label(auth, None)
if not existing:
for ep in _endpoints_for_auth(db, auth.id):
existing = _display_label(auth, ep)
if existing:
break
if chatgpt_subscription.labels_conflict(existing, label):
raise ValueError(f"A ChatGPT subscription labelled '{label}' is already connected.")
def _default_new_label(db, owner: Optional[str]) -> str:
"""Label for a new connection when the user did not supply one.
The first account keeps the legacy unlabelled name so existing single
account setups look unchanged; later accounts get a distinguishable
``account N`` label that is unique for this owner.
"""
existing = _owner_chatgpt_auths(db, owner)
if not existing:
return ""
taken = set()
for auth in existing:
label = _display_label(auth, None)
if not label:
for ep in _endpoints_for_auth(db, auth.id):
label = _display_label(auth, ep)
if label:
break
if label:
taken.add(label.casefold())
n = len(existing) + 1
while f"account {n}".casefold() in taken:
n += 1
return f"account {n}"
def _new_id(db, model_cls) -> str:
for _ in range(8):
candidate = str(uuid.uuid4())[:8]
if db.query(model_cls).filter(model_cls.id == candidate).first() is None:
return candidate
return uuid.uuid4().hex[:12]
def _provision_endpoint(
tokens: Dict,
owner: Optional[str],
*,
label: str = "",
reconnect_auth_id: Optional[str] = None,
reconnect_endpoint_id: Optional[str] = None,
) -> Dict:
"""Create a new ChatGPT account (auth + endpoint) or refresh exactly one.
Without ``reconnect_auth_id`` a brand-new ``ProviderAuthSession`` and
``ModelEndpoint`` are created even when the owner already has other ChatGPT
subscriptions. With it, only that owner-scoped auth row (and its endpoint)
is updated; every other account is left untouched.
"""
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
if not access_token or not refresh_token:
@@ -32,22 +127,28 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
models = chatgpt_subscription.fetch_available_models(access_token)
if not models:
raise ValueError("ChatGPT Subscription connected, but no usable Codex models were discovered for this account.")
label = chatgpt_subscription.normalize_account_label(label)
db = SessionLocal()
try:
auth = (
db.query(ProviderAuthSession)
.filter(
ProviderAuthSession.provider == chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
ProviderAuthSession.owner == owner,
)
.first()
)
if auth is None:
auth = None
if reconnect_auth_id:
auth = chatgpt_subscription.find_owned_auth_session(db, reconnect_auth_id, owner)
if auth is None:
raise chatgpt_subscription.ChatGPTSubscriptionAuthNotFound(
"The ChatGPT subscription being reconnected no longer exists for this user."
)
# A reconnect keeps the existing label unless a new one was given.
if label:
_assert_label_available(db, owner, label, exclude_auth_id=auth.id)
else:
if not label:
label = _default_new_label(db, owner)
_assert_label_available(db, owner, label)
auth = ProviderAuthSession(
id=str(uuid.uuid4())[:8],
provider=chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
id=_new_id(db, ProviderAuthSession),
provider=_PROVIDER,
owner=owner,
label="ChatGPT Subscription",
label=chatgpt_subscription.endpoint_name_for_label(label),
base_url=base,
auth_mode="chatgpt",
)
@@ -57,31 +158,39 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
auth.refresh_token = refresh_token
auth.last_refresh = utcnow_naive()
auth.auth_mode = "chatgpt"
if label:
auth.label = chatgpt_subscription.endpoint_name_for_label(label)
ep = (
db.query(ModelEndpoint)
.filter(
ModelEndpoint.base_url == base,
ModelEndpoint.provider_auth_id == auth.id,
ModelEndpoint.owner == owner,
)
.first()
)
ep = None
if reconnect_auth_id:
ep_q = db.query(ModelEndpoint).filter(ModelEndpoint.provider_auth_id == auth.id)
ep_q = _owner_scope(ep_q, ModelEndpoint, owner)
if reconnect_endpoint_id:
ep = ep_q.filter(ModelEndpoint.id == reconnect_endpoint_id).first()
if ep is None:
raise chatgpt_subscription.ChatGPTSubscriptionAuthNotFound(
"The ChatGPT subscription endpoint no longer exists for this user."
)
else:
ep = ep_q.order_by(ModelEndpoint.created_at).first()
if ep is None:
ep = ModelEndpoint(
id=str(uuid.uuid4())[:8],
name="ChatGPT Subscription",
id=_new_id(db, ModelEndpoint),
name=chatgpt_subscription.endpoint_name_for_label(label),
base_url=base,
model_type="llm",
endpoint_kind="api",
owner=owner,
)
db.add(ep)
ep.name = "ChatGPT Subscription"
if label or not (ep.name or "").strip():
ep.name = chatgpt_subscription.endpoint_name_for_label(label)
ep.base_url = base
ep.api_key = None
ep.provider_auth_id = auth.id
ep.is_enabled = True
# ChatGPT provides inference only. Odysseus is the only agent: no
# provider-native tool schemas are ever sent on this route.
ep.supports_tools = False
ep.model_type = "llm"
ep.endpoint_kind = "api"
@@ -93,10 +202,14 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
"name": ep.name,
"base_url": ep.base_url,
"models": models,
"provider_auth_id": auth.id,
"account_label": _display_label(auth, ep),
"reconnected": bool(reconnect_auth_id),
}
finally:
db.close()
chatgpt_subscription.USAGE_CACHE.invalidate(result["provider_auth_id"])
try:
from routes.model_routes import _invalidate_models_cache
@@ -106,7 +219,53 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
return result
def _start_device_flow(request: Request, _form) -> DeviceFlowStart:
def _form_value(form, key: str) -> str:
try:
value = form.get(key) if form is not None else None
except Exception:
value = None
return str(value).strip() if value is not None else ""
def _start_device_flow(request: Request, form) -> DeviceFlowStart:
owner = effective_user(request) or None
try:
label = chatgpt_subscription.normalize_account_label(_form_value(form, "label"))
except ValueError as exc:
raise HTTPException(400, str(exc))
reconnect_auth_id = _form_value(form, "reconnect_auth_id") or None
reconnect_endpoint_id = _form_value(form, "reconnect_endpoint_id") or None
if reconnect_endpoint_id and not reconnect_auth_id:
raise HTTPException(400, "Reconnect requires an account id")
# Validate the intended operation up front so the user is not sent through
# OAuth for a request that can never be provisioned.
db = SessionLocal()
try:
if reconnect_auth_id:
auth = chatgpt_subscription.find_owned_auth_session(db, reconnect_auth_id, owner)
if auth is None:
raise HTTPException(404, "ChatGPT subscription account not found")
if reconnect_endpoint_id:
ep_q = db.query(ModelEndpoint).filter(
ModelEndpoint.id == reconnect_endpoint_id,
ModelEndpoint.provider_auth_id == auth.id,
)
if _owner_scope(ep_q, ModelEndpoint, owner).first() is None:
raise HTTPException(404, "ChatGPT subscription endpoint not found")
if label:
try:
_assert_label_available(db, owner, label, exclude_auth_id=auth.id)
except ValueError as exc:
raise HTTPException(409, str(exc))
else:
try:
_assert_label_available(db, owner, label)
except ValueError as exc:
raise HTTPException(409, str(exc))
finally:
db.close()
try:
data = chatgpt_subscription.request_device_code()
except Exception as exc:
@@ -116,37 +275,82 @@ def _start_device_flow(request: Request, _form) -> DeviceFlowStart:
user_code = data.get("user_code")
if not device_auth_id or not user_code:
raise HTTPException(502, "ChatGPT did not return a complete device code")
verification_uri = data.get("verification_uri") or f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device"
# Never pass an arbitrary upstream URL into an authorization link.
from urllib.parse import urlsplit
fallback_uri = f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device"
verification_uri = data.get("verification_uri") or fallback_uri
try:
parsed = urlsplit(verification_uri)
if parsed.scheme != "https" or parsed.netloc != "auth.openai.com":
verification_uri = fallback_uri
except (TypeError, ValueError):
verification_uri = fallback_uri
# The pending payload carries only what provisioning needs: the device
# handle, the owner and the intended operation. No access/refresh tokens.
pending: Dict[str, Any] = {
"device_auth_id": device_auth_id,
"user_code": user_code,
"owner": owner,
"label": label,
"reconnect_auth_id": reconnect_auth_id,
"reconnect_endpoint_id": reconnect_endpoint_id,
}
response: Dict[str, Any] = {
"user_code": user_code,
"verification_uri": verification_uri,
"mode": "reconnect" if reconnect_auth_id else "connect",
}
if label:
response["account_label"] = label
return DeviceFlowStart(
pending={
"device_auth_id": device_auth_id,
"user_code": user_code,
"owner": get_current_user(request) or None,
},
response={
"user_code": user_code,
"verification_uri": verification_uri,
},
pending=pending,
response=response,
interval=int(data.get("interval") or 5),
expires_in=int(data.get("expires_in") or 900),
)
def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
def _poll_device_flow(request: Request, pending: Dict) -> DeviceFlowPoll:
# The poller must be the same user who started the flow: a poll id is not
# a bearer for provisioning into someone else's account list.
current_owner = effective_user(request) or None
if (pending.get("owner") or None) != current_owner:
raise HTTPException(403, "This sign-in belongs to another user")
if pending.get("reconnect_auth_id"):
db = SessionLocal()
try:
auth = chatgpt_subscription.find_owned_auth_session(db, pending["reconnect_auth_id"], current_owner)
if auth is None:
raise HTTPException(404, "ChatGPT subscription account not found")
if pending.get("reconnect_endpoint_id"):
ep = _owner_scope(db.query(ModelEndpoint).filter(
ModelEndpoint.id == pending["reconnect_endpoint_id"],
ModelEndpoint.provider_auth_id == auth.id,
), ModelEndpoint, current_owner).first()
if ep is None:
raise HTTPException(404, "ChatGPT subscription endpoint not found")
finally:
db.close()
try:
data = chatgpt_subscription.poll_device_auth(pending["device_auth_id"], pending["user_code"])
except Exception as exc:
logger.debug("ChatGPT device poll failed: %s", exc)
return DeviceFlowPoll.pending(str(exc))
logger.debug("ChatGPT device poll failed: %s", type(exc).__name__)
return DeviceFlowPoll.pending("Sign-in status temporarily unavailable")
authorization_code = data.get("authorization_code")
code_verifier = data.get("code_verifier")
if authorization_code and code_verifier:
try:
tokens = chatgpt_subscription.exchange_authorization_code(authorization_code, code_verifier)
result = _provision_endpoint(tokens, pending["owner"])
result = _provision_endpoint(
tokens,
pending.get("owner"),
label=pending.get("label") or "",
reconnect_auth_id=pending.get("reconnect_auth_id") or None,
reconnect_endpoint_id=pending.get("reconnect_endpoint_id") or None,
)
except Exception as exc:
logger.exception("ChatGPT Subscription endpoint provisioning failed")
logger.warning("ChatGPT Subscription endpoint provisioning failed: %s", type(exc).__name__)
raise chatgpt_subscription.to_http_exception(exc)
return DeviceFlowPoll.authorized(result)
@@ -157,14 +361,97 @@ def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None)
if err in ("expired_token", "access_denied", "denied"):
return DeviceFlowPoll.failed(err)
return DeviceFlowPoll.pending(err or "unknown")
return DeviceFlowPoll.pending("unknown")
# ── Account listing / usage ─────────────────────────────────────────────────
def _account_summary(auth, endpoints) -> Dict[str, Any]:
ep = endpoints[0] if endpoints else None
return {
"auth_id": auth.id,
"label": _display_label(auth, ep),
"name": (ep.name if ep is not None else None) or auth.label or chatgpt_subscription.CHATGPT_SUBSCRIPTION_LEGACY_NAME,
"endpoint_ids": [row.id for row in endpoints],
"connected_at": auth.created_at.isoformat() if getattr(auth, "created_at", None) else None,
"last_refresh": auth.last_refresh.isoformat() if getattr(auth, "last_refresh", None) else None,
"connected": bool(auth.refresh_token),
}
def usage_error_payload(exc: chatgpt_subscription.ChatGPTUsageUnavailable) -> Dict[str, Any]:
"""Safe, credential-free payload for a failed usage read."""
return {
"available": False,
"reason": exc.reason,
"message": str(exc),
"status_code": exc.status_code,
"reconnect_suggested": exc.reason == "reauth",
}
def _load_owned_account(request: Request, auth_id: str):
"""Admin gate + owner scope for account-level operations."""
require_admin(request)
owner = effective_user(request) or get_current_user(request) or None
db = SessionLocal()
try:
auth = chatgpt_subscription.find_owned_auth_session(db, auth_id, owner)
if auth is None:
raise HTTPException(404, "ChatGPT subscription account not found")
endpoints = _endpoints_for_auth(db, auth.id)
return owner, _account_summary(auth, endpoints)
finally:
db.close()
def setup_chatgpt_subscription_routes():
return create_device_flow_router(
router = create_device_flow_router(
prefix="/api/chatgpt-subscription",
tags=["chatgpt-subscription"],
store=_DEVICE_FLOW_STORE,
start_flow=_start_device_flow,
poll_flow=_poll_device_flow,
)
@router.get("/accounts")
def list_accounts(request: Request):
require_admin(request)
owner = effective_user(request) or get_current_user(request) or None
db = SessionLocal()
try:
return [
_account_summary(auth, _endpoints_for_auth(db, auth.id))
for auth in _owner_chatgpt_auths(db, owner)
]
finally:
db.close()
@router.get("/accounts/{auth_id}/usage")
def account_usage(auth_id: str, request: Request, refresh: bool = False, response: Response = None):
"""Read-only, owner-scoped usage for exactly one ChatGPT account.
Failures here are telemetry failures only: the model endpoint is never
disabled, credentials are never destroyed and no reconnect is started.
"""
if response is not None:
response.headers["Cache-Control"] = "no-store"
owner, account = _load_owned_account(request, auth_id)
try:
usage = chatgpt_subscription.get_account_usage(account["auth_id"], owner=owner, force_refresh=refresh)
except chatgpt_subscription.ChatGPTSubscriptionAuthNotFound:
raise HTTPException(404, "ChatGPT subscription account not found")
except chatgpt_subscription.ChatGPTUsageUnavailable as exc:
payload = usage_error_payload(exc)
payload["account"] = account
return payload
except Exception as exc: # pragma: no cover - defensive
logger.warning("ChatGPT usage read failed for auth %s: %s", auth_id, type(exc).__name__)
payload = usage_error_payload(
chatgpt_subscription.ChatGPTUsageUnavailable("upstream", "ChatGPT usage is unavailable.")
)
payload["account"] = account
return payload
return {"available": True, "account": account, "usage": usage}
return router
+55 -13
View File
@@ -315,6 +315,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
"history": history_dict,
"model": db_session.model,
"endpoint_url": db_session.endpoint_url,
"endpoint_id": getattr(db_session, "endpoint_id", None),
"name": db_session.name,
"offset": page_offset,
"limit": page_limit,
@@ -378,6 +379,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
"history": history_dict,
"model": session.model,
"endpoint_url": session.endpoint_url,
"endpoint_id": getattr(session, "endpoint_id", None),
"name": session.name,
}
@@ -802,6 +804,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
model=source.model,
rag=False,
owner=getattr(source, 'owner', None),
endpoint_id=getattr(source, 'endpoint_id', None),
)
# Copy messages up to keep_count
@@ -820,8 +823,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
except Exception:
logger.debug("session_created event dispatch failed", exc_info=True)
from src.model_profiles import supports_user_thinking_toggle
thinking_supported = supports_user_thinking_toggle(session.model)
return {
"status": "ok",
"id": new_id,
@@ -882,6 +883,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
"session_id": session_id,
"model": session.model,
"endpoint_url": session.endpoint_url,
"endpoint_id": getattr(session, "endpoint_id", None),
"used_tokens": used,
"context_length": ctx_len,
"context_percent": pct,
@@ -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()
+294 -33
View File
@@ -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
+23 -3
View File
@@ -455,13 +455,17 @@ def setup_session_routes(
else (s.created_at.isoformat() if s.created_at else None))
)
matches = endpoint_routes.get((s.endpoint_url or '').rstrip('/'), [])
selected_endpoint = matches[0] if len(matches) == 1 else None
bound_id = getattr(s, "endpoint_id", None)
selected_endpoint = (
next((ep for ep in matches if ep.id == bound_id), None)
if bound_id else (matches[0] if len(matches) == 1 else None)
)
sessions.append({
"id": s.id,
"name": s.name,
"model": _public_model(s.name, s.model),
"endpoint_url": s.endpoint_url,
"endpoint_id": selected_endpoint.id if selected_endpoint else None,
"endpoint_id": bound_id or (selected_endpoint.id if selected_endpoint else None),
"endpoint_name": selected_endpoint.name if selected_endpoint else None,
"rag": s.rag,
"archived": s.archived,
@@ -590,6 +594,7 @@ def setup_session_routes(
rag=str(rag).lower() == "true" if rag else False,
owner=user,
cwd=cwd or None,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
)
# Set auth headers for custom API-key endpoints
resolved_key = request_api_key
@@ -597,7 +602,8 @@ def setup_session_routes(
if not resolved_key and endpoint_api_key:
resolved_key = endpoint_api_key
resolved_base = endpoint_base_url
if resolved_key:
from src.chatgpt_subscription import is_chatgpt_subscription_base
if resolved_key and not is_chatgpt_subscription_base(endpoint_url):
from src.endpoint_resolver import build_headers
session.headers = build_headers(resolved_key, resolved_base)
_persist_session_headers(sid, session.headers)
@@ -685,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:
+6
View File
@@ -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,
+512 -11
View File
@@ -9,10 +9,13 @@ from __future__ import annotations
import base64
import json
import math
import os
import re
import threading
import time
from typing import Any, Dict, Optional
import unicodedata
from typing import Any, Dict, List, Optional
import httpx
from fastapi import HTTPException
@@ -22,6 +25,16 @@ DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL = (
or "https://chatgpt.com/backend-api/codex"
)
CHATGPT_SUBSCRIPTION_PROVIDER = "chatgpt-subscription"
# Legacy single-account endpoint/auth name. Rows provisioned before
# multi-account support keep this name and stay functional.
CHATGPT_SUBSCRIPTION_LEGACY_NAME = "ChatGPT Subscription"
CHATGPT_ACCOUNT_LABEL_MAX_LENGTH = 40
# Read-only account usage (rate-limit windows) on the authenticated ChatGPT
# backend. Mirrors openai/codex ``backend-client`` ``PathStyle::ChatGptApi``:
# ``{base}/wham/usage`` where base is ``https://chatgpt.com/backend-api``.
CHATGPT_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
CHATGPT_USAGE_TIMEOUT_SECONDS = 8.0
CHATGPT_USAGE_CACHE_TTL_SECONDS = 45.0
CHATGPT_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CHATGPT_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
CHATGPT_OAUTH_ISSUER = "https://auth.openai.com"
@@ -87,6 +100,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]:
+10 -1
View File
@@ -191,7 +191,16 @@ def build_foreground_route_descriptors(
owner=owner,
)
if selected is None:
selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner)
if selected_endpoint_id:
# An explicit identity must never be relabelled as another account
# with the same URL/model (or even the same current bearer).
selected = {
"endpoint_id": None,
"endpoint_label": "Selected route",
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
}
else:
selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner)
primary = (endpoint_url, model, headers or {})
candidates = [primary]
descriptors = [selected]
+86 -14
View File
@@ -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)
+1
View File
@@ -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
+1 -1
View File
@@ -37,7 +37,7 @@ import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=2
import tasksModule from './js/tasks.js?v=20260914taskmodel1';
import calendarModule from './js/calendar.js?v=20260914emailsource11';
import notesModule from './js/notes.js?v=20260911notesselectioncancel1';
import adminModule from './js/admin.js?v=20260914toolschemaprofiles1';
import adminModule from './js/admin.js?v=20260921chatgptusage2';
import settingsModule from './js/settings.js?v=20260912writingstyle3';
// Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js';
+12 -3
View File
@@ -246,8 +246,8 @@
real request is discarded and the font fetched a second time. -->
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/style.css?v=20260914taskbutton1">
<link rel="modulepreload" href="/static/app.js?v=20260916autoscroll1">
<link rel="stylesheet" href="/static/style.css?v=20260921chatgptusage2">
<link rel="modulepreload" href="/static/app.js?v=20260921chatgptusage2">
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
<link rel="modulepreload" href="/static/js/sessions.js">
@@ -1195,6 +1195,14 @@
</button>
</div>
<div class="chat-input-right">
<!-- Reasoning effort dropdown for models supporting reasoning levels -->
<div class="reasoning-effort-wrap" id="reasoning-effort-wrap" style="display:none;">
<button type="button" class="reasoning-effort-btn" id="reasoning-effort-btn" title="Reasoning effort" aria-haspopup="listbox" aria-expanded="false">
<span class="reasoning-effort-prefix">Effort: </span><span class="reasoning-effort-current" id="reasoning-effort-current">Default</span>
<svg class="reasoning-effort-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="reasoning-effort-menu hidden" id="reasoning-effort-menu" role="listbox"></div>
</div>
<!-- Agent / Chat mode toggle -->
<div class="mode-toggle">
<button type="button" class="mode-toggle-btn active" id="mode-agent-btn" aria-pressed="true">Agent</button>
@@ -2306,6 +2314,7 @@
<option value="https://api.openai.com/v1" data-logo="openai">OpenAI</option>
<option value="copilot" data-logo="github" data-auth-flow="copilot">GitHub Copilot</option>
<option value="chatgpt-subscription" data-logo="openai" data-auth-flow="chatgpt-subscription">ChatGPT Subscription</option>
<option value="https://api.featherless.ai/v1" data-logo="featherless">Featherless.ai</option>
<option value="https://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
<option value="https://ollama.com/api" data-logo="ollama">Ollama Cloud</option>
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
@@ -2623,7 +2632,7 @@
<script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
<script type="module" src="/static/app.js?v=20260916autoscroll1"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/app.js?v=20260921chatgptusage2"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
<script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
+657 -64
View File
@@ -6,6 +6,12 @@ import settingsModule from './settings.js?v=20260912writingstyle3';
import { providerLogo, providerLogoFromUrl } from './providers.js';
import { sortModelObjects } from './modelSort.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
import {
accountTitle as chatgptAccountTitle,
buildUsageViewModel as buildChatgptUsageViewModel,
isChatgptSubscriptionEndpoint,
renderUsageCardHtml as renderChatgptUsageCardHtml,
} from './chatgptSubscriptionUsage.js';
import { getSettings, getTools, invalidateSettings, invalidateTools } from './appConfig.js';
let initialized = false;
@@ -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')
? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>`
: '';
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 `<div class="admin-ep-detail">${parts.join('')}</div>`;
}
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 = `<div class="mcp-tools-header">
<span>Featherless Catalog</span>
</div>
<div class="featherless-panel" style="display:flex;flex-direction:column;gap:12px;padding:6px 0;">
<div class="featherless-search-bar" style="position:relative;display:flex;align-items:center;">
<input type="search" class="mcp-tools-search featherless-search-input" placeholder="Search Featherless models (min 2 chars)..." style="width:100%;box-sizing:border-box;" data-featherless-search="${esc(epId)}">
<span class="featherless-spinner-host" style="display:none;position:absolute;right:8px;font-size:10px;opacity:0.55;">Searching...</span>
</div>
<div class="featherless-enabled-section">
<div style="font-size:11px;font-weight:600;opacity:0.8;margin-bottom:4px;">
Enabled models (<span class="featherless-enabled-count">${enabledSet.size}</span>)
</div>
<div class="featherless-enabled-list mcp-tools-list" style="max-height:160px;overflow-y:auto;"></div>
</div>
<div class="featherless-results-section">
<div style="font-size:11px;font-weight:600;opacity:0.8;margin-bottom:4px;">
Search results
</div>
<div class="featherless-results-list mcp-tools-list" style="max-height:280px;overflow-y:auto;">
<span class="featherless-search-hint" style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">Type at least 2 characters to search over 20,000+ models.</span>
</div>
<div class="featherless-pagination" style="display:none;margin-top:6px;text-align:center;">
<button type="button" class="admin-btn-sm featherless-load-more" style="width:100%;">Load more</button>
</div>
</div>
</div>`;
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 = '<span style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">No models enabled. Search below to add models.</span>';
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 `<div title="${esc(id)}" data-ep-model-row data-model-id="${esc(id)}" class="adm-model-row">
<label class="adm-model-label">
<input type="checkbox" class="adm-cb-hidden" data-featherless-enabled-id="${esc(id)}" checked>
<span class="adm-check-dot" aria-hidden="true"></span>
<span class="adm-model-name">${esc(displayName)}</span>
</label>
<div class="adm-model-tools-col">
<span class="adm-model-tools-label" title="Select the tool schema profile for this model">Tools</span>
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(id)}" data-original-tool-mode="${esc(toolModes[id] || '')}">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
</div>
</div>`;
}).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 = '<span style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">No models found matching your search.</span>';
}
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 `<div title="${esc(item.id)}" data-ep-model-row data-model-id="${esc(item.id)}" class="adm-model-row">
<label class="adm-model-label" style="width:100%;">
<input type="checkbox" class="adm-cb-hidden" data-featherless-search-id="${esc(item.id)}" ${isChecked ? 'checked' : ''}>
<span class="adm-check-dot" aria-hidden="true"></span>
<span class="adm-model-name" style="flex:1;">${esc(displayName)}</span>
${ctx ? `<span class="admin-badge" style="margin-left:auto;font-size:9px;opacity:0.6;">${esc(ctx)}</span>` : ''}
</label>
</div>`;
}).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 = '<span class="featherless-search-hint" style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">Type at least 2 characters to search over 20,000+ models.</span>';
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 = `<span class="admin-error" style="font-size:11px;padding:4px 0;display:block;">Search failed: ${esc(err.message)}</span>`;
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'
? '<span class="admin-badge">no models</span>'
: ep.online
? `<span class="admin-badge">${visibleCount}/${totalCount} models enabled</span>`
? `<span class="admin-badge" data-adm-ep-models-count="${ep.id}">${countText}</span>`
: '<span class="admin-badge admin-badge-off">offline</span>';
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 `
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
@@ -661,7 +1047,7 @@ async function loadEndpoints() {
<span class="adm-check-dot adm-endpoint-select-dot" aria-hidden="true"></span>
</label>` : ''}
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
<span class="admin-user-name">${esc(ep.name)}</span>
<span class="admin-user-name">${esc(epTitle)}</span>
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
${statusBadge}
@@ -675,7 +1061,15 @@ async function loadEndpoints() {
${hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : ''}`}
</div>
</div>
<div class="admin-ep-detail">${esc(ep.base_url)}${category === 'local' ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${keyLabel}</div>
${isChatgptAccount ? `
<div class="adm-chatgpt-controls">
<button type="button" class="admin-btn-sm adm-chatgpt-usage-toggle" data-adm-chatgpt-usage-toggle="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}" aria-expanded="${isUsageExpanded ? 'true' : 'false'}" aria-controls="adm-chatgpt-usage-${esc(ep.id)}">
Usage <span class="adm-chatgpt-usage-chevron" aria-hidden="true">${isUsageExpanded ? '▴' : '▾'}</span>
</button>
<button type="button" class="admin-btn-sm" data-adm-chatgpt-reconnect="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}">Reconnect</button>
</div>
<div id="adm-chatgpt-usage-${esc(ep.id)}" class="adm-chatgpt-usage-host${isUsageExpanded ? '' : ' hidden'}" data-adm-chatgpt-usage-host="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"${isUsageExpanded ? '' : ' style="display:none;"'}><div class="adm-chatgpt-usage adm-chatgpt-usage-loading"><div class="adm-chatgpt-usage-status">Loading usage...</div></div></div>` : ''}
${endpointDetailHtml(ep, category)}
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
</div>`;
});
@@ -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 `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row" style="display:flex;align-items:center;gap:8px;">
<label style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;">
return `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
<label class="adm-model-label">
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${(usesPinnedPicker ? m.is_pinned : !m.is_hidden) ? 'checked' : ''}>
<span class="adm-check-dot" aria-hidden="true"></span>
<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(m.display)}</span>
<span class="adm-model-name">${esc(m.display)}</span>
</label>
<span title="Select the tool schema profile for this model" style="font-size:10px;opacity:0.45;flex-shrink:0;">Tools</span>
<select class="adm-model-tool-mode" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Auto uses Odysseus compact for Odysseus/Ajax names and Regular tools for every other model" style="height:24px;font-size:11px;max-width:170px;flex-shrink:0;">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
<div class="adm-model-tools-col">
<span class="adm-model-tools-label" title="Select the tool schema profile for this model">Tools</span>
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Auto uses Odysseus compact for Odysseus/Ajax names and Regular tools for every other model">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
</div>
</div>`;
}
).join('') + '</div>';
@@ -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 =
'<div class="adm-copilot-panel">' +
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
'<span>' + esc(waitLabel) + '</span></div>' +
'<div class="adm-copilot-coderow">' +
'<span class="adm-copilot-code-label">Code</span>' +
'<code class="adm-copilot-code">' + esc(start.user_code) + '</code>' +
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
'</div>' +
'<a class="admin-btn-add adm-copilot-auth" href="' + esc(authUrl || '') + '" target="_blank" rel="noopener">' + esc(authLabel) + ' ↗</a>' +
'</div>';
const copyBtn = status.querySelector('.adm-device-auth-copy');
if (copyBtn) copyBtn.addEventListener('click', async () => {
const code = start.user_code || '';
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(code);
ok = true;
}
} catch (e) {}
if (!ok) {
// navigator.clipboard is unavailable in non-secure contexts (HTTP
// self-host over a LAN IP), so fall back to execCommand('copy').
const ta = document.createElement('textarea');
ta.value = code;
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
document.body.appendChild(ta);
ta.focus();
ta.select();
try { ta.setSelectionRange(0, code.length); } catch (e) {}
try { ok = document.execCommand('copy'); } catch (e) {}
ta.remove();
}
copyBtn.textContent = ok ? 'Copied' : 'Failed';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
});
}
// ── ChatGPT Subscription per-account usage + reconnect ─────────────────────
// Each ChatGPT account (ProviderAuthSession) is addressed by its stable
// auth id; the endpoint id pins which row the action came from. Usage is
// read-only telemetry: a failed read never disables the endpoint.
const _chatgptReconnectInflight = new Set();
async function _loadChatgptUsage(container, authId, epId, { refresh = false } = {}) {
if (!container || !authId) return;
const url = '/api/chatgpt-subscription/accounts/' + encodeURIComponent(authId) + '/usage' + (refresh ? '?refresh=1' : '');
let payload = null;
try {
const res = await fetch(url, { credentials: 'same-origin' });
if (res.ok) {
try { payload = await res.json(); } catch (_) { payload = null; }
} else {
let reason = 'upstream';
if (res.status === 404) reason = 'upstream';
payload = { available: false, reason, account: { auth_id: authId } };
}
} catch (_) {
payload = { available: false, reason: 'network', account: { auth_id: authId } };
}
if (!payload || typeof payload !== 'object') payload = { available: false, reason: 'malformed', account: { auth_id: authId } };
const vm = buildChatgptUsageViewModel(payload);
vm.authId = authId;
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 = ' <a href="#" data-go-added-models style="margin-left:6px;text-decoration:underline;color:inherit;font-weight:600;">Added Models →</a>';
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 =
'<div class="adm-copilot-panel">' +
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
'<span>' + esc(waitLabel) + '</span></div>' +
'<div class="adm-copilot-coderow">' +
'<span class="adm-copilot-code-label">Code</span>' +
'<code class="adm-copilot-code">' + esc(start.user_code) + '</code>' +
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
'</div>' +
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl || '') + '" target="_blank" rel="noopener">' + esc(authLabel) + ' ↗</a>' +
'</div>';
const copyBtn = status.querySelector('.adm-device-auth-copy');
if (copyBtn) copyBtn.addEventListener('click', async () => {
const code = start.user_code || '';
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(code);
ok = true;
}
} catch (e) {}
if (!ok) {
// navigator.clipboard is unavailable in non-secure contexts (HTTP
// self-host over a LAN IP), so fall back to execCommand('copy').
const ta = document.createElement('textarea');
ta.value = code;
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
document.body.appendChild(ta);
ta.focus();
ta.select();
try { ta.setSelectionRange(0, code.length); } catch (e) {}
try { ok = document.execCommand('copy'); } catch (e) {}
ta.remove();
}
copyBtn.textContent = ok ? 'Copied' : 'Failed';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
});
_renderDeviceAuthWaitPanel(status, providerKey, start, authUrl);
},
});
if (result.status === 'authorized') {
const endpoint = result.endpoint || {};
const n = ((endpoint && endpoint.models) || []).length;
status.className = 'admin-success';
status.textContent = 'Connected - ' + n + ' ' + config.label + ' model' + (n !== 1 ? 's' : '') + ' available.';
const connectedName = endpoint && endpoint.name ? endpoint.name : config.label;
status.textContent = 'Connected ' + connectedName + ' - ' + n + ' model' + (n !== 1 ? 's' : '') + ' available.';
if (endpoint && endpoint.id) _recentlyAddedEpId = String(endpoint.id);
if (_chatgptLabelMode) urlInput.value = '';
await loadEndpoints();
await _selectAddedModelInChat(endpoint || {});
reset();
@@ -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;
+7 -1
View File
@@ -19,7 +19,7 @@ import searchModule from './search.js';
import documentModule from './document.js?v=20260916docctx2';
import * as emailInbox from './emailInbox.js?v=20260914aireply4';
import codeRunnerModule from './codeRunner.js?v=20260831richtexttools91';
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260902tuiharness1';
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260921chatgptusage1';
import createResearchSynapse from './researchSynapse.js?v=20260910roundlabels2';
import { createStreamRenderer } from './streamingRenderer.js';
import { createTurnRendering, startsContinuationRound } from './turnRendering.js?v=20260910round1stable1';
@@ -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
+250
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function formatDuration(totalSeconds) {
const secs = _num(totalSeconds);
if (secs === null) return '';
if (secs <= 0) return 'now';
const days = Math.floor(secs / 86400);
const hours = Math.floor((secs % 86400) / 3600);
const minutes = Math.floor((secs % 3600) / 60);
if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
if (minutes > 0) return `${minutes}m`;
return '<1m';
}
export function formatResetIn(resetsAt, nowSeconds) {
const at = _num(resetsAt);
if (at === null || at <= 0) return '';
const now = _num(nowSeconds);
if (now === null) return '';
const delta = at - now;
if (delta <= 0) return 'resets now';
return `resets in ${formatDuration(delta)}`;
}
export function formatPercent(value) {
const n = _clampPercent(value);
if (n === null) return '';
const rounded = Math.round(n);
return `${rounded}%`;
}
export function planDisplayName(planType) {
if (typeof planType !== 'string' || !planType.trim()) return '';
const key = planType.trim().toLowerCase();
const known = {
free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro', prolite: 'Pro Lite', team: 'Team',
business: 'Business', enterprise: 'Enterprise', edu: 'Edu', education: 'Education',
guest: 'Guest',
};
if (known[key]) return known[key];
return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function _windowViewModel(win, nowSeconds) {
if (!win || typeof win !== 'object') return null;
const used = _clampPercent(win.used_percent);
const remaining = used === null ? _clampPercent(win.remaining_percent) : Math.round((100 - used) * 100) / 100;
const windowMinutes = _num(win.window_minutes);
const resetsAt = _num(win.resets_at);
return {
kind: typeof win.kind === 'string' ? win.kind : '',
name: typeof win.name === 'string' && win.name ? win.name : 'LIMIT',
usedPercent: used,
remainingPercent: remaining,
usedLabel: used === null ? 'usage unknown' : `${formatPercent(used)} used`,
remainingLabel: remaining === null ? '' : `${formatPercent(remaining)} remaining`,
windowMinutes,
resetsAt,
resetLabel: formatResetIn(resetsAt, nowSeconds),
exhausted: used !== null && used >= 100,
};
}
/**
* Build a render-ready view model from the backend usage payload.
*
* `payload` is the JSON body of the usage route. `nowSeconds` is injected so
* reset countdowns are deterministic in tests.
*/
export function buildUsageViewModel(payload, nowSeconds = Math.floor(Date.now() / 1000)) {
const account = (payload && typeof payload.account === 'object' && payload.account) || {};
const base = {
authId: typeof account.auth_id === 'string' ? account.auth_id : '',
label: typeof account.label === 'string' ? account.label : '',
name: typeof account.name === 'string' ? account.name : '',
};
if (!payload || typeof payload !== 'object' || payload.available !== true || !payload.usage || typeof payload.usage !== 'object') {
const reason = payload && typeof payload.reason === 'string' ? payload.reason : 'unavailable';
const messages = {
reauth: 'Usage unavailable — account may need reconnecting',
rate_limited: 'Usage temporarily unavailable (rate limited)',
timeout: 'Usage unavailable (timed out)',
network: 'Usage unavailable (network)',
malformed: 'Usage unavailable (unexpected response)',
upstream: 'Usage unavailable',
};
return {
...base,
available: false,
reason,
reconnectSuggested: !!(payload && payload.reconnect_suggested),
message: messages[reason] || 'Usage unavailable',
plan: '',
limits: [],
};
}
const usage = payload.usage;
const limits = [];
const rawLimits = Array.isArray(usage.limits) ? usage.limits : [];
rawLimits.forEach((limit) => {
if (!limit || typeof limit !== 'object') return;
const windows = (Array.isArray(limit.windows) ? limit.windows : [])
.map((w) => _windowViewModel(w, nowSeconds))
.filter(Boolean);
const limitId = typeof limit.limit_id === 'string' ? limit.limit_id : '';
const limitName = typeof limit.limit_name === 'string' ? limit.limit_name : '';
limits.push({
limitId,
title: limitId === 'codex' ? '' : (limitName || limitId || 'Additional limit'),
modelSlug: typeof limit.normal_model_slug === 'string' ? limit.normal_model_slug : '',
limitReached: limit.limit_reached === true,
windows,
});
});
return {
...base,
available: true,
reason: '',
reconnectSuggested: false,
message: '',
plan: planDisplayName(usage.plan_type),
ordinaryUsageAllowed: typeof usage.ordinary_usage_allowed === 'boolean' ? usage.ordinary_usage_allowed : null,
rateLimitReachedType: typeof usage.rate_limit_reached_type === 'string' ? usage.rate_limit_reached_type : '',
cached: payload.usage.cached === true,
fetchedAt: _num(usage.fetched_at),
limits,
hasWindows: limits.some((l) => l.windows.length > 0),
};
}
function _barHtml(win, esc) {
const used = win.usedPercent === null ? 0 : win.usedPercent;
const tone = win.usedPercent === null ? 'unknown' : (used >= 90 ? 'critical' : (used >= 70 ? 'warn' : 'ok'));
return (
`<div class="adm-chatgpt-usage-row" data-usage-window="${esc(win.kind || win.name)}">` +
`<div class="adm-chatgpt-usage-head">` +
`<span class="adm-chatgpt-usage-name">${esc(win.name)}</span>` +
`<span class="adm-chatgpt-usage-remaining">${esc(win.remainingLabel || win.usedLabel)}</span>` +
`</div>` +
`<div class="adm-chatgpt-usage-bar adm-chatgpt-usage-${tone}" role="progressbar" aria-valuemin="0" aria-valuemax="100"` +
(win.usedPercent === null ? '' : ` aria-valuenow="${Math.round(used)}"`) +
` aria-label="${esc(win.name)} ${esc(win.usedLabel)}">` +
`<span class="adm-chatgpt-usage-fill" style="width:${Math.max(0, Math.min(100, used)).toFixed(0)}%"></span>` +
`</div>` +
`<div class="adm-chatgpt-usage-meta">` +
`<span>${esc(win.usedLabel)}</span>` +
(win.resetLabel ? `<span>${esc(win.resetLabel)}</span>` : '') +
`</div>` +
`</div>`
);
}
/**
* Render the compact usage card body for one account. `esc` is the host
* page's HTML escaper (defaults to a local one). Buttons carry data
* attributes with the exact auth/endpoint ids so handlers target only this
* account.
*/
export function renderUsageCardHtml(viewModel, options = {}) {
const esc = typeof options.esc === 'function' ? options.esc : _defaultEsc;
const vm = viewModel || {};
const authId = esc(vm.authId || '');
const endpointId = esc(options.endpointId || '');
const includeReconnect = options.includeReconnect !== false;
const buttons =
`<div class="adm-chatgpt-usage-actions">` +
`<button type="button" class="admin-btn-sm" data-adm-chatgpt-usage-refresh="${authId}" data-chatgpt-endpoint-id="${endpointId}">Refresh usage</button>` +
(includeReconnect ? `<button type="button" class="admin-btn-sm" data-adm-chatgpt-reconnect="${authId}" data-chatgpt-endpoint-id="${endpointId}">Reconnect</button>` : '') +
`</div>`;
if (!vm.available) {
return (
`<div class="adm-chatgpt-usage adm-chatgpt-usage-unavailable" data-adm-chatgpt-usage="${authId}">` +
`<div class="adm-chatgpt-usage-status">${esc(vm.message || 'Usage unavailable')}</div>` +
buttons +
`</div>`
);
}
const plan = vm.plan ? `<span class="admin-badge adm-chatgpt-plan">${esc(vm.plan)}</span>` : '';
const limitsHtml = (vm.limits || []).map((limit) => {
const title = limit.title
? `<div class="adm-chatgpt-usage-limit-title">${esc(limit.title)}${limit.modelSlug ? ` <span class="adm-chatgpt-usage-model">${esc(limit.modelSlug)}</span>` : ''}</div>`
: '';
return `<div class="adm-chatgpt-usage-limit" data-usage-limit="${esc(limit.limitId)}">${title}${limit.windows.length ? limit.windows.map((w) => _barHtml(w, esc)).join('') : '<div class="adm-chatgpt-usage-status">No rate-limit windows reported</div>'}</div>`;
}).join('');
const blocked = vm.ordinaryUsageAllowed === false
? `<div class="adm-chatgpt-usage-status adm-chatgpt-usage-blocked">Usage currently blocked${vm.rateLimitReachedType ? ` (${esc(vm.rateLimitReachedType.replace(/_/g, ' '))})` : ''}</div>`
: '';
return (
`<div class="adm-chatgpt-usage" data-adm-chatgpt-usage="${authId}">` +
(plan ? `<div class="adm-chatgpt-usage-plan">${plan}</div>` : '') +
blocked +
limitsHtml +
buttons +
`</div>`
);
}
/** True when an endpoint row from /api/model-endpoints is a ChatGPT account. */
export function isChatgptSubscriptionEndpoint(ep) {
return !!(ep && ep.provider === 'chatgpt-subscription' && ep.provider_auth_id);
}
/** Owner-visible account title, e.g. "ChatGPT · codex00". */
export function accountTitle(ep) {
if (!ep) return '';
if (ep.account_label) return `ChatGPT · ${ep.account_label}`;
return ep.name || 'ChatGPT Subscription';
}
export default {
buildUsageViewModel,
renderUsageCardHtml,
formatResetIn,
formatDuration,
formatPercent,
planDisplayName,
isChatgptSubscriptionEndpoint,
accountTitle,
};
+276 -81
View File
@@ -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 =
`<svg class="mp-provider-chevron${isCollapsed ? ' collapsed' : ''}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`;
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 =
`<svg class="mp-provider-chevron${isCollapsed ? ' collapsed' : ''}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`
+ `<span class="mp-provider-name">${_providerGroupName(provider)}</span>`
+ `<span class="mp-provider-count">${models.length}</span>`;
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);
}
}
+4
View File
@@ -87,6 +87,9 @@ const _PROVIDERS = [
// NVIDIA / Nemotron (official Simple Icons)
[/nvidia|nemotron/i,
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"/></svg>'],
// Featherless AI (feather icon)
[/featherless/i,
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z"/><line x1="16" y1="8" x2="2" y2="22"/><line x1="17.5" y1="15" x2="9" y2="15"/></svg>'],
];
// 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"],
+1 -1
View File
@@ -2,7 +2,7 @@
// Lightweight popup that surfaces the existing /command registry as users
// type. Reads COMMANDS from slashCommands.js — no command logic lives here.
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js?v=20260902tuiharness1';
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js?v=20260921chatgptusage1';
const POPUP_ID = 'slash-autocomplete';
const MAX_VISIBLE = 14;
+11 -4
View File
@@ -5035,16 +5035,22 @@ function _clearSetupCommandInput() {
}
}
async function _setupProviderDeviceFlow(providerKey) {
async function _setupProviderDeviceFlow(providerKey, options = {}) {
_clearSetupGuideMessages();
const config = PROVIDER_DEVICE_FLOWS[providerKey];
if (!config) {
await _setupReply('Provider not recognised.');
return;
}
await _setupReply(`Starting ${config.label} sign-in...`);
// `/setup chatgpt-subscription codex00` labels the new account so several
// ChatGPT subscriptions stay distinguishable. The label is cosmetic only.
const accountLabel = providerKey === 'chatgpt-subscription' ? String(options.label || '').trim().slice(0, 40) : '';
const formData = new FormData();
if (accountLabel) formData.append('label', accountLabel);
await _setupReply(`Starting ${config.label} sign-in${accountLabel ? ` for "${accountLabel}"` : ''}...`);
try {
const result = await runProviderDeviceFlow(providerKey, {
formData,
onStart: async ({ start, authUrl }) => {
const place = providerKey === 'copilot' ? 'GitHub' : 'OpenAI';
const action = providerKey === 'copilot' ? 'approve the request' : 'enter the code';
@@ -5067,7 +5073,8 @@ async function _setupProviderDeviceFlow(providerKey) {
});
if (result.status === 'authorized') {
const n = ((result.endpoint && result.endpoint.models) || []).length;
await _setupReply(`Connected - ${n} ${config.label} model${n !== 1 ? 's' : ''} available.`);
const connectedName = (result.endpoint && result.endpoint.name) || config.label;
await _setupReply(`Connected ${connectedName} - ${n} model${n !== 1 ? 's' : ''} available.`);
if (modelsModule) modelsModule.refreshModels(true);
return;
}
@@ -5091,7 +5098,7 @@ async function _cmdSetup(args, ctx) {
const topicArgs = args.slice(1);
const deviceAuthProvider = _setupDeviceAuthProviderFromInput(topic);
if (deviceAuthProvider) {
await _setupProviderDeviceFlow(deviceAuthProvider);
await _setupProviderDeviceFlow(deviceAuthProvider, { label: topicArgs.join(' ') });
return true;
}
const provider = _setupProviderFromInput(topic);
+1 -1
View File
@@ -8,7 +8,7 @@
// Mobile is excluded — tours position halos by rect math that doesn't fit
// the bottom-sheet layout cleanly.
import { handleSlashCommand } from './slashCommands.js?v=20260902tuiharness1';
import { handleSlashCommand } from './slashCommands.js?v=20260921chatgptusage1';
// Modal id → slash command to fire (without the leading "/"). Add to this
// map when a new feature picks up a `tour-*` command.
+229
View File
@@ -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;
+6 -5
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v649-large-tool-synthesis-scroll';
const CACHE_NAME = 'odysseus-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',
+11 -4
View File
@@ -58,14 +58,21 @@ def test_device_auth_selection_disables_and_dims_api_test_button():
def test_device_auth_keeps_manual_auth_button_without_auto_opening_tab():
# The waiting panel (code + manual Authorize link) is shared between the
# Add Models flow and per-account ChatGPT Reconnect.
panel_block = _between(_ADMIN, "function _renderDeviceAuthWaitPanel", "// ── ChatGPT Subscription per-account usage")
auth_block = _between(_ADMIN, "async function _startProviderDeviceAuth", "// Local \"Add\" button")
reconnect_block = _between(_ADMIN, "async function _reconnectChatgptAccount", "function initEndpointForm()")
assert "Authorize with OpenAI" in auth_block
assert "Authorize on GitHub" in auth_block
assert "adm-copilot-panel" in auth_block
assert "adm-device-auth-copy" in auth_block
assert "Authorize with OpenAI" in panel_block
assert "Authorize on GitHub" in panel_block
assert "adm-copilot-panel" in panel_block
assert "adm-device-auth-copy" in panel_block
assert "_renderDeviceAuthWaitPanel(status, providerKey, start, authUrl)" in auth_block
assert "openWindow: () => {}" in auth_block
assert "A new tab opened" not in auth_block
assert "openWindow: () => {}" in reconnect_block
assert "_renderDeviceAuthWaitPanel(status, 'chatgpt-subscription', start, authUrl)" in reconnect_block
def test_loud_oauth_copy_and_removed_button_hooks_do_not_return():
@@ -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: </span>' 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
+162
View File
@@ -0,0 +1,162 @@
"""Persistent account routing must survive reloads and fail closed on deletion."""
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, text, inspect
from sqlalchemy.orm import sessionmaker
import core.database as cdb
import core.session_manager as sm
import routes.chat_helpers as helpers
import routes.chat_routes as chat
import routes.session_routes as sessions
from src import endpoint_resolver
BASE = "https://chatgpt.com/backend-api/codex"
@pytest.fixture
def state(monkeypatch):
engine = create_engine("sqlite:///:memory:")
cdb.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
for module in (sm, helpers, chat, sessions, endpoint_resolver):
monkeypatch.setattr(module, "SessionLocal", factory)
with factory() as db:
for key, owner in (("a", "alice"), ("b", "alice"), ("c", "bob")):
db.add(cdb.ModelEndpoint(id=key, owner=owner, name=key, base_url=BASE,
provider_auth_id="auth-" + key, is_enabled=True,
cached_models='["gpt-5.5"]', supports_tools=False))
db.commit()
manager = sm.SessionManager.__new__(sm.SessionManager)
manager.sessions = {}
manager.upload_handler = None
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime",
lambda ep, owner=None: (BASE, "secret-" + ep.id))
yield factory, manager
engine.dispose()
def _session(manager, binding="b"):
return manager.create_session("s", "Chat", BASE + "/responses", "gpt-5.5",
owner="alice", endpoint_id=binding)
def test_binding_survives_metadata_full_reload_and_sync(state):
factory, manager = state
_session(manager)
with factory() as db:
db.add(cdb.ChatMessage(id="message", session_id="s", role="user", content="Hello"))
db.commit()
row = db.get(cdb.Session, "s")
assert manager._db_to_session_meta(row).endpoint_id == "b"
assert manager._db_to_session(row, db).endpoint_id == "b"
row.endpoint_id = "a"
db.commit()
manager.sync_session_metadata("s")
assert manager.sessions["s"].endpoint_id == "a"
@pytest.mark.parametrize("disabled", [True, False])
def test_missing_or_disabled_bound_account_cannot_borrow_only_remaining_sibling(state, disabled):
factory, manager = state
sess = _session(manager)
with factory() as db:
endpoint = db.get(cdb.ModelEndpoint, "b")
if disabled:
endpoint.is_enabled = False
else:
db.delete(endpoint)
db.get(cdb.Session, "s").headers = {"Authorization": "Bearer stale-secret"}
db.commit()
sess.headers = {"Authorization": "Bearer stale-secret"}
helpers.resolve_session_auth(sess, "s", "alice")
assert sess.headers == {}
assert sess.endpoint_id == "b"
assert chat._clear_orphaned_session_endpoint(sess, "alice") is True
assert chat._recover_empty_session_model(sess, "s", "alice") is False
with factory() as db:
assert db.get(cdb.Session, "s").headers == {}
assert db.get(cdb.Session, "s").endpoint_id == "b"
def test_legacy_binding_is_persisted_even_when_authentication_fails(state, monkeypatch):
factory, manager = state
sess = _session(manager, None)
def unavailable(*args, **kwargs):
raise RuntimeError("credentials unavailable")
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", unavailable)
helpers.resolve_session_auth(sess, "s", "alice")
assert sess.endpoint_id == "a"
with factory() as db:
assert db.get(cdb.Session, "s").endpoint_id == "a"
def test_explicit_selection_switches_same_url_and_model_binding(state):
factory, manager = state
sess = _session(manager)
assert chat._reconcile_selected_route_from_request(None, sess, "s", {
"selected_model": "gpt-5.5", "selected_endpoint_id": "a",
}, "alice")
assert sess.endpoint_id == "a"
with factory() as db:
assert db.get(cdb.Session, "s").endpoint_id == "a"
assert not chat._reconcile_selected_route_from_request(None, sess, "s", {
"selected_model": "gpt-5.5", "selected_endpoint_id": "c",
}, "alice")
assert sess.endpoint_id == "a"
def test_url_only_model_change_retains_exact_binding(state):
factory, manager = state
sess = _session(manager)
assert chat._reconcile_selected_route_from_request(None, sess, "s", {
"selected_model": "another-model", "selected_endpoint_url": BASE,
}, "alice")
assert sess.endpoint_id == "b"
def test_session_patch_and_unrelated_rename_preserve_binding(state, monkeypatch):
factory, manager = state
sess = _session(manager)
monkeypatch.setattr(sessions, "_verify_session_owner", lambda *args: None)
router = sessions.setup_session_routes(manager, {})
patch = [r.endpoint for r in router.routes if r.path == "/api/session/{sid}" and "PATCH" in r.methods][-1]
request = SimpleNamespace(state=SimpleNamespace(current_user="alice"))
kwargs = dict(request=request, sid="s", name=None, folder=None, cwd=None)
patch(**kwargs, model="gpt-5.5", endpoint_url=BASE, endpoint_id="a")
assert sess.endpoint_id == "a"
with factory() as db:
assert db.get(cdb.Session, "s").endpoint_id == "a"
with pytest.raises(HTTPException):
patch(**kwargs, model="gpt-5.5", endpoint_url=BASE, endpoint_id="c")
patch(**kwargs, model=None, endpoint_url=None, endpoint_id=None)
assert sess.endpoint_id == "a"
def test_session_list_reports_bound_account_for_duplicate_model_and_url(state, monkeypatch):
factory, manager = state
sess = _session(manager)
manager.get_sessions_for_user = lambda *args, **kwargs: {"s": sess}
router = sessions.setup_session_routes(manager, {})
listing = [r.endpoint for r in router.routes if r.path == "/api/sessions" and "GET" in r.methods][-1]
rows = listing(SimpleNamespace(state=SimpleNamespace(current_user="alice"), query_params={}))
assert rows[0]["endpoint_id"] == "b"
assert rows[0]["endpoint_name"] == "b"
def test_migration_is_additive_idempotent_and_repairs_missing_index(tmp_path, monkeypatch):
engine = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}")
with engine.begin() as db:
db.execute(text("CREATE TABLE sessions (id VARCHAR PRIMARY KEY, model VARCHAR)"))
db.execute(text("INSERT INTO sessions VALUES ('legacy', 'gpt-5.5')"))
monkeypatch.setattr(cdb, "engine", engine)
cdb._migrate_add_session_endpoint_id_column()
cdb._migrate_add_session_endpoint_id_column()
with engine.begin() as db:
assert db.execute(text("SELECT model, endpoint_id FROM sessions")).one() == ("gpt-5.5", None)
db.execute(text("DROP INDEX ix_sessions_endpoint_id"))
cdb._migrate_add_session_endpoint_id_column()
assert [idx["name"] for idx in inspect(engine).get_indexes("sessions")] == ["ix_sessions_endpoint_id"]
engine.dispose()
@@ -0,0 +1,228 @@
"""Device-flow start/poll semantics for multiple ChatGPT subscriptions.
The pending device-flow state must carry the *intended operation* — create a
new account, or reconnect one exact existing account — with owner validation,
and must never carry access/refresh tokens.
"""
import types
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, ModelEndpoint, ProviderAuthSession
import routes.chatgpt_subscription_routes as csr
from routes.device_flow import PendingDeviceFlowStore
_BASE = "https://chatgpt.com/backend-api/codex"
def _mem_db(monkeypatch):
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
monkeypatch.setattr(csr, "SessionLocal", TestSessionLocal)
return TestSessionLocal
def _request(user):
return types.SimpleNamespace(state=types.SimpleNamespace(current_user=user), app=None, headers={})
def _seed_account(TestSessionLocal, owner, auth_id, ep_id, label="ChatGPT Subscription"):
db = TestSessionLocal()
try:
db.add(ProviderAuthSession(
id=auth_id, provider=csr.chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER, owner=owner,
label=label, base_url=_BASE, access_token="AT", refresh_token="RT", auth_mode="chatgpt",
))
db.add(ModelEndpoint(id=ep_id, name=label, base_url=_BASE, provider_auth_id=auth_id, owner=owner))
db.commit()
finally:
db.close()
def _fake_device_code(monkeypatch):
monkeypatch.setattr(
csr.chatgpt_subscription, "request_device_code",
lambda: {"device_auth_id": "dev-1", "user_code": "ABCD-EFGH", "interval": 3, "expires_in": 120},
)
def test_start_carries_label_and_connect_mode_without_tokens(monkeypatch):
_mem_db(monkeypatch)
_fake_device_code(monkeypatch)
start = csr._start_device_flow(_request("alice"), {"label": " codex00 "})
assert start.pending == {
"device_auth_id": "dev-1",
"user_code": "ABCD-EFGH",
"owner": "alice",
"label": "codex00",
"reconnect_auth_id": None,
"reconnect_endpoint_id": None,
}
assert start.response["mode"] == "connect"
assert start.response["account_label"] == "codex00"
for value in start.pending.values():
assert "access_token" not in str(value) and "refresh_token" not in str(value)
assert "access_token" not in start.pending and "refresh_token" not in start.pending
def test_start_rejects_overlong_or_duplicate_label(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_fake_device_code(monkeypatch)
with pytest.raises(HTTPException) as exc:
csr._start_device_flow(_request("alice"), {"label": "x" * 41})
assert exc.value.status_code == 400
_seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00")
with pytest.raises(HTTPException) as exc:
csr._start_device_flow(_request("alice"), {"label": "codex00"})
assert exc.value.status_code == 409
def test_start_reconnect_carries_exact_target_ids(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_fake_device_code(monkeypatch)
_seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00")
start = csr._start_device_flow(
_request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"},
)
assert start.pending["reconnect_auth_id"] == "auth-a"
assert start.pending["reconnect_endpoint_id"] == "ep-a"
assert start.pending["owner"] == "alice"
assert start.response["mode"] == "reconnect"
def test_start_reconnect_for_another_owner_is_rejected(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_fake_device_code(monkeypatch)
_seed_account(TestSessionLocal, "alice", "auth-a", "ep-a")
with pytest.raises(HTTPException) as exc:
csr._start_device_flow(_request("mallory"), {"reconnect_auth_id": "auth-a"})
assert exc.value.status_code == 404
# Mismatched endpoint id for a real auth id is also rejected.
_seed_account(TestSessionLocal, "alice", "auth-b", "ep-b")
with pytest.raises(HTTPException) as exc:
csr._start_device_flow(_request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-b"})
assert exc.value.status_code == 404
def test_poll_provisions_with_pending_operation_and_owner(monkeypatch):
factory = _mem_db(monkeypatch)
_seed_account(factory, "alice", "auth-a", "ep-a")
monkeypatch.setattr(
csr.chatgpt_subscription, "poll_device_auth",
lambda device_auth_id, user_code: {"authorization_code": "code", "code_verifier": "ver"},
)
monkeypatch.setattr(
csr.chatgpt_subscription, "exchange_authorization_code",
lambda code, verifier: {"access_token": "AT", "refresh_token": "RT"},
)
seen = {}
def fake_provision(tokens, owner, **kwargs):
seen["tokens"] = tokens
seen["owner"] = owner
seen["kwargs"] = kwargs
return {"id": "ep-new", "name": "ChatGPT · codex00", "models": ["gpt-5.5"]}
monkeypatch.setattr(csr, "_provision_endpoint", fake_provision)
pending = {
"device_auth_id": "dev-1", "user_code": "X", "owner": "alice", "label": "codex00",
"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a",
}
outcome = csr._poll_device_flow(_request("alice"), pending)
assert outcome.status == "authorized"
assert seen["owner"] == "alice"
assert seen["kwargs"] == {"label": "codex00", "reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"}
def test_poll_by_a_different_user_is_rejected(monkeypatch):
_mem_db(monkeypatch)
called = {"n": 0}
def _never(*a, **k):
called["n"] += 1
return {}
monkeypatch.setattr(csr.chatgpt_subscription, "poll_device_auth", _never)
pending = {"device_auth_id": "dev-1", "user_code": "X", "owner": "alice", "label": "", "reconnect_auth_id": None, "reconnect_endpoint_id": None}
with pytest.raises(HTTPException) as exc:
csr._poll_device_flow(_request("mallory"), pending)
assert exc.value.status_code == 403
assert called["n"] == 0
def test_expired_pending_flow_is_dropped_by_store():
clock = {"t": 1000.0}
store = PendingDeviceFlowStore(time_func=lambda: clock["t"])
poll_id = store.add({"owner": "alice", "reconnect_auth_id": "auth-a"}, interval=5, expires_in=60)
assert store.get_payload(poll_id)["reconnect_auth_id"] == "auth-a"
clock["t"] += 61
assert store.get_payload(poll_id) is None
clock["t"] += 3600
assert store.get_payload(poll_id) is None
def test_poll_revalidates_reconnect_endpoint_after_start(monkeypatch):
factory = _mem_db(monkeypatch)
_fake_device_code(monkeypatch)
_seed_account(factory, "alice", "auth-a", "ep-a")
start = csr._start_device_flow(_request("alice"), {"reconnect_auth_id": "auth-a", "reconnect_endpoint_id": "ep-a"})
with factory() as db:
db.delete(db.get(ModelEndpoint, "ep-a"))
db.commit()
monkeypatch.setattr(csr.chatgpt_subscription, "poll_device_auth", lambda *args: pytest.fail("must reject before polling"))
with pytest.raises(HTTPException) as exc:
csr._poll_device_flow(_request("alice"), start.pending)
assert exc.value.status_code == 404
def test_pending_payload_ignores_actual_secrets_and_allows_harmless_label(monkeypatch):
_mem_db(monkeypatch)
monkeypatch.setattr(csr.chatgpt_subscription, "request_device_code", lambda: {
"device_auth_id": "dev", "user_code": "code", "access_token": "SECRET-AT", "refresh_token": "SECRET-RT",
})
start = csr._start_device_flow(_request("alice"), {"label": "refresh_token"})
assert start.pending["label"] == "refresh_token"
for payload in (start.pending, start.response):
assert "access_token" not in payload and "refresh_token" not in payload
assert "SECRET-AT" not in str(payload) and "SECRET-RT" not in str(payload)
def test_account_listing_and_usage_route_are_owner_scoped(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(csr, "require_admin", lambda request: None)
_seed_account(TestSessionLocal, "alice", "auth-a", "ep-a", label="ChatGPT · codex00")
_seed_account(TestSessionLocal, "bob", "auth-b", "ep-b", label="ChatGPT · work")
router = csr.setup_chatgpt_subscription_routes()
handlers = {(r.path, tuple(sorted(r.methods))): r.endpoint for r in router.routes}
list_accounts = handlers[("/api/chatgpt-subscription/accounts", ("GET",))]
usage = handlers[("/api/chatgpt-subscription/accounts/{auth_id}/usage", ("GET",))]
alice_accounts = list_accounts(_request("alice"))
assert [a["auth_id"] for a in alice_accounts] == ["auth-a"]
assert alice_accounts[0]["label"] == "codex00"
assert alice_accounts[0]["endpoint_ids"] == ["ep-a"]
assert "access_token" not in str(alice_accounts) and "refresh_token" not in str(alice_accounts)
assert "AT" not in str(alice_accounts)
calls = []
def fake_usage(auth_id, owner=None, force_refresh=False, cache=None):
calls.append((auth_id, owner, force_refresh))
return {"plan_type": "plus", "limits": [], "auth_id": auth_id}
monkeypatch.setattr(csr.chatgpt_subscription, "get_account_usage", fake_usage)
ok = usage("auth-a", _request("alice"), refresh=True)
assert ok["available"] is True
assert ok["usage"]["auth_id"] == "auth-a"
assert calls == [("auth-a", "alice", True)]
# Bob's account is invisible to Alice; the usage helper is never invoked.
with pytest.raises(HTTPException) as exc:
usage("auth-b", _request("alice"))
assert exc.value.status_code == 404
assert len(calls) == 1
+254 -14
View File
@@ -1,6 +1,7 @@
"""DB-backed ChatGPT Subscription endpoint provisioning tests."""
import json
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine
@@ -53,23 +54,163 @@ def test_provision_creates_owner_scoped_auth_session_and_endpoint(monkeypatch):
db.close()
def test_provision_refreshes_existing_auth_session_and_endpoint(monkeypatch):
def _connect(monkeypatch, owner, label="", models=("gpt-5.5",), tokens=None, **kwargs):
monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: list(models))
tokens = tokens or {"access_token": f"AT-{label or 'default'}", "refresh_token": f"RT-{label or 'default'}"}
return csr._provision_endpoint(tokens, owner, label=label, **kwargs)
def test_second_connection_creates_independent_auth_and_endpoint(monkeypatch):
"""Connecting a second subscription must never overwrite the first."""
TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: ["gpt-5.5"])
first = csr._provision_endpoint({"access_token": "OLD", "refresh_token": "OLD-RT"}, "bob")
second = csr._provision_endpoint({"access_token": "NEW", "refresh_token": "NEW-RT"}, "bob")
first = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"})
second = _connect(monkeypatch, "bob", label="codex01", tokens={"access_token": "B-AT", "refresh_token": "B-RT"})
assert first["id"] == second["id"]
assert first["id"] != second["id"]
assert first["provider_auth_id"] != second["provider_auth_id"]
assert first["name"] == "ChatGPT · codex00"
assert second["name"] == "ChatGPT · codex01"
assert second["reconnected"] is False
db = TestSessionLocal()
try:
auth_rows = db.query(ProviderAuthSession).filter(ProviderAuthSession.owner == "bob").all()
ep_rows = db.query(ModelEndpoint).filter(ModelEndpoint.owner == "bob").all()
assert len(auth_rows) == 1
assert len(ep_rows) == 1
assert auth_rows[0].access_token == "NEW"
assert auth_rows[0].refresh_token == "NEW-RT"
assert ep_rows[0].provider_auth_id == auth_rows[0].id
auth_rows = {a.id: a for a in db.query(ProviderAuthSession).filter(ProviderAuthSession.owner == "bob").all()}
ep_rows = {e.id: e for e in db.query(ModelEndpoint).filter(ModelEndpoint.owner == "bob").all()}
assert len(auth_rows) == 2
assert len(ep_rows) == 2
# Account A's credentials are untouched by connecting B.
assert auth_rows[first["provider_auth_id"]].access_token == "A-AT"
assert auth_rows[first["provider_auth_id"]].refresh_token == "A-RT"
assert auth_rows[second["provider_auth_id"]].access_token == "B-AT"
assert ep_rows[first["id"]].provider_auth_id == first["provider_auth_id"]
assert ep_rows[second["id"]].provider_auth_id == second["provider_auth_id"]
# Same base URL + same model on both routes is valid and intentional.
assert ep_rows[first["id"]].base_url == ep_rows[second["id"]].base_url
assert json.loads(ep_rows[first["id"]].cached_models) == json.loads(ep_rows[second["id"]].cached_models)
for ep in ep_rows.values():
assert ep.supports_tools is False
finally:
db.close()
def test_unlabelled_connections_get_distinct_default_names(monkeypatch):
_mem_db(monkeypatch)
first = _connect(monkeypatch, "bob")
second = _connect(monkeypatch, "bob")
third = _connect(monkeypatch, "bob")
# The first account keeps the legacy name; later ones are distinguishable.
assert first["name"] == "ChatGPT Subscription"
assert second["name"] == "ChatGPT · account 2"
assert third["name"] == "ChatGPT · account 3"
assert len({first["provider_auth_id"], second["provider_auth_id"], third["provider_auth_id"]}) == 3
def test_duplicate_label_for_same_owner_is_rejected_but_other_owner_ok(monkeypatch):
_mem_db(monkeypatch)
_connect(monkeypatch, "bob", label="codex00")
with pytest.raises(ValueError, match="already connected"):
_connect(monkeypatch, "bob", label="Codex00")
# Labels are owner scoped: another user may reuse the same label.
other = _connect(monkeypatch, "carol", label="codex00")
assert other["name"] == "ChatGPT · codex00"
def test_label_is_trimmed_bounded_and_control_chars_stripped(monkeypatch):
_mem_db(monkeypatch)
res = _connect(monkeypatch, "bob", label=" co\x00dex 00\t ")
assert res["account_label"] == "codex 00"
assert res["name"] == "ChatGPT · codex 00"
with pytest.raises(ValueError, match="at most 40"):
_connect(monkeypatch, "bob", label="x" * 41)
def test_reconnect_updates_only_the_targeted_account(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
a = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"})
b = _connect(monkeypatch, "bob", label="codex01", tokens={"access_token": "B-AT", "refresh_token": "B-RT"})
res = _connect(
monkeypatch, "bob", tokens={"access_token": "A-AT2", "refresh_token": "A-RT2"},
reconnect_auth_id=a["provider_auth_id"], reconnect_endpoint_id=a["id"],
)
assert res["reconnected"] is True
assert res["id"] == a["id"]
assert res["provider_auth_id"] == a["provider_auth_id"]
assert res["name"] == "ChatGPT · codex00" # label preserved on reconnect
db = TestSessionLocal()
try:
assert db.query(ProviderAuthSession).count() == 2
assert db.query(ModelEndpoint).count() == 2
auth_a = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first()
auth_b = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == b["provider_auth_id"]).first()
assert auth_a.access_token == "A-AT2" and auth_a.refresh_token == "A-RT2"
assert auth_b.access_token == "B-AT" and auth_b.refresh_token == "B-RT"
finally:
db.close()
# And the symmetric case: reconnecting B leaves A alone.
_connect(
monkeypatch, "bob", tokens={"access_token": "B-AT2", "refresh_token": "B-RT2"},
reconnect_auth_id=b["provider_auth_id"], reconnect_endpoint_id=b["id"],
)
db = TestSessionLocal()
try:
auth_a = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first()
auth_b = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == b["provider_auth_id"]).first()
assert auth_a.access_token == "A-AT2"
assert auth_b.access_token == "B-AT2"
finally:
db.close()
def test_reconnect_target_owned_by_another_user_is_rejected(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
a = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"})
with pytest.raises(csr.chatgpt_subscription.ChatGPTSubscriptionAuthNotFound):
_connect(
monkeypatch, "mallory", tokens={"access_token": "M-AT", "refresh_token": "M-RT"},
reconnect_auth_id=a["provider_auth_id"], reconnect_endpoint_id=a["id"],
)
db = TestSessionLocal()
try:
auth_a = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first()
assert auth_a.access_token == "A-AT"
assert db.query(ProviderAuthSession).count() == 1
assert db.query(ModelEndpoint).count() == 1
finally:
db.close()
def test_legacy_single_account_endpoint_can_be_reconnected_in_place(monkeypatch):
"""Rows provisioned before multi-account support keep working unchanged."""
TestSessionLocal = _mem_db(monkeypatch)
db = TestSessionLocal()
try:
db.add(ProviderAuthSession(
id="legacyauth", provider=csr.chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
owner="alice", label="ChatGPT Subscription", base_url="https://chatgpt.com/backend-api/codex",
access_token="OLD", refresh_token="OLD-RT", auth_mode="chatgpt",
))
db.add(ModelEndpoint(
id="legacyep", name="ChatGPT Subscription", base_url="https://chatgpt.com/backend-api/codex",
provider_auth_id="legacyauth", owner="alice", supports_tools=False,
))
db.commit()
finally:
db.close()
res = _connect(
monkeypatch, "alice", tokens={"access_token": "NEW", "refresh_token": "NEW-RT"},
reconnect_auth_id="legacyauth",
)
assert res["id"] == "legacyep"
assert res["name"] == "ChatGPT Subscription"
assert res["account_label"] == ""
db = TestSessionLocal()
try:
assert db.query(ProviderAuthSession).count() == 1
assert db.query(ProviderAuthSession).first().access_token == "NEW"
assert db.query(ModelEndpoint).count() == 1
finally:
db.close()
@@ -221,7 +362,7 @@ def test_delete_endpoint_route_revokes_orphaned_provider_auth(monkeypatch):
db.close()
delete_endpoint = _delete_route(monkeypatch, TestSessionLocal)
result = delete_endpoint("ep1", object())
result = delete_endpoint("ep1", SimpleNamespace(state=SimpleNamespace(current_user="alice")))
assert result["deleted"] is True
# The last (only) endpoint backed by auth1 is gone, so the route revokes it.
@@ -243,7 +384,7 @@ def test_delete_endpoint_route_keeps_auth_when_shared(monkeypatch):
db.close()
delete_endpoint = _delete_route(monkeypatch, TestSessionLocal)
result = delete_endpoint("ep1", object())
result = delete_endpoint("ep1", SimpleNamespace(state=SimpleNamespace(current_user="alice")))
assert result["deleted"] is True
# ep2 still references auth1, so deleting ep1 must NOT revoke the credentials.
@@ -278,3 +419,102 @@ def test_delete_orphaned_provider_auth_revokes_only_after_last_of_several(monkey
assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth1").first() is None
finally:
db.close()
def test_delete_account_a_preserves_account_b(monkeypatch):
"""Deleting one subscription clears only its own orphaned auth row."""
TestSessionLocal = _mem_db(monkeypatch)
a = _connect(monkeypatch, "bob", label="codex00", tokens={"access_token": "A-AT", "refresh_token": "A-RT"})
b = _connect(monkeypatch, "bob", label="codex01", tokens={"access_token": "B-AT", "refresh_token": "B-RT"})
delete_endpoint = _delete_route(monkeypatch, TestSessionLocal)
result = delete_endpoint(a["id"], SimpleNamespace(state=SimpleNamespace(current_user="bob")))
assert result["deleted"] is True
assert result["cleared_provider_auth"] is True
db = TestSessionLocal()
try:
assert db.query(ModelEndpoint).filter(ModelEndpoint.id == a["id"]).first() is None
assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == a["provider_auth_id"]).first() is None
ep_b = db.query(ModelEndpoint).filter(ModelEndpoint.id == b["id"]).first()
auth_b = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == b["provider_auth_id"]).first()
assert ep_b is not None and ep_b.is_enabled is True
assert auth_b is not None and auth_b.access_token == "B-AT" and auth_b.refresh_token == "B-RT"
finally:
db.close()
def test_manual_model_refresh_uses_the_endpoints_own_auth_session(monkeypatch):
"""Refreshing A's models resolves A's bearer and never touches B."""
import routes.model_routes as mr
import src.endpoint_resolver as endpoint_resolver
TestSessionLocal = _mem_db(monkeypatch)
a = _connect(monkeypatch, "bob", label="codex00", models=("gpt-5.5",))
b = _connect(monkeypatch, "bob", label="codex01", models=("gpt-5.5",))
monkeypatch.setattr(mr, "SessionLocal", TestSessionLocal)
monkeypatch.setattr(mr, "require_admin", lambda request: None)
resolved = []
def fake_runtime(ep, owner=None):
resolved.append((ep.id, ep.provider_auth_id, owner))
return (ep.base_url, f"bearer-{ep.provider_auth_id}")
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", fake_runtime)
probed = []
def fake_probe(base, api_key=None, timeout=5):
probed.append(api_key)
return ["gpt-5.5", "gpt-5.5-codex"] if api_key == f"bearer-{a['provider_auth_id']}" else []
monkeypatch.setattr(mr, "_probe_endpoint", fake_probe)
router = mr.setup_model_routes(model_discovery=None)
list_models = next(
r.endpoint for r in router.routes
if getattr(r, "path", "") == "/api/model-endpoints/{ep_id}/models" and "GET" in getattr(r, "methods", set())
)
class _Resp:
headers = {}
rows = list_models(a["id"], SimpleNamespace(state=SimpleNamespace(current_user="bob")), _Resp(), refresh=True)
assert resolved == [(a["id"], a["provider_auth_id"], "bob")]
assert probed == [f"bearer-{a['provider_auth_id']}"]
assert {r["id"] for r in rows} == {"gpt-5.5", "gpt-5.5-codex"}
db = TestSessionLocal()
try:
ep_a = db.query(ModelEndpoint).filter(ModelEndpoint.id == a["id"]).first()
ep_b = db.query(ModelEndpoint).filter(ModelEndpoint.id == b["id"]).first()
assert json.loads(ep_a.cached_models) == ["gpt-5.5", "gpt-5.5-codex"]
assert json.loads(ep_b.cached_models) == ["gpt-5.5"] # untouched
finally:
db.close()
def test_endpoint_listing_exposes_account_metadata_without_credentials(monkeypatch):
import routes.model_routes as mr
TestSessionLocal = _mem_db(monkeypatch)
a = _connect(monkeypatch, "bob", label="codex00")
_connect(monkeypatch, "bob", label="codex01")
monkeypatch.setattr(mr, "SessionLocal", TestSessionLocal)
monkeypatch.setattr(mr, "require_admin", lambda request: None)
router = mr.setup_model_routes(model_discovery=None)
list_endpoints = next(
r.endpoint for r in router.routes
if getattr(r, "path", "") == "/api/model-endpoints" and "GET" in getattr(r, "methods", set())
)
rows = list_endpoints(SimpleNamespace(state=SimpleNamespace(current_user="bob")))
by_id = {r["id"]: r for r in rows}
assert by_id[a["id"]]["provider"] == "chatgpt-subscription"
assert by_id[a["id"]]["provider_auth_id"] == a["provider_auth_id"]
assert by_id[a["id"]]["account_label"] == "codex00"
assert by_id[a["id"]]["supports_tools"] is False
assert by_id[a["id"]]["has_key"] is False
labels = sorted(r["account_label"] for r in rows)
assert labels == ["codex00", "codex01"]
dumped = json.dumps(rows)
assert "AT-" not in dumped and "RT-" not in dumped and "refresh_token" not in dumped
+356
View File
@@ -0,0 +1,356 @@
"""Read-only ChatGPT Subscription usage: contract, normalization, cache, errors.
Contract mirrored from openai/codex ``codex-rs/backend-client`` (ChatGptApi
path style): ``GET {backend-api}/wham/usage`` returning ``plan_type``,
``rate_limit{primary_window,secondary_window}``, ``additional_rate_limits[]``,
``rate_limit_reached_type`` and ``account_id``.
"""
import json
import httpx
import pytest
from src import chatgpt_subscription as cs
@pytest.fixture
def owned_accounts(monkeypatch):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import core.database as cdb
engine = create_engine("sqlite:///:memory:")
cdb.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
monkeypatch.setattr(cs, "_database_handles", lambda: (cdb.ProviderAuthSession, factory, cdb.utcnow_naive))
with factory() as db:
for auth_id in ("auth-a", "auth-b"):
db.add(cdb.ProviderAuthSession(id=auth_id, provider=cs.CHATGPT_SUBSCRIPTION_PROVIDER, owner="alice", base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL))
db.commit()
yield factory
engine.dispose()
def _window(used, seconds, reset_at):
return {"used_percent": used, "limit_window_seconds": seconds, "reset_after_seconds": 10, "reset_at": reset_at}
_FULL_PAYLOAD = {
"plan_type": "plus",
"account_id": "acct_123",
"user_id": "user_1",
"rate_limit": {
"allowed": True,
"limit_reached": False,
"primary_window": _window(71, 300 * 60, 1_800_000_000),
"secondary_window": _window(28, 7 * 24 * 3600, 1_800_400_000),
},
"additional_rate_limits": [
{
"limit_name": "GPT-5.5 Pro",
"metered_feature": "codex_pro",
"normal_model_slug": "gpt-5.5-pro",
"rate_limit": {"allowed": True, "limit_reached": False, "primary_window": _window(5, 3600, 1_800_001_000)},
},
{"limit_name": "future", "metered_feature": "codex_future", "rate_limit": None},
"garbage",
],
"credits": {"has_credits": True, "unlimited": False, "balance": "9.99"},
"spend_control": {"reached": False},
"rate_limit_reached_type": None,
"rate_limit_upsell": {"title": "upgrade"},
"some_new_field": {"nested": [1, 2, 3]},
}
def test_usage_url_matches_codex_chatgpt_backend_contract():
assert cs.CHATGPT_USAGE_URL == "https://chatgpt.com/backend-api/wham/usage"
def test_normalize_primary_secondary_and_additional_buckets():
out = cs.normalize_usage_payload(json.loads(json.dumps(_FULL_PAYLOAD)))
assert out["plan_type"] == "plus"
assert out["account_id"] == "acct_123"
assert out["ordinary_usage_allowed"] is True
assert out["rate_limit_reached_type"] is None
codex, pro, future = out["limits"]
assert codex["limit_id"] == "codex" and codex["limit_name"] is None
primary, secondary = codex["windows"]
assert primary == {
"kind": "primary", "name": "5H", "used_percent": 71.0, "remaining_percent": 29.0,
"window_minutes": 300, "resets_at": 1_800_000_000, "reset_after_seconds": 10,
}
assert secondary["kind"] == "secondary"
assert secondary["name"] == "WEEK"
assert secondary["window_minutes"] == 10080
assert secondary["used_percent"] == 28.0 and secondary["remaining_percent"] == 72.0
assert pro["limit_id"] == "codex_pro"
assert pro["limit_name"] == "GPT-5.5 Pro"
assert pro["normal_model_slug"] == "gpt-5.5-pro"
assert pro["windows"][0]["name"] == "1H"
assert pro["windows"][0]["used_percent"] == 5.0
# Unknown/empty additional bucket is kept (not dropped) but has no windows.
assert future["limit_id"] == "codex_future" and future["windows"] == []
# Unknown top-level fields and raw upstream metadata never leak through.
for forbidden in ("user_id", "credits", "spend_control", "rate_limit_upsell", "some_new_field"):
assert forbidden not in out
def test_normalize_tolerates_absent_windows_and_unknown_reached_type():
out = cs.normalize_usage_payload({"plan_type": "pro", "rate_limit": None, "rate_limit_reached_type": {"type": "workspace_owner_credits_depleted"}})
assert out["plan_type"] == "pro"
assert out["ordinary_usage_allowed"] is None
assert out["rate_limit_reached_type"] == "workspace_owner_credits_depleted"
assert out["limits"] == [{
"limit_id": "codex", "limit_name": None, "normal_model_slug": None,
"allowed": None, "limit_reached": None, "windows": [],
}]
# Missing reset must stay absent — never invented.
out2 = cs.normalize_usage_payload({"plan_type": "free", "rate_limit": {"primary_window": {"used_percent": "12.5", "limit_window_seconds": 0}}})
win = out2["limits"][0]["windows"][0]
assert win["resets_at"] is None and win["window_minutes"] is None and win["name"] == "LIMIT"
assert win["used_percent"] == 12.5
def test_normalize_clamps_percent_and_rejects_non_object():
out = cs.normalize_usage_payload({"rate_limit": {"primary_window": {"used_percent": 250, "limit_window_seconds": 90}}})
win = out["limits"][0]["windows"][0]
assert win["used_percent"] == 100.0 and win["remaining_percent"] == 0.0
assert win["window_minutes"] == 2 # ceil(90/60) like Codex
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.normalize_usage_payload(["not", "an", "object"])
assert exc.value.reason == "malformed"
@pytest.mark.parametrize("minutes,name", [(300, "5H"), (10080, "WEEK"), (20160, "2W"), (1440, "1D"), (90, "90M"), (None, "LIMIT")])
def test_friendly_window_names_derive_from_duration(minutes, name):
assert cs.friendly_window_name(minutes) == name
def _fake_get(monkeypatch, *, status=200, body=b"{}", raise_exc=None, seen=None):
def fake_get(url, headers=None, timeout=None):
if seen is not None:
seen.append({"url": url, "headers": dict(headers or {}), "timeout": timeout})
if raise_exc is not None:
raise raise_exc
return httpx.Response(status, content=body, request=httpx.Request("GET", url))
monkeypatch.setattr(cs.httpx, "get", fake_get)
def test_fetch_usage_uses_bearer_and_account_header_and_strict_timeout(monkeypatch):
seen = []
_fake_get(monkeypatch, body=json.dumps({"plan_type": "plus"}).encode(), seen=seen)
# A JWT whose auth claim carries the ChatGPT account id, as Codex reads it.
import base64
claims = base64.urlsafe_b64encode(json.dumps({"https://api.openai.com/auth": {"chatgpt_account_id": "acct_9"}}).encode()).rstrip(b"=").decode()
token = f"hdr.{claims}.sig"
data = cs.fetch_usage_payload(token)
assert data == {"plan_type": "plus"}
assert seen[0]["url"] == "https://chatgpt.com/backend-api/wham/usage"
assert seen[0]["headers"]["Authorization"] == f"Bearer {token}"
assert seen[0]["headers"]["ChatGPT-Account-Id"] == "acct_9"
assert 0 < seen[0]["timeout"] <= 10
@pytest.mark.parametrize("status,reason", [(401, "reauth"), (403, "reauth"), (429, "rate_limited"), (500, "upstream"), (503, "upstream"), (418, "upstream")])
def test_fetch_usage_classifies_http_failures(monkeypatch, status, reason):
_fake_get(monkeypatch, status=status, body=b"nope")
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.fetch_usage_payload("tok")
assert exc.value.reason == reason
assert exc.value.status_code == status
def test_fetch_usage_handles_timeout_network_and_malformed_json(monkeypatch):
_fake_get(monkeypatch, raise_exc=httpx.ReadTimeout("slow"))
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.fetch_usage_payload("tok")
assert exc.value.reason == "timeout"
_fake_get(monkeypatch, raise_exc=httpx.ConnectError("down"))
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.fetch_usage_payload("tok")
assert exc.value.reason == "network"
_fake_get(monkeypatch, body=b"<html>not json")
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.fetch_usage_payload("tok")
assert exc.value.reason == "malformed"
_fake_get(monkeypatch, body=b"[1,2]")
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.fetch_usage_payload("tok")
assert exc.value.reason == "malformed"
def test_usage_cache_is_per_auth_session_and_expires():
clock = {"t": 100.0}
cache = cs.UsageCache(ttl_seconds=45, time_func=lambda: clock["t"])
cache.put("auth-a", {"plan_type": "plus"})
assert cache.get("auth-a") == {"plan_type": "plus"}
# Cache for A can never satisfy B.
assert cache.get("auth-b") is None
clock["t"] += 44
assert cache.get("auth-a") is not None
clock["t"] += 2
assert cache.get("auth-a") is None
def test_cache_is_bounded_copies_values_and_cleans_expired_accounts():
clock = [0]
cache = cs.UsageCache(time_func=lambda: clock[0], max_entries=2)
original = {"limits": [{"used": 1}]}
cache.put("a", original)
original["limits"][0]["used"] = 99
assert cache.get("a")["limits"][0]["used"] == 1
cache.put("b", {})
cache.put("c", {})
assert cache.get("a") is None
clock[0] = 46
cache.put("d", {})
assert set(cache._entries) == {"d"}
@pytest.mark.parametrize("value", [float("nan"), float("inf"), "NaN", "Infinity", True, {}])
def test_non_finite_and_malformed_usage_never_invents_a_percentage(value):
out = cs.normalize_usage_window({"used_percent": value, "reset_at": value}, "primary")
assert out["used_percent"] is None
assert out["remaining_percent"] is None
assert out["resets_at"] is None
json.dumps(out, allow_nan=False)
def test_future_window_kind_and_reached_kind_are_retained():
out = cs.normalize_usage_payload({"rate_limit": {"tertiary_window": _window(3, 7200, 100)},
"rate_limit_reached_type": {"kind": "future_limit"}})
assert out["limits"][0]["windows"][0]["kind"] == "tertiary"
assert out["limits"][0]["windows"][0]["window_minutes"] == 120
assert out["rate_limit_reached_type"] == "future_limit"
def test_cache_hits_revalidate_owner_and_deleted_auth(owned_accounts):
from core.database import ProviderAuthSession
cache = cs.UsageCache()
cache.put("auth-a", {"plan_type": "plus"})
with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
cs.get_account_usage("auth-a", owner="mallory", cache=cache)
with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
cs.get_account_usage("auth-a", owner=None, cache=cache)
assert cs.get_account_usage("auth-a", owner="alice", cache=cache)["cached"] is True
with owned_accounts() as db:
db.delete(db.get(ProviderAuthSession, "auth-a"))
db.commit()
with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
cs.get_account_usage("auth-a", owner="alice", cache=cache)
def test_oauth_errors_never_echo_upstream_credentials():
response = httpx.Response(401, json={"error": {"code": "invalid_token", "message": "SECRET-AT SECRET-RT"}})
with pytest.raises(cs.ChatGPTSubscriptionReauthRequired) as exc:
cs._raise_for_oauth_response(response, "token refresh")
assert "SECRET" not in str(exc.value)
def test_get_account_usage_uses_exact_auth_session_cache_and_force_refresh(monkeypatch, owned_accounts):
clock = {"t": 1000.0}
cache = cs.UsageCache(ttl_seconds=45, time_func=lambda: clock["t"])
resolved = []
def fake_resolve(auth_id, owner=None, force_refresh=False):
resolved.append((auth_id, owner))
return {"api_key": f"token-for-{auth_id}", "base_url": cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL}
fetched = []
def fake_fetch(access_token, timeout=None):
fetched.append(access_token)
return {"plan_type": "plus" if access_token.endswith("auth-a") else "pro", "rate_limit": {"primary_window": _window(10, 300, 5)}}
monkeypatch.setattr(cs, "resolve_runtime_credentials", fake_resolve)
monkeypatch.setattr(cs, "fetch_usage_payload", fake_fetch)
a1 = cs.get_account_usage("auth-a", owner="alice", cache=cache)
assert a1["plan_type"] == "plus" and a1["cached"] is False and a1["auth_id"] == "auth-a"
assert resolved == [("auth-a", "alice")]
assert fetched == ["token-for-auth-a"]
a2 = cs.get_account_usage("auth-a", owner="alice", cache=cache)
assert a2["cached"] is True and a2["plan_type"] == "plus"
assert len(fetched) == 1 # served from cache
b1 = cs.get_account_usage("auth-b", owner="alice", cache=cache)
assert b1["plan_type"] == "pro" and b1["cached"] is False
assert fetched == ["token-for-auth-a", "token-for-auth-b"] # B never reuses A's cache or token
a3 = cs.get_account_usage("auth-a", owner="alice", force_refresh=True, cache=cache)
assert a3["cached"] is False
assert fetched == ["token-for-auth-a", "token-for-auth-b", "token-for-auth-a"]
# Nothing token-shaped in the returned structure.
dumped = json.dumps(a3)
assert "token-for" not in dumped and "refresh_token" not in dumped and "access_token" not in dumped
def test_get_account_usage_maps_credential_failures_without_touching_endpoint(monkeypatch, owned_accounts):
cache = cs.UsageCache(ttl_seconds=45)
def boom(auth_id, owner=None, force_refresh=False):
raise cs.ChatGPTSubscriptionReauthRequired("expired")
monkeypatch.setattr(cs, "resolve_runtime_credentials", boom)
with pytest.raises(cs.ChatGPTUsageUnavailable) as exc:
cs.get_account_usage("auth-a", owner="alice", cache=cache)
assert exc.value.reason == "reauth"
def missing(auth_id, owner=None, force_refresh=False):
raise cs.ChatGPTSubscriptionAuthNotFound("gone")
monkeypatch.setattr(cs, "resolve_runtime_credentials", missing)
with pytest.raises(cs.ChatGPTSubscriptionAuthNotFound):
cs.get_account_usage("auth-x", owner="alice", cache=cache)
def test_usage_route_failure_is_reported_safely_and_endpoint_stays_enabled(monkeypatch):
import types
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, ModelEndpoint, ProviderAuthSession
import routes.chatgpt_subscription_routes as csr
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
monkeypatch.setattr(csr, "SessionLocal", TestSessionLocal)
monkeypatch.setattr(csr, "require_admin", lambda request: None)
db = TestSessionLocal()
try:
db.add(ProviderAuthSession(id="auth-a", provider=cs.CHATGPT_SUBSCRIPTION_PROVIDER, owner="alice", label="ChatGPT · codex00",
base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL, access_token="SECRET-AT", refresh_token="SECRET-RT"))
db.add(ModelEndpoint(id="ep-a", name="ChatGPT · codex00", base_url=cs.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL,
provider_auth_id="auth-a", owner="alice", is_enabled=True, supports_tools=False))
db.commit()
finally:
db.close()
def failing(auth_id, owner=None, force_refresh=False, cache=None):
raise cs.ChatGPTUsageUnavailable("reauth", "rejected", status_code=401)
monkeypatch.setattr(cs, "get_account_usage", failing)
router = csr.setup_chatgpt_subscription_routes()
usage = next(r.endpoint for r in router.routes if r.path.endswith("/usage"))
request = types.SimpleNamespace(state=types.SimpleNamespace(current_user="alice"), app=None, headers={})
payload = usage("auth-a", request)
assert payload["available"] is False
assert payload["reason"] == "reauth"
assert payload["reconnect_suggested"] is True
assert payload["account"]["auth_id"] == "auth-a"
dumped = json.dumps(payload)
assert "SECRET" not in dumped and "access_token" not in dumped and "refresh_token" not in dumped
db = TestSessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-a").first()
auth = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth-a").first()
# Usage failure != endpoint failure: nothing disabled, nothing destroyed.
assert ep.is_enabled is True
assert auth.refresh_token == "SECRET-RT"
finally:
db.close()
+574
View File
@@ -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": "<img src=x onerror=alert(1)>"},
"usage": {"plan_type": "<b>plus</b>", "limits": [{"limit_id": "codex", "limit_name": "<script>", "windows": [{"kind": "primary", "name": "<5H>", "used_percent": 10}]}],
"access_token": "SHOULD-NOT-BE-HERE"},
}
js = f"""
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
const html = renderUsageCardHtml(buildUsageViewModel({json.dumps(payload)}, 0), {{ endpointId: 'ep-a' }});
console.log(JSON.stringify({{ html }}));
"""
html = _run_node(js)["html"]
assert "<script>" not in html and "<img" not in html and "<b>plus" not in html
assert "&lt;5H&gt;" in html
assert "SHOULD-NOT-BE-HERE" not in html
assert "Bearer" not in html and "access_token" not in html and "refresh_token" not in html
def test_admin_wires_per_account_usage_and_reconnect_by_exact_ids():
load_block = _ADMIN[_ADMIN.index("async function loadEndpoints()"):_ADMIN.index("async function _refreshAfterEndpointChange")] if _ADMIN.index("async function loadEndpoints()") < _ADMIN.index("async function _refreshAfterEndpointChange") else _ADMIN[_ADMIN.index("async function loadEndpoints()"):]
assert "isChatgptSubscriptionEndpoint(ep)" in load_block
assert 'data-adm-chatgpt-usage-host="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"' in load_block
assert "_loadChatgptUsage(host, host.dataset.admChatgptUsageHost, host.dataset.chatgptEndpointId)" in load_block
usage_block = _ADMIN[_ADMIN.index("async function _loadChatgptUsage"):_ADMIN.index("function initEndpointForm()")]
assert "/api/chatgpt-subscription/accounts/' + encodeURIComponent(authId) + '/usage'" in usage_block
assert "refresh ? '?refresh=1' : ''" in usage_block
assert "refreshBtn.dataset.admChatgptUsageRefresh" in usage_block
assert "reconnectBtn.dataset.admChatgptReconnect" in usage_block
assert "formData.append('reconnect_auth_id', authId)" in usage_block
assert "formData.append('reconnect_endpoint_id', epId)" in usage_block
# The browser only ever talks to Odysseus, never to OpenAI directly.
assert "chatgpt.com" not in usage_block
assert "wham/usage" not in usage_block
def test_admin_add_flow_sends_optional_account_label():
form_block = _ADMIN[_ADMIN.index("function _setApiFormForProvider()"):_ADMIN.index("function _renderPickerMenu()")]
assert "Account label, e.g. codex00 (optional)" in form_block
assert "_chatgptLabelMode = true" in form_block
start_block = _ADMIN[_ADMIN.index("async function _startProviderDeviceAuth"):_ADMIN.index('// Local "Add" button')]
assert "formData.append('label', label)" in start_block
assert "formData," in start_block
assert ".adm-chatgpt-usage-bar" in _STYLE and ".adm-chatgpt-usage-fill" in _STYLE
def test_unknown_limits_without_windows_remain_visible():
payload = {"available": True, "account": {"auth_id": "a"}, "usage": {
"limits": [{"limit_id": "future", "limit_name": "Future <limit>", "windows": []}],
}}
out = _run_node(f"""
import {{ buildUsageViewModel, renderUsageCardHtml, formatResetIn }} from '{_MODULE.as_posix()}';
console.log(JSON.stringify({{
html: renderUsageCardHtml(buildUsageViewModel({json.dumps(payload)})),
reset: formatResetIn(0, 100),
}}));
""")
assert "Future &lt;limit&gt;" in out["html"]
assert 'data-usage-limit="future"' in out["html"]
assert "No rate-limit windows reported" in out["html"]
assert out["reset"] == ""
def test_refresh_and_reconnect_handlers_target_only_the_clicked_account():
# Execute the real admin handlers with small DOM doubles. This checks the
# actions themselves, beyond checking renderer attributes or source text.
out = _run_node(f"""
import fs from 'node:fs';
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
const source = fs.readFileSync('{(_MODULE.parent / 'admin.js').as_posix()}', 'utf8');
const start = source.indexOf('const _chatgptReconnectInflight');
const end = source.indexOf('function initEndpointForm()', start);
const urls = [], operations = [];
const makeButton = () => ({{ dataset: {{}}, addEventListener(_, fn) {{ this.click = fn; }} }});
function card(id) {{
const refresh = makeButton(), reconnect = makeButton();
for (const button of [refresh, reconnect]) button.dataset = {{
admChatgptUsageRefresh: id, admChatgptReconnect: id, chatgptEndpointId: 'ep-' + id,
}};
return {{ innerHTML: '', refresh, reconnect, querySelector(sel) {{
if (sel.includes('usage-refresh')) return refresh;
if (sel.includes('chatgpt-reconnect')) return reconnect;
return {{ replaceWith() {{}} }};
}} }};
}}
const handlers = new Function('fetch', 'buildChatgptUsageViewModel', 'renderChatgptUsageCardHtml',
'esc', 'runProviderDeviceFlow', 'document', 'loadEndpoints', 'setTimeout',
source.slice(start, end) + '; return {{ load: _loadChatgptUsage }};'
)(
async url => {{ urls.push(url); return {{ ok: true, json: async () => ({{available: true, usage: {{limits: []}}}}) }}; }},
buildUsageViewModel, renderUsageCardHtml, x => String(x),
async (provider, options) => {{ operations.push(Object.fromEntries(options.formData)); return {{ status: 'authorized' }}; }},
{{ createElement: () => ({{}}) }}, async () => {{}}, () => {{}}
);
const a = card('a'), b = card('b');
await handlers.load(a, 'a', 'ep-a'); await handlers.load(b, 'b', 'ep-b');
const before = b.innerHTML;
await a.refresh.click({{stopPropagation() {{}}}});
await a.reconnect.click({{stopPropagation() {{}}}});
console.log(JSON.stringify({{ urls, operations, bUnchanged: b.innerHTML === before }}));
""")
assert out["urls"] == [
"/api/chatgpt-subscription/accounts/a/usage",
"/api/chatgpt-subscription/accounts/b/usage",
"/api/chatgpt-subscription/accounts/a/usage?refresh=1",
]
assert out["operations"] == [{"reconnect_auth_id": "a", "reconnect_endpoint_id": "ep-a"}]
assert out["bUnchanged"] is True
def test_admin_renders_chatgpt_usage_collapsible_and_styled():
admin_source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
style_source = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
load_block = admin_source[admin_source.index("async function loadEndpoints()"):admin_source.index("function initEndpointForm()")]
assert "adm-chatgpt-controls" in load_block
assert "adm-chatgpt-usage-toggle" in load_block
assert 'aria-expanded="${isUsageExpanded ? \'true\' : \'false\'}"' in load_block
assert 'aria-controls="adm-chatgpt-usage-${esc(ep.id)}"' in load_block
assert "adm-chatgpt-usage-chevron" in load_block
assert 'class="adm-chatgpt-usage-host${isUsageExpanded ? \'\' : \' hidden\'}"' in load_block
assert 'data-adm-chatgpt-usage-host="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"' in load_block
assert 'data-adm-chatgpt-reconnect="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"' in load_block
assert ".adm-chatgpt-controls" in style_source
assert ".adm-chatgpt-usage-chevron" in style_source
def test_chatgpt_usage_collapsible_behavior():
out = _run_node(f"""
import fs from 'node:fs';
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
const source = fs.readFileSync('{(_MODULE.parent / 'admin.js').as_posix()}', 'utf8');
// Extract localStorage helpers
const helperStart = source.indexOf('const CHATGPT_USAGE_EXPANDED_KEY');
const helperEnd = source.indexOf('async function loadEndpoints()');
const helpersCode = source.slice(helperStart, helperEnd);
// Extract _loadChatgptUsage and _reconnectChatgptAccount
const handlerStart = source.indexOf('const _chatgptReconnectInflight');
const handlerEnd = source.indexOf('function initEndpointForm()', handlerStart);
const handlersCode = source.slice(handlerStart, handlerEnd);
// Simulated localStorage
const storage = {{}};
const localStorage = {{
getItem: k => storage[k] || null,
setItem: (k, v) => {{ storage[k] = String(v); }},
removeItem: k => {{ delete storage[k]; }},
}};
const helpers = new Function('localStorage', helpersCode + '; return {{ _loadExpandedUsageEndpoints, _saveExpandedUsageEndpoints, _isChatgptUsageExpanded, _setChatgptUsageExpanded }};')(localStorage);
// Verify localStorage persistence format: IDs only, no tokens/secrets
assertDefaultCollapsed: {{
if (helpers._isChatgptUsageExpanded('ep-a', 'auth-a') !== false) throw new Error('should be collapsed by default');
}}
helpers._setChatgptUsageExpanded('ep-a', 'auth-a', true);
const stored = JSON.parse(storage['odysseus-chatgpt-usage-expanded']);
if (!stored.includes('ep-a') || !stored.includes('auth-a')) throw new Error('storage should have ep and auth ids');
if (storage['odysseus-chatgpt-usage-expanded'].includes('Bearer') || storage['odysseus-chatgpt-usage-expanded'].includes('secret')) throw new Error('storage has credentials');
if (helpers._isChatgptUsageExpanded('ep-a', 'auth-a') !== true) throw new Error('should be expanded');
if (helpers._isChatgptUsageExpanded('ep-b', 'auth-b') !== false) throw new Error('b should remain collapsed');
helpers._setChatgptUsageExpanded('ep-a', 'auth-a', false);
if (helpers._isChatgptUsageExpanded('ep-a', 'auth-a') !== false) throw new Error('should be collapsed after removal');
// Now verify toggle and lazy loading interactions
const urls = [], operations = [];
const makeEl = (tag = 'div') => ({{
tagName: tag,
classList: new Set(),
style: {{}},
dataset: {{}},
attributes: {{}},
setAttribute(k, v) {{ this.attributes[k] = String(v); }},
getAttribute(k) {{ return this.attributes[k]; }},
addEventListener(_, fn) {{ this.click = fn; }},
querySelector() {{ return null; }},
}});
function createAccountRow(id) {{
const row = makeEl('div');
row.classList.add('admin-user-row');
const chevron = makeEl('span');
chevron.textContent = '▾';
const toggleBtn = makeEl('button');
toggleBtn.dataset = {{ admChatgptUsageToggle: id, chatgptEndpointId: 'ep-' + id }};
toggleBtn.setAttribute('aria-expanded', 'false');
toggleBtn.querySelector = sel => sel.includes('chevron') ? chevron : null;
toggleBtn.closest = sel => sel.includes('admin-user-row') ? row : null;
const reconnectBtn = makeEl('button');
reconnectBtn.dataset = {{ admChatgptReconnect: id, chatgptEndpointId: 'ep-' + id }};
reconnectBtn.closest = sel => sel.includes('admin-user-row') ? row : null;
const host = makeEl('div');
host.classList.add('adm-chatgpt-usage-host', 'hidden');
host.style.display = 'none';
host.dataset = {{ admChatgptUsageHost: id, chatgptEndpointId: 'ep-' + id }};
row.querySelector = sel => {{
if (sel.includes('adm-chatgpt-usage-host')) return host;
if (sel.includes('adm-chatgpt-usage-toggle')) return toggleBtn;
if (sel.includes('adm-chatgpt-reconnect')) return reconnectBtn;
return null;
}};
return {{ row, toggleBtn, reconnectBtn, host, chevron }};
}}
const handlers = new Function('fetch', 'buildChatgptUsageViewModel', 'renderChatgptUsageCardHtml',
'esc', 'runProviderDeviceFlow', 'document', 'loadEndpoints', 'setTimeout',
handlersCode + '; return {{ load: _loadChatgptUsage, reconnect: _reconnectChatgptAccount }};'
)(
async url => {{ urls.push(url); return {{ ok: true, json: async () => ({{available: true, usage: {{limits: []}}}}) }}; }},
buildUsageViewModel, renderUsageCardHtml, x => String(x),
async (provider, options) => {{ operations.push(Object.fromEntries(options.formData)); return {{ status: 'authorized' }}; }},
{{ createElement: () => ({{ replaceWith() {{}} }}) }}, async () => {{}}, () => {{}}
);
const a = createAccountRow('auth-a');
const b = createAccountRow('auth-b');
// Wire toggle listener like in admin.js
function wireToggle(rowObj) {{
rowObj.toggleBtn.addEventListener('click', async () => {{
const epId = rowObj.toggleBtn.dataset.chatgptEndpointId;
const authId = rowObj.toggleBtn.dataset.admChatgptUsageToggle;
const host = rowObj.host;
const isHidden = host.classList.has('hidden') || host.style.display === 'none';
if (isHidden) {{
host.classList.delete('hidden');
host.style.display = '';
rowObj.toggleBtn.setAttribute('aria-expanded', 'true');
rowObj.chevron.textContent = '▴';
helpers._setChatgptUsageExpanded(epId, authId, true);
if (!host.dataset.usageLoaded) {{
await handlers.load(host, authId, epId);
}}
}} else {{
host.classList.add('hidden');
host.style.display = 'none';
rowObj.toggleBtn.setAttribute('aria-expanded', 'false');
rowObj.chevron.textContent = '▾';
helpers._setChatgptUsageExpanded(epId, authId, false);
}}
}});
}}
wireToggle(a);
wireToggle(b);
// Step 1: Initial state - 0 fetches before expand
const initialFetches = urls.length;
// Step 2: Expand A -> fetches A only, updates aria-expanded and chevron
await a.toggleBtn.click();
const aExpandedFetches = urls.slice();
const bStateAfterAExpand = {{
hidden: b.host.style.display === 'none',
ariaExpanded: b.toggleBtn.getAttribute('aria-expanded'),
chevron: b.chevron.textContent,
}};
// Step 3: Collapse A -> 0 extra fetches, updates aria-expanded and chevron
await a.toggleBtn.click();
const aCollapsedFetches = urls.slice();
// Step 4: Re-open A -> 0 extra fetches (cached DOM reused)
await a.toggleBtn.click();
const aReopenedFetches = urls.slice();
// Step 5: Refresh A -> forces fetch with ?refresh=1
await handlers.load(a.host, 'auth-a', 'ep-auth-a', {{ refresh: true }});
const refreshFetches = urls.slice();
console.log(JSON.stringify({{
initialFetches,
aExpandedFetches,
bStateAfterAExpand,
aCollapsedFetches,
aReopenedFetches,
refreshFetches,
aFinalAriaExpanded: a.toggleBtn.getAttribute('aria-expanded'),
aFinalChevron: a.chevron.textContent,
}}));
""")
assert out["initialFetches"] == 0
assert out["aExpandedFetches"] == ["/api/chatgpt-subscription/accounts/auth-a/usage"]
assert out["bStateAfterAExpand"] == {"hidden": True, "ariaExpanded": "false", "chevron": "▾"}
assert len(out["aCollapsedFetches"]) == 1 # No extra fetch on collapse
assert len(out["aReopenedFetches"]) == 1 # No extra fetch on reopen (cached DOM reused)
assert out["refreshFetches"] == [
"/api/chatgpt-subscription/accounts/auth-a/usage",
"/api/chatgpt-subscription/accounts/auth-a/usage?refresh=1",
]
assert out["aFinalAriaExpanded"] == "true"
assert out["aFinalChevron"] == "▴"
def test_chatgpt_usage_collapsible_reconnect_and_failure():
out = _run_node(f"""
import fs from 'node:fs';
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
const source = fs.readFileSync('{(_MODULE.parent / 'admin.js').as_posix()}', 'utf8');
const handlerStart = source.indexOf('const _chatgptReconnectInflight');
const handlerEnd = source.indexOf('function initEndpointForm()', handlerStart);
const handlersCode = source.slice(handlerStart, handlerEnd);
const urls = [], operations = [];
const makeEl = (tag = 'div') => ({{
tagName: tag,
classList: new Set(),
style: {{}},
dataset: {{}},
attributes: {{}},
appendChild() {{}},
setAttribute(k, v) {{ this.attributes[k] = String(v); }},
getAttribute(k) {{ return this.attributes[k]; }},
addEventListener(_, fn) {{ this.click = fn; }},
querySelector() {{ return null; }},
}});
function createAccountRow(id) {{
const row = makeEl('div');
const chevron = makeEl('span');
chevron.textContent = '▾';
const toggleBtn = makeEl('button');
toggleBtn.dataset = {{ admChatgptUsageToggle: id, chatgptEndpointId: 'ep-' + id }};
toggleBtn.setAttribute('aria-expanded', 'false');
toggleBtn.querySelector = sel => sel.includes('chevron') ? chevron : null;
toggleBtn.closest = sel => sel.includes('admin-user-row') ? row : null;
const reconnectBtn = makeEl('button');
reconnectBtn.dataset = {{ admChatgptReconnect: id, chatgptEndpointId: 'ep-' + id }};
reconnectBtn.closest = sel => sel.includes('admin-user-row') ? row : null;
const host = makeEl('div');
host.classList.add('adm-chatgpt-usage-host', 'hidden');
host.style.display = 'none';
host.dataset = {{ admChatgptUsageHost: id, chatgptEndpointId: 'ep-' + id }};
row.querySelector = sel => {{
if (sel.includes('adm-chatgpt-usage-host')) return host;
if (sel.includes('adm-chatgpt-usage-toggle')) return toggleBtn;
if (sel.includes('adm-chatgpt-reconnect')) return reconnectBtn;
return null;
}};
return {{ row, toggleBtn, reconnectBtn, host, chevron }};
}}
let shouldFail = false;
const handlers = new Function('fetch', 'buildChatgptUsageViewModel', 'renderChatgptUsageCardHtml',
'esc', 'runProviderDeviceFlow', 'document', 'loadEndpoints', 'setTimeout',
handlersCode + '; return {{ load: _loadChatgptUsage, reconnect: _reconnectChatgptAccount }};'
)(
async url => {{
urls.push(url);
if (shouldFail) throw new Error('network down');
return {{ ok: true, json: async () => ({{ available: true, usage: {{ limits: [] }} }}) }};
}},
buildUsageViewModel, renderUsageCardHtml, x => String(x),
async (provider, options) => {{ operations.push(Object.fromEntries(options.formData)); return {{ status: 'authorized' }}; }},
{{ createElement: () => ({{ replaceWith() {{}} }}) }}, async () => {{}}, () => {{}}
);
const b = createAccountRow('auth-b');
// Wire reconnect listener like in admin.js
b.reconnectBtn.addEventListener('click', async () => {{
const epId = b.reconnectBtn.dataset.chatgptEndpointId;
const authId = b.reconnectBtn.dataset.admChatgptReconnect;
const host = b.host;
host.classList.delete('hidden');
host.style.display = '';
b.toggleBtn.setAttribute('aria-expanded', 'true');
b.chevron.textContent = '▴';
await handlers.reconnect(host, authId, epId);
}});
// Reconnect when collapsed -> unhides host, updates aria-expanded, runs reconnect for B
await b.reconnectBtn.click();
const bReconnectState = {{
hostHidden: b.host.style.display === 'none',
ariaExpanded: b.toggleBtn.getAttribute('aria-expanded'),
chevron: b.chevron.textContent,
operations: operations.slice(),
}};
// Failure state test
shouldFail = true;
const failHost = makeEl('div');
failHost.dataset = {{ admChatgptUsageHost: 'auth-f', chatgptEndpointId: 'ep-f' }};
await handlers.load(failHost, 'auth-f', 'ep-f');
const failHtml = failHost.innerHTML;
console.log(JSON.stringify({{
bReconnectState,
failHasUnavailable: failHtml.includes('adm-chatgpt-usage-unavailable'),
failHasRefresh: failHtml.includes('data-adm-chatgpt-usage-refresh'),
failNoDuplicateReconnect: !failHtml.includes('data-adm-chatgpt-reconnect'),
}}));
""")
assert out["bReconnectState"]["hostHidden"] is False
assert out["bReconnectState"]["ariaExpanded"] == "true"
assert out["bReconnectState"]["chevron"] == "▴"
assert out["bReconnectState"]["operations"] == [{"reconnect_auth_id": "auth-b", "reconnect_endpoint_id": "ep-auth-b"}]
assert out["failHasUnavailable"] is True
assert out["failHasRefresh"] is True
assert out["failNoDuplicateReconnect"] is True
+231
View File
@@ -0,0 +1,231 @@
"""ChatGPT Subscription is model inference only — Odysseus is the only agent.
These regressions pin the zero-native-tool invariant relied on by the Odysseus
dogfood benchmark: the ChatGPT/Codex Responses request never declares
provider-native tools, and the stream handler never executes provider-side
tool calls. Odysseus' own text tool protocol travels as plain instructions/
input and is parsed and executed by Odysseus.
"""
import asyncio
import json
import pytest
from src import llm_core
from src import chatgpt_subscription
_URL = "https://chatgpt.com/backend-api/codex"
_TOOLS = [
{"type": "function", "function": {"name": "run_shell", "description": "Run a shell command", "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}}},
{"type": "web_search_preview"},
{"type": "computer_use_preview", "display_width": 1024, "display_height": 768, "environment": "browser"},
{"type": "file_search", "vector_store_ids": ["vs_1"]},
{"type": "local_shell"},
{"type": "mcp", "server_label": "fs", "server_url": "http://localhost/mcp"},
]
_MESSAGES = [
{"role": "system", "content": "You are Odysseus. Use <tool>read_file</tool> protocol when needed."},
{"role": "user", "content": "List the repo"},
{"role": "assistant", "content": "<tool>read_file</tool>"},
{"role": "tool", "content": "README.md"},
]
_ALLOWED_KEYS = {"model", "instructions", "input", "stream", "store", "temperature"}
def test_responses_payload_has_no_native_tool_surfaces():
payload = llm_core._build_chatgpt_responses_payload("gpt-5.5", _MESSAGES, 0.7, 4096, stream=True)
assert set(payload) <= _ALLOWED_KEYS
assert payload["model"] == "gpt-5.5"
assert payload["stream"] is True
assert payload["store"] is False
assert "tools" not in payload
assert "tool_choice" not in payload
for key in llm_core.CHATGPT_FORBIDDEN_PAYLOAD_KEYS:
assert key not in payload
# Odysseus protocol text is preserved as plain instructions/input.
assert "<tool>read_file</tool>" in payload["instructions"]
roles = [item["role"] for item in payload["input"]]
assert roles == ["user", "assistant", "user"] # tool results become user input text
assert "system" not in roles
def test_upper_level_tools_argument_is_discarded_by_chatgpt_builder():
payload = llm_core._build_chatgpt_responses_payload(
"gpt-5.5", _MESSAGES, 0.7, 4096, stream=True, tools=_TOOLS, tool_choice="required", parallel_tool_calls=True,
)
assert set(payload) <= _ALLOWED_KEYS
serialized = json.dumps(payload)
assert "run_shell" not in serialized
assert "web_search" not in serialized
assert "computer_use" not in serialized
assert "file_search" not in serialized
assert "local_shell" not in serialized
assert '"mcp"' not in serialized
def test_strip_helper_removes_any_native_tool_key_added_later():
payload = {"model": "m", "input": [], "tools": _TOOLS, "tool_choice": "auto", "web_search": {}, "shell": {}, "computer": {}, "future_agent_surface": {}}
stripped = llm_core._strip_chatgpt_native_tool_surfaces(payload)
assert set(stripped) == {"model", "input"}
def test_responses_payload_includes_reasoning_effort_without_tools():
payload = llm_core._build_chatgpt_responses_payload(
"gpt-6-astra", _MESSAGES, 0.7, 4096, stream=True, reasoning_effort="high", tools=_TOOLS
)
assert set(payload) <= (_ALLOWED_KEYS | {"reasoning"})
assert payload["reasoning"] == {"effort": "high"}
assert "tools" not in payload
assert "tool_choice" not in payload
for key in llm_core.CHATGPT_FORBIDDEN_PAYLOAD_KEYS:
assert key not in payload
def test_responses_payload_default_or_none_omits_reasoning():
for effort in [None, "", "default", "Default"]:
payload = llm_core._build_chatgpt_responses_payload(
"gpt-6-astra", _MESSAGES, 0.7, 4096, stream=True, reasoning_effort=effort
)
assert set(payload) <= _ALLOWED_KEYS
assert "reasoning" not in payload
class _Resp:
def __init__(self, lines):
self._lines = lines
self.status_code = 200
async def aiter_lines(self):
for line in self._lines:
yield line
async def aread(self):
return b""
class _Ctx:
def __init__(self, lines):
self._lines = lines
async def __aenter__(self):
return _Resp(self._lines)
async def __aexit__(self, *args):
return False
class _CapturingClient:
def __init__(self, lines):
self._lines = lines
self.requests = []
def stream(self, method, url, **kwargs):
self.requests.append({"method": method, "url": url, "json": kwargs.get("json"), "headers": kwargs.get("headers")})
return _Ctx(self._lines)
def _stream_with_tools(monkeypatch, lines):
client = _CapturingClient(lines)
monkeypatch.setattr(llm_core, "_get_http_client", lambda: client)
monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *a, **k: None)
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a, **k: None)
async def run():
return [
chunk
async for chunk in llm_core._stream_llm_inner(
_URL + "/responses",
"gpt-5.5",
_MESSAGES,
headers={"Authorization": "Bearer test"},
tools=_TOOLS,
)
]
return client, asyncio.run(run())
def test_stream_transport_never_serializes_tools_even_when_passed(monkeypatch):
lines = [
"data: " + json.dumps({"type": "response.output_text.delta", "delta": "<tool>read_file</tool>"}),
"data: " + json.dumps({"type": "response.completed", "response": {"usage": {"input_tokens": 1, "output_tokens": 1}}}),
]
client, chunks = _stream_with_tools(monkeypatch, lines)
assert len(client.requests) == 1
sent = client.requests[0]
assert sent["url"] == _URL + "/responses"
assert set(sent["json"]) <= _ALLOWED_KEYS
assert "tools" not in sent["json"] and "tool_choice" not in sent["json"]
assert "run_shell" not in json.dumps(sent["json"])
# The Odysseus protocol tag is streamed back verbatim for Odysseus to parse.
deltas = [json.loads(c[6:])["delta"] for c in chunks if c.startswith("data: ") and '"delta"' in c]
assert deltas == ["<tool>read_file</tool>"]
def test_stream_handler_ignores_provider_side_tool_call_events(monkeypatch):
"""A provider-emitted function_call is never executed nor surfaced as a tool_call."""
lines = [
"data: " + json.dumps({"type": "response.output_item.added", "item": {"type": "function_call", "name": "run_shell", "call_id": "c1"}}),
"data: " + json.dumps({"type": "response.function_call_arguments.delta", "delta": '{"cmd": "rm -rf /"}'}),
"data: " + json.dumps({"type": "response.function_call_arguments.done", "arguments": '{"cmd": "rm -rf /"}'}),
"data: " + json.dumps({"type": "response.output_text.delta", "delta": "done"}),
"data: " + json.dumps({"type": "response.completed", "response": {"usage": {"input_tokens": 1, "output_tokens": 1}}}),
]
client, chunks = _stream_with_tools(monkeypatch, lines)
joined = "".join(chunks)
assert "tool_calls" not in joined
assert "rm -rf" not in joined
assert "run_shell" not in joined
deltas = [json.loads(c[6:])["delta"] for c in chunks if c.startswith("data: ") and '"delta"' in c]
assert deltas == ["done"]
assert chunks[-1] == "data: [DONE]\n\n"
def test_stream_error_cannot_echo_the_request_bearer(monkeypatch):
_client, chunks = _stream_with_tools(monkeypatch, [
'data: ' + json.dumps({"type": "error", "message": "rejected Bearer test", "status": 401}),
])
assert "Bearer test" not in "".join(chunks)
assert "[redacted]" in "".join(chunks)
def test_model_probe_never_posts_provider_tools(monkeypatch):
import routes.model_routes as models
monkeypatch.setattr(models.httpx, "post", lambda *args, **kwargs: pytest.fail("ChatGPT is discovery-only"))
assert models._probe_single_model(_URL, "secret", "gpt-5.5", with_tools=True)["skipped"] is True
def test_provisioned_endpoint_supports_tools_false(monkeypatch):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, ModelEndpoint
import routes.chatgpt_subscription_routes as csr
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
monkeypatch.setattr(csr, "SessionLocal", TestSessionLocal)
monkeypatch.setattr(chatgpt_subscription, "fetch_available_models", lambda token: ["gpt-5.5"])
a = csr._provision_endpoint({"access_token": "A", "refresh_token": "RA"}, "alice", label="codex00")
b = csr._provision_endpoint({"access_token": "B", "refresh_token": "RB"}, "alice", label="codex01")
# Reconnect must not flip the flag either.
csr._provision_endpoint({"access_token": "A2", "refresh_token": "RA2"}, "alice", reconnect_auth_id=a["provider_auth_id"])
db = TestSessionLocal()
try:
for ep_id in (a["id"], b["id"]):
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
assert ep.supports_tools is False
finally:
db.close()
def test_responses_input_never_carries_tool_call_structures():
items = chatgpt_subscription.build_responses_input([
{"role": "assistant", "content": None, "tool_calls": [{"id": "x", "function": {"name": "run_shell", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "x", "content": "output"},
])
for item in items:
assert set(item) == {"role", "content"}
assert "tool_calls" not in json.dumps(item)
+193
View File
@@ -0,0 +1,193 @@
"""Tests for Part A — Endpoint Card UI Polish and Model Tools Selector Layout."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_ADMIN_JS = _REPO / "static" / "js" / "admin.js"
_ADMIN = _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())
class TestEndpointCardUrlPresentation:
def test_should_display_endpoint_base_url_policy(self):
js = f"""
import fs from 'node:fs';
import {{ isChatgptSubscriptionEndpoint }} from '{(_REPO / 'static' / 'js' / 'chatgptSubscriptionUsage.js').as_posix()}';
const source = fs.readFileSync('{_ADMIN_JS.as_posix()}', 'utf8');
const fnStart = source.indexOf('function shouldDisplayEndpointBaseUrl');
const fnEnd = source.indexOf('// ChatGPT per-endpoint usage panel', fnStart);
const fnCode = source.slice(fnStart, fnEnd);
const fns = new Function('isChatgptSubscriptionEndpoint', 'esc',
fnCode + '; return {{ shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml }};'
)(isChatgptSubscriptionEndpoint, x => String(x));
const results = {{
chatgpt: fns.shouldDisplayEndpointBaseUrl({{
base_url: 'https://chatgpt.com/backend-api/codex',
provider: 'chatgpt-subscription',
provider_auth_id: 'auth-123'
}}),
featherless: fns.shouldDisplayEndpointBaseUrl({{
base_url: 'https://api.featherless.ai/v1',
name: 'Featherless.ai'
}}),
featherless_subdomain: fns.shouldDisplayEndpointBaseUrl({{
base_url: 'https://eu.featherless.ai/v1'
}}),
generic: fns.shouldDisplayEndpointBaseUrl({{
base_url: 'https://api.example.com/v1',
name: 'Custom AI'
}}),
local: fns.shouldDisplayEndpointBaseUrl({{
base_url: 'http://127.0.0.1:11434',
name: 'Ollama'
}}),
null_ep: fns.shouldDisplayEndpointBaseUrl(null)
}};
console.log(JSON.stringify(results));
"""
out = _run_node(js)
assert out["chatgpt"] is False, "ChatGPT raw transport URL must be hidden"
assert out["featherless"] is False, "Featherless raw transport URL must be hidden"
assert out["featherless_subdomain"] is False, "Featherless domain variants must be hidden"
assert out["generic"] is True, "Generic custom endpoint URL must remain visible"
assert out["local"] is True, "Local endpoint URL must remain visible"
assert out["null_ep"] is False
def test_endpoint_detail_html_suppresses_first_class_urls_and_keeps_others(self):
js = f"""
import fs from 'node:fs';
import {{ isChatgptSubscriptionEndpoint }} from '{(_REPO / 'static' / 'js' / 'chatgptSubscriptionUsage.js').as_posix()}';
const source = fs.readFileSync('{_ADMIN_JS.as_posix()}', 'utf8');
const fnStart = source.indexOf('function shouldDisplayEndpointBaseUrl');
const fnEnd = source.indexOf('// ChatGPT per-endpoint usage panel', fnStart);
const fnCode = source.slice(fnStart, fnEnd);
const fns = new Function('isChatgptSubscriptionEndpoint', 'esc',
fnCode + '; return {{ shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml }};'
)(isChatgptSubscriptionEndpoint, x => String(x));
const chatgptEp = {{
base_url: 'https://chatgpt.com/backend-api/codex',
provider: 'chatgpt-subscription',
provider_auth_id: 'auth-1',
has_key: false
}};
const featherlessEp = {{
base_url: 'https://api.featherless.ai/v1',
name: 'Featherless.ai',
has_key: true,
api_key_fingerprint: 'abcd1234'
}};
const genericEp = {{
base_url: 'https://api.custom.com/v1',
name: 'Custom',
has_key: true,
api_key_fingerprint: 'ef5678'
}};
const localEp = {{
base_url: 'http://127.0.0.1:11434',
name: 'Local',
has_key: false
}};
const results = {{
chatgptHtml: fns.endpointDetailHtml(chatgptEp, 'api'),
featherlessHtml: fns.endpointDetailHtml(featherlessEp, 'api'),
genericHtml: fns.endpointDetailHtml(genericEp, 'api'),
localHtml: fns.endpointDetailHtml(localEp, 'local')
}};
console.log(JSON.stringify(results));
"""
out = _run_node(js)
# ChatGPT card suppresses raw base URL and has no key, so no detail line
assert "chatgpt.com" not in out["chatgptHtml"]
assert out["chatgptHtml"] == ""
# Featherless suppresses raw base URL but can display key fingerprint if present
assert "api.featherless.ai" not in out["featherlessHtml"]
assert "abcd1234" in out["featherlessHtml"]
# Generic custom endpoint retains URL and key
assert "https://api.custom.com/v1" in out["genericHtml"]
assert "ef5678" in out["genericHtml"]
# Local endpoint retains URL and copy affordance
assert "http://127.0.0.1:11434" in out["localHtml"]
assert 'data-adm-copy-url="http://127.0.0.1:11434"' in out["localHtml"]
assert "admin-ep-copy-btn" in out["localHtml"]
class TestChatGPTCardLayoutAndControls:
def test_chatgpt_controls_secondary_action_layout(self):
# Verify right-alignment in style.css
assert ".adm-chatgpt-controls {" in _STYLE
controls_block = _STYLE.split(".adm-chatgpt-controls {")[1].split("}")[0]
assert "justify-content: flex-end" in controls_block
assert "display: flex" in controls_block
def test_chatgpt_usage_attributes_and_lazy_fetch_retained(self):
assert 'aria-controls="adm-chatgpt-usage-${esc(ep.id)}"' in _ADMIN
assert 'aria-expanded="${isUsageExpanded ? \'true\' : \'false\'}"' in _ADMIN
assert "data-adm-chatgpt-usage-toggle" in _ADMIN
assert "data-adm-chatgpt-reconnect" in _ADMIN
assert "adm-chatgpt-usage-host" in _ADMIN
# Lazy loading on toggle click
assert "_loadChatgptUsage(host, authId, epId)" in _ADMIN
assert "_isChatgptUsageExpanded(epId, authId)" in _ADMIN
class TestModelRowToolsSelectLayout:
def test_css_classes_prevent_clipping_and_guarantee_layout(self):
# Audit classes in style.css
assert ".adm-model-row {" in _STYLE
assert ".adm-model-label {" in _STYLE
assert ".adm-model-name {" in _STYLE
assert ".adm-model-tools-col {" in _STYLE
assert ".adm-model-tool-mode," in _STYLE or ".adm-model-tool-mode {" in _STYLE
assert ".admin-tools-select" in _STYLE
# Verify tool-mode select styling
tool_mode_css = _STYLE.split(".adm-model-tool-mode,")[1].split("}")[0]
assert "height: 24px" in tool_mode_css
assert "line-height: 22px" in tool_mode_css
assert "padding: 0 18px 0 6px" in tool_mode_css
assert "box-sizing: border-box" in tool_mode_css
assert "width: 124px" in tool_mode_css
assert "vertical-align: middle" in tool_mode_css
# Verify right column is fixed / shrink-safe
col_css = _STYLE.split(".adm-model-tools-col {")[1].split("}")[0]
assert "flex: 0 0 auto" in col_css
assert "margin-left: auto" in col_css
# Verify model name is flexible with ellipsis
name_css = _STYLE.split(".adm-model-name {")[1].split("}")[0]
assert "overflow: hidden" in name_css
assert "text-overflow: ellipsis" in name_css
assert "white-space: nowrap" in name_css
assert "flex: 1" in name_css
def test_admin_js_uses_classes_instead_of_inline_clipping_styles(self):
assert 'class="adm-model-row"' in _ADMIN
assert 'class="adm-model-label"' in _ADMIN
assert 'class="adm-model-name"' in _ADMIN
assert 'class="adm-model-tools-col"' in _ADMIN
assert 'class="adm-model-tool-mode admin-tools-select"' in _ADMIN
+666
View File
@@ -0,0 +1,666 @@
"""Tests for Featherless provider detection, setup, lazy discovery, and catalog search."""
import asyncio
import json
import shutil
import subprocess
import sys
import time
import types
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from fastapi import HTTPException
from tests.helpers.import_state import clear_fake_endpoint_resolver_modules, preserve_import_state
with preserve_import_state("core.database", "src.database", "core.session_manager", "routes.model_routes"):
clear_fake_endpoint_resolver_modules()
if "core.database" not in sys.modules:
_core_db = types.ModuleType("core.database")
for _name in [
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun",
"McpServer", "ProviderAuthSession", "Base",
]:
setattr(_core_db, _name, MagicMock())
_core_db.utcnow_naive = MagicMock()
sys.modules["core.database"] = _core_db
import routes.model_routes as model_routes
import src.llm_core as llm_core
from routes.model_routes import (
_effective_endpoint_kind,
_probe_endpoint,
_ping_endpoint,
_picker_requires_pinning,
_has_explicit_pinned_models,
_picker_models_for_endpoint,
_featherless_search_cache,
_featherless_search_cache_lock,
)
from src.llm_core import (
_detect_provider,
_provider_label,
_is_self_hosted_openai_compatible,
)
_REPO = Path(__file__).resolve().parent.parent
_ADMIN_JS = _REPO / "static" / "js" / "admin.js"
_ROUTER = model_routes.setup_model_routes(model_discovery=None)
_should_refresh_endpoint = _ROUTER._should_refresh_endpoint
search_endpoint_catalog = _ROUTER._search_endpoint_catalog
def _route_endpoint(router, path, method="GET"):
for route in router.routes:
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError(f"{method} {path} route not found")
# ============================================================
# 1. Provider Detection & Identification
# ============================================================
def test_featherless_provider_detection():
url = "https://api.featherless.ai/v1"
assert _detect_provider(url) == "featherless"
assert _provider_label(url) == "Featherless.ai"
assert _is_self_hosted_openai_compatible(url) is False
subdomain_url = "https://eu.featherless.ai/v1"
assert _detect_provider(subdomain_url) == "featherless"
assert _provider_label(subdomain_url) == "Featherless.ai"
assert _is_self_hosted_openai_compatible(subdomain_url) is False
def test_featherless_endpoint_kind_is_api_not_proxy():
ep = SimpleNamespace(endpoint_kind="auto", api_key="sk-test-key")
url = "https://api.featherless.ai/v1"
# Keyed /v1 URLs normally resolve to 'proxy', but Featherless must resolve to 'api'
assert _effective_endpoint_kind(ep, url) == "api"
# ============================================================
# 2. Probing & Setup Validation
# ============================================================
def test_featherless_probe_endpoint_bypasses_full_catalog():
with patch("httpx.get") as mock_get:
models = _probe_endpoint("https://api.featherless.ai/v1", api_key="sk-test-key")
# Probe must immediately return [] without making any HTTP request to fetch 20k+ models
assert models == []
mock_get.assert_not_called()
def test_featherless_ping_endpoint_plan_success():
resp_plan = MagicMock()
resp_plan.status_code = 200
resp_plan.text = '{"plan": "pro"}'
with patch("httpx.get", return_value=resp_plan) as mock_get:
res = _ping_endpoint("https://api.featherless.ai/v1", api_key="sk-test-key")
assert res["reachable"] is True
assert res["status_code"] == 200
assert res["error"] is None
mock_get.assert_called_once()
assert "plan" in mock_get.call_args[0][0]
def test_featherless_ping_endpoint_plan_fallback_to_models():
# If /v1/plan returns 404, fallback to /v1/models with per_page=1
resp_404 = MagicMock()
resp_404.status_code = 404
resp_404.text = "Not found"
resp_models = MagicMock()
resp_models.status_code = 200
resp_models.text = '{"data": [{"id": "model1"}]}'
with patch("httpx.get", side_effect=[resp_404, resp_models]) as mock_get:
res = _ping_endpoint("https://api.featherless.ai/v1", api_key="sk-test-key")
assert res["reachable"] is True
assert res["status_code"] == 200
assert mock_get.call_count == 2
assert "available_on_current_plan=true" in mock_get.call_args_list[1][0][0]
assert "per_page=1" in mock_get.call_args_list[1][0][0]
def test_featherless_ping_endpoint_auth_failure():
resp_401 = MagicMock()
resp_401.status_code = 401
resp_401.text = "Unauthorized"
with patch("httpx.get", return_value=resp_401):
res = _ping_endpoint("https://api.featherless.ai/v1", api_key="bad-key")
assert res["reachable"] is False
assert res["status_code"] == 401
assert "Featherless API key invalid or unauthorized" in res["error"]
# ============================================================
# 3. Background Refresh & Catalog Protection
# ============================================================
def test_featherless_should_refresh_endpoint_returns_false():
ep = SimpleNamespace(
id="ep-fl",
base_url="https://api.featherless.ai/v1",
api_key="sk-test",
provider_auth_id=None,
cached_models=None,
pinned_models="[]",
)
should_refresh, info = _should_refresh_endpoint(ep, time.time())
assert should_refresh is False
assert info["base"] == "https://api.featherless.ai/v1"
# ============================================================
# 4. Pinning & Chat Picker Isolation
# ============================================================
def test_featherless_picker_models_initially_empty():
url = "https://api.featherless.ai/v1"
kind = "api"
assert _picker_requires_pinning(url, kind) is True
ep = SimpleNamespace(
base_url=url,
endpoint_kind=kind,
pinned_models="[]",
cached_models=None,
hidden_models=None,
)
assert _has_explicit_pinned_models(ep) is True
visible, pinned = _picker_models_for_endpoint(ep, url, kind)
# Default state has 0 models enabled
assert visible == []
assert pinned == []
def test_featherless_picker_models_reflects_pinned_only():
url = "https://api.featherless.ai/v1"
kind = "api"
ep = SimpleNamespace(
base_url=url,
endpoint_kind=kind,
pinned_models=json.dumps(["mistralai/Mistral-7B-Instruct-v0.2", "meta-llama/Llama-3-8B-Instruct"]),
cached_models=None,
hidden_models=None,
)
visible, pinned = _picker_models_for_endpoint(ep, url, kind)
assert visible == ["mistralai/Mistral-7B-Instruct-v0.2", "meta-llama/Llama-3-8B-Instruct"]
assert pinned == ["mistralai/Mistral-7B-Instruct-v0.2", "meta-llama/Llama-3-8B-Instruct"]
# ============================================================
# 5. Catalog Search Route
# ============================================================
@pytest.mark.asyncio
async def test_featherless_catalog_search_validation():
# q < 2 chars raises HTTPException(400)
req = MagicMock()
with pytest.raises(HTTPException) as exc_info:
await search_endpoint_catalog("ep-1", req, q="a")
assert exc_info.value.status_code == 400
assert "at least 2 characters" in exc_info.value.detail
class _FakeQuery:
def __init__(self, ep):
self.ep = ep
def filter(self, *args, **kwargs):
return self
def order_by(self, *args, **kwargs):
return self
def all(self):
return [self.ep] if self.ep else []
def first(self):
return self.ep
class _FakeDb:
def __init__(self, ep):
self.ep = ep
def query(self, *args, **kwargs):
return _FakeQuery(self.ep)
def close(self):
pass
def test_create_featherless_endpoint(monkeypatch):
create = _route_endpoint(_ROUTER, "/api/model-endpoints", "POST")
added = []
class FakeDb:
def __init__(self):
self.added = added
def query(self, *args, **kwargs):
return _FakeQuery(None)
def add(self, row):
self.added.append(row)
def commit(self):
pass
def close(self):
pass
monkeypatch.setattr(model_routes, "SessionLocal", FakeDb)
monkeypatch.setattr(model_routes, "require_admin", lambda r: None)
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **kw: {"reachable": True, "error": None})
monkeypatch.setattr(model_routes, "_load_settings", lambda: {})
monkeypatch.setattr(model_routes, "_save_settings", lambda s: None)
req = MagicMock()
result = create(
req,
base_url="https://api.featherless.ai/v1",
name="",
api_key="sk-test",
skip_probe="false",
require_models="false",
model_type="llm",
endpoint_kind="auto",
model_refresh_mode="",
model_refresh_interval="",
model_refresh_timeout="",
supports_tools="",
pinned_models="",
container_local="false",
shared="true",
)
assert result["name"] == "Featherless.ai"
assert result["endpoint_kind"] == "api"
assert result["pinned_models"] == []
assert result["models"] == []
assert result["online"] is True
assert result["status"] == "online"
assert len(added) == 1
ep = added[0]
assert ep.name == "Featherless.ai"
assert ep.endpoint_kind == "api"
assert ep.pinned_models == "[]"
assert ep.cached_models is None
def test_list_featherless_endpoint(monkeypatch):
list_ep = _route_endpoint(_ROUTER, "/api/model-endpoints", "GET")
ep = SimpleNamespace(
id="ep-fl",
name="Featherless.ai",
base_url="https://api.featherless.ai/v1",
api_key="sk-test",
is_enabled=True,
cached_models=None,
pinned_models="[]",
hidden_models=None,
endpoint_kind="api",
model_type="llm",
supports_tools=None,
model_refresh_mode="auto",
model_refresh_interval=None,
model_refresh_timeout=None,
owner=None,
created_at=None,
updated_at=None,
)
class FakeDb:
def query(self, *args, **kwargs):
m = MagicMock()
m.order_by.return_value.all.return_value = [ep]
return m
def close(self):
pass
monkeypatch.setattr(model_routes, "SessionLocal", FakeDb)
monkeypatch.setattr(model_routes, "require_admin", lambda r: None)
monkeypatch.setattr(model_routes, "_disable_stale_cookbook_local_endpoints", lambda db: False)
req = MagicMock()
results = list_ep(req)
assert len(results) == 1
r = results[0]
assert r["name"] == "Featherless.ai"
assert r["status"] == "online"
assert r["online"] is True
assert r["model_count"] == 0
assert r["models"] == []
assert r["pinned_models"] == []
@pytest.mark.asyncio
async def test_featherless_catalog_search_non_featherless_endpoint(monkeypatch):
req = MagicMock()
ep_mock = SimpleNamespace(
id="ep-openai",
base_url="https://api.openai.com/v1",
api_key="sk-test",
)
monkeypatch.setattr(model_routes, "require_admin", lambda r: None)
monkeypatch.setattr(model_routes, "_chatgpt_endpoint_visible", lambda ep, req: True)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDb(ep_mock))
with pytest.raises(HTTPException) as exc_info:
await search_endpoint_catalog("ep-openai", req, q="gpt")
assert exc_info.value.status_code == 400
assert "only supported for Featherless" in exc_info.value.detail
@pytest.mark.asyncio
async def test_featherless_catalog_search_success_and_caching(monkeypatch):
req = MagicMock()
ep_mock = SimpleNamespace(
id="ep-fl",
base_url="https://api.featherless.ai/v1",
api_key="sk-test-key",
)
upstream_data = {
"data": [
{
"id": "mistralai/Mistral-7B-Instruct-v0.2",
"name": "Mistral 7B Instruct v0.2",
"context_length": 32768,
"max_completion_tokens": 8192,
"is_gated": False,
"available_on_current_plan": True,
},
{
"id": "meta-llama/Meta-Llama-3-8B-Instruct",
"context_length": 8192,
},
]
}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = upstream_data
# Clear cache before test
with _featherless_search_cache_lock:
_featherless_search_cache.clear()
monkeypatch.setattr(model_routes, "require_admin", lambda r: None)
monkeypatch.setattr(model_routes, "_chatgpt_endpoint_visible", lambda ep, req: True)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDb(ep_mock))
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp) as mock_async_get:
res1 = await search_endpoint_catalog("ep-fl", req, q="mistral", page=1, per_page=50)
assert len(res1["items"]) == 2
assert res1["items"][0]["id"] == "mistralai/Mistral-7B-Instruct-v0.2"
assert res1["items"][0]["context_length"] == 32768
assert res1["items"][1]["name"] == "meta-llama/Meta-Llama-3-8B-Instruct"
assert res1["page"] == 1
assert res1["per_page"] == 50
assert mock_async_get.call_count == 1
# Check upstream call parameters
call_kwargs = mock_async_get.call_args[1]
assert call_kwargs["params"]["q"] == "mistral"
assert "search" not in call_kwargs["params"]
assert call_kwargs["params"]["available_on_current_plan"] == "true"
assert call_kwargs["params"]["status"] == "active"
assert call_kwargs["params"]["conversational"] == "true"
assert call_kwargs["headers"]["Authorization"] == "Bearer sk-test-key"
# Second call with same query should hit in-memory cache without calling upstream
res2 = await search_endpoint_catalog("ep-fl", req, q="mistral", page=1, per_page=50)
assert res2 == res1
assert mock_async_get.call_count == 1 # Not incremented!
@pytest.mark.asyncio
async def test_featherless_catalog_search_exact_upstream_params(monkeypatch):
"""Proves exact upstream query parameters: q (not search), filters, page, bounded per_page."""
req = MagicMock()
ep_mock = SimpleNamespace(
id="ep-fl",
base_url="https://api.featherless.ai/v1",
api_key="sk-test-key",
)
with _featherless_search_cache_lock:
_featherless_search_cache.clear()
monkeypatch.setattr(model_routes, "require_admin", lambda r: None)
monkeypatch.setattr(model_routes, "_chatgpt_endpoint_visible", lambda ep, req: True)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDb(ep_mock))
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"data": [{"id": "deepseek-ai/DeepSeek-V3"}]}
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp) as mock_async_get:
# Standard query
await search_endpoint_catalog("ep-fl", req, q="deepseek", page=2, per_page=50)
assert mock_async_get.call_count == 1
call_kwargs = mock_async_get.call_args[1]
params = call_kwargs["params"]
assert params["q"] == "deepseek"
assert "search" not in params
assert params["available_on_current_plan"] == "true"
assert params["status"] == "active"
assert params["conversational"] == "true"
assert params["page"] == 2
assert params["per_page"] == 50
# Bounded per_page: upper bound (500 -> 100)
await search_endpoint_catalog("ep-fl", req, q="deepseek-high", page=1, per_page=500)
params_upper = mock_async_get.call_args[1]["params"]
assert params_upper["per_page"] == 100
# Bounded page and per_page: lower bound (page 0 -> 1, per_page -5 -> 1)
await search_endpoint_catalog("ep-fl", req, q="deepseek-low", page=0, per_page=-5)
params_lower = mock_async_get.call_args[1]["params"]
assert params_lower["page"] == 1
assert params_lower["per_page"] == 1
@pytest.mark.asyncio
async def test_featherless_catalog_pagination_defensive_behavior(monkeypatch):
"""Tests defensive pagination: total/count metadata vs data-only fallback."""
req = MagicMock()
ep_mock = SimpleNamespace(
id="ep-fl",
base_url="https://api.featherless.ai/v1",
api_key="sk-test-key",
)
with _featherless_search_cache_lock:
_featherless_search_cache.clear()
monkeypatch.setattr(model_routes, "require_admin", lambda r: None)
monkeypatch.setattr(model_routes, "_chatgpt_endpoint_visible", lambda ep, req: True)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDb(ep_mock))
mock_resp = MagicMock()
mock_resp.status_code = 200
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp) as mock_async_get:
# Case 1: Response with total metadata (page 1 * 50 = 50 < 120 => has_more=True)
mock_resp.json.return_value = {
"data": [{"id": f"model-{i}"} for i in range(50)],
"total": 120,
}
res1 = await search_endpoint_catalog("ep-fl", req, q="query1", page=1, per_page=50)
assert res1["has_more"] is True
assert res1["total"] == 120
assert len(res1["items"]) == 50
# Case 2: Response with total metadata reached (page 1 * 50 = 50 >= 50 => has_more=False)
mock_resp.json.return_value = {
"data": [{"id": f"model-{i}"} for i in range(50)],
"total": 50,
}
res2 = await search_endpoint_catalog("ep-fl", req, q="query2", page=1, per_page=50)
assert res2["has_more"] is False
assert res2["total"] == 50
# Case 3: Response with count metadata reached (page 2 * 50 = 100 >= 80 => has_more=False)
mock_resp.json.return_value = {
"data": [{"id": f"model-{i}"} for i in range(30)],
"count": 80,
}
res3 = await search_endpoint_catalog("ep-fl", req, q="query3", page=2, per_page=50)
assert res3["has_more"] is False
assert res3["total"] == 80
# Case 4: Response containing ONLY {"data": [...]} with exactly per_page items => conservative has_more=True
mock_resp.json.return_value = {
"data": [{"id": f"model-{i}"} for i in range(50)],
}
res4 = await search_endpoint_catalog("ep-fl", req, q="query4", page=1, per_page=50)
assert res4["has_more"] is True
assert "total" not in res4
assert len(res4["items"]) == 50
# Case 5: Response containing ONLY {"data": [...]} with fewer than per_page items => has_more=False
mock_resp.json.return_value = {
"data": [{"id": f"model-{i}"} for i in range(49)],
}
res5 = await search_endpoint_catalog("ep-fl", req, q="query5", page=1, per_page=50)
assert res5["has_more"] is False
assert "total" not in res5
assert len(res5["items"]) == 49
# Case 6: Response containing ONLY {"data": []} => has_more=False
mock_resp.json.return_value = {
"data": [],
}
res6 = await search_endpoint_catalog("ep-fl", req, q="query6", page=1, per_page=50)
assert res6["has_more"] is False
assert "total" not in res6
assert len(res6["items"]) == 0
@pytest.mark.asyncio
async def test_featherless_catalog_search_error_handling(monkeypatch):
req = MagicMock()
ep_mock = SimpleNamespace(
id="ep-fl",
base_url="https://api.featherless.ai/v1",
api_key="sk-test-key",
)
monkeypatch.setattr(model_routes, "require_admin", lambda r: None)
monkeypatch.setattr(model_routes, "_chatgpt_endpoint_visible", lambda ep, req: True)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDb(ep_mock))
# 401 Unauthorized
resp_401 = MagicMock()
resp_401.status_code = 401
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=resp_401):
with pytest.raises(HTTPException) as exc_401:
await search_endpoint_catalog("ep-fl", req, q="llama")
assert exc_401.value.status_code == 401
assert "API key invalid" in exc_401.value.detail
# 429 Rate Limit
resp_429 = MagicMock()
resp_429.status_code = 429
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=resp_429):
with pytest.raises(HTTPException) as exc_429:
await search_endpoint_catalog("ep-fl", req, q="llama")
assert exc_429.value.status_code == 429
assert "rate limit" in exc_429.value.detail.lower()
# 504 Timeout
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, side_effect=httpx.TimeoutException("Timeout")):
with pytest.raises(HTTPException) as exc_504:
await search_endpoint_catalog("ep-fl", req, q="llama")
assert exc_504.value.status_code == 504
# ============================================================
# 6. Frontend JS Tests (Node)
# ============================================================
@pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
class TestFeatherlessFrontend:
def test_featherless_js_panel_and_helpers(self):
js = f"""
import fs from 'node:fs';
import {{ isChatgptSubscriptionEndpoint }} from '{(_REPO / 'static' / 'js' / 'chatgptSubscriptionUsage.js').as_posix()}';
const source = fs.readFileSync('{_ADMIN_JS.as_posix()}', 'utf8');
const fnStart = source.indexOf('function shouldDisplayEndpointBaseUrl');
const fnEnd = source.indexOf('// ChatGPT per-endpoint usage panel', fnStart);
const fnCode = source.slice(fnStart, fnEnd);
const fns = new Function('isChatgptSubscriptionEndpoint', 'esc',
fnCode + '; return {{ shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, renderFeatherlessPanel }};'
)(isChatgptSubscriptionEndpoint, x => String(x));
const ep = {{
id: 'fl-1',
base_url: 'https://api.featherless.ai/v1',
provider: 'featherless',
pinned_models: ['mistralai/Mistral-7B-Instruct-v0.2']
}};
const isFl = fns.isFeatherlessEndpoint(ep);
const showUrl = fns.shouldDisplayEndpointBaseUrl(ep);
// Test renderFeatherlessPanel DOM construction
const mockPanel = {{
dataset: {{}},
innerHTML: '',
querySelector: function(sel) {{
if (sel === '.featherless-search-input') return {{ addEventListener: () => {{}}, value: '' }};
if (sel === '.featherless-enabled-list') return {{ innerHTML: '', querySelectorAll: () => [] }};
if (sel === '.featherless-enabled-count') return {{ textContent: '' }};
if (sel === '.featherless-results-list') return {{ innerHTML: '', querySelectorAll: () => [] }};
if (sel === '.featherless-pagination') return {{ style: {{}} }};
if (sel === '.featherless-load-more') return {{ addEventListener: () => {{}} }};
if (sel === '.featherless-spinner-host') return {{ style: {{}} }};
return null;
}},
querySelectorAll: function() {{ return []; }}
}};
const mockRow = {{
querySelector: function() {{ return {{ textContent: '' }}; }}
}};
fns.renderFeatherlessPanel(mockPanel, ep, mockRow);
console.log(JSON.stringify({{
isFl,
showUrl,
pickerMode: mockPanel.dataset.pickerMode,
hasHeader: mockPanel.innerHTML.includes('Featherless Catalog'),
hasSearchBar: mockPanel.innerHTML.includes('featherless-search-bar'),
hasEnabledSection: mockPanel.innerHTML.includes('featherless-enabled-section'),
hasResultsSection: mockPanel.innerHTML.includes('featherless-results-section'),
}}));
"""
proc = subprocess.run(
["node", "--input-type=module"],
input=js,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, proc.stderr
data = json.loads(proc.stdout.strip())
assert data["isFl"] is True
assert data["showUrl"] is False
assert data["pickerMode"] == "pinned"
assert data["hasHeader"] is True
assert data["hasSearchBar"] is True
assert data["hasEnabledSection"] is True
assert data["hasResultsSection"] is True
+4 -1
View File
@@ -40,8 +40,9 @@ class _FakeSessionManager:
return self.sessions[session_id]
def create_session(self, session_id=None, name=None, endpoint_url=None,
model=None, rag=False, owner=None):
model=None, rag=False, owner=None, endpoint_id=None):
self.created = _FakeSession(name=name, owner=owner)
self.created.endpoint_id = endpoint_id
return self.created
def save_sessions(self):
@@ -59,6 +60,7 @@ def test_fork_does_not_corrupt_source_message_metadata(monkeypatch):
monkeypatch.setattr(mod, "_verify_session_owner", lambda *a, **k: None)
source = _FakeSession(name="Original", owner="alice")
source.endpoint_id = "chatgpt-account-b"
source.history = [
ChatMessage("user", "hi", {"_db_id": "src-0"}),
ChatMessage("assistant", "yo", {"_db_id": "src-1"}),
@@ -78,6 +80,7 @@ def test_fork_does_not_corrupt_source_message_metadata(monkeypatch):
assert result["status"] == "ok"
assert result["kept"] == 2
assert sm.created.endpoint_id == "chatgpt-account-b"
# The forked session got its own metadata dicts...
new_session = sm.created
+170
View File
@@ -163,3 +163,173 @@ def test_chatgpt_subscription_clears_previously_persisted_bearer(monkeypatch):
)
finally:
db.close()
# ── Multi-account: exact endpoint → exact auth session ──────────────────────
def _seed_two_accounts(TestSessionLocal, owner="alice"):
import datetime as _dt
db = TestSessionLocal()
try:
older = _dt.datetime(2026, 1, 1)
newer = _dt.datetime(2026, 6, 1)
db.add(ModelEndpoint(
id="ep-a", name="ChatGPT · codex00", base_url=_CODEX_BASE, provider_auth_id="auth-a",
owner=owner, is_enabled=True, api_key=None, created_at=older, updated_at=older,
))
db.add(ModelEndpoint(
id="ep-b", name="ChatGPT · codex01", base_url=_CODEX_BASE, provider_auth_id="auth-b",
owner=owner, is_enabled=True, api_key=None, created_at=newer, updated_at=newer,
))
db.commit()
finally:
db.close()
def _patch_runtime(monkeypatch, seen):
def fake(ep, owner=None):
seen.append((ep.id, ep.provider_auth_id, owner))
return (_CODEX_BASE, f"bearer-for-{ep.provider_auth_id}")
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", fake)
def test_bound_session_uses_exactly_its_own_account_even_when_urls_match(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
db.add(DbSession(id="sess-b", name="chat", endpoint_url=_CODEX_BASE + "/responses",
model="gpt-5.5", owner="alice", headers={}, endpoint_id="ep-b"))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-b", endpoint_url=_CODEX_BASE + "/responses", model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-b")
chat_helpers.resolve_session_auth(sess, "sess-b", owner="alice")
# Account B (the newer row) is used because the session is bound to ep-b,
# even though ep-a shares the same URL and would otherwise sort first.
assert seen == [("ep-b", "auth-b", "alice")]
assert sess.headers["Authorization"] == "Bearer bearer-for-auth-b"
db = TestSessionLocal()
try:
row = db.query(DbSession).filter(DbSession.id == "sess-b").first()
assert row.endpoint_id == "ep-b"
assert not any(k.lower() == "authorization" for k in (row.headers or {}))
finally:
db.close()
def test_legacy_unbound_session_picks_oldest_account_and_gets_bound(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
db.add(DbSession(id="sess-legacy", name="chat", endpoint_url=_CODEX_BASE,
model="gpt-5.5", owner="alice", headers={}))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-legacy", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id=None)
chat_helpers.resolve_session_auth(sess, "sess-legacy", owner="alice")
# Deterministic: the oldest endpoint is the one that existed when the
# legacy session was created; the choice is persisted so it never drifts.
assert seen == [("ep-a", "auth-a", "alice")]
assert sess.endpoint_id == "ep-a"
db = TestSessionLocal()
try:
row = db.query(DbSession).filter(DbSession.id == "sess-legacy").first()
assert row.endpoint_id == "ep-a"
finally:
db.close()
# A second resolve honours the persisted binding (no re-derivation drift).
seen.clear()
sess2 = types.SimpleNamespace(id="sess-legacy", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-a")
chat_helpers.resolve_session_auth(sess2, "sess-legacy", owner="alice")
assert seen == [("ep-a", "auth-a", "alice")]
def test_bound_session_never_borrows_a_sibling_account_when_its_endpoint_is_gone(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
# Two more siblings share the URL, but the bound endpoint is disabled.
ep_b = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-b").first()
ep_b.is_enabled = False
db.add(ModelEndpoint(id="ep-c", name="ChatGPT · codex02", base_url=_CODEX_BASE, provider_auth_id="auth-c",
owner="alice", is_enabled=True, api_key=None))
db.add(DbSession(id="sess-b", name="chat", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-b"))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-b", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="alice", headers={}, endpoint_id="ep-b")
chat_helpers.resolve_session_auth(sess, "sess-b", owner="alice")
# Ambiguous → no silent account switch, no bearer resolved.
assert seen == []
assert not any(k.lower() == "authorization" for k in sess.headers)
def test_other_owner_cannot_resolve_through_alices_accounts(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
_seed_two_accounts(TestSessionLocal, owner="alice")
db = TestSessionLocal()
try:
db.add(DbSession(id="sess-m", name="chat", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="mallory", headers={}, endpoint_id="ep-a"))
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
sess = types.SimpleNamespace(id="sess-m", endpoint_url=_CODEX_BASE, model="gpt-5.5",
owner="mallory", headers={}, endpoint_id="ep-a")
chat_helpers.resolve_session_auth(sess, "sess-m", owner="mallory")
assert seen == []
assert sess.headers == {}
def test_route_descriptors_distinguish_same_model_on_two_accounts(monkeypatch):
"""Same base URL + same model on A and B are two routes, not duplicates."""
TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(endpoint_resolver, "SessionLocal", TestSessionLocal)
_seed_two_accounts(TestSessionLocal)
db = TestSessionLocal()
try:
import json as _json
for ep in db.query(ModelEndpoint).all():
ep.cached_models = _json.dumps(["gpt-5.5"])
db.commit()
finally:
db.close()
seen = []
_patch_runtime(monkeypatch, seen)
route_a = endpoint_resolver.resolve_endpoint_by_id("ep-a", "gpt-5.5", owner="alice", require_exact_model=True)
route_b = endpoint_resolver.resolve_endpoint_by_id("ep-b", "gpt-5.5", owner="alice", require_exact_model=True)
assert route_a is not None and route_b is not None
assert route_a[0] == route_b[0] and route_a[1] == route_b[1] # same URL + model
assert route_a[2]["Authorization"] != route_b[2]["Authorization"] # different accounts
assert route_a != route_b
desc_a = endpoint_resolver.resolve_route_descriptor_by_id("ep-a", route_a[0], "gpt-5.5", route_a[2], owner="alice")
desc_b = endpoint_resolver.resolve_route_descriptor_by_id("ep-b", route_b[0], "gpt-5.5", route_b[2], owner="alice")
assert desc_a["endpoint_id"] == "ep-a" and desc_a["endpoint_label"] == "ChatGPT · codex00"
assert desc_b["endpoint_id"] == "ep-b" and desc_b["endpoint_label"] == "ChatGPT · codex01"
# Provenance for B's route must not be attributed to A.
assert endpoint_resolver.resolve_route_descriptor_by_id("ep-a", route_b[0], "gpt-5.5", route_b[2], owner="alice") is None
for desc in (desc_a, desc_b):
assert "Authorization" not in str(desc) and "bearer-for" not in str(desc)