mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
Merge befe1b789b into 20e7fc0164
This commit is contained in:
commit
5f7817b630
20 changed files with 1324 additions and 26 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
142
src/llm_core.py
142
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
|
||||
# <think> 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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
396
static/app.js
396
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)
|
||||
|
|
|
|||
|
|
@ -1144,6 +1144,37 @@
|
|||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</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) -->
|
||||
<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>
|
||||
|
|
@ -1492,6 +1523,34 @@
|
|||
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1273,6 +1273,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||
if (dc && dc.endpoint_url && dc.model) {
|
||||
try {
|
||||
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));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -1282,7 +1286,11 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||
}
|
||||
if (dc.endpoint_url && dc.model) {
|
||||
_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');
|
||||
const ok = await sessionModule.materializePendingSession();
|
||||
_sendPerf.mark('direct_chat_materialize_done');
|
||||
|
|
@ -1679,6 +1687,14 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||
isAgentMode = true;
|
||||
}
|
||||
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');
|
||||
if (!isPlanMode && _pendingApprovedPlan) {
|
||||
fd.append('approved_plan', _pendingApprovedPlan.slice(0, 8192));
|
||||
|
|
|
|||
|
|
@ -272,6 +272,20 @@ function _initModelPickerDropdown() {
|
|||
} 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() {
|
||||
const items = (window.modelsModule && window.modelsModule.getCachedItems) ? window.modelsModule.getCachedItems() : [];
|
||||
const result = [];
|
||||
|
|
@ -683,6 +697,7 @@ async function _pick(m) {
|
|||
if (!currentSessionId && _pendingChat) {
|
||||
// Already have a deferred session — just update the model
|
||||
_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
|
||||
updateModelPicker();
|
||||
uiModule.showToast(`Using ${m.display}`);
|
||||
|
|
@ -692,6 +707,7 @@ async function _pick(m) {
|
|||
// No session yet — create one with this model
|
||||
try {
|
||||
await _deps.createDirectChat(m.url, m.mid, m.endpointId);
|
||||
await _applyModelControlDefaults(m, false);
|
||||
} catch (e) {
|
||||
uiModule.showError('Failed to start chat: ' + e);
|
||||
finishSwitch();
|
||||
|
|
@ -714,6 +730,7 @@ async function _pick(m) {
|
|||
finishSwitch();
|
||||
return;
|
||||
}
|
||||
await _applyModelControlDefaults(m, true, currentSessionId);
|
||||
// Header stays as session name — model info shown in picker only
|
||||
} catch (e) {
|
||||
uiModule.showError('Failed to set model: ' + e);
|
||||
|
|
|
|||
|
|
@ -1801,7 +1801,11 @@ export async function loadSessions() {
|
|||
try {
|
||||
const dc = await _getPreferredDefaultChat();
|
||||
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 */ }
|
||||
_autoCreateInProgress = false;
|
||||
|
|
@ -1854,6 +1858,9 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||
if (presetsModule && presetsModule.onSessionSwitch) presetsModule.onSessionSwitch(id);
|
||||
} catch (e) {}
|
||||
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
|
||||
try {
|
||||
|
|
@ -2145,7 +2152,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||
}
|
||||
|
||||
// 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;
|
||||
|
||||
async function _getPreferredDefaultChat() {
|
||||
|
|
@ -2158,13 +2165,25 @@ async function _getPreferredDefaultChat() {
|
|||
dc = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
|
||||
} 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 {
|
||||
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
|
||||
dc = await dcRes.json();
|
||||
if (dc && dc.endpoint_url && dc.model) {
|
||||
try {
|
||||
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));
|
||||
} catch (_) {}
|
||||
return dc;
|
||||
|
|
@ -2173,8 +2192,56 @@ async function _getPreferredDefaultChat() {
|
|||
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 = {}) {
|
||||
const incomingSource = opts.source || 'manual';
|
||||
const defaults = _defaultsForPendingModel({ url, modelId, endpointId });
|
||||
const initialControls = _resolveInitialModelControls(
|
||||
_hasExplicitModelControls(opts) ? opts : defaults,
|
||||
);
|
||||
if (
|
||||
_pendingChat &&
|
||||
_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
|
||||
_pendingChat = { url, modelId, endpointId, source: incomingSource };
|
||||
_pendingChat = {
|
||||
url,
|
||||
modelId,
|
||||
endpointId,
|
||||
source: incomingSource,
|
||||
reasoning_effort: initialControls.reasoning_effort || null,
|
||||
verbosity: initialControls.verbosity || 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;
|
||||
_suppressNextSessionLoading = true;
|
||||
currentSessionId = null;
|
||||
|
|
@ -2270,6 +2352,19 @@ export async function materializePendingSession() {
|
|||
if (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;
|
||||
try {
|
||||
|
|
@ -2771,7 +2866,20 @@ function _initAllDropdowns() {
|
|||
getCurrentSessionId: () => currentSessionId,
|
||||
getSessions: () => sessions,
|
||||
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,
|
||||
});
|
||||
_initDropdownDismiss();
|
||||
|
|
|
|||
|
|
@ -447,6 +447,9 @@ async function initDefaultChat() {
|
|||
var msg = el('set-defaultChatMsg');
|
||||
var fbContainer = el('set-defaultFallbacks');
|
||||
var addFbBtn = el('set-defaultAddFallback');
|
||||
var reasoningSel = el('set-defaultReasoningSelect');
|
||||
var verbositySel = el('set-defaultVerbositySelect');
|
||||
var controlsMsg = el('set-defaultControlsMsg');
|
||||
var _endpoints = [];
|
||||
var _fallbacks = []; // [{endpoint_id, model}] — tried in order if primary fails
|
||||
|
||||
|
|
@ -471,6 +474,28 @@ async function initDefaultChat() {
|
|||
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
|
||||
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.
|
||||
function renderFallbacks() {
|
||||
|
|
@ -539,6 +564,9 @@ async function initDefaultChat() {
|
|||
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();
|
||||
} catch (e) { console.warn('Failed to load default chat settings', e); }
|
||||
|
||||
|
|
@ -553,14 +581,25 @@ async function initDefaultChat() {
|
|||
body: JSON.stringify({
|
||||
default_endpoint_id: epSel.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)';
|
||||
setTimeout(function() { msg.textContent = ''; }, 2000);
|
||||
} 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() {
|
||||
var first = enabledEndpoints()[0];
|
||||
_fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });
|
||||
|
|
|
|||
|
|
@ -2670,6 +2670,102 @@ body.bg-pattern-sparkles {
|
|||
color: var(--accent);
|
||||
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]) */
|
||||
li.task-item {
|
||||
list-style: none;
|
||||
|
|
|
|||
252
tests/test_llm_core_model_controls.py
Normal file
252
tests/test_llm_core_model_controls.py
Normal 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
|
||||
|
|
@ -203,6 +203,8 @@ def test_default_chat_does_not_auto_pick_shared_endpoint_for_fresh_user(monkeypa
|
|||
"endpoint_id": "",
|
||||
"endpoint_url": "",
|
||||
"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_url": "http://localhost:11434/chat/completions",
|
||||
"model": "owned-model",
|
||||
"default_reasoning_effort": "",
|
||||
"default_verbosity": "",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue