evals-lab 0.11.0 → 0.13.0

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 (62) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +4 -4
  3. package/bin/evals-lab.js +9 -1
  4. package/bin/lab-file.js +97 -0
  5. package/bin/run.js +1 -1
  6. package/lab/VERSION +1 -1
  7. package/lab/demo/CREDITS.md +37 -3
  8. package/lab/demo/datasets/demo-1.json +2 -2
  9. package/lab/demo/datasets/demo-2.json +2 -2
  10. package/lab/demo/datasets/sentiment.json +383 -0
  11. package/lab/demo/datasets/weather-alerts.json +61 -0
  12. package/lab/demo/datasets/wordings.json +39 -0
  13. package/lab/demo/manifest.json +3 -3
  14. package/lab/demo/pipelines/demo-1.json +4 -4
  15. package/lab/demo/pipelines/demo-2.json +4 -4
  16. package/lab/demo/pipelines/sentiment.json +65 -0
  17. package/lab/demo/pipelines/weather-alerts.json +66 -0
  18. package/lab/demo/pipelines/wordings.json +104 -0
  19. package/lab/demo/sources/Demo 1/source.json +3 -0
  20. package/lab/demo/sources/Demo 2/source.json +3 -0
  21. package/lab/demo/sources/sentiment/bakery-croissant.txt +1 -0
  22. package/lab/demo/sources/sentiment/bus-timetable.txt +1 -0
  23. package/lab/demo/sources/sentiment/cafe-coffee.txt +1 -0
  24. package/lab/demo/sources/sentiment/concert-night.txt +1 -0
  25. package/lab/demo/sources/sentiment/film-sequel.txt +1 -0
  26. package/lab/demo/sources/sentiment/garden-tomatoes.txt +1 -0
  27. package/lab/demo/sources/sentiment/gym-contract.txt +1 -0
  28. package/lab/demo/sources/sentiment/headphones-review.txt +1 -0
  29. package/lab/demo/sources/sentiment/hotel-room.txt +1 -0
  30. package/lab/demo/sources/sentiment/library-hours.txt +1 -0
  31. package/lab/demo/sources/sentiment/meeting-minutes.txt +1 -0
  32. package/lab/demo/sources/sentiment/parcel-lost.txt +1 -0
  33. package/lab/demo/sources/sentiment/phone-update.txt +1 -0
  34. package/lab/demo/sources/sentiment/puppy-home.txt +1 -0
  35. package/lab/demo/sources/sentiment/recipe-note.txt +1 -0
  36. package/lab/demo/sources/sentiment/road-closure.txt +1 -0
  37. package/lab/demo/sources/sentiment/source.json +3 -0
  38. package/lab/demo/sources/sentiment/teacher-thanks.txt +1 -0
  39. package/lab/demo/sources/sentiment/train-delay.txt +1 -0
  40. package/lab/demo/sources/sentiment/volunteer-day.txt +1 -0
  41. package/lab/demo/sources/sentiment/weather-report.txt +1 -0
  42. package/lab/demo/sources/weather-alerts/https-api.weather.gov-alerts-types.txt +116 -0
  43. package/lab/demo/sources/weather-alerts/source.json +8 -0
  44. package/lab/evals-core.mjs +246 -57
  45. package/lab/flows/wdl.mjs +19 -0
  46. package/lab/kinds/list.mjs +5 -2
  47. package/lab/metrics/builtin.mjs +34 -7
  48. package/lab/run-evals.js +30 -11
  49. package/lab/server.py +614 -75
  50. package/lab/web/dist/assets/gallery-DpaqOg9Z.js +5 -0
  51. package/lab/web/dist/assets/main-BSw-jyYD.css +1 -0
  52. package/lab/web/dist/assets/main-CrB8wPZn.js +17 -0
  53. package/lab/web/dist/assets/tokens-CrjDMCbz.js +60 -0
  54. package/lab/web/dist/assets/tokens-DDOONQon.css +1 -0
  55. package/lab/web/dist/gallery.html +3 -3
  56. package/lab/web/dist/index.html +4 -4
  57. package/package.json +1 -1
  58. package/lab/web/dist/assets/gallery-DKy8QK3o.js +0 -5
  59. package/lab/web/dist/assets/main-BGmSu7aS.css +0 -1
  60. package/lab/web/dist/assets/main-DdGON-yl.js +0 -16
  61. package/lab/web/dist/assets/tokens-BmytrwxE.js +0 -60
  62. package/lab/web/dist/assets/tokens-DBN7I0ru.css +0 -1
package/lab/server.py CHANGED
@@ -42,6 +42,7 @@ import urllib.error
42
42
  import urllib.parse
43
43
  import urllib.request
44
44
  import zipfile
45
+ from concurrent.futures import ThreadPoolExecutor
45
46
  from contextlib import closing
46
47
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
48
  from pathlib import Path
@@ -49,10 +50,14 @@ from pathlib import Path
49
50
  # Ollama's own address on this machine, unless OLLAMA_URL names another: the
50
51
  # deployed lab's is set in the Dockerfile, and no default names a host of its.
51
52
  OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
53
+
52
54
  # The password in front of everything but /healthz (#463). Unset means open,
53
55
  # as a lab run on a workstation always has been; the deployed one sets it,
54
56
  # because it keeps setups and API keys and can spend GPU time.
55
57
  PASSWORD = os.environ.get("LAB_PASSWORD") or None
58
+ # A lab file imported on every start (docs/lab-export.md): a demo lab that
59
+ # keeps no data starts set up, its keys from $EVALSLAB_API_KEY_<SLUG>.
60
+ LAB_IMPORT = os.environ.get("LAB_IMPORT") or None
56
61
  # Where what follows the user between browsers is kept (#463). Unset means
57
62
  # nothing is, and the page stays with its browser as it always has.
58
63
  DATA_DIR = os.environ.get("DATA_DIR") or None
@@ -141,6 +146,16 @@ def host_of(url: str) -> str:
141
146
  return (urllib.parse.urlsplit(url).hostname or "").lower()
142
147
 
143
148
 
149
+ def ollama_shown():
150
+ """OLLAMA as the page may show it, where a connection with no address
151
+ goes: no user or password, should OLLAMA_URL carry one."""
152
+ try:
153
+ u = urllib.parse.urlsplit(OLLAMA)
154
+ return urllib.parse.urlunsplit(u._replace(netloc=u.netloc.rpartition("@")[2]))
155
+ except ValueError:
156
+ return ""
157
+
158
+
144
159
  # The chat endpoint each connection type's request reaches, off the /v1
145
160
  # base -- evals-core.ts's CONNECTION_TYPES, mirrored because the server is
146
161
  # stdlib-only and cannot load it. Ollama's native /api/chat sits at the root;
@@ -170,6 +185,21 @@ def auth_headers(ctype: str, key: str) -> dict:
170
185
  return {"Authorization": "Bearer " + key}
171
186
 
172
187
 
188
+ def http_key_header(how, key: str) -> dict:
189
+ """The header an HTTP endpoint connection sends its key in: its Key header
190
+ setting, kept flat on the connection (evals-core.ts's KEY_HEADERS)."""
191
+ if not key:
192
+ return {}
193
+ how = str(how or "bearer")
194
+ if how == "x-api-key":
195
+ return {"x-api-key": key}
196
+ if how == "api-key":
197
+ return {"api-key": key}
198
+ if how == "none":
199
+ return {}
200
+ return {"Authorization": "Bearer " + key}
201
+
202
+
173
203
  def connection_url(base: str, ctype: str) -> str:
174
204
  path = CONNECTION_CHAT_PATHS.get(ctype, "/chat/completions")
175
205
  if ctype in ("ollama", "ollama-cloud"):
