Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,10 @@ class ModelEndpoint(TimestampMixin, Base):
# can be toggled per-endpoint in the UI. NULL = unknown, falls
# back to the model-name keyword heuristic in agent_loop.py.
supports_tools = Column(Boolean, nullable=True, default=None)
# Per-model reasoning preference: JSON map {model_id: "on"|"off"}. Absent or
# empty = "auto" (leave each model's default). Read per-request in
# src/reasoning_control.py and translated into the right native control.
reasoning_modes = Column(Text, nullable=True, default=None)
# Per-user ownership. NULL = legacy/shared (visible to every user) — this
# is the historical default. When non-null, the model picker only shows
# the endpoint to that user (admins always see everything).
Expand Down Expand Up @@ -1072,6 +1076,25 @@ def _migrate_add_supports_tools_column():
pass


def _migrate_add_reasoning_modes_column():
"""Add reasoning_modes column to model_endpoints if it doesn't exist."""
import sqlite3
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
try:
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA table_info(model_endpoints)")
columns = [row[1] for row in cursor.fetchall()]
if columns and "reasoning_modes" not in columns:
conn.execute("ALTER TABLE model_endpoints ADD COLUMN reasoning_modes TEXT")
conn.commit()
logging.getLogger(__name__).info("Migrated: added 'reasoning_modes' column to model_endpoints")
conn.close()
except Exception as e:
logging.getLogger(__name__).warning(f"reasoning_modes migration failed: {e}")


def _migrate_add_cached_models_column():
"""Add cached_models column to model_endpoints if it doesn't exist."""
import sqlite3
Expand Down Expand Up @@ -1935,6 +1958,7 @@ def init_db():
_migrate_add_model_endpoint_owner_column()
_migrate_add_provider_auth_id_column()
_migrate_add_supports_tools_column()
_migrate_add_reasoning_modes_column()
_migrate_add_task_run_model_column()
_migrate_add_owner_column()
_migrate_add_document_archived_column()
Expand Down
7 changes: 7 additions & 0 deletions routes/model_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1806,6 +1806,7 @@ def list_model_endpoints(request: Request) -> List[Dict[str, Any]]:
"ping_error": (ping or {}).get("error") if ping else None,
"model_type": getattr(r, "model_type", None) or "llm",
"supports_tools": getattr(r, "supports_tools", None),
"reasoning_modes": json.loads(r.reasoning_modes) if getattr(r, "reasoning_modes", None) else {},
"endpoint_kind": kind,
"category": _classify_endpoint(base, kind),
"model_refresh_mode": _endpoint_refresh_mode(r, kind),
Expand Down Expand Up @@ -2351,6 +2352,12 @@ async def toggle_model_endpoint(ep_id: str, request: Request):
if "supports_tools" in body:
v = body["supports_tools"]
ep.supports_tools = {True: True, False: False, 'true': True, 'false': False, 1: True, 0: False}.get(v)
if "reasoning_modes" in body:
# JSON map {model_id: "on"|"off"}; "auto"/other values dropped (= leave default).
_rm = body["reasoning_modes"]
if isinstance(_rm, dict):
_clean = {str(k): val for k, val in _rm.items() if val in ("on", "off")}
ep.reasoning_modes = json.dumps(_clean) if _clean else None
if "is_enabled" in body:
v_ie = body['is_enabled']
ep.is_enabled = v_ie.lower() in ('true', '1', 'yes') if isinstance(v_ie, str) else bool(v_ie)
Expand Down
9 changes: 9 additions & 0 deletions src/llm_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2156,6 +2156,15 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
else:
messages_copy = non_sys

# Per-model reasoning control. Category #1: inject the "/think" soft-switch for
# models that gate reasoning that way, when the per-model preference is "on".
# Other ecosystem categories (#2 system-prompt, #3-#5 request-body fields, #6/#8
# graded effort) are catalogued in src/reasoning_control.py for future support.
from src.reasoning_control import reasoning_mode_for, reasoning_directive, inject_directive
_directive = reasoning_directive(model, reasoning_mode_for(model, url))
if _directive:
inject_directive(messages_copy, _directive)

