From f7dc0b77b47d877e6209af01e1620558d237e2d2 Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Tue, 28 Jul 2026 16:24:07 -0400 Subject: [PATCH] R8a: make reasoning a graded level (off/low/medium/high) across all 5 providers The reasoning control was a bare bool (Thinking/Quick), applied only to Ollama/Llama.cpp models whose name happened to contain "qwen3" or "deepseek", and completely absent for every cloud provider - the composer hid the control entirely outside local mode. Researched how Ollama and each cloud provider actually expose adjustable reasoning effort before touching any code, since the mechanisms are genuinely different per provider and per model generation, not one shared knob: - 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 (a real 400 error) - these are mutually exclusive request shapes, not two names for the same thing, so the model string has to be checked before either is sent - Gemini: thinkingBudget (integer, 2.5-series) vs thinkingLevel (string, Gemini 3) - same story, different generations - Ollama: think is a bool for qwen3/deepseek/qwq, but a REQUIRED string level for gpt-oss - two different mechanisms on the same provider One shared off/low/medium/high vocabulary now drives all five, each mapped to whatever that provider/model actually supports - including being honest where a model can't truly grade: Ollama/Llama.cpp's bool- only models (qwen3/deepseek/qwq) get a real second axis via a plain- language reasoning-budget system-prompt hint layered on top of the bool, documented as prompt-based guidance rather than a fake graded API param. The composer's reasoning control now shows real capability detection (does the CURRENTLY RESOLVED model support reasoning at all) instead of "is this a local provider" - cloud providers get the control whenever their active model genuinely supports it. Settings > Ollama/Llama.cpp pages get the same 4-option radio group the composer uses; adding equivalent Settings pages for the three cloud providers is left for a follow-up, since the composer's own popover already reads/writes their persisted level in the meantime. Existing session.dat files migrate the old 2-value mode to the new scale (Quick -> off, Thinking -> high) rather than resetting silently; the three new cloud fields default to off (extended thinking on a paid API is an opt-in cost/latency tradeoff, never a silent default) while the local providers keep defaulting to high (local compute is free to the user). Verified live: the real, already-migrated session.dat correctly shows ollama_reasoning_level=high/llama_cpp_reasoning_level=high (from the prior Thinking value) and all three cloud fields at off, and the composer's Reasoning popover renders all four real options against the live backend. --- api_provider.py | 365 ++++++++++++++++-- backend/composer.py | 137 +++++-- backend/settings.py | 116 +++--- backend/tests/test_api_provider_reasoning.py | 196 ++++++++++ backend/tests/test_backend_composer.py | 158 +++++--- backend/tests/test_settings.py | 52 +-- contracts/graphlink_app_settings_payload.py | 9 +- graphlink_licensing.py | 94 ++++- web_ui/src/app/chrome/Composer.test.tsx | 18 +- web_ui/src/app/chrome/SettingsDialog.test.tsx | 20 +- web_ui/src/app/chrome/SettingsDialog.tsx | 77 ++-- web_ui/src/app/chrome/composerStore.ts | 2 +- .../generated/app-settings-state.schema.json | 8 +- .../generated/app-settings-state.ts | 16 +- 14 files changed, 977 insertions(+), 291 deletions(-) create mode 100644 backend/tests/test_api_provider_reasoning.py 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", () => { , ); - await user.click(screen.getByText("Quick Mode (No CoT)")); - await user.click(screen.getByText("Thinking Mode (Enable CoT)")); - expect(setReasoningLevel).toHaveBeenCalledWith("thinking"); - expect(screen.queryByText("Thinking Mode (Enable CoT)")).toBeNull(); + await user.click(screen.getByText("Off")); + await user.click(screen.getByText("High")); + expect(setReasoningLevel).toHaveBeenCalledWith("high"); + expect(screen.queryByText("High")).toBeNull(); }); it("Reasoning trigger is disabled when reasoningSelection is false", () => { diff --git a/web_ui/src/app/chrome/SettingsDialog.test.tsx b/web_ui/src/app/chrome/SettingsDialog.test.tsx index 1fa9df7..89e22ad 100644 --- a/web_ui/src/app/chrome/SettingsDialog.test.tsx +++ b/web_ui/src/app/chrome/SettingsDialog.test.tsx @@ -32,7 +32,7 @@ const snapshot = { apiCatalogMessage: "Model catalog has not been refreshed yet.", geminiStaticModels: ["gemini-2.5-flash", "gemini-2.5-pro"], geminiStaticImageModels: ["gemini-2.5-flash-image"], - ollamaReasoningMode: "Thinking", + ollamaReasoningLevel: "high", ollamaCurrentModel: "", ollamaModelAssignments: {}, ollamaScannedModels: [], @@ -40,7 +40,7 @@ const snapshot = { ollamaScanStatus: "idle", ollamaPullStatus: "idle", ollamaNotice: "", - llamaCppReasoningMode: "Thinking", + llamaCppReasoningLevel: "high", llamaCppChatModelPath: "", llamaCppTitleModelPath: "", llamaCppChatFormat: "", @@ -154,7 +154,7 @@ describe("SettingsDialog", () => { await goToLlamaCpp(user, push); expect(screen.queryByText(/lands in R7\.4c/)).toBeNull(); - expect(screen.getByText("Reasoning Mode")).toBeInTheDocument(); + expect(screen.getByText("Reasoning Level")).toBeInTheDocument(); }); it("Ollama page renders for the real (not deferred-placeholder) section", async () => { @@ -162,16 +162,16 @@ describe("SettingsDialog", () => { await goToOllama(user, push); expect(screen.queryByText(/lands in R7\.4c/)).toBeNull(); - expect(screen.getByText("Reasoning Mode")).toBeInTheDocument(); + expect(screen.getByText("Reasoning Level")).toBeInTheDocument(); }); - it("clicking a reasoning mode radio fires setOllamaReasoningMode", async () => { + it("clicking a reasoning level radio fires setOllamaReasoningLevel", async () => { const { user, push, intents } = setup(); await goToOllama(user, push); - await user.click(screen.getByLabelText("Quick Mode (No CoT)")); + await user.click(screen.getByLabelText("Low")); - expect(intents).toContainEqual(["app-settings", "setOllamaReasoningMode", ["Quick"]]); + expect(intents).toContainEqual(["app-settings", "setOllamaReasoningLevel", ["low"]]); }); it("shows the current active model when one is set", async () => { @@ -429,13 +429,13 @@ describe("SettingsDialog", () => { expect(screen.getByLabelText("Chat, Explain, Takeaways (main model)")).toHaveValue(""); }); - it("clicking a reasoning mode radio fires setLlamaCppReasoningMode", async () => { + it("clicking a reasoning level radio fires setLlamaCppReasoningLevel", async () => { const { user, push, intents } = setup(); await goToLlamaCpp(user, push); - await user.click(screen.getByLabelText("Quick Mode (No CoT)")); + await user.click(screen.getByLabelText("Low")); - expect(intents).toContainEqual(["app-settings", "setLlamaCppReasoningMode", ["Quick"]]); + expect(intents).toContainEqual(["app-settings", "setLlamaCppReasoningLevel", ["low"]]); }); it("shows 'No model selected' when no chat model path is set", async () => { diff --git a/web_ui/src/app/chrome/SettingsDialog.tsx b/web_ui/src/app/chrome/SettingsDialog.tsx index cee9339..9887c1e 100644 --- a/web_ui/src/app/chrome/SettingsDialog.tsx +++ b/web_ui/src/app/chrome/SettingsDialog.tsx @@ -30,6 +30,17 @@ const OLLAMA_TASK_LABELS: Record<(typeof OLLAMA_TASKS)[number], string> = { const OLLAMA_MODELS_DATALIST_ID = "settings-ollama-scanned-models"; const LLAMA_CPP_MODELS_DATALIST_ID = "settings-llama-cpp-scanned-models"; +// R8a: reasoning went from a 2-value Mode (Thinking/Quick) to a graded +// 4-value Level shared by both local providers here and the composer's own +// Reasoning popover (backend/composer.py's REASONING_OPTIONS) - same ids, +// same labels, so a user never has to learn two different vocabularies. +const REASONING_LEVEL_OPTIONS = [ + { id: "off", label: "Off" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, +] as const; + // Llama.cpp has no per-task assignment concept like Ollama's OLLAMA_TASKS - // just one global chat model path plus an optional title/naming override // (api_provider.py's _get_llama_cpp_model_path: chart/web-validate/web- @@ -99,7 +110,7 @@ const initialState: AppSettingsState = { apiCatalogMessage: "Model catalog has not been refreshed yet.", geminiStaticModels: [], geminiStaticImageModels: [], - ollamaReasoningMode: "Thinking", + ollamaReasoningLevel: "high", ollamaCurrentModel: "", ollamaModelAssignments: {}, ollamaScannedModels: [], @@ -107,7 +118,7 @@ const initialState: AppSettingsState = { ollamaScanStatus: "idle", ollamaPullStatus: "idle", ollamaNotice: "", - llamaCppReasoningMode: "Thinking", + llamaCppReasoningLevel: "high", llamaCppChatModelPath: "", llamaCppTitleModelPath: "", llamaCppChatFormat: "", @@ -520,25 +531,18 @@ function OllamaPage({ state, transport }: { state: AppSettingsState; transport: return (
- Reasoning Mode - - + Reasoning Level + {REASONING_LEVEL_OPTIONS.map((option) => ( + + ))}

