Commit d9c0e760 authored by Kantz's avatar Kantz
Browse files

tinking reimplemented

parent c9c7f945
......@@ -61,6 +61,7 @@ Backend variables in `math-tutor/backend/.env`:
- `POSTGRES_URL`
- `FRONTEND_URL`
- `LLM_PROVIDER`
- `LLM_THINKING_ENABLED`
- `EMBEDDING_PROVIDER`
Depending on the provider, you also need more variables from `backend/.env-example`, for example:
......
......@@ -7,6 +7,8 @@ DAILY_LLM_TOKEN_LIMIT="500000"
FRONTEND_URL="http://frontend:3000"
LLM_THINKING_ENABLED="False" # "True" or "False"
LLM_TOOL_USE_ENABLED="False" # "True" or "False"
MCP_SHARED_SECRET=""
MCP_BASE_URL=""
......
......@@ -56,6 +56,13 @@ def get_llm_tool_use_enabled() -> bool:
normalized = value.strip().lower()
return normalized == "true"
def get_llm_thinking_enabled() -> bool:
value = os.getenv("LLM_THINKING_ENABLED", "false")
normalized = value.strip().lower()
return normalized == "true"
def get_embedding_provider() -> str:
value = os.getenv("EMBEDDING_PROVIDER")
if value is not None and value.strip():
......
......@@ -64,6 +64,14 @@ def _extract_total_tokens(response: object) -> int:
return int(prompt) + int(completion)
def _get_message_field(message: object, field: str) -> str:
if isinstance(message, dict):
return message.get(field, "") or ""
if hasattr(message, field):
return getattr(message, field) or ""
return ""
# ---
# Quota
# ---
......@@ -147,6 +155,7 @@ def _chat_openai_compatible(
kwargs = {"messages": messages, "model": settings.model}
if settings.temperature is not None:
kwargs["temperature"] = settings.temperature
kwargs["reasoning_effort"] = "medium" if config.get_llm_thinking_enabled() else "none"
response = client.chat.completions.create(**kwargs)
message = response.choices[0].message if response.choices else {}
return {"raw": response, "message": message}
......@@ -167,6 +176,7 @@ def _chat_mistral(
kwargs["temperature"] = settings.temperature
if settings.timeout is not None:
kwargs["timeout_ms"] = int(settings.timeout * 1000)
kwargs["reasoning_effort"] = "medium" if config.get_llm_thinking_enabled() else "none"
with Mistral(api_key=settings.api_key) as client:
response = client.chat.complete(**kwargs)
......@@ -181,7 +191,11 @@ def _chat_ollama(
settings = config.get_ollama_settings()
client = ollama.Client(host=settings.base_url, timeout=settings.timeout)
kwargs: dict = {"model": settings.model, "messages": messages}
kwargs: dict = {
"model": settings.model,
"messages": messages,
"think": config.get_llm_thinking_enabled(),
}
if settings.keepalive:
kwargs["keep_alive"] = settings.keepalive
if settings.temperature is not None:
......@@ -207,11 +221,11 @@ def _run_provider_chat(
def get_message_content(result: dict | object) -> str:
message = result.get("message") if isinstance(result, dict) else result
if isinstance(message, dict):
return message.get("content", "") or ""
if hasattr(message, "content"):
return getattr(message, "content") or ""
return ""
content = _get_message_field(message, "content")
thinking = _get_message_field(message, "thinking").strip()
if thinking:
return f"{thinking}</think>\n\n{content}"
return content
def chat(
......
......@@ -227,6 +227,7 @@ async def _run_openai_compatible_tool_loop(
"model": settings.model,
"messages": request_messages,
"tools": chat_tools,
"reasoning_effort": "medium" if config.get_llm_thinking_enabled() else "none",
}
if settings.temperature is not None:
request_kwargs["temperature"] = settings.temperature
......@@ -293,6 +294,7 @@ async def _run_ollama_tool_loop(
"model": settings.model,
"messages": request_messages,
"tools": chat_tools,
"think": config.get_llm_thinking_enabled(),
}
if settings.keepalive is not None:
request_kwargs["keep_alive"] = settings.keepalive
......
......@@ -121,6 +121,36 @@ class _FakeOpenAIClient:
self.chat = types.SimpleNamespace(completions=completions)
class _FakeSyncOpenAIClient:
def __init__(self, response: object) -> None:
self.calls: list[dict] = []
self.chat = types.SimpleNamespace(
completions=types.SimpleNamespace(create=self._create)
)
self._response = response
def _create(self, **kwargs):
self.calls.append(kwargs)
return self._response
class _FakeMistralClient:
calls: list[dict] = []
def __init__(self, **kwargs) -> None:
self.chat = types.SimpleNamespace(complete=self._complete)
def __enter__(self):
return self
def __exit__(self, *args) -> None:
return None
def _complete(self, **kwargs):
self.calls.append(kwargs)
return types.SimpleNamespace(choices=[types.SimpleNamespace(message={"content": "done"})])
class _FakeToolResult:
def __init__(
self,
......@@ -175,6 +205,13 @@ class LLMProviderConfigTest(unittest.TestCase):
with patch.dict(os.environ, {}, clear=True):
self.assertFalse(config.get_llm_tool_use_enabled())
def test_get_llm_thinking_enabled_reads_boolean(self) -> None:
"""Verifies that thinking mode is controlled by the env flag."""
with patch.dict(os.environ, {}, clear=True):
self.assertFalse(config.get_llm_thinking_enabled())
with patch.dict(os.environ, {"LLM_THINKING_ENABLED": "true"}, clear=True):
self.assertTrue(config.get_llm_thinking_enabled())
def test_get_gwdg_chat_settings_reads_gwdg_keys(self) -> None:
"""Verifies that GWDG chat settings are loaded from the expected env vars."""
env = {
......@@ -415,6 +452,69 @@ class LLMClientProviderTest(unittest.TestCase):
tool_chat.assert_called_once_with("ollama", MESSAGES)
ollama_chat.assert_not_called()
def test_ollama_chat_disables_thinking_but_formats_it_when_returned(self) -> None:
"""Checks that returned Ollama thinking still reaches the existing UI format."""
calls = []
class FakeOllamaClient:
def __init__(self, **kwargs) -> None:
calls.append({"client": kwargs})
def chat(self, model="", messages=None, *, think=None):
kwargs = {"model": model, "messages": messages, "think": think}
calls.append({"chat": kwargs})
return {"message": {"content": "answer", "thinking": "work"}}
settings = config.OllamaSettings(
base_url="http://ollama",
model="qwen3",
timeout=None,
keepalive=None,
temperature=None,
)
with patch.dict(os.environ, {"LLM_THINKING_ENABLED": "false"}), patch.object(
config, "get_ollama_settings", return_value=settings
), patch.object(llm_client.ollama, "Client", FakeOllamaClient):
result = llm_client._chat_ollama(MESSAGES)
self.assertFalse(calls[1]["chat"]["think"])
self.assertEqual(llm_client.get_message_content(result), "work</think>\n\nanswer")
def test_openai_compatible_chat_sets_reasoning_effort_from_env(self) -> None:
"""Checks that OpenAI-compatible chat maps thinking mode to reasoning effort."""
settings = config.OpenAIChatSettings(
base_url="https://example.com/v1",
api_key="key",
model="model",
timeout=30.0,
temperature=None,
)
fake_client = _FakeSyncOpenAIClient(_make_openai_response({"content": "done"}))
with patch.dict(os.environ, {"LLM_THINKING_ENABLED": "true"}), patch.object(
llm_client, "OpenAI", return_value=fake_client
):
llm_client._chat_openai_compatible(MESSAGES, settings)
self.assertEqual(fake_client.calls[0]["reasoning_effort"], "medium")
def test_mistral_chat_sets_reasoning_effort_from_env(self) -> None:
"""Checks that Mistral chat maps thinking mode to reasoning effort."""
settings = config.MistralChatSettings(
api_key="key",
model="mistral",
timeout=None,
temperature=None,
)
_FakeMistralClient.calls = []
with patch.dict(os.environ, {"LLM_THINKING_ENABLED": "false"}), patch.object(
llm_client, "Mistral", _FakeMistralClient
):
llm_client._chat_mistral(MESSAGES, settings)
self.assertEqual(_FakeMistralClient.calls[0]["reasoning_effort"], "none")
def test_selected_gwdg_config_error_happens_before_quota(self) -> None:
"""Checks that missing GWDG config fails before any quota accounting runs."""
with patch.dict(
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment