puremethod 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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- writer/types.py +61 -0
app/api/run_routes.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Runs, commands, ledger reads, live events and downloads (08-app.md §3, KB-021).
|
|
2
|
+
|
|
3
|
+
Control endpoints only queue commands; the run's worker applies them through `loop`
|
|
4
|
+
(§5 step 2). Everything shown comes from the ledger or `loop.status` (INV-7)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable, Iterator
|
|
13
|
+
from dataclasses import asdict
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Annotated, Any
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Depends, Query, Request
|
|
18
|
+
from fastapi.responses import FileResponse, Response, StreamingResponse
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
20
|
+
from starlette.background import BackgroundTask
|
|
21
|
+
|
|
22
|
+
from app import executor_config, model_endpoints
|
|
23
|
+
from app.api import links
|
|
24
|
+
from app.api.common import Ctx, clean_errors, ctx, not_found, repo, tenant, unprocessable
|
|
25
|
+
from app.outputs import bundle_ref
|
|
26
|
+
from app.repo import RunRow, TenantRepo
|
|
27
|
+
from app.setup import run_services, setup_status
|
|
28
|
+
from app.statuses import TERMINAL
|
|
29
|
+
from ledger import ArtifactMissing, Entry
|
|
30
|
+
from loop import RunConfig
|
|
31
|
+
|
|
32
|
+
router = APIRouter()
|
|
33
|
+
Tenant = Annotated[str, Depends(tenant)]
|
|
34
|
+
DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") # the ledger's digest form (04-ledger.md §4)
|
|
35
|
+
MAX_ENTRIES = 5000
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _In(BaseModel):
|
|
39
|
+
model_config = ConfigDict(extra="forbid")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RunIn(_In):
|
|
43
|
+
title: Annotated[str, Field(max_length=200)] = ""
|
|
44
|
+
config: dict[str, Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PauseIn(_In):
|
|
48
|
+
reason: Annotated[str, Field(max_length=500)] = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Empty(_In):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CheckpointIn(_In):
|
|
56
|
+
decision: dict[str, Any]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _run(r: TenantRepo, run_id: str) -> RunRow:
|
|
60
|
+
run = r.get_run(run_id)
|
|
61
|
+
if run is None:
|
|
62
|
+
not_found()
|
|
63
|
+
return run
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _status(c: Ctx, run: RunRow) -> dict[str, Any] | None:
|
|
67
|
+
return asdict(c.status.status(run.ledger_run_id)) if run.ledger_run_id else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _view(c: Ctx, run: RunRow) -> dict[str, Any]:
|
|
71
|
+
view: dict[str, Any] = {
|
|
72
|
+
"id": run.id, "title": run.title, "created_at": run.created_at, "state": run.state,
|
|
73
|
+
"start_error": run.start_error, "worker_error": run.worker_error, "status": None,
|
|
74
|
+
"status_error": None}
|
|
75
|
+
try:
|
|
76
|
+
view["status"] = _status(c, run)
|
|
77
|
+
except Exception as exc: # one unreadable run must not break the list (review finding 5)
|
|
78
|
+
view["status_error"] = f"status unavailable: {type(exc).__name__}"
|
|
79
|
+
return view
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---- runs ----------------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
@router.post("/runs", status_code=202)
|
|
85
|
+
def create_run(request: Request, tid: Tenant, body: RunIn) -> dict[str, Any]:
|
|
86
|
+
"""Expands the LLM profile, which must be checked (KB-041), checks every executor, profile
|
|
87
|
+
and credential named belongs to the tenant, validates the `RunConfig`, and queues `start`
|
|
88
|
+
(KB-021). Consents come only from this request; nothing is carried over from earlier runs
|
|
89
|
+
(INV-4)."""
|
|
90
|
+
c, r = ctx(request), repo(request, tid)
|
|
91
|
+
setup = setup_status(r, request.app.state.local_status)
|
|
92
|
+
if not setup.complete: # 08 §5 step 13
|
|
93
|
+
unprocessable("finish setup first: " + "; ".join(setup.missing))
|
|
94
|
+
cfg = dict(body.config)
|
|
95
|
+
if "llm" in cfg:
|
|
96
|
+
unprocessable("llm is set from llm_profile")
|
|
97
|
+
profile = r.get_profile(str(cfg.pop("llm_profile", "")))
|
|
98
|
+
if profile is None:
|
|
99
|
+
unprocessable("unknown llm_profile")
|
|
100
|
+
if not r.profile_checked(profile): # 08 §3 Run request (KB-041)
|
|
101
|
+
unprocessable(f"check the LLM profile {profile.name!r} first: its latest check did not "
|
|
102
|
+
"pass, or it changed after that check")
|
|
103
|
+
# Endpoints become what the worker needs: a URL, or a tunnel to an SSH machine with its
|
|
104
|
+
# resolved connection, snapshotted like the executor (07 §4, KB-036).
|
|
105
|
+
try:
|
|
106
|
+
cfg["llm"] = {role: model_endpoints.expand(spec, c.settings.mode, r.resolve_machine)
|
|
107
|
+
for role, spec in profile.roles.items()}
|
|
108
|
+
except KeyError as exc:
|
|
109
|
+
unprocessable(f"the profile names an SSH machine that no longer exists: {exc}")
|
|
110
|
+
except ValueError as exc:
|
|
111
|
+
unprocessable(f"the profile's SSH machine cannot be reached: {exc}")
|
|
112
|
+
executor = cfg.get("executor")
|
|
113
|
+
row = r.get_executor(str(executor.get("id", ""))) if isinstance(executor, dict) else None
|
|
114
|
+
if row is None:
|
|
115
|
+
unprocessable("unknown executor")
|
|
116
|
+
try:
|
|
117
|
+
executor_config.validate(row.kind, row.config, c.settings.mode)
|
|
118
|
+
config = RunConfig.model_validate(cfg)
|
|
119
|
+
except ValidationError as exc:
|
|
120
|
+
unprocessable(json.dumps(clean_errors(exc.errors())))
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
unprocessable(str(exc))
|
|
123
|
+
stored = set(r.credential_types())
|
|
124
|
+
needed = set(config.credential_names()) | set(
|
|
125
|
+
executor_config.credentials_named(row.kind, row.config)) | set(
|
|
126
|
+
model_endpoints.ssh_credentials(config.llm))
|
|
127
|
+
for name in sorted(needed - stored):
|
|
128
|
+
unprocessable(f"credential {name!r} is not stored")
|
|
129
|
+
services = run_services(r, c.settings.purescholar_url, c.settings.puretrace_url)
|
|
130
|
+
run, cmd = r.create_run(body.title.strip() or config.idea[:80],
|
|
131
|
+
config.model_dump(mode="json"), profile.prices, row, services,
|
|
132
|
+
llm_profile_id=profile.id)
|
|
133
|
+
return {"run_id": run.id, "command_id": cmd.id}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@router.get("/runs")
|
|
137
|
+
def list_runs(request: Request, tid: Tenant) -> list[dict[str, Any]]:
|
|
138
|
+
c = ctx(request)
|
|
139
|
+
return [_view(c, run) for run in repo(request, tid).list_runs()]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@router.get("/runs/{run_id}")
|
|
143
|
+
def get_run(request: Request, tid: Tenant, run_id: str) -> dict[str, Any]:
|
|
144
|
+
return _view(ctx(request), _run(repo(request, tid), run_id))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _queue(request: Request, tid: str, run_id: str, kind: str,
|
|
148
|
+
payload: dict[str, Any]) -> dict[str, Any]:
|
|
149
|
+
r = repo(request, tid)
|
|
150
|
+
run = _run(r, run_id)
|
|
151
|
+
if run.state == "start_failed":
|
|
152
|
+
unprocessable("the run did not start")
|
|
153
|
+
return {"command_id": r.add_command(run.id, kind, payload).id}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@router.post("/runs/{run_id}/pause", status_code=202)
|
|
157
|
+
def pause(request: Request, tid: Tenant, run_id: str, body: PauseIn) -> dict[str, Any]:
|
|
158
|
+
return _queue(request, tid, run_id, "pause", {"reason": body.reason})
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.post("/runs/{run_id}/resume", status_code=202)
|
|
162
|
+
def resume(request: Request, tid: Tenant, run_id: str, body: Empty) -> dict[str, Any]:
|
|
163
|
+
return _queue(request, tid, run_id, "resume", {})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@router.post("/runs/{run_id}/stop", status_code=202)
|
|
167
|
+
def stop(request: Request, tid: Tenant, run_id: str, body: Empty) -> dict[str, Any]:
|
|
168
|
+
return _queue(request, tid, run_id, "stop", {})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@router.post("/runs/{run_id}/checkpoints/{checkpoint_id}", status_code=202)
|
|
172
|
+
def decide(request: Request, tid: Tenant, run_id: str, checkpoint_id: str,
|
|
173
|
+
body: CheckpointIn) -> dict[str, Any]:
|
|
174
|
+
return _queue(request, tid, run_id, "checkpoint",
|
|
175
|
+
{"checkpoint_id": checkpoint_id, "decision": body.decision})
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@router.get("/runs/{run_id}/commands/{command_id}")
|
|
179
|
+
def get_command(request: Request, tid: Tenant, run_id: str, command_id: str) -> dict[str, Any]:
|
|
180
|
+
cmd = repo(request, tid).get_command(run_id, command_id)
|
|
181
|
+
if cmd is None:
|
|
182
|
+
not_found()
|
|
183
|
+
return {"id": cmd.id, "kind": cmd.kind, "state": cmd.state, "reason": cmd.reason,
|
|
184
|
+
"created_at": cmd.created_at, "done_at": cmd.done_at}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---- ledger reads --------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
@router.get("/runs/{run_id}/entries")
|
|
190
|
+
def entries(request: Request, tid: Tenant, run_id: str,
|
|
191
|
+
since_seq: Annotated[int, Query(ge=0)] = 0,
|
|
192
|
+
types: Annotated[str, Query(max_length=1000)] = "",
|
|
193
|
+
limit: Annotated[int, Query(ge=1, le=MAX_ENTRIES)] = 1000) -> dict[str, Any]:
|
|
194
|
+
c, run = ctx(request), _run(repo(request, tid), run_id)
|
|
195
|
+
if run.ledger_run_id is None:
|
|
196
|
+
return {"entries": []}
|
|
197
|
+
wanted = [t for t in types.split(",") if t] or None
|
|
198
|
+
out: list[dict[str, Any]] = []
|
|
199
|
+
for e in c.ledger.entries(run.ledger_run_id, types=wanted, since_seq=since_seq):
|
|
200
|
+
out.append(e.to_dict())
|
|
201
|
+
if len(out) >= limit:
|
|
202
|
+
break
|
|
203
|
+
return {"entries": out}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@router.get("/runs/{run_id}/artifacts/{digest}")
|
|
207
|
+
def artifact(request: Request, tid: Tenant, run_id: str, digest: str) -> Response:
|
|
208
|
+
c, run = ctx(request), _run(repo(request, tid), run_id)
|
|
209
|
+
if run.ledger_run_id is None or not DIGEST.match(digest):
|
|
210
|
+
not_found()
|
|
211
|
+
try:
|
|
212
|
+
data = c.ledger.get_artifact(run.ledger_run_id, digest)
|
|
213
|
+
except ArtifactMissing:
|
|
214
|
+
not_found()
|
|
215
|
+
# Agent-written content: never rendered by the browser.
|
|
216
|
+
name = digest.removeprefix("sha256:")
|
|
217
|
+
return Response(data, media_type="application/octet-stream",
|
|
218
|
+
headers={"Content-Disposition": f'attachment; filename="{name}"',
|
|
219
|
+
"X-Content-Type-Options": "nosniff"})
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---- live events (SSE) ---------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def _sse(event: str, seq: int, data: Any) -> str:
|
|
225
|
+
return f"id: {seq}\nevent: {event}\ndata: {json.dumps(data, separators=(',', ':'))}\n\n"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def event_stream(c: Ctx, r: TenantRepo, run_id: str, since: int, *,
|
|
229
|
+
clock: Callable[[], float] = time.monotonic,
|
|
230
|
+
sleep: Callable[[float], None] = time.sleep) -> Iterator[str]:
|
|
231
|
+
"""`entry` events in ledger order, then a `status` after each batch; ids are ledger
|
|
232
|
+
seqs, so `Last-Event-ID` resumes. Ends after the final status of a finished run."""
|
|
233
|
+
last, first, beat = since, True, clock()
|
|
234
|
+
while True:
|
|
235
|
+
run = r.get_run(run_id)
|
|
236
|
+
if run is None or run.state == "start_failed":
|
|
237
|
+
return
|
|
238
|
+
batch: list[Entry] = []
|
|
239
|
+
if run.ledger_run_id is not None:
|
|
240
|
+
batch = list(c.ledger.entries(run.ledger_run_id, since_seq=last))
|
|
241
|
+
for e in batch:
|
|
242
|
+
yield _sse("entry", e.seq, e.to_dict())
|
|
243
|
+
last = e.seq
|
|
244
|
+
if batch or first:
|
|
245
|
+
status = c.status.status(run.ledger_run_id)
|
|
246
|
+
yield _sse("status", last, asdict(status))
|
|
247
|
+
beat = clock()
|
|
248
|
+
if status.status in TERMINAL:
|
|
249
|
+
return
|
|
250
|
+
first = False
|
|
251
|
+
if not batch and clock() - beat >= c.heartbeat_s:
|
|
252
|
+
yield ": keep-alive\n\n"
|
|
253
|
+
beat = clock()
|
|
254
|
+
sleep(c.events_poll_s)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@router.get("/runs/{run_id}/events")
|
|
258
|
+
def events(request: Request, tid: Tenant, run_id: str,
|
|
259
|
+
since: Annotated[int, Query(ge=0)] = 0) -> StreamingResponse:
|
|
260
|
+
r = repo(request, tid)
|
|
261
|
+
_run(r, run_id)
|
|
262
|
+
header = request.headers.get("last-event-id", "")
|
|
263
|
+
start = int(header) if header.isdigit() else since
|
|
264
|
+
return StreamingResponse(event_stream(ctx(request), r, run_id, start),
|
|
265
|
+
media_type="text/event-stream",
|
|
266
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---- downloads (INV-6) ---------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def _download(request: Request, tid: str, run_id: str, obj: str, exp: int | None,
|
|
272
|
+
sig: str | None) -> Response:
|
|
273
|
+
c = ctx(request)
|
|
274
|
+
run = _run(repo(request, tid), run_id)
|
|
275
|
+
lid = run.ledger_run_id
|
|
276
|
+
ref = bundle_ref(c.ledger, lid) if lid else None
|
|
277
|
+
if lid is None or ref is None or (obj == "paper" and c.outputs.paper(lid, ref) is None):
|
|
278
|
+
not_found()
|
|
279
|
+
now = time.time()
|
|
280
|
+
if exp is None or sig is None:
|
|
281
|
+
expires = int(now) + links.LINK_TTL_S
|
|
282
|
+
token = links.sign(c.link_key, tid, run.id, obj, expires)
|
|
283
|
+
return Response(json.dumps({
|
|
284
|
+
"url": f"/api/v1/runs/{run.id}/{obj}?exp={expires}&sig={token}",
|
|
285
|
+
"expires_at": datetime.fromtimestamp(expires, UTC).isoformat()}),
|
|
286
|
+
media_type="application/json")
|
|
287
|
+
if not links.valid(c.link_key, tid, run.id, obj, exp, sig, now):
|
|
288
|
+
not_found()
|
|
289
|
+
if obj == "paper":
|
|
290
|
+
pdf = c.outputs.paper(lid, ref)
|
|
291
|
+
if pdf is None:
|
|
292
|
+
not_found()
|
|
293
|
+
return FileResponse(pdf, media_type="application/pdf", filename=f"{run.id}-paper.pdf")
|
|
294
|
+
archive = c.outputs.bundle_archive(lid, ref, run.id)
|
|
295
|
+
if archive is None:
|
|
296
|
+
not_found()
|
|
297
|
+
return FileResponse(archive, media_type="application/gzip",
|
|
298
|
+
filename=f"{run.id}-bundle.tar.gz",
|
|
299
|
+
background=BackgroundTask(os.unlink, archive))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@router.get("/runs/{run_id}/paper", response_model=None)
|
|
303
|
+
def paper(request: Request, tid: Tenant, run_id: str, exp: int | None = None,
|
|
304
|
+
sig: Annotated[str | None, Query(max_length=128)] = None) -> Response:
|
|
305
|
+
return _download(request, tid, run_id, "paper", exp, sig)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@router.get("/runs/{run_id}/bundle", response_model=None)
|
|
309
|
+
def bundle(request: Request, tid: Tenant, run_id: str, exp: int | None = None,
|
|
310
|
+
sig: Annotated[str | None, Query(max_length=128)] = None) -> Response:
|
|
311
|
+
return _download(request, tid, run_id, "bundle", exp, sig)
|
app/api/setup_routes.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Onboarding (08-app.md §3, §5 steps 13 and 14, KB-025, KB-029): credential checks, the
|
|
2
|
+
literature and PureTrace settings, and the setup status. Checks only queue work; a worker
|
|
3
|
+
decrypts the one credential checked (INV-2)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from typing import Annotated, Any, Literal
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, Request
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
13
|
+
|
|
14
|
+
from app import bundled, literature_sources, model_endpoints
|
|
15
|
+
from app.api.common import active_runs, ctx, not_found, repo, tenant, unprocessable
|
|
16
|
+
from app.setup import PURESCHOLAR_CREDENTIAL, literature_status, setup_status
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
Tenant = Annotated[str, Depends(tenant)]
|
|
20
|
+
LLM_PROVIDERS = ("anthropic", "openai", "openai_compatible")
|
|
21
|
+
EMBEDDINGS = ("local", "openai", "sentence-transformers")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _In(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _http_url(value: str) -> str:
|
|
29
|
+
parts = urlsplit(value)
|
|
30
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
31
|
+
raise ValueError("an http or https URL")
|
|
32
|
+
return value.rstrip("/")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Price(_In):
|
|
36
|
+
input: Annotated[float, Field(ge=0)]
|
|
37
|
+
output: Annotated[float, Field(ge=0)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CheckIn(_In):
|
|
41
|
+
"""LLM key: provider and model (base URL, price optional). Service key: url."""
|
|
42
|
+
provider: Literal["anthropic", "openai", "openai_compatible"] | None = None
|
|
43
|
+
model: Annotated[str, Field(min_length=1, max_length=200)] | None = None
|
|
44
|
+
base_url: str | None = None
|
|
45
|
+
allow_unencrypted: bool = False # INV-12: plain http to a public address, confirmed
|
|
46
|
+
local_port: Annotated[int, Field(ge=1, le=65535)] | None = None # a model on this computer
|
|
47
|
+
machine: str | None = None # a model on an SSH machine (KB-036)
|
|
48
|
+
remote_port: Annotated[int, Field(ge=1, le=65535)] | None = None
|
|
49
|
+
price: Price | None = None
|
|
50
|
+
url: str | None = None
|
|
51
|
+
source: Literal["semanticscholar", "ieee", "elsevier", "springer"] | None = None # KB-039
|
|
52
|
+
|
|
53
|
+
@field_validator("base_url", "url")
|
|
54
|
+
@classmethod
|
|
55
|
+
def _url(cls, v: str | None) -> str | None:
|
|
56
|
+
return None if v is None else _http_url(v)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LiteratureIn(_In):
|
|
60
|
+
mode: Literal["local", "remote"]
|
|
61
|
+
url: str | None = None # remote
|
|
62
|
+
embedding: Literal["local", "openai", "sentence-transformers"] | None = None # local
|
|
63
|
+
llm_credential: str | None = None # local: the LLM key PureScholar generates with, or
|
|
64
|
+
llm_profile: str | None = None # a profile role's model (KB-038)
|
|
65
|
+
llm_role: Literal["planner", "coder", "analyst"] | None = None
|
|
66
|
+
openai_credential: str | None = None # local, embedding "openai"
|
|
67
|
+
# local: the scholarly sources, their keys by key group, the OpenAlex email (KB-039)
|
|
68
|
+
sources: list[str] | None = None
|
|
69
|
+
source_keys: dict[str, str] | None = None
|
|
70
|
+
openalex_email: Annotated[str, Field(max_length=254)] | None = None
|
|
71
|
+
|
|
72
|
+
@field_validator("url")
|
|
73
|
+
@classmethod
|
|
74
|
+
def _url(cls, v: str | None) -> str | None:
|
|
75
|
+
return None if v is None else _http_url(v)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PureTraceIn(_In):
|
|
79
|
+
url: str | None = None
|
|
80
|
+
|
|
81
|
+
@field_validator("url")
|
|
82
|
+
@classmethod
|
|
83
|
+
def _url(cls, v: str | None) -> str | None:
|
|
84
|
+
return None if v is None else _http_url(v)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---- credential checks -----------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
@router.post("/credentials/{credential_id}/check", status_code=202)
|
|
90
|
+
def start_credential_check(request: Request, tid: Tenant, credential_id: str,
|
|
91
|
+
body: CheckIn) -> dict[str, Any]:
|
|
92
|
+
r = repo(request, tid)
|
|
93
|
+
cred = r.credential(credential_id)
|
|
94
|
+
if cred is None:
|
|
95
|
+
not_found()
|
|
96
|
+
if cred.type == "llm-key":
|
|
97
|
+
if body.provider is None or body.model is None or body.url is not None:
|
|
98
|
+
unprocessable("an LLM key check names provider and model")
|
|
99
|
+
mode = ctx(request).settings.mode
|
|
100
|
+
# The endpoint, as a profile role would name it (07 §4, KB-036): the check of a
|
|
101
|
+
# local or SSH endpoint is what lets the bundled PureScholar use it (08 §5 step 12).
|
|
102
|
+
spec = {k: v for k, v in {"provider": body.provider, "base_url": body.base_url,
|
|
103
|
+
"local_port": body.local_port, "machine": body.machine,
|
|
104
|
+
"remote_port": body.remote_port,
|
|
105
|
+
"allow_unencrypted": body.allow_unencrypted or None}.items()
|
|
106
|
+
if v is not None}
|
|
107
|
+
try: # 08 INV-11, INV-12 (KB-036)
|
|
108
|
+
model_endpoints.check(spec, mode, {m.id for m in r.list_machines()})
|
|
109
|
+
except model_endpoints.EndpointRefused as exc:
|
|
110
|
+
unprocessable(str(exc))
|
|
111
|
+
try:
|
|
112
|
+
expanded = model_endpoints.expand(spec, mode, r.resolve_machine)
|
|
113
|
+
except (KeyError, ValueError) as exc:
|
|
114
|
+
unprocessable(f"the SSH machine cannot be used: {exc}")
|
|
115
|
+
req: dict[str, Any] = {"provider": body.provider, "model": body.model}
|
|
116
|
+
for key in ("base_url", "public_only", "ssh"):
|
|
117
|
+
if key in expanded:
|
|
118
|
+
req[key] = expanded[key]
|
|
119
|
+
if body.local_port is not None:
|
|
120
|
+
req["local_port"] = body.local_port
|
|
121
|
+
if body.price:
|
|
122
|
+
req["price"] = body.price.model_dump()
|
|
123
|
+
elif cred.type in ("purescholar-key", "puretrace-key"):
|
|
124
|
+
llm_fields = (body.provider, body.model, body.base_url, body.price, body.local_port,
|
|
125
|
+
body.machine, body.remote_port, body.source)
|
|
126
|
+
if body.url is None or any(v is not None for v in llm_fields):
|
|
127
|
+
unprocessable("a service key check names url only")
|
|
128
|
+
req = {"url": body.url}
|
|
129
|
+
elif cred.type == literature_sources.CREDENTIAL_TYPE:
|
|
130
|
+
others = (body.provider, body.model, body.base_url, body.price, body.local_port,
|
|
131
|
+
body.machine, body.remote_port, body.url)
|
|
132
|
+
if body.source is None or any(v is not None for v in others):
|
|
133
|
+
unprocessable("a literature-source key check names source only")
|
|
134
|
+
req = {"group": body.source}
|
|
135
|
+
else:
|
|
136
|
+
unprocessable(f"a {cred.type} credential cannot be checked")
|
|
137
|
+
check = r.add_credential_check(cred.id, req)
|
|
138
|
+
return {"check_id": check.id, "state": check.state}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.get("/credentials/{credential_id}/check/{credential_check_id}")
|
|
142
|
+
def get_credential_check(request: Request, tid: Tenant, credential_id: str,
|
|
143
|
+
credential_check_id: str) -> dict[str, Any]:
|
|
144
|
+
check = repo(request, tid).get_credential_check(credential_id, credential_check_id)
|
|
145
|
+
if check is None:
|
|
146
|
+
not_found()
|
|
147
|
+
return asdict(check)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---- service settings ------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
@router.get("/services/literature")
|
|
153
|
+
def get_literature(request: Request, tid: Tenant) -> dict[str, Any]:
|
|
154
|
+
r = repo(request, tid)
|
|
155
|
+
status = literature_status(r, request.app.state.local_status)
|
|
156
|
+
return {"setting": r.service_setting("literature"),
|
|
157
|
+
"status": {"state": status.state, "reason": status.reason}}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.put("/services/literature")
|
|
161
|
+
def put_literature(request: Request, tid: Tenant, body: LiteratureIn) -> dict[str, Any]:
|
|
162
|
+
r = repo(request, tid)
|
|
163
|
+
types = r.credential_types()
|
|
164
|
+
config: dict[str, Any]
|
|
165
|
+
if body.mode == "remote":
|
|
166
|
+
if body.url is None or any(v is not None for v in (
|
|
167
|
+
body.embedding, body.llm_credential, body.openai_credential, body.llm_profile,
|
|
168
|
+
body.llm_role, body.sources, body.source_keys, body.openalex_email)):
|
|
169
|
+
unprocessable("remote mode names url only")
|
|
170
|
+
if types.get(PURESCHOLAR_CREDENTIAL) != "purescholar-key":
|
|
171
|
+
unprocessable(f"store the PureScholar key as {PURESCHOLAR_CREDENTIAL!r} first")
|
|
172
|
+
config = {"mode": "remote", "url": body.url}
|
|
173
|
+
else:
|
|
174
|
+
by_profile = body.llm_profile is not None
|
|
175
|
+
one_model = (body.llm_credential is None) == by_profile # exactly one of the two
|
|
176
|
+
if body.url is not None or body.embedding is None or not one_model or \
|
|
177
|
+
by_profile != (body.llm_role is not None):
|
|
178
|
+
unprocessable("local mode names embedding, and llm_credential or llm_profile with "
|
|
179
|
+
"llm_role")
|
|
180
|
+
if PURESCHOLAR_CREDENTIAL in (body.llm_credential, body.openai_credential):
|
|
181
|
+
# The bundled PureScholar's own key is stored under this name (08 §5 step 12).
|
|
182
|
+
unprocessable(f"{PURESCHOLAR_CREDENTIAL!r} is the PureScholar key's name; store "
|
|
183
|
+
"the model key under another name")
|
|
184
|
+
if by_profile:
|
|
185
|
+
profile = r.get_profile(str(body.llm_profile))
|
|
186
|
+
if profile is None or str(body.llm_role) not in profile.roles:
|
|
187
|
+
unprocessable(f"no LLM profile {body.llm_profile!r} with that role")
|
|
188
|
+
elif types.get(str(body.llm_credential)) != "llm-key":
|
|
189
|
+
unprocessable(f"no llm-key credential named {body.llm_credential!r}")
|
|
190
|
+
if body.embedding == "openai" and types.get(body.openai_credential or "") != "llm-key":
|
|
191
|
+
unprocessable("the openai embedding needs openai_credential, an llm-key")
|
|
192
|
+
if body.embedding != "openai" and body.openai_credential is not None:
|
|
193
|
+
unprocessable("openai_credential is only for the openai embedding")
|
|
194
|
+
config = {"mode": "local", "embedding": body.embedding}
|
|
195
|
+
if by_profile:
|
|
196
|
+
config |= {"llm_profile": body.llm_profile, "llm_role": body.llm_role}
|
|
197
|
+
else:
|
|
198
|
+
config["llm_credential"] = body.llm_credential
|
|
199
|
+
if body.openai_credential:
|
|
200
|
+
config["openai_credential"] = body.openai_credential
|
|
201
|
+
# The scholarly sources (KB-039): defaults when not named, as for a setting saved
|
|
202
|
+
# before them.
|
|
203
|
+
sources = body.sources if body.sources is not None \
|
|
204
|
+
else list(literature_sources.DEFAULT_SOURCES)
|
|
205
|
+
source_keys = {k: v for k, v in (body.source_keys or {}).items() if v}
|
|
206
|
+
email = (body.openalex_email or "").strip()
|
|
207
|
+
try:
|
|
208
|
+
literature_sources.validate(sources, source_keys, email, types)
|
|
209
|
+
except literature_sources.SourcesRefused as exc:
|
|
210
|
+
unprocessable(str(exc))
|
|
211
|
+
config["sources"] = sources
|
|
212
|
+
if source_keys:
|
|
213
|
+
config["source_keys"] = source_keys
|
|
214
|
+
if email:
|
|
215
|
+
config["openalex_email"] = email
|
|
216
|
+
old = r.service_setting("literature") or {}
|
|
217
|
+
# 08 §5 step 12 (KB-032): local to remote stops the bundled service; remote to local makes
|
|
218
|
+
# the bundled start replace the `purescholar` key a remote run authenticates with; any
|
|
219
|
+
# local save restarts the bundled service. None of these happens under an active run. A
|
|
220
|
+
# remote URL change is allowed: a run keeps the URL it was created with.
|
|
221
|
+
if old and not (old.get("mode") == config["mode"] == "remote"):
|
|
222
|
+
active = active_runs(request, r)
|
|
223
|
+
if active:
|
|
224
|
+
names = ", ".join(f"{run.title!r} ({run.id})" for run in active)
|
|
225
|
+
unprocessable("the literature service cannot change while runs are active: "
|
|
226
|
+
f"{names}; wait for them to finish or stop them")
|
|
227
|
+
# The embedding the bundled corpus was indexed with survives a switch to remote mode,
|
|
228
|
+
# since the corpus stays in its volume (review of slice 3, MINOR-2).
|
|
229
|
+
indexed = old.get("embedding") if old.get("mode") == "local" else old.get("local_embedding")
|
|
230
|
+
if config["mode"] == "local" and indexed and indexed != config["embedding"] \
|
|
231
|
+
and request.query_params.get("reindex") != "yes":
|
|
232
|
+
# 08 §5 step 12: a new embedding needs a re-index; say so before applying it.
|
|
233
|
+
unprocessable("changing the embedding re-indexes the stored papers; "
|
|
234
|
+
"send ?reindex=yes to confirm")
|
|
235
|
+
if config["mode"] == "remote" and indexed:
|
|
236
|
+
config["local_embedding"] = indexed
|
|
237
|
+
r.set_service_setting("literature", config)
|
|
238
|
+
if config["mode"] == "local":
|
|
239
|
+
bundled.request_start(r) # the supervisor starts it (08 §5 step 12)
|
|
240
|
+
elif old.get("mode") == "local":
|
|
241
|
+
bundled.stop_tenant(r, "the literature service is remote")
|
|
242
|
+
if config["mode"] == "remote":
|
|
243
|
+
cred = next((c for c in r.list_credentials() if c.name == PURESCHOLAR_CREDENTIAL), None)
|
|
244
|
+
if cred is None: # deleted meanwhile
|
|
245
|
+
unprocessable(f"store the PureScholar key as {PURESCHOLAR_CREDENTIAL!r} first")
|
|
246
|
+
r.add_credential_check(cred.id, {"url": config["url"]})
|
|
247
|
+
return get_literature(request, tid)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@router.get("/services/puretrace")
|
|
251
|
+
def get_puretrace(request: Request, tid: Tenant) -> dict[str, Any]:
|
|
252
|
+
setting = repo(request, tid).service_setting("puretrace")
|
|
253
|
+
return {"url": setting.get("url") if setting else None}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@router.put("/services/puretrace")
|
|
257
|
+
def put_puretrace(request: Request, tid: Tenant, body: PureTraceIn) -> dict[str, Any]:
|
|
258
|
+
repo(request, tid).set_service_setting("puretrace",
|
|
259
|
+
{"url": body.url} if body.url else None)
|
|
260
|
+
return get_puretrace(request, tid)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---- setup -----------------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
@router.get("/setup")
|
|
266
|
+
def get_setup(request: Request, tid: Tenant) -> dict[str, Any]:
|
|
267
|
+
return setup_status(repo(request, tid), request.app.state.local_status).as_dict()
|