This commit is contained in:
Matyas Gosztonyi 2026-08-04 14:46:33 +03:00 committed by GitHub
commit 5f7817b630
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1324 additions and 26 deletions

View file

@ -220,6 +220,8 @@ class Session(TimestampMixin, Base):
total_input_tokens = Column(Integer, default=0) total_input_tokens = Column(Integer, default=0)
total_output_tokens = Column(Integer, default=0) total_output_tokens = Column(Integer, default=0)
mode = Column(String, nullable=True) # 'agent', 'chat', or 'research' 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 crew_member_id = Column(String, nullable=True) # links to crew_members.id
# Relationship to chat messages # Relationship to chat messages
@ -248,6 +250,8 @@ class Session(TimestampMixin, Base):
'folder': self.folder, 'folder': self.folder,
'total_input_tokens': self.total_input_tokens or 0, 'total_input_tokens': self.total_input_tokens or 0,
'total_output_tokens': self.total_output_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, 'crew_member_id': self.crew_member_id,
} }
@ -1172,6 +1176,35 @@ def _migrate_add_mode_column():
except Exception: except Exception:
pass 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(): def _migrate_add_folder_column():
"""Add folder column to sessions table if it doesn't exist.""" """Add folder column to sessions table if it doesn't exist."""
import sqlite3 import sqlite3
@ -1942,6 +1975,7 @@ def init_db():
_migrate_add_folder_column() _migrate_add_folder_column()
_migrate_add_token_columns() _migrate_add_token_columns()
_migrate_add_mode_column() _migrate_add_mode_column()
_migrate_add_session_model_control_columns()
_migrate_add_multiuser_owner_columns() _migrate_add_multiuser_owner_columns()
_migrate_add_gallery_caption_column() _migrate_add_gallery_caption_column()
_migrate_add_api_token_scopes_column() _migrate_add_api_token_scopes_column()

View file

@ -74,6 +74,8 @@ class Session:
owner: Optional[str] = None owner: Optional[str] = None
is_important: bool = False is_important: bool = False
message_count: int = 0 message_count: int = 0
reasoning_effort: Optional[str] = None
verbosity: Optional[str] = None
def __post_init__(self): def __post_init__(self):
if self.headers is None: if self.headers is None:

View file

@ -134,6 +134,8 @@ class SessionManager:
history=[], history=[],
owner=getattr(db_session, "owner", None), owner=getattr(db_session, "owner", None),
is_important=getattr(db_session, "is_important", False) or False, 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 session.message_count = getattr(db_session, "message_count", 0) or 0
return session return session
@ -192,6 +194,8 @@ class SessionManager:
history=history, history=history,
owner=getattr(db_session, 'owner', None), owner=getattr(db_session, 'owner', None),
is_important=getattr(db_session, 'is_important', False) or False, 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)) session.message_count = getattr(db_session, 'message_count', len(history))
@ -500,7 +504,9 @@ class SessionManager:
endpoint_url: str, endpoint_url: str,
model: str, model: str,
rag: bool = False, rag: bool = False,
owner: str = None owner: str = None,
reasoning_effort: str = None,
verbosity: str = None,
) -> Session: ) -> Session:
"""Create a new session and save to database.""" """Create a new session and save to database."""
db = SessionLocal() db = SessionLocal()
@ -513,6 +519,8 @@ class SessionManager:
rag=rag, rag=rag,
headers={}, headers={},
owner=owner, owner=owner,
reasoning_effort=reasoning_effort,
verbosity=verbosity,
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc) updated_at=datetime.now(timezone.utc)
) )
@ -527,6 +535,8 @@ class SessionManager:
rag=rag, rag=rag,
headers={}, headers={},
owner=owner, owner=owner,
reasoning_effort=reasoning_effort,
verbosity=verbosity,
) )
self.sessions[session_id] = session self.sessions[session_id] = session

View file

@ -656,6 +656,10 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"agent_max_rounds": (1, 200), "agent_max_rounds": (1, 200),
"agent_max_tool_calls": (0, 1000), # 0 = unlimited "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: for key in DEFAULT_SETTINGS:
if key not in body: if key not in body:
continue continue
@ -667,6 +671,14 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
except (TypeError, ValueError): except (TypeError, ValueError):
raise HTTPException(400, f"{key} must be an integer") raise HTTPException(400, f"{key} must be an integer")
val = max(lo, min(val, hi)) 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 current[key] = val
_save_settings(current) _save_settings(current)
return current return current

View file

@ -734,6 +734,21 @@ def setup_chat_routes(
incognito = str(form_data.get("incognito", "")).lower() == "true" 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" 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' 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: confine the agent's file/shell tools to this folder.
workspace, workspace_rejected = _resolve_request_workspace( workspace, workspace_rejected = _resolve_request_workspace(
request, form_data.get("workspace") request, form_data.get("workspace")
@ -1536,6 +1551,8 @@ def setup_chat_routes(
prompt_type=preset_id, prompt_type=preset_id,
tools=None, tools=None,
session_id=session, session_id=session,
reasoning_effort=reasoning_effort,
verbosity=verbosity,
): ):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try: try:
@ -1588,6 +1605,7 @@ def setup_chat_routes(
last_metrics["tps_source"] = "backend" last_metrics["tps_source"] = "backend"
# Wall-clock response time for the stats popup ("Time"). # Wall-clock response time for the stats popup ("Time").
last_metrics.setdefault("response_time", round(time.time() - _chat_start, 2)) 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' yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
except json.JSONDecodeError: except json.JSONDecodeError:
yield chunk yield chunk
@ -1616,8 +1634,10 @@ def setup_chat_routes(
"requested_model": _requested_model, "requested_model": _requested_model,
"usage_source": "estimated", "usage_source": "estimated",
} }
last_metrics = _stamp_requested_model_controls(last_metrics)
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n' yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
if full_response: if full_response:
last_metrics = _stamp_requested_model_controls(last_metrics)
_metrics_to_save = dict(last_metrics or {}) _metrics_to_save = dict(last_metrics or {})
if thinking_response.strip() and not _metrics_to_save.get("thinking"): if thinking_response.strip() and not _metrics_to_save.get("thinking"):
_metrics_to_save["thinking"] = thinking_response.strip() _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)) 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( _stopped_content, _stopped_md = clean_thinking_for_save(
full_response, full_response,
{ _stamp_requested_model_controls({
"stopped": True, "stopped": True,
"model": _actual_model or _answered_by or _requested_model, "model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model, "requested_model": _requested_model,
}, }),
) )
sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md)) sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md))
session_manager.save_sessions() session_manager.save_sessions()
@ -1716,6 +1736,8 @@ def setup_chat_routes(
workspace=workspace or None, workspace=workspace or None,
forced_tools=_forced_tools, forced_tools=_forced_tools,
uploaded_files=ctx.uploaded_files, uploaded_files=ctx.uploaded_files,
reasoning_effort=reasoning_effort,
verbosity=verbosity,
): ):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try: try:
@ -1772,6 +1794,7 @@ def setup_chat_routes(
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim 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_before_trim"] = ctx.context_tokens_before_trim
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_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' yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
except json.JSONDecodeError: except json.JSONDecodeError:
yield chunk yield chunk
@ -1781,7 +1804,7 @@ def setup_chat_routes(
_has_tool_events = bool((last_metrics or {}).get("tool_events")) _has_tool_events = bool((last_metrics or {}).get("tool_events"))
if full_response or _has_tool_events: if full_response or _has_tool_events:
_response_to_save = full_response or "Done." _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"): if thinking_response.strip() and not _metrics_to_save.get("thinking"):
_metrics_to_save["thinking"] = thinking_response.strip() _metrics_to_save["thinking"] = thinking_response.strip()
_saved_id = save_assistant_response( _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)) 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( _stopped_content2, _stopped_md2 = clean_thinking_for_save(
full_response, full_response,
{ _stamp_requested_model_controls({
"stopped": True, "stopped": True,
"model": _actual_model or _answered_by or _requested_model, "model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model, "requested_model": _requested_model,
}, }),
) )
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2)) sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
session_manager.save_sessions() session_manager.save_sessions()

