Skip to content

Commit ea7ab09

Browse files
authored
Added chat and chat/health handler registration, dispatch, and testing (#9)
1 parent 8a687d3 commit ea7ab09

8 files changed

Lines changed: 290 additions & 6 deletions

File tree

lf_toolkit/chat/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1+
from ..shared.mued_api_v0_1_0 import ChatCapabilities
2+
from ..shared.mued_api_v0_1_0 import ChatHealthResponse
13
from ..shared.mued_api_v0_1_0 import ChatRequest
24
from ..shared.mued_api_v0_1_0 import ChatResponse
35
from ..shared.mued_api_v0_1_0 import Message
46
from .params import ChatParams
57
from .result import ChatResult
68

7-
__all__ = ["ChatRequest", "ChatResponse", "Message", "ChatParams", "ChatResult"]
9+
__all__ = [
10+
"ChatRequest",
11+
"ChatResponse",
12+
"ChatCapabilities",
13+
"ChatHealthResponse",
14+
"Message",
15+
"ChatParams",
16+
"ChatResult",
17+
]

lf_toolkit/io/base_server.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from typing import Optional
88
from typing import Union
99

10+
from ..chat import ChatHealthResponse
11+
from ..chat import ChatRequest
12+
from ..chat import ChatResponse
1013
from ..evaluation import Result as EvaluationResult
1114
from ..preview import Result as PreviewResult
1215
from ..shared import Params
@@ -22,6 +25,14 @@
2225
[Any, Params], Union[PreviewResult, Awaitable[PreviewResult]]
2326
]
2427

28+
ChatFunction = Callable[
29+
[ChatRequest], Union[ChatResponse, Awaitable[ChatResponse]]
30+
]
31+
32+
ChatHealthFunction = Callable[
33+
[], Union[ChatHealthResponse, Awaitable[ChatHealthResponse]]
34+
]
35+
2536

2637
class BaseServer(ABC):
2738

@@ -43,6 +54,12 @@ def eval(self, fn: EvaluationFunction):
4354
def preview(self, fn: PreviewFunction):
4455
return handler_decorator(self._handler, "preview", fn)
4556

57+
def chat(self, fn: ChatFunction):
58+
return handler_decorator(self._handler, "chat", fn)
59+
60+
def chat_health(self, fn: ChatHealthFunction):
61+
return handler_decorator(self._handler, "chat/health", fn)
62+
4663

4764
def handler_decorator(registry: Handler, name: str, fn):
4865
@wraps(fn)

lf_toolkit/io/handler.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,20 @@
77

88
import anyio
99

10+
from ..chat import ChatHealthResponse
11+
from ..chat import ChatRequest
12+
from ..chat import ChatResponse
1013
from ..evaluation import Result as EvaluationResult
1114
from ..shared import Command
1215
from ..shared import Params
1316

1417

1518
class Handler(ABC):
1619

17-
_handlers: Dict[str, Callable] = {}
20+
_handlers: Dict[str, Callable]
21+
22+
def __init__(self):
23+
self._handlers = {}
1824

1925
@abstractmethod
2026
async def dispatch(self, req: str) -> str:
@@ -61,11 +67,29 @@ async def handle_healthcheck(self, req: dict):
6167
from .healthcheck import run_healthcheck
6268
return await anyio.to_thread.run_sync(run_healthcheck)
6369

70+
async def handle_chat(self, req: dict):
71+
params = req["params"]
72+
chat_request = ChatRequest.model_validate(params)
73+
74+
result = await self._call_user_handler("chat", chat_request)
75+
76+
if isinstance(result, ChatResponse):
77+
return result.model_dump(mode="json", exclude_none=True)
78+
79+
return result
80+
81+
async def handle_chat_health(self, req: dict):
82+
result = await self._call_user_handler("chat/health")
83+
84+
if isinstance(result, ChatHealthResponse):
85+
return result.model_dump(mode="json", exclude_none=True)
86+
87+
return result
88+
6489
async def handle(self, name: Command, req: dict) -> dict:
65-
handler = getattr(self, f"handle_{name}", None)
90+
handler = getattr(self, f"handle_{name.replace('/', '_')}", None)
6691

6792
if handler is None:
6893
raise ValueError(f"No handler for '{name}'")
6994

7095
return await handler(req)
71-

lf_toolkit/io/rpc_handler.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
class JsonRpcHandler(Handler):
1212

1313
def __init__(self):
14+
super().__init__()
1415
self._methods = {
15-
name: jsonrpc_handler(self, name) for name in ["eval", "preview", "healthcheck"]
16+
name: jsonrpc_handler(self, name)
17+
for name in ["eval", "preview", "healthcheck", "chat", "chat/health"]
1618
}
1719

