Compare commits

...
2 Commits
Author SHA1 Message Date
MagiomakesandClaude Opus 4.8 4e46e415ea fix(tasks): normalize task endpoint URL to /chat/completions before model call (#4619)
Upstream bug (present in pewdiepie-archdaemon/odysseus main): the task
executor passes task.endpoint_url VERBATIM to the model HTTP call, unlike
the chat path which stores build_chat_url(normalize_base(base)) on the
session. A task carrying an explicit bare OpenAI-compatible base such as
"http://host:11434/v1" therefore POSTs to a 404 ("page not found"); the
agent loop swallows the empty body into "The model returned an empty
response" and marks the run success, so nothing surfaces the failure.

Tasks that omit an endpoint dodge this only because _resolve_defaults()
cribs an already-full URL from a recent chat session. The API/token path
(e.g. an external client that POSTs /api/tasks with endpoint_url=".../v1")
hits it every time.

Fix: route every resolved task endpoint through _normalize_chat_endpoint()
at the three resolution sites (_execute_llm_task, the persona/research
session path, and _execute_research_task). The helper is idempotent
(strips any existing chat suffix, re-appends the correct one) and leaves
native-Ollama (/api...) and already-concrete URLs untouched, so other
providers are unaffected. Proven via isolated repro: ".../v1" -> 404 ->
empty; ".../v1/chat/completions" -> 200 -> real gemma4:31b output.

Regression test asserts the bare-/v1 -> full-chat-URL mapping, idempotency,
and the native-Ollama/empty passthroughs.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:02:31 +02:00
Solanki SumitandClaude Opus 4.8 6a2a39f892 refactor(exceptions): dedupe src/exceptions via core re-export (#4785)
src/exceptions.py was a byte-for-byte duplicate of the canonical
core/exceptions.py. Replace its class bodies with a re-export shim
(mirroring the core/constants.py -> src/constants.py pattern) so the
exception classes are defined in exactly one place. Also fix the stale
"# src/exceptions.py" header comment in core/exceptions.py.

No behavior change: both import paths resolve to the same class objects
(verified by identity), so `except SessionNotFoundError` works regardless
of which module it was imported from. Ran py_compile and
pytest tests/test_app.py (12 passed).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:50:07 +02:00
4 changed files with 103 additions and 27 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# src/exceptions.py
# core/exceptions.py
"""Custom exceptions for the application."""
class SessionNotFoundError(Exception):
+19 -26
View File
@@ -1,29 +1,22 @@
# src/exceptions.py
"""Custom exceptions for the application."""
"""Backward-compatible shim — the single source of truth is core/exceptions.py.
class SessionNotFoundError(Exception):
"""Raised when a requested session is not found."""
def __init__(self, session_id: str):
self.session_id = session_id
super().__init__(f"Session '{session_id}' not found")
Historically this module was a byte-for-byte duplicate of core/exceptions.py,
which is the canonical definition (imported by app.py, core/__init__.py, and
routes/chat_routes.py). To kill the drift, this now simply re-exports the
exception classes from core.exceptions so there is exactly one place that
defines them. Existing `from src.exceptions import ...` callers keep working.
"""
from core.exceptions import ( # noqa: F401
SessionNotFoundError,
InvalidFileUploadError,
LLMServiceError,
WebSearchError,
)
class InvalidFileUploadError(Exception):
"""Raised when a file upload fails validation."""
def __init__(self, message: str, filename: str = None):
self.filename = filename
self.message = message
super().__init__(message)
class LLMServiceError(Exception):
"""Raised when there is an error communicating with the LLM service."""
def __init__(self, message: str, endpoint: str = None):
self.endpoint = endpoint
self.message = message
super().__init__(message)
class WebSearchError(Exception):
"""Raised when there is an error with web search functionality."""
def __init__(self, message: str, query: str = None):
self.query = query
self.message = message
super().__init__(message)
__all__ = [
"SessionNotFoundError",
"InvalidFileUploadError",
"LLMServiceError",
"WebSearchError",
]
+40
View File
@@ -289,6 +289,42 @@ def _checkin_calendar_events(db, owner, start, end):
)
def _normalize_chat_endpoint(url: str) -> str:
"""Repair a resolved task endpoint to a full chat-completions URL.
Unlike the chat path — which stores ``build_chat_url(normalize_base(base))``
on the session — the task executor passes ``task.endpoint_url`` verbatim to
the model HTTP call. A bare OpenAI-compatible base such as
``http://host:11434/v1`` therefore POSTs to a 404 ("page not found") and the
model silently appears to "return an empty response".
Repair only bare OpenAI-compatible bases. Native-Ollama URLs (``/api...``)
and URLs that already point at a concrete endpoint are returned untouched, so
their own downstream normalizers keep working. Idempotent: a URL already
ending in ``/chat/completions`` is left as-is.
"""
if not url:
return url
# Imports kept function-local (endpoint_resolver pulls in heavy deps) but
# OUTSIDE the try: an import failure is a real bug that should surface, not
# be silently swallowed into the un-normalized URL this function exists to
# repair.
from urllib.parse import urlparse
from src.endpoint_resolver import normalize_base, build_chat_url
path = (urlparse(url).path or "").rstrip("/")
if path == "/api" or path.startswith("/api/"):
return url # native Ollama — handled by the native path downstream
if path.endswith(("/chat/completions", "/messages", "/responses", "/completions")):
return url # already a concrete endpoint
try:
return build_chat_url(normalize_base(url))
except Exception:
# Guard only the actual normalization. Returning the URL un-normalized
# reverts to the 404 this fixes, so make the silent revert visible.
logger.debug("task endpoint normalization failed for %r; using as-is", url, exc_info=True)
return url
class TaskScheduler:
def __init__(self, session_manager):
self._session_manager = session_manager
@@ -1357,6 +1393,7 @@ class TaskScheduler:
endpoint_url, model = self._resolve_defaults(db, task.owner)
if not endpoint_url or not model:
raise RuntimeError("No model/endpoint configured")
endpoint_url = _normalize_chat_endpoint(endpoint_url)
# Record the resolved model so _execute_task_locked can persist it on
# the run (tasks rarely pin a model, so this is the only record of
# which model actually produced the output).
@@ -1548,6 +1585,8 @@ class TaskScheduler:
except Exception:
pass
endpoint_url = _normalize_chat_endpoint(endpoint_url)
session_id = task.session_id
if not session_id:
session_id = str(uuid.uuid4())
@@ -1821,6 +1860,7 @@ class TaskScheduler:
endpoint_url, model = self._resolve_defaults(db, task.owner)
if not endpoint_url or not model:
raise RuntimeError("No model/endpoint configured for research")
endpoint_url = _normalize_chat_endpoint(endpoint_url)
# Record the resolved model for the run record (see _execute_task_locked).
self._last_run_model = model
+43
View File
@@ -0,0 +1,43 @@
"""Regression test for the task-path endpoint-URL normalization fix.
Bug: the task executor passed ``task.endpoint_url`` verbatim to the model HTTP
call (unlike the chat path, which normalizes via ``build_chat_url``). A bare
OpenAI-compatible base such as ``http://host:11434/v1`` POSTed to a 404 and the
run silently reported "The model returned an empty response".
The fix routes every resolved task endpoint through ``_normalize_chat_endpoint``.
"""
from src.task_scheduler import _normalize_chat_endpoint
def test_bare_v1_base_gets_chat_completions_suffix():
# The exact failure case: a bare /v1 base must become a full chat URL.
assert (
_normalize_chat_endpoint("http://localhost:11434/v1")
== "http://localhost:11434/v1/chat/completions"
)
def test_full_chat_url_is_unchanged_idempotent():
full = "http://localhost:11434/v1/chat/completions"
assert _normalize_chat_endpoint(full) == full
# Idempotent under repeated application.
assert _normalize_chat_endpoint(_normalize_chat_endpoint(full)) == full
def test_native_ollama_url_left_alone():
# Native Ollama (/api...) has its own downstream normalizer — don't touch it.
assert _normalize_chat_endpoint("http://localhost:11434/api") == "http://localhost:11434/api"
assert _normalize_chat_endpoint("http://localhost:11434/api/chat") == "http://localhost:11434/api/chat"
def test_empty_and_none_are_passthrough():
assert _normalize_chat_endpoint("") == ""
assert _normalize_chat_endpoint(None) is None
def test_trailing_slash_base_normalized():
assert (
_normalize_chat_endpoint("http://localhost:11434/v1/")
== "http://localhost:11434/v1/chat/completions"
)