View file

@ -2438,6 +2438,8 @@ def setup_model_routes(model_discovery):
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip() ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
model = (_user_prefs.get("default_model") or "").strip() model = (_user_prefs.get("default_model") or "").strip()
_fallbacks = _user_prefs.get("default_model_fallbacks") or [] _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 # If user has no personal default, fall back to global default
# But only based on the "share_defaults_with_users" flag # But only based on the "share_defaults_with_users" flag
# (only if share_defaults_with_users is enabled) # (only if share_defaults_with_users is enabled)
@ -2448,10 +2450,35 @@ def setup_model_routes(model_discovery):
model = settings.get("default_model", "") model = settings.get("default_model", "")
if not _fallbacks: if not _fallbacks:
_fallbacks = settings.get("default_model_fallbacks") or [] _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: else:
ep_id = settings.get("default_endpoint_id", "") ep_id = settings.get("default_endpoint_id", "")
model = settings.get("default_model", "") model = settings.get("default_model", "")
_fallbacks = settings.get("default_model_fallbacks") or [] _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() db = SessionLocal()
try: try:
ep = None ep = None
@ -2504,7 +2531,7 @@ def setup_model_routes(model_discovery):
_last_q = owner_filter(_last_q, ModelEndpoint, _user, include_shared=False) _last_q = owner_filter(_last_q, ModelEndpoint, _user, include_shared=False)
ep = _last_q.first() ep = _last_q.first()
if not ep: if not ep:
return {"endpoint_id": "", "endpoint_url": "", "model": ""} return {"endpoint_id": "", "endpoint_url": "", "model": "", **default_controls}
base = _normalize_base(ep.base_url) base = _normalize_base(ep.base_url)
chat_url = build_chat_url(base) chat_url = build_chat_url(base)
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)): 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] model = visible[0]
except Exception: except Exception:
pass 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: finally:
db.close() db.close()

View file