1820
async def dispatch(self, req: str) -> str:

lf_toolkit/shared/command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from typing import Literal
22

33

4-
Command = Literal["eval", "preview"]
4+
Command = Literal["eval", "preview", "chat", "chat/health"]

tests/io/base_server_test.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from lf_toolkit.chat import ChatHealthResponse
2+
from lf_toolkit.chat import ChatRequest
3+
from lf_toolkit.chat import ChatResponse
4+
from lf_toolkit.io.base_server import BaseServer
5+
6+
7+
class ConcreteServer(BaseServer):
8+
async def run(self):
9+
pass
10+
11+
12+
class TestBaseServerChatRegistration:
13+
14+
def test_chat_registers_under_chat_name(self):
15+
server = ConcreteServer()
16+
17+
@server.chat
18+
def chat_fn(request: ChatRequest) -> ChatResponse:
19+
raise NotImplementedError
20+
21+
assert server._handler._handlers["chat"] is chat_fn
22+
23+
def test_chat_health_registers_under_chat_slash_health_name(self):
24+
server = ConcreteServer()
25+
26+
@server.chat_health
27+
def chat_health_fn() -> ChatHealthResponse:
28+
raise NotImplementedError
29+
30+
assert server._handler._handlers["chat/health"] is chat_health_fn

tests/io/handler_test.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import pytest
2+
3+
from lf_toolkit.chat import ChatCapabilities
4+
from lf_toolkit.chat import ChatHealthResponse
5+
from lf_toolkit.chat import ChatRequest
6+
from lf_toolkit.chat import ChatResponse
7+
from lf_toolkit.chat import Message
8+
from lf_toolkit.io.file_server import FileHandler
9+
from lf_toolkit.shared.mued_api_v0_1_0 import DataPolicySupport
10+
from lf_toolkit.shared.mued_api_v0_1_0 import HealthStatus
11+
from lf_toolkit.shared.mued_api_v0_1_0 import Role
12+
13+
pytest_plugins = ('pytest_asyncio',)
14+
15+
16+
class TestHandleChat:
17+
18+
@pytest.fixture
19+
def handler(self):
20+
return FileHandler()
21+
22+
@pytest.mark.asyncio
23+
async def test_calls_registered_handler_with_chat_request(self, handler):
24+
received = {}
25+
26+
def chat_fn(request: ChatRequest) -> ChatResponse:
27+
received["request"] = request
28+
return ChatResponse(output=Message(role=Role.ASSISTANT, content="hi there"))
29+
30+
handler.register("chat", chat_fn)
31+
32+
result = await handler.handle_chat({
33+
"params": {"messages": [{"role": "USER", "content": "hello"}]}
34+
})
35+
36+
assert isinstance(received["request"], ChatRequest)
37+
assert received["request"].messages[0].content == "hello"
38+
assert result == {"output": {"role": "ASSISTANT", "content": "hi there"}}
39+
40+
@pytest.mark.asyncio
41+
async def test_passes_through_non_chat_response_result(self, handler):
42+
handler.register("chat", lambda request: {"output": {"role": "ASSISTANT", "content": "raw"}})
43+
44+
result = await handler.handle_chat({
45+
"params": {"messages": [{"role": "USER", "content": "hello"}]}
46+
})
47+
48+
assert result == {"output": {"role": "ASSISTANT", "content": "raw"}}
49+
50+
@pytest.mark.asyncio
51+
async def test_raises_when_no_handler_registered(self, handler):
52+
with pytest.raises(ValueError, match="No user handler for 'chat'"):
53+
await handler.handle_chat({
54+
"params": {"messages": [{"role": "USER", "content": "hello"}]}
55+
})
56+
57+
58+
class TestHandleChatHealth:
59+
60+
@pytest.fixture
61+
def handler(self):
62+
return FileHandler()
63+
64+
@pytest.mark.asyncio
65+
async def test_calls_registered_handler_with_no_arguments(self, handler):
66+
def chat_health_fn() -> ChatHealthResponse:
67+
return ChatHealthResponse(
68+
status=HealthStatus.OK,
69+
capabilities=ChatCapabilities(
70+
supportsChat=True,
71+
supportsDataPolicy=DataPolicySupport.NOT_SUPPORTED,
72+
),
73+
)
74+
75+
handler.register("chat/health", chat_health_fn)
76+
77+
result = await handler.handle_chat_health({"params": {}})
78+
79+
assert result == {
80+
"status": "OK",
81+
"capabilities": {"supportsChat": True, "supportsDataPolicy": "NOT_SUPPORTED"},
82+
}
83+
84+
@pytest.mark.asyncio
85+
async def test_passes_through_non_chat_health_response_result(self, handler):
86+
handler.register("chat/health", lambda: {"status": "OK"})
87+
88+
result = await handler.handle_chat_health({"params": {}})
89+
90+
assert result == {"status": "OK"}
91+
92+
93+
class TestHandleDispatch:
94+
"""Covers the name -> method lookup in Handler.handle, including the
95+
'chat/health' slash normalisation."""
96+
97+
@pytest.fixture
98+
def handler(self):
99+
return FileHandler()
100+
101+
@pytest.mark.asyncio
102+
async def test_dispatches_chat_to_handle_chat(self, handler):
103+
handler.register("chat", lambda request: ChatResponse(
104+
output=Message(role=Role.ASSISTANT, content="ok")
105+
))
106+
107+
result = await handler.handle("chat", {
108+
"params": {"messages": [{"role": "USER", "content": "hi"}]}
109+
})
110+
111+
assert result == {"output": {"role": "ASSISTANT", "content": "ok"}}
112+
113+
@pytest.mark.asyncio
114+
async def test_dispatches_chat_slash_health_to_handle_chat_health(self, handler):
115+
handler.register("chat/health", lambda: ChatHealthResponse(
116+
status=HealthStatus.OK,
117+
capabilities=ChatCapabilities(
118+
supportsChat=True,
119+
supportsDataPolicy=DataPolicySupport.NOT_SUPPORTED,
120+
),
121+
))
122+
123+
result = await handler.handle("chat/health", {"params": {}})
124+
125+
assert result["status"] == "OK"
126+
127+
@pytest.mark.asyncio
128+
async def test_unknown_command_raises(self, handler):
129+
with pytest.raises(ValueError, match="No handler for 'unknown'"):
130+
await handler.handle("unknown", {"params": {}})

