mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-13 19:52:20 +02:00
350 lines
13 KiB
Python
350 lines
13 KiB
Python
"""Streaming tool-call accumulation tests for the OpenAI-compatible path.
|
|
|
|
Regression for Gemini's OpenAI-compat layer, which (a) attaches an opaque
|
|
thought_signature in `extra_content` on the function-call delta and (b) omits
|
|
`index` on PARALLEL tool calls — every parallel delta arrives as index=None.
|
|
The accumulator must give each parallel call its own slot (otherwise they
|
|
collide into slot 0, overwriting the first call's name and concatenating —
|
|
corrupting — its arguments) and must preserve extra_content per call.
|
|
"""
|
|
import json
|
|
import asyncio
|
|
|
|
from src import llm_core
|
|
|
|
|
|
class _FakeResp:
|
|
def __init__(self, lines):
|
|
self._lines = lines
|
|
self.status_code = 200
|
|
|
|
async def aiter_lines(self):
|
|
for ln in self._lines:
|
|
yield ln
|
|
|
|
async def aread(self):
|
|
return b""
|
|
|
|
|
|
class _FakeStreamCtx:
|
|
def __init__(self, lines):
|
|
self._lines = lines
|
|
|
|
async def __aenter__(self):
|
|
return _FakeResp(self._lines)
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self, lines):
|
|
self._lines = lines
|
|
|
|
def stream(self, method, url, **kw):
|
|
return _FakeStreamCtx(self._lines)
|
|
|
|
|
|
class _DelayedResp(_FakeResp):
|
|
def __init__(self, lines, delay):
|
|
super().__init__(lines)
|
|
self._delay = delay
|
|
|
|
async def aiter_lines(self):
|
|
await asyncio.sleep(self._delay)
|
|
async for line in super().aiter_lines():
|
|
yield line
|
|
|
|
|
|
class _RoleOnlyThenStallResp(_FakeResp):
|
|
"""Emit a provider preamble, then stall before substantive output."""
|
|
|
|
async def aiter_lines(self):
|
|
yield _sse({"role": "assistant"})
|
|
await asyncio.sleep(1)
|
|
|
|
|
|
class _WhitespaceThenStallResp(_FakeResp):
|
|
"""Emit whitespace content, then stall before substantive output."""
|
|
|
|
async def aiter_lines(self):
|
|
yield _sse({"content": " \n\t"})
|
|
await asyncio.sleep(1)
|
|
|
|
|
|
class _SequenceStreamCtx:
|
|
def __init__(self, response):
|
|
self._response = response
|
|
|
|
async def __aenter__(self):
|
|
return self._response
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
|
|
class _SequenceClient:
|
|
def __init__(self, responses):
|
|
self._responses = iter(responses)
|
|
self.payloads = []
|
|
|
|
def stream(self, method, url, **kw):
|
|
self.payloads.append(kw.get("json") or {})
|
|
return _SequenceStreamCtx(next(self._responses))
|
|
|
|
|
|
def _drive(monkeypatch, lines, model="gemini-3.1-pro-preview-customtools"):
|
|
"""Run stream_llm against a canned SSE line list; return parsed events."""
|
|
monkeypatch.setattr(llm_core, "_get_http_client", lambda: _FakeClient(lines))
|
|
monkeypatch.setattr(llm_core, "_is_host_dead", lambda u: False)
|
|
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a, **k: None)
|
|
monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *a, **k: None)
|
|
|
|
async def run():
|
|
events = []
|
|
async for chunk in llm_core.stream_llm(
|
|
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
model,
|
|
[{"role": "user", "content": "hi"}],
|
|
headers={"Authorization": "Bearer k"},
|
|
tools=[{"type": "function", "function": {"name": "x", "parameters": {}}}],
|
|
):
|
|
for ln in chunk.split("\n"):
|
|
ln = ln.strip()
|
|
if ln.startswith("data: ") and ln[6:] != "[DONE]":
|
|
try:
|
|
events.append(json.loads(ln[6:]))
|
|
except ValueError:
|
|
pass
|
|
return events
|
|
|
|
return asyncio.run(run())
|
|
|
|
|
|
def _sse(delta):
|
|
return "data: " + json.dumps({"choices": [{"delta": delta}]})
|
|
|
|
|
|
def test_response_reference_is_emitted_once(monkeypatch):
|
|
lines = [
|
|
"data: " + json.dumps({
|
|
"id": "response-neutral-1",
|
|
"model": "provider-model",
|
|
"choices": [{"delta": {"content": "ready"}}],
|
|
}),
|
|
"data: " + json.dumps({
|
|
"id": "response-neutral-1",
|
|
"model": "provider-model",
|
|
"choices": [{"delta": {"content": "."}}],
|
|
}),
|
|
"data: [DONE]",
|
|
]
|
|
|
|
events = _drive(monkeypatch, lines, model="requested-model")
|
|
references = [event for event in events if event.get("type") == "model_response_ref"]
|
|
|
|
assert references == [{
|
|
"type": "model_response_ref",
|
|
"response_id": "response-neutral-1",
|
|
"model": "provider-model",
|
|
}]
|
|
|
|
|
|
def test_silent_local_first_event_retries_without_cache_affinity(monkeypatch):
|
|
# A local server can accept the request and then stall before its first
|
|
# SSE event. The retry must be limited to that silent-first-event case and
|
|
# must remove the local session affinity fields from the second request.
|
|
lines = [_sse({"content": "recovered"}), "data: [DONE]"]
|
|
client = _SequenceClient([
|
|
_DelayedResp([], 0.05),
|
|
_FakeResp(lines),
|
|
])
|
|
monkeypatch.setattr(llm_core, "_get_http_client", lambda: client)
|
|
monkeypatch.setattr(llm_core, "_is_host_dead", lambda u: False)
|
|
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a, **k: None)
|
|
monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *a, **k: None)
|
|
monkeypatch.setattr(llm_core, "is_local_endpoint", lambda u: True)
|
|
monkeypatch.setenv("ODYSSEUS_FIRST_TOKEN_TIMEOUT", "0.01")
|
|
|
|
async def run():
|
|
chunks = []
|
|
async for chunk in llm_core.stream_llm(
|
|
"http://127.0.0.1:18403/v1",
|
|
"local-model",
|
|
[{"role": "user", "content": "hi"}],
|
|
session_id="session-1",
|
|
):
|
|
chunks.append(chunk)
|
|
return chunks
|
|
|
|
chunks = asyncio.run(run())
|
|
assert any("recovered" in chunk for chunk in chunks)
|
|
assert client.payloads[0]["session_id"] == "session-1"
|
|
assert "session_id" not in client.payloads[1]
|
|
|
|
|
|
def test_role_only_message_does_not_disable_silent_local_watchdog(monkeypatch):
|
|
# Providers may send a role-only preamble before generation. It must not
|
|
# count as the first usable event; otherwise a subsequent silent stream
|
|
# waits for the much longer ordinary read timeout.
|
|
lines = [_sse({"content": "recovered"}), "data: [DONE]"]
|
|
client = _SequenceClient([
|
|
_RoleOnlyThenStallResp([]),
|
|
_FakeResp(lines),
|
|
])
|
|
monkeypatch.setattr(llm_core, "_get_http_client", lambda: client)
|
|
monkeypatch.setattr(llm_core, "_is_host_dead", lambda u: False)
|
|
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a, **k: None)
|
|
monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *a, **k: None)
|
|
monkeypatch.setattr(llm_core, "is_local_endpoint", lambda u: True)
|
|
monkeypatch.setenv("ODYSSEUS_FIRST_TOKEN_TIMEOUT", "0.01")
|
|
|
|
async def run():
|
|
chunks = []
|
|
async for chunk in llm_core.stream_llm(
|
|
"http://127.0.0.1:18403/v1",
|
|
"local-model",
|
|
[{"role": "user", "content": "hi"}],
|
|
):
|
|
chunks.append(chunk)
|
|
return chunks
|
|
|
|
chunks = asyncio.run(run())
|
|
assert any("recovered" in chunk for chunk in chunks)
|
|
assert len(client.payloads) == 2
|
|
|
|
|
|
def test_whitespace_content_does_not_disable_silent_local_watchdog(monkeypatch):
|
|
# Whitespace-only deltas are another common gateway heartbeat shape. They
|
|
# must not turn a silent follow-up into a full five-minute read timeout.
|
|
lines = [_sse({"content": "recovered"}), "data: [DONE]"]
|
|
client = _SequenceClient([
|
|
_WhitespaceThenStallResp([]),
|
|
_FakeResp(lines),
|
|
])
|
|
monkeypatch.setattr(llm_core, "_get_http_client", lambda: client)
|
|
monkeypatch.setattr(llm_core, "_is_host_dead", lambda u: False)
|
|
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a, **k: None)
|
|
monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *a, **k: None)
|
|
monkeypatch.setattr(llm_core, "is_local_endpoint", lambda u: True)
|
|
monkeypatch.setenv("ODYSSEUS_FIRST_TOKEN_TIMEOUT", "0.01")
|
|
|
|
async def run():
|
|
chunks = []
|
|
async for chunk in llm_core.stream_llm(
|
|
"http://127.0.0.1:18403/v1",
|
|
"local-model",
|
|
[{"role": "user", "content": "hi"}],
|
|
):
|
|
chunks.append(chunk)
|
|
return chunks
|
|
|
|
chunks = asyncio.run(run())
|
|
assert any("recovered" in chunk for chunk in chunks)
|
|
assert len(client.payloads) == 2
|
|
|
|
|
|
def test_private_gpu_endpoint_gets_first_event_watchdog_even_if_api_classified(monkeypatch):
|
|
# Endpoint metadata may call a self-hosted GPU URL an API/proxy because it
|
|
# has an auth record. The private address still identifies a managed
|
|
# service, so it must not be allowed to hang for the full read timeout.
|
|
monkeypatch.setattr(llm_core, "is_local_endpoint", lambda u: False)
|
|
monkeypatch.delenv("ODYSSEUS_FIRST_TOKEN_TIMEOUT", raising=False)
|
|
|
|
assert llm_core._first_token_timeout("http://100.118.44.115:18403/v1", 300) == 60
|
|
assert llm_core._first_token_timeout("https://api.openai.com/v1", 300) == 0
|
|
|
|
|
|
def test_parallel_calls_with_null_index_do_not_collide(monkeypatch):
|
|
# Two parallel calls, each complete in one delta, both with index=None
|
|
# (exactly what Gemini's OpenAI-compat layer emits). Only the first carries
|
|
# a thought_signature.
|
|
lines = [
|
|
_sse({"tool_calls": [{
|
|
"index": None, "id": "call_a", "type": "function",
|
|
"function": {"name": "get_memory", "arguments": "{}"},
|
|
"extra_content": {"google": {"thought_signature": "SIG0"}},
|
|
}]}),
|
|
_sse({"tool_calls": [{
|
|
"index": None, "id": "call_b", "type": "function",
|
|
"function": {"name": "bash", "arguments": '{"command":"echo hi"}'},
|
|
}]}),
|
|
"data: [DONE]",
|
|
]
|
|
events = _drive(monkeypatch, lines)
|
|
calls = next(e["calls"] for e in events if e.get("type") == "tool_calls")
|
|
assert len(calls) == 2, f"parallel calls collided: {calls}"
|
|
by_name = {c["name"]: c for c in calls}
|
|
assert set(by_name) == {"get_memory", "bash"}
|
|
# arguments are NOT corrupted by concatenation
|
|
assert by_name["get_memory"]["arguments"] == "{}"
|
|
assert by_name["bash"]["arguments"] == '{"command":"echo hi"}'
|
|
# signature preserved on the first call only, exactly as received
|
|
assert by_name["get_memory"]["extra_content"] == {"google": {"thought_signature": "SIG0"}}
|
|
assert "extra_content" not in by_name["bash"]
|
|
|
|
|
|
def test_single_call_chunked_arguments_still_accumulate(monkeypatch):
|
|
# Conformant OpenAI style: index present, arguments streamed in pieces.
|
|
lines = [
|
|
_sse({"tool_calls": [{"index": 0, "id": "c", "type": "function",
|
|
"function": {"name": "search", "arguments": '{"q":"'}}]}),
|
|
_sse({"tool_calls": [{"index": 0, "function": {"arguments": 'cats"}'}}]}),
|
|
"data: [DONE]",
|
|
]
|
|
events = _drive(monkeypatch, lines, model="gpt-4o-test")
|
|
calls = next(e["calls"] for e in events if e.get("type") == "tool_calls")
|
|
assert len(calls) == 1
|
|
assert calls[0]["name"] == "search"
|
|
assert calls[0]["arguments"] == '{"q":"cats"}'
|
|
|
|
|
|
def test_null_index_chunked_arguments_attach_to_last_call(monkeypatch):
|
|
# index=None where the name arrives first, then an arg-only continuation:
|
|
# the continuation must attach to the just-started call, not open a new one.
|
|
lines = [
|
|
_sse({"tool_calls": [{"index": None, "id": "c", "type": "function",
|
|
"function": {"name": "search", "arguments": '{"q":'}}]}),
|
|
_sse({"tool_calls": [{"index": None, "function": {"arguments": '"dogs"}'}}]}),
|
|
"data: [DONE]",
|
|
]
|
|
events = _drive(monkeypatch, lines)
|
|
calls = next(e["calls"] for e in events if e.get("type") == "tool_calls")
|
|
assert len(calls) == 1, f"continuation opened a spurious call: {calls}"
|
|
assert calls[0]["arguments"] == '{"q":"dogs"}'
|
|
|
|
|
|
def test_sparse_integer_indices_then_null_do_not_collide(monkeypatch):
|
|
# Hardening: a provider that uses sparse integer indices (0 and 2) and then
|
|
# a null-index call must allocate ABOVE the max key, not at len()==2 (which
|
|
# would overwrite slot 2). Three distinct calls must survive.
|
|
lines = [
|
|
_sse({"tool_calls": [{"index": 0, "id": "a", "function": {"name": "f0", "arguments": "{}"}}]}),
|
|
_sse({"tool_calls": [{"index": 2, "id": "b", "function": {"name": "f2", "arguments": "{}"}}]}),
|
|
_sse({"tool_calls": [{"index": None, "id": "c", "function": {"name": "fn", "arguments": "{}"}}]}),
|
|
"data: [DONE]",
|
|
]
|
|
events = _drive(monkeypatch, lines)
|
|
calls = next(e["calls"] for e in events if e.get("type") == "tool_calls")
|
|
assert sorted(c["name"] for c in calls) == ["f0", "f2", "fn"], f"collision: {calls}"
|
|
|
|
|
|
def test_null_arguments_delta_does_not_drop_sibling_calls(monkeypatch):
|
|
# A gateway can emit a tool_call delta whose `arguments` is JSON null. The
|
|
# accumulator did `"" += None`, raising TypeError caught by the broad except
|
|
# that wraps the whole chunk — so it abandoned the rest of the tool_calls
|
|
# loop, silently dropping every LATER call in the same delta. Here the first
|
|
# call has arguments: null; the second (same delta) must still survive.
|
|
lines = [
|
|
_sse({"tool_calls": [
|
|
{"index": 0, "id": "a", "type": "function",
|
|
"function": {"name": "first", "arguments": None}},
|
|
{"index": 1, "id": "b", "type": "function",
|
|
"function": {"name": "second", "arguments": "{}"}},
|
|
]}),
|
|
"data: [DONE]",
|
|
]
|
|
events = _drive(monkeypatch, lines, model="gpt-4o-test")
|
|
calls = next(e["calls"] for e in events if e.get("type") == "tool_calls")
|
|
assert sorted(c["name"] for c in calls) == ["first", "second"], calls
|