@ -40,6 +40,31 @@ def _public_model(name: str, model: str) -> str:
return model 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: def _content_to_text(content) -> str:
"""Flatten a message's content to plain text for text-based exports. """Flatten a message's content to plain text for text-based exports.
@ -269,7 +294,22 @@ def setup_session_routes(
last_msg_map = {} last_msg_map = {}
mode_map = {} mode_map = {}
msg_count_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) q = owner_filter(q, DbSession, user)
rows = q.all() rows = q.all()
for row in rows: for row in rows:
@ -287,6 +327,8 @@ def setup_session_routes(
) )
mode_map[row.id] = row.mode mode_map[row.id] = row.mode
msg_count_map[row.id] = row.message_count or 0 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 # Sessions with active documents that have content
from sqlalchemy import func from sqlalchemy import func
doc_session_ids = set( doc_session_ids = set(
@ -319,6 +361,8 @@ def setup_session_routes(
"has_documents": s.id in doc_session_ids, "has_documents": s.id in doc_session_ids,
"has_images": s.id in img_session_ids, "has_images": s.id in img_session_ids,
"mode": mode_map.get(s.id), "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)} "message_count": msg_count_map.get(s.id, 0)}
for s in user_sessions.values() for s in user_sessions.values()
if not s.archived if not s.archived
@ -337,6 +381,8 @@ def setup_session_routes(
skip_validation: str = Form(None), skip_validation: str = Form(None),
api_key: str = Form(""), api_key: str = Form(""),
endpoint_id: str = Form(""), endpoint_id: str = Form(""),
reasoning_effort: str = Form(None),
verbosity: str = Form(None),
): ):
skip_val = str(skip_validation).lower() == "true" skip_val = str(skip_validation).lower() == "true"
user = effective_user(request) user = effective_user(request)
@ -425,6 +471,8 @@ def setup_session_routes(
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())
user = effective_user(request) user = effective_user(request)
saved_reasoning = _normalize_session_reasoning(reasoning_effort)
saved_verbosity = _normalize_session_verbosity(verbosity)
session = session_manager.create_session( session = session_manager.create_session(
session_id=sid, session_id=sid,
name=name or "", name=name or "",
@ -432,6 +480,8 @@ def setup_session_routes(
model=model_to_use, model=model_to_use,
rag=str(rag).lower() == "true" if rag else False, rag=str(rag).lower() == "true" if rag else False,
owner=user, owner=user,
reasoning_effort=saved_reasoning,
verbosity=saved_verbosity,
) )
# Set auth headers for custom API-key endpoints # Set auth headers for custom API-key endpoints
resolved_key = request_api_key resolved_key = request_api_key
@ -456,7 +506,9 @@ def setup_session_routes(
name=session.name, name=session.name,
model=model_to_use, model=model_to_use,
rag=str(rag).lower() == "true" if rag else False, rag=str(rag).lower() == "true" if rag else False,
archived=False archived=False,
reasoning_effort=saved_reasoning,
verbosity=saved_verbosity,
) )
@router.patch("/session/{sid}") @router.patch("/session/{sid}")
def rename_session( def rename_session(
@ -464,6 +516,7 @@ def setup_session_routes(
name: str = Form(None), folder: str = Form(None), name: str = Form(None), folder: str = Form(None),
model: str = Form(None), endpoint_url: str = Form(None), model: str = Form(None), endpoint_url: str = Form(None),
endpoint_id: str = Form(None), endpoint_id: str = Form(None),
reasoning_effort: str = Form(None), verbosity: str = Form(None),
): ):
_verify_session_owner(request, sid) _verify_session_owner(request, sid)
try: try:
@ -534,6 +587,23 @@ def setup_session_routes(
db.close() db.close()
result["model"] = model result["model"] = model
result["endpoint_url"] = endpoint_url 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 return result
@router.post("/session/{sid}/inject_messages") @router.post("/session/{sid}/inject_messages")

View file

@ -3101,6 +3101,8 @@ async def stream_agent_loop(
forced_tools: Optional[Set[str]] = None, forced_tools: Optional[Set[str]] = None,
uploaded_files: Optional[List[Dict]] = None, uploaded_files: Optional[List[Dict]] = None,
workload: str = "foreground", workload: str = "foreground",
reasoning_effort: Optional[str] = None,
verbosity: Optional[str] = None,
_is_teacher_run: bool = False, _is_teacher_run: bool = False,
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""Streaming agent loop generator. """Streaming agent loop generator.
@ -3980,6 +3982,8 @@ async def stream_agent_loop(
timeout=agent_stream_timeout, timeout=agent_stream_timeout,
session_id=session_id, session_id=session_id,
workload=workload, workload=workload,
reasoning_effort=reasoning_effort,
verbosity=verbosity,
): ):
if not _round_first_event_logged: if not _round_first_event_logged:
_round_first_event_logged = True _round_first_event_logged = True

View file

@ -617,6 +617,7 @@ def _build_ollama_payload(
stream: bool = False, stream: bool = False,
tools: Optional[List[Dict]] = None, tools: Optional[List[Dict]] = None,
num_ctx: Optional[int] = None, num_ctx: Optional[int] = None,
reasoning_effort: Optional[str] = None,
) -> Dict: ) -> Dict:
"""Build the JSON payload for Ollama's /api/chat endpoint. """Build the JSON payload for Ollama's /api/chat endpoint.
@ -645,6 +646,9 @@ def _build_ollama_payload(
payload["options"] = options payload["options"] = options
if tools: if tools:
payload["tools"] = tools payload["tools"] = tools
thinking_enabled = _ollama_think_value(model, reasoning_effort)
if thinking_enabled is not None:
payload["think"] = thinking_enabled
return payload return payload
@ -1110,6 +1114,8 @@ def _build_chatgpt_responses_payload(
max_tokens: int, max_tokens: int,
*, *,
stream: bool = False, stream: bool = False,
reasoning_effort: Optional[str] = None,
verbosity: Optional[str] = None,
) -> Dict: ) -> Dict:
from src.chatgpt_subscription import build_responses_input from src.chatgpt_subscription import build_responses_input
@ -1123,6 +1129,12 @@ def _build_chatgpt_responses_payload(
} }
if not _restricts_temperature(model): if not _restricts_temperature(model):
payload["temperature"] = temperature 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 — # ChatGPT Subscription Codex API does not support max_output_tokens —
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens". # passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
# Do not include it in the payload. # Do not include it in the payload.
@ -1311,6 +1323,105 @@ def _normalize_mistral_content(content):
return "".join(text_parts), "".join(thinking_parts) 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): def _convert_openai_content_to_anthropic(content):
"""Convert OpenAI multimodal content blocks to Anthropic format. """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, 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, 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.""" """Synchronous LLM call with optional prompt type enhancement."""
h = _provider_headers(_detect_provider(url)) h = _provider_headers(_detect_provider(url))
# Tolerate headers that arrive as a JSON string (some sessions stored them # 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( payload = _build_ollama_payload(
model, messages_copy, temperature, max_tokens, model, messages_copy, temperature, max_tokens,
stream=False, num_ctx=get_context_length(url, model), stream=False, num_ctx=get_context_length(url, model),
reasoning_effort=reasoning_effort,
) )
else: else:
target_url = _normalize_openai_chat_url(url) target_url = _normalize_openai_chat_url(url)
@ -1970,6 +2083,8 @@ async def llm_call_async(
prompt_type: Optional[str] = None, prompt_type: Optional[str] = None,
session_id: Optional[str] = None, session_id: Optional[str] = None,
workload: str = "foreground", workload: str = "foreground",
reasoning_effort: Optional[str] = None,
verbosity: Optional[str] = None,
) -> str: ) -> str:
"""Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging.""" """Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging."""
provider = _detect_provider(url) provider = _detect_provider(url)
@ -2008,6 +2123,8 @@ async def llm_call_async(
headers=headers, headers=headers,
timeout=timeout, timeout=timeout,
workload=workload, workload=workload,
reasoning_effort=reasoning_effort,
verbosity=verbosity,
): ):
event_is_error = False event_is_error = False
for line in str(chunk).splitlines(): for line in str(chunk).splitlines():
@ -2050,6 +2167,7 @@ async def llm_call_async(
payload = _build_ollama_payload( payload = _build_ollama_payload(
model, messages_copy, temperature, max_tokens, model, messages_copy, temperature, max_tokens,
stream=False, num_ctx=get_context_length(url, model), stream=False, num_ctx=get_context_length(url, model),
reasoning_effort=reasoning_effort,
) )
else: else:
target_url = _normalize_openai_chat_url(url) 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, max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None, timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
tools: Optional[List[Dict]] = None, session_id: 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) target_url = _stream_target_url(url)
async with _local_model_slot(target_url, model, workload): async with _local_model_slot(target_url, model, workload):
async for chunk in _stream_llm_inner( 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, tools=tools,
session_id=session_id, session_id=session_id,
tool_choice_none=tool_choice_none, tool_choice_none=tool_choice_none,
reasoning_effort=reasoning_effort,
verbosity=verbosity,
): ):
yield chunk 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, max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None, timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
tools: Optional[List[Dict]] = None, session_id: 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. """Stream LLM responses with improved error handling.
Yields SSE chunks: Yields SSE chunks:
@ -2205,11 +2327,15 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
payload = _build_ollama_payload( payload = _build_ollama_payload(
model, messages_copy, temperature, max_tokens, model, messages_copy, temperature, max_tokens,
stream=True, tools=tools, num_ctx=get_context_length(url, model), stream=True, tools=tools, num_ctx=get_context_length(url, model),
reasoning_effort=reasoning_effort,
) )
elif provider == "chatgpt-subscription": elif provider == "chatgpt-subscription":
target_url = _normalize_chatgpt_subscription_url(url) target_url = _normalize_chatgpt_subscription_url(url)
h = _provider_headers(provider, headers) 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: else:
target_url = _normalize_openai_chat_url(url) target_url = _normalize_openai_chat_url(url)
payload = { payload = {
@ -2235,11 +2361,11 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
# (high / medium / low / none); default "high". # (high / medium / low / none); default "high".
if provider == "mistral" and _supports_thinking(model): if provider == "mistral" and _supports_thinking(model):
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
# For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3, # Ollama /v1 accepts the same binary think flag as native /api/chat.
# gemma4, etc.), suppress thinking so tool calls aren't swallowed inside # Keep the current safe default for tools, but honor explicit user controls.
# <think> blocks. Ollama /v1 accepts "think": false as a top-level param.
if _is_ollama_openai_compat_url(url) and _supports_thinking(model): 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_cache_affinity(payload, url, session_id)
_apply_local_generation_stability(payload, target_url, model) _apply_local_generation_stability(payload, target_url, model)
_scrub_openai_chat_tool_reasoning(payload, target_url, model) _scrub_openai_chat_tool_reasoning(payload, target_url, model)

View file

@ -125,6 +125,8 @@ class SessionResponse(BaseModel):
model: str = Field(..., description="Model being used") model: str = Field(..., description="Model being used")
rag: bool = Field(default=False, description="RAG enabled") rag: bool = Field(default=False, description="RAG enabled")
archived: bool = Field(default=False, description="Whether session is archived") 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): class MemoryResponse(BaseModel):

View file

@ -134,6 +134,8 @@ DEFAULT_SETTINGS = {
"task_model": "", "task_model": "",
"default_endpoint_id": "", "default_endpoint_id": "",
"default_model": "", "default_model": "",
"default_reasoning_effort": "",
"default_verbosity": "",
# Optional prose style used only for normal document writing/editing. # Optional prose style used only for normal document writing/editing.
# Email replies use email_writing_style instead because greetings, # Email replies use email_writing_style instead because greetings,
# signatures, and mailbox identity rules are medium-specific. # 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 # account inherited whatever the most-recent admin picked, which then
# got injected into the chat composer on first open. # got injected into the chat composer on first open.
"default_endpoint_id", "default_model", "default_model_fallbacks", "default_endpoint_id", "default_model", "default_model_fallbacks",
"default_reasoning_effort", "default_verbosity",
"utility_endpoint_id", "utility_model", "utility_model_fallbacks", "utility_endpoint_id", "utility_model", "utility_model_fallbacks",
"research_endpoint_id", "research_model", "research_endpoint_id", "research_model",
} }

View file

@ -209,6 +209,10 @@ try {
if (cachedDefaultChat && cachedDefaultChat.endpoint_url && cachedDefaultChat.model) { if (cachedDefaultChat && cachedDefaultChat.endpoint_url && cachedDefaultChat.model) {
_defaultChat = cachedDefaultChat; _defaultChat = cachedDefaultChat;
window.__odysseusDefaultChat = cachedDefaultChat; window.__odysseusDefaultChat = cachedDefaultChat;
window.__odysseusModelControlDefaults = {
reasoning_effort: cachedDefaultChat.default_reasoning_effort || 'auto',
verbosity: cachedDefaultChat.default_verbosity || 'auto',
};
} }
} catch (_) {} } catch (_) {}
async function _refreshDefaultChat() { async function _refreshDefaultChat() {
@ -218,6 +222,10 @@ async function _refreshDefaultChat() {
_defaultChat = d; _defaultChat = d;
try { try {
window.__odysseusDefaultChat = d; 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)); localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(d));
} catch (_) {} } catch (_) {}
return d; return d;
@ -248,7 +256,11 @@ async function _createDirectChatFromPreferredModel() {
const dc = await _refreshDefaultChat(); const dc = await _refreshDefaultChat();
if (dc) { 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; return true;
} }
@ -370,6 +382,7 @@ function initializeEventListeners() {
document.querySelectorAll( document.querySelectorAll(
'.skill-kebab-menu, .note-reminder-menu, .task-dropdown, .doclib-card-dropdown, .email-card-dropdown, .msg-overflow-menu' '.skill-kebab-menu, .note-reminder-menu, .task-dropdown, .doclib-card-dropdown, .email-card-dropdown, .msg-overflow-menu'
).forEach(m => { if (m !== except) m.remove(); }); ).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 // Window-opening / nav controls (rail buttons, sidebar tool rows + session
// rows, section headers) count as "other actions" — dismiss popups when one // 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('web-toggle-btn', 'web-toggle', 'web');
setupToggle('bash-toggle-btn', 'bash-toggle', 'bash'); 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 (_) {} try { workspaceModule.initWorkspace(); } catch (_) {}
// Document editor toggle (special: uses module panel, not a checkbox) // Document editor toggle (special: uses module panel, not a checkbox)

View file

@ -1144,6 +1144,37 @@
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/> <polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg> </svg>
</button> </button>
<div class="model-control-wrapper" id="reasoning-control">
<button type="button" class="input-icon-btn model-control-btn" title="Reasoning effort" id="reasoning-control-btn" aria-label="Reasoning effort" aria-haspopup="true" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12c.6.5 1 1.2 1 2h6c0-.8.4-1.5 1-2a7 7 0 0 0-4-12z"/>
</svg>
<span class="model-control-label" id="reasoning-control-label">Auto</span>
</button>
<div id="reasoning-control-menu" class="model-control-menu hidden">
<button type="button" class="model-control-option" data-value="auto">Auto</button>
<button type="button" class="model-control-option" data-value="off">Off</button>
<button type="button" class="model-control-option" data-value="on">On</button>
<button type="button" class="model-control-option" data-value="minimal">Minimal</button>
<button type="button" class="model-control-option" data-value="low">Low</button>
<button type="button" class="model-control-option" data-value="medium">Medium</button>
<button type="button" class="model-control-option" data-value="high">High</button>
</div>
</div>
<div class="model-control-wrapper" id="verbosity-control">
<button type="button" class="input-icon-btn model-control-btn" title="Verbosity" id="verbosity-control-btn" aria-label="Verbosity" aria-haspopup="true" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M4 7h16"/><path d="M4 12h12"/><path d="M4 17h8"/>
</svg>
<span class="model-control-label" id="verbosity-control-label">Auto</span>
</button>
<div id="verbosity-control-menu" class="model-control-menu hidden">
<button type="button" class="model-control-option" data-value="auto">Auto</button>
<button type="button" class="model-control-option" data-value="low">Low</button>
<button type="button" class="model-control-option" data-value="medium">Medium</button>
<button type="button" class="model-control-option" data-value="high">High</button>
</div>
</div>
<!-- Workspace indicator (hidden until a folder is set) --> <!-- Workspace indicator (hidden until a folder is set) -->
<button type="button" class="input-icon-btn tool-indicator" title="Workspace - click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;"> <button type="button" class="input-icon-btn tool-indicator" title="Workspace - click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
<svg class="workspace-indicator-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg> <svg class="workspace-indicator-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
@ -1492,6 +1523,34 @@
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div> <div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div> </div>
</div> </div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12c.6.5 1 1.2 1 2h6c0-.8.4-1.5 1-2a7 7 0 0 0-4-12z"/></svg>Model Response Defaults</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Default controls for newly created chat sessions. Existing chats keep their saved settings.</div>
<div class="settings-col">
<div class="settings-row">
<label class="settings-label">Reasoning</label>
<select id="set-defaultReasoningSelect" class="settings-select">
<option value="">Auto</option>
<option value="off">Off</option>
<option value="on">On</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="settings-row">
<label class="settings-label">Verbosity</label>
<select id="set-defaultVerbositySelect" class="settings-select">
<option value="">Auto</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div id="set-defaultControlsMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
<div class="admin-card"> <div class="admin-card">
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Utility Model <span style="font-size:0.72em;opacity:0.55;font-weight:normal;">(Recommended: Local Endpoint)</span></h2> <h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Utility Model <span style="font-size:0.72em;opacity:0.55;font-weight:normal;">(Recommended: Local Endpoint)</span></h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Runs background tasks (compaction, cleanup, auto-naming, retrieving memories from files) on a small/local model instead of your chat model. Leave blank to use the chat model.</div> <div class="admin-toggle-sub" style="margin-bottom:8px">Runs background tasks (compaction, cleanup, auto-naming, retrieving memories from files) on a small/local model instead of your chat model. Leave blank to use the chat model.</div>

View file

@ -1273,6 +1273,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (dc && dc.endpoint_url && dc.model) { if (dc && dc.endpoint_url && dc.model) {
try { try {
window.__odysseusDefaultChat = dc; window.__odysseusDefaultChat = dc;
window.__odysseusModelControlDefaults = {
reasoning_effort: dc.default_reasoning_effort || 'auto',
verbosity: dc.default_verbosity || 'auto',
};
localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc)); localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc));
} catch (_) {} } catch (_) {}
} }
@ -1282,7 +1286,11 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
} }
if (dc.endpoint_url && dc.model) { if (dc.endpoint_url && dc.model) {
_sendPerf.mark('direct_chat_create_begin'); _sendPerf.mark('direct_chat_create_begin');
await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' }); await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, {
source: 'default',
reasoning_effort: dc.default_reasoning_effort || '',
verbosity: dc.default_verbosity || '',
});
_sendPerf.mark('direct_chat_create_done'); _sendPerf.mark('direct_chat_create_done');
const ok = await sessionModule.materializePendingSession(); const ok = await sessionModule.materializePendingSession();
_sendPerf.mark('direct_chat_materialize_done'); _sendPerf.mark('direct_chat_materialize_done');
@ -1679,6 +1687,14 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
isAgentMode = true; isAgentMode = true;
} }
fd.append('mode', isAgentMode ? 'agent' : 'chat'); fd.append('mode', isAgentMode ? 'agent' : 'chat');
const reasoningEffort = (toggleState.reasoning_effort || 'auto').toLowerCase();
if (reasoningEffort && reasoningEffort !== 'auto') {
fd.append('reasoning_effort', reasoningEffort);
}
const verbosity = (toggleState.verbosity || 'auto').toLowerCase();
if (verbosity && verbosity !== 'auto') {
fd.append('verbosity', verbosity);
}
fd.append('plan_mode', isPlanMode ? 'true' : 'false'); fd.append('plan_mode', isPlanMode ? 'true' : 'false');
if (!isPlanMode && _pendingApprovedPlan) { if (!isPlanMode && _pendingApprovedPlan) {
fd.append('approved_plan', _pendingApprovedPlan.slice(0, 8192)); fd.append('approved_plan', _pendingApprovedPlan.slice(0, 8192));

View file

@ -272,6 +272,20 @@ function _initModelPickerDropdown() {
} catch (_) { /* leave stale data; picker still works */ } } catch (_) { /* leave stale data; picker still works */ }
} }
async function _applyModelControlDefaults(m, persist, sessionId) {
if (!m || !window.odysseusModelControls || typeof window.odysseusModelControls.applyDefaultsForContext !== 'function') {
return;
}
try {
await window.odysseusModelControls.applyDefaultsForContext(
{ model: m.mid || '', endpointUrl: m.url || '' },
{ persist: !!persist, sessionId: sessionId || null },
);
} catch (e) {
console.warn('Model picker: failed to apply model control defaults', e);
}
}
function _getAllModels() { function _getAllModels() {
const items = (window.modelsModule && window.modelsModule.getCachedItems) ? window.modelsModule.getCachedItems() : []; const items = (window.modelsModule && window.modelsModule.getCachedItems) ? window.modelsModule.getCachedItems() : [];
const result = []; const result = [];
@ -683,6 +697,7 @@ async function _pick(m) {
if (!currentSessionId && _pendingChat) { if (!currentSessionId && _pendingChat) {
// Already have a deferred session — just update the model // Already have a deferred session — just update the model
_deps.setPendingChat({ url: m.url, modelId: m.mid, endpointId: m.endpointId, source: 'manual' }); _deps.setPendingChat({ url: m.url, modelId: m.mid, endpointId: m.endpointId, source: 'manual' });
await _applyModelControlDefaults(m, false);
// Header stays as session name — model switch only updates picker // Header stays as session name — model switch only updates picker
updateModelPicker(); updateModelPicker();
uiModule.showToast(`Using ${m.display}`); uiModule.showToast(`Using ${m.display}`);
@ -692,6 +707,7 @@ async function _pick(m) {
// No session yet — create one with this model // No session yet — create one with this model
try { try {
await _deps.createDirectChat(m.url, m.mid, m.endpointId); await _deps.createDirectChat(m.url, m.mid, m.endpointId);
await _applyModelControlDefaults(m, false);
} catch (e) { } catch (e) {
uiModule.showError('Failed to start chat: ' + e); uiModule.showError('Failed to start chat: ' + e);
finishSwitch(); finishSwitch();
@ -714,6 +730,7 @@ async function _pick(m) {
finishSwitch(); finishSwitch();
return; return;
} }
await _applyModelControlDefaults(m, true, currentSessionId);
// Header stays as session name — model info shown in picker only // Header stays as session name — model info shown in picker only
} catch (e) { } catch (e) {
uiModule.showError('Failed to set model: ' + e); uiModule.showError('Failed to set model: ' + e);

View file

@ -1801,7 +1801,11 @@ export async function loadSessions() {
try { try {
const dc = await _getPreferredDefaultChat(); const dc = await _getPreferredDefaultChat();
if (dc && dc.endpoint_url && dc.model) { if (dc && dc.endpoint_url && dc.model) {
await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' }); await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, {
source: 'default',
reasoning_effort: dc.default_reasoning_effort || '',
verbosity: dc.default_verbosity || '',
});
} }
} catch (_) { /* no default model — that's fine, user can /setup */ } } catch (_) { /* no default model — that's fine, user can /setup */ }
_autoCreateInProgress = false; _autoCreateInProgress = false;
@ -1854,6 +1858,9 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
if (presetsModule && presetsModule.onSessionSwitch) presetsModule.onSessionSwitch(id); if (presetsModule && presetsModule.onSessionSwitch) presetsModule.onSessionSwitch(id);
} catch (e) {} } catch (e) {}
const meta = sessions.find(s => s.id === id); const meta = sessions.find(s => s.id === id);
if (window.odysseusModelControls && window.odysseusModelControls.applySession) {
window.odysseusModelControls.applySession(meta || {});
}
// Detach any in-flight stream to background instead of aborting // Detach any in-flight stream to background instead of aborting
try { try {
@ -2145,7 +2152,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
} }
// Pending session — stored locally until the first message is sent // Pending session — stored locally until the first message is sent
let _pendingChat = null; // { url, modelId, endpointId } let _pendingChat = null; // { url, modelId, endpointId, reasoning_effort, verbosity }
let _pendingMaterializePromise = null; let _pendingMaterializePromise = null;
async function _getPreferredDefaultChat() { async function _getPreferredDefaultChat() {
@ -2158,13 +2165,25 @@ async function _getPreferredDefaultChat() {
dc = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null'); dc = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
} catch (_) {} } catch (_) {}
} }
if (dc && dc.endpoint_url && dc.model) return dc; if (dc && dc.endpoint_url && dc.model) {
try {
window.__odysseusModelControlDefaults = {
reasoning_effort: dc.default_reasoning_effort || 'auto',
verbosity: dc.default_verbosity || 'auto',
};
} catch (_) {}
return dc;
}
try { try {
const dcRes = await fetch(`${API_BASE}/api/default-chat`); const dcRes = await fetch(`${API_BASE}/api/default-chat`);
dc = await dcRes.json(); dc = await dcRes.json();
if (dc && dc.endpoint_url && dc.model) { if (dc && dc.endpoint_url && dc.model) {
try { try {
window.__odysseusDefaultChat = dc; window.__odysseusDefaultChat = dc;
window.__odysseusModelControlDefaults = {
reasoning_effort: dc.default_reasoning_effort || 'auto',
verbosity: dc.default_verbosity || 'auto',
};
localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc)); localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc));
} catch (_) {} } catch (_) {}
return dc; return dc;
@ -2173,8 +2192,56 @@ async function _getPreferredDefaultChat() {
return null; return null;
} }
function _normalizePendingReasoning(value) {
let v = String(value || '').trim().toLowerCase().replace(/-/g, '_');
if (v === 'x_high') v = 'xhigh';
if (v === 'none') v = 'off';
return ['off', 'on', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(v) ? v : '';
}
function _normalizePendingVerbosity(value) {
const v = String(value || '').trim().toLowerCase();
return ['low', 'medium', 'high'].includes(v) ? v : '';
}
function _resolveInitialModelControls(modelControls) {
const defaults = (window.odysseusModelControls && window.odysseusModelControls.getDefaults)
? window.odysseusModelControls.getDefaults()
: (window.__odysseusModelControlDefaults || {});
const source = modelControls || defaults || {};
return {
reasoning_effort: _normalizePendingReasoning(source.reasoning_effort || source.default_reasoning_effort || ''),
verbosity: _normalizePendingVerbosity(source.verbosity || source.default_verbosity || ''),
};
}
function _hasExplicitModelControls(modelControls) {
if (!modelControls) return false;
return Object.prototype.hasOwnProperty.call(modelControls, 'reasoning_effort')
|| Object.prototype.hasOwnProperty.call(modelControls, 'default_reasoning_effort')
|| Object.prototype.hasOwnProperty.call(modelControls, 'verbosity')
|| Object.prototype.hasOwnProperty.call(modelControls, 'default_verbosity');
}
function _defaultsForPendingModel(pending) {
if (
window.odysseusModelControls
&& typeof window.odysseusModelControls.resolveDefaultsForContext === 'function'
) {
return window.odysseusModelControls.resolveDefaultsForContext({
model: (pending && pending.modelId) || '',
endpointUrl: (pending && pending.url) || '',
});
}
return _resolveInitialModelControls(null);
}
export function createDirectChat(url, modelId, endpointId, opts = {}) { export function createDirectChat(url, modelId, endpointId, opts = {}) {
const incomingSource = opts.source || 'manual'; const incomingSource = opts.source || 'manual';
const defaults = _defaultsForPendingModel({ url, modelId, endpointId });
const initialControls = _resolveInitialModelControls(
_hasExplicitModelControls(opts) ? opts : defaults,
);
if ( if (
_pendingChat && _pendingChat &&
_pendingChat.modelId && _pendingChat.modelId &&
@ -2197,8 +2264,23 @@ export function createDirectChat(url, modelId, endpointId, opts = {}) {
} }
// Don't hit the API — just store the model info and prepare the UI // Don't hit the API — just store the model info and prepare the UI
_pendingChat = { url, modelId, endpointId, source: incomingSource }; _pendingChat = {
url,
modelId,
endpointId,
source: incomingSource,
reasoning_effort: initialControls.reasoning_effort || null,
verbosity: initialControls.verbosity || null,
};
_pendingMaterializePromise = null; _pendingMaterializePromise = null;
if (window.odysseusModelControls && window.odysseusModelControls.applySession) {
window.odysseusModelControls.applySession({
model: modelId || '',
endpoint_url: url || '',
reasoning_effort: initialControls.reasoning_effort || null,
verbosity: initialControls.verbosity || null,
});
}
_skipAutoSelect = true; _skipAutoSelect = true;
_suppressNextSessionLoading = true; _suppressNextSessionLoading = true;
currentSessionId = null; currentSessionId = null;
@ -2270,6 +2352,19 @@ export async function materializePendingSession() {
if (pending.endpointId) { if (pending.endpointId) {
fd.append('endpoint_id', pending.endpointId); fd.append('endpoint_id', pending.endpointId);
} }
const modelControls = {
...Storage.loadToggleState(),
reasoning_effort: pending.reasoning_effort || Storage.loadToggleState().reasoning_effort || 'auto',
verbosity: pending.verbosity || Storage.loadToggleState().verbosity || 'auto',
};
const reasoningEffort = String(modelControls.reasoning_effort || 'auto').toLowerCase();
const verbosity = String(modelControls.verbosity || 'auto').toLowerCase();
if (reasoningEffort && reasoningEffort !== 'auto') {
fd.append('reasoning_effort', reasoningEffort);
}
if (verbosity && verbosity !== 'auto') {
fd.append('verbosity', verbosity);
}
let res; let res;
try { try {
@ -2771,7 +2866,20 @@ function _initAllDropdowns() {
getCurrentSessionId: () => currentSessionId, getCurrentSessionId: () => currentSessionId,
getSessions: () => sessions, getSessions: () => sessions,
getPendingChat: () => _pendingChat, getPendingChat: () => _pendingChat,
setPendingChat: (v) => { _pendingChat = v; }, setPendingChat: (v) => {
const controls = _hasExplicitModelControls(v)
? _resolveInitialModelControls(v)
: _resolveInitialModelControls(_defaultsForPendingModel(v));
_pendingChat = v ? { ...v, ...controls } : v;
if (_pendingChat && window.odysseusModelControls && window.odysseusModelControls.applySession) {
window.odysseusModelControls.applySession({
model: _pendingChat.modelId || '',
endpoint_url: _pendingChat.url || '',
reasoning_effort: _pendingChat.reasoning_effort || null,
verbosity: _pendingChat.verbosity || null,
});
}
},
createDirectChat, createDirectChat,
}); });
_initDropdownDismiss(); _initDropdownDismiss();

View file

@ -447,6 +447,9 @@ async function initDefaultChat() {
var msg = el('set-defaultChatMsg'); var msg = el('set-defaultChatMsg');
var fbContainer = el('set-defaultFallbacks'); var fbContainer = el('set-defaultFallbacks');
var addFbBtn = el('set-defaultAddFallback'); var addFbBtn = el('set-defaultAddFallback');
var reasoningSel = el('set-defaultReasoningSelect');
var verbositySel = el('set-defaultVerbositySelect');
var controlsMsg = el('set-defaultControlsMsg');
var _endpoints = []; var _endpoints = [];
var _fallbacks = []; // [{endpoint_id, model}] — tried in order if primary fails var _fallbacks = []; // [{endpoint_id, model}] — tried in order if primary fails
@ -471,6 +474,28 @@ async function initDefaultChat() {
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value); refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
renderFallbacks(); renderFallbacks();
} }
function normalizeReasoningDefault(value) {
var v = String(value || 'auto').toLowerCase().replace(/-/g, '_');
if (v === 'none') v = 'off';
return ['auto', 'off', 'on', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(v) ? v : 'auto';
}
function normalizeVerbosityDefault(value) {
var v = String(value || 'auto').toLowerCase();
return ['auto', 'low', 'medium', 'high'].includes(v) ? v : 'auto';
}
function currentControlDefaults() {
return {
reasoning_effort: normalizeReasoningDefault(reasoningSel ? reasoningSel.value : ''),
verbosity: normalizeVerbosityDefault(verbositySel ? verbositySel.value : ''),
};
}
function publishControlDefaults() {
var defaults = currentControlDefaults();
try { window.__odysseusModelControlDefaults = defaults; } catch (_) {}
if (window.odysseusModelControls && window.odysseusModelControls.setDefaults) {
window.odysseusModelControls.setDefaults(defaults);
}
}
// Render the fallback chain. Each row is endpoint + model + remove. // Render the fallback chain. Each row is endpoint + model + remove.
function renderFallbacks() { function renderFallbacks() {
@ -539,6 +564,9 @@ async function initDefaultChat() {
return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' }; return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' };
}) })
: []; : [];
if (reasoningSel) reasoningSel.value = normalizeReasoningDefault(settings.default_reasoning_effort);
if (verbositySel) verbositySel.value = normalizeVerbosityDefault(settings.default_verbosity);
publishControlDefaults();
renderFallbacks(); renderFallbacks();
} catch (e) { console.warn('Failed to load default chat settings', e); } } catch (e) { console.warn('Failed to load default chat settings', e); }
@ -553,14 +581,25 @@ async function initDefaultChat() {
body: JSON.stringify({ body: JSON.stringify({
default_endpoint_id: epSel.value, default_endpoint_id: epSel.value,
default_model: modelSel.value, default_model: modelSel.value,
default_model_fallbacks: clean default_model_fallbacks: clean,
default_reasoning_effort: currentControlDefaults().reasoning_effort,
default_verbosity: currentControlDefaults().verbosity
}) })
}); });
publishControlDefaults();
if (controlsMsg) {
controlsMsg.textContent = 'Saved';
controlsMsg.style.color = 'var(--fg)';
setTimeout(function() { controlsMsg.textContent = ''; }, 2000);
}
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000); setTimeout(function() { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
} }
if (reasoningSel) reasoningSel.addEventListener('change', saveDefault);
if (verbositySel) verbositySel.addEventListener('change', saveDefault);
if (addFbBtn) addFbBtn.addEventListener('click', function() { if (addFbBtn) addFbBtn.addEventListener('click', function() {
var first = enabledEndpoints()[0]; var first = enabledEndpoints()[0];
_fallbacks.push({ endpoint_id: first ? first.id : '', model: '' }); _fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });

View file

@ -2670,6 +2670,102 @@ body.bg-pattern-sparkles {
color: var(--accent); color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, transparent); background: color-mix(in srgb, var(--accent) 14%, transparent);
} }
.model-control-wrapper {
flex-shrink: 0;
position: relative;
}
.model-control-btn {
gap: 4px;
min-width: 54px;
height: 28px;
padding: 0 7px;
border: 1px solid transparent;
box-sizing: border-box;
}
.model-control-btn.active,
.model-control-btn.expanded {
border-color: color-mix(in srgb, var(--accent, var(--red)) 28%, transparent);
background: color-mix(in srgb, var(--accent, var(--red)) 11%, transparent);
}
.model-control-btn svg {
flex-shrink: 0;
opacity: 0.8;
}
.model-control-label {
font-size: 10px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
max-width: 48px;
overflow: hidden;
text-overflow: ellipsis;
}
.model-control-menu {
position: fixed;
z-index: 10020;
min-width: 118px;
padding: 5px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
display: flex;
flex-direction: column;
gap: 2px;
transform-origin: bottom left;
animation: overflow-menu-pop 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.model-control-menu.hidden {
display: none;
}
.model-control-option {
appearance: none;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 26px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: none;
color: color-mix(in srgb, var(--fg) 70%, transparent);
cursor: pointer;
font-family: inherit;
font-size: 11px;
text-align: left;
}
.model-control-option:hover,
.model-control-option.active {
color: var(--fg);
background: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);
}
.model-control-wrapper.disabled {
opacity: 0.58;
}
.model-control-btn:disabled {
cursor: not-allowed;
}
.model-control-option:disabled,
.model-control-option.disabled {
color: color-mix(in srgb, var(--muted) 70%, transparent);
cursor: not-allowed;
opacity: 0.55;
}
.model-control-option:disabled:hover,
.model-control-option.disabled:hover {
color: color-mix(in srgb, var(--muted) 70%, transparent);
background: none;
}
.model-control-option.active::after {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent, var(--red));
margin-left: 10px;
flex-shrink: 0;
}
/* GitHub-style task lists (- [ ] / - [x]) */ /* GitHub-style task lists (- [ ] / - [x]) */
li.task-item { li.task-item {
list-style: none; list-style: none;

View file

@ -0,0 +1,252 @@
"""Tests for optional reasoning-effort and verbosity request controls."""
import asyncio
from src import llm_core
class _FakeResp:
status_code = 200
async def aiter_lines(self):
yield 'data: {"choices":[{"delta":{"content":"ok"}}]}'
yield "data: [DONE]"
async def aread(self):
return b""
class _FakeStreamCtx:
def __init__(self, captured):
self._captured = captured
async def __aenter__(self):
return _FakeResp()
async def __aexit__(self, *exc):
return False
class _FakeClient:
def __init__(self):
self.captured_payload = {}
def stream(self, method, url, **kwargs):
self.captured_payload = kwargs.get("json") or {}
return _FakeStreamCtx(self.captured_payload)
def _capture_stream_payload(monkeypatch, url, model, **kwargs):
client = _FakeClient()
monkeypatch.setattr(llm_core, "_get_http_client", lambda: client)
monkeypatch.setattr(llm_core, "_is_host_dead", lambda u: False)
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a, **k: None)
monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *a, **k: None)
monkeypatch.setattr(llm_core, "get_context_length", lambda u, m: 32768)
async def run():
return [chunk async for chunk in llm_core.stream_llm(
url,
model,
[{"role": "user", "content": "Hi"}],
**kwargs,
)]
asyncio.run(run())
return client.captured_payload
def test_chatgpt_subscription_payload_adds_supported_controls():
payload = llm_core._build_chatgpt_responses_payload(
"gpt-5.1-codex",
[{"role": "user", "content": "Hi"}],
temperature=0.2,
max_tokens=0,
reasoning_effort="high",
verbosity="low",
)
assert payload["reasoning"] == {"effort": "high"}
assert payload["text"] == {"verbosity": "low"}
def test_chatgpt_subscription_payload_maps_off_to_none_for_newer_gpt5():
payload = llm_core._build_chatgpt_responses_payload(
"gpt-5.5",
[{"role": "user", "content": "Hi"}],
temperature=0.2,
max_tokens=0,
reasoning_effort="off",
)
assert payload["reasoning"] == {"effort": "none"}
def test_chatgpt_subscription_payload_omits_none_for_pre_5_1_gpt5():
payload = llm_core._build_chatgpt_responses_payload(
"gpt-5",
[{"role": "user", "content": "Hi"}],
temperature=0.2,
max_tokens=0,
reasoning_effort="off",
)
assert "reasoning" not in payload
def test_chatgpt_subscription_payload_auto_omits_controls():
payload = llm_core._build_chatgpt_responses_payload(
"gpt-5.1-codex",
[{"role": "user", "content": "Hi"}],
temperature=0.2,
max_tokens=0,
reasoning_effort="auto",
verbosity="auto",
)
assert "reasoning" not in payload
assert "text" not in payload
def test_chatgpt_subscription_payload_unsupported_model_omits_controls():
payload = llm_core._build_chatgpt_responses_payload(
"gpt-4o",
[{"role": "user", "content": "Hi"}],
temperature=0.2,
max_tokens=0,
reasoning_effort="high",
verbosity="high",
)
assert "reasoning" not in payload
assert "text" not in payload
def test_chatgpt_subscription_payload_o_series_omits_minimal_reasoning():
payload = llm_core._build_chatgpt_responses_payload(
"o3-mini",
[{"role": "user", "content": "Hi"}],
temperature=0.2,
max_tokens=0,
reasoning_effort="minimal",
verbosity="high",
)
assert "reasoning" not in payload
assert "text" not in payload
def test_chatgpt_subscription_payload_o_series_accepts_high_reasoning():
payload = llm_core._build_chatgpt_responses_payload(
"o3-mini",
[{"role": "user", "content": "Hi"}],
temperature=0.2,
max_tokens=0,
reasoning_effort="high",
)
assert payload["reasoning"] == {"effort": "high"}
def test_chatgpt_subscription_payload_invalid_values_omit_controls():
payload = llm_core._build_chatgpt_responses_payload(
"gpt-5.1-codex",
[{"role": "user", "content": "Hi"}],
temperature=0.2,
max_tokens=0,
reasoning_effort="maximum",
verbosity="long",
)
assert "reasoning" not in payload
assert "text" not in payload
def test_ollama_native_reasoning_control_maps_to_binary_think():
messages = [{"role": "user", "content": "Hi"}]
auto_payload = llm_core._build_ollama_payload(
"qwen3:14b",
messages,
temperature=0.2,
max_tokens=0,
reasoning_effort="auto",
)
off_payload = llm_core._build_ollama_payload(
"qwen3:14b",
messages,
temperature=0.2,
max_tokens=0,
reasoning_effort="off",
)
on_payload = llm_core._build_ollama_payload(
"qwen3:14b",
messages,
temperature=0.2,
max_tokens=0,
reasoning_effort="high",
)
assert "think" not in auto_payload
assert off_payload["think"] is False
assert on_payload["think"] is True
def test_generic_openai_compatible_endpoint_does_not_receive_controls(monkeypatch):
payload = _capture_stream_payload(
monkeypatch,
"https://api.openai.com/v1/chat/completions",
"gpt-5.1-codex",
reasoning_effort="high",
verbosity="high",
)
assert payload["model"] == "gpt-5.1-codex"
assert "reasoning" not in payload
assert "text" not in payload
assert "reasoning_effort" not in payload
assert "verbosity" not in payload
def test_ollama_openai_compat_auto_preserves_think_false(monkeypatch):
payload = _capture_stream_payload(
monkeypatch,
"http://127.0.0.1:11434/v1/chat/completions",
"qwen3:14b",
reasoning_effort="auto",
)
assert payload["think"] is False
def test_ollama_openai_compat_explicit_reasoning_enables_think(monkeypatch):
payload = _capture_stream_payload(
monkeypatch,
"http://127.0.0.1:11434/v1/chat/completions",
"qwen3:14b",
reasoning_effort="high",
)
assert payload["think"] is True
def test_ollama_openai_compat_off_disables_think(monkeypatch):
payload = _capture_stream_payload(
monkeypatch,
"http://127.0.0.1:11434/v1/chat/completions",
"qwen3:14b",
reasoning_effort="off",
)
assert payload["think"] is False
def test_ollama_openai_compat_non_thinking_model_omits_think(monkeypatch):
payload = _capture_stream_payload(
monkeypatch,
"http://127.0.0.1:11434/v1/chat/completions",
"llama3.2:3b",
reasoning_effort="high",
)
assert "think" not in payload

View file

@ -203,6 +203,8 @@ def test_default_chat_does_not_auto_pick_shared_endpoint_for_fresh_user(monkeypa
"endpoint_id": "", "endpoint_id": "",
"endpoint_url": "", "endpoint_url": "",
"model": "", "model": "",
"default_reasoning_effort": "",
"default_verbosity": "",
} }
@ -245,6 +247,8 @@ def test_default_chat_uses_owned_endpoint_as_regular_user_last_resort(monkeypatc
"endpoint_id": "owned", "endpoint_id": "owned",
"endpoint_url": "http://localhost:11434/chat/completions", "endpoint_url": "http://localhost:11434/chat/completions",
"model": "owned-model", "model": "owned-model",
"default_reasoning_effort": "",
"default_verbosity": "",
} }