diff --git a/core/database.py b/core/database.py index a9ad90b8b..d08c90766 100644 --- a/core/database.py +++ b/core/database.py @@ -220,6 +220,8 @@ class Session(TimestampMixin, Base): total_input_tokens = Column(Integer, default=0) total_output_tokens = Column(Integer, default=0) mode = Column(String, nullable=True) # 'agent', 'chat', or 'research' + reasoning_effort = Column(String, nullable=True) + verbosity = Column(String, nullable=True) crew_member_id = Column(String, nullable=True) # links to crew_members.id # Relationship to chat messages @@ -248,6 +250,8 @@ class Session(TimestampMixin, Base): 'folder': self.folder, 'total_input_tokens': self.total_input_tokens or 0, 'total_output_tokens': self.total_output_tokens or 0, + 'reasoning_effort': self.reasoning_effort, + 'verbosity': self.verbosity, 'crew_member_id': self.crew_member_id, } @@ -1172,6 +1176,35 @@ def _migrate_add_mode_column(): except Exception: pass +def _migrate_add_session_model_control_columns(): + """Add per-session model control columns if missing.""" + import sqlite3 + db_path = DATABASE_URL.replace("sqlite:///", "") + if not os.path.exists(db_path): + return + conn = None + try: + conn = sqlite3.connect(db_path) + cursor = conn.execute("PRAGMA table_info(sessions)") + columns = [row[1] for row in cursor.fetchall()] + changed = False + if "reasoning_effort" not in columns: + conn.execute("ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT") + changed = True + if "verbosity" not in columns: + conn.execute("ALTER TABLE sessions ADD COLUMN verbosity TEXT") + changed = True + if changed: + conn.commit() + logging.getLogger(__name__).info("Migrated: added session model control columns") + except Exception as e: + logging.getLogger(__name__).warning(f"session model control migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass + def _migrate_add_folder_column(): """Add folder column to sessions table if it doesn't exist.""" import sqlite3 @@ -1942,6 +1975,7 @@ def init_db(): _migrate_add_folder_column() _migrate_add_token_columns() _migrate_add_mode_column() + _migrate_add_session_model_control_columns() _migrate_add_multiuser_owner_columns() _migrate_add_gallery_caption_column() _migrate_add_api_token_scopes_column() diff --git a/core/models.py b/core/models.py index 56f05dc4e..090f5e0da 100644 --- a/core/models.py +++ b/core/models.py @@ -74,6 +74,8 @@ class Session: owner: Optional[str] = None is_important: bool = False message_count: int = 0 + reasoning_effort: Optional[str] = None + verbosity: Optional[str] = None def __post_init__(self): if self.headers is None: diff --git a/core/session_manager.py b/core/session_manager.py index 6eb493e95..4ce0871f0 100644 --- a/core/session_manager.py +++ b/core/session_manager.py @@ -134,6 +134,8 @@ class SessionManager: history=[], owner=getattr(db_session, "owner", None), is_important=getattr(db_session, "is_important", False) or False, + reasoning_effort=getattr(db_session, "reasoning_effort", None), + verbosity=getattr(db_session, "verbosity", None), ) session.message_count = getattr(db_session, "message_count", 0) or 0 return session @@ -192,6 +194,8 @@ class SessionManager: history=history, owner=getattr(db_session, 'owner', None), is_important=getattr(db_session, 'is_important', False) or False, + reasoning_effort=getattr(db_session, 'reasoning_effort', None), + verbosity=getattr(db_session, 'verbosity', None), ) session.message_count = getattr(db_session, 'message_count', len(history)) @@ -500,7 +504,9 @@ class SessionManager: endpoint_url: str, model: str, rag: bool = False, - owner: str = None + owner: str = None, + reasoning_effort: str = None, + verbosity: str = None, ) -> Session: """Create a new session and save to database.""" db = SessionLocal() @@ -513,6 +519,8 @@ class SessionManager: rag=rag, headers={}, owner=owner, + reasoning_effort=reasoning_effort, + verbosity=verbosity, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc) ) @@ -527,6 +535,8 @@ class SessionManager: rag=rag, headers={}, owner=owner, + reasoning_effort=reasoning_effort, + verbosity=verbosity, ) self.sessions[session_id] = session diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 5c7a4e04a..c8db2dbaf 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -656,6 +656,10 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: "agent_max_rounds": (1, 200), "agent_max_tool_calls": (0, 1000), # 0 = unlimited } + _CHOICE_VALUES = { + "default_reasoning_effort": {"", "auto", "off", "on", "none", "minimal", "low", "medium", "high", "xhigh"}, + "default_verbosity": {"", "auto", "low", "medium", "high"}, + } for key in DEFAULT_SETTINGS: if key not in body: continue @@ -667,6 +671,14 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: except (TypeError, ValueError): raise HTTPException(400, f"{key} must be an integer") val = max(lo, min(val, hi)) + if key in _CHOICE_VALUES: + val = str(val or "").strip().lower().replace("-", "_") + if val == "x_high": + val = "xhigh" + if val in ("auto", "default"): + val = "" + if val not in _CHOICE_VALUES[key]: + raise HTTPException(400, f"Unsupported value for {key}") current[key] = val _save_settings(current) return current diff --git a/routes/chat_routes.py b/routes/chat_routes.py index b081d5f1c..b7afa0445 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -734,6 +734,21 @@ def setup_chat_routes( incognito = str(form_data.get("incognito", "")).lower() == "true" plan_mode = str(form_data.get("plan_mode") or (body or {}).get("plan_mode") or "").lower() == "true" chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent' + reasoning_effort = (form_data.get("reasoning_effort") or "").strip() + verbosity = (form_data.get("verbosity") or "").strip() + + def _stamp_requested_model_controls(metrics): + data = dict(metrics or {}) + requested_reasoning = reasoning_effort.lower().replace("-", "_") + requested_verbosity = verbosity.lower() + if requested_reasoning and requested_reasoning not in ("auto", "default"): + if requested_reasoning == "x_high": + requested_reasoning = "xhigh" + data["requested_reasoning_effort"] = requested_reasoning + if requested_verbosity and requested_verbosity not in ("auto", "default"): + data["requested_verbosity"] = requested_verbosity + return data + # Workspace: confine the agent's file/shell tools to this folder. workspace, workspace_rejected = _resolve_request_workspace( request, form_data.get("workspace") @@ -1536,6 +1551,8 @@ def setup_chat_routes( prompt_type=preset_id, tools=None, session_id=session, + reasoning_effort=reasoning_effort, + verbosity=verbosity, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: @@ -1588,6 +1605,7 @@ def setup_chat_routes( last_metrics["tps_source"] = "backend" # Wall-clock response time for the stats popup ("Time"). last_metrics.setdefault("response_time", round(time.time() - _chat_start, 2)) + last_metrics = _stamp_requested_model_controls(last_metrics) yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n' except json.JSONDecodeError: yield chunk @@ -1616,8 +1634,10 @@ def setup_chat_routes( "requested_model": _requested_model, "usage_source": "estimated", } + last_metrics = _stamp_requested_model_controls(last_metrics) yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n' if full_response: + last_metrics = _stamp_requested_model_controls(last_metrics) _metrics_to_save = dict(last_metrics or {}) if thinking_response.strip() and not _metrics_to_save.get("thinking"): _metrics_to_save["thinking"] = thinking_response.strip() @@ -1648,11 +1668,11 @@ def setup_chat_routes( logger.info("Client disconnected mid-stream (chat mode) for session %s, saving partial (%d chars)", session, len(full_response)) _stopped_content, _stopped_md = clean_thinking_for_save( full_response, - { + _stamp_requested_model_controls({ "stopped": True, "model": _actual_model or _answered_by or _requested_model, "requested_model": _requested_model, - }, + }), ) sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md)) session_manager.save_sessions() @@ -1716,6 +1736,8 @@ def setup_chat_routes( workspace=workspace or None, forced_tools=_forced_tools, uploaded_files=ctx.uploaded_files, + reasoning_effort=reasoning_effort, + verbosity=verbosity, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: @@ -1772,6 +1794,7 @@ def setup_chat_routes( last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim + last_metrics = _stamp_requested_model_controls(last_metrics) yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n' except json.JSONDecodeError: yield chunk @@ -1781,7 +1804,7 @@ def setup_chat_routes( _has_tool_events = bool((last_metrics or {}).get("tool_events")) if full_response or _has_tool_events: _response_to_save = full_response or "Done." - _metrics_to_save = dict(last_metrics or {}) + _metrics_to_save = dict(_stamp_requested_model_controls(last_metrics) or {}) if thinking_response.strip() and not _metrics_to_save.get("thinking"): _metrics_to_save["thinking"] = thinking_response.strip() _saved_id = save_assistant_response( @@ -1820,11 +1843,11 @@ def setup_chat_routes( logger.info("Client disconnected mid-stream for session %s, saving partial response (%d chars)", session, len(full_response)) _stopped_content2, _stopped_md2 = clean_thinking_for_save( full_response, - { + _stamp_requested_model_controls({ "stopped": True, "model": _actual_model or _answered_by or _requested_model, "requested_model": _requested_model, - }, + }), ) sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2)) session_manager.save_sessions() diff --git a/routes/model_routes.py b/routes/model_routes.py index 600150a66..d6472812a 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -2438,6 +2438,8 @@ def setup_model_routes(model_discovery): ep_id = (_user_prefs.get("default_endpoint_id") or "").strip() model = (_user_prefs.get("default_model") or "").strip() _fallbacks = _user_prefs.get("default_model_fallbacks") or [] + default_reasoning_effort = (_user_prefs.get("default_reasoning_effort") or "").strip() + default_verbosity = (_user_prefs.get("default_verbosity") or "").strip() # If user has no personal default, fall back to global default # But only based on the "share_defaults_with_users" flag # (only if share_defaults_with_users is enabled) @@ -2448,10 +2450,35 @@ def setup_model_routes(model_discovery): model = settings.get("default_model", "") if not _fallbacks: _fallbacks = settings.get("default_model_fallbacks") or [] + if not default_reasoning_effort: + default_reasoning_effort = (settings.get("default_reasoning_effort") or "").strip() + if not default_verbosity: + default_verbosity = (settings.get("default_verbosity") or "").strip() else: ep_id = settings.get("default_endpoint_id", "") model = settings.get("default_model", "") _fallbacks = settings.get("default_model_fallbacks") or [] + default_reasoning_effort = (settings.get("default_reasoning_effort") or "").strip() + default_verbosity = (settings.get("default_verbosity") or "").strip() + + def _clean_default_reasoning(value: str) -> str: + cleaned = (value or "").strip().lower().replace("-", "_") + if cleaned in {"", "auto", "default"}: + return "" + if cleaned == "none": + return "off" + return cleaned if cleaned in {"off", "on", "minimal", "low", "medium", "high", "xhigh"} else "" + + def _clean_default_verbosity(value: str) -> str: + cleaned = (value or "").strip().lower() + if cleaned in {"", "auto", "default"}: + return "" + return cleaned if cleaned in {"low", "medium", "high"} else "" + + default_controls = { + "default_reasoning_effort": _clean_default_reasoning(default_reasoning_effort), + "default_verbosity": _clean_default_verbosity(default_verbosity), + } db = SessionLocal() try: ep = None @@ -2504,7 +2531,7 @@ def setup_model_routes(model_discovery): _last_q = owner_filter(_last_q, ModelEndpoint, _user, include_shared=False) ep = _last_q.first() if not ep: - return {"endpoint_id": "", "endpoint_url": "", "model": ""} + return {"endpoint_id": "", "endpoint_url": "", "model": "", **default_controls} base = _normalize_base(ep.base_url) chat_url = build_chat_url(base) if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)): @@ -2514,7 +2541,7 @@ def setup_model_routes(model_discovery): model = visible[0] except Exception: pass - return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model} + return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model, **default_controls} finally: db.close() diff --git a/routes/session_routes.py b/routes/session_routes.py index dc29a64e4..3ed089167 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -40,6 +40,31 @@ def _public_model(name: str, model: str) -> str: return model +_SESSION_REASONING_VALUES = {"auto", "off", "on", "none", "minimal", "low", "medium", "high", "xhigh"} +_SESSION_VERBOSITY_VALUES = {"auto", "low", "medium", "high"} + + +def _normalize_session_control(value, allowed_values, field_name: str): + if value is None: + return None + normalized = str(value).strip().lower().replace("-", "_") + if normalized == "x_high": + normalized = "xhigh" + if normalized in ("", "auto", "default"): + return None + if normalized not in allowed_values: + raise HTTPException(400, f"Unsupported {field_name}: {value}") + return normalized + + +def _normalize_session_reasoning(value): + return _normalize_session_control(value, _SESSION_REASONING_VALUES, "reasoning_effort") + + +def _normalize_session_verbosity(value): + return _normalize_session_control(value, _SESSION_VERBOSITY_VALUES, "verbosity") + + def _content_to_text(content) -> str: """Flatten a message's content to plain text for text-based exports. @@ -269,7 +294,22 @@ def setup_session_routes( last_msg_map = {} mode_map = {} msg_count_map = {} - q = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False) + reasoning_map = {} + verbosity_map = {} + q = db.query( + DbSession.id, + DbSession.folder, + DbSession.total_input_tokens, + DbSession.total_output_tokens, + DbSession.is_important, + DbSession.created_at, + DbSession.updated_at, + DbSession.last_message_at, + DbSession.mode, + DbSession.message_count, + DbSession.reasoning_effort, + DbSession.verbosity, + ).filter(DbSession.archived == False) q = owner_filter(q, DbSession, user) rows = q.all() for row in rows: @@ -287,6 +327,8 @@ def setup_session_routes( ) mode_map[row.id] = row.mode msg_count_map[row.id] = row.message_count or 0 + reasoning_map[row.id] = row.reasoning_effort + verbosity_map[row.id] = row.verbosity # Sessions with active documents that have content from sqlalchemy import func doc_session_ids = set( @@ -319,6 +361,8 @@ def setup_session_routes( "has_documents": s.id in doc_session_ids, "has_images": s.id in img_session_ids, "mode": mode_map.get(s.id), + "reasoning_effort": reasoning_map.get(s.id), + "verbosity": verbosity_map.get(s.id), "message_count": msg_count_map.get(s.id, 0)} for s in user_sessions.values() if not s.archived @@ -337,6 +381,8 @@ def setup_session_routes( skip_validation: str = Form(None), api_key: str = Form(""), endpoint_id: str = Form(""), + reasoning_effort: str = Form(None), + verbosity: str = Form(None), ): skip_val = str(skip_validation).lower() == "true" user = effective_user(request) @@ -425,6 +471,8 @@ def setup_session_routes( sid = str(uuid.uuid4()) user = effective_user(request) + saved_reasoning = _normalize_session_reasoning(reasoning_effort) + saved_verbosity = _normalize_session_verbosity(verbosity) session = session_manager.create_session( session_id=sid, name=name or "", @@ -432,6 +480,8 @@ def setup_session_routes( model=model_to_use, rag=str(rag).lower() == "true" if rag else False, owner=user, + reasoning_effort=saved_reasoning, + verbosity=saved_verbosity, ) # Set auth headers for custom API-key endpoints resolved_key = request_api_key @@ -456,7 +506,9 @@ def setup_session_routes( name=session.name, model=model_to_use, rag=str(rag).lower() == "true" if rag else False, - archived=False + archived=False, + reasoning_effort=saved_reasoning, + verbosity=saved_verbosity, ) @router.patch("/session/{sid}") def rename_session( @@ -464,6 +516,7 @@ def setup_session_routes( name: str = Form(None), folder: str = Form(None), model: str = Form(None), endpoint_url: str = Form(None), endpoint_id: str = Form(None), + reasoning_effort: str = Form(None), verbosity: str = Form(None), ): _verify_session_owner(request, sid) try: @@ -534,6 +587,23 @@ def setup_session_routes( db.close() result["model"] = model result["endpoint_url"] = endpoint_url + if reasoning_effort is not None or verbosity is not None: + saved_reasoning = _normalize_session_reasoning(reasoning_effort) if reasoning_effort is not None else session.reasoning_effort + saved_verbosity = _normalize_session_verbosity(verbosity) if verbosity is not None else session.verbosity + session.reasoning_effort = saved_reasoning + session.verbosity = saved_verbosity + db = SessionLocal() + try: + db_session = db.query(DbSession).filter(DbSession.id == sid).first() + if db_session: + db_session.reasoning_effort = saved_reasoning + db_session.verbosity = saved_verbosity + db_session.updated_at = utcnow_naive() + db.commit() + finally: + db.close() + result["reasoning_effort"] = saved_reasoning + result["verbosity"] = saved_verbosity return result @router.post("/session/{sid}/inject_messages") diff --git a/src/agent_loop.py b/src/agent_loop.py index cca93fe56..258360b9a 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -3101,6 +3101,8 @@ async def stream_agent_loop( forced_tools: Optional[Set[str]] = None, uploaded_files: Optional[List[Dict]] = None, workload: str = "foreground", + reasoning_effort: Optional[str] = None, + verbosity: Optional[str] = None, _is_teacher_run: bool = False, ) -> AsyncGenerator[str, None]: """Streaming agent loop generator. @@ -3980,6 +3982,8 @@ async def stream_agent_loop( timeout=agent_stream_timeout, session_id=session_id, workload=workload, + reasoning_effort=reasoning_effort, + verbosity=verbosity, ): if not _round_first_event_logged: _round_first_event_logged = True diff --git a/src/llm_core.py b/src/llm_core.py index 3e84c1060..ab810e93c 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -617,6 +617,7 @@ def _build_ollama_payload( stream: bool = False, tools: Optional[List[Dict]] = None, num_ctx: Optional[int] = None, + reasoning_effort: Optional[str] = None, ) -> Dict: """Build the JSON payload for Ollama's /api/chat endpoint. @@ -645,6 +646,9 @@ def _build_ollama_payload( payload["options"] = options if tools: payload["tools"] = tools + thinking_enabled = _ollama_think_value(model, reasoning_effort) + if thinking_enabled is not None: + payload["think"] = thinking_enabled return payload @@ -1110,6 +1114,8 @@ def _build_chatgpt_responses_payload( max_tokens: int, *, stream: bool = False, + reasoning_effort: Optional[str] = None, + verbosity: Optional[str] = None, ) -> Dict: from src.chatgpt_subscription import build_responses_input @@ -1123,6 +1129,12 @@ def _build_chatgpt_responses_payload( } if not _restricts_temperature(model): payload["temperature"] = temperature + normalized_effort = _openai_reasoning_effort_value(model, reasoning_effort) + if normalized_effort: + payload["reasoning"] = {"effort": normalized_effort} + normalized_verbosity = _normalize_verbosity(verbosity) + if _supports_openai_text_verbosity(model) and normalized_verbosity: + payload["text"] = {"verbosity": normalized_verbosity} # 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. @@ -1311,6 +1323,105 @@ def _normalize_mistral_content(content): return "".join(text_parts), "".join(thinking_parts) +_REASONING_AUTO = {"", "auto", "default"} +_REASONING_OFF = {"off", "false", "disabled", "disable", "no"} +_REASONING_NONE = {"none"} +_REASONING_ON = {"on", "true", "enabled", "enable", "yes"} +_OPENAI_REASONING_EFFORTS = {"low", "medium", "high"} +_VERBOSITY_VALUES = {"low", "medium", "high"} + + +def _normalize_reasoning_effort(value: Optional[str]) -> Optional[str]: + """Return a normalized reasoning control, or None for provider default.""" + if value is None: + return None + effort = str(value).strip().lower().replace("-", "_") + if effort in _REASONING_AUTO: + return None + if effort in _REASONING_NONE: + return "none" + if effort in _REASONING_OFF: + return "off" + if effort in _REASONING_ON: + return "on" + if effort == "x_high": + return "xhigh" + return effort + + +def _normalize_verbosity(value: Optional[str]) -> Optional[str]: + if value is None: + return None + verbosity = str(value).strip().lower() + if verbosity in {"", "auto", "default"}: + return None + if verbosity in _VERBOSITY_VALUES: + return verbosity + return None + + +def _supports_openai_reasoning_effort(model: str) -> bool: + return _restricts_temperature(model) + + +def _supports_openai_minimal_reasoning(model: str) -> bool: + if not model: + return False + m = model.lower() + return m.startswith("gpt-5") or "/gpt-5" in m + + +def _gpt5_minor_version(model: str) -> Optional[int]: + if not model: + return None + match = re.search(r"(?:^|[/\s_-])gpt[\s_-]*5(?:[._-](\d+))?", model.lower()) + if not match: + return None + if match.group(1) is None: + return 0 + try: + return int(match.group(1)) + except ValueError: + return None + + +def _supports_openai_none_reasoning(model: str) -> bool: + minor = _gpt5_minor_version(model) + return minor is not None and minor >= 1 + + +def _openai_reasoning_effort_value(model: str, reasoning_effort: Optional[str]) -> Optional[str]: + if not _supports_openai_reasoning_effort(model): + return None + effort = _normalize_reasoning_effort(reasoning_effort) + if effort in _OPENAI_REASONING_EFFORTS: + return effort + if effort == "minimal" and _supports_openai_minimal_reasoning(model): + return effort + if effort in {"off", "none"} and _supports_openai_none_reasoning(model): + return "none" + return None + + +def _supports_openai_text_verbosity(model: str) -> bool: + if not model: + return False + m = model.lower() + return m.startswith("gpt-5") or "/gpt-5" in m + + +def _ollama_think_value(model: str, reasoning_effort: Optional[str]) -> Optional[bool]: + """Map the shared reasoning control to Ollama's binary think flag.""" + if not _supports_thinking(model): + return None + effort = _normalize_reasoning_effort(reasoning_effort) + if effort is None: + return None + if effort in {"off", "none"}: + return False + return True + + def _convert_openai_content_to_anthropic(content): """Convert OpenAI multimodal content blocks to Anthropic format. @@ -1798,7 +1909,8 @@ def normalize_model_id( def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE, max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None, - timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None) -> str: + timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None, + reasoning_effort: Optional[str] = None, verbosity: Optional[str] = None) -> str: """Synchronous LLM call with optional prompt type enhancement.""" h = _provider_headers(_detect_provider(url)) # Tolerate headers that arrive as a JSON string (some sessions stored them @@ -1843,6 +1955,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL payload = _build_ollama_payload( model, messages_copy, temperature, max_tokens, stream=False, num_ctx=get_context_length(url, model), + reasoning_effort=reasoning_effort, ) else: target_url = _normalize_openai_chat_url(url) @@ -1970,6 +2083,8 @@ async def llm_call_async( prompt_type: Optional[str] = None, session_id: Optional[str] = None, workload: str = "foreground", + reasoning_effort: Optional[str] = None, + verbosity: Optional[str] = None, ) -> str: """Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging.""" provider = _detect_provider(url) @@ -2008,6 +2123,8 @@ async def llm_call_async( headers=headers, timeout=timeout, workload=workload, + reasoning_effort=reasoning_effort, + verbosity=verbosity, ): event_is_error = False for line in str(chunk).splitlines(): @@ -2050,6 +2167,7 @@ async def llm_call_async( payload = _build_ollama_payload( model, messages_copy, temperature, max_tokens, stream=False, num_ctx=get_context_length(url, model), + reasoning_effort=reasoning_effort, ) else: target_url = _normalize_openai_chat_url(url) @@ -2144,7 +2262,8 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None, 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"): + tool_choice_none: bool = False, workload: str = "foreground", + reasoning_effort: Optional[str] = None, verbosity: 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( @@ -2159,6 +2278,8 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl tools=tools, session_id=session_id, tool_choice_none=tool_choice_none, + reasoning_effort=reasoning_effort, + verbosity=verbosity, ): yield chunk @@ -2167,7 +2288,8 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None, 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): + tool_choice_none: bool = False, + reasoning_effort: Optional[str] = None, verbosity: Optional[str] = None): """Stream LLM responses with improved error handling. Yields SSE chunks: @@ -2205,11 +2327,15 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat payload = _build_ollama_payload( model, messages_copy, temperature, max_tokens, stream=True, tools=tools, num_ctx=get_context_length(url, model), + reasoning_effort=reasoning_effort, ) 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, verbosity=verbosity, + ) else: target_url = _normalize_openai_chat_url(url) payload = { @@ -2235,11 +2361,11 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat # (high / medium / low / none); default "high". if provider == "mistral" and _supports_thinking(model): payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT - # For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3, - # gemma4, etc.), suppress thinking so tool calls aren't swallowed inside - # blocks. Ollama /v1 accepts "think": false as a top-level param. + # Ollama /v1 accepts the same binary think flag as native /api/chat. + # Keep the current safe default for tools, but honor explicit user controls. if _is_ollama_openai_compat_url(url) and _supports_thinking(model): - payload["think"] = False + think_value = _ollama_think_value(model, reasoning_effort) + payload["think"] = False if think_value is None else think_value _apply_local_cache_affinity(payload, url, session_id) _apply_local_generation_stability(payload, target_url, model) _scrub_openai_chat_tool_reasoning(payload, target_url, model) diff --git a/src/request_models.py b/src/request_models.py index f7755b1d4..625dd8357 100644 --- a/src/request_models.py +++ b/src/request_models.py @@ -125,6 +125,8 @@ class SessionResponse(BaseModel): model: str = Field(..., description="Model being used") rag: bool = Field(default=False, description="RAG enabled") archived: bool = Field(default=False, description="Whether session is archived") + reasoning_effort: Optional[str] = Field(default=None, description="Per-session reasoning control") + verbosity: Optional[str] = Field(default=None, description="Per-session verbosity control") class MemoryResponse(BaseModel): diff --git a/src/settings.py b/src/settings.py index 5836765f1..16839efaf 100644 --- a/src/settings.py +++ b/src/settings.py @@ -134,6 +134,8 @@ DEFAULT_SETTINGS = { "task_model": "", "default_endpoint_id": "", "default_model": "", + "default_reasoning_effort": "", + "default_verbosity": "", # Optional prose style used only for normal document writing/editing. # Email replies use email_writing_style instead because greetings, # signatures, and mailbox identity rules are medium-specific. @@ -271,6 +273,7 @@ _PER_USER_KEYS = { # account inherited whatever the most-recent admin picked, which then # got injected into the chat composer on first open. "default_endpoint_id", "default_model", "default_model_fallbacks", + "default_reasoning_effort", "default_verbosity", "utility_endpoint_id", "utility_model", "utility_model_fallbacks", "research_endpoint_id", "research_model", } diff --git a/static/app.js b/static/app.js index 97f0ae77e..0bd938e6b 100644 --- a/static/app.js +++ b/static/app.js @@ -209,6 +209,10 @@ try { if (cachedDefaultChat && cachedDefaultChat.endpoint_url && cachedDefaultChat.model) { _defaultChat = cachedDefaultChat; window.__odysseusDefaultChat = cachedDefaultChat; + window.__odysseusModelControlDefaults = { + reasoning_effort: cachedDefaultChat.default_reasoning_effort || 'auto', + verbosity: cachedDefaultChat.default_verbosity || 'auto', + }; } } catch (_) {} async function _refreshDefaultChat() { @@ -218,6 +222,10 @@ async function _refreshDefaultChat() { _defaultChat = d; try { window.__odysseusDefaultChat = d; + window.__odysseusModelControlDefaults = { + reasoning_effort: d.default_reasoning_effort || 'auto', + verbosity: d.default_verbosity || 'auto', + }; localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(d)); } catch (_) {} return d; @@ -248,7 +256,11 @@ async function _createDirectChatFromPreferredModel() { const dc = await _refreshDefaultChat(); if (dc) { - sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' }); + sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { + source: 'default', + reasoning_effort: dc.default_reasoning_effort || '', + verbosity: dc.default_verbosity || '', + }); return true; } @@ -370,6 +382,7 @@ function initializeEventListeners() { document.querySelectorAll( '.skill-kebab-menu, .note-reminder-menu, .task-dropdown, .doclib-card-dropdown, .email-card-dropdown, .msg-overflow-menu' ).forEach(m => { if (m !== except) m.remove(); }); + document.dispatchEvent(new CustomEvent('model-control-close-all')); }; // Window-opening / nav controls (rail buttons, sidebar tool rows + session // rows, section headers) count as "other actions" — dismiss popups when one @@ -1951,6 +1964,387 @@ function initializeEventListeners() { } setupToggle('web-toggle-btn', 'web-toggle', 'web'); setupToggle('bash-toggle-btn', 'bash-toggle', 'bash'); + + // Per-chat model controls. Auto is the default and is omitted from requests. + (function initModelControls() { + const controls = [ + { + key: 'reasoning_effort', + name: 'Reasoning', + btnId: 'reasoning-control-btn', + menuId: 'reasoning-control-menu', + labelId: 'reasoning-control-label', + labels: { + auto: 'Auto', + off: 'Off', + on: 'On', + minimal: 'Min', + low: 'Low', + medium: 'Med', + high: 'High', + }, + }, + { + key: 'verbosity', + name: 'Verbosity', + btnId: 'verbosity-control-btn', + menuId: 'verbosity-control-menu', + labelId: 'verbosity-control-label', + labels: { + auto: 'Auto', + low: 'Low', + medium: 'Med', + high: 'High', + }, + }, + ]; + + const normalizeValue = (value, labels) => { + const v = String(value || 'auto').toLowerCase(); + return Object.prototype.hasOwnProperty.call(labels, v) ? v : 'auto'; + }; + const normalizeDefaultControl = (key, value) => { + let normalized = String(value || 'auto').toLowerCase().replace(/-/g, '_'); + if (normalized === 'none') normalized = 'off'; + const config = controls.find(c => c.key === key); + return config ? normalizeValue(normalized, config.labels) : 'auto'; + }; + let modelControlDefaults = { + reasoning_effort: 'auto', + verbosity: 'auto', + }; + function setModelControlDefaults(values = {}) { + modelControlDefaults = { + reasoning_effort: normalizeDefaultControl( + 'reasoning_effort', + values.reasoning_effort || values.default_reasoning_effort || 'auto', + ), + verbosity: normalizeDefaultControl( + 'verbosity', + values.verbosity || values.default_verbosity || 'auto', + ), + }; + try { window.__odysseusModelControlDefaults = { ...modelControlDefaults }; } catch (_) {} + } + + async function refreshModelControlDefaults() { + try { + const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); + if (!res.ok) return { ...modelControlDefaults }; + const settings = await res.json(); + if (settings) setModelControlDefaults(settings); + } catch (_) {} + return { ...modelControlDefaults }; + } + + const isThinkingModel = model => { + const m = String(model || '').toLowerCase(); + return ['qwen3', 'qwq', 'deepseek-r1', 'deepseek-reasoner', 'minimax', 'm2-reap', 'gemma'] + .some(part => m.includes(part)); + }; + + const isOllamaEndpoint = url => { + const u = String(url || '').toLowerCase(); + return u.includes('11434') || u.includes('ollama'); + }; + + const isChatGptSubscriptionEndpoint = url => ( + String(url || '').trim().toLowerCase() === 'chatgpt-subscription' + || chatRenderer.isSubscriptionEndpoint(url) + ); + + const isOSeriesReasoningModel = model => { + const m = String(model || '').toLowerCase(); + return /(^|[/\s_-])o\d/.test(m); + }; + + const isGpt5Family = model => { + const m = String(model || '').toLowerCase(); + return /(^|[/\s_-])gpt[\s_-]*5/.test(m); + }; + + const gpt5MinorVersion = model => { + const match = String(model || '').toLowerCase().match(/(?:^|[/\s_-])gpt[\s_-]*5(?:[._-](\d+))?/); + if (!match) return null; + if (match[1] == null) return 0; + const parsed = Number.parseInt(match[1], 10); + return Number.isFinite(parsed) ? parsed : null; + }; + + const supportsOpenAiNoneReasoning = model => { + const minor = gpt5MinorVersion(model); + return minor != null && minor >= 1; + }; + + function currentModelContext(override = null) { + const sid = sessionModule && sessionModule.getCurrentSessionId ? sessionModule.getCurrentSessionId() : null; + const sessions = sessionModule && sessionModule.getSessions ? sessionModule.getSessions() : []; + const meta = sid ? sessions.find(s => s.id === sid) : null; + const pending = sessionModule && sessionModule.getPendingChat ? sessionModule.getPendingChat() : null; + return { + sessionId: sid, + meta, + model: (override && override.model) || (meta && meta.model) || (pending && pending.modelId) || '', + endpointUrl: (override && override.endpointUrl) || (meta && meta.endpoint_url) || (pending && pending.url) || '', + }; + } + + function capabilitiesFor(key, override = null) { + const ctx = currentModelContext(override); + const model = ctx.model || ''; + const endpointUrl = ctx.endpointUrl || ''; + if (!model) { + return { supported: false, allowed: new Set(['auto']), reason: 'Select a model first' }; + } + const chatgptSubscription = isChatGptSubscriptionEndpoint(endpointUrl); + if (key === 'reasoning_effort') { + if (chatgptSubscription && (isGpt5Family(model) || isOSeriesReasoningModel(model))) { + const allowed = new Set(['auto', 'low', 'medium', 'high']); + if (isGpt5Family(model)) allowed.add('minimal'); + if (supportsOpenAiNoneReasoning(model)) allowed.add('off'); + return { supported: true, allowed, reason: '' }; + } + if (isOllamaEndpoint(endpointUrl) && isThinkingModel(model)) { + return { supported: true, allowed: new Set(['auto', 'off', 'on']), reason: '' }; + } + return { supported: false, allowed: new Set(['auto']), reason: 'Reasoning controls are unavailable for this model endpoint' }; + } + if (key === 'verbosity') { + if (chatgptSubscription && isGpt5Family(model)) { + return { supported: true, allowed: new Set(['auto', 'low', 'medium', 'high']), reason: '' }; + } + return { supported: false, allowed: new Set(['auto']), reason: 'Verbosity controls are unavailable for this model endpoint' }; + } + return { supported: false, allowed: new Set(['auto']), reason: '' }; + } + + async function persistSessionControl(key, value, sessionId = null) { + const sid = sessionId || (sessionModule && sessionModule.getCurrentSessionId ? sessionModule.getCurrentSessionId() : null); + if (!sid) return; + const fd = new FormData(); + fd.append(key, value || 'auto'); + try { + const res = await fetch(`${API_BASE}/api/session/${sid}`, { method: 'PATCH', body: fd }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const payload = await res.json().catch(() => ({})); + const sessions = sessionModule && sessionModule.getSessions ? sessionModule.getSessions() : []; + const meta = sessions.find(s => s.id === sid); + if (meta) { + meta.reasoning_effort = payload.reasoning_effort || null; + meta.verbosity = payload.verbosity || null; + } + } catch (err) { + console.warn('Failed to save model control setting:', err); + if (uiModule && uiModule.showToast) uiModule.showToast('Could not save model setting'); + } + } + + function positionMenu(btn, menu) { + const r = btn.getBoundingClientRect(); + const naturalHeight = menu.scrollHeight || 0; + const naturalWidth = menu.offsetWidth || 118; + const left = Math.max(8, Math.min(r.left, window.innerWidth - naturalWidth - 8)); + const top = Math.max(8, r.top - naturalHeight - 8); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + } + + const registry = {}; + + function refreshCapabilities(override = null) { + Object.values(registry).forEach(api => api.refreshCapability(override)); + } + + function resolveControlForContext(key, value, contextOverride = null) { + const config = controls.find(item => item.key === key); + if (!config) return 'auto'; + const capability = capabilitiesFor(key, contextOverride); + const normalized = normalizeDefaultControl(key, value); + return capability.allowed.has(normalized) ? normalized : 'auto'; + } + + function resolveDefaultsForContext(contextOverride = null) { + return { + reasoning_effort: resolveControlForContext( + 'reasoning_effort', + modelControlDefaults.reasoning_effort, + contextOverride, + ), + verbosity: resolveControlForContext( + 'verbosity', + modelControlDefaults.verbosity, + contextOverride, + ), + }; + } + + async function applyDefaultsForContext(contextOverride = null, options = {}) { + if (options.refresh !== false) { + await refreshModelControlDefaults(); + } + const resolved = resolveDefaultsForContext(contextOverride); + if (registry.reasoning_effort) { + registry.reasoning_effort.setValue(resolved.reasoning_effort, { + persist: options.persist !== false, + contextOverride, + sessionId: options.sessionId || null, + }); + } + if (registry.verbosity) { + registry.verbosity.setValue(resolved.verbosity, { + persist: options.persist !== false, + contextOverride, + sessionId: options.sessionId || null, + }); + } + refreshCapabilities(contextOverride); + return resolved; + } + + controls.forEach(config => { + const btn = el(config.btnId); + const menu = el(config.menuId); + const label = el(config.labelId); + if (!btn || !menu || !label) return; + const ownerWrap = menu.parentElement; + const wrapper = btn.closest('.model-control-wrapper'); + const options = Array.from(menu.querySelectorAll('.model-control-option')); + let currentValue = 'auto'; + + function setValue(value, optionsArg = {}) { + const capability = capabilitiesFor(config.key, optionsArg.contextOverride || null); + let normalized = normalizeValue(value, config.labels); + if (!capability.allowed.has(normalized)) normalized = 'auto'; + currentValue = normalized; + const state = loadToggleState(); + state[config.key] = normalized; + saveToggleState(state); + label.textContent = config.labels[normalized] || 'Auto'; + const active = normalized !== 'auto'; + btn.classList.toggle('active', active); + btn.setAttribute('aria-pressed', String(active)); + btn.title = `${config.name}: ${config.labels[normalized] || 'Auto'}`; + options.forEach(opt => { + opt.classList.toggle('active', opt.dataset.value === normalized); + }); + if (optionsArg.persist !== false) { + persistSessionControl(config.key, normalized, optionsArg.sessionId || null); + } + } + + function refreshCapability(override = null) { + const capability = capabilitiesFor(config.key, override); + const disabled = !capability.supported; + if (wrapper) wrapper.classList.toggle('disabled', disabled); + btn.disabled = disabled; + btn.title = disabled + ? capability.reason + : `${config.name}: ${config.labels[currentValue] || 'Auto'}`; + options.forEach(opt => { + const allowed = capability.allowed.has(opt.dataset.value); + opt.disabled = !allowed; + opt.classList.toggle('disabled', !allowed); + opt.title = allowed ? '' : capability.reason; + }); + if (!capability.allowed.has(currentValue)) { + setValue('auto', { persist: false, contextOverride: override }); + } + } + + function closeMenu() { + if (menu.classList.contains('hidden')) return; + menu.classList.add('hidden'); + btn.classList.remove('expanded'); + btn.setAttribute('aria-expanded', 'false'); + if (ownerWrap && menu.parentElement !== ownerWrap) { + ownerWrap.appendChild(menu); + } + } + + function openMenu() { + document.dispatchEvent(new CustomEvent('model-control-close-all', { detail: { except: config.menuId } })); + menu.classList.remove('hidden'); + document.body.appendChild(menu); + btn.classList.add('expanded'); + btn.setAttribute('aria-expanded', 'true'); + positionMenu(btn, menu); + } + + btn.addEventListener('pointerdown', e => e.preventDefault()); + btn.addEventListener('click', e => { + e.stopPropagation(); + if (btn.disabled) return; + if (menu.classList.contains('hidden')) openMenu(); + else closeMenu(); + }); + options.forEach(opt => { + opt.addEventListener('pointerdown', e => e.preventDefault()); + opt.addEventListener('click', e => { + e.stopPropagation(); + if (opt.disabled) return; + setValue(opt.dataset.value); + closeMenu(); + }); + }); + document.addEventListener('model-control-close-all', e => { + if (e.detail && e.detail.except === config.menuId) return; + closeMenu(); + }); + document.addEventListener('click', e => { + if (menu.contains(e.target) || btn.contains(e.target)) return; + closeMenu(); + }); + document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeMenu(); + }); + window.addEventListener('resize', () => { + if (!menu.classList.contains('hidden')) positionMenu(btn, menu); + }); + + const state = loadToggleState(); + registry[config.key] = { setValue, refreshCapability }; + setValue(state[config.key] || 'auto', { persist: false }); + }); + + refreshCapabilities(); + setModelControlDefaults(window.__odysseusModelControlDefaults || window.__odysseusDefaultChat || {}); + + window.odysseusModelControls = { + applySession(meta = {}) { + if (registry.reasoning_effort) { + registry.reasoning_effort.setValue(meta.reasoning_effort || 'auto', { persist: false }); + } + if (registry.verbosity) { + registry.verbosity.setValue(meta.verbosity || 'auto', { persist: false }); + } + refreshCapabilities(); + }, + getDefaults() { + return { ...modelControlDefaults }; + }, + resolveDefaultsForContext, + applyDefaultsForContext, + setDefaults(values = {}) { + setModelControlDefaults(values); + }, + refreshDefaults: refreshModelControlDefaults, + refreshCapabilities, + }; + + fetch('/api/auth/settings', { credentials: 'same-origin' }) + .then(res => res.ok ? res.json() : null) + .then(settings => { + if (settings) setModelControlDefaults(settings); + }) + .catch(() => {}); + + document.addEventListener('odysseus:model-picked', e => { + const detail = (e && e.detail) || {}; + refreshCapabilities({ model: detail.mid, endpointUrl: detail.url }); + setTimeout(() => refreshCapabilities(), 250); + }); + })(); + try { workspaceModule.initWorkspace(); } catch (_) {} // Document editor toggle (special: uses module panel, not a checkbox) diff --git a/static/index.html b/static/index.html index 8257660fe..7db8b179a 100644 --- a/static/index.html +++ b/static/index.html @@ -1144,6 +1144,37 @@ +
+ + +
+
+ + +