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.
- package/CHANGELOG.md +96 -0
- package/README.md +4 -4
- package/bin/evals-lab.js +9 -1
- package/bin/lab-file.js +97 -0
- package/bin/run.js +1 -1
- package/lab/VERSION +1 -1
- package/lab/demo/CREDITS.md +37 -3
- package/lab/demo/datasets/demo-1.json +2 -2
- package/lab/demo/datasets/demo-2.json +2 -2
- package/lab/demo/datasets/sentiment.json +383 -0
- package/lab/demo/datasets/weather-alerts.json +61 -0
- package/lab/demo/datasets/wordings.json +39 -0
- package/lab/demo/manifest.json +3 -3
- package/lab/demo/pipelines/demo-1.json +4 -4
- package/lab/demo/pipelines/demo-2.json +4 -4
- package/lab/demo/pipelines/sentiment.json +65 -0
- package/lab/demo/pipelines/weather-alerts.json +66 -0
- package/lab/demo/pipelines/wordings.json +104 -0
- package/lab/demo/sources/Demo 1/source.json +3 -0
- package/lab/demo/sources/Demo 2/source.json +3 -0
- package/lab/demo/sources/sentiment/bakery-croissant.txt +1 -0
- package/lab/demo/sources/sentiment/bus-timetable.txt +1 -0
- package/lab/demo/sources/sentiment/cafe-coffee.txt +1 -0
- package/lab/demo/sources/sentiment/concert-night.txt +1 -0
- package/lab/demo/sources/sentiment/film-sequel.txt +1 -0
- package/lab/demo/sources/sentiment/garden-tomatoes.txt +1 -0
- package/lab/demo/sources/sentiment/gym-contract.txt +1 -0
- package/lab/demo/sources/sentiment/headphones-review.txt +1 -0
- package/lab/demo/sources/sentiment/hotel-room.txt +1 -0
- package/lab/demo/sources/sentiment/library-hours.txt +1 -0
- package/lab/demo/sources/sentiment/meeting-minutes.txt +1 -0
- package/lab/demo/sources/sentiment/parcel-lost.txt +1 -0
- package/lab/demo/sources/sentiment/phone-update.txt +1 -0
- package/lab/demo/sources/sentiment/puppy-home.txt +1 -0
- package/lab/demo/sources/sentiment/recipe-note.txt +1 -0
- package/lab/demo/sources/sentiment/road-closure.txt +1 -0
- package/lab/demo/sources/sentiment/source.json +3 -0
- package/lab/demo/sources/sentiment/teacher-thanks.txt +1 -0
- package/lab/demo/sources/sentiment/train-delay.txt +1 -0
- package/lab/demo/sources/sentiment/volunteer-day.txt +1 -0
- package/lab/demo/sources/sentiment/weather-report.txt +1 -0
- package/lab/demo/sources/weather-alerts/https-api.weather.gov-alerts-types.txt +116 -0
- package/lab/demo/sources/weather-alerts/source.json +8 -0
- package/lab/evals-core.mjs +246 -57
- package/lab/flows/wdl.mjs +19 -0
- package/lab/kinds/list.mjs +5 -2
- package/lab/metrics/builtin.mjs +34 -7
- package/lab/run-evals.js +30 -11
- package/lab/server.py +614 -75
- package/lab/web/dist/assets/gallery-DpaqOg9Z.js +5 -0
- package/lab/web/dist/assets/main-BSw-jyYD.css +1 -0
- package/lab/web/dist/assets/main-CrB8wPZn.js +17 -0
- package/lab/web/dist/assets/tokens-CrjDMCbz.js +60 -0
- package/lab/web/dist/assets/tokens-DDOONQon.css +1 -0
- package/lab/web/dist/gallery.html +3 -3
- package/lab/web/dist/index.html +4 -4
- package/package.json +1 -1
- package/lab/web/dist/assets/gallery-DKy8QK3o.js +0 -5
- package/lab/web/dist/assets/main-BGmSu7aS.css +0 -1
- package/lab/web/dist/assets/main-DdGON-yl.js +0 -16
- package/lab/web/dist/assets/tokens-BmytrwxE.js +0 -60
- 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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
5028
|
-
"""
|
|
5029
|
-
for every image type, and a full-size render for
|
|
5030
|
-
cannot decode, whose zoom view has no original to
|
|
5031
|
-
|
|
5032
|
-
|
|
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
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
5347
|
-
|
|
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
|
|
5357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
|
7382
|
-
# address to reach, no key to carry, so the destination rules have
|
|
7383
|
-
# say about them. A
|
|
7384
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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()
|