sefia-fastapi 0.1.0__tar.gz

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.
@@ -0,0 +1,175 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py.cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ cover/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Django stuff:
57
+ *.log
58
+ local_settings.py
59
+ db.sqlite3
60
+ db.sqlite3-journal
61
+
62
+ # Flask stuff:
63
+ instance/
64
+ .webassets-cache
65
+
66
+ # Scrapy stuff:
67
+ .scrapy
68
+
69
+ # Sphinx documentation
70
+ docs/_build/
71
+
72
+ # PyBuilder
73
+ .pybuilder/
74
+ target/
75
+
76
+ # Jupyter Notebook
77
+ .ipynb_checkpoints
78
+
79
+ # IPython
80
+ profile_default/
81
+ ipython_config.py
82
+
83
+ # pyenv
84
+ # .python-version
85
+
86
+ # pipenv
87
+ #Pipfile.lock
88
+
89
+ # UV
90
+ #uv.lock
91
+
92
+ # poetry
93
+ #poetry.lock
94
+ #poetry.toml
95
+
96
+ # pdm
97
+ #pdm.lock
98
+ #pdm.toml
99
+ .pdm-python
100
+ .pdm-build/
101
+
102
+ # pixi
103
+ #pixi.lock
104
+ .pixi
105
+
106
+ # PEP 582
107
+ __pypackages__/
108
+
109
+ # Celery stuff
110
+ celerybeat-schedule
111
+ celerybeat.pid
112
+
113
+ # SageMath parsed files
114
+ *.sage.py
115
+
116
+ # Environments
117
+ .env
118
+ .envrc
119
+ .venv
120
+ env/
121
+ venv/
122
+ ENV/
123
+ env.bak/
124
+ venv.bak/
125
+
126
+ # Spyder project settings
127
+ .spyderproject
128
+ .spyproject
129
+
130
+ # Rope project settings
131
+ .ropeproject
132
+
133
+ # mkdocs documentation
134
+ /site
135
+
136
+ # mypy
137
+ .mypy_cache/
138
+ .dmypy.json
139
+ dmypy.json
140
+
141
+ # Pyre type checker
142
+ .pyre/
143
+
144
+ # pytype static type analyzer
145
+ .pytype/
146
+
147
+ # Cython debug symbols
148
+ cython_debug/
149
+
150
+ # PyCharm
151
+ #.idea/
152
+
153
+ # Abstra
154
+ .abstra/
155
+
156
+ # Visual Studio Code
157
+ # .vscode/
158
+
159
+ # Ruff stuff:
160
+ .ruff_cache/
161
+
162
+ # PyPI configuration file
163
+ .pypirc
164
+
165
+ # Cursor
166
+ .cursorignore
167
+ .cursorindexingignore
168
+
169
+ # Marimo
170
+ marimo/_static/
171
+ marimo/_lsp/
172
+ __marimo__/
173
+
174
+ /out/
175
+ /.local/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nueruyu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: sefia-fastapi
3
+ Version: 0.1.0
4
+ Summary: FastAPI (HTTP) building blocks for Sefia applications.
5
+ Project-URL: Homepage, https://github.com/nueruyu/sefia
6
+ Project-URL: Repository, https://github.com/nueruyu/sefia
7
+ Project-URL: Issues, https://github.com/nueruyu/sefia/issues
8
+ Project-URL: Documentation, https://github.com/nueruyu/sefia/tree/main/docs
9
+ Author: nueruyu
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 nueruyu
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: fastapi,human-in-the-loop,sefia,sse
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Framework :: AsyncIO
35
+ Classifier: Framework :: FastAPI
36
+ Classifier: Intended Audience :: Developers
37
+ Classifier: License :: OSI Approved :: MIT License
38
+ Classifier: Operating System :: OS Independent
39
+ Classifier: Programming Language :: Python :: 3
40
+ Classifier: Programming Language :: Python :: 3.11
41
+ Classifier: Programming Language :: Python :: 3.12
42
+ Classifier: Programming Language :: Python :: 3.13
43
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
44
+ Classifier: Typing :: Typed
45
+ Requires-Python: >=3.11
46
+ Requires-Dist: fastapi>=0.110
47
+ Requires-Dist: sefia>=0.1.0
48
+ Description-Content-Type: text/markdown
49
+
50
+ # sefia-fastapi
51
+
52
+ FastAPI (HTTP) building blocks for [Sefia](https://pypi.org/project/sefia/)
53
+ applications.
54
+
55
+ This package holds the HTTP-side pieces that depend only on `sefia` and
56
+ FastAPI: the input core (an `InputChannel` persisted over a `KeyValueStore`),
57
+ per-session SSE streams (`SessionEvents`), and the exceptions an application
58
+ maps to HTTP responses. The runtime wiring — session management, persistence,
59
+ and the pausing tool — is provided by an integration layer such as
60
+ `sefios.fastapi` from [`sefios`](https://pypi.org/project/sefios/).
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ pip install sefia-fastapi
66
+ ```
67
+
68
+ Most applications install it through the stack instead:
69
+
70
+ ```bash
71
+ pip install 'sefios[fastapi]'
72
+ ```
73
+
74
+ ## Documentation
75
+
76
+ See the [repository](https://github.com/nueruyu/sefia) for the full README,
77
+ tutorial, and architecture docs.
78
+
79
+ ## Status
80
+
81
+ Early development. APIs may change before v1.0.
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,36 @@
1
+ # sefia-fastapi
2
+
3
+ FastAPI (HTTP) building blocks for [Sefia](https://pypi.org/project/sefia/)
4
+ applications.
5
+
6
+ This package holds the HTTP-side pieces that depend only on `sefia` and
7
+ FastAPI: the input core (an `InputChannel` persisted over a `KeyValueStore`),
8
+ per-session SSE streams (`SessionEvents`), and the exceptions an application
9
+ maps to HTTP responses. The runtime wiring — session management, persistence,
10
+ and the pausing tool — is provided by an integration layer such as
11
+ `sefios.fastapi` from [`sefios`](https://pypi.org/project/sefios/).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install sefia-fastapi
17
+ ```
18
+
19
+ Most applications install it through the stack instead:
20
+
21
+ ```bash
22
+ pip install 'sefios[fastapi]'
23
+ ```
24
+
25
+ ## Documentation
26
+
27
+ See the [repository](https://github.com/nueruyu/sefia) for the full README,
28
+ tutorial, and architecture docs.
29
+
30
+ ## Status
31
+
32
+ Early development. APIs may change before v1.0.
33
+
34
+ ## License
35
+
36
+ MIT
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "sefia-fastapi"
3
+ description = "FastAPI (HTTP) building blocks for Sefia applications."
4
+ readme = "README.md"
5
+ license = { file = "LICENSE" }
6
+ authors = [{ name = "nueruyu" }]
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "sefia>=0.1.0",
10
+ "fastapi>=0.110",
11
+ ]
12
+ dynamic = ["version"]
13
+ keywords = [
14
+ "fastapi",
15
+ "sse",
16
+ "human-in-the-loop",
17
+ "sefia",
18
+ ]
19
+ classifiers = [
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Operating System :: OS Independent",
26
+ "Development Status :: 3 - Alpha",
27
+ "Framework :: AsyncIO",
28
+ "Framework :: FastAPI",
29
+ "Intended Audience :: Developers",
30
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
31
+ "Typing :: Typed",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/nueruyu/sefia"
36
+ Repository = "https://github.com/nueruyu/sefia"
37
+ Issues = "https://github.com/nueruyu/sefia/issues"
38
+ Documentation = "https://github.com/nueruyu/sefia/tree/main/docs"
39
+
40
+ [build-system]
41
+ requires = ["hatchling", "hatch-vcs"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/sefia_fastapi"]
46
+
47
+ [tool.hatch.version]
48
+ source = "vcs"
49
+ raw-options = { root = "../.." }
50
+
51
+ [tool.pytest.ini_options]
52
+ asyncio_mode = "auto"
53
+ testpaths = ["tests"]
54
+
55
+ [tool.uv.sources]
56
+ sefia = { workspace = true }
57
+
58
+ [dependency-groups]
59
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
@@ -0,0 +1,31 @@
1
+ """FastAPI (HTTP) building blocks for Sefia applications.
2
+
3
+ This package holds the HTTP-side pieces that depend only on ``sefia`` and
4
+ FastAPI: the input core (an :class:`InputChannel` persisted over a
5
+ :class:`KeyValueStore`), per-session SSE streams (:class:`SessionEvents`),
6
+ and the exceptions an application maps to HTTP responses. The runtime
7
+ wiring — session management, persistence, and the pausing tool — is provided
8
+ by an integration layer such as ``sefios.fastapi``.
9
+ """
10
+
11
+ from ._events import SessionEvents, SSEEvent
12
+ from ._input import InputChannel, InputRequest
13
+ from ._kv import KeyValueStore
14
+ from .exceptions import (
15
+ AmbiguousInputError,
16
+ InputRequired,
17
+ UnknownInputError,
18
+ UnknownSessionError,
19
+ )
20
+
21
+ __all__ = [
22
+ "InputChannel",
23
+ "InputRequest",
24
+ "KeyValueStore",
25
+ "SessionEvents",
26
+ "SSEEvent",
27
+ "InputRequired",
28
+ "UnknownSessionError",
29
+ "UnknownInputError",
30
+ "AmbiguousInputError",
31
+ ]
@@ -0,0 +1,102 @@
1
+ """Per-session server-sent events: publish, token relay, and the SSE response."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from fastapi.encoders import jsonable_encoder
13
+ from fastapi.responses import StreamingResponse
14
+ from sefia.event_system import EventHandler
15
+ from sefia.llm.events import LLMTokenReceived
16
+
17
+
18
+ class SSEEvent:
19
+ """The wire names of the server-sent events an application publishes.
20
+
21
+ Single source of truth: the facade and browser clients import these rather
22
+ than repeating literals.
23
+ """
24
+
25
+ TOKEN = "token"
26
+ INPUT_REQUIRED = "input_required"
27
+ OUTPUT = "output"
28
+ COMPLETED = "completed"
29
+ EXECUTION_FAILED = "execution_failed"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class _SessionEvent:
34
+ name: str
35
+ data: Any
36
+
37
+
38
+ class SessionEvents:
39
+ """Per-session event streams for an HTTP application.
40
+
41
+ One object owns the whole surface: :meth:`publish` fans an event out to a
42
+ session's subscribers, :meth:`token_handler` returns a sefia event handler
43
+ that relays LLM tokens into the stream, and :meth:`response` serves the
44
+ stream as a ``text/event-stream`` response. Publishing to a session nobody
45
+ is subscribed to is a no-op.
46
+ """
47
+
48
+ def __init__(self):
49
+ self._subscribers: dict[str, set[asyncio.Queue[_SessionEvent]]] = {}
50
+
51
+ async def publish(self, session_id: str, name: str, data: Any) -> None:
52
+ subscribers = list(self._subscribers.get(session_id, ()))
53
+ if not subscribers:
54
+ return
55
+ event = _SessionEvent(name=name, data=data)
56
+ for queue in subscribers:
57
+ await queue.put(event)
58
+
59
+ def token_handler(self, session_id: str) -> EventHandler[LLMTokenReceived]:
60
+ """A sefia event handler relaying LLM tokens into this session's stream."""
61
+ return _TokenRelay(self, session_id)
62
+
63
+ def response(self, session_id: str) -> StreamingResponse:
64
+ """A ``text/event-stream`` response relaying this session's events."""
65
+ return StreamingResponse(
66
+ self._event_stream(session_id),
67
+ media_type="text/event-stream",
68
+ )
69
+
70
+ @asynccontextmanager
71
+ async def _subscribe(
72
+ self, session_id: str
73
+ ) -> AsyncIterator[asyncio.Queue[_SessionEvent]]:
74
+ queue: asyncio.Queue[_SessionEvent] = asyncio.Queue()
75
+ subscribers = self._subscribers.setdefault(session_id, set())
76
+ subscribers.add(queue)
77
+ try:
78
+ yield queue
79
+ finally:
80
+ subscribers.discard(queue)
81
+ if not subscribers:
82
+ self._subscribers.pop(session_id, None)
83
+
84
+ async def _event_stream(self, session_id: str) -> AsyncIterator[str]:
85
+ async with self._subscribe(session_id) as queue:
86
+ while True:
87
+ event = await queue.get()
88
+ yield _format_sse_event(event.name, event.data)
89
+
90
+
91
+ class _TokenRelay(EventHandler[LLMTokenReceived]):
92
+ def __init__(self, events: SessionEvents, session_id: str):
93
+ self._events = events
94
+ self._session_id = session_id
95
+
96
+ async def handle(self, event: LLMTokenReceived) -> None:
97
+ await self._events.publish(self._session_id, SSEEvent.TOKEN, event.token)
98
+
99
+
100
+ def _format_sse_event(event: str, data: Any) -> str:
101
+ payload = json.dumps(jsonable_encoder(data), ensure_ascii=False)
102
+ return f"event: {event}\ndata: {payload}\n\n"
@@ -0,0 +1,201 @@
1
+ """The HTTP-side input core.
2
+
3
+ Pending prompts, provided inputs, and queued inputs are persisted through a
4
+ :class:`KeyValueStore`, so a paused request can be resumed by a later one. The
5
+ channel only sees primitives; how the runtime provides persistence (and which
6
+ tool raises the pause) is wired up by the integration layer.
7
+
8
+ Deliberately independent from the CLI counterpart in ``sefia_typer``: the two
9
+ surfaces share semantics today but are free to diverge.
10
+ """
11
+
12
+ from contextlib import contextmanager
13
+ from contextvars import ContextVar
14
+ from dataclasses import dataclass
15
+
16
+ from ._kv import KeyValueStore
17
+ from .exceptions import AmbiguousInputError, UnknownInputError
18
+
19
+ _DEFAULT_NAMESPACE = "input_channel"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class InputRequest:
24
+ """A pending request for external input."""
25
+
26
+ interaction_id: str
27
+ prompt: str
28
+
29
+
30
+ class InputChannel:
31
+ """The input pipe between an HTTP application and a paused agent.
32
+
33
+ One object owns the whole lifecycle. The tool-facing side records prompts
34
+ and picks up provided input (:meth:`record_request` / :meth:`provide_input`
35
+ / :meth:`complete_request`); the application-facing side routes arriving
36
+ input to pending requests (:meth:`receive_input`); :meth:`use_store` binds
37
+ the persistence both sides share.
38
+
39
+ Reads observe writes made earlier in the same session because the bound
40
+ :class:`KeyValueStore` is expected to provide read-your-writes consistency.
41
+ The active binding is held in a :class:`~contextvars.ContextVar` rather
42
+ than a plain attribute so that a single shared channel stays correct when
43
+ several sessions run concurrently (e.g. one asyncio task per HTTP
44
+ request): each task binds and reads its own store.
45
+ """
46
+
47
+ def __init__(self, *, namespace: str = _DEFAULT_NAMESPACE):
48
+ namespace = namespace.strip("/")
49
+ if not namespace:
50
+ raise ValueError("Input channel namespace must not be empty.")
51
+ self._namespace = namespace
52
+ self._active_store: ContextVar[KeyValueStore | None] = ContextVar(
53
+ "input_active_store", default=None
54
+ )
55
+
56
+ @contextmanager
57
+ def use_store(self, store: KeyValueStore):
58
+ """Bind the persistence backing this channel for the enclosed block."""
59
+ token = self._active_store.set(store)
60
+ try:
61
+ yield
62
+ finally:
63
+ self._active_store.reset(token)
64
+
65
+ async def pending(self) -> list[InputRequest]:
66
+ """The requests still waiting for input, ordered by interaction id."""
67
+ pending = await self._pending_map()
68
+ return [
69
+ InputRequest(interaction_id=entry["id"], prompt=entry["prompt"])
70
+ for _, entry in sorted(pending.items())
71
+ ]
72
+
73
+ async def receive_input(
74
+ self,
75
+ input_value: str | list[str] | None,
76
+ *,
77
+ reply_to: str | None = None,
78
+ ) -> None:
79
+ """Route request input to a pending prompt, or queue it for the next.
80
+
81
+ ``None`` and blank input are ignored. With ``reply_to`` the input
82
+ resolves that specific request; otherwise a single pending request is
83
+ resolved directly, multiple pending requests raise
84
+ :class:`AmbiguousInputError`, and no pending request queues the input
85
+ for the next prompt.
86
+ """
87
+ if input_value is None:
88
+ return
89
+ input_text = _to_input_text(input_value)
90
+ if not input_text:
91
+ return
92
+
93
+ pending = await self._pending_map()
94
+
95
+ if reply_to is not None:
96
+ if reply_to not in pending:
97
+ raise UnknownInputError(reply_to)
98
+ await self._store_input(reply_to, input_text)
99
+ return
100
+
101
+ if len(pending) == 1:
102
+ await self._store_input(next(iter(pending)), input_text)
103
+ return
104
+
105
+ if len(pending) > 1:
106
+ raise AmbiguousInputError(sorted(pending))
107
+
108
+ await self._queue_input(input_text)
109
+
110
+ async def provide_input(self, interaction_id: str) -> str | None:
111
+ """Return the stored input, or claim a queued one if unambiguous."""
112
+ provided = await self._stored_input(interaction_id)
113
+ if provided is not None:
114
+ return provided
115
+
116
+ pending = await self._pending_map()
117
+ if any(other_id != interaction_id for other_id in pending):
118
+ return None
119
+
120
+ return await self._pop_queued_input()
121
+
122
+ async def record_request(self, interaction_id: str, prompt: str) -> None:
123
+ pending = await self._pending_map()
124
+ pending[interaction_id] = {"id": interaction_id, "prompt": prompt}
125
+ await self._save_pending(pending)
126
+
127
+ async def complete_request(self, interaction_id: str) -> None:
128
+ pending = await self._pending_map()
129
+ pending.pop(interaction_id, None)
130
+ await self._save_pending(pending)
131
+
132
+ async def _pending_map(self) -> dict[str, dict]:
133
+ store = self._store()
134
+ pending = await store.get(self._pending_key, dict) or {}
135
+ if not pending:
136
+ return {}
137
+
138
+ unresolved = {}
139
+ for interaction_id, request in pending.items():
140
+ provided = await self._stored_input(interaction_id)
141
+ if provided is None:
142
+ unresolved[interaction_id] = request
143
+
144
+ await self._save_pending(unresolved)
145
+ return dict(unresolved)
146
+
147
+ async def _save_pending(self, pending: dict[str, dict]) -> None:
148
+ store = self._store()
149
+ if pending:
150
+ await store.set(self._pending_key, pending, dict)
151
+ return
152
+
153
+ await store.delete(self._pending_key)
154
+
155
+ async def _stored_input(self, interaction_id: str) -> str | None:
156
+ return await self._store().get(self._input_key(interaction_id), str)
157
+
158
+ async def _store_input(self, interaction_id: str, input_text: str) -> None:
159
+ await self._store().set(self._input_key(interaction_id), input_text, str)
160
+
161
+ async def _queue_input(self, input_text: str) -> None:
162
+ store = self._store()
163
+ queue = await store.get(self._queued_key, list) or []
164
+ queue.append(input_text)
165
+ await store.set(self._queued_key, queue, list)
166
+
167
+ async def _pop_queued_input(self) -> str | None:
168
+ store = self._store()
169
+ queue = await store.get(self._queued_key, list)
170
+ if not queue:
171
+ return None
172
+
173
+ next_input = queue.pop(0)
174
+ if queue:
175
+ await store.set(self._queued_key, queue, list)
176
+ else:
177
+ await store.delete(self._queued_key)
178
+ return next_input
179
+
180
+ def _store(self) -> KeyValueStore:
181
+ store = self._active_store.get()
182
+ if store is None:
183
+ raise RuntimeError("Input channel is not bound to a store.")
184
+ return store
185
+
186
+ @property
187
+ def _pending_key(self) -> str:
188
+ return f"{self._namespace}/pending"
189
+
190
+ @property
191
+ def _queued_key(self) -> str:
192
+ return f"{self._namespace}/queued"
193
+
194
+ def _input_key(self, interaction_id: str) -> str:
195
+ return f"{self._namespace}/input/{interaction_id}"
196
+
197
+
198
+ def _to_input_text(input_value: str | list[str]) -> str:
199
+ if isinstance(input_value, str):
200
+ return input_value.strip()
201
+ return " ".join(input_value).strip()
@@ -0,0 +1,16 @@
1
+ from typing import Any, Protocol
2
+
3
+
4
+ class KeyValueStore(Protocol):
5
+ """Async key-value persistence required by the HTTP input state.
6
+
7
+ Structurally matches ``sefios.SessionStorage``, so a bound session storage
8
+ can be passed in directly; any other implementation with the same shape
9
+ works too.
10
+ """
11
+
12
+ async def get(self, key: str, type_hint: type) -> Any | None: ...
13
+
14
+ async def set(self, key: str, value: Any, type_hint: type) -> None: ...
15
+
16
+ async def delete(self, key: str) -> None: ...
@@ -0,0 +1,39 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ class UnknownSessionError(Exception):
5
+ """Raised when a requested HTTP session is not known."""
6
+
7
+ def __init__(self, session_id: str):
8
+ super().__init__(f"Unknown session: {session_id}")
9
+ self.session_id = session_id
10
+
11
+
12
+ class UnknownInputError(Exception):
13
+ """Raised when an input targets an unknown pending input."""
14
+
15
+ def __init__(self, interaction_id: str):
16
+ super().__init__(f"Unknown pending input: {interaction_id}")
17
+ self.interaction_id = interaction_id
18
+
19
+
20
+ class AmbiguousInputError(Exception):
21
+ """Raised when multiple pending inputs need an explicit reply target."""
22
+
23
+ def __init__(self, interaction_ids: list[str]):
24
+ super().__init__(
25
+ "Multiple pending inputs exist. Specify one with reply_to: "
26
+ + ", ".join(interaction_ids)
27
+ )
28
+ self.interaction_ids = interaction_ids
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class InputRequired(Exception):
33
+ """Raised when a session pauses to wait for external input."""
34
+
35
+ interaction_id: str
36
+ prompt: str
37
+
38
+ def __str__(self) -> str:
39
+ return f"Input required: {self.prompt}"
File without changes
@@ -0,0 +1,24 @@
1
+ from typing import Any
2
+
3
+ import pytest
4
+
5
+
6
+ class InMemoryKeyValueStore:
7
+ """A dict-backed KeyValueStore for exercising the input core."""
8
+
9
+ def __init__(self):
10
+ self._data: dict[str, Any] = {}
11
+
12
+ async def get(self, key: str, type_hint: type) -> Any | None:
13
+ return self._data.get(key)
14
+
15
+ async def set(self, key: str, value: Any, type_hint: type) -> None:
16
+ self._data[key] = value
17
+
18
+ async def delete(self, key: str) -> None:
19
+ self._data.pop(key, None)
20
+
21
+
22
+ @pytest.fixture
23
+ def kv_store() -> InMemoryKeyValueStore:
24
+ return InMemoryKeyValueStore()
@@ -0,0 +1,102 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+
5
+ from sefia.llm.events import LLMTokenReceived
6
+ from sefia_fastapi import SessionEvents, SSEEvent
7
+
8
+
9
+ class TestSSEEvent:
10
+ def test_names_are_the_wire_contract(self):
11
+ assert SSEEvent.TOKEN == "token"
12
+ assert SSEEvent.INPUT_REQUIRED == "input_required"
13
+ assert SSEEvent.OUTPUT == "output"
14
+ assert SSEEvent.COMPLETED == "completed"
15
+ assert SSEEvent.EXECUTION_FAILED == "execution_failed"
16
+
17
+
18
+ class TestPublish:
19
+ async def test_publish_without_subscribers_is_a_noop(self):
20
+ events = SessionEvents()
21
+
22
+ await events.publish("s1", "token", "hello")
23
+
24
+ async def test_subscriber_receives_published_events(self):
25
+ events = SessionEvents()
26
+
27
+ async with events._subscribe("s1") as queue:
28
+ await events.publish("s1", "token", "hello")
29
+
30
+ event = queue.get_nowait()
31
+
32
+ assert event.name == "token"
33
+ assert event.data == "hello"
34
+
35
+ async def test_events_are_scoped_to_their_session(self):
36
+ events = SessionEvents()
37
+
38
+ async with events._subscribe("s1") as queue:
39
+ await events.publish("other", "token", "hello")
40
+
41
+ assert queue.empty()
42
+
43
+ async def test_unsubscribed_queue_stops_receiving(self):
44
+ events = SessionEvents()
45
+
46
+ async with events._subscribe("s1") as queue:
47
+ pass
48
+ await events.publish("s1", "token", "late")
49
+
50
+ assert queue.empty()
51
+
52
+
53
+ class TestTokenHandler:
54
+ async def test_relays_tokens_to_the_session_stream(self):
55
+ events = SessionEvents()
56
+ handler = events.token_handler("s1")
57
+
58
+ async with events._subscribe("s1") as queue:
59
+ await handler.handle(LLMTokenReceived(token="hi"))
60
+
61
+ event = queue.get_nowait()
62
+
63
+ assert event.name == SSEEvent.TOKEN
64
+ assert event.data == "hi"
65
+
66
+
67
+ class TestResponse:
68
+ async def test_streams_published_events_as_sse(self):
69
+ events = SessionEvents()
70
+ response = events.response("s1")
71
+ assert response.media_type == "text/event-stream"
72
+
73
+ stream = response.body_iterator.__aiter__()
74
+ first_chunk = asyncio.ensure_future(stream.__anext__())
75
+ # Yield control so the stream subscribes before the publish below.
76
+ await asyncio.sleep(0)
77
+
78
+ await events.publish("s1", "completed", {"session_id": "s1"})
79
+
80
+ chunk = await asyncio.wait_for(first_chunk, timeout=1)
81
+ assert chunk == 'event: completed\ndata: {"session_id": "s1"}\n\n'
82
+
83
+ async def test_serializes_dataclass_payloads(self):
84
+ @dataclass(frozen=True)
85
+ class Payload:
86
+ interaction_id: str
87
+ created_at: datetime
88
+
89
+ events = SessionEvents()
90
+ response = events.response("s1")
91
+
92
+ stream = response.body_iterator.__aiter__()
93
+ first_chunk = asyncio.ensure_future(stream.__anext__())
94
+ await asyncio.sleep(0)
95
+
96
+ payload = Payload("x", datetime(2026, 7, 12, 6, 35, 48))
97
+ await events.publish("s1", "input_required", {"request": payload})
98
+
99
+ chunk = await asyncio.wait_for(first_chunk, timeout=1)
100
+ assert isinstance(chunk, str) # narrow str | bytes for the `in` checks
101
+ assert '"interaction_id": "x"' in chunk
102
+ assert '"created_at": "2026-07-12T06:35:48"' in chunk
@@ -0,0 +1,154 @@
1
+ """Mirrors the CLI input core tests in ``sefia_typer``.
2
+
3
+ The two packages implement the same semantics independently; keeping the test
4
+ suites aligned guards against unintentional drift.
5
+ """
6
+
7
+ import pytest
8
+ from sefia_fastapi import (
9
+ AmbiguousInputError,
10
+ InputChannel,
11
+ InputRequest,
12
+ UnknownInputError,
13
+ )
14
+
15
+
16
+ @pytest.fixture
17
+ def channel(kv_store):
18
+ channel = InputChannel()
19
+ with channel.use_store(kv_store):
20
+ yield channel
21
+
22
+
23
+ class TestBinding:
24
+ async def test_requires_bound_store(self):
25
+ channel = InputChannel()
26
+
27
+ with pytest.raises(RuntimeError):
28
+ await channel.pending()
29
+
30
+ def test_empty_namespace_raises(self):
31
+ with pytest.raises(ValueError, match="namespace"):
32
+ InputChannel(namespace="/")
33
+
34
+ async def test_namespace_scopes_persisted_keys(self, kv_store):
35
+ channel = InputChannel(namespace="custom/input")
36
+
37
+ with channel.use_store(kv_store):
38
+ await channel.record_request("x", "why?")
39
+ await channel.receive_input("answer", reply_to="x")
40
+
41
+ assert "custom/input/pending" in kv_store._data
42
+ assert kv_store._data["custom/input/input/x"] == "answer"
43
+ assert "input_channel/pending" not in kv_store._data
44
+
45
+
46
+ class TestPending:
47
+ async def test_empty_by_default(self, channel):
48
+ assert await channel.pending() == []
49
+
50
+ async def test_recorded_request_is_pending(self, channel):
51
+ await channel.record_request("a", "prompt a?")
52
+
53
+ assert await channel.pending() == [
54
+ InputRequest(interaction_id="a", prompt="prompt a?")
55
+ ]
56
+
57
+ async def test_pending_is_ordered_by_interaction_id(self, channel):
58
+ await channel.record_request("b", "prompt b?")
59
+ await channel.record_request("a", "prompt a?")
60
+
61
+ pending = await channel.pending()
62
+
63
+ assert [request.interaction_id for request in pending] == ["a", "b"]
64
+
65
+ async def test_resolved_requests_are_dropped_from_pending(self, channel):
66
+ await channel.record_request("a", "prompt a?")
67
+ await channel.record_request("b", "prompt b?")
68
+
69
+ await channel.receive_input("resolved", reply_to="a")
70
+
71
+ pending = await channel.pending()
72
+ assert [request.interaction_id for request in pending] == ["b"]
73
+
74
+ async def test_complete_request_removes_pending(self, channel):
75
+ await channel.record_request("x", "why?")
76
+
77
+ await channel.complete_request("x")
78
+
79
+ assert await channel.pending() == []
80
+
81
+
82
+ class TestReceiveInput:
83
+ async def test_none_input_is_ignored(self, channel):
84
+ await channel.receive_input(None)
85
+
86
+ assert await channel.provide_input("any") is None
87
+
88
+ async def test_blank_input_is_ignored(self, channel):
89
+ await channel.receive_input(" ")
90
+
91
+ assert await channel.provide_input("any") is None
92
+
93
+ async def test_list_input_is_joined(self, channel):
94
+ await channel.receive_input(["hello", "world"])
95
+
96
+ assert await channel.provide_input("any") == "hello world"
97
+
98
+ async def test_input_is_queued_when_nothing_pending(self, channel):
99
+ await channel.receive_input("hello")
100
+
101
+ assert await channel.provide_input("any") == "hello"
102
+
103
+ async def test_single_pending_request_is_resolved(self, channel):
104
+ await channel.record_request("only", "prompt?")
105
+
106
+ await channel.receive_input("the input")
107
+
108
+ assert await channel.provide_input("only") == "the input"
109
+
110
+ async def test_multiple_pending_requires_reply_to(self, channel):
111
+ await channel.record_request("a", "prompt a?")
112
+ await channel.record_request("b", "prompt b?")
113
+
114
+ with pytest.raises(AmbiguousInputError) as exc_info:
115
+ await channel.receive_input("ambiguous")
116
+
117
+ assert sorted(exc_info.value.interaction_ids) == ["a", "b"]
118
+
119
+ async def test_reply_to_targets_specific_request(self, channel):
120
+ await channel.record_request("a", "prompt a?")
121
+ await channel.record_request("b", "prompt b?")
122
+
123
+ await channel.receive_input("for b", reply_to="b")
124
+
125
+ assert await channel.provide_input("b") == "for b"
126
+
127
+ async def test_reply_to_unknown_request_raises(self, channel):
128
+ await channel.record_request("a", "prompt a?")
129
+
130
+ with pytest.raises(UnknownInputError) as exc_info:
131
+ await channel.receive_input("oops", reply_to="missing")
132
+
133
+ assert exc_info.value.interaction_id == "missing"
134
+
135
+
136
+ class TestProvideInput:
137
+ async def test_returns_none_when_nothing_available(self, channel):
138
+ assert await channel.provide_input("x") is None
139
+
140
+ async def test_queued_inputs_are_claimed_in_order(self, channel):
141
+ await channel.receive_input("first")
142
+ await channel.receive_input("second")
143
+
144
+ assert await channel.provide_input("i1") == "first"
145
+ assert await channel.provide_input("i2") == "second"
146
+ assert await channel.provide_input("i3") is None
147
+
148
+ async def test_does_not_claim_queue_with_other_pending(self, channel):
149
+ await channel.receive_input("queued")
150
+ await channel.record_request("other", "other prompt?")
151
+
152
+ assert await channel.provide_input("mine") is None
153
+ # The queued input is left for the pending request.
154
+ assert await channel.provide_input("other") == "queued"