lorebase-guide 0.2.3__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.
- lorebase_guide/__init__.py +10 -0
- lorebase_guide/__main__.py +93 -0
- lorebase_guide/agent.py +337 -0
- lorebase_guide/app.py +451 -0
- lorebase_guide/auth.py +183 -0
- lorebase_guide/embed.py +74 -0
- lorebase_guide/keeper.py +97 -0
- lorebase_guide/limits.py +113 -0
- lorebase_guide/modes.py +156 -0
- lorebase_guide/prompts.py +89 -0
- lorebase_guide/settings.py +269 -0
- lorebase_guide/telemetry.py +42 -0
- lorebase_guide/tools.py +561 -0
- lorebase_guide/trace.py +68 -0
- lorebase_guide-0.2.3.dist-info/METADATA +15 -0
- lorebase_guide-0.2.3.dist-info/RECORD +18 -0
- lorebase_guide-0.2.3.dist-info/WHEEL +4 -0
- lorebase_guide-0.2.3.dist-info/entry_points.txt +2 -0
lorebase_guide/app.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""The Guide HTTP service.
|
|
2
|
+
|
|
3
|
+
- `POST /embed` `{text, mode: query|document}` -> `{model, dims, vector, mode}`;
|
|
4
|
+
- `POST /explore` `{domain, branch?, question, max_steps?, budget?}` -> server-sent events:
|
|
5
|
+
`session`, then `thought`, `tool_call` and `tool_result` per step, then `answer` (or `error`
|
|
6
|
+
followed by `answer`), and `done`;
|
|
7
|
+
- `GET /healthz` (liveness) and `GET /readyz` (Keeper reachable).
|
|
8
|
+
|
|
9
|
+
Every request is authenticated with the caller's own credential and checked by Keeper before
|
|
10
|
+
any model work (see `auth.py`); limits per credential protect the model budget (`limits.py`).
|
|
11
|
+
Errors are problem documents (`application/problem+json`) with a `code`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
|
|
21
|
+
from contextlib import AsyncExitStack
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Annotated, Any, Literal
|
|
24
|
+
|
|
25
|
+
from fastapi import FastAPI, Request
|
|
26
|
+
from fastapi.exceptions import RequestValidationError
|
|
27
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
28
|
+
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
29
|
+
from opentelemetry.trace import SpanKind
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
31
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
32
|
+
|
|
33
|
+
from lorebase_client.errors import LorebaseError
|
|
34
|
+
from lorebase_guide import __version__
|
|
35
|
+
from lorebase_guide.agent import Budget, Exploration, Explorer
|
|
36
|
+
from lorebase_guide.auth import AuthRefusedError, Caller, read_caller, verify
|
|
37
|
+
from lorebase_guide.embed import Embedder
|
|
38
|
+
from lorebase_guide.keeper import KeeperClients
|
|
39
|
+
from lorebase_guide.limits import Gate, Refusal, SlidingWindow, Slots
|
|
40
|
+
from lorebase_guide.settings import GuideSettings
|
|
41
|
+
from lorebase_guide.telemetry import (
|
|
42
|
+
EMBEDDINGS,
|
|
43
|
+
EXPLORATIONS,
|
|
44
|
+
REFUSED,
|
|
45
|
+
REQUEST_DURATION,
|
|
46
|
+
REQUESTS,
|
|
47
|
+
tracer,
|
|
48
|
+
)
|
|
49
|
+
from lorebase_guide.trace import TraceSession, new_session_id
|
|
50
|
+
from lorebase_inference import ChatModel, InferenceError, InferenceSettings, create_chat_model
|
|
51
|
+
from lorebase_platform.logging import request_context
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger("lorebase.guide")
|
|
54
|
+
access_logger = logging.getLogger("lorebase.guide.access")
|
|
55
|
+
|
|
56
|
+
NAME = r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
|
57
|
+
BRANCH = r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$"
|
|
58
|
+
PROBLEM = "application/problem+json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EmbedRequest(BaseModel):
|
|
62
|
+
model_config = ConfigDict(extra="forbid")
|
|
63
|
+
|
|
64
|
+
text: str = Field(min_length=1, description="Text to embed.")
|
|
65
|
+
mode: Literal["query", "document"] = Field(
|
|
66
|
+
default="query",
|
|
67
|
+
description="query: inference.embeddings.query_prefix is put before the text; "
|
|
68
|
+
"document: document_prefix, as Scribe does for Record texts.",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class EmbedResponse(BaseModel):
|
|
73
|
+
model: str = Field(description="Model name vectors are stored under (Keeper's model).")
|
|
74
|
+
dims: int
|
|
75
|
+
mode: Literal["query", "document"]
|
|
76
|
+
vector: list[float]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ExploreRequest(BaseModel):
|
|
80
|
+
model_config = ConfigDict(extra="forbid")
|
|
81
|
+
|
|
82
|
+
domain: Annotated[str, Field(pattern=NAME, description="Domain id.")]
|
|
83
|
+
branch: Annotated[str | None, Field(default=None, pattern=BRANCH)] = None
|
|
84
|
+
question: str = Field(min_length=1)
|
|
85
|
+
max_steps: int | None = Field(
|
|
86
|
+
default=None, ge=1, le=50, description="Lower than guide.agent.max_steps only."
|
|
87
|
+
)
|
|
88
|
+
budget: int | None = Field(
|
|
89
|
+
default=None,
|
|
90
|
+
ge=100,
|
|
91
|
+
description="Token budget; lower than guide.agent.max_total_tokens only.",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def problem(
|
|
96
|
+
status: int, code: str, detail: str, *, headers: dict[str, str] | None = None
|
|
97
|
+
) -> JSONResponse:
|
|
98
|
+
return JSONResponse(
|
|
99
|
+
{"type": "about:blank", "title": code, "status": status, "code": code, "detail": detail},
|
|
100
|
+
status_code=status,
|
|
101
|
+
media_type=PROBLEM,
|
|
102
|
+
headers=headers,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _refused(refusal: Refusal) -> JSONResponse:
|
|
107
|
+
REFUSED.add(1, {"reason": refusal.reason})
|
|
108
|
+
detail = {
|
|
109
|
+
"rate": "too many requests from this caller; retry later",
|
|
110
|
+
"concurrent": "this caller already has an exploration running",
|
|
111
|
+
"busy": "Guide is busy; retry later",
|
|
112
|
+
}.get(refusal.reason, "refused")
|
|
113
|
+
return problem(429, refusal.reason, detail, headers={"Retry-After": str(refusal.retry_after_s)})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _auth_refused(exc: AuthRefusedError) -> JSONResponse:
|
|
117
|
+
REFUSED.add(1, {"reason": exc.code})
|
|
118
|
+
headers = {"WWW-Authenticate": "Bearer"} if exc.status == 401 else None
|
|
119
|
+
return problem(exc.status, exc.code, exc.message, headers=headers)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sse(name: str, data: dict[str, Any]) -> bytes:
|
|
123
|
+
return f"event: {name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class _EventStream(StreamingResponse):
|
|
127
|
+
"""A stream whose resources (concurrency slot, Keeper client) are released however the
|
|
128
|
+
response ends, also when the client goes away before the first event."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
content: AsyncIterator[bytes],
|
|
133
|
+
cleanup: Callable[[], Awaitable[None]],
|
|
134
|
+
**kwargs: Any,
|
|
135
|
+
) -> None:
|
|
136
|
+
super().__init__(content, **kwargs)
|
|
137
|
+
self._cleanup = cleanup
|
|
138
|
+
|
|
139
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
140
|
+
try:
|
|
141
|
+
await super().__call__(scope, receive, send)
|
|
142
|
+
finally:
|
|
143
|
+
await self._cleanup()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class Runtime:
|
|
148
|
+
settings: GuideSettings
|
|
149
|
+
keepers: KeeperClients
|
|
150
|
+
embedder: Embedder
|
|
151
|
+
chat: Callable[[], ChatModel]
|
|
152
|
+
explore_window: SlidingWindow
|
|
153
|
+
embed_window: SlidingWindow
|
|
154
|
+
slots: Slots
|
|
155
|
+
verifying: Gate
|
|
156
|
+
_explorer: Explorer | None = None
|
|
157
|
+
|
|
158
|
+
def explorer(self) -> Explorer:
|
|
159
|
+
if self._explorer is None:
|
|
160
|
+
self._explorer = Explorer(self.chat(), self.settings.agent, self.embedder)
|
|
161
|
+
return self._explorer
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class AccessMiddleware:
|
|
165
|
+
"""Request id, server span, body size limit, timing and an access log line without
|
|
166
|
+
values (method, route, status, duration)."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, app: ASGIApp, *, max_body_bytes: int) -> None:
|
|
169
|
+
self._app = app
|
|
170
|
+
self._max_body = max_body_bytes
|
|
171
|
+
|
|
172
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
173
|
+
if scope["type"] != "http":
|
|
174
|
+
await self._app(scope, receive, send)
|
|
175
|
+
return
|
|
176
|
+
request_id = uuid.uuid4().hex
|
|
177
|
+
path = scope.get("path", "")
|
|
178
|
+
started = time.monotonic()
|
|
179
|
+
status = 500
|
|
180
|
+
|
|
181
|
+
async def tracked_send(message: Message) -> None:
|
|
182
|
+
nonlocal status
|
|
183
|
+
if message["type"] == "http.response.start":
|
|
184
|
+
status = message["status"]
|
|
185
|
+
headers = list(message.get("headers", []))
|
|
186
|
+
headers.append((b"x-request-id", request_id.encode()))
|
|
187
|
+
headers.append((b"x-content-type-options", b"nosniff"))
|
|
188
|
+
message = {**message, "headers": headers}
|
|
189
|
+
await send(message)
|
|
190
|
+
|
|
191
|
+
declared = _content_length(scope)
|
|
192
|
+
refusal: JSONResponse | None = None
|
|
193
|
+
if scope.get("method") == "POST":
|
|
194
|
+
if declared is None:
|
|
195
|
+
refusal = problem(411, "length_required", "send the body with a Content-Length")
|
|
196
|
+
elif declared > self._max_body:
|
|
197
|
+
refusal = problem(413, "payload_too_large", "request body too large")
|
|
198
|
+
with (
|
|
199
|
+
request_context(request_id),
|
|
200
|
+
tracer.start_as_current_span(
|
|
201
|
+
f"{scope.get('method', '')} {path}", kind=SpanKind.SERVER
|
|
202
|
+
) as span,
|
|
203
|
+
):
|
|
204
|
+
try:
|
|
205
|
+
if refusal is not None:
|
|
206
|
+
await refusal(scope, receive, tracked_send)
|
|
207
|
+
else:
|
|
208
|
+
await self._app(scope, _capped(receive, self._max_body), tracked_send)
|
|
209
|
+
finally:
|
|
210
|
+
elapsed = time.monotonic() - started
|
|
211
|
+
span.set_attribute("http.response.status_code", status)
|
|
212
|
+
route = path if path in _ROUTES else "other"
|
|
213
|
+
REQUESTS.add(1, {"route": route, "status": status})
|
|
214
|
+
REQUEST_DURATION.record(elapsed, {"route": route})
|
|
215
|
+
access_logger.info(
|
|
216
|
+
"request",
|
|
217
|
+
extra={
|
|
218
|
+
"method": scope.get("method"),
|
|
219
|
+
"route": route,
|
|
220
|
+
"status": status,
|
|
221
|
+
"duration_ms": round(elapsed * 1000),
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _capped(receive: Receive, limit: int) -> Receive:
|
|
227
|
+
"""`receive` that ends the body once `limit` bytes were read (a Content-Length that lies
|
|
228
|
+
cannot make the service read more)."""
|
|
229
|
+
received = 0
|
|
230
|
+
|
|
231
|
+
async def capped() -> Message:
|
|
232
|
+
nonlocal received
|
|
233
|
+
message = await receive()
|
|
234
|
+
if message["type"] == "http.request":
|
|
235
|
+
body = message.get("body", b"")
|
|
236
|
+
if received + len(body) > limit:
|
|
237
|
+
body = body[: max(0, limit - received)]
|
|
238
|
+
message = {"type": "http.request", "body": body, "more_body": False}
|
|
239
|
+
received += len(body)
|
|
240
|
+
return message
|
|
241
|
+
|
|
242
|
+
return capped
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_ROUTES = frozenset({"/embed", "/explore", "/healthz", "/readyz"})
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _content_length(scope: Scope) -> int | None:
|
|
249
|
+
for name, value in scope.get("headers", []):
|
|
250
|
+
if name == b"content-length":
|
|
251
|
+
try:
|
|
252
|
+
return int(value)
|
|
253
|
+
except ValueError:
|
|
254
|
+
return None
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _headers(request: Request) -> dict[str, str]:
|
|
259
|
+
return {k.lower(): v for k, v in request.headers.items()}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def create_app(
|
|
263
|
+
settings: GuideSettings,
|
|
264
|
+
inference: InferenceSettings,
|
|
265
|
+
*,
|
|
266
|
+
chat: Callable[[], ChatModel] | None = None,
|
|
267
|
+
embedder: Embedder | None = None,
|
|
268
|
+
keeper_transport: Any = None,
|
|
269
|
+
clock: Callable[[], float] = time.monotonic,
|
|
270
|
+
) -> FastAPI:
|
|
271
|
+
"""The Guide application. `chat`, `embedder` and `keeper_transport` replace the configured
|
|
272
|
+
model, embedding model and network (tests)."""
|
|
273
|
+
runtime = Runtime(
|
|
274
|
+
settings=settings,
|
|
275
|
+
keepers=KeeperClients(settings, transport=keeper_transport),
|
|
276
|
+
embedder=embedder or Embedder(inference),
|
|
277
|
+
chat=chat or (lambda: create_chat_model(inference, task=settings.agent.task)),
|
|
278
|
+
explore_window=SlidingWindow(
|
|
279
|
+
settings.limits.explorations_per_minute,
|
|
280
|
+
clock,
|
|
281
|
+
max_keys=settings.limits.max_tracked_callers,
|
|
282
|
+
),
|
|
283
|
+
embed_window=SlidingWindow(
|
|
284
|
+
settings.limits.embeds_per_minute,
|
|
285
|
+
clock,
|
|
286
|
+
max_keys=settings.limits.max_tracked_callers,
|
|
287
|
+
),
|
|
288
|
+
slots=Slots(settings.limits.max_concurrent, settings.limits.max_concurrent_per_caller),
|
|
289
|
+
verifying=Gate(settings.limits.max_concurrent_verifications),
|
|
290
|
+
)
|
|
291
|
+
app = FastAPI(
|
|
292
|
+
title="Lorebase Guide",
|
|
293
|
+
version=__version__,
|
|
294
|
+
docs_url=None,
|
|
295
|
+
redoc_url=None,
|
|
296
|
+
openapi_url=None,
|
|
297
|
+
)
|
|
298
|
+
app.state.runtime = runtime
|
|
299
|
+
http = settings.http
|
|
300
|
+
if http.origins_fell_back:
|
|
301
|
+
logger.warning(
|
|
302
|
+
"guide.http.allowed_origins is an empty list; using guide.http.default_origins",
|
|
303
|
+
extra={"origins": list(http.origins)},
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
@app.exception_handler(RequestValidationError)
|
|
307
|
+
async def _invalid(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
308
|
+
problems = "; ".join(
|
|
309
|
+
f"{'.'.join(str(p) for p in e['loc'][1:]) or 'body'}: {e['msg']}"
|
|
310
|
+
for e in exc.errors()[:5]
|
|
311
|
+
)
|
|
312
|
+
return problem(422, "invalid_request", problems[:500])
|
|
313
|
+
|
|
314
|
+
@app.get("/healthz")
|
|
315
|
+
async def healthz() -> dict[str, str]:
|
|
316
|
+
return {"status": "ok"}
|
|
317
|
+
|
|
318
|
+
@app.get("/readyz")
|
|
319
|
+
async def readyz() -> JSONResponse:
|
|
320
|
+
ok = await runtime.keepers.probe(settings.readiness_timeout_s)
|
|
321
|
+
body = {"status": "ready" if ok else "not_ready", "checks": {"keeper": ok}}
|
|
322
|
+
return JSONResponse(body, status_code=200 if ok else 503)
|
|
323
|
+
|
|
324
|
+
async def _verified(caller: Caller) -> Caller | JSONResponse:
|
|
325
|
+
if not runtime.verifying.enter():
|
|
326
|
+
return _refused(Refusal("busy", 1))
|
|
327
|
+
try:
|
|
328
|
+
async with runtime.keepers.open(caller) as client:
|
|
329
|
+
return await verify(caller, client)
|
|
330
|
+
except AuthRefusedError as exc:
|
|
331
|
+
return _auth_refused(exc)
|
|
332
|
+
except LorebaseError:
|
|
333
|
+
return problem(502, "keeper_unavailable", "Keeper could not be reached")
|
|
334
|
+
finally:
|
|
335
|
+
runtime.verifying.leave()
|
|
336
|
+
|
|
337
|
+
@app.post("/embed", response_model=EmbedResponse)
|
|
338
|
+
async def embed(body: EmbedRequest, request: Request) -> Response:
|
|
339
|
+
try:
|
|
340
|
+
caller = read_caller(_headers(request), http)
|
|
341
|
+
except AuthRefusedError as exc:
|
|
342
|
+
return _auth_refused(exc)
|
|
343
|
+
if len(body.text) > settings.limits.max_embed_chars:
|
|
344
|
+
return problem(422, "text_too_long", "text is longer than allowed")
|
|
345
|
+
verified = await _verified(caller)
|
|
346
|
+
if isinstance(verified, JSONResponse):
|
|
347
|
+
return verified
|
|
348
|
+
refusal = runtime.embed_window.take(verified.identity)
|
|
349
|
+
if refusal is not None:
|
|
350
|
+
return _refused(refusal)
|
|
351
|
+
try:
|
|
352
|
+
result = await runtime.embedder.embed(body.text, body.mode)
|
|
353
|
+
except InferenceError as exc:
|
|
354
|
+
logger.warning("embedding failed", extra={"error": type(exc).__name__})
|
|
355
|
+
return problem(502, "embedding_failed", "the embedding model failed")
|
|
356
|
+
EMBEDDINGS.add(1, {"mode": body.mode})
|
|
357
|
+
payload = EmbedResponse(
|
|
358
|
+
model=result.model, dims=result.dims, mode=body.mode, vector=result.vector
|
|
359
|
+
)
|
|
360
|
+
return JSONResponse(payload.model_dump(), headers={"Cache-Control": "no-store"})
|
|
361
|
+
|
|
362
|
+
@app.post("/explore")
|
|
363
|
+
async def explore(body: ExploreRequest, request: Request) -> Response:
|
|
364
|
+
try:
|
|
365
|
+
caller = read_caller(_headers(request), http)
|
|
366
|
+
except AuthRefusedError as exc:
|
|
367
|
+
return _auth_refused(exc)
|
|
368
|
+
if len(body.question) > settings.agent.max_question_chars:
|
|
369
|
+
return problem(422, "question_too_long", "the question is longer than allowed")
|
|
370
|
+
if not runtime.verifying.enter():
|
|
371
|
+
return _refused(Refusal("busy", 1))
|
|
372
|
+
stack = AsyncExitStack()
|
|
373
|
+
try:
|
|
374
|
+
trace = TraceSession(settings.trace, new_session_id(), body.domain)
|
|
375
|
+
client = await stack.enter_async_context(runtime.keepers.open(caller, trace.headers()))
|
|
376
|
+
caller = await verify(caller, client)
|
|
377
|
+
explorer = runtime.explorer()
|
|
378
|
+
except AuthRefusedError as exc:
|
|
379
|
+
await stack.aclose()
|
|
380
|
+
return _auth_refused(exc)
|
|
381
|
+
except LorebaseError:
|
|
382
|
+
await stack.aclose()
|
|
383
|
+
return problem(502, "keeper_unavailable", "Keeper could not be reached")
|
|
384
|
+
except BaseException:
|
|
385
|
+
await stack.aclose()
|
|
386
|
+
raise
|
|
387
|
+
finally:
|
|
388
|
+
runtime.verifying.leave()
|
|
389
|
+
# Limits count verified callers only, keyed on the principal Keeper reported.
|
|
390
|
+
key = caller.identity
|
|
391
|
+
refusal = runtime.explore_window.take(key) or runtime.slots.acquire(key)
|
|
392
|
+
if refusal is not None:
|
|
393
|
+
await stack.aclose()
|
|
394
|
+
return _refused(refusal)
|
|
395
|
+
stack.callback(runtime.slots.release, key)
|
|
396
|
+
budget = Budget.of(settings.agent, max_steps=body.max_steps, budget=body.budget)
|
|
397
|
+
state = Exploration()
|
|
398
|
+
|
|
399
|
+
async def stream() -> AsyncGenerator[bytes]:
|
|
400
|
+
started = time.monotonic()
|
|
401
|
+
try:
|
|
402
|
+
async for event in explorer.run(
|
|
403
|
+
client=client,
|
|
404
|
+
trace=trace,
|
|
405
|
+
domain=body.domain,
|
|
406
|
+
branch=body.branch,
|
|
407
|
+
question=body.question,
|
|
408
|
+
budget=budget,
|
|
409
|
+
state=state,
|
|
410
|
+
):
|
|
411
|
+
yield sse(event.name, event.data)
|
|
412
|
+
finally:
|
|
413
|
+
if state.outcome == "running":
|
|
414
|
+
state.outcome = "aborted"
|
|
415
|
+
EXPLORATIONS.add(1, {"outcome": state.outcome})
|
|
416
|
+
logger.info(
|
|
417
|
+
"exploration finished",
|
|
418
|
+
extra={
|
|
419
|
+
"session": trace.session_id,
|
|
420
|
+
"outcome": state.outcome,
|
|
421
|
+
"steps": state.steps,
|
|
422
|
+
"input_tokens": state.usage.input_tokens,
|
|
423
|
+
"output_tokens": state.usage.output_tokens,
|
|
424
|
+
"duration_ms": round((time.monotonic() - started) * 1000),
|
|
425
|
+
},
|
|
426
|
+
)
|
|
427
|
+
await stack.aclose()
|
|
428
|
+
|
|
429
|
+
events = stream()
|
|
430
|
+
|
|
431
|
+
async def cleanup() -> None:
|
|
432
|
+
await events.aclose()
|
|
433
|
+
await stack.aclose()
|
|
434
|
+
|
|
435
|
+
return _EventStream(
|
|
436
|
+
events,
|
|
437
|
+
cleanup,
|
|
438
|
+
media_type="text/event-stream",
|
|
439
|
+
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
app.add_middleware(
|
|
443
|
+
CORSMiddleware,
|
|
444
|
+
allow_origins=list(http.origins),
|
|
445
|
+
allow_methods=["GET", "POST"],
|
|
446
|
+
allow_headers=[http.credential_header, http.csrf_header, "Content-Type"],
|
|
447
|
+
allow_credentials=bool(http.session_cookie),
|
|
448
|
+
max_age=600,
|
|
449
|
+
)
|
|
450
|
+
app.add_middleware(AccessMiddleware, max_body_bytes=http.max_body_bytes)
|
|
451
|
+
return app
|
lorebase_guide/auth.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""The caller's credential: read from the request, checked by Keeper, forwarded unchanged.
|
|
2
|
+
|
|
3
|
+
Guide has no Keeper credential of its own and never substitutes one. A caller authenticates
|
|
4
|
+
in one of three ways:
|
|
5
|
+
|
|
6
|
+
- `bearer`: the credential header (`Authorization: Bearer <OIDC token or API key>`, a bare API
|
|
7
|
+
key is accepted too). Sent to Keeper as is.
|
|
8
|
+
- `session`: Keeper's session cookie (`guide.http.session_cookie`) with its CSRF token in
|
|
9
|
+
`guide.http.csrf_header`, when Guide is served under the web app's origin. Only that one
|
|
10
|
+
cookie and the token are forwarded; Guide asks Keeper for the session's token
|
|
11
|
+
(`GET /auth/session`) and refuses the request unless the two match, so a cross-site page
|
|
12
|
+
cannot spend the user's model budget with the cookie alone.
|
|
13
|
+
- `anonymous`: no credential, only with `guide.http.allow_anonymous` (Keeper in auth mode none).
|
|
14
|
+
|
|
15
|
+
Before any model call, `verify` asks Keeper who the caller is: an unknown or expired
|
|
16
|
+
credential is refused before it costs tokens. A request carrying an `Origin` that is not in
|
|
17
|
+
`guide.http.allowed_origins` (or `default_origins` while that is empty), or marked cross-site by
|
|
18
|
+
the browser, is refused.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import hmac
|
|
25
|
+
import re
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from http.cookies import CookieError, SimpleCookie
|
|
29
|
+
from typing import Any, Literal
|
|
30
|
+
|
|
31
|
+
from lorebase_client import AsyncLorebaseClient
|
|
32
|
+
from lorebase_client.core import Call, parse_json
|
|
33
|
+
from lorebase_client.errors import LorebaseError
|
|
34
|
+
from lorebase_guide.settings import HttpSettings
|
|
35
|
+
|
|
36
|
+
CredentialKind = Literal["bearer", "session", "anonymous"]
|
|
37
|
+
_MAX_CREDENTIAL_CHARS = 8_192
|
|
38
|
+
_CROSS_SITE = "cross-site"
|
|
39
|
+
_SESSION_ID = re.compile(r"[A-Za-z0-9_-]{32,128}")
|
|
40
|
+
"""Shape of Keeper's session ids; any other cookie value is refused, never forwarded."""
|
|
41
|
+
_CSRF_TOKEN = re.compile(r"[A-Za-z0-9_.~+/=-]{1,256}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthRefusedError(Exception):
|
|
45
|
+
"""The request is refused before any work: `status` is 401 or 403."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.status = status
|
|
50
|
+
self.code = code
|
|
51
|
+
self.message = message
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Caller:
|
|
56
|
+
"""The credential of one request. Its repr never shows the secret parts."""
|
|
57
|
+
|
|
58
|
+
kind: CredentialKind
|
|
59
|
+
authorization: str | None = None
|
|
60
|
+
session_id: str | None = None
|
|
61
|
+
csrf_token: str | None = None
|
|
62
|
+
principal: str | None = None
|
|
63
|
+
"""Principal id reported by Keeper once verified."""
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"Caller(kind={self.kind!r}, principal={self.principal!r})"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def identity(self) -> str:
|
|
70
|
+
"""Key of the per-caller limits: the verified principal, else the credential's
|
|
71
|
+
fingerprint (anonymous callers share one key)."""
|
|
72
|
+
return f"principal:{self.principal}" if self.principal else self.fingerprint
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def fingerprint(self) -> str:
|
|
76
|
+
"""Stable, non-reversible id of the credential, used for per-caller limits."""
|
|
77
|
+
secret = self.authorization or self.session_id or ""
|
|
78
|
+
if not secret:
|
|
79
|
+
return "anonymous"
|
|
80
|
+
return hashlib.sha256(f"{self.kind}\x00{secret}".encode()).hexdigest()[:32]
|
|
81
|
+
|
|
82
|
+
def keeper_headers(self, http: HttpSettings) -> dict[str, str]:
|
|
83
|
+
"""Headers that carry this credential to Keeper, and nothing else of the request."""
|
|
84
|
+
if self.kind == "bearer" and self.authorization:
|
|
85
|
+
return {"Authorization": self.authorization}
|
|
86
|
+
if self.kind == "session" and self.session_id and self.csrf_token:
|
|
87
|
+
return {
|
|
88
|
+
"Cookie": f"{http.session_cookie}={self.session_id}",
|
|
89
|
+
http.csrf_header: self.csrf_token,
|
|
90
|
+
}
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _single_line(value: str) -> bool:
|
|
95
|
+
return "\r" not in value and "\n" not in value and len(value) <= _MAX_CREDENTIAL_CHARS
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cookie(headers: Mapping[str, str], name: str) -> str | None:
|
|
99
|
+
raw = headers.get("cookie")
|
|
100
|
+
if not raw or not name:
|
|
101
|
+
return None
|
|
102
|
+
jar: SimpleCookie = SimpleCookie()
|
|
103
|
+
try:
|
|
104
|
+
jar.load(raw)
|
|
105
|
+
except CookieError:
|
|
106
|
+
return None
|
|
107
|
+
morsel = jar.get(name)
|
|
108
|
+
return morsel.value if morsel is not None and morsel.value else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check_origin(headers: Mapping[str, str], http: HttpSettings) -> None:
|
|
112
|
+
"""Refuse browser requests from origins that are not configured, and cross-site ones."""
|
|
113
|
+
origin = headers.get("origin")
|
|
114
|
+
if origin is not None and origin.rstrip("/").lower() not in http.origins:
|
|
115
|
+
raise AuthRefusedError(
|
|
116
|
+
403, "origin_not_allowed", "requests from this origin are not allowed"
|
|
117
|
+
)
|
|
118
|
+
site = headers.get("sec-fetch-site")
|
|
119
|
+
if site is not None and site.lower() == _CROSS_SITE and origin is None:
|
|
120
|
+
raise AuthRefusedError(403, "cross_site", "cross-site requests are not allowed")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_caller(headers: Mapping[str, str], http: HttpSettings) -> Caller:
|
|
124
|
+
"""The credential of a request (lower-case header names), not yet verified."""
|
|
125
|
+
check_origin(headers, http)
|
|
126
|
+
authorization = headers.get(http.credential_header.lower())
|
|
127
|
+
if authorization is not None and authorization.strip():
|
|
128
|
+
value = authorization.strip()
|
|
129
|
+
if not _single_line(value):
|
|
130
|
+
raise AuthRefusedError(401, "unauthorized", "malformed credential")
|
|
131
|
+
scheme, _, rest = value.partition(" ")
|
|
132
|
+
if scheme.lower() == "bearer":
|
|
133
|
+
if not rest.strip():
|
|
134
|
+
raise AuthRefusedError(401, "unauthorized", "malformed credential")
|
|
135
|
+
value = f"Bearer {rest.strip()}"
|
|
136
|
+
elif rest:
|
|
137
|
+
raise AuthRefusedError(401, "unauthorized", "only Bearer credentials are accepted")
|
|
138
|
+
else:
|
|
139
|
+
value = f"Bearer {value}"
|
|
140
|
+
return Caller(kind="bearer", authorization=value)
|
|
141
|
+
session_id = _cookie(headers, http.session_cookie)
|
|
142
|
+
if session_id is not None:
|
|
143
|
+
token = (headers.get(http.csrf_header.lower()) or "").strip()
|
|
144
|
+
if not _SESSION_ID.fullmatch(session_id):
|
|
145
|
+
raise AuthRefusedError(401, "unauthorized", "malformed session cookie")
|
|
146
|
+
if not _CSRF_TOKEN.fullmatch(token):
|
|
147
|
+
raise AuthRefusedError(
|
|
148
|
+
403, "csrf", f"a session cookie needs its CSRF token in {http.csrf_header}"
|
|
149
|
+
)
|
|
150
|
+
return Caller(kind="session", session_id=session_id, csrf_token=token)
|
|
151
|
+
if http.allow_anonymous:
|
|
152
|
+
return Caller(kind="anonymous")
|
|
153
|
+
raise AuthRefusedError(
|
|
154
|
+
401,
|
|
155
|
+
"unauthorized",
|
|
156
|
+
f"send `{http.credential_header}: Bearer <token or API key>` or the session cookie",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def verify(caller: Caller, client: AsyncLorebaseClient) -> Caller:
|
|
161
|
+
"""Ask Keeper who the caller is, with the caller's own credential; for a session, also
|
|
162
|
+
check the CSRF token against the session's. Returns the caller with its principal."""
|
|
163
|
+
try:
|
|
164
|
+
who = await client.whoami()
|
|
165
|
+
if caller.kind == "session":
|
|
166
|
+
session: Any = await client.send(Call("GET", "/auth/session", parse_json, safe=True))
|
|
167
|
+
expected = str(session.get("csrf_token") or "") if isinstance(session, dict) else ""
|
|
168
|
+
given = caller.csrf_token or ""
|
|
169
|
+
if not expected or not hmac.compare_digest(expected.encode(), given.encode()):
|
|
170
|
+
raise AuthRefusedError(403, "csrf", "the CSRF token does not match the session")
|
|
171
|
+
except LorebaseError as exc:
|
|
172
|
+
if exc.status in (401, 403):
|
|
173
|
+
raise AuthRefusedError(
|
|
174
|
+
exc.status, "unauthorized", "Keeper refused the credential"
|
|
175
|
+
) from None
|
|
176
|
+
raise
|
|
177
|
+
return Caller(
|
|
178
|
+
kind=caller.kind,
|
|
179
|
+
authorization=caller.authorization,
|
|
180
|
+
session_id=caller.session_id,
|
|
181
|
+
csrf_token=caller.csrf_token,
|
|
182
|
+
principal=who.id or None,
|
|
183
|
+
)
|