feat(chat): add reasoning and verbosity controls

This commit is contained in:
Matyas Fenyves 2026-06-11 20:57:07 +02:00
parent 25c9e735ef
commit 380e5305a6
14 changed files with 980 additions and 16 deletions

View file

@ -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()

View file

@ -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:

View file

@ -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

View file

@ -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()

View file

@ -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 = datetime.utcnow()
db.commit()
finally:
db.close()
result["reasoning_effort"] = saved_reasoning
result["verbosity"] = saved_verbosity
return result
@router.post("/session/{sid}/inject_messages")

View file

@ -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

View file

@ -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)

View file

@ -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):

View file

@ -370,6 +370,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 +1952,291 @@ 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 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 => {
const u = String(url || '').toLowerCase();
return u.includes('chatgpt.com') || u.includes('chatgpt-subscription');
};
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) {
const sid = 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));
}
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);
}
}
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();
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();
},
refreshCapabilities,
};
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)

View file

@ -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>

View file

@ -1679,6 +1679,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));

View file

@ -1854,6 +1854,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 {
@ -2199,6 +2202,14 @@ 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 };
_pendingMaterializePromise = null;
if (window.odysseusModelControls && window.odysseusModelControls.applySession) {
window.odysseusModelControls.applySession({
model: modelId || '',
endpoint_url: url || '',
reasoning_effort: null,
verbosity: null,
});
}
_skipAutoSelect = true;
_suppressNextSessionLoading = true;
currentSessionId = null;
@ -2270,6 +2281,15 @@ export async function materializePendingSession() {
if (pending.endpointId) {
fd.append('endpoint_id', pending.endpointId);
}
const modelControls = Storage.loadToggleState();
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 {

View file

@ -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;

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