From 411559913c7d7db1a10225247a523c0a6a51ed5e Mon Sep 17 00:00:00 2001
From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Date: Tue, 22 Sep 2026 00:36:47 +0100
Subject: [PATCH] feat(ui): refine subscription model and usage controls
---
routes/chat_routes.py | 17 +
routes/history/history_routes.py | 62 +++-
routes/model_routes.py | 8 +-
routes/session_routes.py | 7 +
src/agent_loop.py | 6 +
src/chatgpt_subscription.py | 103 +++++-
src/llm_core.py | 23 +-
src/request_models.py | 1 +
static/index.html | 8 +
static/js/admin.js | 114 +++++-
static/js/chat.js | 6 +
static/js/chatgptSubscriptionUsage.js | 3 +-
static/js/modelPicker.js | 357 ++++++++++++++-----
static/style.css | 92 +++++
tests/test_chatgpt_reasoning_and_grouping.py | 226 ++++++++++++
tests/test_chatgpt_subscription_usage_js.py | 307 ++++++++++++++++
tests/test_chatgpt_zero_native_tools.py | 21 ++
17 files changed, 1258 insertions(+), 103 deletions(-)
create mode 100644 tests/test_chatgpt_reasoning_and_grouping.py
diff --git a/routes/chat_routes.py b/routes/chat_routes.py
index 33e8d6bd3..6294d3adb 100644
--- a/routes/chat_routes.py
+++ b/routes/chat_routes.py
@@ -2135,6 +2135,14 @@ 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,
@@ -2253,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(
@@ -2351,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, ""):
@@ -2620,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.
@@ -3925,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:
@@ -4368,6 +4384,7 @@ def setup_chat_routes(
exact_approval=exact_tool_approval,
client_runtime_context=client_runtime_context,
thinking_mode=thinking_mode,
+ reasoning_effort=reasoning_effort,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
diff --git a/routes/history/history_routes.py b/routes/history/history_routes.py
index 26d3dca48..64ccb3dea 100644
--- a/routes/history/history_routes.py
+++ b/routes/history/history_routes.py
@@ -1024,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()
@@ -1042,7 +1075,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
row.thinking_mode, row.temperature_override, row.max_tokens_override = mode, temperature, max_tokens
db.commit()
session.thinking_mode, session.temperature_override, session.max_tokens_override = mode, temperature, max_tokens
- return {"status": "success", "thinking_mode": mode, "temperature_override": temperature, "max_tokens_override": max_tokens}
+ resp_effort = mode[7:] if mode.startswith("effort:") else ("default" if mode in {"", "off"} else None)
+ return {
+ "status": "success",
+ "thinking_mode": mode,
+ "reasoning_effort": resp_effort,
+ "temperature_override": temperature,
+ "max_tokens_override": max_tokens,
+ }
finally:
db.close()
diff --git a/routes/model_routes.py b/routes/model_routes.py
index 069aeb322..82ec7261a 100644
--- a/routes/model_routes.py
+++ b/routes/model_routes.py
@@ -1663,7 +1663,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,
@@ -1677,6 +1682,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
diff --git a/routes/session_routes.py b/routes/session_routes.py
index 3714a8347..d6c1e8b42 100644
--- a/routes/session_routes.py
+++ b/routes/session_routes.py
@@ -705,6 +705,12 @@ def setup_session_routes(
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:
@@ -714,6 +720,7 @@ def setup_session_routes(
db_session.endpoint_url = endpoint_url
db_session.endpoint_id = session.endpoint_id
db_session.headers = session.headers or {}
+ db_session.thinking_mode = getattr(session, "thinking_mode", "off") or "off"
db_session.updated_at = utcnow_naive()
db.commit()
finally:
diff --git a/src/agent_loop.py b/src/agent_loop.py
index 0af073fbc..f503d7e78 100644
--- a/src/agent_loop.py
+++ b/src/agent_loop.py
@@ -20364,6 +20364,7 @@ async def stream_agent_loop(
force_textual_tool_transport: bool = False,
thinking_mode: Optional[str] = None,
suppress_skills: bool = False,
+ reasoning_effort: Optional[str] = None,
) -> AsyncGenerator[str, None]:
"""Streaming agent loop generator.
@@ -21475,6 +21476,8 @@ async def stream_agent_loop(
timeout=int(get_setting("agent_stream_timeout_seconds", 300) or 300),
session_id=session_id,
workload=workload,
+ thinking_mode=thinking_mode,
+ reasoning_effort=reasoning_effort,
fallback_statuses=fallback_statuses,
fallback_on_empty=fallback_on_empty,
candidate_request_factory=_direct_candidate_request,
@@ -25480,6 +25483,7 @@ async def stream_agent_loop(
else _requested_temperature
),
"thinking_mode": state.get("thinking_mode"),
+ "reasoning_effort": reasoning_effort,
},
}
@@ -26172,6 +26176,8 @@ async def stream_agent_loop(
timeout=agent_stream_timeout,
session_id=session_id,
workload=workload,
+ thinking_mode=thinking_mode,
+ reasoning_effort=reasoning_effort,
fallback_statuses=fallback_statuses,
fallback_on_empty=fallback_on_empty,
candidate_request_factory=_candidate_request,
diff --git a/src/chatgpt_subscription.py b/src/chatgpt_subscription.py
index 0ee313b3e..3a8cb682a 100644
--- a/src/chatgpt_subscription.py
+++ b/src/chatgpt_subscription.py
@@ -147,6 +147,99 @@ 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 []
@@ -169,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()
diff --git a/src/llm_core.py b/src/llm_core.py
index fa9cf045b..0d3abb09f 100644
--- a/src/llm_core.py
+++ b/src/llm_core.py
@@ -254,7 +254,8 @@ def _cache_header_identity(headers) -> str:
def _get_cache_key(url: str, model: str, messages: List[Dict],
temperature: float, max_tokens: int, headers=None,
- thinking_mode: Optional[str] = None) -> str:
+ thinking_mode: Optional[str] = None,
+ reasoning_effort: Optional[str] = None) -> str:
"""Generate a cache key partitioned by endpoint and credential identity."""
hashable_messages = []
for msg in messages:
@@ -268,6 +269,7 @@ def _get_cache_key(url: str, model: str, messages: List[Dict],
'temp': temperature,
'max_tokens': max_tokens,
'thinking_mode': _normalize_thinking_mode(thinking_mode),
+ 'reasoning_effort': str(reasoning_effort or "").strip().lower(),
# Never put credentials in a cache key or loggable cache payload. The
# digest only prevents responses from one configured account/route
# being returned under another route with the same URL and model.
@@ -1531,6 +1533,7 @@ def _build_chatgpt_responses_payload(
*,
stream: bool = False,
tools: Optional[List[Dict]] = None,
+ reasoning_effort: Optional[str] = None,
**_ignored,
) -> Dict:
"""Build the ChatGPT/Codex Responses request: model inference only.
@@ -1555,11 +1558,13 @@ 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.
+ 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",
+ "model", "instructions", "input", "stream", "store", "temperature", "reasoning",
})
@@ -2575,6 +2580,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)
@@ -2613,7 +2619,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:
@@ -2637,6 +2643,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():
@@ -2891,7 +2899,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(
@@ -2907,6 +2915,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
@@ -2916,6 +2925,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.
@@ -2958,7 +2968,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 = {
diff --git a/src/request_models.py b/src/request_models.py
index 5b2b5928e..dde1c59aa 100644
--- a/src/request_models.py
+++ b/src/request_models.py
@@ -13,6 +13,7 @@ class ChatRequest(BaseModel):
time_filter: Optional[str] = Field(default=None, description="Time filter for search")
preset_id: Optional[str] = Field(default=None, description="Preset identifier")
selected_endpoint_id: Optional[str] = Field(default=None, description="Selected model endpoint ID")
+ reasoning_effort: Optional[str] = Field(default=None, description="Reasoning effort override")
@field_validator('message')
@classmethod
diff --git a/static/index.html b/static/index.html
index 1b87eff7f..86ef35843 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1195,6 +1195,14 @@
+
+
+
+
+
diff --git a/static/js/admin.js b/static/js/admin.js
index e6098908f..e302acfd6 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -599,6 +599,50 @@ function _ensureEndpointBulkControls() {
_updateEndpointBulkControls();
}
+// 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');
@@ -659,6 +703,7 @@ async function loadEndpoints() {
? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)')
: '';
const isChatgptAccount = isChatgptSubscriptionEndpoint(ep);
+ const isUsageExpanded = isChatgptAccount && _isChatgptUsageExpanded(ep.id, ep.provider_auth_id);
return `
@@ -682,7 +727,14 @@ async function loadEndpoints() {
${hasModels ? '
' : ''}`}
- ${isChatgptAccount ? `
` : ''}
+ ${isChatgptAccount ? `
+
+
+
+
+
` : ''}
${esc(ep.base_url)}${category === 'local' ? `
` : ''}${keyLabel}
${hasModels ? `
` : ''}
`;
@@ -727,7 +779,61 @@ async function loadEndpoints() {
// auth id, so account A's refresh can never repaint account B.
queryAll('[data-adm-chatgpt-usage-host]').forEach(host => {
host.addEventListener('click', (e) => e.stopPropagation());
- _loadChatgptUsage(host, host.dataset.admChatgptUsageHost, host.dataset.chatgptEndpointId);
+ 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) => {
@@ -1069,7 +1175,9 @@ async function _loadChatgptUsage(container, authId, epId, { refresh = false } =
if (!payload || typeof payload !== 'object') payload = { available: false, reason: 'malformed', account: { auth_id: authId } };
const vm = buildChatgptUsageViewModel(payload);
vm.authId = authId;
- container.innerHTML = renderChatgptUsageCardHtml(vm, { esc, endpointId: epId });
+ 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();
diff --git a/static/js/chat.js b/static/js/chat.js
index cc261615b..ffab94526 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -2575,6 +2575,12 @@ import { invalidateSettings } from './appConfig.js';
if (presetsModule.getSelectedPreset()) {
fd.append('preset_id', presetsModule.getSelectedPreset());
}
+ try {
+ const effort = window.__odysseusGetReasoningEffort ? window.__odysseusGetReasoningEffort() : null;
+ if (effort) {
+ fd.append('reasoning_effort', effort);
+ }
+ } catch (_) {}
// Superseded during preflight (uploads, document saves): a newer send
diff --git a/static/js/chatgptSubscriptionUsage.js b/static/js/chatgptSubscriptionUsage.js
index f96b6bf08..326218fb7 100644
--- a/static/js/chatgptSubscriptionUsage.js
+++ b/static/js/chatgptSubscriptionUsage.js
@@ -192,10 +192,11 @@ export function renderUsageCardHtml(viewModel, options = {}) {
const vm = viewModel || {};
const authId = esc(vm.authId || '');
const endpointId = esc(options.endpointId || '');
+ const includeReconnect = options.includeReconnect !== false;
const buttons =
`
` +
`` +
- `` +
+ (includeReconnect ? `` : '') +
`
`;
if (!vm.available) {
return (
diff --git a/static/js/modelPicker.js b/static/js/modelPicker.js
index e76da0f40..937693bde 100644
--- a/static/js/modelPicker.js
+++ b/static/js/modelPicker.js
@@ -202,6 +202,7 @@ function _initModelPickerDropdown() {
const listEl = document.getElementById('model-picker-list');
const searchRow = menu ? menu.querySelector('.model-picker-search-row') : null;
const refreshBtn = document.getElementById('model-picker-refresh-btn');
+ _initReasoningEffort();
if (!wrap || !btn || !menu || !search || !listEl) return;
if (wrap.dataset.modelPickerBound === '1') return;
wrap.dataset.modelPickerBound = '1';
@@ -294,8 +295,10 @@ function _initModelPickerDropdown() {
// A registered route is a user choice, including local routes using
// identical weights with different harness profiles. Never collapse
// distinct endpoints just because their model IDs match.
- const seenKey = _pickerModelKey({ endpointId: item.endpoint_id,
- url: item.url, epName: item.endpoint_name, mid });
+ const isApiEndpoint = item.category && item.category !== 'local';
+ const seenKey = isApiEndpoint
+ ? `${item.endpoint_id || item.url || item.endpoint_name || 'api'}::${mid}`
+ : _pickerModelKey({ endpointId: item.endpoint_id, url: item.url, epName: item.endpoint_name, mid }); // const seenKey = _pickerModelKey(
if (seen.has(seenKey)) return;
seen.add(seenKey);
result.push({
@@ -306,6 +309,7 @@ function _initModelPickerDropdown() {
endpointId: item.endpoint_id,
epName: item.endpoint_name || '',
category: item.category || '',
+ modelsMetadata: item.models_metadata || {},
providerText: [
item.endpoint_name || '',
item.category || '',
@@ -402,18 +406,28 @@ function _initModelPickerDropdown() {
'bytedance-seed': 'bytedance', '~anthropic': 'anthropic',
'~google': 'google', '~moonshotai': 'moonshotai', '~openai': 'openai',
};
- function _providerDisplayName(slug) {
- return _PROVIDER_NAMES[slug] || slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ');
- }
+ const _endpointGroupNames = new Map();
function _providerGroupKey(m) {
- if (m && m.category && m.category !== 'local' && m.epName) {
+ if (!m) return 'other';
+ // Grouping must be keyed on endpoint_id, falling back to url or epName
+ const gid = m.endpointId || m.url || (m.category && m.category !== 'local' && m.epName ? m.epName : '');
+ if (gid) {
+ const gname = m.epName || (m.endpointId ? m.endpointId : (m.url || 'Other Models'));
+ _endpointGroupNames.set(gid, gname);
+ return `~endpoint:${gid}`;
+ }
+ if (m.category && m.category !== 'local' && m.epName) {
+ _endpointGroupNames.set(m.epName, m.epName);
return `~endpoint:${m.epName}`;
}
return _providerSlug((m && m.mid) || '');
}
- function _providerGroupName(key) {
- if (String(key || '').startsWith('~endpoint:')) return String(key).slice('~endpoint:'.length);
- return _providerDisplayName(key);
+ function _providerGroupName(provider) {
+ if (String(provider || '').startsWith('~endpoint:')) {
+ const raw = String(provider).slice('~endpoint:'.length);
+ return _endpointGroupNames.get(raw) || raw;
+ }
+ return _providerDisplayName(provider);
}
function _providerSlug(mid) {
const slash = mid.indexOf('/');
@@ -464,7 +478,7 @@ function _initModelPickerDropdown() {
empty.textContent = text;
listEl.appendChild(empty);
}
- function _addRow(m) {
+ function _addRow(m, { inGroup = false } = {}) {
const row = document.createElement('div');
row.className = 'model-switch-item';
if (m.stale) {
@@ -494,7 +508,8 @@ function _initModelPickerDropdown() {
const epSpan = document.createElement('span');
epSpan.className = 'model-switch-ep';
// Don't show endpoint name if it matches the model name (local self-hosted)
- const _epDisplay = m.epName && !m.display.toLowerCase().includes(m.epName.toLowerCase().split('/').pop()) ? m.epName : '';
+ // or if it's already rendered under its endpoint group header
+ const _epDisplay = (!inGroup && m.epName && !m.display.toLowerCase().includes(m.epName.toLowerCase().split('/').pop())) ? m.epName : '';
epSpan.textContent = _epDisplay;
row.appendChild(epSpan);
@@ -537,32 +552,83 @@ function _initModelPickerDropdown() {
listEl.appendChild(row);
}
- // ── Search mode: flat, filtered results across the whole catalog ──
+ function _renderGroup(provider, models, { isSearch = false } = {}) {
+ if (!models || !models.length) return;
+ const isCollapsed = !isSearch && _collapsedProviders.has(provider);
+ const header = document.createElement('div');
+ header.className = 'mp-provider-header';
+ header.innerHTML =
+ `
`;
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'mp-provider-name';
+ nameSpan.textContent = _providerGroupName(provider);
+ header.appendChild(nameSpan);
+ const countSpan = document.createElement('span');
+ countSpan.className = 'mp-provider-count';
+ countSpan.textContent = `${models.length} model${models.length === 1 ? '' : 's'}`;
+ header.appendChild(countSpan);
+
+ header.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (_collapsedProviders.has(provider)) {
+ _collapsedProviders.delete(provider);
+ _justExpandedProvider = provider;
+ } else {
+ _collapsedProviders.add(provider);
+ _justExpandedProvider = null;
+ }
+ _saveList('odysseus-model-collapsed', [..._collapsedProviders]);
+ const st = listEl.scrollTop;
+ _populate(search ? search.value : '');
+ listEl.scrollTop = st;
+ });
+ listEl.appendChild(header);
+
+ if (!isCollapsed) {
+ const group = document.createElement('div');
+ group.className = 'mp-provider-group' + (_justExpandedProvider === provider ? ' mp-just-expanded' : '');
+ models.forEach(m => {
+ _addRow(m, { inGroup: true });
+ // Move the just-appended row into the group container
+ group.appendChild(listEl.lastElementChild);
+ });
+ listEl.appendChild(group);
+ if (_justExpandedProvider === provider) _justExpandedProvider = null;
+ }
+ }
+
+ // ── Search mode: grouped, filtered results across the whole catalog ──
if (q) {
const matches = all.filter(m => {
- const provName = _providerDisplayName(_providerSlug(m.mid)).toLowerCase();
- return [m.mid, m.display, m.epName, m.providerText, provName]
+ const groupKey = _providerGroupKey(m);
+ const groupName = _providerGroupName(groupKey).toLowerCase();
+ return [m.mid, m.display, m.epName, m.providerText, groupName]
.filter(Boolean).join(' ').toLowerCase().includes(q);
});
- if (matches.length === 0) _addEmpty('No matching models');
- else matches.forEach(_addRow);
+ if (matches.length === 0) {
+ _addEmpty('No matching models');
+ } else {
+ const groups = new Map();
+ matches.forEach(m => {
+ const key = _providerGroupKey(m);
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(m);
+ });
+ const sorted = [...groups.keys()].sort((a, b) =>
+ _providerGroupName(a).localeCompare(_providerGroupName(b)));
+ sorted.forEach(provider => {
+ _renderGroup(provider, groups.get(provider), { isSearch: true });
+ });
+ }
return;
}
// ── Browse mode: Favorites (manual) + Recent (auto), with dedupe. ──
- // Rules:
- // 1. Never list the same model twice in the dropdown. Favorites
- // win over Recent (if you favorited it, that's where it
- // belongs — Recent shouldn't show it again as duplicate).
- // 2. Small catalogs (≤ BROWSE_ALL_LIMIT total) skip the Recent
- // section entirely — when there's only ~10 models, the whole
- // list fits below as "All models" and a separate Recent
- // section just duplicates rows.
const shown = new Set();
const favModels = favs.map(id => byKey.get(id) || byId.get(id)).filter(Boolean);
if (favModels.length) {
_addSection('Favorites');
- favModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
+ favModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m, { inGroup: false }); });
}
// Recent: only render when the catalog is big enough that surfacing
// a recency shortlist is actually useful, AND only models that
@@ -575,66 +641,24 @@ function _initModelPickerDropdown() {
.slice(0, RECENT_MAX);
if (recentModels.length) {
_addSection('Recent');
- recentModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
+ recentModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m, { inGroup: false }); });
}
}
- // Small catalogs: still list everything so users aren't forced to search.
- if (all.length <= BROWSE_ALL_LIMIT) {
- const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
- if (rest.length) {
- if (shown.size) _addSection('All models');
- rest.forEach(_addRow);
- }
- } else {
- // Large catalog: show provider groups with collapsible sections.
- const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
- const groups = new Map();
- rest.forEach(m => {
- const slug = _providerGroupKey(m);
- if (!groups.has(slug)) groups.set(slug, []);
- groups.get(slug).push(m);
- });
- const sorted = [...groups.keys()].sort((a, b) =>
- _providerGroupName(a).localeCompare(_providerGroupName(b)));
+ // Provider / endpoint groups with collapsible sections.
+ const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
+ const groups = new Map();
+ rest.forEach(m => {
+ const key = _providerGroupKey(m);
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(m);
+ });
+ const sorted = [...groups.keys()].sort((a, b) =>
+ _providerGroupName(a).localeCompare(_providerGroupName(b)));
- sorted.forEach(provider => {
- const models = groups.get(provider);
- const isCollapsed = _collapsedProviders.has(provider);
- const header = document.createElement('div');
- header.className = 'mp-provider-header';
- header.innerHTML =
- `
`
- + `
${_providerGroupName(provider)}`
- + `
${models.length}`;
- header.addEventListener('click', (e) => {
- e.stopPropagation();
- if (_collapsedProviders.has(provider)) {
- _collapsedProviders.delete(provider);
- _justExpandedProvider = provider;
- } else {
- _collapsedProviders.add(provider);
- _justExpandedProvider = null;
- }
- _saveList('odysseus-model-collapsed', [..._collapsedProviders]);
- const st = listEl.scrollTop;
- _populate('');
- listEl.scrollTop = st;
- });
- listEl.appendChild(header);
- if (!isCollapsed) {
- const group = document.createElement('div');
- group.className = 'mp-provider-group' + (_justExpandedProvider === provider ? ' mp-just-expanded' : '');
- models.forEach(m => {
- _addRow(m);
- // Move the just-appended row into the group container
- group.appendChild(listEl.lastElementChild);
- });
- listEl.appendChild(group);
- if (_justExpandedProvider === provider) _justExpandedProvider = null;
- }
- });
- }
+ sorted.forEach(provider => {
+ _renderGroup(provider, groups.get(provider), { isSearch: false });
+ });
}
async function _pick(m) {
@@ -964,4 +988,175 @@ export function updateModelPicker() {
} else {
label.textContent = displayName;
}
+ _updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint);
+}
+
+// ── Reasoning effort control for models supporting reasoning levels ──
+let _reasoningEffortBound = false;
+let _pendingReasoningEffort = null;
+
+export function getSelectedReasoningEffort() {
+ if (!_deps) return _pendingReasoningEffort;
+ const currentSessionId = _deps.getCurrentSessionId ? _deps.getCurrentSessionId() : null;
+ if (!currentSessionId) return _pendingReasoningEffort;
+ const sessions = _deps.getSessions ? _deps.getSessions() : [];
+ const s = sessions.find(x => x.id === currentSessionId);
+ const mode = s?.thinking_mode || '';
+ if (mode.startsWith('effort:')) {
+ return mode.slice('effort:'.length).trim().toLowerCase();
+ }
+ return null;
+}
+try { window.__odysseusGetReasoningEffort = getSelectedReasoningEffort; } catch (_) {}
+
+function _initReasoningEffort() {
+ if (_reasoningEffortBound) return;
+ const wrap = document.getElementById('reasoning-effort-wrap');
+ const btn = document.getElementById('reasoning-effort-btn');
+ const menu = document.getElementById('reasoning-effort-menu');
+ if (!wrap || !btn || !menu) return;
+ _reasoningEffortBound = true;
+
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const isHidden = menu.classList.contains('hidden');
+ if (isHidden) {
+ menu.classList.remove('hidden');
+ btn.setAttribute('aria-expanded', 'true');
+ } else {
+ menu.classList.add('hidden');
+ btn.setAttribute('aria-expanded', 'false');
+ }
+ });
+
+ document.addEventListener('click', (e) => {
+ if (!wrap.contains(e.target) && !menu.classList.contains('hidden')) {
+ menu.classList.add('hidden');
+ btn.setAttribute('aria-expanded', 'false');
+ }
+ });
+}
+
+function _findModelMetadata(modelId, selectedEndpoint) {
+ if (selectedEndpoint?.models_metadata?.[modelId]) {
+ return selectedEndpoint.models_metadata[modelId];
+ }
+ const routeItems = window.modelsModule?.getCachedItems?.() || [];
+ for (const ep of routeItems) {
+ if (ep.models_metadata && ep.models_metadata[modelId]) {
+ return ep.models_metadata[modelId];
+ }
+ }
+ return null;
+}
+
+async function _updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint) {
+ _initReasoningEffort();
+ const wrap = document.getElementById('reasoning-effort-wrap');
+ const btn = document.getElementById('reasoning-effort-btn');
+ const currentSpan = document.getElementById('reasoning-effort-current');
+ const menu = document.getElementById('reasoning-effort-menu');
+ if (!wrap || !btn || !currentSpan || !menu) return;
+
+ if (!modelId) {
+ wrap.style.display = 'none';
+ return;
+ }
+
+ const metadata = _findModelMetadata(modelId, selectedEndpoint);
+ const levels = metadata?.supported_reasoning_levels;
+ if (!Array.isArray(levels) || levels.length === 0) {
+ wrap.style.display = 'none';
+ return;
+ }
+
+ wrap.style.display = 'inline-flex';
+
+ const supportedEffortNames = levels.map(l => (typeof l === 'string' ? l : l.effort).toLowerCase());
+ let activeLevel = 'default';
+ const sessionMode = s?.thinking_mode || '';
+
+ if (s && s.id) {
+ if (sessionMode.startsWith('effort:')) {
+ const parsed = sessionMode.slice('effort:'.length).trim().toLowerCase();
+ if (supportedEffortNames.includes(parsed)) {
+ activeLevel = parsed;
+ } else {
+ activeLevel = 'default';
+ s.thinking_mode = 'off';
+ try {
+ fetch(`${API_BASE}/api/session/${encodeURIComponent(s.id)}/generation-settings`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ thinking_mode: 'off', reasoning_effort: null }),
+ }).catch(() => {});
+ } catch (_) {}
+ }
+ }
+ } else if (_pendingReasoningEffort) {
+ if (supportedEffortNames.includes(_pendingReasoningEffort)) {
+ activeLevel = _pendingReasoningEffort;
+ } else {
+ activeLevel = 'default';
+ _pendingReasoningEffort = null;
+ }
+ }
+
+ currentSpan.textContent = activeLevel === 'default' ? 'Default' : (activeLevel.charAt(0).toUpperCase() + activeLevel.slice(1));
+ btn.title = 'Reasoning effort';
+
+ menu.innerHTML = '';
+ const options = [{ effort: 'default', label: 'Default', desc: `Model default (${metadata.default_reasoning_level || 'standard'})` }];
+ for (const l of levels) {
+ const eff = (typeof l === 'string' ? l : l.effort).toLowerCase();
+ const desc = (typeof l === 'object' && l.description) ? l.description : '';
+ options.push({ effort: eff, label: eff.charAt(0).toUpperCase() + eff.slice(1), desc });
+ }
+
+ for (const opt of options) {
+ const optBtn = document.createElement('button');
+ optBtn.type = 'button';
+ optBtn.className = 'reasoning-effort-option' + (opt.effort === activeLevel ? ' active' : '');
+ optBtn.setAttribute('role', 'option');
+ optBtn.setAttribute('aria-selected', opt.effort === activeLevel ? 'true' : 'false');
+ if (opt.desc) optBtn.title = opt.desc;
+
+ const lbl = document.createElement('span');
+ lbl.textContent = opt.label;
+ optBtn.appendChild(lbl);
+
+ if (opt.effort === activeLevel) {
+ const check = document.createElement('span');
+ check.textContent = '✓';
+ check.style.fontSize = '11px';
+ optBtn.appendChild(check);
+ }
+
+ optBtn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ menu.classList.add('hidden');
+ btn.setAttribute('aria-expanded', 'false');
+ const newEffort = opt.effort;
+ const thinkingModeVal = newEffort === 'default' ? 'off' : `effort:${newEffort}`;
+ const effortVal = newEffort === 'default' ? null : newEffort;
+
+ if (s && s.id) {
+ s.thinking_mode = thinkingModeVal;
+ try {
+ await fetch(`${API_BASE}/api/session/${encodeURIComponent(s.id)}/generation-settings`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ thinking_mode: thinkingModeVal, reasoning_effort: effortVal }),
+ });
+ } catch (_) {}
+ } else {
+ _pendingReasoningEffort = effortVal;
+ }
+ _updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint);
+ });
+
+ menu.appendChild(optBtn);
+ }
}
diff --git a/static/style.css b/static/style.css
index 80cbe6654..14f77835f 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2788,6 +2788,86 @@ body.bg-pattern-ascii-fireflies {
align-items: center;
flex-shrink: 0;
}
+ /* Reasoning effort pill / dropdown in composer */
+ .reasoning-effort-wrap {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ }
+ .reasoning-effort-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 28px;
+ padding: 0 8px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: color-mix(in srgb, var(--fg) 4%, transparent);
+ color: var(--fg);
+ font-size: 11.5px;
+ font-weight: 500;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.15s, border-color 0.15s;
+ }
+ .reasoning-effort-btn:hover {
+ background: color-mix(in srgb, var(--fg) 8%, transparent);
+ border-color: color-mix(in srgb, var(--fg) 20%, transparent);
+ }
+ .reasoning-effort-chevron {
+ opacity: 0.5;
+ transition: transform 0.15s;
+ flex-shrink: 0;
+ }
+ .reasoning-effort-btn[aria-expanded="true"] .reasoning-effort-chevron {
+ transform: rotate(180deg);
+ }
+ .reasoning-effort-menu {
+ position: absolute;
+ bottom: calc(100% + 6px);
+ right: 0;
+ min-width: 140px;
+ background: var(--bg-surface, var(--bg));
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
+ padding: 4px;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+ .reasoning-effort-menu.hidden {
+ display: none !important;
+ }
+ .reasoning-effort-option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 10px;
+ border-radius: 5px;
+ font-size: 12px;
+ color: var(--fg);
+ cursor: pointer;
+ background: transparent;
+ border: none;
+ text-align: left;
+ width: 100%;
+ transition: background 0.12s;
+ }
+ .reasoning-effort-option:hover {
+ background: color-mix(in srgb, var(--fg) 8%, transparent);
+ }
+ .reasoning-effort-option.active {
+ color: var(--accent);
+ font-weight: 600;
+ }
+ @container chatbar (max-width: 480px) {
+ .reasoning-effort-prefix { display: none; }
+ }
+ @media (max-width: 480px) {
+ .reasoning-effort-prefix { display: none; }
+ }
.input-icon-btn {
background: none;
border: none;
@@ -49549,6 +49629,18 @@ body.theme-frosted .modal {
color: color-mix(in srgb, var(--fg) 45%, transparent);
}
/* ChatGPT Subscription per-account usage card (Settings → Added Models) */
+.adm-chatgpt-controls {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ margin: 4px 0 4px 22px;
+}
+.adm-chatgpt-usage-chevron {
+ font-size: 10px;
+ line-height: 1;
+ display: inline-block;
+ margin-left: 2px;
+}
.adm-chatgpt-usage-host {
margin: 4px 0 6px 22px;
}
diff --git a/tests/test_chatgpt_reasoning_and_grouping.py b/tests/test_chatgpt_reasoning_and_grouping.py
new file mode 100644
index 000000000..9c36ad474
--- /dev/null
+++ b/tests/test_chatgpt_reasoning_and_grouping.py
@@ -0,0 +1,226 @@
+"""Tests for Model Picker Endpoint Grouping and ChatGPT Reasoning Effort Control.
+
+Covers:
+1. Endpoint/account grouping in modelPicker.js (keyed on endpoint_id, distinct routes, collapsible headers, search auto-expansion, keyboard nav).
+2. ChatGPT reasoning effort (validation, catalog metadata, session thinking_mode persistence, payload shaping with zero provider tools).
+3. Composer UI elements and styles.
+"""
+
+import json
+from pathlib import Path
+import subprocess
+import pytest
+
+from src import chatgpt_subscription, llm_core
+
+ROOT = Path(__file__).parents[1]
+
+
+# ============================================================
+# PART 1: FRONTEND MODEL PICKER GROUPING & UI
+# ============================================================
+
+def test_model_picker_endpoint_grouping_logic_in_node():
+ """Verify in node that modelPicker groups models by endpoint_id and maintains distinct routes."""
+ source = (ROOT / "static/js/modelPicker.js").read_text(encoding="utf-8")
+ key_start = source.index("function _pickerModelKey(m)")
+ key_end = source.index("// ── Shared keyboard nav")
+ key_helper = source[key_start:key_end]
+
+ group_start = source.index("const _endpointGroupNames =")
+ group_end = source.index("const _collapsedProviders =")
+ group_snippet = source[group_start:group_end]
+
+ test_js = f"""
+ const _PROVIDER_NAMES = {{}};
+ const _PROVIDER_ALIAS = {{}};
+ {key_helper}
+ {group_snippet}
+
+ const codex00_model = {{
+ mid: 'gpt-5.5',
+ display: 'gpt-5.5',
+ endpointId: 'chatgpt-codex00',
+ epName: 'ChatGPT · codex00',
+ category: 'chatgpt_subscription',
+ url: 'https://chatgpt.com/backend-api/codex'
+ }};
+
+ const codex01_model = {{
+ mid: 'gpt-5.5',
+ display: 'gpt-5.5',
+ endpointId: 'chatgpt-codex01',
+ epName: 'ChatGPT · codex01',
+ category: 'chatgpt_subscription',
+ url: 'https://chatgpt.com/backend-api/codex'
+ }};
+
+ const k0 = _pickerModelKey(codex00_model);
+ const k1 = _pickerModelKey(codex01_model);
+ if (k0 === k1) throw new Error('Same model across accounts must have distinct route keys');
+
+ const g0 = _providerGroupKey(codex00_model);
+ const g1 = _providerGroupKey(codex01_model);
+ if (g0 === g1) throw new Error('Groups must be separate per account endpoint');
+
+ const name0 = _providerGroupName(g0);
+ const name1 = _providerGroupName(g1);
+ if (name0 !== 'ChatGPT · codex00') throw new Error('Unexpected group name for codex00: ' + name0);
+ if (name1 !== 'ChatGPT · codex01') throw new Error('Unexpected group name for codex01: ' + name1);
+
+ console.log(JSON.stringify({{ k0, k1, g0, g1, name0, name1 }}));
+ """
+ proc = subprocess.run(["node", "-e", test_js], check=True, capture_output=True, text=True)
+ res = json.loads(proc.stdout)
+ assert res["k0"] != res["k1"]
+ assert res["g0"] != res["g1"]
+ assert res["name0"] == "ChatGPT · codex00"
+ assert res["name1"] == "ChatGPT · codex01"
+
+
+def test_model_picker_source_invariants():
+ """Verify modelPicker.js contains required UI grouping structures."""
+ src = (ROOT / "static/js/modelPicker.js").read_text(encoding="utf-8")
+ # Grouping keyed on endpoint_id
+ assert "m.endpointId || m.url" in src
+ # Header class and chevron
+ assert "mp-provider-header" in src
+ assert "mp-provider-chevron" in src
+ assert "mp-provider-name" in src
+ assert "mp-provider-count" in src
+ # Group container
+ assert "mp-provider-group" in src
+ # Search mode groups matches
+ assert "isSearch" in src
+ # TextContent used to prevent XSS
+ assert "nameSpan.textContent = _providerGroupName(provider)" in src
+ # Collapsed persistence key
+ assert "odysseus-model-collapsed" in src
+
+
+def test_composer_reasoning_effort_ui_markup():
+ """Verify static/index.html and static/style.css include reasoning effort controls."""
+ html = (ROOT / "static/index.html").read_text(encoding="utf-8")
+ css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ # HTML elements
+ assert 'id="reasoning-effort-wrap"' in html
+ assert 'id="reasoning-effort-btn"' in html
+ assert 'id="reasoning-effort-current"' in html
+ assert 'id="reasoning-effort-menu"' in html
+ assert 'title="Reasoning effort"' in html
+ assert 'class="reasoning-effort-prefix">Effort: ' in html
+ # CSS classes
+ assert ".reasoning-effort-wrap" in css
+ assert ".reasoning-effort-btn" in css
+ assert ".reasoning-effort-menu" in css
+ assert ".reasoning-effort-option" in css
+ # Responsive hide of prefix
+ assert ".reasoning-effort-prefix { display: none; }" in css
+
+
+def test_chat_submit_includes_reasoning_effort():
+ """Verify static/js/chat.js sends reasoning_effort when active."""
+ src = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
+ assert "window.__odysseusGetReasoningEffort" in src
+ assert "fd.append('reasoning_effort', effort)" in src
+
+
+# ============================================================
+# PART 2: BACKEND REASONING EFFORT CATALOG & VALIDATION
+# ============================================================
+
+def test_chatgpt_reasoning_effort_validation():
+ """Test validate_reasoning_effort against catalog definitions."""
+ # gpt-6-astra supports low, medium, high, xhigh, max, ultra
+ assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "high") == "high"
+ assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "LOW") == "low"
+ assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "medium") == "medium"
+ assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "xhigh") == "xhigh"
+ assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "unsupported_level") is None
+ assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "default") is None
+ assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", None) is None
+
+ # Model metadata catalog
+ meta_astra = chatgpt_subscription.get_chatgpt_model_metadata("gpt-6-astra")
+ assert meta_astra["default_reasoning_level"] == "low"
+ levels = meta_astra["supported_reasoning_levels"]
+ assert "low" in levels
+ assert "medium" in levels
+ assert "high" in levels
+
+ meta_55 = chatgpt_subscription.get_chatgpt_model_metadata("gpt-5.5")
+ assert meta_55["default_reasoning_level"] == "medium"
+
+
+def test_models_metadata_attached_for_chatgpt_catalog():
+ """Verify get_chatgpt_model_metadata populates metadata for ChatGPT models."""
+ from src.chatgpt_subscription import get_chatgpt_model_metadata
+ models = ["gpt-6-astra", "gpt-5.5", "gpt-5.6-sol", "llama3"]
+ models_metadata = {}
+ for mid in models:
+ meta = get_chatgpt_model_metadata(mid)
+ if meta:
+ models_metadata[mid] = meta
+ assert "gpt-6-astra" in models_metadata
+ assert "gpt-5.5" in models_metadata
+ assert "gpt-5.6-sol" in models_metadata
+ assert "llama3" not in models_metadata
+ assert models_metadata["gpt-6-astra"]["default_reasoning_level"] == "low"
+ assert "high" in models_metadata["gpt-6-astra"]["supported_reasoning_levels"]
+
+
+def test_model_routes_contains_metadata_population():
+ """Verify routes/model_routes.py populates models_metadata."""
+ src = (ROOT / "routes/model_routes.py").read_text(encoding="utf-8")
+ assert "from src.chatgpt_subscription import get_chatgpt_model_metadata" in src
+ assert "models_metadata[mid] = meta" in src
+ assert '"models_metadata": models_metadata' in src
+
+
+def test_history_and_chat_routes_support_reasoning_effort():
+ """Verify history and chat routes support reasoning_effort and thinking_mode persistence."""
+ hist_src = (ROOT / "routes/history/history_routes.py").read_text(encoding="utf-8")
+ assert "reasoning_effort" in hist_src
+ assert 'mode = f"effort:{clean_effort}"' in hist_src
+
+ chat_src = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
+ assert "validate_reasoning_effort(sess.model, reasoning_effort)" in chat_src
+ assert "session_mode.startswith(\"effort:\")" in chat_src
+
+ sess_src = (ROOT / "routes/session_routes.py").read_text(encoding="utf-8")
+ assert "session.thinking_mode = \"off\"" in sess_src
+
+
+def test_responses_payload_zero_tools_with_reasoning():
+ """Verify that reasoning effort does NOT permit any native tool surfaces."""
+ forbidden_tools = [
+ {"type": "function", "function": {"name": "test"}},
+ {"type": "web_search_preview"},
+ ]
+ payload = llm_core._build_chatgpt_responses_payload(
+ model="gpt-6-astra",
+ messages=[{"role": "user", "content": "hello"}],
+ temperature=0.7,
+ max_tokens=4096,
+ stream=True,
+ reasoning_effort="high",
+ tools=forbidden_tools,
+ )
+ assert payload["reasoning"] == {"effort": "high"}
+ assert "tools" not in payload
+ assert "tool_choice" not in payload
+ for k in llm_core.CHATGPT_FORBIDDEN_PAYLOAD_KEYS:
+ assert k not in payload
+ assert set(payload.keys()) <= llm_core.CHATGPT_ALLOWED_PAYLOAD_KEYS
+
+
+def test_session_model_change_revalidates_effort_logic():
+ """Switching to an unsupported model resets effort to off."""
+ meta_astra = chatgpt_subscription.get_chatgpt_model_metadata("gpt-6-astra")
+ supported = [lvl.lower() for lvl in meta_astra.get("supported_reasoning_levels", [])]
+
+ current_effort = "high"
+ assert current_effort in supported # retained for gpt-6-astra
+
+ meta_local = chatgpt_subscription.get_chatgpt_model_metadata("llama3.2")
+ assert meta_local is None # unsupported -> resets to off
diff --git a/tests/test_chatgpt_subscription_usage_js.py b/tests/test_chatgpt_subscription_usage_js.py
index 8c4141eb7..401cef6de 100644
--- a/tests/test_chatgpt_subscription_usage_js.py
+++ b/tests/test_chatgpt_subscription_usage_js.py
@@ -265,3 +265,310 @@ def test_refresh_and_reconnect_handlers_target_only_the_clicked_account():
]
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
diff --git a/tests/test_chatgpt_zero_native_tools.py b/tests/test_chatgpt_zero_native_tools.py
index 6ce0d9df1..8715ab8a0 100644
--- a/tests/test_chatgpt_zero_native_tools.py
+++ b/tests/test_chatgpt_zero_native_tools.py
@@ -70,6 +70,27 @@ def test_strip_helper_removes_any_native_tool_key_added_later():
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