mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
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:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// DOM-free view-model + renderer for per-account ChatGPT Subscription usage.
|
||||
//
|
||||
// The backend (/api/chatgpt-subscription/accounts/{auth_id}/usage) returns a
|
||||
// normalized, credential-free payload. This module turns it into something the
|
||||
// Settings "Added Models" card can render, defensively: unknown buckets are
|
||||
// kept, missing windows are tolerated and nothing (reset times, percentages)
|
||||
// is ever invented.
|
||||
|
||||
function _num(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _clampPercent(value) {
|
||||
const n = _num(value);
|
||||
if (n === null) return null;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
function _defaultEsc(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function formatDuration(totalSeconds) {
|
||||
const secs = _num(totalSeconds);
|
||||
if (secs === null) return '';
|
||||
if (secs <= 0) return 'now';
|
||||
const days = Math.floor(secs / 86400);
|
||||
const hours = Math.floor((secs % 86400) / 3600);
|
||||
const minutes = Math.floor((secs % 3600) / 60);
|
||||
if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
||||
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
export function formatResetIn(resetsAt, nowSeconds) {
|
||||
const at = _num(resetsAt);
|
||||
if (at === null || at <= 0) return '';
|
||||
const now = _num(nowSeconds);
|
||||
if (now === null) return '';
|
||||
const delta = at - now;
|
||||
if (delta <= 0) return 'resets now';
|
||||
return `resets in ${formatDuration(delta)}`;
|
||||
}
|
||||
|
||||
export function formatPercent(value) {
|
||||
const n = _clampPercent(value);
|
||||
if (n === null) return '';
|
||||
const rounded = Math.round(n);
|
||||
return `${rounded}%`;
|
||||
}
|
||||
|
||||
export function planDisplayName(planType) {
|
||||
if (typeof planType !== 'string' || !planType.trim()) return '';
|
||||
const key = planType.trim().toLowerCase();
|
||||
const known = {
|
||||
free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro', prolite: 'Pro Lite', team: 'Team',
|
||||
business: 'Business', enterprise: 'Enterprise', edu: 'Edu', education: 'Education',
|
||||
guest: 'Guest',
|
||||
};
|
||||
if (known[key]) return known[key];
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function _windowViewModel(win, nowSeconds) {
|
||||
if (!win || typeof win !== 'object') return null;
|
||||
const used = _clampPercent(win.used_percent);
|
||||
const remaining = used === null ? _clampPercent(win.remaining_percent) : Math.round((100 - used) * 100) / 100;
|
||||
const windowMinutes = _num(win.window_minutes);
|
||||
const resetsAt = _num(win.resets_at);
|
||||
return {
|
||||
kind: typeof win.kind === 'string' ? win.kind : '',
|
||||
name: typeof win.name === 'string' && win.name ? win.name : 'LIMIT',
|
||||
usedPercent: used,
|
||||
remainingPercent: remaining,
|
||||
usedLabel: used === null ? 'usage unknown' : `${formatPercent(used)} used`,
|
||||
remainingLabel: remaining === null ? '' : `${formatPercent(remaining)} remaining`,
|
||||
windowMinutes,
|
||||
resetsAt,
|
||||
resetLabel: formatResetIn(resetsAt, nowSeconds),
|
||||
exhausted: used !== null && used >= 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a render-ready view model from the backend usage payload.
|
||||
*
|
||||
* `payload` is the JSON body of the usage route. `nowSeconds` is injected so
|
||||
* reset countdowns are deterministic in tests.
|
||||
*/
|
||||
export function buildUsageViewModel(payload, nowSeconds = Math.floor(Date.now() / 1000)) {
|
||||
const account = (payload && typeof payload.account === 'object' && payload.account) || {};
|
||||
const base = {
|
||||
authId: typeof account.auth_id === 'string' ? account.auth_id : '',
|
||||
label: typeof account.label === 'string' ? account.label : '',
|
||||
name: typeof account.name === 'string' ? account.name : '',
|
||||
};
|
||||
if (!payload || typeof payload !== 'object' || payload.available !== true || !payload.usage || typeof payload.usage !== 'object') {
|
||||
const reason = payload && typeof payload.reason === 'string' ? payload.reason : 'unavailable';
|
||||
const messages = {
|
||||
reauth: 'Usage unavailable — account may need reconnecting',
|
||||
rate_limited: 'Usage temporarily unavailable (rate limited)',
|
||||
timeout: 'Usage unavailable (timed out)',
|
||||
network: 'Usage unavailable (network)',
|
||||
malformed: 'Usage unavailable (unexpected response)',
|
||||
upstream: 'Usage unavailable',
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
available: false,
|
||||
reason,
|
||||
reconnectSuggested: !!(payload && payload.reconnect_suggested),
|
||||
message: messages[reason] || 'Usage unavailable',
|
||||
plan: '',
|
||||
limits: [],
|
||||
};
|
||||
}
|
||||
const usage = payload.usage;
|
||||
const limits = [];
|
||||
const rawLimits = Array.isArray(usage.limits) ? usage.limits : [];
|
||||
rawLimits.forEach((limit) => {
|
||||
if (!limit || typeof limit !== 'object') return;
|
||||
const windows = (Array.isArray(limit.windows) ? limit.windows : [])
|
||||
.map((w) => _windowViewModel(w, nowSeconds))
|
||||
.filter(Boolean);
|
||||
const limitId = typeof limit.limit_id === 'string' ? limit.limit_id : '';
|
||||
const limitName = typeof limit.limit_name === 'string' ? limit.limit_name : '';
|
||||
limits.push({
|
||||
limitId,
|
||||
title: limitId === 'codex' ? '' : (limitName || limitId || 'Additional limit'),
|
||||
modelSlug: typeof limit.normal_model_slug === 'string' ? limit.normal_model_slug : '',
|
||||
limitReached: limit.limit_reached === true,
|
||||
windows,
|
||||
});
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
available: true,
|
||||
reason: '',
|
||||
reconnectSuggested: false,
|
||||
message: '',
|
||||
plan: planDisplayName(usage.plan_type),
|
||||
ordinaryUsageAllowed: typeof usage.ordinary_usage_allowed === 'boolean' ? usage.ordinary_usage_allowed : null,
|
||||
rateLimitReachedType: typeof usage.rate_limit_reached_type === 'string' ? usage.rate_limit_reached_type : '',
|
||||
cached: payload.usage.cached === true,
|
||||
fetchedAt: _num(usage.fetched_at),
|
||||
limits,
|
||||
hasWindows: limits.some((l) => l.windows.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
function _barHtml(win, esc) {
|
||||
const used = win.usedPercent === null ? 0 : win.usedPercent;
|
||||
const tone = win.usedPercent === null ? 'unknown' : (used >= 90 ? 'critical' : (used >= 70 ? 'warn' : 'ok'));
|
||||
return (
|
||||
`<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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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 "<5H>" 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 <limit>" 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user