tests/io/rpc_handler_test.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import ujson
2+
import pytest
3+
4+
from lf_toolkit.chat import ChatCapabilities
5+
from lf_toolkit.chat import ChatHealthResponse
6+
from lf_toolkit.chat import ChatRequest
7+
from lf_toolkit.chat import ChatResponse
8+
from lf_toolkit.chat import Message
9+
from lf_toolkit.io.rpc_handler import JsonRpcHandler
10+
from lf_toolkit.shared.mued_api_v0_1_0 import DataPolicySupport
11+
from lf_toolkit.shared.mued_api_v0_1_0 import HealthStatus
12+
from lf_toolkit.shared.mued_api_v0_1_0 import Role
13+
14+
pytest_plugins = ('pytest_asyncio',)
15+
16+
17+
class TestJsonRpcHandlerChat:
18+
19+
@pytest.fixture
20+
def handler(self):
21+
return JsonRpcHandler()
22+
23+
def test_registers_chat_methods(self, handler):
24+
assert "chat" in handler._methods
25+
assert "chat/health" in handler._methods
26+
27+
@pytest.mark.asyncio
28+
async def test_dispatch_chat_round_trip(self, handler):
29+
def chat_fn(request: ChatRequest) -> ChatResponse:
30+
last = request.messages[-1]
31+
return ChatResponse(output=Message(role=Role.ASSISTANT, content=f"echo: {last.content}"))
32+
33+
handler.register("chat", chat_fn)
34+
35+
# go-ethereum's rpc.Client sends a single positional params array,
36+
# not a params object -- this is the actual wire shape shimmy produces.
37+
req = ujson.dumps({
38+
"jsonrpc": "2.0",
39+
"method": "chat",
40+
"params": [{"messages": [{"role": "USER", "content": "hi"}]}],
41+
"id": 1,
42+
})
43+
44+
response = ujson.loads(await handler.dispatch(req))
45+
46+
assert response["result"] == {"output": {"role": "ASSISTANT", "content": "echo: hi"}}
47+
48+
@pytest.mark.asyncio
49+
async def test_dispatch_chat_health_round_trip(self, handler):
50+
def chat_health_fn() -> ChatHealthResponse:
51+
return ChatHealthResponse(
52+
status=HealthStatus.OK,
53+
capabilities=ChatCapabilities(
54+
supportsChat=True,
55+
supportsDataPolicy=DataPolicySupport.NOT_SUPPORTED,
56+
),
57+
)
58+
59+
handler.register("chat/health", chat_health_fn)
60+
61+
req = ujson.dumps({
62+
"jsonrpc": "2.0",
63+
"method": "chat/health",
64+
"params": [{}],
65+
"id": 2,
66+
})
67+
68+
response = ujson.loads(await handler.dispatch(req))
69+
70+
assert response["result"]["status"] == "OK"
71+
assert response["result"]["capabilities"]["supportsChat"] is True

0 commit comments

Comments
 (0)