zettcode-server 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.
- zettcode_server/__init__.py +33 -0
- zettcode_server/app.py +580 -0
- zettcode_server/cli.py +114 -0
- zettcode_server/runner.py +472 -0
- zettcode_server/schemas.py +555 -0
- zettcode_server/serialize.py +292 -0
- zettcode_server/settings.py +58 -0
- zettcode_server-0.1.0.dist-info/METADATA +66 -0
- zettcode_server-0.1.0.dist-info/RECORD +11 -0
- zettcode_server-0.1.0.dist-info/WHEEL +4 -0
- zettcode_server-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""ZettCode behind an HTTP API: the coding agent, driven by `zettcode.sdk`, one workspace per process.
|
|
2
|
+
|
|
3
|
+
The server is a thin layer: an endpoint says what to ask the agent and how to let a run answer for itself, and the
|
|
4
|
+
SDK does the work. It owns no model loop, no tools, and no session format, so a session begun here is one the
|
|
5
|
+
terminal can open afterwards, and the other way round.
|
|
6
|
+
|
|
7
|
+
The application is built by :func:`create_app`, and served by the ``zettcode-server`` program.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only; importing the app costs FastAPI
|
|
17
|
+
from .app import create_app
|
|
18
|
+
from .settings import Settings
|
|
19
|
+
|
|
20
|
+
__all__ = ["Settings", "__version__", "create_app"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def __getattr__(name: str) -> Any:
|
|
24
|
+
"""Load the application on first use, so ``--version`` does not import FastAPI."""
|
|
25
|
+
if name == "create_app":
|
|
26
|
+
from .app import create_app
|
|
27
|
+
|
|
28
|
+
return create_app
|
|
29
|
+
if name == "Settings":
|
|
30
|
+
from .settings import Settings
|
|
31
|
+
|
|
32
|
+
return Settings
|
|
33
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
zettcode_server/app.py
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""The FastAPI application: the endpoints, the error shapes, and the one run in progress.
|
|
2
|
+
|
|
3
|
+
Every endpoint is a thin move on the SDK. What this module owns is the contract: which status a failure becomes, what
|
|
4
|
+
a stream frame looks like, and that a second prompt while one runs is refused rather than queued.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated, Any
|
|
17
|
+
|
|
18
|
+
from fastapi import APIRouter, Depends, FastAPI, Header, Query, Request
|
|
19
|
+
from fastapi.exceptions import RequestValidationError
|
|
20
|
+
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
21
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
22
|
+
|
|
23
|
+
from zettcode.sdk.errors import (
|
|
24
|
+
ConfigurationError,
|
|
25
|
+
NothingToDoError,
|
|
26
|
+
RunError,
|
|
27
|
+
RunTimeoutError,
|
|
28
|
+
SessionBusyError,
|
|
29
|
+
SessionLockedError,
|
|
30
|
+
SessionNotFoundError,
|
|
31
|
+
ZettCodeError,
|
|
32
|
+
)
|
|
33
|
+
from zettcode.sdk.models import add_model as add_model_entry
|
|
34
|
+
from zettcode.sdk.models import remove_model as remove_model_entry
|
|
35
|
+
from zettcode.sdk.models import update_model as update_model_entry
|
|
36
|
+
|
|
37
|
+
from . import __version__
|
|
38
|
+
from .runner import NoModelError, Runner, prepare, result_payload
|
|
39
|
+
from .runner import Request as RunRequest
|
|
40
|
+
from .schemas import (
|
|
41
|
+
AnswerOut,
|
|
42
|
+
ApprovalAnswer,
|
|
43
|
+
ApprovalPolicy,
|
|
44
|
+
CancelledOut,
|
|
45
|
+
CompactionOut,
|
|
46
|
+
ContextOut,
|
|
47
|
+
CurrentSessionOut,
|
|
48
|
+
DoctorOut,
|
|
49
|
+
DoctorRequest,
|
|
50
|
+
ErrorOut,
|
|
51
|
+
HealthOut,
|
|
52
|
+
HistoryOut,
|
|
53
|
+
MentionsOut,
|
|
54
|
+
ModelCreate,
|
|
55
|
+
ModelsOut,
|
|
56
|
+
ModelUpdate,
|
|
57
|
+
PluginsOut,
|
|
58
|
+
PromptRequest,
|
|
59
|
+
QuestionAnswer,
|
|
60
|
+
RenameRequest,
|
|
61
|
+
ResolvedOut,
|
|
62
|
+
ResumableOut,
|
|
63
|
+
ResumeRequest,
|
|
64
|
+
RunStateOut,
|
|
65
|
+
SelfCompactionOut,
|
|
66
|
+
SelfCompactionUpdate,
|
|
67
|
+
SessionCreate,
|
|
68
|
+
SessionOut,
|
|
69
|
+
SessionsOut,
|
|
70
|
+
SessionStatsListOut,
|
|
71
|
+
SessionStatsOut,
|
|
72
|
+
SettingsOut,
|
|
73
|
+
SettingsUpdate,
|
|
74
|
+
SideQuestionRequest,
|
|
75
|
+
SteeredOut,
|
|
76
|
+
SteerIn,
|
|
77
|
+
UsageOut,
|
|
78
|
+
WorkspaceUsageOut,
|
|
79
|
+
)
|
|
80
|
+
from .serialize import (
|
|
81
|
+
check_out,
|
|
82
|
+
compaction_out,
|
|
83
|
+
context_out,
|
|
84
|
+
current_session_out,
|
|
85
|
+
ledger_out,
|
|
86
|
+
mention_out,
|
|
87
|
+
message_out,
|
|
88
|
+
model_out,
|
|
89
|
+
session_out,
|
|
90
|
+
stats_out,
|
|
91
|
+
usage_out,
|
|
92
|
+
)
|
|
93
|
+
from .settings import Settings
|
|
94
|
+
|
|
95
|
+
#: Builds the client a server runs on; a test passes a stand-in that answers without a model.
|
|
96
|
+
ClientFactory = Callable[[], Any]
|
|
97
|
+
|
|
98
|
+
#: The exceptions that become a client error, and the code and status each one gets.
|
|
99
|
+
FAILURES: tuple[tuple[type[BaseException], int, str], ...] = (
|
|
100
|
+
(SessionNotFoundError, 404, "not_found"),
|
|
101
|
+
(SessionLockedError, 409, "locked"),
|
|
102
|
+
(SessionBusyError, 409, "busy"),
|
|
103
|
+
(NothingToDoError, 409, "nothing_to_do"),
|
|
104
|
+
(NoModelError, 409, "no_model"),
|
|
105
|
+
(ConfigurationError, 400, "invalid"),
|
|
106
|
+
(RunTimeoutError, 504, "timeout"),
|
|
107
|
+
(RunError, 502, "run"),
|
|
108
|
+
(ZettCodeError, 500, "internal"),
|
|
109
|
+
(ValueError, 400, "invalid"),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def failure_of(error: BaseException) -> tuple[int, str]:
|
|
114
|
+
"""Return the status and the error code for an exception raised by an endpoint."""
|
|
115
|
+
for kind, status, code in FAILURES:
|
|
116
|
+
if isinstance(error, kind):
|
|
117
|
+
return status, code
|
|
118
|
+
return 500, "internal"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def error_body(code: str, message: str) -> dict[str, str]:
|
|
122
|
+
"""Return the one shape a failure is written in."""
|
|
123
|
+
return ErrorOut(error=code, message=message).model_dump()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def sse_frame(event: str, payload: Mapping[str, object]) -> bytes:
|
|
127
|
+
"""Return one server-sent event: its name, and its JSON on one ``data:`` line."""
|
|
128
|
+
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
129
|
+
return f"event: {event}\ndata: {body}\n\n".encode()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def stream_of(runner: Runner, request: RunRequest, *, resume: bool = False) -> StreamingResponse:
|
|
133
|
+
"""Start a run and return the SSE response; the checks that deserve a status code happen before it opens.
|
|
134
|
+
|
|
135
|
+
A body that cannot run (a command the approval rule refuses to match, an image that is not base64) is refused with
|
|
136
|
+
a 4xx here, while a status line is still possible. What fails once the stream is open becomes an ``error`` frame,
|
|
137
|
+
because by then the 200 has gone out.
|
|
138
|
+
"""
|
|
139
|
+
if runner.busy:
|
|
140
|
+
raise SessionBusyError("A run is already in progress; wait for it or cancel it")
|
|
141
|
+
if not runner.ready:
|
|
142
|
+
raise NoModelError("No model is configured; add one with POST /models")
|
|
143
|
+
prepare(request)
|
|
144
|
+
|
|
145
|
+
async def frames() -> AsyncIterator[bytes]:
|
|
146
|
+
try:
|
|
147
|
+
async for name, payload in runner.stream(request, resume=resume):
|
|
148
|
+
yield sse_frame(name, payload)
|
|
149
|
+
except Exception as error: # noqa: BLE001 - the status line is gone; the failure goes down the stream
|
|
150
|
+
_, code = failure_of(error)
|
|
151
|
+
yield sse_frame("error", error_body(code, str(error)))
|
|
152
|
+
|
|
153
|
+
return StreamingResponse(
|
|
154
|
+
frames(),
|
|
155
|
+
media_type="text/event-stream",
|
|
156
|
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def runner_of(request: Request) -> Runner:
|
|
161
|
+
"""Return the runner the server's lifespan put on the application."""
|
|
162
|
+
return request.app.state.runner
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
#: The runner, as the dependency of every endpoint that needs the agent. It is written at module level because
|
|
166
|
+
#: the endpoint signatures are strings (``from __future__ import annotations``) and FastAPI resolves them there.
|
|
167
|
+
RunnerDep = Annotated[Runner, Depends(runner_of)]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def create_app(settings: Settings, *, client_factory: ClientFactory | None = None) -> FastAPI:
|
|
171
|
+
"""Build the application for one workspace.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
settings: The workspace, the socket it will be served on, the token and how a run starts.
|
|
175
|
+
client_factory: Builds the client; the real SDK client on the workspace unless a test supplies one.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
@asynccontextmanager
|
|
179
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
180
|
+
runner = Runner(settings, client_factory)
|
|
181
|
+
await runner.start()
|
|
182
|
+
app.state.runner = runner
|
|
183
|
+
try:
|
|
184
|
+
yield
|
|
185
|
+
finally:
|
|
186
|
+
await runner.close()
|
|
187
|
+
|
|
188
|
+
app = FastAPI(
|
|
189
|
+
title="ZettCode server",
|
|
190
|
+
version=__version__,
|
|
191
|
+
summary="The ZettCode coding agent over HTTP: prompts streamed as they run, sessions, models, usage.",
|
|
192
|
+
lifespan=lifespan,
|
|
193
|
+
)
|
|
194
|
+
app.state.settings = settings
|
|
195
|
+
|
|
196
|
+
async def authorize(authorization: Annotated[str | None, Header()] = None) -> None:
|
|
197
|
+
"""Require the bearer token, when one was configured, on everything but ``/health``."""
|
|
198
|
+
if settings.token is None:
|
|
199
|
+
return
|
|
200
|
+
scheme, _, given = (authorization or "").partition(" ")
|
|
201
|
+
if scheme.lower() != "bearer" or not hmac.compare_digest(given.strip(), settings.token):
|
|
202
|
+
raise StarletteHTTPException(
|
|
203
|
+
status_code=401, detail="A bearer token is required", headers={"WWW-Authenticate": "Bearer"}
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
protected = APIRouter(dependencies=[Depends(authorize)])
|
|
207
|
+
|
|
208
|
+
@app.get("/", include_in_schema=False)
|
|
209
|
+
async def index() -> dict[str, str]:
|
|
210
|
+
"""Say what this is and where the rest is, for a browser pointed at the port."""
|
|
211
|
+
return {
|
|
212
|
+
"name": "ZettCode server",
|
|
213
|
+
"version": __version__,
|
|
214
|
+
"docs": "/docs",
|
|
215
|
+
"health": "/health",
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@app.get("/health", response_model=HealthOut, tags=["health"])
|
|
219
|
+
async def health(runner: RunnerDep) -> HealthOut:
|
|
220
|
+
"""Say that the server is up, whether it has a model to work with, and which session it serves."""
|
|
221
|
+
client = runner.maybe_client
|
|
222
|
+
models = client.models if client is not None else runner.models_now()
|
|
223
|
+
return HealthOut(
|
|
224
|
+
version=__version__,
|
|
225
|
+
workspace=str(settings.workspace),
|
|
226
|
+
ready=client is not None,
|
|
227
|
+
session=None if client is None else client.session.id,
|
|
228
|
+
models=len(models),
|
|
229
|
+
busy=runner.busy,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@protected.get("/models", response_model=ModelsOut, tags=["models"])
|
|
233
|
+
async def models(runner: RunnerDep) -> ModelsOut:
|
|
234
|
+
"""List the configured models and say which one the next prompt would use.
|
|
235
|
+
|
|
236
|
+
This works before a model is configured: the list then comes from the configuration file, and ``current`` is
|
|
237
|
+
null until one is added.
|
|
238
|
+
"""
|
|
239
|
+
return ModelsOut(models=[model_out(model) for model in runner.models_now()], current=runner.current_model)
|
|
240
|
+
|
|
241
|
+
@protected.post("/models", response_model=ModelsOut, status_code=201, tags=["models"])
|
|
242
|
+
async def add_model(body: ModelCreate, runner: RunnerDep) -> ModelsOut:
|
|
243
|
+
"""Add a model to the configuration file, and use it when the server had none.
|
|
244
|
+
|
|
245
|
+
The first model is what turns a fresh server into a working one: the client is built as soon as the file
|
|
246
|
+
holds it. A model added while another is in use is available for ``PATCH /settings``, and the conversation
|
|
247
|
+
carries on with the one it was using.
|
|
248
|
+
"""
|
|
249
|
+
if runner.busy:
|
|
250
|
+
raise SessionBusyError("A run is in progress; change the models when it ends")
|
|
251
|
+
added = await asyncio.to_thread(add_model_entry, body.entry(), path=settings.config_path)
|
|
252
|
+
if not runner.ready:
|
|
253
|
+
await runner.reload(model=added[-1].name)
|
|
254
|
+
return ModelsOut(models=[model_out(model) for model in runner.models_now()], current=runner.current_model)
|
|
255
|
+
|
|
256
|
+
@protected.patch("/models/{name}", response_model=ModelsOut, tags=["models"])
|
|
257
|
+
async def change_model(name: str, body: ModelUpdate, runner: RunnerDep) -> ModelsOut:
|
|
258
|
+
"""Change one model in the configuration file; the token is kept unless a new one is given."""
|
|
259
|
+
if runner.busy:
|
|
260
|
+
raise SessionBusyError("A run is in progress; change the models when it ends")
|
|
261
|
+
changes = body.changes()
|
|
262
|
+
if not changes:
|
|
263
|
+
raise ValueError("Name at least one field to change")
|
|
264
|
+
await asyncio.to_thread(update_model_entry, name, changes, path=settings.config_path)
|
|
265
|
+
await runner.reload(model=runner.current_model)
|
|
266
|
+
return ModelsOut(models=[model_out(model) for model in runner.models_now()], current=runner.current_model)
|
|
267
|
+
|
|
268
|
+
@protected.delete("/models/{name}", response_model=ModelsOut, tags=["models"])
|
|
269
|
+
async def delete_model(name: str, runner: RunnerDep) -> ModelsOut:
|
|
270
|
+
"""Remove one model from the configuration file; the last one cannot go, since a client needs one."""
|
|
271
|
+
if runner.busy:
|
|
272
|
+
raise SessionBusyError("A run is in progress; change the models when it ends")
|
|
273
|
+
await asyncio.to_thread(remove_model_entry, name, path=settings.config_path)
|
|
274
|
+
await runner.reload()
|
|
275
|
+
return ModelsOut(models=[model_out(model) for model in runner.models_now()], current=runner.current_model)
|
|
276
|
+
|
|
277
|
+
@protected.get("/settings", response_model=SettingsOut, tags=["models"])
|
|
278
|
+
async def settings_out(runner: RunnerDep) -> SettingsOut:
|
|
279
|
+
"""Return the model, effort and permission a run starts with."""
|
|
280
|
+
client = runner.client
|
|
281
|
+
return SettingsOut(
|
|
282
|
+
model=model_out(client.model),
|
|
283
|
+
effort=client.effort,
|
|
284
|
+
efforts=list(client.efforts),
|
|
285
|
+
permission=client.permission,
|
|
286
|
+
permissions=list(client.permissions),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
@protected.patch("/settings", response_model=SettingsOut, tags=["models"])
|
|
290
|
+
async def change_settings(update: SettingsUpdate, runner: RunnerDep) -> SettingsOut:
|
|
291
|
+
"""Change the model, the reasoning effort, the permission level or the compaction point for the prompts that follow."""
|
|
292
|
+
if (
|
|
293
|
+
update.model is None
|
|
294
|
+
and update.effort is None
|
|
295
|
+
and update.permission is None
|
|
296
|
+
and update.compact_percent is None
|
|
297
|
+
):
|
|
298
|
+
raise ValueError("Name at least one of model, effort, permission or compact_percent")
|
|
299
|
+
client = runner.client
|
|
300
|
+
if update.model is not None:
|
|
301
|
+
client.use_model(update.model)
|
|
302
|
+
if update.effort is not None:
|
|
303
|
+
client.use_effort(update.effort)
|
|
304
|
+
if update.permission is not None:
|
|
305
|
+
client.use_permission(update.permission)
|
|
306
|
+
unsaved: str | None = None
|
|
307
|
+
if update.compact_percent is not None:
|
|
308
|
+
if runner.busy:
|
|
309
|
+
raise SessionBusyError("A run is in progress; move the compaction point when it ends")
|
|
310
|
+
_, unsaved = await asyncio.to_thread(client.use_compact_percent, update.compact_percent)
|
|
311
|
+
current = await settings_out(runner)
|
|
312
|
+
return current.model_copy(update={"unsaved": unsaved}) if unsaved else current
|
|
313
|
+
|
|
314
|
+
@protected.get("/sessions", response_model=SessionsOut, tags=["sessions"])
|
|
315
|
+
async def list_sessions(runner: RunnerDep, limit: Annotated[int, Query(ge=1, le=200)] = 20) -> SessionsOut:
|
|
316
|
+
"""List the stored sessions of this workspace, most recently active first."""
|
|
317
|
+
client = runner.client
|
|
318
|
+
stored = await client.sessions.list(limit=limit)
|
|
319
|
+
return SessionsOut(sessions=[session_out(item, client.session.id) for item in stored])
|
|
320
|
+
|
|
321
|
+
@protected.post("/sessions", response_model=SessionOut, status_code=201, tags=["sessions"])
|
|
322
|
+
async def new_session(runner: RunnerDep, body: SessionCreate | None = None) -> SessionOut:
|
|
323
|
+
"""Start an empty conversation and make it the one the next prompt continues."""
|
|
324
|
+
client = runner.client
|
|
325
|
+
session = client.sessions.new()
|
|
326
|
+
if body is not None and body.title:
|
|
327
|
+
await session.rename(body.title)
|
|
328
|
+
return SessionOut(id=session.id, title=session.title, current=True)
|
|
329
|
+
|
|
330
|
+
@protected.get("/sessions/current", response_model=CurrentSessionOut, tags=["sessions"])
|
|
331
|
+
async def current_session(runner: RunnerDep) -> CurrentSessionOut:
|
|
332
|
+
"""Return the conversation the next prompt continues, and what it has spent."""
|
|
333
|
+
return current_session_out(runner.client.session)
|
|
334
|
+
|
|
335
|
+
@protected.post("/sessions/{session_id}/open", response_model=CurrentSessionOut, tags=["sessions"])
|
|
336
|
+
async def open_session(session_id: str, runner: RunnerDep) -> CurrentSessionOut:
|
|
337
|
+
"""Continue a stored conversation: the next prompt goes to it."""
|
|
338
|
+
session = await runner.client.sessions.open(session_id)
|
|
339
|
+
return current_session_out(session)
|
|
340
|
+
|
|
341
|
+
@protected.post("/sessions/current/rename", response_model=SessionOut, tags=["sessions"])
|
|
342
|
+
async def rename_session(body: RenameRequest, runner: RunnerDep) -> SessionOut:
|
|
343
|
+
"""Name the current conversation."""
|
|
344
|
+
session = runner.client.session
|
|
345
|
+
title = await session.rename(body.title)
|
|
346
|
+
return SessionOut(id=session.id, title=title, current=True)
|
|
347
|
+
|
|
348
|
+
@protected.post("/sessions/current/fork", response_model=CurrentSessionOut, tags=["sessions"])
|
|
349
|
+
async def fork_session(runner: RunnerDep) -> CurrentSessionOut:
|
|
350
|
+
"""Carry this conversation into a new session and continue there."""
|
|
351
|
+
return current_session_out(await runner.client.session.fork())
|
|
352
|
+
|
|
353
|
+
@protected.post("/sessions/current/compact", response_model=CompactionOut, tags=["sessions"])
|
|
354
|
+
async def compact_session(runner: RunnerDep) -> CompactionOut:
|
|
355
|
+
"""Summarize the conversation now, instead of waiting for the window to fill."""
|
|
356
|
+
return compaction_out(await runner.client.session.compact())
|
|
357
|
+
|
|
358
|
+
@protected.get("/sessions/current/context", response_model=ContextOut | None, tags=["sessions"])
|
|
359
|
+
async def context_of_session(runner: RunnerDep) -> ContextOut | None:
|
|
360
|
+
"""Return how full the context window is, or ``null`` before a request has been measured."""
|
|
361
|
+
report = await runner.client.session.context()
|
|
362
|
+
return None if report is None else context_out(report)
|
|
363
|
+
|
|
364
|
+
@protected.get("/sessions/current/history", response_model=HistoryOut, tags=["sessions"])
|
|
365
|
+
async def history_of_session(runner: RunnerDep, full: bool = False) -> HistoryOut:
|
|
366
|
+
"""Return the conversation of the current session, oldest message first.
|
|
367
|
+
|
|
368
|
+
By default these are the messages the model is working with. `full=true` returns the whole conversation as it was
|
|
369
|
+
written, with each compaction's summary (role `summary`) where it was made.
|
|
370
|
+
"""
|
|
371
|
+
messages = await runner.client.session.history(full=full)
|
|
372
|
+
return HistoryOut(messages=[message_out(message) for message in messages])
|
|
373
|
+
|
|
374
|
+
@protected.get("/usage", response_model=UsageOut, tags=["usage"])
|
|
375
|
+
async def usage_of_session(runner: RunnerDep) -> UsageOut:
|
|
376
|
+
"""Return what this session has spent, in this process and before it was resumed."""
|
|
377
|
+
return usage_out(runner.client.usage)
|
|
378
|
+
|
|
379
|
+
@protected.get("/usage/ledger", response_model=WorkspaceUsageOut, tags=["usage"])
|
|
380
|
+
async def usage_ledger(runner: RunnerDep) -> WorkspaceUsageOut:
|
|
381
|
+
"""Return what the workspace's stored sessions have spent: by session, model and day.
|
|
382
|
+
|
|
383
|
+
This is the ``/usage`` page's data, read back from the conversations, so it covers every session of the
|
|
384
|
+
workspace and not only this one.
|
|
385
|
+
"""
|
|
386
|
+
return ledger_out(await runner.usage_ledger())
|
|
387
|
+
|
|
388
|
+
@protected.get("/sessions/current/stats", response_model=SessionStatsOut | None, tags=["sessions"])
|
|
389
|
+
async def stats_of_session(runner: RunnerDep) -> SessionStatsOut | None:
|
|
390
|
+
"""Return the stored totals of the current session: entries, replies, tokens and the cached share."""
|
|
391
|
+
found = await runner.session_stats(None)
|
|
392
|
+
return stats_out(found[0]) if found else None
|
|
393
|
+
|
|
394
|
+
@protected.get("/sessions/stats", response_model=SessionStatsListOut, tags=["sessions"])
|
|
395
|
+
async def stats_of_sessions(
|
|
396
|
+
runner: RunnerDep, ids: Annotated[str, Query(description="The session ids, comma separated")] = ""
|
|
397
|
+
) -> SessionStatsListOut:
|
|
398
|
+
"""Return the stored totals of named sessions, as the terminal's ``/resume`` panel lists its columns."""
|
|
399
|
+
wanted = [part.strip() for part in ids.split(",") if part.strip()][:200]
|
|
400
|
+
return SessionStatsListOut(stats=[stats_out(item) for item in await runner.session_stats(wanted)])
|
|
401
|
+
|
|
402
|
+
@protected.get("/sessions/current/self-compaction", response_model=SelfCompactionOut, tags=["sessions"])
|
|
403
|
+
async def self_compaction(runner: RunnerDep) -> SelfCompactionOut:
|
|
404
|
+
"""Whether the model may compact this session itself, and the session's own choice."""
|
|
405
|
+
choice, effective = runner.self_compaction()
|
|
406
|
+
return SelfCompactionOut(choice=choice, effective=effective)
|
|
407
|
+
|
|
408
|
+
@protected.put("/sessions/current/self-compaction", response_model=SelfCompactionOut, tags=["sessions"])
|
|
409
|
+
async def set_self_compaction(body: SelfCompactionUpdate, runner: RunnerDep) -> SelfCompactionOut:
|
|
410
|
+
"""Let the model compact this session itself (``true``), stop it (``false``), or follow the default (``null``)."""
|
|
411
|
+
effective = await runner.use_self_compaction(body.choice)
|
|
412
|
+
return SelfCompactionOut(choice=body.choice, effective=effective)
|
|
413
|
+
|
|
414
|
+
@protected.post("/prompts", tags=["prompts"], responses={200: {"content": {"text/event-stream": {}}}})
|
|
415
|
+
async def prompts(request: PromptRequest, runner: RunnerDep) -> StreamingResponse:
|
|
416
|
+
"""Run one prompt and stream it as server-sent events; see the guide for the frames.
|
|
417
|
+
|
|
418
|
+
The stream ends with ``result`` when the model finished, ``error`` when the run failed, and ``cancelled`` when
|
|
419
|
+
it was stopped; closing the connection stops the run. An approval or question the request defers arrives as a
|
|
420
|
+
frame and is answered through ``/runs/approvals`` or ``/runs/questions``.
|
|
421
|
+
"""
|
|
422
|
+
return stream_of(runner, request)
|
|
423
|
+
|
|
424
|
+
@protected.post("/runs/resume", tags=["prompts"], responses={200: {"content": {"text/event-stream": {}}}})
|
|
425
|
+
async def resume_run(request: ResumeRequest, runner: RunnerDep) -> StreamingResponse:
|
|
426
|
+
"""Continue the interrupted request where it stopped, streaming it like ``/prompts``.
|
|
427
|
+
|
|
428
|
+
``409 nothing_to_do`` (as an ``error`` frame) when there is nothing to pick up.
|
|
429
|
+
"""
|
|
430
|
+
return stream_of(runner, request, resume=True)
|
|
431
|
+
|
|
432
|
+
@protected.get("/runs/current", response_model=RunStateOut, tags=["prompts"])
|
|
433
|
+
async def run_state(runner: RunnerDep) -> RunStateOut:
|
|
434
|
+
"""Say whether a run is going and what it waits on, so a reconnecting client can pick it up."""
|
|
435
|
+
return RunStateOut(running=runner.busy, pending=runner.pending)
|
|
436
|
+
|
|
437
|
+
@protected.get("/runs/resumable", response_model=ResumableOut, tags=["prompts"])
|
|
438
|
+
async def resumable(runner: RunnerDep) -> ResumableOut:
|
|
439
|
+
"""Say where the conversation was interrupted, or that it was not."""
|
|
440
|
+
info = await runner.resumable()
|
|
441
|
+
if info is None:
|
|
442
|
+
return ResumableOut(resumable=False)
|
|
443
|
+
return ResumableOut(resumable=True, kind=info.kind, description=info.description)
|
|
444
|
+
|
|
445
|
+
@protected.post("/runs/approvals", response_model=ResolvedOut, tags=["prompts"])
|
|
446
|
+
async def answer_approval(body: ApprovalAnswer, runner: RunnerDep) -> ResolvedOut:
|
|
447
|
+
"""Approve or deny the shell command the run waits on."""
|
|
448
|
+
resolved = await runner.resolve_approval(body.call_id, body.approve, body.remember)
|
|
449
|
+
return ResolvedOut(resolved=resolved)
|
|
450
|
+
|
|
451
|
+
@protected.post("/runs/questions", response_model=ResolvedOut, tags=["prompts"])
|
|
452
|
+
async def answer_question(body: QuestionAnswer, runner: RunnerDep) -> ResolvedOut:
|
|
453
|
+
"""Answer the question the model asked, or decline it with ``null``."""
|
|
454
|
+
return ResolvedOut(resolved=await runner.resolve_question(body.call_id, body.answer))
|
|
455
|
+
|
|
456
|
+
@protected.post("/runs/steer", response_model=SteeredOut, tags=["prompts"])
|
|
457
|
+
async def steer(body: SteerIn, runner: RunnerDep) -> SteeredOut:
|
|
458
|
+
"""Send a message into the run in progress; it drops what it was doing to take it into account."""
|
|
459
|
+
return SteeredOut(steered=runner.steer(body.text))
|
|
460
|
+
|
|
461
|
+
@protected.post("/runs/cancel", response_model=CancelledOut, tags=["prompts"])
|
|
462
|
+
async def cancel_run(runner: RunnerDep) -> CancelledOut:
|
|
463
|
+
"""Stop the run in progress, if there is one; its stream ends with ``cancelled``."""
|
|
464
|
+
return CancelledOut(cancelled=await runner.cancel())
|
|
465
|
+
|
|
466
|
+
@protected.post("/sessions/current/ask", response_model=AnswerOut, tags=["sessions"])
|
|
467
|
+
async def ask_side_question(body: SideQuestionRequest, runner: RunnerDep) -> AnswerOut:
|
|
468
|
+
"""Ask something answered from the conversation but kept out of it, as ``/btw`` does."""
|
|
469
|
+
if runner.busy:
|
|
470
|
+
raise SessionBusyError("A run is already in progress; ask again when it ends")
|
|
471
|
+
return AnswerOut(answer=await runner.client.session.ask(body.question, timeout=body.timeout))
|
|
472
|
+
|
|
473
|
+
@protected.post(
|
|
474
|
+
"/sessions/current/ask/stream", tags=["sessions"], responses={200: {"content": {"text/event-stream": {}}}}
|
|
475
|
+
)
|
|
476
|
+
async def ask_side_question_stream(body: SideQuestionRequest, runner: RunnerDep) -> StreamingResponse:
|
|
477
|
+
"""Ask a side question and stream the answer as it is written, in the frames ``/prompts`` uses.
|
|
478
|
+
|
|
479
|
+
The answer arrives as ``text_delta`` events and the stream ends with ``result``, ``error`` or ``cancelled``;
|
|
480
|
+
``/runs/cancel`` stops it. Like the plain endpoint, nothing of it joins the conversation.
|
|
481
|
+
"""
|
|
482
|
+
if runner.busy:
|
|
483
|
+
raise SessionBusyError("A run is already in progress; ask again when it ends")
|
|
484
|
+
if not runner.ready:
|
|
485
|
+
raise NoModelError("No model is configured; add one with POST /models")
|
|
486
|
+
|
|
487
|
+
async def frames() -> AsyncIterator[bytes]:
|
|
488
|
+
try:
|
|
489
|
+
async for name, payload in runner.ask_stream(body.question, body.timeout):
|
|
490
|
+
yield sse_frame(name, payload)
|
|
491
|
+
except Exception as error: # noqa: BLE001 - the status line is gone; the failure goes down the stream
|
|
492
|
+
_, code = failure_of(error)
|
|
493
|
+
yield sse_frame("error", error_body(code, str(error)))
|
|
494
|
+
|
|
495
|
+
return StreamingResponse(
|
|
496
|
+
frames(),
|
|
497
|
+
media_type="text/event-stream",
|
|
498
|
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
@protected.post("/sessions/current/name", response_model=SessionOut, tags=["sessions"])
|
|
502
|
+
async def name_session(runner: RunnerDep) -> SessionOut:
|
|
503
|
+
"""Ask the model for a name for this conversation, and give it one."""
|
|
504
|
+
session = runner.client.session
|
|
505
|
+
title = await session.name_it()
|
|
506
|
+
return SessionOut(id=session.id, title=title, current=True)
|
|
507
|
+
|
|
508
|
+
@protected.get("/sessions/current/export", tags=["sessions"], response_class=Response)
|
|
509
|
+
async def export_session(runner: RunnerDep) -> Response:
|
|
510
|
+
"""Return the conversation as a standalone HTML page, to show or download."""
|
|
511
|
+
session = runner.client.session
|
|
512
|
+
with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as file:
|
|
513
|
+
path = Path(file.name)
|
|
514
|
+
try:
|
|
515
|
+
await session.export(path)
|
|
516
|
+
page = path.read_text(encoding="utf-8")
|
|
517
|
+
finally:
|
|
518
|
+
path.unlink(missing_ok=True)
|
|
519
|
+
return Response(
|
|
520
|
+
content=page,
|
|
521
|
+
media_type="text/html; charset=utf-8",
|
|
522
|
+
headers={"Content-Disposition": f'attachment; filename="zettcode-{session.id[:8]}.html"'},
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
@protected.get("/plugins", response_model=PluginsOut, tags=["models"])
|
|
526
|
+
async def plugins(runner: RunnerDep) -> PluginsOut:
|
|
527
|
+
"""List the plugins this server loaded, and the ones that could not load."""
|
|
528
|
+
report = runner.client.plugins
|
|
529
|
+
return PluginsOut(loaded=list(report.loaded), failures=list(report.failures))
|
|
530
|
+
|
|
531
|
+
@protected.get("/mentions", response_model=MentionsOut, tags=["models"])
|
|
532
|
+
async def mentions(
|
|
533
|
+
runner: RunnerDep,
|
|
534
|
+
query: Annotated[str, Query(description="What was typed after the @, without it")] = "",
|
|
535
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
536
|
+
) -> MentionsOut:
|
|
537
|
+
"""List the files, skills and plugin resources an ``@`` in the prompt can reference."""
|
|
538
|
+
return MentionsOut(mentions=[mention_out(item) for item in await runner.mentions(query, limit)])
|
|
539
|
+
|
|
540
|
+
@protected.post("/doctor", response_model=DoctorOut, tags=["models"])
|
|
541
|
+
async def doctor(runner: RunnerDep, body: DoctorRequest | None = None) -> DoctorOut:
|
|
542
|
+
"""Ask every configured model one short question and report which of them answer, as ``zettcode doctor``."""
|
|
543
|
+
timeout = 30.0 if body is None else body.timeout
|
|
544
|
+
return DoctorOut(checks=[check_out(item) for item in await runner.check_models(timeout)])
|
|
545
|
+
|
|
546
|
+
app.include_router(protected)
|
|
547
|
+
|
|
548
|
+
@app.exception_handler(StarletteHTTPException)
|
|
549
|
+
async def http_failure(request: Request, error: StarletteHTTPException) -> JSONResponse:
|
|
550
|
+
"""Write an HTTP failure in the same shape as every other one."""
|
|
551
|
+
code = "unauthorized" if error.status_code == 401 else "not_found" if error.status_code == 404 else "invalid"
|
|
552
|
+
return JSONResponse(
|
|
553
|
+
error_body(code, str(error.detail)), status_code=error.status_code, headers=dict(error.headers or {})
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
@app.exception_handler(RequestValidationError)
|
|
557
|
+
async def invalid_body(request: Request, error: RequestValidationError) -> JSONResponse:
|
|
558
|
+
"""Report a body the schema refused, with the field that was wrong."""
|
|
559
|
+
problems = "; ".join(
|
|
560
|
+
f"{'.'.join(str(part) for part in item['loc'][1:])}: {item['msg']}" for item in error.errors()
|
|
561
|
+
)
|
|
562
|
+
return JSONResponse(error_body("invalid", problems or "The request is not valid"), status_code=422)
|
|
563
|
+
|
|
564
|
+
@app.middleware("http")
|
|
565
|
+
async def failures(request: Request, call_next):
|
|
566
|
+
"""Turn an SDK failure into a status a client can branch on.
|
|
567
|
+
|
|
568
|
+
A middleware rather than an exception handler for ``Exception``: Starlette's handler for it answers and then
|
|
569
|
+
re-raises, which every in-process client (and the tests) sees instead of the response.
|
|
570
|
+
"""
|
|
571
|
+
try:
|
|
572
|
+
return await call_next(request)
|
|
573
|
+
except Exception as error: # noqa: BLE001 - this is the one place a failure becomes a response
|
|
574
|
+
status, code = failure_of(error)
|
|
575
|
+
return JSONResponse(error_body(code, str(error) or type(error).__name__), status_code=status)
|
|
576
|
+
|
|
577
|
+
return app
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
__all__ = ["ApprovalPolicy", "ClientFactory", "create_app", "error_body", "failure_of", "result_payload", "sse_frame"]
|