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"];