if provider == "anthropic":
target_url = _normalize_anthropic_url(url)
h = _build_anthropic_headers(headers)
Expand Down
194 changes: 194 additions & 0 deletions src/reasoning_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""Per-model reasoning control — Category #1: the ``/think`` soft-switch directive.

Introduces a per-model preference (``ModelEndpoint.reasoning_modes``, a JSON map
``{model_id: "on" | "off"}``; absent = ``"auto"`` = leave the model's default) and,
for models that gate reasoning via the Qwen3/Nemotron ``/think`` soft-switch,
injects ``/think`` into the latest user message when reasoning is turned on.

Why per-model (not per-endpoint or global): whether reasoning can be toggled —
and *how* — is a property of the model, and one endpoint can serve several models
that differ. Odysseus had no per-model settings mechanism (``supports_tools`` etc.
are per-endpoint), so this adds a minimal one keyed by model id, with the
``/think`` directive as its first consumer.

── Reasoning-toggle implementations across the ecosystem ──
Only Category #1 is implemented here; the rest are catalogued so this can grow
without reshaping. (Full map + sources: docs / reasoning-toggle taxonomy.)

Prompt-injection (mutate the messages):
#1 user-message soft-switch "/think" / "/no_think" [IMPLEMENTED] Qwen3, Nemotron-VL, Hunyuan
#2 system-prompt instruction "detailed thinking on/off" [future] Llama-Nemotron (Nano/Super/nim-nano)
Request-body field (add a field to the outgoing request):
#3 chat_template_kwargs.enable_thinking (bool) [future] Qwen3, DeepSeek-V3.1/3.2 self-host, GLM, Granite
#4 native top-level bool (e.g. Ollama "think") [future] Ollama, DashScope
#5 structured {type: enabled|disabled|adaptive} object [future] Anthropic, GLM/Z.ai, DeepSeek/Kimi/Cohere (hosted)
Graded — a separate setting, not a binary toggle (out of scope):
#6 budget where a sentinel disables (Gemini thinkingBudget: 0)
#8 reasoning_effort (low|medium|high) OpenAI o-series/GPT-5, Grok, Mistral
(#7 "reasoning is a separate model id" is model selection, not a toggle — excluded.)

To add a category later: extend the family detection below for prompt-injection
styles (#2), or have the resolver also return a request-body fragment for the
body-field styles (#3–#5), merged into the payload in llm_core.stream_llm.

── Scope of this slice ──
This is *default-off ``/think`` enablement*: the targeted family (Nemotron-VL)
reasons only when ``/think`` is present, so ``on`` injects it and ``off``/``auto``
inject nothing. The ``/no_think`` direction (turning reasoning OFF on default-ON
``/think`` models such as Qwen3) is the natural next increment and is
intentionally not included here.

── Alignment with the #2739 capability schema ──
Two follow-ups land once #2739's control evidence is wired (and persisted) at
runtime; both swap points are isolated to a single function each:
• dispatch off control evidence rather than the model-name heuristic — key on
#2739's ``REASONING_CONTROL_reasoning_message_directive`` instead of
``_is_think_directive_model`` (see TODO there);
• resolve the preference by stable endpoint/model identity rather than the
URL-based lookup (see ``_endpoint_for``).
Until then this stays standalone: name heuristic + model-aware URL lookup.
"""
from __future__ import annotations

import json
import logging
from typing import List, Optional

logger = logging.getLogger(__name__)

AUTO, ON, OFF = "auto", "on", "off"

# Category #1 — models that gate reasoning via the "/think" soft-switch.
# Nemotron-VL is OFF by default and responds ONLY to /think (it ignores the
# enable_thinking kwarg), so for this family `on` -> "/think" and `off`/`auto`
# inject nothing. Substring match; extend this set (and add /no_think handling
# for default-ON families like Qwen3) to cover more #1 models.
# TODO(#2739): replace this name-substring dispatch with #2739 control evidence
# (REASONING_CONTROL_reasoning_message_directive) once that evidence is wired at
# runtime — see "Alignment with the #2739 capability schema" above.
_THINK_DIRECTIVE_MODELS = ("nemotron-nano-12b-vl", "nemotron-nano-vl")


def _is_think_directive_model(model: str) -> bool:
m = (model or "").lower()
if any(p in m for p in _THINK_DIRECTIVE_MODELS):
return True
return "nemotron" in m and "-vl" in m # any Nemotron-VL variant


def reasoning_directive(model: str, mode: str) -> Optional[str]:
"""The Category-#1 message directive to inject for this model + preference, or None.

Only the "/think" soft-switch is implemented (default-off enablement). For
the Nemotron-VL family (default OFF): `on` -> "/think"; `off`/`auto` -> None.
The `/no_think` off-direction for default-ON models is not handled yet.
Models that use a
different mechanism (#2–#5) or have no per-request toggle return None, so the
request is left unchanged — nothing is ever sent to a model that wouldn't
understand it.
"""
if mode != ON:
return None
return "/think" if _is_think_directive_model(model) else None


def inject_directive(messages: List[dict], directive: str) -> None:
"""Prepend `directive` to the latest user message, in place (string or
multimodal-list content). No-op if it's already present."""
for msg in reversed(messages):
if msg.get("role") != "user":
continue
content = msg.get("content")
if isinstance(content, str):
if directive not in content:
msg["content"] = f"{directive} {content}"
elif isinstance(content, list):
if not any(isinstance(b, dict) and directive in (b.get("text") or "") for b in content):
msg["content"] = [{"type": "text", "text": directive}] + content
return


def reasoning_mode_for(model: str, endpoint_url: str) -> str:
"""The stored *user preference* (`on`/`off`) for this model on the endpoint
serving `endpoint_url`, else `auto`. Never raises.

`auto`/`on`/`off` here is intent (what the user wants), kept distinct from
capability metadata (what the model/provider supports). `auto` means "no
explicit choice — leave the model's default", NOT "the provider advertises an
adaptive mode" (that is a #2739 capability concept, resolved separately).
"""
try:
ep = _endpoint_for(model, endpoint_url)
raw = getattr(ep, "reasoning_modes", None) if ep is not None else None
if not raw:
return AUTO
modes = json.loads(raw) if isinstance(raw, str) else (raw or {})
val = modes.get(model) or modes.get((model or "").lower())
return val if val in (ON, OFF) else AUTO
except Exception as e:
logger.debug("reasoning_mode_for failed for %s: %s", endpoint_url, e)
return AUTO


def _endpoint_serves(ep, model: str) -> bool:
"""Whether `model` is among an endpoint's visible model ids — cached or
pinned, minus any that failed probing (`hidden_models`)."""
if not model:
return False
visible, hidden = set(), set()
for attr, sink in (("cached_models", visible),
("pinned_models", visible),
("hidden_models", hidden)):
raw = getattr(ep, attr, None)
if not raw:
continue
try:
ids = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
continue
if isinstance(ids, list):
sink.update(ids)
return model in (visible - hidden)


def _endpoint_for(model: str, endpoint_url: str):
"""Resolve the ModelEndpoint whose preference applies to (model, url).

One base URL can be shared by several endpoint rows (different api keys /
owners / model sets), so a URL-only "first match" can read the wrong row.
Among the URL matches we therefore prefer the row that actually serves
`model`, falling back to the first (preserving the old behaviour when only
one matches or none lists the model).

This is the best identity signal available at the stream layer today, which
sees url + model but not the endpoint id. The fuller fix — resolving by stable
endpoint/model identity — is the #2739-aligned step once that evidence is
threaded through (see the dispatch TODO above).

Reuses agent_loop's candidate-key logic (lazy import to avoid an import cycle).
"""
from core.database import SessionLocal, ModelEndpoint
try:
from src.agent_loop import _endpoint_lookup_keys
keys = _endpoint_lookup_keys(endpoint_url)
except Exception:
raw = (endpoint_url or "").strip()
keys = [raw, raw.rstrip("/")]
db = SessionLocal()
try:
matches, seen = [], set()
for key in keys:
for ep in db.query(ModelEndpoint).filter(ModelEndpoint.base_url == key).all():
if ep.id not in seen:
seen.add(ep.id)
matches.append(ep)
if not matches:
return None
if len(matches) == 1:
return matches[0]
for ep in matches: # disambiguate same-base-url rows by model membership
if _endpoint_serves(ep, model):
return ep
return matches[0]
finally:
db.close()
101 changes: 101 additions & 0 deletions tests/test_reasoning_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Unit tests for per-model reasoning control (src/reasoning_control.py).

Scope: Category #1 — the "/think" soft-switch directive. Covers that it injects
only for /think-dialect models when reasoning is "on", never for off/auto, and
never for models that use a different mechanism (so no directive is leaked to a
model that wouldn't understand it).
"""
from src.reasoning_control import reasoning_directive, inject_directive, reasoning_mode_for, ON, OFF, AUTO


class TestReasoningDirective:
def test_nemotron_vl_on_injects_think(self):
assert reasoning_directive("nemotron-nano-12b-vl", ON) == "/think"

def test_nemotron_vl_variant_matches(self):
assert reasoning_directive("nvidia/nemotron-nano-vl-8b", ON) == "/think"

def test_off_and_auto_inject_nothing(self):
assert reasoning_directive("nemotron-nano-12b-vl", OFF) is None
assert reasoning_directive("nemotron-nano-12b-vl", AUTO) is None

def test_non_think_models_unchanged(self):
# Models that use a different mechanism (or none) must NOT get /think.
assert reasoning_directive("qwen3-vl-30b", ON) is None
assert reasoning_directive("gpt-oss-120b", ON) is None
assert reasoning_directive("llama-3.3-70b-instruct", ON) is None


class TestInjectDirective:
def test_string_content(self):
msgs = [{"role": "user", "content": "hello"}]
inject_directive(msgs, "/think")
assert msgs[0]["content"] == "/think hello"

def test_multimodal_list_content(self):
msgs = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
inject_directive(msgs, "/think")
assert msgs[0]["content"][0] == {"type": "text", "text": "/think"}

def test_idempotent(self):
msgs = [{"role": "user", "content": "/think hello"}]
inject_directive(msgs, "/think")
assert msgs[0]["content"] == "/think hello"

def test_targets_latest_user_turn(self):
msgs = [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "x"},
{"role": "user", "content": "second"},
]
inject_directive(msgs, "/think")
assert msgs[0]["content"] == "first"
assert msgs[2]["content"] == "/think second"


class TestReasoningModeFor:
def test_unknown_url_degrades_to_auto(self):
assert reasoning_mode_for("some-model", "http://nonexistent.invalid:9/v1") == AUTO


class TestEndpointResolution:
"""#6 — when several endpoint rows share a base URL, the preference must be
read from the row that actually serves the model; and a model no row serves
must not pick up a stray preference (no-leak)."""

def test_same_base_url_disambiguates_by_model(self):
import json
from core.database import SessionLocal, ModelEndpoint, Base, engine
# CI runs bare pytest against an in-memory SQLite (see conftest); the
# model_endpoints table may not be present on the active connection by the
# time this runs, so ensure it exists before seeding (no-op if present).
Base.metadata.create_all(bind=engine)
url = "http://shared-rc-test.invalid:9911/v1"
ids = ["rc-test-a", "rc-test-b"]
db = SessionLocal()
try:
db.query(ModelEndpoint).filter(ModelEndpoint.id.in_(ids)).delete(synchronize_session=False)
db.add_all([
ModelEndpoint(id="rc-test-a", name="A", base_url=url, is_enabled=True,
cached_models=json.dumps(["model-a"]),
reasoning_modes=json.dumps({"model-a": "on"})),
ModelEndpoint(id="rc-test-b", name="B", base_url=url, is_enabled=True,
cached_models=json.dumps(["model-b"]),
reasoning_modes=json.dumps({"model-b": "off"})),
])
db.commit()
finally:
db.close()
try:
# each model resolves via the row that actually serves it...
assert reasoning_mode_for("model-a", url) == ON
assert reasoning_mode_for("model-b", url) == OFF
# ...and a model neither row serves gets no stray preference.
assert reasoning_mode_for("model-c", url) == AUTO
finally:
db = SessionLocal()
try:
db.query(ModelEndpoint).filter(ModelEndpoint.id.in_(ids)).delete(synchronize_session=False)
db.commit()
finally:
db.close()