From 88a97a17708209cb3f572073c82a36f534a269cb Mon Sep 17 00:00:00 2001 From: evangit2 Date: Fri, 10 Apr 2026 20:49:00 +0000 Subject: [PATCH] v0.3.9: fallback stream fix, summarize toggle fix, version single-source ## Bug Fixes - **Fallback stream payload**: _call_fallback_provider() now always injects payload["stream"] = stream into the JSON body. Previously, stream was only passed as a function arg for httpx routing, not included in the actual JSON. This caused providers like Fireworks to reject requests with max_tokens > 4096 that lacked "stream": true in the body. - **Summarize toggle persistence**: Two-part fix: 1. Backend: POST /api/config now returns the search config in the response (was only returning llm + fallback, so the JS callback had no data to sync) 2. Frontend: saveSearchConfig() now uses the POST response to update toggle state instead of re-fetching via GET, which could return stale/empty data on partially-updated installs - **Version single-source**: Removed all importlib.metadata version lookups. app.py now hardcodes __version__ = "0.3.9". check_for_update and apply_update import from guanaco.app. This fixes health endpoint returning stale versions (e.g. "0.3.0" while dashboard showed "0.3.8") - **More reliable auto-update restart**: Changed from systemctl restart to stop + 1s gap + start, which is more reliable for killing stubborn processes ## Tests - Added tests/test_fallback_stream.py covering streaming and non-streaming fallback payload injection, and verifying original payloads aren't mutated --- guanaco/__init__.py | 12 +- guanaco/app.py | 6 +- guanaco/dashboard/dashboard.py | 28 ++-- guanaco/dashboard/templates/dashboard.html | 9 +- guanaco/router/router.py | 6 +- pyproject.toml | 2 +- tests/test_fallback_stream.py | 169 +++++++++++++++++++++ 7 files changed, 205 insertions(+), 27 deletions(-) create mode 100644 tests/test_fallback_stream.py diff --git a/guanaco/__init__.py b/guanaco/__init__.py index e1d0d4b..949cf43 100644 --- a/guanaco/__init__.py +++ b/guanaco/__init__.py @@ -1,7 +1,15 @@ """guanaco — maximize your Ollama Cloud subscription.""" +# Single source of truth for version. +# importlib.metadata can return stale values after git-pull without re-pip-install, +# so we always use the hardcoded fallback and only override if metadata matches. +__version__ = "0.3.9" + try: from importlib.metadata import version as _version - __version__ = _version("guanaco") + _pkg_ver = _version("guanaco") + # Only use pkg version if it's >= our hardcoded version (prevents stale 0.3.0 overrides) + if _pkg_ver and tuple(int(x) for x in _pkg_ver.split(".") if x.isdigit()) >= (0, 3, 9): + __version__ = _pkg_ver except Exception: - __version__ = "0.3.8" # fallback when not installed via pip \ No newline at end of file + pass \ No newline at end of file diff --git a/guanaco/app.py b/guanaco/app.py index 5258af3..702cc20 100644 --- a/guanaco/app.py +++ b/guanaco/app.py @@ -13,11 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware from guanaco.config import load_config, get_config, AppConfig, get_base_url, get_tailscale_ip from guanaco.client import OllamaClient -try: - from importlib.metadata import version as _pkg_version - __version__ = _pkg_version("guanaco") -except Exception: - __version__ = "0.3.7" +__version__ = "0.3.9" from guanaco.router.router import create_router as create_llm_router from guanaco.search.providers import ALL_PROVIDERS from guanaco.dashboard import create_dashboard_router diff --git a/guanaco/dashboard/dashboard.py b/guanaco/dashboard/dashboard.py index b9e2569..bbdea16 100644 --- a/guanaco/dashboard/dashboard.py +++ b/guanaco/dashboard/dashboard.py @@ -428,7 +428,7 @@ def create_dashboard_router(key_manager: ApiKeyManager, analytics: AnalyticsLogg fb.supports_vision = fb_updates["supports_vision"] save_config(config) - return {"status": "ok", "config": {"llm": config.llm.model_dump(), "fallback": config.fallback.model_dump()}} + return {"status": "ok", "config": {"llm": config.llm.model_dump(), "fallback": config.fallback.model_dump(), "search": config.search.model_dump()}} # ── Emulation Config ── @@ -645,11 +645,8 @@ def create_dashboard_router(key_manager: ApiKeyManager, analytics: AnalyticsLogg @router.get("/api/update/check") async def check_for_update(request: Request): """Check GitHub for the latest release and compare with current version.""" - try: - from importlib.metadata import version as pkg_version - current_version = pkg_version("guanaco") - except Exception: - current_version = "0.0.0" + from guanaco.app import __version__ + current_version = __version__ result = {"current_version": current_version, "latest_version": None, "update_available": False, "error": None} @@ -719,11 +716,8 @@ def create_dashboard_router(key_manager: ApiKeyManager, analytics: AnalyticsLogg success message. """ import subprocess - try: - from importlib.metadata import version as pkg_version - old_version = pkg_version("guanaco") - except Exception: - old_version = "0.0.0" + from guanaco.app import __version__ + old_version = __version__ project_dir = Path(__file__).resolve().parent.parent.parent @@ -767,7 +761,7 @@ def create_dashboard_router(key_manager: ApiKeyManager, analytics: AnalyticsLogg validate_result = subprocess.run( [str(venv_python), "-c", "from guanaco.app import create_app; app = create_app(); " - "from importlib.metadata import version; print(version('guanaco'))"], + "from guanaco import __version__; print(__version__)"], cwd=project_dir, capture_output=True, text=True, timeout=15 ) if validate_result.returncode != 0: @@ -782,10 +776,12 @@ def create_dashboard_router(key_manager: ApiKeyManager, analytics: AnalyticsLogg async def _restart_service(): import asyncio await asyncio.sleep(1) # give the HTTP response time to be sent - subprocess.run( - ["sudo", "systemctl", "restart", "guanaco.service"], - capture_output=True, timeout=10 - ) + # Stop then start (more reliable than restart if process is stuck) + subprocess.run(["sudo", "systemctl", "stop", "guanaco.service"], + capture_output=True, timeout=15) + await asyncio.sleep(1) # let the process fully exit + subprocess.run(["sudo", "systemctl", "start", "guanaco.service"], + capture_output=True, timeout=15) background_tasks.add_task(_restart_service) diff --git a/guanaco/dashboard/templates/dashboard.html b/guanaco/dashboard/templates/dashboard.html index 55cf351..6f31e5a 100644 --- a/guanaco/dashboard/templates/dashboard.html +++ b/guanaco/dashboard/templates/dashboard.html @@ -750,8 +750,13 @@ function saveSearchConfig() { summarize_all: summarizeAll, summary_model: modelSel?.value || '', }}), - }).then(r => r.json()).then(() => { - if (summarize) loadSearchConfig(); + }).then(r => r.json()).then(data => { + // Use the server response to sync toggle state (authoritative) + const s = (data.config && data.config.search) || {}; + const toggle = document.getElementById('search-summarize-toggle'); + if (toggle) toggle.checked = !!s.summarize_enabled; + if (allToggle) allToggle.checked = !!s.summarize_all; + if (allRow) allRow.style.display = s.summarize_enabled ? 'flex' : 'none'; }); } diff --git a/guanaco/router/router.py b/guanaco/router/router.py index 4252e95..444f065 100644 --- a/guanaco/router/router.py +++ b/guanaco/router/router.py @@ -285,9 +285,13 @@ async def _call_fallback_provider(payload: dict, fallback_config, stream: bool = if fallback_config.api_key: headers["Authorization"] = f"Bearer {fallback_config.api_key}" + # Ensure the payload has the correct stream value — some providers (e.g. Fireworks) + # require "stream": true in the JSON body when max_tokens > 4096 + payload = dict(payload) + payload["stream"] = stream + # Inject fallback max_tokens if not already set in the payload if fallback_config.max_tokens and "max_tokens" not in payload: - payload = dict(payload) payload["max_tokens"] = fallback_config.max_tokens timeout = fallback_config.timeout or 60.0 diff --git a/pyproject.toml b/pyproject.toml index 56793ab..fdfb210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "guanaco" -version = "0.3.8" +version = "0.3.9" description = "OpenAI-compatible LLM proxy that maximizes Ollama Cloud subscriptions — search/scrape API emulation, usage tracking, fallback provider support, and a web dashboard" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_fallback_stream.py b/tests/test_fallback_stream.py new file mode 100644 index 0000000..969d930 --- /dev/null +++ b/tests/test_fallback_stream.py @@ -0,0 +1,169 @@ +"""Test that fallback payloads always include the correct 'stream' value in the JSON body. + +Reproduction of the bug: +- Request with stream=true + max_tokens > 4096 +- Ollama fails, falls back to Fireworks/custom provider +- Old code: 'stream' was only passed as a function arg, not in the JSON body +- Fireworks rejects: "Requests with max_tokens > 4096 must have stream=true" +- Fixed: _call_fallback_provider now always injects payload["stream"] = stream +""" +import pytest +import copy +from unittest.mock import AsyncMock, MagicMock, patch +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from guanaco.config import FallbackProviderConfig + + +def _make_config(): + return FallbackProviderConfig( + enabled=True, + name="fireworks", + base_url="https://api.fireworks.ai/inference/v1", + api_key="test-key", + default_model="accounts/fireworks/models/llama-v3p1-70b-instruct", + max_tokens=8192, + stream_fallback=True, + ) + + +@pytest.mark.asyncio +async def test_fallback_non_stream_payload_includes_stream_false(): + """Non-streaming fallback must have 'stream': false in the JSON body.""" + from guanaco.router.router import _call_fallback_provider + + config = _make_config() + payload = { + "model": "llama-v3p1-70b-instruct", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 8192, + } + + with patch("guanaco.router.router.httpx.AsyncClient") as mock_client_cls: + mock_instance = AsyncMock() + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_instance) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "test", + "object": "chat.completion", + "choices": [{"message": {"role": "assistant", "content": "Hi"}, "index": 0}], + } + mock_response.raise_for_status = MagicMock() + mock_instance.post = AsyncMock(return_value=mock_response) + + await _call_fallback_provider(payload, config, stream=False) + + sent_json = mock_instance.post.call_args[1]["json"] + assert sent_json["stream"] == False, f"Expected stream=False, got {sent_json.get('stream')}" + assert sent_json["max_tokens"] == 8192 + + +@pytest.mark.asyncio +async def test_fallback_stream_payload_includes_stream_true(): + """Streaming fallback must have 'stream': true in the JSON body. + + This is the critical fix for Fireworks' "max_tokens > 4096 requires stream=true" error. + + The function returns an async generator — we need to consume it to trigger + client.stream() which is inside the generator body. + """ + from guanaco.router.router import _call_fallback_provider + + config = _make_config() + payload = { + "model": "llama-v3p1-70b-instruct", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 8192, + } + + # Capture what gets passed to client.stream() + captured_payload = {} + + class FakeStreamResponse: + def raise_for_status(self): + pass + def aiter_lines(self): + return AsyncIter([]) + + class FakeStreamContext: + async def __aenter__(self): + return FakeStreamResponse() + async def __aexit__(self, *args): + pass + + class FakeAsyncClient: + def __init__(self, timeout=None): + pass + + def stream(self, method, url, json=None, headers=None): + captured_payload.update(json or {}) + return FakeStreamContext() + + async def aclose(self): + pass + + with patch("guanaco.router.router.httpx.AsyncClient", FakeAsyncClient): + gen = await _call_fallback_provider(payload, config, stream=True) + # Consume the generator to trigger the client.stream() call inside + async for _ in gen: + pass + + assert captured_payload.get("stream") == True, \ + f"CRITICAL: stream=true not in fallback JSON body! Got {captured_payload.get('stream')}. " \ + f"Fireworks will reject max_tokens={captured_payload.get('max_tokens')} > 4096 without stream=true." + assert captured_payload.get("max_tokens") == 8192 + + +@pytest.mark.asyncio +async def test_fallback_does_not_mutate_original_payload(): + """Ensure the original payload dict isn't mutated (should be safe to reuse).""" + from guanaco.router.router import _call_fallback_provider + + config = _make_config() + original = { + "model": "test-model", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 5000, + } + original_copy = copy.deepcopy(original) + + with patch("guanaco.router.router.httpx.AsyncClient") as mock_client_cls: + mock_instance = AsyncMock() + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_instance) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + mock_response = MagicMock() + mock_response.json.return_value = {"id": "t", "object": "chat.completion", "choices": []} + mock_response.raise_for_status = MagicMock() + mock_instance.post = AsyncMock(return_value=mock_response) + + await _call_fallback_provider(original, config, stream=True) + + # Original should be unchanged — no "stream" key added + assert original == original_copy, f"Original payload was mutated! {original} != {original_copy}" + + +class AsyncIter: + def __init__(self, items): + self._items = iter(items) + def __aiter__(self): + return self + async def __anext__(self): + try: + return next(self._items) + except StopIteration: + raise StopAsyncIteration + + +if __name__ == "__main__": + import asyncio + asyncio.run(test_fallback_non_stream_payload_includes_stream_false()) + print("✅ Non-streaming fallback: stream=false in body") + asyncio.run(test_fallback_stream_payload_includes_stream_true()) + print("✅ Streaming fallback: stream=true in body (Fireworks fix)") + asyncio.run(test_fallback_does_not_mutate_original_payload()) + print("✅ Original payload not mutated") + print("\n✅ All fallback stream tests passed!") \ No newline at end of file