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
This commit is contained in:
parent
0bbc3298e6
commit
88a97a1770
7 changed files with 205 additions and 27 deletions
|
|
@ -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
|
||||
pass
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
169
tests/test_fallback_stream.py
Normal file
169
tests/test_fallback_stream.py
Normal file
|
|
@ -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!")
|
||||
Loading…
Add table
Add a link
Reference in a new issue