@@ -292,6 +322,8 @@ FILE_TYPES = {
292
322
  ".dng": "image/x-adobe-dng",
293
323
  ".tif": "image/tiff",
294
324
  ".tiff": "image/tiff",
325
+ # Handed to the model whole, as a document (evals-core.ts DOCUMENT_TYPES).
326
+ ".pdf": "application/pdf",
295
327
  ".txt": "text/plain; charset=utf-8",
296
328
  ".md": "text/markdown; charset=utf-8",
297
329
  ".csv": "text/csv; charset=utf-8",
@@ -348,7 +380,8 @@ SOURCE_PROVIDERS = {
348
380
  # The API call provider (docs/dynamic-sources.md § 2): HTTP GET plus a verb,
349
381
  # headers and a body, keyed from a connection -- the same relay fetch, one
350
382
  # layer up. `produce` and `take` are attached below, beside http-get's.
351
- "api": {"label": "API call", "noun": "call", "tier": "pull", "flag": None,
383
+ # `keyed`: it sends a connection's key, so a pack may not carry one (read_pack).
384
+ "api": {"label": "API call", "noun": "call", "tier": "pull", "flag": None, "keyed": True,
352
385
  "uploads": dict(_CALL_UPLOADS), "longConfig": {"body": 8000}},
353
386
  # Supplied has no `produce`: the caller hands items in (the CLI's --items, or
354
387
  # an Upload… by hand), so the server fetches and executes nothing -- it only
@@ -774,7 +807,11 @@ def connection_auth(pid):
774
807
  connection's type says (an HTTP endpoint's Key header, else the type's own,
775
808
  auth_headers) -- it never leaves the server or reaches a Call (#258).
776
809
  Returns (headers, key, None); ({}, "", None) for no connection or no key;
777
- or (None, None, (code, why))."""
810
+ or (None, None, (code, why)). This is the third seam a connection resolves
811
+ to a key, beside worker_destinations and the relay's _unshared, so it
812
+ refuses a connection not shared with the bound workspace the same way --
813
+ the request thread's for a snapshot, the submitting request's for a live
814
+ run (docs/workspaces.md)."""
778
815
  if not pid:
779
816
  return {}, "", None
780
817
  doc = (STORE.all().get("promptlab.connections") or {}) if STORE is not None else {}
@@ -782,19 +819,16 @@ def connection_auth(pid):
782
819
  profile = next((p for p in profiles if isinstance(p, dict) and p.get("id") == pid), None)
783
820
  if profile is None:
784
821
  return None, None, (400, "that Source's auth connection is not one the lab has")
822
+ ws = workspace_of(STORE)
823
+ if not STORE.shared("profile", pid, ws):
824
+ return None, None, (403, not_shared_sentence([profile.get("name") or pid], STORE.workspace_name(ws)))
785
825
  key = str(profile.get("key") or "").strip()
786
826
  if not key:
787
827
  return {}, "", None
788
828
  if not header_safe(key):
789
829
  return None, None, (400, "that connection's key cannot be sent in a header")
790
830
  ctype = profile.get("type")
791
- if ctype == "http":
792
- options = profile.get("options") if isinstance(profile.get("options"), dict) else {}
793
- how = str(options.get("auth") or "bearer")
794
- head = ({"x-api-key": key} if how == "x-api-key" else {"api-key": key} if how == "api-key"
795
- else {} if how == "none" else {"Authorization": "Bearer " + key})
796
- else:
797
- head = auth_headers(ctype, key)
831
+ head = http_key_header(profile.get("auth"), key) if ctype == "http" else auth_headers(ctype, key)
798
832
  return head, key, None
799
833
 
800
834
 
@@ -809,6 +843,10 @@ def produce_api(config):
809
843
  method = (cfg.get("method").upper() if isinstance(cfg.get("method"), str) and cfg.get("method").strip()
810
844
  else "GET")
811
845
  auth, key, why = connection_auth(cfg.get("connection"))
846
+ if why and why[0] == 403:
847
+ # Refused, not failed: no request went out, so the caller answers the
848
+ # refusal rather than keeping a failed Call.
849
+ return {"error": why[1], "refused": why, "request": {"url": url, "method": method}, "status": None}
812
850
  if why:
813
851
  return {"error": why[1], "request": {"url": url, "method": method}, "status": None}
814
852
  headers = {**_header_pairs(cfg.get("reqHeaders")), **auth}
@@ -1187,9 +1225,20 @@ def workspace_of(store) -> str:
1187
1225
 
1188
1226
 
1189
1227
  # The `connection_shares` sentinels (phase 4 fills and enforces the table;
1190
- # phase 1 only creates it empty): every workspace, and future ones.
1228
+ # phase 1 only creates it empty): every workspace, future ones, and none.
1229
+ # No rows is All, so a set that loses its last workspace (a purge, the Share
1230
+ # link's Undo, a stale id) holds SHARE_NONE rather than nothing (#506).
1191
1231
  SHARE_ALL = "*all"
1192
1232
  SHARE_NEW = "*new"
1233
+ SHARE_NONE = "*none"
1234
+
1235
+ def hold_nowhere(db, conns) -> None:
1236
+ """SHARE_NONE for each (kind, id) in [conns] left with no share row, which
1237
+ would otherwise read as shared with every workspace."""
1238
+ db.executemany("INSERT INTO connection_shares (kind, id, workspace) SELECT ?, ?, ? "
1239
+ "WHERE NOT EXISTS (SELECT 1 FROM connection_shares WHERE kind = ? AND id = ?)",
1240
+ [(k, i, SHARE_NONE, k, i) for k, i in conns])
1241
+
1193
1242
 
1194
1243
  # How the `docs` table scopes by key (docs/workspaces.md): workflows are
1195
1244
  # per-workspace, profiles/tokens global, and promptlab.versions is split --
@@ -1494,7 +1543,8 @@ class Store:
1494
1543
  # An AI Connection or an Account (kind "profile"/"account", id the
1495
1544
  # connection's -- the Target profile's it kept -- or the account's) is
1496
1545
  # usable in a workspace by its share set in `connection_shares`: a row for
1497
- # that workspace, or the SHARE_ALL sentinel.
1546
+ # that workspace, or the SHARE_ALL sentinel. SHARE_NONE is a row that
1547
+ # matches no workspace, so a set holding only it is usable nowhere.
1498
1548
  # No row at all is the migration and new-connection default -- shared with
1499
1549
  # every workspace, so nothing stops running the moment workspaces exist
1500
1550
  # (open question 2, resolved All); narrowing a connection is adding the
@@ -1515,7 +1565,7 @@ class Store:
1515
1565
  """Every connection's share set, for the Connections menus: keyed by
1516
1566
  "<kind>:<id>", the workspace values as stored (workspace ids and the
1517
1567
  sentinels). A connection with no row is absent, which the page reads as
1518
- shared with All. No key is anywhere in this (#258)."""
1568
+ shared with All; one with SHARE_NONE is shared with no workspace. No key is anywhere in this (#258)."""
1519
1569
  out: dict = {}
1520
1570
  with self.lock, closing(sqlite3.connect(self.path)) as db:
1521
1571
  for kind, cid, ws in db.execute("SELECT kind, id, workspace FROM connection_shares"):
@@ -1523,15 +1573,20 @@ class Store:
1523
1573
  return out
1524
1574
 
1525
1575
  def set_shares(self, kind: str, cid: str, workspaces) -> None:
1526
- """Replace a connection's share set. SHARE_ALL means every workspace,
1527
- so it is kept alone; only the sentinels and live workspace ids are let
1528
- in, so a stale id cannot linger in the table."""
1576
+ """Replace a connection's share set. SHARE_ALL means every workspace
1577
+ and SHARE_NONE none, so each is kept alone; only the sentinels and live
1578
+ workspace ids are let in, so a stale id cannot linger in the table. A
1579
+ set named but left empty by that is SHARE_NONE, not the All an empty
1580
+ set reads as; an empty set asked for is a plain delete, so a failed
1581
+ lab import's rollback leaves no row for a connection it never wrote."""
1529
1582
  with self.lock, closing(sqlite3.connect(self.path)) as db, db:
1530
1583
  valid = {r[0] for r in db.execute("SELECT id FROM workspaces WHERE trash IS NULL")}
1531
1584
  chosen = [w for w in dict.fromkeys(workspaces or [])
1532
- if w in (SHARE_ALL, SHARE_NEW) or w in valid]
1585
+ if w in (SHARE_ALL, SHARE_NEW, SHARE_NONE) or w in valid]
1533
1586
  if SHARE_ALL in chosen:
1534
1587
  chosen = [SHARE_ALL]
1588
+ elif SHARE_NONE in chosen or (workspaces and not chosen):
1589
+ chosen = [SHARE_NONE]
1535
1590
  db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ?", (kind, cid))
1536
1591
  db.executemany("INSERT INTO connection_shares (kind, id, workspace) VALUES (?, ?, ?)",
1537
1592
  [(kind, cid, w) for w in chosen])
@@ -1540,14 +1595,18 @@ class Store:
1540
1595
  """Add or remove one workspace from a connection's share set -- the Run
1541
1596
  bar's Share link and its Undo (docs/workspaces.md). The link is offered
1542
1597
  only where the connection is actually narrowed, so adding a row is what
1543
- grants the blocked workspace its use and Undo takes it back."""
1598
+ grants the blocked workspace its use and Undo takes it back -- to
1599
+ SHARE_NONE when it was the last, as a set shared nowhere was."""
1544
1600
  with self.lock, closing(sqlite3.connect(self.path)) as db, db:
1545
1601
  if on:
1546
1602
  db.execute("INSERT OR IGNORE INTO connection_shares (kind, id, workspace) "
1547
1603
  "VALUES (?, ?, ?)", (kind, cid, ws))
1604
+ db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ? AND workspace = ?",
1605
+ (kind, cid, SHARE_NONE))
1548
1606
  else:
1549
1607
  db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ? AND workspace = ?",
1550
1608
  (kind, cid, ws))
1609
+ hold_nowhere(db, [(kind, cid)])
1551
1610
 
1552
1611
  def workspace_name(self, ws) -> str:
1553
1612
  """A workspace's name, for the refusal sentence the Run bar shows."""
@@ -1677,8 +1736,11 @@ class Workspaces:
1677
1736
  if isinstance(shares, list)
1678
1737
  else list(db.execute("SELECT kind, id FROM connection_shares WHERE workspace = ?",
1679
1738
  (SHARE_NEW,))))
1739
+ grant = [(k, i) for k, i in grant if k and i]
1680
1740
  db.executemany("INSERT OR IGNORE INTO connection_shares (kind, id, workspace) VALUES (?, ?, ?)",
1681
- [(k, i, wid) for k, i in grant if k and i])
1741
+ [(k, i, wid) for k, i in grant])
1742
+ db.executemany("DELETE FROM connection_shares WHERE kind = ? AND id = ? AND workspace = ?",
1743
+ [(k, i, SHARE_NONE) for k, i in grant])
1682
1744
  return wid, None
1683
1745
 
1684
1746
  def rename(self, wid, name) -> tuple:
@@ -1818,7 +1880,11 @@ class Workspaces:
1818
1880
  db.execute("DELETE FROM queue WHERE workspace = ?", (ws,))
1819
1881
  db.execute("DELETE FROM runs WHERE workspace = ?", (ws,))
1820
1882
  db.execute("DELETE FROM docs WHERE workspace = ?", (ws,))
1883
+ # A connection shared only here is shared nowhere after, not with
1884
+ # All, which is what no rows would read as (#506).
1885
+ held = list(db.execute("SELECT kind, id FROM connection_shares WHERE workspace = ?", (ws,)))
1821
1886
  db.execute("DELETE FROM connection_shares WHERE workspace = ?", (ws,))
1887
+ hold_nowhere(db, held)
1822
1888
  db.execute("DELETE FROM workspaces WHERE id = ?", (ws,))
1823
1889
  return sids, rids
1824
1890
 
@@ -2488,6 +2554,8 @@ class Sources:
2488
2554
  if produce is None:
2489
2555
  return None, (400, "that provider cannot be produced from on the server")
2490
2556
  result = produce(cfg)
2557
+ if result.get("refused"):
2558
+ return None, result["refused"]
2491
2559
  request = result.get("request")
2492
2560
  status = result.get("status")
2493
2561
  content = result.get("content")
@@ -4384,8 +4452,8 @@ class Datasets:
4384
4452
 
4385
4453
  def save(self, did, version, body):
4386
4454
  """The body, written at [version] -- the one it began from. Returns
4387
- ({version}, None), or (None, error); a stale version's error carries
4388
- the current row."""
4455
+ ({version, versions}, None), or (None, error); a stale version's error
4456
+ carries the current row."""
4389
4457
  if type(version) is not int:
4390
4458
  return None, (400, "a save names the version it began from")
4391
4459
  # A body of an earlier version -- from a page loaded before this one --
@@ -4404,7 +4472,9 @@ class Datasets:
4404
4472
  self._keep(db, r, text)
4405
4473
  db.execute("UPDATE datasets SET body = ?, version = ?, updated_at = ? WHERE id = ?",
4406
4474
  (text, version + 1, self._now(), did))
4407
- return {"version": version + 1}, None
4475
+ # The save may have cut a version: a link pins the newest (#547).
4476
+ newest = self._head(db, did)["n"]
4477
+ return {"version": version + 1, "versions": newest}, None
4408
4478
 
4409
4479
  def remove(self, did):
4410
4480
  """Into the trash, at once; Undo restores it. Returns (token, None)."""
@@ -4836,8 +4906,8 @@ class FilterSets:
4836
4906
 
4837
4907
  def save(self, fid, version, body):
4838
4908
  """The body, written at [version] -- the one it began from. Returns
4839
- ({version}, None), or (None, error); a stale version's error carries
4840
- the current row."""
4909
+ ({version, versions}, None), or (None, error); a stale version's error
4910
+ carries the current row."""
4841
4911
  if type(version) is not int:
4842
4912
  return None, (400, "a save names the version it began from")
4843
4913
  body = upgrade_filter_set_body(body)
@@ -4854,7 +4924,9 @@ class FilterSets:
4854
4924
  self._keep(db, r, text)
4855
4925
  db.execute("UPDATE filter_sets SET body = ?, version = ?, updated_at = ? WHERE id = ?",
4856
4926
  (text, version + 1, self._now(), fid))
4857
- return {"version": version + 1}, None
4927
+ # The save may have cut a version: a link pins the newest (#547).
4928
+ newest = self._head(db, fid)["n"]
4929
+ return {"version": version + 1, "versions": newest}, None
4858
4930
 
4859
4931
  def remove(self, fid):
4860
4932
  """Into the trash, at once; Undo restores it. Returns (token, None)."""
@@ -5024,32 +5096,47 @@ class Pipelines:
5024
5096
  self.store._put(db, self.VERSIONS, target_ws, tv + 1, tbody, self._now())
5025
5097
 
5026
5098
 
5027
- def make_thumbs(sid, name):
5028
- """JPEGs a browser can render, beside a stored file: a tile-size one
5029
- for every image type, and a full-size render for the formats a browser
5030
- cannot decode, whose zoom view has no original to show. Non-fatal by
5031
- design: a missing thumb is a blank tile, not a failed upload, and the
5032
- startup sweep clears an orphan on a later start. #33 #527"""
5099
+ def thumb_jobs(sid, name):
5100
+ """The `magick` runs that make a stored file's thumbnails, each (argv,
5101
+ out): a tile-size JPEG for every image type, and a full-size render for
5102
+ the formats a browser cannot decode, whose zoom view has no original to
5103
+ show. The paths are looked up here, in the request's own thread, since a
5104
+ Source is found through the workspace bound to it. #33 #527"""
5033
5105
  if CONVERT is None or Path(name).suffix.lower() not in IMAGE_TYPES:
5034
- return
5106
+ return []
5107
+ jobs = []
5035
5108
  for full in (False, True):
5036
5109
  if full and Path(name).suffix.lower() not in THUMB_TYPES:
5037
5110
  continue
5038
5111
  path = SOURCES.file_path(sid, name)
5039
5112
  out = SOURCES.thumb_path(sid, name, full)
5040
5113
  if path is None or out is None:
5041
- return
5114
+ return jobs
5042
5115
  size = FULL_SIZE if full else TILE_SIZE
5043
- out.parent.mkdir(parents=True, exist_ok=True)
5044
- try:
5045
- subprocess.run([CONVERT, str(path), "-auto-orient",
5046
- "-thumbnail", f"{size}x{size}>",
5047
- "-quality", str(FULL_QUALITY if full else TILE_QUALITY),
5048
- str(out)],
5049
- timeout=30, stdout=subprocess.DEVNULL,
5050
- stderr=subprocess.DEVNULL, check=True)
5051
- except (subprocess.SubprocessError, OSError):
5052
- out.unlink(missing_ok=True)
5116
+ jobs.append(([CONVERT, str(path), "-auto-orient",
5117
+ "-thumbnail", f"{size}x{size}>",
5118
+ "-quality", str(FULL_QUALITY if full else TILE_QUALITY),
5119
+ str(out)], out))
5120
+ return jobs
5121
+
5122
+
5123
+ def run_thumb(job):
5124
+ """One thumbnail. Non-fatal by design: a missing thumb is a blank tile,
5125
+ not a failed upload, and the startup sweep clears an orphan on a later
5126
+ start."""
5127
+ argv, out = job
5128
+ out.parent.mkdir(parents=True, exist_ok=True)
5129
+ try:
5130
+ subprocess.run(argv, timeout=30, stdout=subprocess.DEVNULL,
5131
+ stderr=subprocess.DEVNULL, check=True)
5132
+ except (subprocess.SubprocessError, OSError):
5133
+ out.unlink(missing_ok=True)
5134
+
5135
+
5136
+ def make_thumbs(sid, name):
5137
+ """A stored file's thumbnails, made now (thumb_jobs)."""
5138
+ for job in thumb_jobs(sid, name):
5139
+ run_thumb(job)
5053
5140
 
5054
5141
 
5055
5142
  # ---- Packs ------------------------------------------------------------------
@@ -5099,6 +5186,43 @@ def demo_pack() -> bytes:
5099
5186
  return buf.getvalue()
5100
5187
 
5101
5188
 
5189
+ # The connection types a prompt cannot be asked of (evals-core.ts: their
5190
+ # `answers` lacks "prompt", or they answer from the item and are no model).
5191
+ NO_WORDS = {"echo", "recorded", "http"}
5192
+
5193
+
5194
+ def pack_connection(ws):
5195
+ """The AI Connection a pack's target answers on where it names one this
5196
+ lab lacks (#550): the lab's grader, or else the first connection that is
5197
+ asked words, either shared with workspace [ws]; None where there is none,
5198
+ so the target keeps its placeholder and the Run bar says to add one."""
5199
+ body = ((STORE.all().get("promptlab.connections") or {}).get("body") or {}) if STORE is not None else {}
5200
+ words = [c for c in body.get("list") or []
5201
+ if isinstance(c, dict) and isinstance(c.get("id"), str) and c["id"]
5202
+ and c.get("type") not in NO_WORDS and STORE.shared("profile", c["id"], ws)]
5203
+ return next((c for c in words if c["id"] == body.get("grader")), words[0] if words else None)
5204
+
5205
+
5206
+ def repoint_targets(doc: dict, ws) -> None:
5207
+ """A pack pipeline's targets that name a connection this lab does not have,
5208
+ pointed at the one pack_connection picks, its profile dropped with it."""
5209
+ targets = doc.get("targets")
5210
+ if not isinstance(targets, list) or STORE is None:
5211
+ return
5212
+ have = {c.get("id") for c in ((STORE.all().get("promptlab.connections") or {}).get("body") or {}).get("list") or []
5213
+ if isinstance(c, dict)}
5214
+ pick = None
5215
+ for t in targets:
5216
+ ref = t.get("connection") if isinstance(t, dict) else None
5217
+ if not isinstance(ref, dict) or ref.get("id") in have:
5218
+ continue
5219
+ pick = pick or pack_connection(ws)
5220
+ if pick is None:
5221
+ return
5222
+ t["connection"] = {"id": pick["id"], "name": pick.get("name") or pick["id"]}
5223
+ t["profile"] = None
5224
+
5225
+
5102
5226
  def pack_requires_problem(manifest: dict, plugins: set) -> str:
5103
5227
  """Why this lab cannot take a pack its manifest describes, or ""."""
5104
5228
  req = manifest.get("requires") or {}
@@ -5120,7 +5244,9 @@ def pack_requires_problem(manifest: dict, plugins: set) -> str:
5120
5244
  def read_pack(data: bytes):
5121
5245
  """A pack's zip read and checked whole: (pack, None) or (None, why). The
5122
5246
  pack is {manifest, datasets: [(key, name, body, raw)], pipelines:
5123
- [(key, doc)], presets: [...], sources: {name: [(file, bytes)]}}."""
5247
+ [(key, doc)], presets: [...], sources: {name: [(file, bytes)]}, source_meta:
5248
+ {name: {name, type, config}}} -- a Source's meta from its folder's
5249
+ `source.json`, or the folder's name as a File Library."""
5124
5250
  try:
