"""Unit tests for the agent HTTP adapters (Anthropic + OpenAI) and parsing. Every test drives a REAL adapter over a real aiohttp loopback (``TestServer``/``TestClient``) and a real ``/generate`` upstream (:class:`tests.test_agent._fakes.FakeSGLangServer`) -- so the whole translate -> sglang -> parse -> record_turn -> finish_session path runs unmocked; only the model server and tokenizer are faked. Covers both wire protocols plus the standalone parsing helpers in ``slime.agent.parsing``. Replaces the pre-refactor ``tests/test_agent_adapters.py`` (which imported now- removed symbols and a dropped ``/v1/responses`` endpoint). """ from __future__ import annotations import asyncio import json import sys from pathlib import Path import pytest from aiohttp.test_utils import TestClient, TestServer REPO_ROOT = Path(__file__).resolve().parents[2] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) from tests.test_agent._fakes import FakeSGLangServer, FakeTokenizer # noqa: E402 from slime.agent.adapters import anthropic, openai # noqa: E402 from slime.agent.parsing import parse_model_output, parse_xml_tool_uses # noqa: E402 from slime.utils.types import Sample # noqa: E402 NUM_GPUS = 0 # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- class _Headers: def __init__(self, headers: dict[str, str]) -> None: self.headers = headers def _parse_sse(raw: str) -> list[tuple[str, object]]: """Parse an SSE byte-stream body into ``(event_name, payload)`` pairs; ``payload`` is the decoded JSON, or the literal ``"[DONE]"``.""" events: list[tuple[str, object]] = [] event_name = "message" data_lines: list[str] = [] def flush() -> None: nonlocal event_name, data_lines if data_lines: data = "\n".join(data_lines) events.append((event_name, data if data == "[DONE]" else json.loads(data))) event_name = "message" data_lines = [] for line in raw.splitlines(): if not line: flush() elif line.startswith("event:"): event_name = line.removeprefix("event:").strip() elif line.startswith("data:"): data_lines.append(line.removeprefix("data:").strip()) flush() return events async def _drain(adapter, sid) -> list[Sample]: return await adapter.finish_session(sid, base_sample=Sample(index=0, prompt=""), reward=1.0) # =========================================================================== # §1 session-id resolution # =========================================================================== def test_anthropic_session_id_prefers_bearer_then_api_key(): assert anthropic._request_session_id(_Headers({"X-Api-Key": "key"})) == "key" assert anthropic._request_session_id(_Headers({"Authorization": "Bearer bsid", "X-Api-Key": "key"})) == "bsid" assert anthropic._request_session_id(_Headers({})) == "default" def test_openai_session_id_prefers_bearer_then_body(): req = _Headers({}) assert openai._request_session_id(req, {"metadata": {"session_id": "meta"}, "user": "u"}) == "meta" assert openai._request_session_id(req, {"user": "u"}) == "u" assert openai._request_session_id(_Headers({"Authorization": "Bearer bsid"}), {"user": "u"}) == "bsid" assert openai._request_session_id(req, {}) == "default" # =========================================================================== # §2 translation (wire -> chat-template messages) # =========================================================================== def test_anthropic_translation_keeps_tool_results_thinking_and_tools(): messages = [ {"role": "user", "content": [{"type": "text", "text": "hi"}]}, { "role": "assistant", "content": [ {"type": "thinking", "thinking": "plan"}, {"type": "text", "text": "ok"}, {"type": "tool_use", "name": "lookup", "input": {"q": "slime"}}, ], }, {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "u1", "content": "result"}]}, ] translated = anthropic._translate_messages(messages, system="sys") assert translated == [ {"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}, { "role": "assistant", "content": "ok", "reasoning_content": "plan", "tool_calls": [{"type": "function", "function": {"name": "lookup", "arguments": {"q": "slime"}}}], }, {"role": "tool", "content": "result"}, ] tools = anthropic._tools_to_chat_tools( [{"name": "lookup", "description": "search", "input_schema": {"type": "object"}}] ) assert tools == [ {"type": "function", "function": {"name": "lookup", "description": "search", "parameters": {"type": "object"}}} ] def test_openai_translation_developer_to_system_and_tool_calls_to_dict(): translated = openai._translate_messages( [ {"role": "developer", "content": "rules"}, {"role": "user", "content": [{"type": "text", "text": "hello"}]}, { "role": "assistant", "content": None, "tool_calls": [ {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": '{"q": "slime"}'}} ], }, {"role": "tool", "tool_call_id": "call_1", "content": "found"}, ] ) assert translated == [ {"role": "system", "content": "rules"}, {"role": "user", "content": "hello"}, # wire-only id dropped; arguments coerced JSON-string -> dict. { "role": "assistant", "content": "", "tool_calls": [{"type": "function", "function": {"name": "lookup", "arguments": {"q": "slime"}}}], }, # tool_call_id dropped. {"role": "tool", "content": "found"}, ] # =========================================================================== # §3 non-stream JSON + token capture (real HTTP, real /generate) # =========================================================================== def test_anthropic_messages_nonstream_records_token_segments(): async def run_case(): async with FakeSGLangServer([[(-0.1, 101), (-0.2, 102)]]) as sglang: tok = FakeTokenizer(outputs={(101, 102): "done now"}) adapter = anthropic.AnthropicAdapter(tokenizer=tok, sglang_url=sglang.url) adapter.open_session("sid-a") client = TestClient(TestServer(adapter.app)) await client.start_server() try: resp = await client.post( "/v1/messages", headers={"Authorization": "Bearer sid-a"}, json={"model": "m", "max_tokens": 7, "messages": [{"role": "user", "content": "hi"}]}, ) data = await resp.json() finally: await client.close() samples = await _drain(adapter, "sid-a") assert resp.status == 200 assert data["type"] == "message" and data["stop_reason"] == "end_turn" assert data["content"] == [{"type": "text", "text": "done now"}] # adapter posted the rendered prompt ids and capped max_new_tokens at max_tokens. assert sglang.requests[0]["sampling_params"]["max_new_tokens"] == 7 assert sglang.routing_keys == ["sid-a"] # one trained turn: the two response ids carry loss=1 + real logprobs. assert len(samples) == 1 s = samples[0] assert s.tokens[-2:] == [101, 102] assert s.loss_mask[-2:] == [1, 1] assert s.rollout_log_probs[-2:] == [-0.1, -0.2] assert s.response == "done now" asyncio.run(run_case()) def test_openai_chat_completions_nonstream_records_token_segments(): async def run_case(): async with FakeSGLangServer([[(-0.3, 201)]]) as sglang: tok = FakeTokenizer(outputs={(201,): "hello"}) adapter = openai.OpenAIAdapter(tokenizer=tok, sglang_url=sglang.url) adapter.open_session("sid-o") client = TestClient(TestServer(adapter.app)) await client.start_server() try: resp = await client.post( "/v1/chat/completions", headers={"Authorization": "Bearer sid-o"}, json={"model": "m", "max_tokens": 4, "messages": [{"role": "user", "content": "hi?"}]}, ) data = await resp.json() finally: await client.close() samples = await _drain(adapter, "sid-o") assert resp.status == 200 assert data["object"] == "chat.completion" assert data["choices"][0]["message"] == {"role": "assistant", "content": "hello"} assert data["choices"][0]["finish_reason"] == "stop" assert sglang.requests[0]["sampling_params"]["max_new_tokens"] == 4 assert len(samples) == 1 and samples[0].tokens[-1] == 201 and samples[0].loss_mask[-1] == 1 asyncio.run(run_case()) # =========================================================================== # §4 streaming SSE # =========================================================================== def test_anthropic_messages_streams_blocks(): async def run_case(): async with FakeSGLangServer([[(-0.1, 301)]]) as sglang: tok = FakeTokenizer(outputs={(301,): "streamed"}) adapter = anthropic.AnthropicAdapter(tokenizer=tok, sglang_url=sglang.url) adapter.open_session("sid-as") client = TestClient(TestServer(adapter.app)) await client.start_server() try: resp = await client.post( "/v1/messages", headers={"Authorization": "Bearer sid-as", "Accept": "text/event-stream"}, json={ "model": "m", "stream": True, "max_tokens": 8, "messages": [{"role": "user", "content": "x"}], }, ) raw = await resp.text() finally: await client.close() await _drain(adapter, "sid-as") names = [name for name, _ in _parse_sse(raw)] assert names[0] == "message_start" assert "content_block_delta" in names assert names[-1] == "message_stop" deltas = [p for n, p in _parse_sse(raw) if n == "content_block_delta"] assert any(d["delta"].get("text") == "streamed" for d in deltas) asyncio.run(run_case()) def test_openai_chat_completions_streams_chunks_until_done(): async def run_case(): async with FakeSGLangServer([[(-0.1, 401)]]) as sglang: tok = FakeTokenizer(outputs={(401,): "streamed text"}) adapter = openai.OpenAIAdapter(tokenizer=tok, sglang_url=sglang.url) adapter.open_session("sid-os") client = TestClient(TestServer(adapter.app)) await client.start_server() try: resp = await client.post( "/v1/chat/completions", headers={"Authorization": "Bearer sid-os"}, json={"model": "m", "stream": True, "messages": [{"role": "user", "content": "x"}]}, ) raw = await resp.text() finally: await client.close() await _drain(adapter, "sid-os") events = _parse_sse(raw) chunks = [p for _, p in events if isinstance(p, dict)] assert chunks[0]["choices"][0]["delta"] == {"role": "assistant"} assert chunks[-1]["choices"][0]["finish_reason"] == "stop" assert events[-1] == ("message", "[DONE]") asyncio.run(run_case()) # =========================================================================== # §5 multi-turn token alignment (tool call -> tool result -> answer) # =========================================================================== def test_anthropic_multiturn_wire_roundtrip_and_token_capture(): """Two-turn round-trip: a tool-call turn, then a tool-result + answer turn. Asserts the adapter-level behaviour the branching test can't see: the wire tool_use block round-trips, both turns route to the same sid, and finish_session yields aligned training samples. (The fine-grained clean/drift linearization is owned by test_trajectory_manager_branching.)""" async def run_case(): r1 = "slime" async with FakeSGLangServer([[(-0.5, 700), (-0.5, 701)], [(-0.4, 800)]]) as sglang: tok = FakeTokenizer(outputs={(700, 701): r1, (800,): "the answer"}) adapter = anthropic.AnthropicAdapter(tokenizer=tok, sglang_url=sglang.url) adapter.open_session("sid-mt") client = TestClient(TestServer(adapter.app)) await client.start_server() tools = [ {"name": "lookup", "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}}} ] try: first = await client.post( "/v1/messages", headers={"Authorization": "Bearer sid-mt"}, json={ "model": "m", "max_tokens": 5, "tools": tools, "messages": [{"role": "user", "content": [{"type": "text", "text": "find slime"}]}], }, ) fdata = await first.json() tool_use = next(b for b in fdata["content"] if b["type"] == "tool_use") second = await client.post( "/v1/messages", headers={"Authorization": "Bearer sid-mt"}, json={ "model": "m", "max_tokens": 7, "tools": tools, "messages": [ {"role": "user", "content": [{"type": "text", "text": "find slime"}]}, {"role": "assistant", "content": fdata["content"]}, { "role": "user", "content": [ {"type": "tool_result", "tool_use_id": tool_use["id"], "content": "found"} ], }, ], }, ) await second.json() finally: await client.close() samples = await _drain(adapter, "sid-mt") assert first.status == 200 and second.status == 200 assert fdata["stop_reason"] == "tool_use" assert tool_use["name"] == "lookup" and tool_use["input"] == {"query": "slime"} # both turns routed to the same sid; the adapter posted the growing prompt. assert sglang.routing_keys == ["sid-mt", "sid-mt"] assert ( sglang.requests[1]["input_ids"][: len(sglang.requests[0]["input_ids"])] == sglang.requests[0]["input_ids"] ) # finish_session produces at least one aligned, partly-trained sample. assert samples for s in samples: assert len(s.loss_mask) == len(s.rollout_log_probs) == s.response_length assert sum(s.loss_mask) > 0 asyncio.run(run_case()) # =========================================================================== # §6 adapter behaviour: turn cap, mid-list system fold # =========================================================================== def test_max_turns_per_sid_returns_429(): async def run_case(): async with FakeSGLangServer([[(-0.1, 501)], [(-0.1, 502)]]) as sglang: tok = FakeTokenizer() adapter = anthropic.AnthropicAdapter(tokenizer=tok, sglang_url=sglang.url, max_turns_per_sid=1) adapter.open_session("sid-cap") client = TestClient(TestServer(adapter.app)) await client.start_server() try: body = {"model": "m", "max_tokens": 4, "messages": [{"role": "user", "content": "x"}]} h = {"Authorization": "Bearer sid-cap"} first = await client.post("/v1/messages", headers=h, json=body) second = await client.post("/v1/messages", headers=h, json=body) finally: await client.close() await _drain(adapter, "sid-cap") assert first.status == 200 assert second.status == 429 asyncio.run(run_case()) def test_mid_list_system_folds_into_user(): body = { "messages": [ {"role": "user", "content": "hello"}, {"role": "system", "content": "skills list"}, {"role": "user", "content": "next"}, ] } changed = anthropic._fold_mid_list_system_into_user(body) assert changed # the mid-list system message is gone; its text is wrapped into the prior user. assert [m["role"] for m in body["messages"]] == ["user", "user"] folded = body["messages"][0]["content"] assert any(b.get("text", "").startswith("") for b in folded) # =========================================================================== # §7 parsing helpers (slime.agent.parsing) # =========================================================================== def test_parse_model_output_plain_text_no_parsers(): parsed = parse_model_output("just text", tools_schema=None, tool_parser_name=None, reasoning_parser_name=None) assert parsed.text == "just text" assert parsed.tool_uses == [] assert parsed.reasoning == "" def test_parse_model_output_think_split_fallback(): # The qwen3 reasoning parser lives in sglang (lazy import); skip where the # lean CPU CI env has no sglang installed. pytest.importorskip("sglang") parsed = parse_model_output( "reason herevisible", tools_schema=None, tool_parser_name=None, reasoning_parser_name="qwen3", ) # the qwen3 reasoning parser (or the fallback) splits reasoning out. assert "visible" in parsed.text assert "reason here" in parsed.reasoning def test_parse_xml_tool_uses_fallback(): raw = "lead slime tail" schema = [{"function": {"name": "lookup"}}] cleaned, uses = parse_xml_tool_uses(raw, schema) assert uses == [{"name": "lookup", "input": {"q": "slime"}}] assert "" not in cleaned assert "lead" in cleaned and "tail" in cleaned def test_parse_xml_tool_uses_ignores_unknown_tool(): raw = "x" cleaned, uses = parse_xml_tool_uses(raw, [{"function": {"name": "lookup"}}]) assert uses == [] assert "" in cleaned # left untouched if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-v"]))