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 ? `
Loading usage...
` : ''} + ${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