5125
5251
  zf = zipfile.ZipFile(io.BytesIO(data))
5126
5252
  except zipfile.BadZipFile:
@@ -5149,7 +5275,8 @@ def read_pack(data: bytes):
5149
5275
  for k in ("name", "packVersion"):
5150
5276
  if not isinstance(manifest.get(k), str) or not manifest[k].strip():
5151
5277
  return None, f"a pack's manifest needs a {k}"
5152
- pack = {"manifest": manifest, "datasets": [], "pipelines": [], "presets": [], "sources": {}}
5278
+ pack = {"manifest": manifest, "datasets": [], "pipelines": [], "presets": [], "sources": {}, "source_meta": {}}
5279
+ declared = {}
5153
5280
  for name in sorted(names):
5154
5281
  parts = name.split("/")
5155
5282
  if name == "manifest.json":
@@ -5192,21 +5319,86 @@ def read_pack(data: bytes):
5192
5319
  or not (isinstance(doc.get("jobs"), list) or isinstance(doc.get("chains"), list))):
5193
5320
  return None, f"{name} is not a pipeline document the lab reads"
5194
5321
  pack["pipelines"].append((parts[1][:-5], doc))
5322
+ elif len(parts) == 3 and parts[0] == "sources" and parts[2] == PACK_SOURCE_META:
5323
+ src = source_name(parts[1])
5324
+ meta, why = load(name)
5325
+ if why:
5326
+ return None, why
5327
+ if src is None or not isinstance(meta, dict):
5328
+ return None, f"{name} is not a Source's {PACK_SOURCE_META}"
5329
+ declared[src] = meta
5195
5330
  elif len(parts) == 3 and parts[0] == "sources":