@@ -706,25 +710,18 @@ function LlamaCppPage({ state, transport }: { state: AppSettingsState; transport return (

- Reasoning Mode - - + Reasoning Level + {REASONING_LEVEL_OPTIONS.map((option) => ( + + ))}

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; ollamaScannedModels: string[]; @@ -37,7 +37,7 @@ export interface AppSettingsState { ollamaScanStatus: string; ollamaPullStatus: string; ollamaNotice: string; - llamaCppReasoningMode: string; + llamaCppReasoningLevel: string; llamaCppChatModelPath: string; llamaCppTitleModelPath: string; llamaCppChatFormat: string; @@ -195,9 +195,9 @@ function checkAppSettingsState(value: unknown, path: string, errors: string[]): else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.geminiStaticImageModels` + `[${i}]` + ": expected string"); }); } } { - const fieldValue = value["ollamaReasoningMode"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ollamaReasoningMode: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.ollamaReasoningMode` + ": expected string"); } + const fieldValue = value["ollamaReasoningLevel"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ollamaReasoningLevel: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.ollamaReasoningLevel` + ": expected string"); } } { const fieldValue = value["ollamaCurrentModel"]; @@ -237,9 +237,9 @@ function checkAppSettingsState(value: unknown, path: string, errors: string[]): else { if (typeof fieldValue !== "string") errors.push(`${path}.ollamaNotice` + ": expected string"); } } { - const fieldValue = value["llamaCppReasoningMode"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppReasoningMode: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppReasoningMode` + ": expected string"); } + const fieldValue = value["llamaCppReasoningLevel"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppReasoningLevel: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppReasoningLevel` + ": expected string"); } } { const fieldValue = value["llamaCppChatModelPath"];