guanaco/tests/test_fallback_stream.py
evangit2 88a97a1770 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
2026-04-10 20:49:00 +00:00

169 lines
No EOL
6.1 KiB
Python

"""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!")