5196
5331
  src, file = source_name(parts[1]), source_name(parts[2])
5197
- if src is None or file is None or Path(file).suffix.lower() not in FILE_TYPES:
5332
+ if src is None or file is None:
5198
5333
  return None, f"{name} is not a file a Source takes"
5199
5334
  if names[name].file_size > MAX_FILE:
5200
5335
  return None, f"{name} is over the cap for a file"
5201
5336
  pack["sources"].setdefault(src, []).append((file, zf.read(name)))
5202
5337
  else:
5203
5338
  return None, f"{name} is not something a pack holds"
5204
- for src, files in pack["sources"].items():
5339
+ for src in sorted(set(pack["sources"]) | set(declared)):
5340
+ meta, why = pack_source_meta(src, declared.get(src))
5341
+ if why:
5342
+ return None, why
5343
+ pack["source_meta"][src] = meta
5344
+ files = pack["sources"].setdefault(src, [])
5345
+ entry = source_entry(meta["type"], meta["config"]) or {}
5346
+ takes, take = entry.get("uploads") or {}, entry.get("take")
5347
+ for i, (file, blob) in enumerate(files):
5348
+ if Path(file).suffix.lower() not in takes:
5349
+ return None, f"sources/{src}/{file} is not a file a Source takes"
5350
+ # A shipped Call is checked as any other is: a token refuses the
5351
+ # pack, and a secret's shape is redacted before it is kept.
5352
+ if take is not None:
5353
+ blob, err = take(file, blob)
5354
+ if err:
5355
+ return None, f"sources/{src}/{file}: {err[1]}"
5356
+ files[i] = (file, blob)
5205
5357
  if sum(len(b) for _, b in files) > MAX_SOURCE:
5206
5358
  return None, f"the Source {src} is over the cap for a Source"
5207
5359
  return pack, None
5208
5360
 
5209
5361
 
5362
+ # A pack Source's own meta, beside its files: {name?, type?, config?}.
5363
+ PACK_SOURCE_META = "source.json"
5364
+
5365
+
5366
+ def pack_source_meta(folder, meta):
5367
+ """A pack Source's {name, type, config} from its folder's source.json (or
5368
+ None: a File Library named after the folder), or (None, why). A pack may
5369
+ carry a File Library or a dynamic Source whose provider pulls with no key
5370
+ and is enrolled here -- the line a plugin's provider is held to
5371
+ (docs/packs.md); anything else refuses the pack whole."""
5372
+ meta = meta or {}
5373
+ at = f"sources/{folder}/{PACK_SOURCE_META}"
5374
+ unknown = set(meta) - {"name", "type", "config"}
5375
+ if unknown:
5376
+ return None, f"{at} holds {sorted(unknown)[0]}, which a pack Source does not"
5377
+ name, err = Sources._name(meta.get("name", folder))
5378
+ if err:
5379
+ return None, f"{at}: {err[1]}"
5380
+ kind = meta.get("type", DEFAULT_SOURCE_TYPE)
5381
+ config = meta.get("config")
5382
+ if kind == DEFAULT_SOURCE_TYPE:
5383
+ if config is not None:
5384
+ return None, f"{at}: a File Library takes no config"
5385
+ return {"name": name, "type": kind, "config": None}, None
5386
+ if not (SOURCE_TYPES.get(kind) or {}).get("providers"):
5387
+ return None, f"{at}: a pack carries a File Library or a dynamic Source, not {kind!r}"
5388
+ provider = config.get("provider") if isinstance(config, dict) else None
5389
+ entry = SOURCE_PROVIDERS.get(provider)
5390
+ if entry is None:
5391
+ return None, f"{at}: the lab has no provider {provider!r}"
5392
+ if entry.get("tier") != "pull" or entry.get("keyed") or config.get("connection"):
5393
+ return None, f"{at}: a pack's dynamic Source pulls with no key, and {entry.get('label', provider)} does not"
5394
+ if not provider_enrolled(entry):
5395
+ return None, f"{at}: this lab does not enrol {entry.get('label', provider)}"
5396
+ _, err = Sources._checked(kind, config)
5397
+ if err:
5398
+ return None, f"{at}: {err[1]}"
5399
+ return {"name": name, "type": kind, "config": config}, None
5400
+
5401
+
5210
5402
  class Packs:
