humanrpc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
humanrpc/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from humanrpc.client.client import HumanRPCClient
2
+
3
+ Client = HumanRPCClient
4
+
5
+ __all__ = [
6
+ "Client",
7
+ "HumanRPCClient",
8
+ ]
humanrpc/__main__.py ADDED
@@ -0,0 +1,145 @@
1
+ import json
2
+ from pathlib import Path
3
+ from importlib.resources import files
4
+
5
+ import httpx
6
+ import typer
7
+ import uvicorn
8
+
9
+ from rich import print
10
+ from rich.table import Table
11
+
12
+ from humanrpc.server.app import create_app
13
+
14
+ cli = typer.Typer(help="HumanRPC CLI")
15
+
16
+ inspect_cli = typer.Typer(help="Inspect server state")
17
+ respond_cli = typer.Typer(help="Manually respond to requests")
18
+
19
+ cli.add_typer(inspect_cli, name="inspect")
20
+ cli.add_typer(respond_cli, name="respond")
21
+
22
+
23
+ @cli.command()
24
+ def serve(
25
+ host: str = "127.0.0.1",
26
+ port: int = 1078,
27
+ no_static: bool = False,
28
+ ):
29
+ app = create_app(
30
+ serve_static=not no_static,
31
+ )
32
+
33
+ uvicorn.run(
34
+ app,
35
+ host=host,
36
+ port=port,
37
+ reload=False,
38
+ )
39
+
40
+
41
+ @cli.command("static-path")
42
+ def static_path():
43
+ path = files("humanrpc").joinpath("static")
44
+ print(Path(path))
45
+
46
+
47
+ @inspect_cli.command("endpoints")
48
+ def inspect_endpoints(
49
+ endpoint: str = "http://127.0.0.1:1078",
50
+ ):
51
+ r = httpx.get(f"{endpoint}/endpoints")
52
+ data = r.json()
53
+
54
+ table = Table(title="Registered Endpoints")
55
+ table.add_column("Name")
56
+
57
+ for name in data.keys():
58
+ table.add_row(name)
59
+
60
+ print(table)
61
+
62
+
63
+ @inspect_cli.command("request")
64
+ def inspect_request(
65
+ request_id: str,
66
+ endpoint: str = "http://127.0.0.1:1078",
67
+ ):
68
+ r = httpx.get(f"{endpoint}/requests/{request_id}")
69
+ data = r.json()
70
+
71
+ print(data)
72
+
73
+
74
+ @inspect_cli.command("pending")
75
+ def inspect_pending(
76
+ endpoint: str = "http://127.0.0.1:1078",
77
+ ):
78
+ r = httpx.get(f"{endpoint}/requests/pending")
79
+ data = r.json()
80
+
81
+ print(data)
82
+
83
+
84
+ @respond_cli.command("success")
85
+ def respond_success(
86
+ request_id: str,
87
+ payload: str,
88
+ endpoint: str = "http://127.0.0.1:1078",
89
+ ):
90
+ data = json.loads(payload)
91
+
92
+ httpx.post(
93
+ f"{endpoint}/respond/success/{request_id}",
94
+ json=data,
95
+ )
96
+
97
+ print("[green]Response sent (success)[/green]")
98
+
99
+
100
+ @respond_cli.command("exception")
101
+ def respond_exception(
102
+ request_id: str,
103
+ exception_type: str,
104
+ payload: str,
105
+ endpoint: str = "http://127.0.0.1:1078",
106
+ ):
107
+ data = json.loads(payload)
108
+
109
+ httpx.post(
110
+ f"{endpoint}/respond/exception/{request_id}",
111
+ params={"exception_type": exception_type},
112
+ json=data,
113
+ )
114
+
115
+ print(
116
+ f"[yellow]Response sent (exception: {exception_type})[/yellow]"
117
+ )
118
+
119
+
120
+ @respond_cli.command("timeout")
121
+ def respond_timeout(
122
+ request_id: str,
123
+ endpoint: str = "http://127.0.0.1:1078",
124
+ ):
125
+ httpx.post(f"{endpoint}/respond/timeout/{request_id}")
126
+
127
+ print("[blue]Response sent (timeout)[/blue]")
128
+
129
+
130
+ @respond_cli.command("ignore")
131
+ def respond_ignore(
132
+ request_id: str,
133
+ endpoint: str = "http://127.0.0.1:1078",
134
+ ):
135
+ httpx.post(f"{endpoint}/respond/ignore/{request_id}")
136
+
137
+ print("[dim]Response sent (ignore)[/dim]")
138
+
139
+
140
+ def main():
141
+ cli()
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
File without changes
@@ -0,0 +1,46 @@
1
+ import httpx
2
+
3
+ from humanrpc.client.endpoint import Endpoint
4
+ from humanrpc.models.endpoint import (
5
+ EndpointDefinition,
6
+ ExceptionDefinition,
7
+ )
8
+
9
+
10
+ class HumanRPCClient:
11
+ def __init__(self, base_url: str = "http://127.0.0.1:1078"):
12
+ self.base_url = base_url
13
+
14
+ def endpoint(
15
+ self,
16
+ *,
17
+ name: str,
18
+ input_model,
19
+ response_model,
20
+ exceptions=None,
21
+ ) -> Endpoint:
22
+ exceptions = exceptions or []
23
+
24
+ definition = EndpointDefinition(
25
+ name=name,
26
+ input_schema=input_model.model_json_schema(),
27
+ response_schema=response_model.model_json_schema(),
28
+ exceptions=[
29
+ ExceptionDefinition(name=exc.__name__)
30
+ for exc in exceptions
31
+ ],
32
+ )
33
+
34
+ httpx.post(
35
+ f"{self.base_url}/endpoints",
36
+ json=definition.model_dump(),
37
+ timeout=5.0,
38
+ )
39
+
40
+ return Endpoint(
41
+ name=name,
42
+ input_model=input_model,
43
+ response_model=response_model,
44
+ exceptions=exceptions,
45
+ base_url=self.base_url,
46
+ )
@@ -0,0 +1,119 @@
1
+ import uuid
2
+ import httpx
3
+ import asyncio
4
+ import json
5
+ import time
6
+ import websockets
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class Endpoint:
12
+ def __init__(
13
+ self,
14
+ *,
15
+ name: str,
16
+ input_model,
17
+ response_model,
18
+ exceptions: list[type[Exception]],
19
+ base_url: str,
20
+ ):
21
+ self.name = name
22
+ self.input_model = input_model
23
+ self.response_model = response_model
24
+ self.exceptions = exceptions
25
+ self.base_url = base_url
26
+
27
+ async def aask(self, request: BaseModel):
28
+ payload = request.model_dump()
29
+ request_id = str(uuid.uuid4())
30
+
31
+ # Use AsyncClient to avoid blocking the loop
32
+ async with httpx.AsyncClient() as client:
33
+ await client.post(
34
+ f"{self.base_url}/requests",
35
+ json={
36
+ "request_id": request_id,
37
+ "endpoint_name": self.name,
38
+ "payload": payload,
39
+ },
40
+ timeout=5.0,
41
+ )
42
+
43
+ ws_url = self.base_url.replace("http", "ws")
44
+ ws_url = f"{ws_url}/ws/requests/{request_id}"
45
+
46
+ async with websockets.connect(ws_url) as ws:
47
+ await ws.send("ready")
48
+
49
+ while True:
50
+ msg = await ws.recv()
51
+ record = json.loads(msg)
52
+
53
+ response = record.get("response")
54
+ if not response:
55
+ continue
56
+
57
+ kind = response.get("kind")
58
+
59
+ if kind == "success":
60
+ return self.response_model.model_validate(response["payload"])
61
+
62
+ if kind == "exception":
63
+ exc_type = response["exception_type"]
64
+
65
+ for exc in self.exceptions:
66
+ if exc.__name__ == exc_type:
67
+ raise exc(**response["payload"])
68
+
69
+ raise Exception(f"Unknown exception: {exc_type}")
70
+
71
+ if kind == "timeout":
72
+ raise TimeoutError()
73
+
74
+ if kind == "ignore":
75
+ return None
76
+
77
+ def ask(self, request: BaseModel):
78
+ request_id = str(uuid.uuid4())
79
+
80
+ with httpx.Client() as client:
81
+ client.post(
82
+ f"{self.base_url}/requests",
83
+ json={
84
+ "request_id": request_id,
85
+ "endpoint_name": self.name,
86
+ "payload": request.model_dump(),
87
+ },
88
+ timeout=5.0,
89
+ )
90
+
91
+ # Poll for resolution
92
+ while True:
93
+ r = client.get(f"{self.base_url}/requests/{request_id}")
94
+ if r.status_code == 200:
95
+ record = r.json()
96
+ if record.get("status") == "completed":
97
+ response = record.get("response")
98
+ if not response:
99
+ raise Exception("Request completed with no response payload.")
100
+
101
+ kind = response.get("kind")
102
+
103
+ if kind == "success":
104
+ return self.response_model.model_validate(response["payload"])
105
+
106
+ if kind == "exception":
107
+ exc_type = response["exception_type"]
108
+ for exc in self.exceptions:
109
+ if exc.__name__ == exc_type:
110
+ raise exc(**response["payload"])
111
+ raise Exception(f"Unknown exception: {exc_type}")
112
+
113
+ if kind == "timeout":
114
+ raise TimeoutError()
115
+
116
+ if kind == "ignore":
117
+ return None
118
+
119
+ time.sleep(0.2)
File without changes
@@ -0,0 +1,14 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class ExceptionDefinition(BaseModel):
5
+ name: str
6
+
7
+
8
+ class EndpointDefinition(BaseModel):
9
+ name: str
10
+
11
+ input_schema: dict
12
+ response_schema: dict
13
+
14
+ exceptions: list[ExceptionDefinition] = Field(default_factory=list)
@@ -0,0 +1,13 @@
1
+ from uuid import uuid4
2
+ from datetime import datetime
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class RequestEnvelope(BaseModel):
7
+ request_id: str = Field(default_factory=lambda: str(uuid4()))
8
+
9
+ endpoint_name: str
10
+
11
+ payload: dict
12
+
13
+ created_at: datetime = Field(default_factory=datetime.utcnow)
@@ -0,0 +1,33 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class SuccessResponse(BaseModel):
7
+ kind: Literal["success"] = "success"
8
+
9
+ payload: dict
10
+
11
+
12
+ class ExceptionResponse(BaseModel):
13
+ kind: Literal["exception"] = "exception"
14
+
15
+ exception_type: str
16
+
17
+ payload: dict
18
+
19
+
20
+ class TimeoutResponse(BaseModel):
21
+ kind: Literal["timeout"] = "timeout"
22
+
23
+
24
+ class IgnoreResponse(BaseModel):
25
+ kind: Literal["ignore"] = "ignore"
26
+
27
+ ResponseEnvelope = (
28
+ SuccessResponse
29
+ | ExceptionResponse
30
+ | TimeoutResponse
31
+ | IgnoreResponse
32
+ )
33
+
File without changes
humanrpc/server/app.py ADDED
@@ -0,0 +1,37 @@
1
+ from importlib.resources import files
2
+
3
+ from fastapi import FastAPI
4
+ from fastapi.staticfiles import StaticFiles
5
+
6
+ from starlette.routing import Mount
7
+
8
+ from humanrpc.server.routes import router as core_router
9
+ from humanrpc.server.manual import router as manual_router
10
+ from humanrpc.server.ws import router as ws_router
11
+
12
+
13
+ def create_app(*, serve_static: bool = True) -> FastAPI:
14
+ app = FastAPI()
15
+
16
+ app.include_router(ws_router)
17
+ app.include_router(core_router)
18
+ app.include_router(manual_router)
19
+
20
+ if serve_static:
21
+ static_dir = files("humanrpc").joinpath("static")
22
+
23
+ app.mount(
24
+ "/",
25
+ StaticFiles(
26
+ directory=str(static_dir),
27
+ html=True,
28
+ ),
29
+ name="static",
30
+ )
31
+
32
+ return app
33
+
34
+
35
+ app = create_app()
36
+
37
+ app.routes.sort(key=lambda r: 1 if isinstance(r, Mount) and r.path == "/" else 0)
@@ -0,0 +1,71 @@
1
+ from datetime import datetime
2
+ from fastapi import APIRouter
3
+
4
+ from humanrpc.server.state import state
5
+ from humanrpc.models.response import (
6
+ SuccessResponse,
7
+ ExceptionResponse,
8
+ TimeoutResponse,
9
+ IgnoreResponse,
10
+ )
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ async def _complete(request_id: str):
16
+ await state.notify(request_id)
17
+
18
+
19
+ @router.post("/respond/success/{request_id}")
20
+ async def respond_success(request_id: str, payload: dict):
21
+ record = state.requests.get(request_id)
22
+
23
+ if record:
24
+ record.status = "completed"
25
+ record.response = SuccessResponse(payload=payload)
26
+ record.responded_at = datetime.utcnow()
27
+ await _complete(request_id)
28
+
29
+ return {"ok": True}
30
+
31
+
32
+ @router.post("/respond/exception/{request_id}")
33
+ async def respond_exception(request_id: str, exception_type: str, payload: dict):
34
+ record = state.requests.get(request_id)
35
+
36
+ if record:
37
+ record.status = "completed"
38
+ record.response = ExceptionResponse(
39
+ exception_type=exception_type,
40
+ payload=payload,
41
+ )
42
+ record.responded_at = datetime.utcnow()
43
+ await _complete(request_id)
44
+
45
+ return {"ok": True}
46
+
47
+
48
+ @router.post("/respond/timeout/{request_id}")
49
+ async def respond_timeout(request_id: str):
50
+ record = state.requests.get(request_id)
51
+
52
+ if record:
53
+ record.status = "completed"
54
+ record.response = TimeoutResponse()
55
+ record.responded_at = datetime.utcnow()
56
+ await _complete(request_id)
57
+
58
+ return {"ok": True}
59
+
60
+
61
+ @router.post("/respond/ignore/{request_id}")
62
+ async def respond_ignore(request_id: str):
63
+ record = state.requests.get(request_id)
64
+
65
+ if record:
66
+ record.status = "completed"
67
+ record.response = IgnoreResponse()
68
+ record.responded_at = datetime.utcnow()
69
+ await _complete(request_id)
70
+
71
+ return {"ok": True}
@@ -0,0 +1,110 @@
1
+ from datetime import datetime
2
+ from fastapi import APIRouter, HTTPException
3
+ from fastapi.encoders import jsonable_encoder
4
+ from humanrpc.models.endpoint import EndpointDefinition
5
+ from humanrpc.models.request import RequestEnvelope
6
+ from humanrpc.server.state import state, RequestRecord
7
+ from humanrpc.models.response import (
8
+ SuccessResponse,
9
+ ExceptionResponse,
10
+ TimeoutResponse,
11
+ IgnoreResponse,
12
+ )
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ @router.post("/endpoints")
18
+ async def register_endpoint(endpoint: EndpointDefinition):
19
+ state.endpoints[endpoint.name] = endpoint
20
+ return {"status": "ok"}
21
+
22
+
23
+ @router.get("/endpoints")
24
+ async def list_endpoints():
25
+ return state.endpoints
26
+
27
+
28
+ @router.post("/requests")
29
+ async def submit_request(request: RequestEnvelope):
30
+ if request.endpoint_name not in state.endpoints:
31
+ raise HTTPException(status_code=404, detail="Unknown endpoint")
32
+
33
+ state.requests[request.request_id] = RequestRecord(request=request)
34
+
35
+ return {"status": "queued", "request_id": request.request_id}
36
+
37
+
38
+ @router.get("/requests/pending")
39
+ async def list_pending():
40
+ return jsonable_encoder([
41
+ r.model_dump(mode="json")
42
+ for r in state.requests.values()
43
+ if r.status == "pending"
44
+ ])
45
+
46
+
47
+ @router.get("/requests")
48
+ async def list_requests():
49
+ return jsonable_encoder([
50
+ r.model_dump(mode="json")
51
+ for r in state.requests.values()
52
+ ])
53
+
54
+
55
+ @router.get("/requests/{request_id}")
56
+ async def get_request(request_id: str):
57
+ record = state.requests.get(request_id)
58
+
59
+ if not record:
60
+ raise HTTPException(status_code=404, detail="Not found")
61
+
62
+ # IMPORTANT: JSON-safe (fixes datetime crash)
63
+ return jsonable_encoder(record.model_dump(mode="json"))
64
+
65
+
66
+ @router.post("/requests/{request_id}/resolve")
67
+ @router.post("/api/requests/{request_id}/resolve")
68
+ async def resolve_request(request_id: str, data: dict):
69
+ record = state.requests.get(request_id)
70
+ if not record:
71
+ raise HTTPException(status_code=404, detail="Not found")
72
+
73
+ status = data.get("status", "completed")
74
+ response = data.get("response")
75
+
76
+ # Record status must be "completed" for client to stop waiting
77
+ record.status = "completed"
78
+
79
+ # Map frontend status to the correct ResponseEnvelope
80
+ if status == "rejected":
81
+ record.response = ExceptionResponse(
82
+ exception_type="Exception",
83
+ payload={"message": "Rejected by human operator"}
84
+ )
85
+ else:
86
+ # Ensure we construct the proper ResponseEnvelope subclass
87
+ if isinstance(response, dict) and "kind" in response:
88
+ kind = response.get("kind")
89
+ if kind == "success":
90
+ record.response = SuccessResponse(payload=response.get("payload", {}))
91
+ elif kind == "exception":
92
+ record.response = ExceptionResponse(
93
+ exception_type=response.get("exception_type", "Exception"),
94
+ payload=response.get("payload", {})
95
+ )
96
+ elif kind == "timeout":
97
+ record.response = TimeoutResponse()
98
+ elif kind == "ignore":
99
+ record.response = IgnoreResponse()
100
+ else:
101
+ record.response = SuccessResponse(payload=response)
102
+ else:
103
+ record.response = SuccessResponse(payload=response)
104
+
105
+ record.responded_at = datetime.utcnow()
106
+
107
+ # Notify all listeners (including client polling/WebSockets)
108
+ await state.notify(request_id)
109
+
110
+ return {"status": "ok"}
@@ -0,0 +1,52 @@
1
+ from datetime import datetime
2
+ from typing import Literal, Callable, Awaitable
3
+ from pydantic import BaseModel
4
+
5
+ from humanrpc.models.endpoint import EndpointDefinition
6
+ from humanrpc.models.request import RequestEnvelope
7
+ from humanrpc.models.response import ResponseEnvelope
8
+
9
+
10
+ class RequestRecord(BaseModel):
11
+ request: RequestEnvelope
12
+ status: Literal["pending", "completed"] = "pending"
13
+ response: ResponseEnvelope | None = None
14
+ responded_at: datetime | None = None
15
+
16
+
17
+ Listener = Callable[[RequestRecord], Awaitable[None]]
18
+
19
+
20
+ class State:
21
+ def __init__(self):
22
+ self.endpoints: dict[str, EndpointDefinition] = {}
23
+ self.requests: dict[str, RequestRecord] = {}
24
+ self.listeners: dict[str, list[Listener]] = {}
25
+
26
+ def add_listener(self, request_id: str, cb: Listener):
27
+ self.listeners.setdefault(request_id, []).append(cb)
28
+
29
+ def remove_listener(self, request_id: str, cb: Listener):
30
+ if request_id in self.listeners:
31
+ try:
32
+ self.listeners[request_id].remove(cb)
33
+ except ValueError:
34
+ pass
35
+ if not self.listeners[request_id]:
36
+ self.listeners.pop(request_id, None)
37
+
38
+ def remove_listeners(self, request_id: str):
39
+ self.listeners.pop(request_id, None)
40
+
41
+ async def notify(self, request_id: str):
42
+ record = self.requests.get(request_id)
43
+ if not record:
44
+ return
45
+
46
+ for cb in list(self.listeners.get(request_id, [])):
47
+ try:
48
+ await cb(record)
49
+ except Exception:
50
+ pass
51
+
52
+ state = State()
humanrpc/server/ws.py ADDED
@@ -0,0 +1,252 @@
1
+ import asyncio
2
+ from datetime import datetime
3
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
4
+
5
+ from humanrpc.server.state import state
6
+ from humanrpc.models.response import (
7
+ SuccessResponse,
8
+ ExceptionResponse,
9
+ TimeoutResponse,
10
+ IgnoreResponse,
11
+ )
12
+
13
+ router = APIRouter()
14
+
15
+ # Keep track of active browser console dashboard WebSocket connections
16
+ active_consoles = set()
17
+
18
+
19
+ class ObservableDict(dict):
20
+ """
21
+ A dictionary wrapper that executes a callback whenever a key-value
22
+ pair is added or updated, enabling automatic live notifications.
23
+ """
24
+ def __init__(self, initial_dict, on_set=None):
25
+ super().__init__(initial_dict)
26
+ self._on_set = on_set
27
+
28
+ def __setitem__(self, key, value):
29
+ is_new = key not in self
30
+ super().__setitem__(key, value)
31
+ if self._on_set:
32
+ self._on_set(key, value, is_new)
33
+
34
+
35
+ def broadcast_to_consoles(message):
36
+ """Safely dispatches a JSON message to all open console dashboards."""
37
+ async def send_all():
38
+ for ws in list(active_consoles):
39
+ try:
40
+ await ws.send_json(message)
41
+ except Exception:
42
+ active_consoles.discard(ws)
43
+
44
+ try:
45
+ loop = asyncio.get_running_loop()
46
+ if loop.is_running():
47
+ loop.create_task(send_all())
48
+ except RuntimeError:
49
+ pass
50
+
51
+
52
+ def get_all_endpoints():
53
+ """Helper to cleanly serialize registered schemas from state."""
54
+ endpoints = []
55
+ if hasattr(state, "endpoints"):
56
+ raw = state.endpoints.values() if isinstance(state.endpoints, dict) else state.endpoints
57
+ for e in raw:
58
+ if hasattr(e, "model_dump"):
59
+ endpoints.append(e.model_dump(mode="json"))
60
+ else:
61
+ endpoints.append(e)
62
+ return endpoints
63
+
64
+
65
+ def get_all_requests():
66
+ """Helper to cleanly serialize active/historical requests from state."""
67
+ requests = []
68
+ for r in state.requests.values():
69
+ if hasattr(r, "model_dump"):
70
+ requests.append(r.model_dump(mode="json"))
71
+ else:
72
+ requests.append(r)
73
+ return requests
74
+
75
+
76
+ def on_request_set(request_id, record, is_new):
77
+ """Triggered automatically whenever a request is registered or modified."""
78
+ event_type = "request_created" if is_new else "request_updated"
79
+
80
+ # Notify all consoles about the request change
81
+ broadcast_to_consoles({
82
+ "type": event_type,
83
+ "request": record.model_dump(mode="json") if hasattr(record, "model_dump") else record
84
+ })
85
+
86
+ # If a new request is created, hook a listener to propagate updates
87
+ if is_new:
88
+ async def listener(rec):
89
+ broadcast_to_consoles({
90
+ "type": "request_updated",
91
+ "request": rec.model_dump(mode="json") if hasattr(rec, "model_dump") else rec
92
+ })
93
+ try:
94
+ state.add_listener(request_id, listener)
95
+ except Exception:
96
+ pass
97
+
98
+
99
+ # 1. Bind listeners for any pre-existing requests on server startup
100
+ for req_id, rec in list(state.requests.items()):
101
+ async def legacy_listener(r):
102
+ broadcast_to_consoles({
103
+ "type": "request_updated",
104
+ "request": r.model_dump(mode="json") if hasattr(r, "model_dump") else r
105
+ })
106
+ try:
107
+ state.add_listener(req_id, legacy_listener)
108
+ except Exception:
109
+ pass
110
+
111
+ # 2. Patch the requests dictionary to automatically observe future updates
112
+ state.requests = ObservableDict(state.requests, on_set=on_request_set)
113
+
114
+ # 3. Patch the endpoints collection (if dict/list) to notify consoles on updates
115
+ if hasattr(state, "endpoints"):
116
+ if isinstance(state.endpoints, dict):
117
+ def on_endpoint_set(key, val, is_new):
118
+ broadcast_to_consoles({
119
+ "type": "init",
120
+ "endpoints": get_all_endpoints(),
121
+ "requests": get_all_requests()
122
+ })
123
+ state.endpoints = ObservableDict(state.endpoints, on_set=on_endpoint_set)
124
+ elif isinstance(state.endpoints, list):
125
+ original_append = state.endpoints.append
126
+ def patched_append(item):
127
+ original_append(item)
128
+ broadcast_to_consoles({
129
+ "type": "init",
130
+ "endpoints": get_all_endpoints(),
131
+ "requests": get_all_requests()
132
+ })
133
+ state.endpoints.append = patched_append
134
+
135
+
136
+ # --- 1. Global Console Dashboard WebSocket Endpoint ---
137
+ @router.websocket("/ws")
138
+ async def ws_console(websocket: WebSocket):
139
+ """
140
+ Acts as the main real-time bridge for the browser dashboard.
141
+ Loads initial data on load, and listens for client updates.
142
+ """
143
+ await websocket.accept()
144
+ active_consoles.add(websocket)
145
+
146
+ try:
147
+ # Load active dataset
148
+ await websocket.send_json({
149
+ "type": "init",
150
+ "endpoints": get_all_endpoints(),
151
+ "requests": get_all_requests()
152
+ })
153
+
154
+ while True:
155
+ # Optionally receive human actions (e.g., resolutions over WebSockets)
156
+ data = await websocket.receive_json()
157
+ if data.get("type") in ("resolve", "respond"):
158
+ request_id = data.get("id") or data.get("request_id")
159
+ response = data.get("response")
160
+ status = data.get("status", "completed")
161
+
162
+ if request_id:
163
+ record = state.requests.get(request_id)
164
+ if record:
165
+ # Record status must be "completed" so that the waiting client resumes
166
+ record.status = "completed"
167
+
168
+ # Set proper ResponseEnvelope subclass values
169
+ if status == "rejected":
170
+ record.response = ExceptionResponse(
171
+ exception_type="Exception",
172
+ payload={"message": "Rejected by human operator"}
173
+ )
174
+ else:
175
+ if isinstance(response, dict) and "kind" in response:
176
+ kind = response.get("kind")
177
+ if kind == "success":
178
+ record.response = SuccessResponse(payload=response.get("payload", {}))
179
+ elif kind == "exception":
180
+ record.response = ExceptionResponse(
181
+ exception_type=response.get("exception_type", "Exception"),
182
+ payload=response.get("payload", {})
183
+ )
184
+ elif kind == "timeout":
185
+ record.response = TimeoutResponse()
186
+ elif kind == "ignore":
187
+ record.response = IgnoreResponse()
188
+ else:
189
+ record.response = SuccessResponse(payload=response)
190
+ else:
191
+ record.response = SuccessResponse(payload=response)
192
+
193
+ record.responded_at = datetime.utcnow()
194
+
195
+ # Correctly dispatch notifications to registered listeners
196
+ await state.notify(request_id)
197
+ except WebSocketDisconnect:
198
+ pass
199
+ finally:
200
+ active_consoles.discard(websocket)
201
+
202
+
203
+ # --- 2. Client-Specific Blocking WebSocket Endpoint ---
204
+ @router.websocket("/ws/requests/{request_id}")
205
+ async def ws_request(request_id: str, websocket: WebSocket):
206
+ """Matches standard client await loops."""
207
+ await websocket.accept()
208
+
209
+ record = state.requests.get(request_id)
210
+ if record and record.status == "completed":
211
+ await websocket.send_json(record.model_dump(mode="json"))
212
+ await websocket.close()
213
+ return
214
+
215
+ stop_event = asyncio.Event()
216
+
217
+ async def listener(rec):
218
+ try:
219
+ await websocket.send_json(rec.model_dump(mode="json"))
220
+ if rec.status == "completed":
221
+ stop_event.set()
222
+ except Exception:
223
+ stop_event.set()
224
+
225
+ state.add_listener(request_id, listener)
226
+
227
+ if record:
228
+ try:
229
+ await websocket.send_json(record.model_dump(mode="json"))
230
+ except Exception:
231
+ stop_event.set()
232
+
233
+ async def receive_loop():
234
+ try:
235
+ while not stop_event.is_set():
236
+ await websocket.receive_text()
237
+ except WebSocketDisconnect:
238
+ pass
239
+ finally:
240
+ stop_event.set()
241
+
242
+ receive_task = asyncio.create_task(receive_loop())
243
+
244
+ try:
245
+ await stop_event.wait()
246
+ finally:
247
+ state.remove_listener(request_id, listener)
248
+ receive_task.cancel()
249
+ try:
250
+ await websocket.close()
251
+ except Exception:
252
+ pass
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: humanrpc
3
+ Version: 0.1.0
4
+ Summary: Human-powered typed RPC endpoints for building systems before the real implementation exists.
5
+ Author: cvaz1306
6
+ Author-email: christophervaz160@gmail.com
7
+ Requires-Python: >=3.12
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Requires-Dist: fastapi (>=0.136.3,<0.137.0)
13
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
14
+ Requires-Dist: pydantic (>=2.13.4,<3.0.0)
15
+ Requires-Dist: rich (>=15.0.0,<16.0.0)
16
+ Requires-Dist: typer (>=0.26.6,<0.27.0)
17
+ Requires-Dist: uvicorn (>=0.48.0,<0.49.0)
18
+ Requires-Dist: websockets (>=16.0,<17.0)
19
+ Description-Content-Type: text/markdown
20
+
21
+ # HumanRPC
22
+
23
+ HumanRPC is a human-powered RPC server for developing distributed systems before the real implementation exists.
24
+
25
+ Instead of calling an AI agent, microservice, planner, or capability, your application calls a typed endpoint. A human operator receives the request in a web UI, provides a response, and HumanRPC returns the result to the caller.
26
+
27
+ The goal is to enable rapid development of orchestration, routing, scheduling, memory, and workflow systems without waiting for every component to be implemented.
28
+
29
+ ## Features
30
+
31
+ * Pydantic-first API
32
+ * Typed request and response models
33
+ * Async and sync clients
34
+ * Human-powered endpoint implementations
35
+ * Exception simulation
36
+ * Timeout simulation
37
+ * Web UI for handling requests
38
+ * No external infrastructure required
39
+
40
+ ## Example
41
+
42
+ ```python
43
+ from humanrpc import Client
44
+
45
+ client = Client()
46
+
47
+ agent = client.endpoint(
48
+ name="agent",
49
+ input_model=ChatRequest,
50
+ response_model=ChatResponse,
51
+ exceptions=[
52
+ ValueError,
53
+ HTTPException,
54
+ ],
55
+ )
56
+
57
+ result = await agent.aask(
58
+ ChatRequest(...)
59
+ )
60
+ ```
61
+
62
+ ## Status
63
+
64
+ Early development.
65
+
66
+
@@ -0,0 +1,19 @@
1
+ humanrpc/__init__.py,sha256=_AP5_HdH1yieYH5fIZhc1KV54i35x6Vjw3iNBBY-wJY,134
2
+ humanrpc/__main__.py,sha256=a2i2DGMG7NDhn2r_sdFAE5Ly61GHw5VJhOywzRFn5Ss,3011
3
+ humanrpc/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ humanrpc/client/client.py,sha256=-5PY0OcfwlAIe3K6xchjC6dpgmqkoeHdCXuRUj_uT_g,1188
5
+ humanrpc/client/endpoint.py,sha256=oXaP4Nybc9DxulDF5zeCAQ6_mD0Rd2bM-FhETyzZPJg,3873
6
+ humanrpc/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ humanrpc/models/endpoint.py,sha256=HW6jNe_w8OCnyc7d5Qrh2Z6oZEnfnNRNdtff34znyik,282
8
+ humanrpc/models/request.py,sha256=ElcQ7lH3uM5mQkUO4SMHMLhylg_mCkzlqzp0pn5G5pk,316
9
+ humanrpc/models/response.py,sha256=ZtJbbwtaknwjebSZnuj1sGZCqI1qmMsDF45AgCKZ8qI,578
10
+ humanrpc/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ humanrpc/server/app.py,sha256=MmWHf75Iu7n-Yk8IdukGQwHjsW0Nx8HY4CMc-4PVWj4,925
12
+ humanrpc/server/manual.py,sha256=cm6CY8FjV3HpyRWoHalLOviFHQQzuPsqmfEDrzqMXuw,1909
13
+ humanrpc/server/routes.py,sha256=XV_C_C3TA2cbeK5VQUT_jfhonmJNrVIp520xQiS4oY0,3601
14
+ humanrpc/server/state.py,sha256=1YBN8s2eN0s2O2oIjF1lxQhqKMOrvv-c-s90kwvx1ug,1648
15
+ humanrpc/server/ws.py,sha256=0nbDwaPeQq_SIv3W9QtDPIUWnh4BuagBmgJvesRBbs4,9216
16
+ humanrpc-0.1.0.dist-info/entry_points.txt,sha256=d6pWmG3REfFpvuQRMQHPDsODi8GwiCvLQQYF7120oLk,51
17
+ humanrpc-0.1.0.dist-info/METADATA,sha256=5cHDOsUR59XTPyiersnIgJprDiez4BTU7bf6oz5Cqbw,1871
18
+ humanrpc-0.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
19
+ humanrpc-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ humanrpc=humanrpc.__main__:main
3
+