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.
Files changed (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. 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)
@@ -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()