diff --git a/api_provider.py b/api_provider.py
index ac86c6d..b7b78ff 100644
--- a/api_provider.py
+++ b/api_provider.py
@@ -32,7 +32,17 @@
API_KEY = None
API_BASE_URL = None
LOCAL_PROVIDER_TYPE = config.LOCAL_PROVIDER_OLLAMA
-OLLAMA_REASONING_MODE = "Thinking"
+
+# R8a: reasoning is now a graded level, not a bool "mode" - see
+# REASONING_LEVELS' own docstring below for the full mapping story. Local
+# providers default to "high" (the old "Thinking" default - local compute
+# is free to the user, so thorough-by-default is the right starting
+# point); cloud providers default to "off" (extended thinking on a paid
+# API is an opt-in cost/latency tradeoff, never a silent default).
+OLLAMA_REASONING_LEVEL = "high"
+ANTHROPIC_REASONING_LEVEL = "off"
+GEMINI_REASONING_LEVEL = "off"
+OPENAI_REASONING_LEVEL = "off"
API_MODELS = {
config.TASK_TITLE: None,
config.TASK_CHAT: None,
@@ -44,7 +54,7 @@
LLAMA_CPP_SETTINGS = {
"chat_model_path": "",
"title_model_path": "",
- "reasoning_mode": "Thinking",
+ "reasoning_level": "high",
"chat_format": "",
"n_ctx": 4096,
"n_gpu_layers": 0,
@@ -73,7 +83,10 @@ class _ProviderSnapshot(NamedTuple):
local_provider_type: str
api_models: dict
llama_cpp_settings: dict
- ollama_reasoning_mode: str
+ ollama_reasoning_level: str
+ anthropic_reasoning_level: str
+ gemini_reasoning_level: str
+ openai_reasoning_level: str
def _snapshot_provider_state() -> _ProviderSnapshot:
@@ -87,7 +100,10 @@ def _snapshot_provider_state() -> _ProviderSnapshot:
local_provider_type=LOCAL_PROVIDER_TYPE,
api_models=dict(API_MODELS),
llama_cpp_settings=dict(LLAMA_CPP_SETTINGS),
- ollama_reasoning_mode=OLLAMA_REASONING_MODE,
+ ollama_reasoning_level=OLLAMA_REASONING_LEVEL,
+ anthropic_reasoning_level=ANTHROPIC_REASONING_LEVEL,
+ gemini_reasoning_level=GEMINI_REASONING_LEVEL,
+ openai_reasoning_level=OPENAI_REASONING_LEVEL,
)
GEMINI_MODELS_STATIC = sorted([
@@ -775,11 +791,7 @@ def _normalize_llama_cpp_settings(settings: dict | None = None) -> dict:
normalized = {
"chat_model_path": str(raw_settings.get("chat_model_path", "")).strip(),
"title_model_path": str(raw_settings.get("title_model_path", "")).strip(),
- "reasoning_mode": (
- "Thinking"
- if str(raw_settings.get("reasoning_mode", "Thinking")).strip().lower() == "thinking"
- else "Quick"
- ),
+ "reasoning_level": normalize_reasoning_level(raw_settings.get("reasoning_level", "high")),
"chat_format": str(raw_settings.get("chat_format", "")).strip(),
"n_ctx": max(256, int(raw_settings.get("n_ctx", 4096) or 4096)),
"n_gpu_layers": int(raw_settings.get("n_gpu_layers", 0) or 0),
@@ -881,6 +893,206 @@ def _assert_llama_cpp_message_support(messages: list):
)
+# R8a: reasoning went from a bool "mode" (Thinking/Quick) to a graded
+# level, because every provider this app talks to now has SOME real
+# graded mechanism - confirmed against each one's own current docs rather
+# than assumed:
+# - OpenAI: reasoning_effort (minimal/low/medium/high/xhigh, model-dependent)
+# - Anthropic: thinking.budget_tokens on older models; a newer `effort`
+# param on Opus 4.7+ that REJECTS budget_tokens outright (400 error) -
+# these are mutually exclusive, not two names for the same thing
+# - Gemini: thinkingBudget (integer, 2.5-series) vs thinkingLevel
+# (string, Gemini 3) - same story, different generations, different shape
+# - Ollama: think is a bool for qwen3/deepseek/qwq, but a REQUIRED string
+# level ("low"/"medium"/"high") for gpt-oss - two different mechanisms
+# on the same provider
+# REASONING_LEVELS is the one vocabulary the composer UI and every
+# per-provider mapping function below speaks, so a user learns one control
+# regardless of which provider/model is active; each function here is
+# responsible for translating it into whatever that specific
+# provider/model actually accepts, including admitting when a model can't
+# fully honor a rung (see the docstrings below for exactly which
+# mappings are approximations and why).
+REASONING_LEVELS = ("off", "low", "medium", "high")
+
+
+def normalize_reasoning_level(value: str | None) -> str:
+ """Canonicalizes to one of REASONING_LEVELS, defaulting to "off" for
+ anything unrecognized - the safe direction to fail in for a parameter
+ that can affect cost/latency on a paid API: a garbled persisted value
+ must never silently escalate to expensive reasoning."""
+ normalized = str(value or "").strip().lower()
+ return normalized if normalized in REASONING_LEVELS else "off"
+
+
+def _is_ollama_gpt_oss_model(model_name: str) -> bool:
+ return "gpt-oss" in str(model_name or "").lower()
+
+
+def _is_ollama_bool_reasoning_model(model_name: str) -> bool:
+ normalized = str(model_name or "").lower()
+ return any(token in normalized for token in ("qwen3", "deepseek", "qwq"))
+
+
+def ollama_think_kwarg(model_name: str, level: str) -> bool | str | None:
+ """The exact value for ollama_kwargs["think"], or None when this model
+ has no reasoning control Ollama exposes at all (the kwarg should be
+ omitted entirely - the same behavior this app had for any non-
+ reasoning model before this change).
+
+ gpt-oss REQUIRES a string level - Ollama's own issue tracker documents
+ real bugs when a bool or "minimal" is sent instead (ollama/ollama
+ #12004, #11766), so "off" (not a supported rung for a model that
+ always reasons at some level) maps to the cheapest real one, "low",
+ rather than attempting to disable it outright.
+
+ qwen3/deepseek/qwq only expose a plain on/off think bool via Ollama's
+ own /api/chat - there is no numeric or string-graded knob for these on
+ Ollama specifically (Qwen's own native cloud API does support a
+ numeric thinking_budget, but that is a different API this app does
+ not call). See reasoning_budget_hint below for how low/medium/high
+ still differ meaningfully for these models despite the bool ceiling."""
+ normalized = str(model_name or "").lower()
+ if _is_ollama_gpt_oss_model(normalized):
+ return {"off": "low", "low": "low", "medium": "medium", "high": "high"}[level]
+ if _is_ollama_bool_reasoning_model(normalized):
+ return level != "off"
+ return None
+
+
+def reasoning_budget_hint(level: str) -> str | None:
+ """A real, if soft, SECOND axis of control for models whose only
+ native lever (Ollama's think bool, Llama.cpp's /think directive) is a
+ plain on/off switch: a natural-language nudge on how much reasoning to
+ do, layered on TOP of enabling thinking, not a replacement for it.
+ This is prompt-based guidance, not an API-enforced cap - honestly
+ weaker than Anthropic's budget_tokens or Gemini's thinkingBudget - but
+ a genuine difference in model behavior, not three identical "on"
+ states wearing different labels."""
+ return {
+ "low": "Keep your internal reasoning brief - a few short steps, then answer.",
+ "medium": None,
+ "high": "Reason thoroughly through the problem and verify your answer before responding.",
+ }.get(level)
+
+
+def _append_system_hint(messages: list, hint: str | None) -> list:
+ """Appends `hint` as its OWN leading system-role message. Deliberately
+ separate from _inject_qwen_thinking_instruction below (which prepends
+ INTO the first existing system message as a model-specific chat-
+ template directive) - this is a generic additive instruction, not a
+ template convention, so it gets its own message rather than risking
+ interference with content already sitting in the real system prompt."""
+ if not hint:
+ return messages
+ return [{"role": "system", "content": hint}, *messages]
+
+
+_ANTHROPIC_EFFORT_MODEL_PATTERN = re.compile(r"opus-4-[7-9]\b|opus-4-\d{2,}\b|opus-[5-9]-|opus-\d{2,}-", re.IGNORECASE)
+
+
+def _is_anthropic_effort_model(model_id: str) -> bool:
+ """True for Opus 4.7 and later, which use the newer `effort` parameter
+ and REJECT the older thinking/budget_tokens shape outright (a real 400
+ error, confirmed via Anthropic's own migration docs - these are not
+ two names for the same mechanism). The pattern is deliberately
+ generous about future versions (4.8, 4.9, 5.x, ...) since Anthropic's
+ own docs describe this as the new direction, not a one-off; a model
+ this doesn't recognize falls back to budget_tokens, which has been
+ stable across a much wider range of models and is the safer default
+ when a future model name is genuinely unrecognized."""
+ return bool(_ANTHROPIC_EFFORT_MODEL_PATTERN.search(str(model_id or "")))
+
+
+_ANTHROPIC_BUDGET_TOKENS = {"low": 2000, "medium": 8000, "high": 16000}
+# Thinking tokens count against max_tokens, and budget_tokens must stay
+# strictly under it (Anthropic rejects a budget >= max_tokens) - this is
+# the headroom left for the actual final answer after a request's
+# thinking budget, not an arbitrary constant.
+_ANTHROPIC_THINKING_HEADROOM_TOKENS = 2048
+
+
+def anthropic_reasoning_kwargs(model_id: str, level: str, max_tokens: int) -> dict:
+ """Kwargs to MERGE into an Anthropic request - empty for "off" (no
+ thinking requested at all, the model's plain fast-path response).
+ Also returns a raised `max_tokens` when the caller's own value would
+ leave no room for the requested budget, so picking Low/Medium/High
+ degrades to a clear, working response rather than a silent API
+ rejection."""
+ if level == "off":
+ return {}
+ if _is_anthropic_effort_model(model_id):
+ return {"effort": level}
+ budget = _ANTHROPIC_BUDGET_TOKENS[level]
+ result = {"thinking": {"type": "enabled", "budget_tokens": budget}}
+ if max_tokens <= budget:
+ result["max_tokens"] = budget + _ANTHROPIC_THINKING_HEADROOM_TOKENS
+ return result
+
+
+def _is_gemini_3_model(model_id: str) -> bool:
+ return bool(re.search(r"gemini-3", str(model_id or ""), re.IGNORECASE))
+
+
+def _is_gemini_thinking_capable(model_id: str) -> bool:
+ """2.5-series and 3-series document thinking configuration; 2.0-flash
+ and the image-generation models do not, so the reasoning control
+ should not be sent at all for those rather than silently including a
+ parameter the model may ignore or reject."""
+ normalized = str(model_id or "").lower()
+ if "-image" in normalized:
+ return False
+ if _is_gemini_3_model(normalized):
+ return True
+ return "gemini-2.5" in normalized
+
+
+_GEMINI_THINKING_BUDGET_TOKENS = {"off": 0, "low": 2048, "medium": 8192, "high": 24576}
+# Gemini 3's thinkingLevel only defines three rungs (MINIMAL/LOW/HIGH,
+# confirmed via Google's own docs) - "medium" and "high" both resolve to
+# HIGH there rather than inventing a fourth string value the API doesn't
+# define.
+_GEMINI_THINKING_LEVEL = {"off": "MINIMAL", "low": "LOW", "medium": "HIGH", "high": "HIGH"}
+
+
+def gemini_thinking_config(model_id: str, level: str) -> dict | None:
+ """The `thinkingConfig` value to merge into generationConfig, or None
+ for a model this app doesn't consider thinking-capable (the caller
+ should omit the key entirely)."""
+ if not _is_gemini_thinking_capable(model_id):
+ return None
+ if _is_gemini_3_model(model_id):
+ return {"thinkingLevel": _GEMINI_THINKING_LEVEL[level]}
+ return {"thinkingBudget": _GEMINI_THINKING_BUDGET_TOKENS[level], "includeThoughts": level != "off"}
+
+
+# This endpoint may point at real OpenAI, or at ANY OpenAI-API-shaped
+# server (Groq, a self-hosted vLLM/LM Studio proxy, etc. - see
+# backend/settings.py's own comments on why "OpenAI-Compatible" is
+# base_url-configurable rather than assumed to be api.openai.com).
+# reasoning_effort is only sent when the model name itself suggests a
+# real reasoning model - sending it to an arbitrary non-reasoning
+# endpoint risks a hard 400 from a strict server, not a silent no-op.
+_OPENAI_REASONING_MODEL_PREFIXES = ("o1", "o3", "o4", "gpt-5", "gpt-oss")
+
+
+def _is_openai_reasoning_model(model_id: str) -> bool:
+ normalized = str(model_id or "").strip().lower()
+ return normalized.startswith(_OPENAI_REASONING_MODEL_PREFIXES)
+
+
+def openai_reasoning_kwargs(model_id: str, level: str) -> dict:
+ """Empty for "off" (never sends reasoning_effort, deferring entirely
+ to the model/server's own default) or for any model this app doesn't
+ recognize as a reasoning model by name. Deliberately never sends
+ "minimal" - real-world reports show it's inconsistently supported/
+ buggy across reasoning model versions (see ollama/ollama#12004);
+ "low" is the conservative floor instead."""
+ if level == "off" or not _is_openai_reasoning_model(model_id):
+ return {}
+ return {"reasoning_effort": level}
+
+
def _is_qwen_reasoning_model_path(model_path: str | None) -> bool:
normalized_path = os.path.basename(str(model_path or "")).strip().lower()
if not normalized_path:
@@ -911,8 +1123,15 @@ def _prepare_llama_cpp_messages(messages: list, task: str, settings: dict | None
active_settings = settings if settings is not None else LLAMA_CPP_SETTINGS
normalized_messages = [dict(message) for message in messages]
if task == config.TASK_CHAT and _is_qwen_reasoning_model_path(_get_llama_cpp_model_path(task, active_settings)):
- enable_thinking = str(active_settings.get("reasoning_mode", "Quick")).strip().lower() == "thinking"
- normalized_messages = _inject_qwen_thinking_instruction(normalized_messages, enable_thinking)
+ level = normalize_reasoning_level(active_settings.get("reasoning_level", "high"))
+ normalized_messages = _inject_qwen_thinking_instruction(normalized_messages, level != "off")
+ # A second, real axis of control on top of the /think directive -
+ # see reasoning_budget_hint's own docstring: Qwen/QwQ's chat
+ # template only exposes on/off via /think //no_think, so low/
+ # medium/high still need this to differ meaningfully once thinking
+ # is enabled.
+ if level != "off":
+ normalized_messages = _append_system_hint(normalized_messages, reasoning_budget_hint(level))
processed_messages = []
for msg in normalized_messages:
@@ -947,7 +1166,7 @@ def _prepare_llama_cpp_kwargs(kwargs: dict, settings: dict | None = None) -> dic
if prepared.pop("format", None) == "json":
prepared.setdefault("response_format", {"type": "json_object"})
prepared.pop("response_mime_type", None)
- enable_thinking = str(active_settings.get("reasoning_mode", "Quick")).strip().lower() == "thinking"
+ enable_thinking = normalize_reasoning_level(active_settings.get("reasoning_level", "high")) != "off"
prepared.setdefault("enable_thinking", enable_thinking)
chat_template_kwargs = prepared.get("chat_template_kwargs")
if isinstance(chat_template_kwargs, dict):
@@ -1010,7 +1229,7 @@ def _configure_llama_cpp_chat_handler(client, settings: dict | None = None):
if base_handler is None:
return
- enable_thinking = str(active_settings.get("reasoning_mode", "Quick")).strip().lower() == "thinking"
+ enable_thinking = normalize_reasoning_level(active_settings.get("reasoning_level", "high")) != "off"
current_flag = getattr(client, "_graphlink_enable_thinking", None)
if current_flag == enable_thinking and getattr(getattr(client, "chat_handler", None), "_graphlink_wrapped_handler", False):
return
@@ -1482,7 +1701,7 @@ def _prepare_anthropic_messages(messages: list, cancel_event=None) -> tuple[str
return (system_prompt or None), anthropic_messages
-def _prepare_anthropic_kwargs(task: str, kwargs: dict) -> dict:
+def _prepare_anthropic_kwargs(task: str, kwargs: dict, model_id: str = "", reasoning_level: str = "off") -> dict:
anthropic_kwargs = dict(kwargs or {})
if "max_completion_tokens" in anthropic_kwargs and "max_tokens" not in anthropic_kwargs:
@@ -1498,6 +1717,13 @@ def _prepare_anthropic_kwargs(task: str, kwargs: dict) -> dict:
if not anthropic_kwargs.get("max_tokens"):
anthropic_kwargs["max_tokens"] = ANTHROPIC_DEFAULT_MAX_TOKENS.get(task, 4096)
+ # anthropic_reasoning_kwargs may raise max_tokens (a requested thinking
+ # budget must stay strictly under it) - update() applies that override
+ # on top of the default/caller-supplied value set just above.
+ anthropic_kwargs.update(
+ anthropic_reasoning_kwargs(model_id, reasoning_level, anthropic_kwargs["max_tokens"])
+ )
+
return anthropic_kwargs
@@ -1873,8 +2099,14 @@ def chat(task: str, messages: list, **kwargs) -> dict:
ollama_messages = _prepare_ollama_messages(messages)
ollama_kwargs = kwargs.copy()
- if task == config.TASK_CHAT and ("qwen3" in model.lower() or "deepseek" in model.lower()):
- ollama_kwargs["think"] = state.ollama_reasoning_mode == "Thinking"
+ if task == config.TASK_CHAT:
+ think_value = ollama_think_kwarg(model, state.ollama_reasoning_level)
+ if think_value is not None:
+ ollama_kwargs["think"] = think_value
+ if _is_ollama_bool_reasoning_model(model) and state.ollama_reasoning_level != "off":
+ ollama_messages = _append_system_hint(
+ ollama_messages, reasoning_budget_hint(state.ollama_reasoning_level)
+ )
# Reasoning-capable local models occasionally exhaust their own
# "thinking" budget before writing a final answer - often just sampling
@@ -1956,10 +2188,13 @@ def chat(task: str, messages: list, **kwargs) -> dict:
)
if state.api_provider_type == config.API_PROVIDER_OPENAI:
+ openai_kwargs = dict(kwargs)
+ if task == config.TASK_CHAT:
+ openai_kwargs.update(openai_reasoning_kwargs(api_model, state.openai_reasoning_level))
response = state.api_client.chat.completions.create(
model=api_model,
messages=messages,
- **kwargs,
+ **openai_kwargs,
)
_raise_if_cancelled(cancel_event)
return {
@@ -1974,10 +2209,11 @@ def chat(task: str, messages: list, **kwargs) -> dict:
messages,
cancel_event=cancel_event,
)
+ reasoning_level = state.anthropic_reasoning_level if task == config.TASK_CHAT else "off"
request_kwargs = {
"model": api_model,
"messages": anthropic_messages,
- **_prepare_anthropic_kwargs(task, kwargs),
+ **_prepare_anthropic_kwargs(task, kwargs, api_model, reasoning_level),
}
if system_prompt:
request_kwargs["system"] = system_prompt
@@ -2008,6 +2244,11 @@ def chat(task: str, messages: list, **kwargs) -> dict:
cancel_event=cancel_event,
api_key=state.api_key,
)
+ generation_config = dict(kwargs) if kwargs else {}
+ if task == config.TASK_CHAT:
+ thinking_config = gemini_thinking_config(api_model, state.gemini_reasoning_level)
+ if thinking_config is not None:
+ generation_config["thinkingConfig"] = thinking_config
request_body = {
"contents": gemini_contents,
}
@@ -2015,8 +2256,8 @@ def chat(task: str, messages: list, **kwargs) -> dict:
request_body["system_instruction"] = {
"parts": [{"text": str(system_prompt)}],
}
- if kwargs:
- request_body["generationConfig"] = kwargs
+ if generation_config:
+ request_body["generationConfig"] = generation_config
try:
payload = _gemini_post_json(
@@ -2172,8 +2413,14 @@ def chat_stream(task: str, messages: list, on_chunk: Callable[[str, bool], None]
ollama_messages = _prepare_ollama_messages(messages)
ollama_kwargs = {k: v for k, v in kwargs.items() if k != "cancellation_event"}
- if task == config.TASK_CHAT and ("qwen3" in model.lower() or "deepseek" in model.lower()):
- ollama_kwargs["think"] = state.ollama_reasoning_mode == "Thinking"
+ if task == config.TASK_CHAT:
+ think_value = ollama_think_kwarg(model, state.ollama_reasoning_level)
+ if think_value is not None:
+ ollama_kwargs["think"] = think_value
+ if _is_ollama_bool_reasoning_model(model) and state.ollama_reasoning_level != "off":
+ ollama_messages = _append_system_hint(
+ ollama_messages, reasoning_budget_hint(state.ollama_reasoning_level)
+ )
# Same 3-attempt reasoning-retry loop as chat() (see ReasoningWithoutAnswerError):
# each attempt streams live, and if an attempt is discarded, the caller is told via
@@ -2324,13 +2571,13 @@ def initialize_local_provider(
*,
preload_model: bool = False,
):
- global USE_API_MODE, LOCAL_PROVIDER_TYPE, API_PROVIDER_TYPE, API_CLIENT, API_KEY, API_BASE_URL, LLAMA_CPP_SETTINGS, OLLAMA_REASONING_MODE
+ global USE_API_MODE, LOCAL_PROVIDER_TYPE, API_PROVIDER_TYPE, API_CLIENT, API_KEY, API_BASE_URL, LLAMA_CPP_SETTINGS, OLLAMA_REASONING_LEVEL
if provider == config.LOCAL_PROVIDER_OLLAMA:
normalized_settings = _normalize_llama_cpp_settings()
with _PROVIDER_STATE_LOCK:
- requested_reasoning = str((settings or {}).get("reasoning_mode") or OLLAMA_REASONING_MODE).strip().lower()
- OLLAMA_REASONING_MODE = "Thinking" if requested_reasoning == "thinking" else "Quick"
+ requested_reasoning = (settings or {}).get("reasoning_level")
+ OLLAMA_REASONING_LEVEL = normalize_reasoning_level(requested_reasoning) if requested_reasoning else OLLAMA_REASONING_LEVEL
USE_API_MODE = False
LOCAL_PROVIDER_TYPE = provider
API_PROVIDER_TYPE = None
@@ -2441,12 +2688,29 @@ def set_mode(use_api: bool):
USE_API_MODE = use_api
-def set_ollama_reasoning_mode(mode: str):
+def set_ollama_reasoning_level(level: str):
"""Update the request snapshot source used by reasoning-capable Ollama models."""
- global OLLAMA_REASONING_MODE
- normalized = "Thinking" if str(mode or "").strip().lower() == "thinking" else "Quick"
+ global OLLAMA_REASONING_LEVEL
+ with _PROVIDER_STATE_LOCK:
+ OLLAMA_REASONING_LEVEL = normalize_reasoning_level(level)
+
+
+def set_anthropic_reasoning_level(level: str):
+ global ANTHROPIC_REASONING_LEVEL
+ with _PROVIDER_STATE_LOCK:
+ ANTHROPIC_REASONING_LEVEL = normalize_reasoning_level(level)
+
+
+def set_gemini_reasoning_level(level: str):
+ global GEMINI_REASONING_LEVEL
+ with _PROVIDER_STATE_LOCK:
+ GEMINI_REASONING_LEVEL = normalize_reasoning_level(level)
+
+
+def set_openai_reasoning_level(level: str):
+ global OPENAI_REASONING_LEVEL
with _PROVIDER_STATE_LOCK:
- OLLAMA_REASONING_MODE = normalized
+ OPENAI_REASONING_LEVEL = normalize_reasoning_level(level)
def set_task_model(task: str, api_model: str):
@@ -2471,6 +2735,49 @@ def is_local_llama_cpp_mode() -> bool:
return not USE_API_MODE and LOCAL_PROVIDER_TYPE == config.LOCAL_PROVIDER_LLAMACPP
+# R8a: public "does this resolved model support reasoning at all" checks -
+# backend/composer.py uses these to decide whether to show the reasoning
+# control at all (the capability gate), rather than reaching into the
+# private _is_*_reasoning_model detection helpers above directly.
+
+
+def ollama_supports_reasoning(model_name: str) -> bool:
+ return _is_ollama_gpt_oss_model(model_name) or _is_ollama_bool_reasoning_model(model_name)
+
+
+def llama_cpp_supports_reasoning(model_path: str) -> bool:
+ return _is_qwen_reasoning_model_path(model_path)
+
+
+_ANTHROPIC_NO_REASONING_PATTERN = re.compile(r"claude-1\b|claude-instant|claude-2\b|claude-3-haiku\b", re.IGNORECASE)
+
+
+def anthropic_supports_reasoning(model_id: str) -> bool:
+ """No hardcoded Claude model list exists in this app (the catalog is
+ fetched live from Anthropic's own API - see ANTHROPIC_MODELS_URL), so
+ this is a denylist, not an allowlist: extended thinking is a broad,
+ still-expanding feature across Anthropic's modern lineup, so a new,
+ unrecognized model name is assumed capable rather than assumed not -
+ only clearly legacy/non-reasoning models (Claude 1/2/Instant, Haiku 3)
+ are excluded by name. Real Anthropic model ids put the version before
+ the tier name ("claude-3-haiku-20240307", not "claude-haiku-3"), so
+ the literal "claude-3-haiku" pattern here does NOT also match the
+ newer, reasoning-capable "claude-3-5-haiku" - no lookahead trick
+ needed, the "-5-" in between already makes them different substrings."""
+ normalized = str(model_id or "").strip()
+ if not normalized:
+ return False
+ return not _ANTHROPIC_NO_REASONING_PATTERN.search(normalized)
+
+
+def gemini_supports_reasoning(model_id: str) -> bool:
+ return _is_gemini_thinking_capable(model_id)
+
+
+def openai_supports_reasoning(model_id: str) -> bool:
+ return _is_openai_reasoning_model(model_id)
+
+
def get_mode() -> str:
if USE_API_MODE:
return "API"
diff --git a/backend/composer.py b/backend/composer.py
index c377137..26e9fe3 100644
--- a/backend/composer.py
+++ b/backend/composer.py
@@ -36,24 +36,30 @@
from uuid import uuid4
import api_provider
+import graphlink_task_config as config
from backend.events import SessionBus
from backend.notifications import NotificationState
from backend.attachments import AttachmentError, StagedAttachment, stage_file
from backend.settings import (
_apply,
- apply_llama_cpp_reasoning_mode,
+ apply_anthropic_reasoning_level,
+ apply_gemini_reasoning_level,
+ apply_llama_cpp_reasoning_level,
apply_ollama_chat_model,
- apply_ollama_reasoning_mode,
+ apply_ollama_reasoning_level,
+ apply_openai_reasoning_level,
)
from backend.token_counter import TokenCounterState
from graphlink_licensing import SettingsManager
REASONING_OPTIONS = [
- {"id": "thinking", "label": "Thinking Mode (Enable CoT)", "description": "Slower, higher-quality reasoning."},
- {"id": "quick", "label": "Quick Mode (No CoT)", "description": "Faster, direct answers."},
+ {"id": "off", "label": "Off", "description": "No extended reasoning - the fastest, most direct answers."},
+ {"id": "low", "label": "Low", "description": "A little reasoning before answering."},
+ {"id": "medium", "label": "Medium", "description": "A balanced amount of reasoning."},
+ {"id": "high", "label": "High", "description": "Thorough reasoning - slower, higher-quality answers."},
]
-DEFAULT_REASONING_LEVEL = "quick"
+DEFAULT_REASONING_LEVEL = "off"
SEND_MODES = ("enter_to_send", "ctrl_enter_to_send")
@@ -81,22 +87,27 @@ class ComposerDocument:
# Whenever register_composer got a real SettingsManager, the persisted
# value wins - see reasoning_level_reader below.
reasoning_level: str = DEFAULT_REASONING_LEVEL
- # R7.5d follow-up: reads the ACTIVE provider's persisted reasoning mode
- # ("Thinking"/"Quick"). Legacy's composer never stored a reasoning level
- # of its own either - graphlink_composer_bridge.py's _reasoning() derives
- # it from the settings manager on every payload build, which is why the
- # Qt composer and the Settings dialog could never disagree.
+ # R7.5d follow-up: reads the ACTIVE provider's persisted reasoning
+ # level. Legacy's composer never stored a reasoning level of its own
+ # either - graphlink_composer_bridge.py's _reasoning() derives it from
+ # the settings manager on every payload build, which is why the Qt
+ # composer and the Settings dialog could never disagree.
#
# The first pass of R7.5d wired only the WRITE half of that (the toggle
# began genuinely persisting + re-applying to api_provider) and left this
# document's own private reasoning_level as the display source. That made
# the two halves diverge in a way that was worse than the original bug:
- # get_ollama_reasoning_mode() defaults to "Thinking", so a user who had
- # never touched the setting saw the composer confidently report "Quick
- # Mode (No CoT)" while every chat call really ran with think=True, and
- # selecting the already-active "Quick" was the only way to reach the
- # state the UI already claimed. Deriving instead of mirroring removes the
- # second source of truth rather than trying to keep two in sync.
+ # get_ollama_reasoning_level() defaulted to "Thinking" (now "high"), so a
+ # user who had never touched the setting saw the composer confidently
+ # report "Quick Mode (No CoT)" (now "Off") while every chat call really
+ # ran with reasoning enabled, and selecting the already-active choice was
+ # the only way to reach the state the UI already claimed. Deriving
+ # instead of mirroring removes the second source of truth rather than
+ # trying to keep two in sync.
+ #
+ # R8a: extended from a local-only (Ollama/Llama.cpp) 2-value mode to a
+ # graded 4-value level (off/low/medium/high) shared by all 5 providers -
+ # see _persisted_reasoning_level's own docstring in register_composer.
reasoning_level_reader: Callable[[], str] | None = field(default=None, repr=False)
route_reader: Callable[[], dict[str, Any]] | None = field(default=None, repr=False)
# R4: the in-flight agent-dispatch request, if any - set by
@@ -141,14 +152,17 @@ def effective_reasoning_level(self) -> str:
"""The reasoning level to DISPLAY: the active provider's persisted
setting when one is reachable, else this document's own fallback.
- Normalizes the settings manager's Title-Case vocabulary
- ("Thinking"/"Quick") to this payload's lowercase option ids, which
- are the two the frontend renders and sends back.
+ Validates against REASONING_OPTIONS' own ids rather than trusting
+ the reader outright - api_provider.normalize_reasoning_level already
+ guarantees this for every real reader this app wires, but falling
+ back to "off" (not raising) for anything else keeps this method
+ total, matching its own contract as a display-only derivation.
"""
if self.reasoning_level_reader is None:
return self.reasoning_level
raw = str(self.reasoning_level_reader() or "").strip().lower()
- return "thinking" if raw == "thinking" else "quick"
+ valid_ids = {option["id"] for option in REASONING_OPTIONS}
+ return raw if raw in valid_ids else "off"
def _reasoning_label(self, level: str) -> str:
return next(o["label"] for o in REASONING_OPTIONS if o["id"] == level)
@@ -233,13 +247,35 @@ def payload(self) -> dict[str, Any]:
# Real, and derived - not a constant. The composer's model
# control enables exactly when there is something to choose.
"modelSelection": bool(route.get("modelOptions")),
- "reasoningSelection": api_provider.is_local_ollama_mode() or api_provider.is_local_llama_cpp_mode(),
+ # R8a: real capability detection, not "local providers only" -
+ # every provider this app talks to now has SOME reasoning
+ # mechanism (see api_provider.py's REASONING_LEVELS docstring),
+ # so this shows the control whenever the CURRENTLY RESOLVED
+ # model actually supports it, cloud included.
+ "reasoningSelection": _route_supports_reasoning(route),
"settingsShortcut": True,
"cancellation": True,
},
}
+def _route_supports_reasoning(route: dict[str, Any]) -> bool:
+ mode = route.get("mode")
+ model_id = str(route.get("modelId") or "")
+ if mode == "ollama":
+ return api_provider.ollama_supports_reasoning(model_id)
+ if mode == "llama_cpp":
+ return api_provider.llama_cpp_supports_reasoning(model_id)
+ if mode == "api":
+ provider = route.get("provider")
+ if provider == config.API_PROVIDER_ANTHROPIC:
+ return api_provider.anthropic_supports_reasoning(model_id)
+ if provider == config.API_PROVIDER_GEMINI:
+ return api_provider.gemini_supports_reasoning(model_id)
+ return api_provider.openai_supports_reasoning(model_id)
+ return False
+
+
def register_composer(
bus: SessionBus,
token_counter: TokenCounterState,
@@ -258,12 +294,28 @@ def register_composer(
# Settings dialog edits and api_provider actually obeys, exactly as
# legacy's _reasoning() did (graphlink_composer_bridge.py:464-474),
# branching on the live provider for the same reason it did.
- def _persisted_reasoning_mode() -> str:
+ #
+ # R8a: extended from Ollama/Llama.cpp only to all 5 providers - a
+ # cloud reasoning level only matters once reasoningSelection (see
+ # _route_supports_reasoning above) has already gated the control on,
+ # so an unrecognized/future API provider string here just falls
+ # through to the OpenAI-compatible getter, the same conservative
+ # "off by default" family as the other two cloud providers.
+ def _persisted_reasoning_level() -> str:
if api_provider.is_local_llama_cpp_mode():
- return settings_manager.get_llama_cpp_reasoning_mode()
- return settings_manager.get_ollama_reasoning_mode()
+ return settings_manager.get_llama_cpp_reasoning_level()
+ if api_provider.is_local_ollama_mode():
+ return settings_manager.get_ollama_reasoning_level()
+ if api_provider.is_api_mode():
+ provider = settings_manager.get_api_provider()
+ if provider == config.API_PROVIDER_ANTHROPIC:
+ return settings_manager.get_anthropic_reasoning_level()
+ if provider == config.API_PROVIDER_GEMINI:
+ return settings_manager.get_gemini_reasoning_level()
+ return settings_manager.get_openai_reasoning_level()
+ return DEFAULT_REASONING_LEVEL
- document.reasoning_level_reader = _persisted_reasoning_mode
+ document.reasoning_level_reader = _persisted_reasoning_level
def _live_route() -> dict[str, Any]:
"""Resolve the route the way a real send resolves it.
@@ -357,31 +409,34 @@ async def set_reasoning_level(level):
# Unchanged existing behavior: raises ComposerError for an
# unrecognized id (see
# test_set_reasoning_level_intent_updates_and_rejects_unknown).
+ # `level` is now one of REASONING_OPTIONS' own 4 ids (already
+ # lowercase) rather than needing a Title-Case normalization step -
+ # api_provider.normalize_reasoning_level is the ONE remaining
+ # normalizer, applied inside each set_*_reasoning_level setter.
document.set_reasoning_level(level)
- # Same Title-Case normalization backend/settings.py's own
- # _OLLAMA_REASONING_MODES/_LLAMA_CPP_REASONING_MODES validate
- # against, and legacy's own bridge applied. This ternary is TOTAL -
- # it always produces "Thinking" or "Quick" - so the shared apply
- # functions below never need to re-validate whatever this handler
- # passes them.
- normalized_mode = "Thinking" if str(level).strip().lower() == "thinking" else "Quick"
-
if settings_manager is not None:
# Mutually exclusive by construction (api_provider.py) - order
- # between the two branches is arbitrary; Ollama-first here
- # purely to match backend/settings.py's own file ordering
- # (Ollama defined before Llama.cpp throughout that file).
+ # matches backend/settings.py's own file ordering.
if api_provider.is_local_ollama_mode():
- await apply_ollama_reasoning_mode(settings_manager, normalized_mode)
+ await apply_ollama_reasoning_level(settings_manager, level)
elif api_provider.is_local_llama_cpp_mode():
- failure = await apply_llama_cpp_reasoning_mode(settings_manager, normalized_mode)
+ failure = await apply_llama_cpp_reasoning_level(settings_manager, level)
if failure is not None and notifications is not None:
notifications.show(failure, "error")
await bus.publish("notification")
- # else: cloud/API mode - reasoningSelection is already False
- # there (see payload() above), so the UI disables this control
- # entirely; skip the apply step, never raise.
+ elif api_provider.is_api_mode():
+ # R8a: reasoningSelection now gates on the RESOLVED MODEL's
+ # real capability (_route_supports_reasoning), not just "is
+ # this a local provider" - cloud providers reach this branch
+ # whenever their active model actually supports reasoning.
+ provider = settings_manager.get_api_provider()
+ if provider == config.API_PROVIDER_ANTHROPIC:
+ await apply_anthropic_reasoning_level(settings_manager, level)
+ elif provider == config.API_PROVIDER_GEMINI:
+ await apply_gemini_reasoning_level(settings_manager, level)
+ else:
+ await apply_openai_reasoning_level(settings_manager, level)
# The Settings dialog renders the same persisted value on its
# Ollama/Llama.cpp page. Without this it keeps showing the old
diff --git a/backend/settings.py b/backend/settings.py
index 8cdeab8..8ea5d40 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -103,13 +103,16 @@
config.TASK_WEB_VALIDATE,
config.TASK_WEB_SUMMARIZE,
)
-_OLLAMA_REASONING_MODES = ("Thinking", "Quick")
-
-# Same 2-mode set, distinct constant deliberately (not reused) - Llama.cpp
-# and Ollama have entirely separate SettingsManager fields/api_provider
-# global state for reasoning mode, so keeping the constants separate avoids
-# an accidental coupling if one provider's valid-mode set ever diverges.
-_LLAMA_CPP_REASONING_MODES = ("Thinking", "Quick")
+# R8a: ONE shared vocabulary for all 5 providers' reasoning levels -
+# deliberately consolidated from what used to be per-provider "distinct
+# constant, not reused" tuples (Ollama/Llama.cpp each had their own
+# identical 2-value set). That separation existed because the two
+# providers' valid-mode sets only happened to match, with no shared
+# meaning behind the values - here the whole point is the opposite: one
+# real, shared vocabulary every provider's mapping function in
+# api_provider.py translates from, so keeping five copies of the same
+# tuple would be the ad-hoc-duplication problem, not a safeguard against one.
+_REASONING_LEVELS = api_provider.REASONING_LEVELS
# Every persistence-touching mutation runs in a worker thread
# (asyncio.to_thread) so SettingsManager._save_state's json-dump + fsync +
@@ -154,22 +157,23 @@ async def _republish_composer_reasoning(bus: SessionBus) -> None:
await bus.publish("app-composer")
-async def apply_ollama_reasoning_mode(manager: SettingsManager, mode: str) -> None:
- """Persist `mode` and, if Ollama is still the live provider, re-apply it to
+async def apply_ollama_reasoning_level(manager: SettingsManager, level: str) -> None:
+ """Persist `level` and, if Ollama is still the live provider, re-apply it to
api_provider's module state so the very next chat()/chat_stream() call
- picks it up. `mode` MUST already be "Thinking" or "Quick" - this function
- does not re-validate it (every caller's own normalization is total, so
- there is no path that could hand it anything else). Checked-and-applied
- inside the SAME asyncio.to_thread hop as a deliberate race-preemption:
- see set_ollama_reasoning_mode's inline comment for why. Shared by this
- file's own setOllamaReasoningMode intent (Settings-dialog Ollama page)
- and backend/composer.py's setReasoningLevel intent (composer's own
- quick-access popover) so both surfaces apply a change identically."""
- await asyncio.to_thread(_apply, manager.set_ollama_reasoning_mode, mode)
+ picks it up. `level` MUST already be one of api_provider.REASONING_LEVELS -
+ this function does not re-validate it (every caller's own normalization
+ is total, so there is no path that could hand it anything else).
+ Checked-and-applied inside the SAME asyncio.to_thread hop as a
+ deliberate race-preemption: see set_ollama_reasoning_level's inline
+ comment for why. Shared by this file's own setOllamaReasoningLevel
+ intent (Settings-dialog Ollama page) and backend/composer.py's
+ setReasoningLevel intent (composer's own quick-access popover) so both
+ surfaces apply a change identically."""
+ await asyncio.to_thread(_apply, manager.set_ollama_reasoning_level, level)
def _reapply_if_ollama_is_still_the_live_provider() -> None:
if api_provider.is_local_ollama_mode():
- api_provider.initialize_local_provider(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_mode": mode})
+ api_provider.initialize_local_provider(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_level": level})
await asyncio.to_thread(_reapply_if_ollama_is_still_the_live_provider)
@@ -180,7 +184,7 @@ async def apply_ollama_chat_model(manager: SettingsManager, model_id: str) -> No
Extracted from register_settings' own set_ollama_model_assignment intent
so the composer's model picker and the Settings > Ollama page write through
ONE implementation instead of two that can drift. Same precedent as
- apply_ollama_reasoning_mode above.
+ apply_ollama_reasoning_level above.
The read-modify-write stays inside a single _apply-locked closure, not
split across an await: that split is the R7.4a race where a concurrent
@@ -202,31 +206,55 @@ def _persist() -> None:
await asyncio.to_thread(_apply, _persist)
-async def apply_llama_cpp_reasoning_mode(manager: SettingsManager, mode: str) -> str | None:
- """Same contract as apply_ollama_reasoning_mode, for Llama.cpp. Returns a
- human-readable failure message if the live re-apply fails (the mode is
+async def apply_llama_cpp_reasoning_level(manager: SettingsManager, level: str) -> str | None:
+ """Same contract as apply_ollama_reasoning_level, for Llama.cpp. Returns a
+ human-readable failure message if the live re-apply fails (the level is
ALREADY persisted regardless - only the live effect is delayed to the
next mode switch/restart), or None on success / when Llama.cpp is not the
active provider. Returns rather than writes a notice directly: this
- file's own setLlamaCppReasoningMode intent writes the message into its
+ file's own setLlamaCppReasoningLevel intent writes the message into its
page-local llama_notice cell; backend/composer.py's intent has no such
cell and surfaces it through the shared NotificationState banner
instead - that decision belongs to each caller, not to this function."""
- await asyncio.to_thread(_apply, manager.set_llama_cpp_reasoning_mode, mode)
+ await asyncio.to_thread(_apply, manager.set_llama_cpp_reasoning_level, level)
def _reapply_if_llama_cpp_is_still_the_live_provider() -> None:
if api_provider.is_local_llama_cpp_mode():
settings = _locked_llama_cpp_settings(manager)
- settings["reasoning_mode"] = mode
+ settings["reasoning_level"] = level
api_provider.initialize_local_provider(config.LOCAL_PROVIDER_LLAMACPP, settings)
try:
await asyncio.to_thread(_reapply_if_llama_cpp_is_still_the_live_provider)
except Exception as exc: # noqa: BLE001 - no secret in this path (a local file path, not a credential)
- return f"Reasoning mode saved, but could not be applied to the live model: {exc}"
+ return f"Reasoning level saved, but could not be applied to the live model: {exc}"
return None
+# R8a: the three cloud providers' reasoning levels are each an INDEPENDENT
+# api_provider global (ANTHROPIC_REASONING_LEVEL/GEMINI_REASONING_LEVEL/
+# OPENAI_REASONING_LEVEL) read only at the moment of an actual API call for
+# that provider - unlike Ollama/Llama.cpp above, there is no "is this still
+# the live provider" gate needed: setting the value is always safe
+# regardless of which provider is currently active, so these three are
+# simpler than their local-provider counterparts, not an oversight.
+
+
+async def apply_anthropic_reasoning_level(manager: SettingsManager, level: str) -> None:
+ await asyncio.to_thread(_apply, manager.set_anthropic_reasoning_level, level)
+ await asyncio.to_thread(api_provider.set_anthropic_reasoning_level, level)
+
+
+async def apply_gemini_reasoning_level(manager: SettingsManager, level: str) -> None:
+ await asyncio.to_thread(_apply, manager.set_gemini_reasoning_level, level)
+ await asyncio.to_thread(api_provider.set_gemini_reasoning_level, level)
+
+
+async def apply_openai_reasoning_level(manager: SettingsManager, level: str) -> None:
+ await asyncio.to_thread(_apply, manager.set_openai_reasoning_level, level)
+ await asyncio.to_thread(api_provider.set_openai_reasoning_level, level)
+
+
def _redact(text: str, secret: Any) -> str:
# Post-review fix: some HTTP client libraries embed request parameters
# (including a rejected API key) directly in exception text - the
@@ -418,7 +446,7 @@ def build_payload() -> dict[str, Any]:
payload["geminiStaticModels"] = list(api_provider.GEMINI_MODELS_STATIC)
payload["geminiStaticImageModels"] = list(api_provider.GEMINI_IMAGE_MODELS_STATIC)
- payload["ollamaReasoningMode"] = manager.get_ollama_reasoning_mode()
+ payload["ollamaReasoningLevel"] = manager.get_ollama_reasoning_level()
payload["ollamaCurrentModel"] = config.OLLAMA_MODELS.get(config.TASK_CHAT, "")
payload["ollamaModelAssignments"] = _ollama_model_assignments_for_wire(manager)
payload["ollamaScannedModels"] = manager.get_ollama_scanned_models()
@@ -427,7 +455,7 @@ def build_payload() -> dict[str, Any]:
payload["ollamaPullStatus"] = ollama_pull_status["value"]
payload["ollamaNotice"] = ollama_notice["value"]
- payload["llamaCppReasoningMode"] = manager.get_llama_cpp_reasoning_mode()
+ payload["llamaCppReasoningLevel"] = manager.get_llama_cpp_reasoning_level()
# Staged (session-local), NOT manager.get_llama_cpp_chat_model_path()
# - the field must show the in-progress draft, not the last-saved
# value, exactly like the API-provider page's draftBaseUrl/
@@ -728,7 +756,7 @@ def _reset() -> None:
await bus.publish("notification")
await bus.publish("app-settings")
- async def set_ollama_reasoning_mode(mode: str):
+ async def set_ollama_reasoning_level(level: str):
# Checked and applied inside the SAME to_thread hop, not split across
# an await boundary: a concurrent mode switch (the toolbar's provider
# selector) landing in a gap between a separate check and a later
@@ -755,11 +783,11 @@ async def set_ollama_reasoning_mode(mode: str):
#
# R7.5d: this apply/re-apply sequence is now shared with
# backend/composer.py's own setReasoningLevel intent - see
- # apply_ollama_reasoning_mode's own docstring above.
- mode = str(mode)
- if mode not in _OLLAMA_REASONING_MODES:
+ # apply_ollama_reasoning_level's own docstring above.
+ level = str(level)
+ if level not in _REASONING_LEVELS:
return
- await apply_ollama_reasoning_mode(manager, mode)
+ await apply_ollama_reasoning_level(manager, level)
await bus.publish("app-settings")
await _republish_composer_reasoning(bus)
@@ -950,8 +978,8 @@ def _persist() -> None:
ollama_notice["value"] = ""
await bus.publish("app-settings")
- async def set_llama_cpp_reasoning_mode(mode: str):
- # Same shape as set_ollama_reasoning_mode's own live re-apply: gated
+ async def set_llama_cpp_reasoning_level(level: str):
+ # Same shape as set_ollama_reasoning_level's own live re-apply: gated
# on is_local_llama_cpp_mode(), checked and applied inside the SAME
# to_thread hop so a concurrent provider-mode switch can't be
# clobbered back to Llama.cpp. Unlike Ollama's version, this
@@ -959,17 +987,17 @@ async def set_llama_cpp_reasoning_mode(mode: str):
# re-validates chat_model_path (must still be a real, existing .gguf
# file) every time it runs - if that file was deleted/moved from
# under an already-active session since Llama.cpp was last
- # activated, this raises. The mode is already persisted regardless,
+ # activated, this raises. The level is already persisted regardless,
# so a failure here only means it takes effect on the next mode
# switch/restart instead of immediately - not a lost setting.
#
# R7.5d: this apply/re-apply sequence is now shared with
# backend/composer.py's own setReasoningLevel intent - see
- # apply_llama_cpp_reasoning_mode's own docstring above.
- mode = str(mode)
- if mode not in _LLAMA_CPP_REASONING_MODES:
+ # apply_llama_cpp_reasoning_level's own docstring above.
+ level = str(level)
+ if level not in _REASONING_LEVELS:
return
- failure = await apply_llama_cpp_reasoning_mode(manager, mode)
+ failure = await apply_llama_cpp_reasoning_level(manager, level)
if failure is not None:
llama_notice["value"] = failure
# NOTE: on success, do NOT clear llama_notice to "" - this preserves
@@ -1184,7 +1212,7 @@ async def save_llama_cpp_settings():
def _maybe_reapply_live() -> None:
# Checked and applied inside the SAME to_thread hop - the same
- # race-preemption shape as set_llama_cpp_reasoning_mode's own
+ # race-preemption shape as set_llama_cpp_reasoning_level's own
# live re-apply above.
if api_provider.is_local_llama_cpp_mode():
settings = _locked_llama_cpp_settings(manager)
@@ -1218,12 +1246,12 @@ def _persist() -> None:
bus.register_intent("app-settings", "loadApiModels", load_api_models)
bus.register_intent("app-settings", "saveApiConfiguration", save_api_configuration)
bus.register_intent("app-settings", "resetApiSettings", reset_api_settings)
- bus.register_intent("app-settings", "setOllamaReasoningMode", set_ollama_reasoning_mode)
+ bus.register_intent("app-settings", "setOllamaReasoningLevel", set_ollama_reasoning_level)
bus.register_intent("app-settings", "setOllamaModelAssignment", set_ollama_model_assignment)
bus.register_intent("app-settings", "scanOllamaSystem", scan_ollama_system)
bus.register_intent("app-settings", "pullOllamaModel", pull_ollama_model)
bus.register_intent("app-settings", "pickOllamaScanFolder", pick_ollama_scan_folder)
- bus.register_intent("app-settings", "setLlamaCppReasoningMode", set_llama_cpp_reasoning_mode)
+ bus.register_intent("app-settings", "setLlamaCppReasoningLevel", set_llama_cpp_reasoning_level)
bus.register_intent("app-settings", "setLlamaCppChatFormat", set_llama_cpp_chat_format)
bus.register_intent("app-settings", "setLlamaCppNCtx", set_llama_cpp_n_ctx)
bus.register_intent("app-settings", "setLlamaCppNGpuLayers", set_llama_cpp_n_gpu_layers)
diff --git a/backend/tests/test_api_provider_reasoning.py b/backend/tests/test_api_provider_reasoning.py
new file mode 100644
index 0000000..9f2a68f
--- /dev/null
+++ b/backend/tests/test_api_provider_reasoning.py
@@ -0,0 +1,196 @@
+"""Tests for api_provider.py's R8a graded-reasoning-level mapping functions.
+
+api_provider.py itself has no dedicated test file elsewhere in the repo -
+its provider-dispatch logic is only exercised indirectly through
+backend/composer.py and backend/settings.py's own tests. The functions
+covered here are pure (no network/SDK calls, no module-global state), so
+they're unit-tested directly rather than only through those indirect paths.
+"""
+
+import api_provider
+
+
+# -- normalize_reasoning_level -----------------------------------------------
+
+
+def test_normalize_reasoning_level_accepts_the_four_real_values():
+ for level in ("off", "low", "medium", "high"):
+ assert api_provider.normalize_reasoning_level(level) == level
+
+
+def test_normalize_reasoning_level_is_case_and_whitespace_insensitive():
+ assert api_provider.normalize_reasoning_level(" HIGH ") == "high"
+ assert api_provider.normalize_reasoning_level("Low") == "low"
+
+
+def test_normalize_reasoning_level_falls_back_to_off_for_garbage():
+ assert api_provider.normalize_reasoning_level("banana") == "off"
+ assert api_provider.normalize_reasoning_level(None) == "off"
+ assert api_provider.normalize_reasoning_level("") == "off"
+
+
+# -- Ollama: think kwarg + budget hint ---------------------------------------
+
+
+def test_ollama_think_kwarg_is_a_bool_for_qwen3_deepseek_qwq():
+ for model in ("qwen3:8b", "deepseek-r1:32b", "qwq:32b", "QWEN3:LATEST"):
+ assert api_provider.ollama_think_kwarg(model, "off") is False
+ assert api_provider.ollama_think_kwarg(model, "low") is True
+ assert api_provider.ollama_think_kwarg(model, "medium") is True
+ assert api_provider.ollama_think_kwarg(model, "high") is True
+
+
+def test_ollama_think_kwarg_is_a_string_for_gpt_oss():
+ assert api_provider.ollama_think_kwarg("gpt-oss:20b", "low") == "low"
+ assert api_provider.ollama_think_kwarg("gpt-oss:20b", "medium") == "medium"
+ assert api_provider.ollama_think_kwarg("gpt-oss:20b", "high") == "high"
+ # gpt-oss cannot fully disable reasoning - "off" maps to the cheapest
+ # real rung instead of a bool/omitted kwarg.
+ assert api_provider.ollama_think_kwarg("gpt-oss:20b", "off") == "low"
+
+
+def test_ollama_think_kwarg_is_none_for_a_non_reasoning_model():
+ assert api_provider.ollama_think_kwarg("llama3.1:8b", "high") is None
+ assert api_provider.ollama_think_kwarg("", "high") is None
+
+
+def test_reasoning_budget_hint_differs_by_level_and_is_none_for_medium():
+ assert "brief" in api_provider.reasoning_budget_hint("low").lower()
+ assert api_provider.reasoning_budget_hint("medium") is None
+ assert "thorough" in api_provider.reasoning_budget_hint("high").lower()
+ assert api_provider.reasoning_budget_hint("off") is None
+
+
+def test_ollama_supports_reasoning_matches_the_same_model_families():
+ assert api_provider.ollama_supports_reasoning("qwen3:8b") is True
+ assert api_provider.ollama_supports_reasoning("gpt-oss:20b") is True
+ assert api_provider.ollama_supports_reasoning("llama3.1:8b") is False
+
+
+# -- Llama.cpp --------------------------------------------------------------
+
+
+def test_llama_cpp_supports_reasoning_matches_qwen_and_qwq_gguf_paths():
+ assert api_provider.llama_cpp_supports_reasoning("C:/models/qwen3-8b.gguf") is True
+ assert api_provider.llama_cpp_supports_reasoning("C:/models/qwq-32b.gguf") is True
+ assert api_provider.llama_cpp_supports_reasoning("C:/models/llama-3.1-8b.gguf") is False
+ assert api_provider.llama_cpp_supports_reasoning("") is False
+
+
+# -- Anthropic: budget_tokens vs effort, model-version detection -------------
+
+
+def test_anthropic_reasoning_kwargs_off_sends_nothing():
+ assert api_provider.anthropic_reasoning_kwargs("claude-sonnet-4-5", "off", 4096) == {}
+
+
+def test_anthropic_reasoning_kwargs_uses_budget_tokens_for_older_models():
+ result = api_provider.anthropic_reasoning_kwargs("claude-sonnet-4-5", "low", 4096)
+ assert result["thinking"] == {"type": "enabled", "budget_tokens": 2000}
+ assert "effort" not in result
+
+
+def test_anthropic_reasoning_kwargs_bumps_max_tokens_when_budget_would_not_fit():
+ # budget_tokens must stay strictly under max_tokens - a small default
+ # (4096) can't hold a 16000-token "high" budget, so max_tokens must rise.
+ result = api_provider.anthropic_reasoning_kwargs("claude-sonnet-4-5", "high", 4096)
+ assert result["thinking"]["budget_tokens"] == 16000
+ assert result["max_tokens"] > result["thinking"]["budget_tokens"]
+
+
+def test_anthropic_reasoning_kwargs_does_not_bump_max_tokens_when_already_large_enough():
+ result = api_provider.anthropic_reasoning_kwargs("claude-sonnet-4-5", "low", 50000)
+ assert "max_tokens" not in result
+
+
+def test_anthropic_reasoning_kwargs_uses_effort_for_opus_4_7_and_later():
+ result = api_provider.anthropic_reasoning_kwargs("claude-opus-4-7", "high", 4096)
+ assert result == {"effort": "high"}
+
+
+def test_anthropic_reasoning_kwargs_effort_model_detection_is_forward_looking():
+ # Deliberately generous about FUTURE versions (4.8, 4.9, 5.x) per the
+ # function's own docstring - not just the one version known today.
+ assert api_provider._is_anthropic_effort_model("claude-opus-4-8") is True
+ assert api_provider._is_anthropic_effort_model("claude-opus-5-1") is True
+ assert api_provider._is_anthropic_effort_model("claude-opus-4-6") is False
+ assert api_provider._is_anthropic_effort_model("claude-sonnet-4-5") is False
+
+
+def test_anthropic_supports_reasoning_excludes_known_legacy_models():
+ assert api_provider.anthropic_supports_reasoning("claude-sonnet-4-5") is True
+ assert api_provider.anthropic_supports_reasoning("claude-opus-4-7") is True
+ assert api_provider.anthropic_supports_reasoning("claude-3-haiku-20240307") is False
+ assert api_provider.anthropic_supports_reasoning("claude-2.1") is False
+ assert api_provider.anthropic_supports_reasoning("claude-instant-1.2") is False
+ assert api_provider.anthropic_supports_reasoning("") is False
+ # Haiku 3.5 is a distinct, newer model - the exclusion pattern must not
+ # over-match it just because it also contains "haiku-3".
+ assert api_provider.anthropic_supports_reasoning("claude-3-5-haiku-20241022") is True
+
+
+# -- Gemini: thinkingBudget vs thinkingLevel, model-family detection ---------
+
+
+def test_gemini_thinking_config_uses_thinking_budget_for_25_series():
+ assert api_provider.gemini_thinking_config("gemini-2.5-pro", "off") == {
+ "thinkingBudget": 0,
+ "includeThoughts": False,
+ }
+ assert api_provider.gemini_thinking_config("gemini-2.5-flash", "high") == {
+ "thinkingBudget": 24576,
+ "includeThoughts": True,
+ }
+
+
+def test_gemini_thinking_config_uses_thinking_level_for_gemini_3():
+ assert api_provider.gemini_thinking_config("gemini-3-flash-preview", "low") == {"thinkingLevel": "LOW"}
+ assert api_provider.gemini_thinking_config("gemini-3.1-pro-preview", "off") == {"thinkingLevel": "MINIMAL"}
+ # Gemini 3's thinkingLevel only defines 3 rungs - medium and high both
+ # resolve to HIGH rather than inventing a value the API doesn't define.
+ assert api_provider.gemini_thinking_config("gemini-3-flash-preview", "medium") == {"thinkingLevel": "HIGH"}
+ assert api_provider.gemini_thinking_config("gemini-3-flash-preview", "high") == {"thinkingLevel": "HIGH"}
+
+
+def test_gemini_thinking_config_is_none_for_a_non_capable_model():
+ assert api_provider.gemini_thinking_config("gemini-2.0-flash", "high") is None
+ assert api_provider.gemini_thinking_config("gemini-2.5-flash-image", "high") is None
+
+
+def test_gemini_supports_reasoning_matches_25_and_3_series_only():
+ assert api_provider.gemini_supports_reasoning("gemini-2.5-pro") is True
+ assert api_provider.gemini_supports_reasoning("gemini-3-flash-preview") is True
+ assert api_provider.gemini_supports_reasoning("gemini-2.0-flash") is False
+
+
+# -- OpenAI-compatible: reasoning_effort, gated by model name ----------------
+
+
+def test_openai_reasoning_kwargs_off_sends_nothing():
+ assert api_provider.openai_reasoning_kwargs("o3-mini", "off") == {}
+
+
+def test_openai_reasoning_kwargs_sends_reasoning_effort_for_known_reasoning_models():
+ for model in ("o1-preview", "o3-mini", "o4-mini", "gpt-5.1-codex-max", "gpt-oss-120b"):
+ assert api_provider.openai_reasoning_kwargs(model, "high") == {"reasoning_effort": "high"}
+
+
+def test_openai_reasoning_kwargs_never_sends_minimal():
+ # Deliberately never sent - real-world reports show "minimal" is
+ # inconsistently supported/buggy across reasoning model versions.
+ result = api_provider.openai_reasoning_kwargs("o1-preview", "off")
+ assert result.get("reasoning_effort") != "minimal"
+
+
+def test_openai_reasoning_kwargs_sends_nothing_for_an_unrecognized_model():
+ # A self-hosted/Groq/vLLM endpoint serving a non-reasoning model must
+ # never receive a parameter it might reject with a hard 400.
+ assert api_provider.openai_reasoning_kwargs("llama-3.1-70b", "high") == {}
+ assert api_provider.openai_reasoning_kwargs("gpt-4o", "high") == {}
+
+
+def test_openai_supports_reasoning_matches_known_prefixes_only():
+ assert api_provider.openai_supports_reasoning("o1-preview") is True
+ assert api_provider.openai_supports_reasoning("gpt-5.1-codex-max") is True
+ assert api_provider.openai_supports_reasoning("gpt-4o") is False
+ assert api_provider.openai_supports_reasoning("llama-3.1-70b") is False
diff --git a/backend/tests/test_backend_composer.py b/backend/tests/test_backend_composer.py
index 35be3f1..ac171fc 100644
--- a/backend/tests/test_backend_composer.py
+++ b/backend/tests/test_backend_composer.py
@@ -54,13 +54,12 @@ def _make_bus_with_settings(settings_manager):
def test_default_payload_matches_generated_validator_shape(monkeypatch):
- # R7.5d: reasoningSelection is now computed from live api_provider
- # module state (see test_reasoning_selection_capability_reflects_
- # active_local_provider below), not hardcoded - pin it here so this
- # shape assertion is deterministic regardless of any other test's
- # ambient global provider state.
- monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True)
- monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
+ # R8a: reasoningSelection is now computed from the RESOLVED MODEL's real
+ # capability (_route_supports_reasoning), not just "is this provider
+ # active" - pin ollama_supports_reasoning directly so this shape
+ # assertion is deterministic regardless of any other test's ambient
+ # global provider state or the bare document's empty modelId.
+ monkeypatch.setattr(api_provider, "ollama_supports_reasoning", lambda model: True)
payload = ComposerDocument().payload()
assert set(payload) == {"draft", "context", "route", "request", "capabilities"}
assert set(payload["draft"]) == {"id", "text", "contextMode", "sendMode", "restored"}
@@ -89,10 +88,10 @@ async def run():
def test_set_reasoning_level_intent_updates_and_rejects_unknown():
async def run():
bus, composer, _, _, _ = make_bus()
- await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"])
- assert composer.reasoning_level == "thinking"
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["high"])
+ assert composer.reasoning_level == "high"
payload = composer.payload()
- assert payload["route"]["reasoning"]["label"] == "Thinking Mode (Enable CoT)"
+ assert payload["route"]["reasoning"]["label"] == "High"
with pytest.raises(ComposerError):
await bus.dispatch_intent("app-composer", "setReasoningLevel", ["nonsense"])
@@ -102,58 +101,91 @@ async def run():
def test_reasoning_selection_capability_reflects_active_local_provider(monkeypatch):
monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True)
monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
+ monkeypatch.setattr(api_provider, "ollama_supports_reasoning", lambda model: True)
assert ComposerDocument().payload()["capabilities"]["reasoningSelection"] is True
- monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
- monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True)
- assert ComposerDocument().payload()["capabilities"]["reasoningSelection"] is True
+ # Same model, but Ollama says it has no reasoning mechanism for it -
+ # the capability must follow the MODEL, not just "is Ollama active".
+ monkeypatch.setattr(api_provider, "ollama_supports_reasoning", lambda model: False)
+ assert ComposerDocument().payload()["capabilities"]["reasoningSelection"] is False
+
+def test_reasoning_selection_capability_is_real_for_cloud_providers_too(monkeypatch):
+ # R8a: this used to be hardcoded to local-only - cloud providers now show
+ # the control whenever their resolved model actually supports reasoning.
monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
- assert ComposerDocument().payload()["capabilities"]["reasoningSelection"] is False
+
+ document = ComposerDocument()
+ document.route_reader = lambda: {
+ "mode": "api",
+ "provider": config.API_PROVIDER_ANTHROPIC,
+ "modelId": "claude-opus-4-6",
+ "modelLabel": "claude-opus-4-6",
+ "modelOptions": [],
+ "label": config.API_PROVIDER_ANTHROPIC,
+ "available": True,
+ "canChange": False,
+ }
+ assert document.payload()["capabilities"]["reasoningSelection"] is True
+
+ document.route_reader = lambda: {
+ "mode": "api",
+ "provider": config.API_PROVIDER_ANTHROPIC,
+ "modelId": "claude-2",
+ "modelLabel": "claude-2",
+ "modelOptions": [],
+ "label": config.API_PROVIDER_ANTHROPIC,
+ "available": True,
+ "canChange": False,
+ }
+ assert document.payload()["capabilities"]["reasoningSelection"] is False
def test_set_reasoning_level_reapplies_live_when_ollama_is_active(tmp_path, monkeypatch):
manager = SettingsManager(tmp_path / "session.dat")
monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True)
monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
+ monkeypatch.setattr(api_provider, "is_api_mode", lambda: False)
calls = []
monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a))
async def run():
bus, document, _, _ = _make_bus_with_settings(manager)
- await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"])
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["high"])
asyncio.run(run())
- assert manager.get_ollama_reasoning_mode() == "Thinking"
- assert calls == [(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_mode": "Thinking"})]
+ assert manager.get_ollama_reasoning_level() == "high"
+ assert calls == [(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_level": "high"})]
def test_set_reasoning_level_reapplies_live_when_llama_cpp_is_active(tmp_path, monkeypatch):
manager = SettingsManager(tmp_path / "session.dat")
monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True)
+ monkeypatch.setattr(api_provider, "is_api_mode", lambda: False)
calls = []
monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a))
async def run():
bus, document, _, _ = _make_bus_with_settings(manager)
- await bus.dispatch_intent("app-composer", "setReasoningLevel", ["quick"])
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["off"])
asyncio.run(run())
- assert manager.get_llama_cpp_reasoning_mode() == "Quick"
+ assert manager.get_llama_cpp_reasoning_level() == "off"
assert len(calls) == 1
provider, settings = calls[0]
assert provider == config.LOCAL_PROVIDER_LLAMACPP
- assert settings["reasoning_mode"] == "Quick"
+ assert settings["reasoning_level"] == "off"
def test_set_reasoning_level_llama_cpp_reapply_failure_surfaces_a_notification(tmp_path, monkeypatch):
manager = SettingsManager(tmp_path / "session.dat")
monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True)
+ monkeypatch.setattr(api_provider, "is_api_mode", lambda: False)
def _boom(*a, **k):
raise RuntimeError("boom")
@@ -162,7 +194,7 @@ def _boom(*a, **k):
async def run():
bus, document, notifications, recorder = _make_bus_with_settings(manager)
- await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"])
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["high"])
return notifications, recorder
notifications, recorder = asyncio.run(run())
@@ -177,26 +209,38 @@ async def run():
# the persisted reasoning_level change.
assert recorder.topics_seen().count("app-composer") == 1
# Persisted despite the live-apply failure.
- assert manager.get_llama_cpp_reasoning_mode() == "Thinking"
+ assert manager.get_llama_cpp_reasoning_level() == "high"
-def test_set_reasoning_level_skips_apply_in_cloud_mode(tmp_path, monkeypatch):
+def test_set_reasoning_level_calls_the_right_cloud_apply_function(tmp_path, monkeypatch):
+ # R8a: cloud providers now genuinely apply too (reasoningSelection only
+ # ever gates the CONTROL's visibility, not whether a chosen value takes
+ # effect) - one call per provider, routed by settings_manager.get_api_provider().
manager = SettingsManager(tmp_path / "session.dat")
monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
- calls = []
- monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a))
+ monkeypatch.setattr(api_provider, "is_api_mode", lambda: True)
- async def run():
- bus, document, _, recorder = _make_bus_with_settings(manager)
- await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"])
- return document, recorder
+ for provider_const, getter_name, setter_name in (
+ (config.API_PROVIDER_ANTHROPIC, "get_anthropic_reasoning_level", "set_anthropic_reasoning_level"),
+ (config.API_PROVIDER_GEMINI, "get_gemini_reasoning_level", "set_gemini_reasoning_level"),
+ (config.API_PROVIDER_OPENAI, "get_openai_reasoning_level", "set_openai_reasoning_level"),
+ ):
+ # No single-purpose setter exists (set_api_settings also needs a
+ # base_url + 3 keys this test has no use for) - this is the same
+ # direct-state shortcut a bare-bones test double would use.
+ manager.state["api_provider"] = provider_const
+ calls = []
+ monkeypatch.setattr(api_provider, setter_name, lambda level, _calls=calls: _calls.append(level))
- document, recorder = asyncio.run(run())
+ async def run():
+ bus, document, _, _ = _make_bus_with_settings(manager)
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["medium"])
- assert document.reasoning_level == "thinking"
- assert calls == []
- assert recorder.topics_seen().count("app-composer") == 1
+ asyncio.run(run())
+
+ assert getattr(manager, getter_name)() == "medium", provider_const
+ assert calls == ["medium"], provider_const
def test_set_reasoning_level_busy_guard_no_ops_even_with_settings_manager(tmp_path, monkeypatch):
@@ -210,13 +254,13 @@ async def run():
bus, document, _, recorder = _make_bus_with_settings(manager)
document.begin_request("req-1")
publishes_before = recorder.topics_seen().count("app-composer")
- await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"])
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["high"])
publishes_after = recorder.topics_seen().count("app-composer")
return document, publishes_before, publishes_after
document, publishes_before, publishes_after = asyncio.run(run())
- assert document.reasoning_level == "quick"
+ assert document.reasoning_level == "off"
assert calls == []
assert publishes_after == publishes_before
@@ -316,11 +360,11 @@ async def run():
# -- R7.5d follow-up: the composer must DISPLAY the same persisted reasoning
-# mode it writes. The first R7.5d pass wired only the write half, leaving the
+# level it writes. The first R7.5d pass wired only the write half, leaving the
# composer's own private reasoning_level as the display source - and since
-# get_ollama_reasoning_mode() defaults to "Thinking" while that field defaults
-# to "quick", a user who had never touched the setting saw the composer report
-# "Quick Mode (No CoT)" while every chat call really ran with think=True.
+# get_ollama_reasoning_level() defaults to "high" while that field defaults
+# to "off", a user who had never touched the setting saw the composer report
+# "Off" while every chat call really ran with reasoning enabled.
def _make_bus_with_composer_and_settings(settings_manager):
@@ -340,26 +384,26 @@ def test_composer_reasoning_level_derives_from_the_persisted_setting(tmp_path, m
manager = SettingsManager(tmp_path / "session.dat")
# The exact startup case that was wrong: never-touched settings, whose
- # getter defaults to "Thinking", against a composer defaulting to "quick".
- assert manager.get_ollama_reasoning_mode() == "Thinking"
+ # getter defaults to "high", against a composer defaulting to "off".
+ assert manager.get_ollama_reasoning_level() == "high"
_, composer, _ = _make_bus_with_composer_and_settings(manager)
- assert composer.payload()["route"]["reasoning"]["level"] == "thinking"
- assert composer.payload()["route"]["reasoning"]["label"] == "Thinking Mode (Enable CoT)"
+ assert composer.payload()["route"]["reasoning"]["level"] == "high"
+ assert composer.payload()["route"]["reasoning"]["label"] == "High"
- manager.set_ollama_reasoning_mode("Quick")
- assert composer.payload()["route"]["reasoning"]["level"] == "quick"
+ manager.set_ollama_reasoning_level("off")
+ assert composer.payload()["route"]["reasoning"]["level"] == "off"
def test_composer_reasoning_level_follows_llama_cpp_when_llama_cpp_is_active(tmp_path, monkeypatch):
monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True)
manager = SettingsManager(tmp_path / "session.dat")
- manager.set_ollama_reasoning_mode("Thinking")
- manager.set_llama_cpp_reasoning_mode("Quick")
+ manager.set_ollama_reasoning_level("high")
+ manager.set_llama_cpp_reasoning_level("off")
_, composer, _ = _make_bus_with_composer_and_settings(manager)
# Must read the ACTIVE provider's setting, not whichever getter is first.
- assert composer.payload()["route"]["reasoning"]["level"] == "quick"
+ assert composer.payload()["route"]["reasoning"]["level"] == "off"
def test_bare_composer_document_still_uses_its_own_fallback_level(monkeypatch):
@@ -368,9 +412,9 @@ def test_bare_composer_document_still_uses_its_own_fallback_level(monkeypatch):
# No reader wired (no SettingsManager) - the local field is the source,
# which is what keeps register_composer(bus, counter) usable in tests.
document = ComposerDocument()
- assert document.payload()["route"]["reasoning"]["level"] == "quick"
- document.set_reasoning_level("thinking")
- assert document.payload()["route"]["reasoning"]["level"] == "thinking"
+ assert document.payload()["route"]["reasoning"]["level"] == "off"
+ document.set_reasoning_level("high")
+ assert document.payload()["route"]["reasoning"]["level"] == "high"
def test_settings_reasoning_change_republishes_the_composer(tmp_path, monkeypatch):
@@ -378,16 +422,16 @@ def test_settings_reasoning_change_republishes_the_composer(tmp_path, monkeypatc
monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: None)
manager = SettingsManager(tmp_path / "session.dat")
- manager.set_ollama_reasoning_mode("Quick")
+ manager.set_ollama_reasoning_level("off")
async def run():
bus, composer, recorder = _make_bus_with_composer_and_settings(manager)
recorder.messages.clear()
- await bus.dispatch_intent("app-settings", "setOllamaReasoningMode", ["Thinking"])
+ await bus.dispatch_intent("app-settings", "setOllamaReasoningLevel", ["high"])
return composer, recorder
composer, recorder = asyncio.run(run())
- assert composer.payload()["route"]["reasoning"]["level"] == "thinking"
+ assert composer.payload()["route"]["reasoning"]["level"] == "high"
# Both surfaces render this value, so both have to be told to rebuild.
assert recorder.topics_seen().count("app-settings") == 1
assert recorder.topics_seen().count("app-composer") == 1
@@ -402,11 +446,11 @@ def test_composer_reasoning_change_republishes_settings(tmp_path, monkeypatch):
async def run():
bus, _, recorder = _make_bus_with_composer_and_settings(manager)
recorder.messages.clear()
- await bus.dispatch_intent("app-composer", "setReasoningLevel", ["quick"])
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["off"])
return recorder
recorder = asyncio.run(run())
- assert manager.get_ollama_reasoning_mode() == "Quick"
+ assert manager.get_ollama_reasoning_level() == "off"
assert recorder.topics_seen().count("app-composer") == 1
assert recorder.topics_seen().count("app-settings") == 1
@@ -421,7 +465,7 @@ def test_composer_reasoning_republish_is_skipped_when_settings_is_not_registered
async def run():
bus, _, _, recorder = _make_bus_with_settings(manager) # no register_settings
- await bus.dispatch_intent("app-composer", "setReasoningLevel", ["quick"])
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["off"])
return recorder
recorder = asyncio.run(run())
diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py
index 35fb185..f2522e2 100644
--- a/backend/tests/test_settings.py
+++ b/backend/tests/test_settings.py
@@ -829,35 +829,35 @@ def _isolate_ollama_task_config(monkeypatch):
monkeypatch.setattr(config, "OLLAMA_MODELS", dict(config.OLLAMA_MODELS))
-def test_set_ollama_reasoning_mode_persists_and_rejects_unknown_modes(manager):
- bus = SessionBus("settings-ollama-reasoning-mode-test")
+def test_set_ollama_reasoning_level_persists_and_rejects_unknown_levels(manager):
+ bus = SessionBus("settings-ollama-reasoning-level-test")
register_settings(bus, manager)
- asyncio.run(bus.dispatch_intent("app-settings", "setOllamaReasoningMode", ["Quick"]))
- assert manager.get_ollama_reasoning_mode() == "Quick"
+ asyncio.run(bus.dispatch_intent("app-settings", "setOllamaReasoningLevel", ["off"]))
+ assert manager.get_ollama_reasoning_level() == "off"
- asyncio.run(bus.dispatch_intent("app-settings", "setOllamaReasoningMode", ["not-a-real-mode"]))
- assert manager.get_ollama_reasoning_mode() == "Quick" # unchanged, not overwritten with garbage
+ asyncio.run(bus.dispatch_intent("app-settings", "setOllamaReasoningLevel", ["not-a-real-level"]))
+ assert manager.get_ollama_reasoning_level() == "off" # unchanged, not overwritten with garbage
-def test_set_ollama_reasoning_mode_reapplies_live_only_when_ollama_is_the_active_provider(manager, monkeypatch):
+def test_set_ollama_reasoning_level_reapplies_live_only_when_ollama_is_the_active_provider(manager, monkeypatch):
# Regression-shaped test for a race preempted at design time (the same
# class the R7.4a audit found after the fact): re-applying the live
# provider state unconditionally would forcibly switch an active
# Anthropic/OpenAI session back to Ollama just because its reasoning
- # mode changed in the background.
+ # level changed in the background.
calls = []
monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a))
monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
bus = SessionBus("settings-ollama-reasoning-not-active-test")
register_settings(bus, manager)
- asyncio.run(bus.dispatch_intent("app-settings", "setOllamaReasoningMode", ["Quick"]))
+ asyncio.run(bus.dispatch_intent("app-settings", "setOllamaReasoningLevel", ["off"]))
assert calls == [] # Ollama isn't live - must not touch the live provider at all
monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True)
- asyncio.run(bus.dispatch_intent("app-settings", "setOllamaReasoningMode", ["Thinking"]))
- assert calls == [(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_mode": "Thinking"})]
+ asyncio.run(bus.dispatch_intent("app-settings", "setOllamaReasoningLevel", ["high"]))
+ assert calls == [(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_level": "high"})]
def test_set_ollama_model_assignment_rejects_unknown_task(manager, monkeypatch):
@@ -1239,36 +1239,36 @@ async def _fake_pick_folder(directory=""):
assert calls == [1]
-def test_set_llama_cpp_reasoning_mode_persists_and_rejects_unknown_modes(manager):
- bus = SessionBus("settings-llama-cpp-reasoning-mode-test")
+def test_set_llama_cpp_reasoning_level_persists_and_rejects_unknown_levels(manager):
+ bus = SessionBus("settings-llama-cpp-reasoning-level-test")
register_settings(bus, manager)
- asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningMode", ["Quick"]))
- assert manager.get_llama_cpp_reasoning_mode() == "Quick"
+ asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningLevel", ["off"]))
+ assert manager.get_llama_cpp_reasoning_level() == "off"
- asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningMode", ["not-a-real-mode"]))
- assert manager.get_llama_cpp_reasoning_mode() == "Quick"
+ asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningLevel", ["not-a-real-level"]))
+ assert manager.get_llama_cpp_reasoning_level() == "off"
-def test_set_llama_cpp_reasoning_mode_reapplies_live_only_when_llama_cpp_is_the_active_provider(manager, monkeypatch):
+def test_set_llama_cpp_reasoning_level_reapplies_live_only_when_llama_cpp_is_the_active_provider(manager, monkeypatch):
calls = []
monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a))
monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
bus = SessionBus("settings-llama-cpp-reasoning-not-active-test")
register_settings(bus, manager)
- asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningMode", ["Quick"]))
+ asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningLevel", ["off"]))
assert calls == []
monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True)
- asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningMode", ["Thinking"]))
+ asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningLevel", ["high"]))
assert len(calls) == 1
assert calls[0][0] == config.LOCAL_PROVIDER_LLAMACPP
- assert calls[0][1]["reasoning_mode"] == "Thinking"
+ assert calls[0][1]["reasoning_level"] == "high"
-def test_set_llama_cpp_reasoning_mode_reapply_failure_reports_a_notice_without_crashing(manager, monkeypatch):
- # Unlike Ollama's reasoning-mode reapply, this one has a REAL failure
+def test_set_llama_cpp_reasoning_level_reapply_failure_reports_a_notice_without_crashing(manager, monkeypatch):
+ # Unlike Ollama's reasoning-level reapply, this one has a REAL failure
# mode: initialize_local_provider re-validates chat_model_path every
# call, which can raise if the persisted GGUF file no longer exists.
monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True)
@@ -1282,10 +1282,10 @@ def _boom(*a, **k):
recorder = Recorder()
bus.attach(recorder)
- asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningMode", ["Quick"]))
+ asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningLevel", ["off"]))
- # The mode is still persisted even though the live reapply failed.
- assert manager.get_llama_cpp_reasoning_mode() == "Quick"
+ # The level is still persisted even though the live reapply failed.
+ assert manager.get_llama_cpp_reasoning_level() == "off"
payload = recorder.messages[-1]["payload"]
assert "could not be applied to the live model" in payload["llamaCppNotice"]
diff --git a/contracts/graphlink_app_settings_payload.py b/contracts/graphlink_app_settings_payload.py
index 9b5e4fe..66b19dc 100644
--- a/contracts/graphlink_app_settings_payload.py
+++ b/contracts/graphlink_app_settings_payload.py
@@ -51,8 +51,9 @@ class AppSettingsStatePayload:
apiCatalogMessage: str
geminiStaticModels: list[str]
geminiStaticImageModels: list[str]
- # R7.4b: Ollama page.
- ollamaReasoningMode: str
+ # R7.4b: Ollama page. R8a: reasoning went from a 2-value Mode
+ # (Thinking/Quick) to a graded 4-value Level (off/low/medium/high).
+ ollamaReasoningLevel: str
ollamaCurrentModel: str
ollamaModelAssignments: dict[str, str]
ollamaScannedModels: list[str]
@@ -60,8 +61,8 @@ class AppSettingsStatePayload:
ollamaScanStatus: str
ollamaPullStatus: str
ollamaNotice: str
- # R7.4c: Llama.cpp page.
- llamaCppReasoningMode: str
+ # R7.4c: Llama.cpp page. R8a: same Mode -> Level change as Ollama above.
+ llamaCppReasoningLevel: str
llamaCppChatModelPath: str
llamaCppTitleModelPath: str
llamaCppChatFormat: str
diff --git a/graphlink_licensing.py b/graphlink_licensing.py
index d6a1fab..6683364 100644
--- a/graphlink_licensing.py
+++ b/graphlink_licensing.py
@@ -20,12 +20,19 @@ def _is_llama_cpp_gguf_path(path_value) -> bool:
class SettingsManager:
NOTIFICATION_TYPES = ("info", "success", "warning", "error")
+ # R8a: the graded reasoning-effort vocabulary shared by all 5 providers'
+ # reasoning-level fields below - see api_provider.py's own REASONING_LEVELS
+ # docstring for the full per-provider mapping story this feeds.
+ REASONING_LEVELS = ("off", "low", "medium", "high")
# Bumped whenever session.dat's shape changes in a way future code needs to branch
# on. Version 2 introduces provider-scoped cloud profiles and explicit local
# model assignment modes. Version 3 persists refreshed cloud model catalogs so
# the composer can offer a useful selector without making a network request on
- # every render.
- CURRENT_SCHEMA_VERSION = 3
+ # every render. Version 4 replaces the 2-value Ollama/Llama.cpp reasoning
+ # "mode" (Thinking/Quick) with a graded 4-value "level" (off/low/medium/
+ # high) shared by all 5 providers, adding real reasoning-effort fields for
+ # Anthropic/Gemini/OpenAI-compatible where none existed before.
+ CURRENT_SCHEMA_VERSION = 4
LEGACY_PRODUCT_MODEL_IDS = {"qwen3:8b", "deepseek-coder:6.7b"}
OLLAMA_MODEL_TASKS = (
"task_title",
@@ -94,8 +101,15 @@ def _load_state(self):
state['ollama_web_validate_model'] = ''
if 'ollama_web_summarize_model' not in state:
state['ollama_web_summarize_model'] = ''
- if 'ollama_reasoning_mode' not in state:
- state['ollama_reasoning_mode'] = 'Thinking'
+ if 'ollama_reasoning_level' not in state:
+ # R8a: reasoning went from a 2-value Ollama/Llama.cpp
+ # bool "mode" to a graded 4-value level shared by every
+ # provider - migrate any already-persisted choice
+ # faithfully rather than silently resetting it: "Quick"
+ # meant no reasoning at all (-> off), "Thinking" meant
+ # full reasoning (-> high, this field's own default).
+ state['ollama_reasoning_level'] = 'off' if state.get('ollama_reasoning_mode') == 'Quick' else 'high'
+ state.pop('ollama_reasoning_mode', None)
if 'ollama_scanned_models' not in state:
state['ollama_scanned_models'] = []
if 'ollama_model_scan_mode' not in state:
@@ -108,8 +122,20 @@ def _load_state(self):
state['llama_cpp_chat_model_path'] = ''
if 'llama_cpp_title_model_path' not in state:
state['llama_cpp_title_model_path'] = ''
- if 'llama_cpp_reasoning_mode' not in state:
- state['llama_cpp_reasoning_mode'] = 'Thinking'
+ if 'llama_cpp_reasoning_level' not in state:
+ # Same migration story as ollama_reasoning_level above.
+ state['llama_cpp_reasoning_level'] = 'off' if state.get('llama_cpp_reasoning_mode') == 'Quick' else 'high'
+ state.pop('llama_cpp_reasoning_mode', None)
+ if 'anthropic_reasoning_level' not in state:
+ # New cloud-provider fields (R8a) - "off" by default,
+ # matching api_provider.py's own conservative default:
+ # extended thinking on a paid API is an opt-in cost/
+ # latency tradeoff, never a silent default.
+ state['anthropic_reasoning_level'] = 'off'
+ if 'gemini_reasoning_level' not in state:
+ state['gemini_reasoning_level'] = 'off'
+ if 'openai_reasoning_level' not in state:
+ state['openai_reasoning_level'] = 'off'
if 'llama_cpp_chat_format' not in state:
state['llama_cpp_chat_format'] = ''
if 'llama_cpp_n_ctx' not in state:
@@ -220,14 +246,14 @@ def _create_initial_state(self):
"task_web_validate": {"mode": INHERIT_MODEL, "model_id": ""},
"task_web_summarize": {"mode": INHERIT_MODEL, "model_id": ""},
},
- "ollama_reasoning_mode": "Thinking",
+ "ollama_reasoning_level": "high",
"ollama_scanned_models": [],
"ollama_model_scan_mode": "",
"ollama_model_scan_path": "",
"ollama_model_scan_locations": [],
"llama_cpp_chat_model_path": "",
"llama_cpp_title_model_path": "",
- "llama_cpp_reasoning_mode": "Thinking",
+ "llama_cpp_reasoning_level": "high",
"llama_cpp_chat_format": "",
"llama_cpp_n_ctx": 4096,
"llama_cpp_n_gpu_layers": 0,
@@ -243,6 +269,12 @@ def _create_initial_state(self):
"anthropic_api_key": "",
"gemini_api_key": "",
"github_access_token": "",
+ # R8a: off by default - extended thinking on a paid API is an
+ # opt-in cost/latency tradeoff, never a silent default (unlike
+ # the local providers above, whose compute is free to the user).
+ "anthropic_reasoning_level": "off",
+ "gemini_reasoning_level": "off",
+ "openai_reasoning_level": "off",
"api_models": {},
"api_models_by_provider": {},
"api_model_catalog_by_provider": {},
@@ -485,12 +517,12 @@ def get_ollama_web_summarize_model(self):
def set_ollama_web_summarize_model(self, model_name: str):
self._set_ollama_model("task_web_summarize", "ollama_web_summarize_model", model_name)
- def get_ollama_reasoning_mode(self):
- return self.state.get("ollama_reasoning_mode", "Thinking")
+ def get_ollama_reasoning_level(self):
+ return self.state.get("ollama_reasoning_level", "high")
- def set_ollama_reasoning_mode(self, mode: str):
- if mode in ['Thinking', 'Quick']:
- self.state['ollama_reasoning_mode'] = mode
+ def set_ollama_reasoning_level(self, level: str):
+ if level in self.REASONING_LEVELS:
+ self.state['ollama_reasoning_level'] = level
self._save_state()
def get_ollama_scanned_models(self):
@@ -544,12 +576,36 @@ def set_llama_cpp_title_model_path(self, model_path: str):
self.state["llama_cpp_title_model_path"] = str(model_path or "").strip()
self._save_state()
- def get_llama_cpp_reasoning_mode(self):
- return self.state.get("llama_cpp_reasoning_mode", "Thinking")
+ def get_llama_cpp_reasoning_level(self):
+ return self.state.get("llama_cpp_reasoning_level", "high")
- def set_llama_cpp_reasoning_mode(self, mode: str):
- if mode in ['Thinking', 'Quick']:
- self.state['llama_cpp_reasoning_mode'] = mode
+ def set_llama_cpp_reasoning_level(self, level: str):
+ if level in self.REASONING_LEVELS:
+ self.state['llama_cpp_reasoning_level'] = level
+ self._save_state()
+
+ def get_anthropic_reasoning_level(self):
+ return self.state.get("anthropic_reasoning_level", "off")
+
+ def set_anthropic_reasoning_level(self, level: str):
+ if level in self.REASONING_LEVELS:
+ self.state['anthropic_reasoning_level'] = level
+ self._save_state()
+
+ def get_gemini_reasoning_level(self):
+ return self.state.get("gemini_reasoning_level", "off")
+
+ def set_gemini_reasoning_level(self, level: str):
+ if level in self.REASONING_LEVELS:
+ self.state['gemini_reasoning_level'] = level
+ self._save_state()
+
+ def get_openai_reasoning_level(self):
+ return self.state.get("openai_reasoning_level", "off")
+
+ def set_openai_reasoning_level(self, level: str):
+ if level in self.REASONING_LEVELS:
+ self.state['openai_reasoning_level'] = level
self._save_state()
def get_llama_cpp_chat_format(self):
@@ -627,7 +683,7 @@ def get_llama_cpp_settings(self):
return {
"chat_model_path": self.get_llama_cpp_chat_model_path(),
"title_model_path": self.get_llama_cpp_title_model_override_path(),
- "reasoning_mode": self.get_llama_cpp_reasoning_mode(),
+ "reasoning_level": self.get_llama_cpp_reasoning_level(),
"chat_format": self.get_llama_cpp_chat_format(),
"n_ctx": self.get_llama_cpp_n_ctx(),
"n_gpu_layers": self.get_llama_cpp_n_gpu_layers(),
diff --git a/web_ui/src/app/chrome/Composer.test.tsx b/web_ui/src/app/chrome/Composer.test.tsx
index 07e75a7..c5f2af7 100644
--- a/web_ui/src/app/chrome/Composer.test.tsx
+++ b/web_ui/src/app/chrome/Composer.test.tsx
@@ -279,11 +279,13 @@ describe("Composer", () => {
route: {
...initialComposerState.route,
reasoning: {
- level: "quick",
- label: "Quick Mode (No CoT)",
+ level: "off",
+ label: "Off",
options: [
- { id: "thinking", label: "Thinking Mode (Enable CoT)", description: "Slower." },
- { id: "quick", label: "Quick Mode (No CoT)", description: "Faster." },
+ { id: "off", label: "Off", description: "No extended reasoning - the fastest, most direct answers." },
+ { id: "low", label: "Low", description: "A little reasoning before answering." },
+ { id: "medium", label: "Medium", description: "A balanced amount of reasoning." },
+ { id: "high", label: "High", description: "Thorough reasoning - slower, higher-quality answers." },
],
},
},
@@ -296,10 +298,10 @@ describe("Composer", () => {
@@ -706,25 +710,18 @@ function LlamaCppPage({ state, transport }: { state: AppSettingsState; transport return (
diff --git a/web_ui/src/app/chrome/composerStore.ts b/web_ui/src/app/chrome/composerStore.ts
index a29c4e2..435d149 100644
--- a/web_ui/src/app/chrome/composerStore.ts
+++ b/web_ui/src/app/chrome/composerStore.ts
@@ -23,7 +23,7 @@ export const initialComposerState: AppComposerState = {
modelId: "",
modelLabel: "",
modelOptions: [],
- reasoning: { level: "quick", label: "Quick Mode (No CoT)", options: [] },
+ reasoning: { level: "off", label: "Off", options: [] },
label: "Ollama (Local)",
available: true,
canChange: false,
diff --git a/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json b/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json
index b24af7b..d724186 100644
--- a/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json
+++ b/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json
@@ -99,7 +99,7 @@
"llamaCppNotice": {
"type": "string"
},
- "llamaCppReasoningMode": {
+ "llamaCppReasoningLevel": {
"type": "string"
},
"llamaCppScanStatus": {
@@ -141,7 +141,7 @@
"ollamaPullStatus": {
"type": "string"
},
- "ollamaReasoningMode": {
+ "ollamaReasoningLevel": {
"type": "string"
},
"ollamaScanStatus": {
@@ -191,7 +191,7 @@
"apiCatalogMessage",
"geminiStaticModels",
"geminiStaticImageModels",
- "ollamaReasoningMode",
+ "ollamaReasoningLevel",
"ollamaCurrentModel",
"ollamaModelAssignments",
"ollamaScannedModels",
@@ -199,7 +199,7 @@
"ollamaScanStatus",
"ollamaPullStatus",
"ollamaNotice",
- "llamaCppReasoningMode",
+ "llamaCppReasoningLevel",
"llamaCppChatModelPath",
"llamaCppTitleModelPath",
"llamaCppChatFormat",
diff --git a/web_ui/src/lib/bridge-core/generated/app-settings-state.ts b/web_ui/src/lib/bridge-core/generated/app-settings-state.ts
index 08cadf5..de24a4c 100644
--- a/web_ui/src/lib/bridge-core/generated/app-settings-state.ts
+++ b/web_ui/src/lib/bridge-core/generated/app-settings-state.ts
@@ -29,7 +29,7 @@ export interface AppSettingsState {
apiCatalogMessage: string;
geminiStaticModels: string[];
geminiStaticImageModels: string[];
- ollamaReasoningMode: string;
+ ollamaReasoningLevel: string;
ollamaCurrentModel: string;
ollamaModelAssignments: Record