5211
5403
  """What each installed pack made, so it can be updated and removed."""
5212
5404
 
@@ -5341,22 +5533,41 @@ class Packs:
5341
5533
  made = {}
5342
5534
  # Sources first, then datasets: a pipeline names both.
5343
5535
  src_ids = {}
5536
+ thumbs = []
5344
5537
  for name, files in pack["sources"].items():
5538
+ meta = pack["source_meta"][name]
5345
5539
  sid = owned.get(("source", name))
5346
- if sid and SOURCES.get(sid):
5347
- old = [f["name"] for f in SOURCES.get(sid)["files"]]
5540
+ have = SOURCES.get(sid) if sid else None
5541
+ # An update keeps the Source's name, so one renamed here, or
5542
+ # by a later pack, is not renamed back. Another kind is
5543
+ # another Source.
5544
+ if have and have.get("type") == meta["type"]:
5545
+ old = [f["name"] for f in have["files"]]
5348
5546
  if old:
5349
5547
  SOURCES.remove_files(sid, old)
5548
+ if meta["config"] is not None:
5549
+ SOURCES.update(sid, {"config": meta["config"]})
5350
5550
  else:
5351
5551
  taken = {s["name"] for s in SOURCES.list()}
5352
- src, _ = SOURCES.create(unique_dataset_name(name, taken))
5552
+ src, _ = SOURCES.create(unique_dataset_name(meta["name"], taken), meta["type"], meta["config"])
5353
5553
  sid = src["id"]
5354
5554
  for file, blob in files:
5355
5555
  stored, err = SOURCES.add_file(sid, file, blob)
5356
- if not err:
5357
- make_thumbs(sid, stored)
5556
+ if err:
5557
+ continue
5558
+ thumbs += thumb_jobs(sid, stored)
5559
+ # A dynamic Source's files are Calls: the pack's are its
5560
+ # snapshot, fetched when the pack was made.
5561
+ if meta["type"] != DEFAULT_SOURCE_TYPE:
5562
+ cfg = meta["config"]
5563
+ request = {"url": cfg["url"], "method": cfg.get("method") or "GET"} if cfg.get("url") else None
5564
+ SOURCES._record_call(sid, stored, "snapshot", cfg.get("provider"), request, None, None, stored)
5358
5565
  src_ids[name] = sid
5359
5566
  made[("source", name)] = sid
5567
+ # The demo pack installs as the server starts, before it answers,
5568
+ # and one `magick` at a time was most of that wait.
5569
+ with ThreadPoolExecutor(max_workers=min(4, os.cpu_count() or 1)) as pool:
5570
+ list(pool.map(run_thumb, thumbs))
5360
5571
  ds_ids = {}
5361
5572
  cuts = [] # (kind, id, the document as it was), kept before it changes
5362
5573
  for key, name, body, raw, preview in pack["datasets"]:
@@ -5404,6 +5615,7 @@ class Packs:
5404
5615
  sid = src_ids.get(content["ref"].get("id")) or src_ids.get(content["ref"].get("name"))
5405
5616
  if sid:
5406
5617
  content["ref"] = {"id": sid, "name": SOURCES.get(sid)["name"]}
5618
+ repoint_targets(doc, ws)
5407
5619
  new_work.append((key, doc))
5408
5620
  def place(body):
5409
5621
  body.setdefault("list", [])
@@ -5413,7 +5625,8 @@ class Packs:
5413
5625
  entry = next((w for w in body["list"] if w.get("id") == wid), None) if wid else None
5414
5626
  if entry:
5415
5627
  cuts.append(("pipeline", wid, entry.get("work")))
5416
- entry["work"] = doc
5628
+ # Its name is the lab's now, as a dataset's is.
5629
+ entry["work"] = {**doc, "name": entry.get("name") or doc.get("name")}
5417
5630
  else:
5418
5631
  wid = "w" + secrets.token_hex(6)
5419
5632
  name = unique_dataset_name(doc.get("name") or key, {w.get("name") for w in body["list"]})
@@ -7280,11 +7493,19 @@ def worker_destinations(run: dict):
7280
7493
  if STORE is not None:
7281
7494
  doc = STORE.all().get("promptlab.connections") or {}
7282
7495
  stored = (doc.get("body") or {}).get("list") or []
7283
- # One variable a connection, however many targets or profiles share it.
7284
- env = {}
7496
+ # One variable a connection, however many targets or profiles share it --
7497
+ # and never two connections one variable, whichever path the run came by:
7498
+ # a stored snapshot is not checked again as a submit is (run_problems),
7499
+ # and the runner reads each connection's key from its slug's variable, so
7500
+ # the second would send the first one's key to its own address.
7501
+ env, spelt = {}, {}
7285
7502
  for key, conn in table.items():
7286
7503
  pid = answer_connection(key, conn)
7287
7504
  name = conn.get("name") or pid
7505
+ var = key_var(pid, conn.get("slug") if isinstance(conn.get("slug"), str) else None)
7506
+ if var in spelt and spelt[var][0] != pid:
7507
+ return None, f"Connections {spelt[var][1]} and {name} both take their key from ${var}"
7508
+ spelt[var] = (pid, name)
7288
7509
  if isinstance(conn, dict) and conn.get("type") in LOCAL_CONNECTIONS:
7289
7510
  continue
7290
7511
  base = api_base(str(conn.get("url") or "")) or api_base(OLLAMA)
@@ -7301,7 +7522,7 @@ def worker_destinations(run: dict):
7301
7522
  why = allowed(base, key)
7302
7523
  if why:
7303
7524
  return None, f"Connection {name}: {why}"
7304
- env[key_var(pid, conn.get("slug"))] = key
7525
+ env[var] = key
7305
7526
  return env, None
7306
7527
 
7307
7528
 
@@ -7333,10 +7554,12 @@ def worker_destinations(run: dict):
7333
7554
  # 16: a target names an AI Connection and, optionally, a Connection Profile,
7334
7555
  # where it named one Target profile; the run's table is keyed by the pair
7335
7556
  # (answer_key), each entry naming the connection whose key it takes (#445).
7336
- PIPELINE_VERSION = 16
7557
+ # 17: an HTTP Request target has no `api` of its own; its job's flow step's
7558
+ # is the one its body is read by (#477).
7559
+ PIPELINE_VERSION = 17
7337
7560
  # What a stored run may be: the current version, and the ones evals-core.ts's
7338
7561
  # upgradePipeline reads. A new submission is upgraded to the current one.
7339
- READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
7562
+ READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17)
7340
7563
  TARGET_CAP = 4
7341
7564
  # Target steps whose words the Prompt library does not record as a use: they
7342
7565
  # ask no model (evals-core.ts's STEP_TYPES.echo).
@@ -7369,7 +7592,7 @@ CONNECTION_TYPES = {
7369
7592
  "echo": (),
7370
7593
  # An HTTP endpoint (evals-core.ts): an address and how its key travels; an
7371
7594
  # HTTP Request step builds the whole request.
7372
- "http": ("auth", "headers", "testPath"),
7595
+ "http": ("auth", "headers", "testPath", "requestFormat"),
7373
7596
  "ollama": ("seed", "nPredict", "temperature", "repeatPenalty"),
7374
7597
  "ollama-cloud": ("seed", "nPredict", "temperature", "repeatPenalty"),
7375
7598
  "openai-compatible": ("temperature", "nPredict"),
@@ -7378,10 +7601,12 @@ CONNECTION_TYPES = {
7378
7601
  "presencePenalty", "dryMultiplier", "dryBase", "dryAllowedLength", "grammar",
7379
7602
  "imagePosition", "cachePrompt", "thinking", "rejectLoops"),
7380
7603
  }
7381
- # Types that answer from the item and send no request (Echo, #97): no
7382
- # address to reach, no key to carry, so the destination rules have nothing to
7383
- # say about them. A plugin's type joins with `"local": true`.
7384
- LOCAL_CONNECTIONS = {"echo"}
7604
+ # Types that answer from the item and send no request (Echo, #97; Recorded,
7605
+ # #299): no address to reach, no key to carry, so the destination rules have
7606
+ # nothing to say about them. A target step stands in for each now, so a stored
7607
+ # row of one is never exported (lab_export). A plugin's type joins with
7608
+ # `"local": true`.
7609
+ LOCAL_CONNECTIONS = {"echo", "recorded"}
7385
7610
  # The built-in types, kept apart so a plugin's can be taken away again.
7386
7611
  BUILTIN_CONNECTION_TYPES = dict(CONNECTION_TYPES)
7387
7612
  BUILTIN_LOCAL = set(LOCAL_CONNECTIONS)
@@ -7424,7 +7649,7 @@ def run_answer(table, key):
7424
7649
  # other setting set is a Connection Profile's. Mirrored, and held to the
7425
7650
  # core's splitProfile, by proxy-check.py.
7426
7651
  AI_CONNECTION_FIELDS = ("id", "name", "slug", "type", "url", "key", "model", "profile")
7427
- CONNECTION_SCOPED = {"http": ("auth", "headers", "testPath")}
7652
+ CONNECTION_SCOPED = {"http": ("auth", "headers", "testPath", "requestFormat")}
7428
7653
  # evals-core.ts REPLY_TOKENS_BEFORE, and the setting of each type that sent it.
7429
7654
  REPLY_TOKENS_BEFORE = "160"
7430
7655
  REPLY_TOKENS_SETTING = {"openai-compatible": "nPredict", "llama.cpp": "nPredict", "anthropic": "maxTokens"}
@@ -7526,14 +7751,7 @@ def http_endpoint_test(payload, key):
7526
7751
  name, value = part.split(":", 1)
7527
7752
  if name.strip():
7528
7753
  headers[name.strip()] = value.strip()
7529
- if key:
7530
- how = str(settings.get("auth") or "bearer")
7531
- if how == "x-api-key":
7532
- headers["x-api-key"] = key
7533
- elif how == "api-key":
7534
- headers["api-key"] = key
7535
- elif how != "none":
7536
- headers["Authorization"] = "Bearer " + key
7754
+ headers.update(http_key_header(settings.get("auth"), key))
7537
7755
  if not all(header_safe(v) for v in headers.values()):
7538
7756
  return "", {}, "A header of this profile has characters that cannot be sent."
7539
7757
  return origin + (path if path.startswith("/") else "/" + path), headers, ""
@@ -7834,6 +8052,11 @@ class NoRedirects(urllib.request.HTTPRedirectHandler):
7834
8052
  # -- a host nobody put on the list -- and the relay reported 200. An
7835
8053
  # allow-list that an environment variable can route around is not one.
7836
8054
  OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirects)
8055
+ # urllib's own "Python-urllib/3.x" is refused outright by many public APIs
8056
+ # behind Cloudflare (#512), so every outbound request names the lab instead --
8057
+ # honestly, never a browser. A request's own User-Agent, such as an API call
8058
+ # Source's Headers, wins: urllib adds this only where none is set.
8059
+ OPENER.addheaders = [("User-Agent", "evals-lab")]
7837
8060
 
7838
8061
 
7839
8062
  class Handler(BaseHTTPRequestHandler):
@@ -8025,7 +8248,8 @@ class Handler(BaseHTTPRequestHandler):
8025
8248
  # its own, and only in a lab with a store to keep it in.
8026
8249
  return self._json(200, {"targetCap": TARGET_CAP, "microsoft": microsoft_config(),
8027
8250
  "microsoftEditable": MICROSOFT is not None and not M365_CLIENT_ID,
8028
- "dynamicProviders": dynamic_providers_config()})
8251
+ "dynamicProviders": dynamic_providers_config(),
8252
+ "ollama": ollama_shown()})
8029
8253
  if path == "/api/state":
8030
8254
  if STORE is None:
8031
8255
  return self._send(404, b"not found", "text/plain")
@@ -8119,6 +8343,16 @@ class Handler(BaseHTTPRequestHandler):
8119
8343
  return self._send(404, b"not found", "text/plain")
8120
8344
  ctype = TYPES[".json"] if f.suffix in (".json", ".map") else TYPES[".js"]
8121
8345
  return self._send(200, f.read_bytes(), ctype, (("Cache-Control", "no-store"),))
8346
+ if path == "/api/lab-export":
8347
+ # The lab's settings as a file (docs/lab-export.md): every
8348
+ # workspace's entry, or ?workspace=<slug>'s alone. No key.
8349
+ if STORE is None or WORKSPACES is None:
8350
+ return self._send(404, b"not found", "text/plain")
8351
+ only = (urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query).get("workspace") or [None])[0]
8352
+ out = lab_export(only)
8353
+ if out is None:
8354
+ return self._json(404, {"error": f"no workspace at /w/{only}"})
8355
+ return self._json(200, out)
8122
8356
  if path == "/api/packs":
8123
8357
  if PACKS is None:
8124
8358
  return self._send(404, b"not found", "text/plain")
@@ -8471,6 +8705,22 @@ class Handler(BaseHTTPRequestHandler):
8471
8705
  return self._filter_sets_post(path)
8472
8706
  if path == "/api/copy-move" or path.startswith("/api/copy-move/"):
8473
8707
  return self._copy_move_post(path)
8708
+ if path == "/api/lab-import":
8709
+ # A lab file into this lab (docs/lab-export.md), adding what it
8710
+ # lacks. `keys` is what `evals-lab import` read from its own
8711
+ # $EVALSLAB_API_KEY_<SLUG> variables; the page sends none.
8712
+ if STORE is None or WORKSPACES is None:
8713
+ return self._send(404, b"not found", "text/plain")
8714
+ payload = self._payload()
8715
+ if payload is None:
8716
+ return self._json(400, {"error": "an import is {lab, keys?} as JSON"})
8717
+ keys = payload.get("keys") if isinstance(payload.get("keys"), dict) else {}
8718
+ keys = {k: v for k, v in keys.items()
8719
+ if isinstance(k, str) and k.startswith("EVALSLAB_API_KEY_") and isinstance(v, str)}
8720
+ said, err = lab_import(payload.get("lab"), keys)
8721
+ if err:
8722
+ return self._json(err[0], {"error": err[1]})
8723
+ return self._json(200, {"said": said})
8474
8724
  if path == "/api/packs" or path.startswith("/api/packs/"):
8475
8725
  return self._packs_post(path)
8476
8726
  if path == "/api/workspaces" or path.startswith("/api/workspaces/"):
@@ -8629,8 +8879,8 @@ class Handler(BaseHTTPRequestHandler):
8629
8879
  else None. Only a held key (KEY_HELD<id>) names a stored profile; a key
8630
8880
  typed into the Setup form is a connection not yet saved, so nothing
8631
8881
  gates it. This is the relay/Test seam, beside worker_destinations' run
8632
- seam -- the two places a connection resolves to a key, so the two
8633
- places sharing is enforced. The caller writes the 403: a helper that
8882
+ seam and connection_auth's API Source seam -- the places a connection
8883
+ resolves to a key, so the places sharing is enforced. The caller writes the 403: a helper that
8634
8884
  answers here would send the body and still fall through to the proxy."""
8635
8885
  if STORE is None:
8636
8886
  return None
@@ -8811,7 +9061,11 @@ class Handler(BaseHTTPRequestHandler):
8811
9061
  if err:
8812
9062
  return self._json(err[0], {"error": err[1]})
8813
9063
  content["live"] = True
8814
- content["files"] = [name]
9064
+ # The one capture, looped as a snapshot's files are
9065
+ # (evals-core.ts expand): Run first cannot cut below
9066
+ # one item, and Loop repeats it for reply variance (#513).
9067
+ loops = content.get("loops")
9068
+ content["files"] = [name] * (loops if type(loops) is int and loops >= 1 else 1)
8815
9069
  content["revs"] = {name: "live:" + hashlib.sha256(
8816
9070
  produced[name].encode("utf-8")).hexdigest()}
8817
9071
  else:
@@ -8897,6 +9151,8 @@ class Handler(BaseHTTPRequestHandler):
8897
9151
  if produce is None:
8898
9152
  return None, None, (400, "that provider cannot be produced from on the server")
8899
9153
  result = produce(cfg)
9154
+ if result.get("refused"):
9155
+ return None, None, result["refused"]
8900
9156
  content = result.get("content")
8901
9157
  if content is None:
8902
9158
  return None, None, (502, "the live fetch produced nothing: " + (result.get("error") or "unknown"))
@@ -9514,6 +9770,266 @@ class Handler(BaseHTTPRequestHandler):
9514
9770
  return self._json(502, {"error": f"{url}: {e.reason}"})
9515
9771
 
9516
9772
 
9773
+ # ---- Lab export and import (docs/lab-export.md) -----------------------------
9774
+ #
9775
+ # A lab's settings as one file: its AI Connections, Connection Profiles and
9776
+ # grader, and its workspaces, so a new lab can start as another one is set up
9777
+ # -- by Setup › Workspaces › Data, by `evals-lab export`/`import`, or on every
9778
+ # start with LAB_IMPORT (a demo lab that keeps no data). An import only adds:
9779
+ # what the lab already has, by a connection's or profile's id and a
9780
+ # workspace's address, is left as it is, so importing twice changes nothing
9781
+ # and a lab that keeps its data keeps its edits. The file never holds a key
9782
+ # (#258): each comes from $EVALSLAB_API_KEY_<SLUG>, as a CI run's does, and a
9783
+ # key that comes is the connection's from then on, a rotated one included.
9784
+ # A connection carries its share set (`shares`, workspace slugs and the
9785
+ # sentinels; absent is All) from version 2: version 1 carried none, so an
9786
+ # import reads its connections as All.
9787
+
9788
+ LAB_FORMAT = "evals-lab/lab"
9789
+ LAB_VERSIONS = (1, 2)
9790
+ LAB_FIELDS = ("format", "version", "exportedAt", "connections", "connectionProfiles", "grader", "workspaces")
9791
+
9792
+
9793
+ def lab_export(only=None) -> dict:
9794
+ """The lab's settings as a file, every workspace's entry or the one at
9795
+ /w/[only]; None when no workspace is at that address. One workspace's file
9796
+ carries only what that workspace can use: the connections shared with it,
9797
+ the profiles over them (or over none) and the grader if it is one of them,
9798
+ each share set narrowed to that workspace, the one the file names, and
9799
+ *new kept where the connection has it. A connection shared only with
9800
+ workspaces in the trash, or with none, is usable nowhere, and an empty share set would
9801
+ import as All, so it is left out, and out as the grader too. So is a
9802
+ connection of a local type (Echo): a target step stands in for it, and the
9803
+ lab it lands in offers that step already."""
9804
+ body = (STORE.all().get("promptlab.connections") or {}).get("body") or {}
9805
+ profiles = (STORE.all().get("promptlab.connectionProfiles") or {}).get("body") or {}
9806
+ every = WORKSPACES.list()
9807
+ spaces = [w for w in every if not only or w["slug"] == only]
9808
+ if only and not spaces:
9809
+ return None
9810
+ slug_of = {w["id"]: w["slug"] for w in (spaces if only else every)}
9811
+ rows = STORE.shares()
9812
+ conns, nowhere = [], set()
9813
+ for c in body.get("list") or []:
9814
+ if not isinstance(c, dict) or (only and not STORE.shared("profile", str(c.get("id") or ""), spaces[0]["id"])):
9815
+ continue
9816
+ if c.get("type") in LOCAL_CONNECTIONS:
9817
+ nowhere.add(c.get("id"))
9818
+ continue
9819
+ entry = {k: v for k, v in c.items() if k not in ("key", "shares")}
9820
+ held = rows.get(f"profile:{c.get('id')}")
9821
+ if held and SHARE_ALL not in held:
9822
+ entry["shares"] = [w if w == SHARE_NEW else slug_of[w] for w in held if w == SHARE_NEW or w in slug_of]
9823
+ if not entry["shares"]:
9824
+ nowhere.add(c.get("id"))
9825
+ continue
9826
+ conns.append(entry)
9827
+ kept = {c["id"] for c in conns}
9828
+
9829
+ def over_kept(p):
9830
+ ref = p.get("connection")
9831
+ return not ref or not only or (isinstance(ref, dict) and ref.get("id") in kept)
9832
+ out = {"format": LAB_FORMAT, "version": LAB_VERSIONS[-1],
9833
+ "exportedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
9834
+ "connections": conns,
9835
+ "connectionProfiles": [p for p in profiles.get("list") or [] if isinstance(p, dict) and over_kept(p)],
9836
+ "workspaces": [{"slug": w["slug"], "name": w["name"], **({"archived": True} if w["archived"] else {})}
9837
+ for w in spaces]}
9838
+ if (isinstance(body.get("grader"), str) and body["grader"] and body["grader"] not in nowhere
9839
+ and (not only or body["grader"] in kept)):
9840
+ out["grader"] = body["grader"]
9841
+ return out
9842
+
9843
+
9844
+ def _keyless(entry, what):
9845
+ """A sentence when [entry] holds a key, which a lab file never does."""
9846
+ for k, v in entry.items():
9847
+ if LOOKS_LIKE_A_KEY.match(k) and v not in ("", None):
9848
+ return f"{what} holds a {k}; a lab file holds no key -- set ${key_var(entry['id'], entry.get('slug'))}"
9849
+ return None
9850
+
9851
+
9852
+ def lab_problems(doc) -> str:
9853
+ """Why [doc] is not a lab file this lab imports, or None. Checked whole
9854
+ before anything is written, as a pack is."""
9855
+ if not isinstance(doc, dict) or doc.get("format") != LAB_FORMAT:
9856
+ return f"not a lab file: its format is not {LAB_FORMAT}"
9857
+ if doc.get("version") not in LAB_VERSIONS:
9858
+ return f"a lab file of version {doc.get('version')!r}; this lab reads {', '.join(map(str, LAB_VERSIONS))}"
9859
+ extra = sorted(set(doc) - set(LAB_FIELDS))
9860
+ if extra:
9861
+ return f"a lab file holds no {', '.join(extra)}"
9862
+ seen = set()
9863
+ for i, c in enumerate(doc.get("connections") or []):
9864
+ what = f"connection {i + 1}"
9865
+ if not isinstance(c, dict) or not isinstance(c.get("id"), str) or not PROFILE_ID.fullmatch(c["id"]):
9866
+ return f"{what} has no id of letters and digits"
9867
+ what = f"connection {c['id']}"
9868
+ if c["id"] in seen:
9869
+ return f"{what} is listed twice"
9870
+ seen.add(c["id"])
9871
+ if not isinstance(c.get("name"), str) or not c["name"].strip():
9872
+ return f"{what} has no name"
9873
+ if c.get("type") not in CONNECTION_TYPES:
9874
+ return f"{what} is of type {c.get('type')!r}, which this lab has no connection type for"
9875
+ bad = next((k for k, v in c.items() if not isinstance(v, str) and k != "shares"), None)
9876
+ if "shares" in c and doc["version"] < 2:
9877
+ bad = "shares"
9878
+ if bad:
9879
+ return f"{what}'s {bad} is not text"
9880
+ why = _keyless(c, what)
9881
+ if why:
9882
+ return why
9883
+ seen = set()
9884
+ for i, p in enumerate(doc.get("connectionProfiles") or []):
9885
+ what = f"connection profile {i + 1}"
9886
+ if not isinstance(p, dict) or not isinstance(p.get("id"), str) or not PROFILE_ID.fullmatch(p["id"]):
9887
+ return f"{what} has no id of letters and digits"
9888
+ what = f"connection profile {p['id']}"
9889
+ if p["id"] in seen:
9890
+ return f"{what} is listed twice"
9891
+ seen.add(p["id"])
9892
+ if not isinstance(p.get("name"), str) or not p["name"].strip():
9893
+ return f"{what} has no name"
9894
+ why = _keyless(p, what)
9895
+ if why:
9896
+ return why
9897
+ if "grader" in doc and not isinstance(doc["grader"], str):
9898
+ return "the grader is not a connection's id"
9899
+ seen = set()
9900
+ for i, w in enumerate(doc.get("workspaces") or []):
9901
+ what = f"workspace {i + 1}"
9902
+ if not isinstance(w, dict) or not isinstance(w.get("slug"), str) or not w["slug"]:
9903
+ return f"{what} has no address"
9904
+ if w["slug"] in seen:
9905
+ return f"workspace {w['slug']} is listed twice"
9906
+ seen.add(w["slug"])
9907
+ if not isinstance(w.get("name"), str) or not w["name"].strip():
9908
+ return f"workspace {w['slug']} has no name"
9909
+ if Workspaces._slugify(w["slug"]) != w["slug"]:
9910
+ return f"workspace {w['slug']}'s address is not one a lab makes: it would be {Workspaces._slugify(w['slug'])}"
9911
+ fields = {"slug", "name"} | ({"archived"} if doc["version"] >= 2 else set())
9912
+ if set(w) - fields:
9913
+ return f"workspace {w['slug']} holds no {', '.join(sorted(set(w) - fields))}"
9914
+ if "archived" in w and not isinstance(w["archived"], bool):
9915
+ return f"workspace {w['slug']}'s archived is not true or false"
9916
+ for k in ("connections", "connectionProfiles", "workspaces"):
9917
+ if k in doc and not isinstance(doc[k], list):
9918
+ return f"a lab file's {k} is a list"
9919
+ # Workspaces before shares: a share names the file's workspaces or the lab's.
9920
+ known = seen | ({w["slug"] for w in WORKSPACES.list()} if WORKSPACES is not None else set())
9921
+ for c in doc.get("connections") or []:
9922
+ if "shares" not in c:
9923
+ continue
9924
+ if not isinstance(c["shares"], list) or not all(isinstance(s, str) for s in c["shares"]):
9925
+ return f"connection {c['id']}'s shares is a list of workspace addresses"
9926
+ # No rows is All in the store, so an empty list would grant everywhere.
9927
+ if not c["shares"]:
9928
+ return (f"connection {c['id']}'s shares is empty; leave shares out for All, "
9929
+ "or name a workspace or *new")
9930
+ # *none is the store's own mark for a set that lost its last workspace;
9931
+ # an export leaves such a connection out, so a file never carries it.
9932
+ if SHARE_NONE in c["shares"]:
9933
+ return (f"connection {c['id']}'s shares names *none; a lab file holds no connection "
9934
+ "shared with no workspace -- leave it out, or name a workspace or *new")
9935
+ lost = [s for s in c["shares"] if s not in (SHARE_ALL, SHARE_NEW) and s not in known]
9936
+ if lost:
9937
+ return f"connection {c['id']} is shared with {', '.join(lost)}, which neither the file nor this lab has"
9938
+ return None
9939
+
9940
+
9941
+ def lab_import(doc, keys) -> tuple:
9942
+ """[doc] into this lab, adding what it lacks; [keys] is where each
9943
+ connection's key is looked up by its variable's name ($EVALSLAB_API_KEY_
9944
+ <SLUG>) -- the server's environment as it starts, or what `evals-lab
9945
+ import` sent. Returns (lines saying what happened, None) or (None, error).
9946
+ No line holds a key."""
9947
+ why = lab_problems(doc)
9948
+ if why:
9949
+ return None, (400, why)
9950
+ said = []
9951
+ # A file's address to this lab's workspace id; one added where the trash
9952
+ # holds its address lands at the next free one, and its shares follow it.
9953
+ wid_of = {w["slug"]: w["id"] for w in WORKSPACES.list()}
9954
+ for w in doc.get("workspaces") or []:
9955
+ if w["slug"] in wid_of:
9956
+ continue
9957
+ wid, err = WORKSPACES.create(w["name"], w["slug"])
9958
+ wid_of[w["slug"]] = wid
9959
+ if not err and w.get("archived"):
9960
+ _, err = WORKSPACES.archive(wid)
9961
+ if err:
9962
+ return None, err
9963
+ said.append(f"workspace {w['name']} added at /w/{WORKSPACES.get(wid)['slug']}"
9964
+ + (", archived" if w.get("archived") else ""))
9965
+ for _ in range(3):
9966
+ docs = STORE.all()
9967
+ cur = docs.get("promptlab.connections") or {"version": 0, "body": None}
9968
+ pcur = docs.get("promptlab.connectionProfiles") or {"version": 0, "body": None}
9969
+ body = json.loads(json.dumps(cur["body"] or {"list": [], "at": ""}))
9970
+ pbody = json.loads(json.dumps(pcur["body"] or {"list": []}))
9971
+ conns = body.setdefault("list", [])
9972
+ lines, changed, pchanged, added = [], False, False, []
9973
+ for c in doc.get("connections") or []:
9974
+ mine = next((x for x in conns if isinstance(x, dict) and x.get("id") == c["id"]), None)
9975
+ if mine is None:
9976
+ mine = {**{k: v for k, v in c.items() if k != "shares"}, "key": ""}
9977
+ added.append(c)
9978
+ conns.append(mine)
9979
+ changed = True
9980
+ lines.append(f"connection {c['name']} added")
9981
+ key = str(keys.get(key_var(c["id"], c.get("slug"))) or "").strip()
9982
+ if key and mine.get("key") != key:
9983
+ if not header_safe(key):
9984
+ return None, (400, f"${key_var(c['id'], c.get('slug'))} cannot go in a header")
9985
+ mine["key"] = key
9986
+ changed = True
9987
+ lines.append(f"connection {mine.get('name') or c['name']} takes its key from "
9988
+ f"${key_var(c['id'], c.get('slug'))}")
9989
+ profs = pbody.setdefault("list", [])
9990
+ for p in doc.get("connectionProfiles") or []:
9991
+ if not any(isinstance(x, dict) and x.get("id") == p["id"] for x in profs):
9992
+ profs.append(dict(p))
9993
+ pchanged = True
9994
+ lines.append(f"connection profile {p['name']} added")
9995
+ if doc.get("grader") and not body.get("grader"):
9996
+ body["grader"] = doc["grader"]
9997
+ changed = True
9998
+ lines.append(f"grader set to {doc['grader']}")
9999
+ # Only an added connection takes the file's share set: one the lab had
10000
+ # keeps its own, as everything else it had does. Set before the write,
10001
+ # so the connection is never there and shared with All in between.
10002
+ for c in added:
10003
+ if "shares" in c:
10004
+ STORE.set_shares("profile", c["id"], [s if s in (SHARE_ALL, SHARE_NEW) else wid_of[s]
10005
+ for s in c["shares"]])
10006
+ writes = {}
10007
+ if changed:
10008
+ body["at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
10009
+ writes["promptlab.connections"] = {"version": cur["version"], "body": body}
10010
+ if pchanged:
10011
+ writes["promptlab.connectionProfiles"] = {"version": pcur["version"], "body": pbody}
10012
+ if not writes:
10013
+ break
10014
+ # Global documents, so the default workspace's write reaches them.
10015
+ ok, _ = STORE.write(writes, STORE.default_ws)
10016
+ if ok:
10017
+ break
10018
+ # A failed write takes back the rows of each id that still never
10019
+ # landed, as no rows at all (an empty set is a delete, never *none).
10020
+ # One another writer landed in between keeps the rows it has now:
10021
+ # taking them would leave it shared with All.
10022
+ landed = {x.get("id") for x in ((STORE.all().get("promptlab.connections") or {}).get("body")
10023
+ or {}).get("list") or [] if isinstance(x, dict)}
10024
+ for c in added:
10025
+ if "shares" in c and c["id"] not in landed:
10026
+ STORE.set_shares("profile", c["id"], [])
10027
+ else:
10028
+ return None, (409, "the lab's connections kept changing while they were imported; import again")
10029
+ said += lines
10030
+ return said or ["nothing to add"], None
10031
+
10032
+
9517
10033
  def main():
9518
10034
  if not (WEB_DIST / "index.html").is_file():
9519
10035
  raise SystemExit(f"no {WEB_DIST / 'index.html'} -- the page is built from web/: "
@@ -9525,6 +10041,11 @@ def main():
9525
10041
  # package, not one without the app: said at once, not at Sign in.
9526
10042
  if PUBLIC_GOOGLE_PROBLEM:
9527
10043
  raise SystemExit(PUBLIC_GOOGLE_PROBLEM)
10044
+ # A lab file named and not there is a broken deployment, said at once.
10045
+ if LAB_IMPORT and not Path(LAB_IMPORT).is_file():
10046
+ raise SystemExit(f"LAB_IMPORT names {LAB_IMPORT}, which is not a file")
10047
+ if LAB_IMPORT and STORE is None:
10048
+ raise SystemExit("LAB_IMPORT needs DATA_DIR: a lab file's settings are kept in its store")
9528
10049
  # A root that is not there would refuse every File Source -- said at once.
9529
10050
  if LOCAL_ROOT and not Path(LOCAL_ROOT).is_dir():
9530
10051
  raise SystemExit(f"SOURCE_LOCAL_ROOT names {LOCAL_ROOT}, which is not a directory")
@@ -9593,6 +10114,24 @@ def main():
9593
10114
  # The drop-in folder: a pack put there is installed as the lab starts.
9594
10115
  for line in PACKS.install_dropins(Path(DATA_DIR) / "packs"):
9595
10116
  print(f"pack: {line}", flush=True)
10117
+ if LAB_IMPORT:
10118
+ # After the demo: a lab file adds settings, never datasets, so the
10119
+ # demo still finds the new lab it installs into.
10120
+ try:
10121
+ doc = json.loads(Path(LAB_IMPORT).read_text("utf-8"))
10122
+ except (OSError, ValueError) as e:
10123
+ raise SystemExit(f"LAB_IMPORT: {LAB_IMPORT} is not JSON ({e})") from None
10124
+ said, err = lab_import(doc, os.environ)
10125
+ if err:
10126
+ raise SystemExit(f"LAB_IMPORT: {err[1]}")
10127
+ for line in said:
10128
+ print(f"import: {line}", flush=True)
10129
+ # A held key goes wherever a submitted run points it, so a lab open to
10130
+ # its network with keys in it is as loud as one with a Local provider on.
10131
+ if local_exposed() and any(os.environ.get(key_var(c["id"], c.get("slug")))
10132
+ for c in doc.get("connections") or []):
10133
+ print(f"WARNING: LAB_IMPORT gave a connection its key, and {HOST} is beyond loopback with no "
10134
+ "LAB_PASSWORD -- anyone who can reach this port can run against it", flush=True)
9596
10135
  if QUEUE is not None:
9597
10136
  QUEUE.start()
9598
10137
  ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()