evals-lab 0.10.0 → 0.11.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 +41 -1
- package/README.md +8 -2
- package/bin/run.js +15 -15
- package/lab/VERSION +1 -1
- package/lab/demo/pipelines/demo-1.json +5 -3
- package/lab/demo/pipelines/demo-2.json +5 -3
- package/lab/evals-core.mjs +512 -155
- package/lab/flows/record.mjs +16 -4
- package/lab/kinds/list.mjs +32 -23
- package/lab/run-evals.js +3 -3
- package/lab/server.py +433 -69
- package/lab/web/dist/assets/gallery-DKy8QK3o.js +5 -0
- package/lab/web/dist/assets/main-BGmSu7aS.css +1 -0
- package/lab/web/dist/assets/main-DdGON-yl.js +16 -0
- package/lab/web/dist/assets/tokens-BmytrwxE.js +60 -0
- package/lab/web/dist/assets/tokens-DBN7I0ru.css +1 -0
- package/lab/web/dist/gallery.html +3 -3
- package/lab/web/dist/index.html +4 -4
- package/package.json +10 -2
- package/lab/web/dist/assets/gallery-BcvmOPqb.js +0 -3
- package/lab/web/dist/assets/main-BBUxHZ-D.js +0 -16
- package/lab/web/dist/assets/main-CMoyjImZ.css +0 -1
- package/lab/web/dist/assets/tokens-B7N4NvmM.css +0 -1
- package/lab/web/dist/assets/tokens-CiJkwD3V.js +0 -60
package/lab/server.py
CHANGED
|
@@ -565,7 +565,20 @@ def redact_flow(value):
|
|
|
565
565
|
# again, so a page that got it wrong cannot put a key or a malformed record on
|
|
566
566
|
# disk.
|
|
567
567
|
RECORD_FORMAT = "evals-lab/record"
|
|
568
|
-
RECORD_VERSION =
|
|
568
|
+
RECORD_VERSION = 2
|
|
569
|
+
# The origins a record names. Version 2 added `edited`: a call changed in the
|
|
570
|
+
# Call dialog, keeping the `run` and `iteration` it came from.
|
|
571
|
+
RECORD_ORIGINS = ("run", "hand", "upload", "edited")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def upgrade_record(value):
|
|
575
|
+
"""A version-1 record as today's: flows/record.ts's upgradeRecord. Version
|
|
576
|
+
2 only widened the origins, so the upgrade is the number. Anything else is
|
|
577
|
+
returned as it is, for record_problems to judge."""
|
|
578
|
+
if isinstance(value, dict) and value.get("format") == RECORD_FORMAT and value.get("version") == 1 \
|
|
579
|
+
and not isinstance(value.get("version"), bool):
|
|
580
|
+
return {**value, "version": RECORD_VERSION}
|
|
581
|
+
return value
|
|
569
582
|
|
|
570
583
|
|
|
571
584
|
def record_problems(value):
|
|
@@ -600,8 +613,8 @@ def record_problems(value):
|
|
|
600
613
|
or isinstance(res.get("error"), dict))):
|
|
601
614
|
bad.append("a record's result is { status, body }, { error } or null")
|
|
602
615
|
origin = value.get("origin")
|
|
603
|
-
if not isinstance(origin, dict) or origin.get("kind") not in
|
|
604
|
-
bad.append("a record's origin is run, hand or
|
|
616
|
+
if not isinstance(origin, dict) or origin.get("kind") not in RECORD_ORIGINS:
|
|
617
|
+
bad.append("a record's origin is run, hand, upload or edited")
|
|
605
618
|
return bad
|
|
606
619
|
|
|
607
620
|
|
|
@@ -612,6 +625,7 @@ def take_record(name, data):
|
|
|
612
625
|
value = json.loads(data.decode("utf-8"))
|
|
613
626
|
except (UnicodeDecodeError, ValueError):
|
|
614
627
|
return None, (400, f"{name} is not JSON")
|
|
628
|
+
value = upgrade_record(value)
|
|
615
629
|
bad = record_problems(value)
|
|
616
630
|
if bad:
|
|
617
631
|
return None, (400, f"{name}: {bad[0]}")
|
|
@@ -689,6 +703,12 @@ def take_dynamic(name, data):
|
|
|
689
703
|
return None, (400, f"{name} is not text")
|
|
690
704
|
if TOKEN_SHAPE.search(text):
|
|
691
705
|
return None, (400, f"{name} holds a token, and a token never reaches the server")
|
|
706
|
+
# The lab's own secrets have no shape to redact by, and a command can read
|
|
707
|
+
# them from its parent's environment, so they are matched by value -- a
|
|
708
|
+
# verbatim read only; an encoded one gets past. A value under 8 characters is too likely to
|
|
709
|
+
# be ordinary text to refuse a Call for.
|
|
710
|
+
if any(len(v) >= 8 and v in text for v in (os.environ.get(k) or "" for k in _LAB_SECRET_ENV)):
|
|
711
|
+
return None, (400, f"{name} holds one of the lab's own secrets")
|
|
692
712
|
for pattern, to in SECRET_TEXT:
|
|
693
713
|
text = pattern.sub(to, text)
|
|
694
714
|
return text.encode("utf-8"), None
|
|
@@ -750,14 +770,14 @@ def _header_pairs(spec):
|
|
|
750
770
|
def connection_auth(pid):
|
|
751
771
|
"""The one auth header a dynamic API Source borrows from the connection
|
|
752
772
|
[pid] it names, and the key it carries so the relay's https-or-loopback
|
|
753
|
-
rule can bind. The key is the
|
|
773
|
+
rule can bind. The key is the connections store's, by id, sent the way the
|
|
754
774
|
connection's type says (an HTTP endpoint's Key header, else the type's own,
|
|
755
775
|
auth_headers) -- it never leaves the server or reaches a Call (#258).
|
|
756
776
|
Returns (headers, key, None); ({}, "", None) for no connection or no key;
|
|
757
777
|
or (None, None, (code, why))."""
|
|
758
778
|
if not pid:
|
|
759
779
|
return {}, "", None
|
|
760
|
-
doc = (STORE.all().get("promptlab.
|
|
780
|
+
doc = (STORE.all().get("promptlab.connections") or {}) if STORE is not None else {}
|
|
761
781
|
profiles = (doc.get("body") or {}).get("list") or []
|
|
762
782
|
profile = next((p for p in profiles if isinstance(p, dict) and p.get("id") == pid), None)
|
|
763
783
|
if profile is None:
|
|
@@ -858,7 +878,8 @@ def _outside_root(real):
|
|
|
858
878
|
root = Path(LOCAL_ROOT).resolve()
|
|
859
879
|
return real != root and root not in real.parents
|
|
860
880
|
|
|
861
|
-
# The lab's own secrets in its environment, never handed to a command it runs
|
|
881
|
+
# The lab's own secrets in its environment, never handed to a command it runs
|
|
882
|
+
# nor kept in a Call (take_dynamic).
|
|
862
883
|
_LAB_SECRET_ENV = ("LAB_PASSWORD", "GOOGLE_CLIENT_SECRET", "GOOGLE_API_KEY")
|
|
863
884
|
|
|
864
885
|
|
|
@@ -895,7 +916,8 @@ def command_config_problems(config):
|
|
|
895
916
|
def produce_file(config):
|
|
896
917
|
"""Read a File on disk Source's one Call now: the path its config names,
|
|
897
918
|
capped at the file cap. The lab's own data directory is never read -- it
|
|
898
|
-
holds the store, keys included
|
|
919
|
+
holds the store, keys included -- nor /proc, which holds the server's
|
|
920
|
+
environment. Returns the shape _relay_produce does."""
|
|
899
921
|
path = config.get("path") if isinstance(config, dict) else None
|
|
900
922
|
request = {"path": path}
|
|
901
923
|
if not isinstance(path, str) or not path.strip():
|
|
@@ -910,6 +932,10 @@ def produce_file(config):
|
|
|
910
932
|
for own in {Path(d).resolve() for d in (DATA_DIR, STORE and Path(STORE.path).parent) if d}:
|
|
911
933
|
if real == own or own in real.parents:
|
|
912
934
|
return {"error": "the lab's own data is not a file it reads", "request": request, "status": None}
|
|
935
|
+
# /proc holds the server's own environment (every secret it was started
|
|
936
|
+
# with, not only the lab's) and its memory: the process, not a file.
|
|
937
|
+
if real == Path("/proc") or Path("/proc") in real.parents:
|
|
938
|
+
return {"error": "the server's own process is not a file it reads", "request": request, "status": None}
|
|
913
939
|
if not real.is_file():
|
|
914
940
|
return {"error": "that path is not a file", "request": request, "status": None}
|
|
915
941
|
try:
|
|
@@ -1024,8 +1050,10 @@ SIGN_INS = {"microsoft": lambda: microsoft_config() is not None}
|
|
|
1024
1050
|
# in the store: a document is not migrated or deleted because nothing reads it.
|
|
1025
1051
|
# promptlab.cases, .rules and their .base copies went that way when a dataset
|
|
1026
1052
|
# became a row of its own (#86), and promptlab.mappings once a dataset named
|
|
1027
|
-
# the Source it grades (#199).
|
|
1028
|
-
|
|
1053
|
+
# the Source it grades (#199). promptlab.profiles went once a Target profile
|
|
1054
|
+
# was split into an AI Connection and a Connection Profile (#445), each kept
|
|
1055
|
+
# in a document of its own; its rows are what the split read.
|
|
1056
|
+
SYNCED = ("promptlab.workflows", "promptlab.connections", "promptlab.connectionProfiles",
|
|
1029
1057
|
"promptlab.versions", "promptlab.tokens")
|
|
1030
1058
|
MAX_DOC = 8 * 1024 * 1024
|
|
1031
1059
|
RUNS_PAGE = 25
|
|
@@ -1088,14 +1116,16 @@ QUEUE_WAIT = 1.0
|
|
|
1088
1116
|
WATCH_EVERY = 5.0
|
|
1089
1117
|
|
|
1090
1118
|
|
|
1091
|
-
#
|
|
1119
|
+
# An AI Connection's key is written from the page and never read back by it
|
|
1092
1120
|
# (#258): a client holding the lab's password -- CI's runners and their logs
|
|
1093
|
-
# among them -- is handed no key. Where a
|
|
1121
|
+
# among them -- is handed no key. Where a connection holds one, what is served
|
|
1094
1122
|
# (/api/state, the page's carried copy, a refused write's current copy) holds
|
|
1095
|
-
# KEY_HELD and the
|
|
1096
|
-
# keeps the key the store holds for the id: the
|
|
1097
|
-
# was cloned or restored from. KEY_HELD starts with a character no key can
|
|
1098
|
-
# (header_safe), so a pasted key is never taken for it.
|
|
1123
|
+
# KEY_HELD and the connection's id instead, and a write that brings that back
|
|
1124
|
+
# keeps the key the store holds for the id: the connection's own, or the one
|
|
1125
|
+
# it was cloned or restored from. KEY_HELD starts with a character no key can
|
|
1126
|
+
# (header_safe), so a pasted key is never taken for it. A Target profile held
|
|
1127
|
+
# the key before the split (#445); a connection keeps its id, so a KEY_HELD
|
|
1128
|
+
# the page carried then still names it.
|
|
1099
1129
|
KEY_HELD = "\u2022held:"
|
|
1100
1130
|
|
|
1101
1131
|
|
|
@@ -1104,11 +1134,12 @@ def held(key) -> bool:
|
|
|
1104
1134
|
|
|
1105
1135
|
|
|
1106
1136
|
def profiles_in(name: str, body):
|
|
1107
|
-
"""
|
|
1108
|
-
each profile version's copy
|
|
1137
|
+
"""Everything keyed a synced document holds: the connections store's
|
|
1138
|
+
list, and each profile version's copy -- a Target profile's settings and
|
|
1139
|
+
key, under its connection's id."""
|
|
1109
1140
|
if not isinstance(body, dict):
|
|
1110
1141
|
return
|
|
1111
|
-
if name == "promptlab.
|
|
1142
|
+
if name == "promptlab.connections":
|
|
1112
1143
|
for p in body.get("list") or []:
|
|
1113
1144
|
if isinstance(p, dict):
|
|
1114
1145
|
yield p
|
|
@@ -1165,7 +1196,9 @@ SHARE_NEW = "*new"
|
|
|
1165
1196
|
# its pipeline/dataset slices per-workspace, its profile slice global, because
|
|
1166
1197
|
# profiles are global so their Restore must be too. served()/write() route
|
|
1167
1198
|
# each key by these; any other key is per-workspace.
|
|
1168
|
-
|
|
1199
|
+
# promptlab.profiles stays global though no longer served: its row is what
|
|
1200
|
+
# the split on open reads (split_profiles).
|
|
1201
|
+
DOC_GLOBAL = ("promptlab.connections", "promptlab.connectionProfiles", "promptlab.profiles", "promptlab.tokens")
|
|
1169
1202
|
DOC_SPLIT = "promptlab.versions"
|
|
1170
1203
|
|
|
1171
1204
|
|
|
@@ -1232,6 +1265,30 @@ class Store:
|
|
|
1232
1265
|
db.execute("UPDATE runs SET workspace = ? WHERE workspace IS NULL", (self.default_ws,))
|
|
1233
1266
|
if cols and "workspace" not in cols:
|
|
1234
1267
|
self._rebuild_docs(db)
|
|
1268
|
+
self._split_profiles(db)
|
|
1269
|
+
|
|
1270
|
+
def _split_profiles(self, db):
|
|
1271
|
+
"""A store from before the split (#445) opened once: its Target
|
|
1272
|
+
profiles become AI Connections, each keeping the profile's id, its key
|
|
1273
|
+
and its Setup choices (`at`, the lab's grader), and -- where a profile
|
|
1274
|
+
set settings of its own -- Connection Profiles (split_profiles). The
|
|
1275
|
+
old document stays, unserved. Idempotent: a store holding the
|
|
1276
|
+
connections document already, even an emptied one, is left as it is;
|
|
1277
|
+
a store with no profiles has nothing to split."""
|
|
1278
|
+
if db.execute("SELECT 1 FROM docs WHERE name = 'promptlab.connections' AND workspace IS NULL").fetchone():
|
|
1279
|
+
return
|
|
1280
|
+
row = db.execute("SELECT body FROM docs WHERE name = 'promptlab.profiles' AND workspace IS NULL").fetchone()
|
|
1281
|
+
try:
|
|
1282
|
+
body = json.loads(row[0]) if row and row[0] else None
|
|
1283
|
+
except ValueError:
|
|
1284
|
+
body = None
|
|
1285
|
+
if not isinstance(body, dict):
|
|
1286
|
+
return
|
|
1287
|
+
connections, profiles = split_profiles(body.get("list"))
|
|
1288
|
+
kept = {k: body[k] for k in ("at", "grader") if k in body}
|
|
1289
|
+
at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
1290
|
+
self._put(db, "promptlab.connections", None, 1, {"list": connections, "at": "", **kept}, at)
|
|
1291
|
+
self._put(db, "promptlab.connectionProfiles", None, 1, {"list": profiles}, at)
|
|
1235
1292
|
|
|
1236
1293
|
def _ensure_default(self, db) -> str:
|
|
1237
1294
|
"""The flagged default workspace's id, seeding "Default" (/w/default,
|
|
@@ -1363,8 +1420,8 @@ class Store:
|
|
|
1363
1420
|
(name, ws, version, text, at))
|
|
1364
1421
|
|
|
1365
1422
|
def _keyring(self, db, now: dict) -> dict:
|
|
1366
|
-
"""Each
|
|
1367
|
-
dropped one's, else its newest version's."""
|
|
1423
|
+
"""Each connection id's key as the store holds it: its connection's,
|
|
1424
|
+
else a dropped one's, else its newest version's."""
|
|
1368
1425
|
ring = {}
|
|
1369
1426
|
for p in profiles_in("promptlab.versions", now.get("promptlab.versions", {}).get("body")):
|
|
1370
1427
|
pid, key = str(p.get("id") or ""), p.get("key")
|
|
@@ -1372,7 +1429,7 @@ class Store:
|
|
|
1372
1429
|
ring[pid] = key
|
|
1373
1430
|
db.execute("DELETE FROM dropped_keys WHERE at < ?", (time.time() - TRASH_SECONDS,))
|
|
1374
1431
|
ring.update(db.execute("SELECT id, key FROM dropped_keys").fetchall())
|
|
1375
|
-
for p in profiles_in("promptlab.
|
|
1432
|
+
for p in profiles_in("promptlab.connections", now.get("promptlab.connections", {}).get("body")):
|
|
1376
1433
|
key = p.get("key")
|
|
1377
1434
|
if isinstance(key, str) and key and not held(key):
|
|
1378
1435
|
ring[str(p.get("id") or "")] = key
|
|
@@ -1408,14 +1465,14 @@ class Store:
|
|
|
1408
1465
|
for p in profiles_in(n, d["body"]):
|
|
1409
1466
|
if held(p.get("key")):
|
|
1410
1467
|
p["key"] = ring.get(p["key"][len(KEY_HELD):], "")
|
|
1411
|
-
if "promptlab.
|
|
1468
|
+
if "promptlab.connections" in docs:
|
|
1412
1469
|
kept = {str(p.get("id") or "") for p in profiles_in(
|
|
1413
|
-
"promptlab.
|
|
1470
|
+
"promptlab.connections", docs["promptlab.connections"]["body"])}
|
|
1414
1471
|
db.executemany("DELETE FROM dropped_keys WHERE id = ?", [(i,) for i in kept])
|
|
1415
1472
|
db.executemany(
|
|
1416
1473
|
"INSERT OR REPLACE INTO dropped_keys (id, key, at) VALUES (?, ?, ?)",
|
|
1417
1474
|
[(str(p.get("id") or ""), p["key"], time.time())
|
|
1418
|
-
for p in profiles_in("promptlab.
|
|
1475
|
+
for p in profiles_in("promptlab.connections", have("promptlab.connections")["body"])
|
|
1419
1476
|
if str(p.get("id") or "") not in kept and isinstance(p.get("key"), str) and p["key"]])
|
|
1420
1477
|
for n, d in docs.items():
|
|
1421
1478
|
version, body = have(n)["version"] + 1, d["body"]
|
|
@@ -1434,9 +1491,10 @@ class Store:
|
|
|
1434
1491
|
return {n: have(n)["version"] + 1 for n in docs}, None
|
|
1435
1492
|
|
|
1436
1493
|
# ---- shared-by-choice connections (docs/workspaces.md) ------------------
|
|
1437
|
-
#
|
|
1438
|
-
#
|
|
1439
|
-
#
|
|
1494
|
+
# An AI Connection or an Account (kind "profile"/"account", id the
|
|
1495
|
+
# connection's -- the Target profile's it kept -- or the account's) is
|
|
1496
|
+
# usable in a workspace by its share set in `connection_shares`: a row for
|
|
1497
|
+
# that workspace, or the SHARE_ALL sentinel.
|
|
1440
1498
|
# No row at all is the migration and new-connection default -- shared with
|
|
1441
1499
|
# every workspace, so nothing stops running the moment workspaces exist
|
|
1442
1500
|
# (open question 2, resolved All); narrowing a connection is adding the
|
|
@@ -1498,10 +1556,10 @@ class Store:
|
|
|
1498
1556
|
return row[0] if row else "this workspace"
|
|
1499
1557
|
|
|
1500
1558
|
def profile_name(self, pid: str) -> str:
|
|
1501
|
-
"""
|
|
1559
|
+
"""An AI Connection's display name, by id, from the global connections
|
|
1502
1560
|
document -- for the refusal sentence. The id itself when none is held."""
|
|
1503
1561
|
with self.lock, closing(sqlite3.connect(self.path)) as db:
|
|
1504
|
-
row = db.execute("SELECT body FROM docs WHERE name = 'promptlab.
|
|
1562
|
+
row = db.execute("SELECT body FROM docs WHERE name = 'promptlab.connections' "
|
|
1505
1563
|
"AND workspace IS NULL").fetchone()
|
|
1506
1564
|
try:
|
|
1507
1565
|
for p in (json.loads(row[0]) or {}).get("list", []) if row and row[0] else []:
|
|
@@ -1827,6 +1885,15 @@ CONFIG_KEYS_MAX = 10
|
|
|
1827
1885
|
CONFIG_VALUE_MAX = 400
|
|
1828
1886
|
|
|
1829
1887
|
|
|
1888
|
+
class _Refused(Exception):
|
|
1889
|
+
"""A refusal found inside a transaction: rolls it back, carrying the
|
|
1890
|
+
(code, sentence) the caller answers with."""
|
|
1891
|
+
|
|
1892
|
+
def __init__(self, err):
|
|
1893
|
+
super().__init__(err[1])
|
|
1894
|
+
self.err = err
|
|
1895
|
+
|
|
1896
|
+
|
|
1830
1897
|
class Sources:
|
|
1831
1898
|
"""
|
|
1832
1899
|
A named bag of files a run might be over (#526).
|
|
@@ -2426,11 +2493,12 @@ class Sources:
|
|
|
2426
2493
|
content = result.get("content")
|
|
2427
2494
|
if content is None:
|
|
2428
2495
|
# A failed production: kept as a Call with its error, no file.
|
|
2429
|
-
name = dynamic_item_name(cfg, (request or {}).get("url", "")
|
|
2496
|
+
name = self._snapshot_name(sid, dynamic_item_name(cfg, (request or {}).get("url", "")
|
|
2497
|
+
or secrets.token_hex(6)))
|
|
2430
2498
|
self._record_call(sid, name, "snapshot", provider, request, status, result.get("error"), None)
|
|
2431
2499
|
return {"name": name, "origin": "snapshot", "at": None, "provider": provider,
|
|
2432
2500
|
"request": request, "status": status, "error": result.get("error")}, None
|
|
2433
|
-
name = dynamic_item_name(cfg, content)
|
|
2501
|
+
name = self._snapshot_name(sid, dynamic_item_name(cfg, content))
|
|
2434
2502
|
kept, take_err = entry["take"](name, content)
|
|
2435
2503
|
if take_err:
|
|
2436
2504
|
code, message = take_err
|
|
@@ -2438,7 +2506,8 @@ class Sources:
|
|
|
2438
2506
|
return {"name": name, "origin": "snapshot", "at": None, "provider": provider,
|
|
2439
2507
|
"request": request, "status": status, "error": message}, None
|
|
2440
2508
|
# The name is stable (the URL's), so a re-snapshot overwrites the item
|
|
2441
|
-
# rather than adding a second: remove the old file first, then add.
|
|
2509
|
+
# rather than adding a second: remove the old file first, then add. An
|
|
2510
|
+
# Edited Call is never the one overwritten (_snapshot_name).
|
|
2442
2511
|
self.remove_files(sid, [name])
|
|
2443
2512
|
stored, add_err = self.add_file(sid, name, kept)
|
|
2444
2513
|
if add_err:
|
|
@@ -2447,6 +2516,162 @@ class Sources:
|
|
|
2447
2516
|
return {"name": stored, "origin": "snapshot", "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
2448
2517
|
"provider": provider, "request": request, "status": status, "bytes": len(kept)}, None
|
|
2449
2518
|
|
|
2519
|
+
def _snapshot_name(self, sid, name):
|
|
2520
|
+
"""The name a snapshot is kept under: [name], unless an Edited Call
|
|
2521
|
+
holds it -- a snapshot never overwrites an edit -- and then the first
|
|
2522
|
+
of `<stem>-2`, `<stem>-3`... that none does. One that is not Edited is
|
|
2523
|
+
overwritten, as the stable name always was."""
|
|
2524
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2525
|
+
edited = {r[0] for r in db.execute(
|
|
2526
|
+
"SELECT name FROM source_calls WHERE source = ? AND origin = 'edited'", (sid,))}
|
|
2527
|
+
if name not in edited:
|
|
2528
|
+
return name
|
|
2529
|
+
stem, ext = os.path.splitext(name)
|
|
2530
|
+
n = 2
|
|
2531
|
+
while f"{stem}-{n}{ext}" in edited:
|
|
2532
|
+
n += 1
|
|
2533
|
+
return f"{stem}-{n}{ext}"
|
|
2534
|
+
|
|
2535
|
+
# ---- a Call opened in the Call dialog: edited, renamed, cloned (#438) ---
|
|
2536
|
+
#
|
|
2537
|
+
# Both kinds of Call -- a dynamic Source's and a Power Automate record --
|
|
2538
|
+
# are files of a Source whose kind has a `take` hook, so a saved edit goes
|
|
2539
|
+
# through the same check as content arriving any other way: a key refused,
|
|
2540
|
+
# the rest redacted. A File Library has no hook and no Calls.
|
|
2541
|
+
|
|
2542
|
+
# The origins a dynamic Call's provenance row may be put back to (Undo).
|
|
2543
|
+
CALL_ORIGINS = ("snapshot", "run", "upload", "hand", "edited")
|
|
2544
|
+
|
|
2545
|
+
def _calls_entry(self, sid):
|
|
2546
|
+
"""The enforcement entry of a user Source whose files are Calls, or
|
|
2547
|
+
(None, error)."""
|
|
2548
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2549
|
+
r = db.execute("SELECT system FROM sources WHERE id = ? AND (workspace = ? OR system = 1)",
|
|
2550
|
+
(sid, workspace_of(self.store))).fetchone()
|
|
2551
|
+
if r is None:
|
|
2552
|
+
return None, (404, "no such source")
|
|
2553
|
+
if r[0]:
|
|
2554
|
+
return None, (403, "the sample library is read-only")
|
|
2555
|
+
entry = self.type_of(sid) or {}
|
|
2556
|
+
if entry.get("take") is None:
|
|
2557
|
+
return None, (400, "that Source keeps no Calls")
|
|
2558
|
+
return entry, None
|
|
2559
|
+
|
|
2560
|
+
def _names_held(self, db, sid):
|
|
2561
|
+
"""Every name a Source's files and Calls hold: what a new name may not be."""
|
|
2562
|
+
return ({r[0] for r in db.execute("SELECT name FROM source_files WHERE source = ?", (sid,))}
|
|
2563
|
+
| {r[0] for r in db.execute("SELECT name FROM source_calls WHERE source = ?", (sid,))})
|
|
2564
|
+
|
|
2565
|
+
def edit_call(self, sid, name, to=None, content=None, origin=None, named_by=None):
|
|
2566
|
+
"""One Call as the Call dialog saves it: renamed to [to], its text
|
|
2567
|
+
replaced by [content], or both, in one write. The text goes through
|
|
2568
|
+
the Source's `take` hook as an upload does. A rename keeps the
|
|
2569
|
+
extension and refuses a name in use, or a Call that a dataset's cases
|
|
2570
|
+
name ([named_by] answers which datasets do), since a case finds its
|
|
2571
|
+
item by name. A dynamic Call's origin becomes `edited` when its text
|
|
2572
|
+
changed -- a rename alone is no edit -- unless [origin] puts back the
|
|
2573
|
+
one it had (Undo). Nothing is written unless all of it passes.
|
|
2574
|
+
Returns ({name, origin, bytes}, None), or (None, (code, sentence)) --
|
|
2575
|
+
(code, sentence, "name") when the Name field is what is wrong."""
|
|
2576
|
+
entry, err = self._calls_entry(sid)
|
|
2577
|
+
if err:
|
|
2578
|
+
return None, err
|
|
2579
|
+
path = self.file_path(sid, name)
|
|
2580
|
+
if path is None or not path.is_file():
|
|
2581
|
+
return None, (404, f"{name} is not a Call of that Source")
|
|
2582
|
+
if origin is not None and origin not in self.CALL_ORIGINS:
|
|
2583
|
+
return None, (400, "that is not an origin a Call has")
|
|
2584
|
+
new = name
|
|
2585
|
+
if to is not None and to != name:
|
|
2586
|
+
new = source_name(to)
|
|
2587
|
+
if new is None or new != to:
|
|
2588
|
+
return None, (400, "that name is not accepted here", "name")
|
|
2589
|
+
if Path(new).suffix.lower() != Path(name).suffix.lower():
|
|
2590
|
+
return None, (400, "a Call keeps its extension", "name")
|
|
2591
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2592
|
+
taken = new in self._names_held(db, sid)
|
|
2593
|
+
if taken:
|
|
2594
|
+
return None, (409, f"{new} is taken.", "name")
|
|
2595
|
+
users = named_by(name) if named_by else []
|
|
2596
|
+
if users:
|
|
2597
|
+
return None, (409, f"{users[0]} names {name} in its cases.", "name")
|
|
2598
|
+
old = path.read_bytes()
|
|
2599
|
+
data = old
|
|
2600
|
+
if content is not None:
|
|
2601
|
+
if not isinstance(content, str):
|
|
2602
|
+
return None, (400, "a Call's content is text")
|
|
2603
|
+
raw = content.encode("utf-8")
|
|
2604
|
+
if not raw:
|
|
2605
|
+
return None, (400, "a Call needs some content")
|
|
2606
|
+
if len(raw) > MAX_FILE:
|
|
2607
|
+
return None, (413, "that is over the file cap")
|
|
2608
|
+
data, take_err = entry["take"](new, raw)
|
|
2609
|
+
if take_err:
|
|
2610
|
+
return None, take_err
|
|
2611
|
+
changed = data != old
|
|
2612
|
+
folder = self.dir / sid
|
|
2613
|
+
tmp = None
|
|
2614
|
+
if changed or new != name:
|
|
2615
|
+
with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
|
|
2616
|
+
f.write(data)
|
|
2617
|
+
tmp = f.name
|
|
2618
|
+
try:
|
|
2619
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2620
|
+
if new != name and new in self._names_held(db, sid):
|
|
2621
|
+
raise _Refused((409, f"{new} is taken.", "name"))
|
|
2622
|
+
total = db.execute("SELECT bytes FROM sources WHERE id = ?", (sid,)).fetchone()[0]
|
|
2623
|
+
if total + len(data) - len(old) > MAX_SOURCE:
|
|
2624
|
+
raise _Refused((413, "that would take the source past its cap"))
|
|
2625
|
+
db.execute("UPDATE source_files SET name = ?, bytes = ? WHERE source = ? AND name = ?",
|
|
2626
|
+
(new, len(data), sid, name))
|
|
2627
|
+
db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
|
|
2628
|
+
row = db.execute("SELECT origin FROM source_calls WHERE source = ? AND name = ?",
|
|
2629
|
+
(sid, name)).fetchone()
|
|
2630
|
+
now = None
|
|
2631
|
+
if row is not None:
|
|
2632
|
+
now = origin or ("edited" if changed else row[0])
|
|
2633
|
+
db.execute("UPDATE source_calls SET name = ?, file = ?, origin = ? "
|
|
2634
|
+
"WHERE source = ? AND name = ?", (new, new, now, sid, name))
|
|
2635
|
+
except _Refused as no:
|
|
2636
|
+
if tmp:
|
|
2637
|
+
os.unlink(tmp)
|
|
2638
|
+
return None, no.err
|
|
2639
|
+
if tmp:
|
|
2640
|
+
os.replace(tmp, folder / new)
|
|
2641
|
+
if new != name:
|
|
2642
|
+
(folder / name).unlink(missing_ok=True)
|
|
2643
|
+
return {"name": new, "origin": now, "bytes": len(data)}, None
|
|
2644
|
+
|
|
2645
|
+
def clone_call(self, sid, name):
|
|
2646
|
+
"""A copy of one Call under `<stem>-copy` (then `-copy-2`, ...), its
|
|
2647
|
+
file and, for a dynamic Call, its provenance -- origin, request,
|
|
2648
|
+
status and when it was produced -- as they are. Returns ({name}, None),
|
|
2649
|
+
or (None, error)."""
|
|
2650
|
+
entry, err = self._calls_entry(sid)
|
|
2651
|
+
if err:
|
|
2652
|
+
return None, err
|
|
2653
|
+
path = self.file_path(sid, name)
|
|
2654
|
+
if path is None or not path.is_file():
|
|
2655
|
+
return None, (404, f"{name} is not a Call of that Source")
|
|
2656
|
+
stem, ext = os.path.splitext(name)
|
|
2657
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2658
|
+
held = self._names_held(db, sid)
|
|
2659
|
+
n = 1
|
|
2660
|
+
while True:
|
|
2661
|
+
tail = "-copy" if n == 1 else f"-copy-{n}"
|
|
2662
|
+
copy = stem[:120 - len(tail) - len(ext)] + tail + ext
|
|
2663
|
+
if copy not in held:
|
|
2664
|
+
break
|
|
2665
|
+
n += 1
|
|
2666
|
+
stored, err = self.add_file(sid, copy, path.read_bytes())
|
|
2667
|
+
if err:
|
|
2668
|
+
return None, err
|
|
2669
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2670
|
+
db.execute("INSERT INTO source_calls (source, name, origin, at, provider, request, status, error, file) "
|
|
2671
|
+
"SELECT source, ?, origin, at, provider, request, status, error, ? "
|
|
2672
|
+
"FROM source_calls WHERE source = ? AND name = ?", (stored, stored, sid, name))
|
|
2673
|
+
return {"name": stored}, None
|
|
2674
|
+
|
|
2450
2675
|
def remove_calls(self, sid, names):
|
|
2451
2676
|
"""Calls out by name: a file-backed Call to the trash (Undo restores
|
|
2452
2677
|
it), a failed Call's row dropped outright (it has no file). Returns
|
|
@@ -3871,6 +4096,24 @@ class Datasets:
|
|
|
3871
4096
|
"SELECT name FROM datasets WHERE trash IS NULL AND id IS NOT ? AND workspace = ?",
|
|
3872
4097
|
(but, workspace_of(self.store)))}
|
|
3873
4098
|
|
|
4099
|
+
def naming_item(self, sid, item) -> list:
|
|
4100
|
+
"""The names of this workspace's groups previewed against Source
|
|
4101
|
+
[sid] whose cases name [item]: what renaming that item would break."""
|
|
4102
|
+
out = []
|
|
4103
|
+
with self.store.lock, self._connect() as db:
|
|
4104
|
+
for r in db.execute("SELECT name, body, preview FROM datasets WHERE trash IS NULL "
|
|
4105
|
+
"AND workspace = ? AND preview IS NOT NULL ORDER BY name COLLATE NOCASE",
|
|
4106
|
+
(workspace_of(self.store),)):
|
|
4107
|
+
try:
|
|
4108
|
+
ref, body = json.loads(r["preview"]), json.loads(r["body"])
|
|
4109
|
+
except ValueError:
|
|
4110
|
+
continue
|
|
4111
|
+
cases = body.get("cases") if isinstance(body, dict) else None
|
|
4112
|
+
if isinstance(ref, dict) and ref.get("id") == sid and isinstance(cases, list) \
|
|
4113
|
+
and any(isinstance(c, dict) and c.get("item") == item for c in cases):
|
|
4114
|
+
out.append(r["name"])
|
|
4115
|
+
return out
|
|
4116
|
+
|
|
3874
4117
|
def list(self) -> list:
|
|
3875
4118
|
with self.store.lock, self._connect() as db:
|
|
3876
4119
|
counts = self._counts(db)
|
|
@@ -6942,7 +7185,7 @@ def bundle_problems(files, keys=()):
|
|
|
6942
7185
|
for key in keys:
|
|
6943
7186
|
key = str(key or "").strip()
|
|
6944
7187
|
if len(key) >= 4 and key in text:
|
|
6945
|
-
return f"{path} holds a
|
|
7188
|
+
return f"{path} holds a connection's key, and a key never leaves the lab"
|
|
6946
7189
|
if TOKEN_SHAPE.search(text):
|
|
6947
7190
|
return f"{path} holds a token, and a key never leaves the lab"
|
|
6948
7191
|
m = BUNDLE_KEY_FIELD.search(text)
|
|
@@ -6965,7 +7208,7 @@ def build_bundle(payload):
|
|
|
6965
7208
|
return None, (400, "a bundle holds pipeline.yaml and profiles.yaml")
|
|
6966
7209
|
stored = []
|
|
6967
7210
|
if STORE is not None:
|
|
6968
|
-
stored = ((STORE.all().get("promptlab.
|
|
7211
|
+
stored = ((STORE.all().get("promptlab.connections") or {}).get("body") or {}).get("list") or []
|
|
6969
7212
|
why = bundle_problems(files, [p.get("key") for p in stored if isinstance(p, dict)])
|
|
6970
7213
|
if why:
|
|
6971
7214
|
return None, (400, why)
|
|
@@ -6994,10 +7237,12 @@ def build_bundle(payload):
|
|
|
6994
7237
|
|
|
6995
7238
|
|
|
6996
7239
|
# Shared-by-choice enforcement (docs/workspaces.md): the one sentence the Run
|
|
6997
|
-
# bar turns into a Share link, and the
|
|
6998
|
-
#
|
|
6999
|
-
#
|
|
7000
|
-
#
|
|
7240
|
+
# bar turns into a Share link, and the connections that earn it -- the AI
|
|
7241
|
+
# Connections the run resolved to that are not shared with workspace `ws`,
|
|
7242
|
+
# a profile's own connection included where it answers on one. A local
|
|
7243
|
+
# connection reaches nothing, so sharing does not gate it. A share set is
|
|
7244
|
+
# written against the id a Target profile had, which its connection keeps
|
|
7245
|
+
# (#445), so a "profile" row names the connection.
|
|
7001
7246
|
def not_shared_sentence(names, wsname: str) -> str:
|
|
7002
7247
|
verb = "is" if len(names) == 1 else "are"
|
|
7003
7248
|
return f"{', '.join(names)} {verb} not shared with {wsname}."
|
|
@@ -7007,12 +7252,16 @@ def unshared_profiles(run: dict, ws):
|
|
|
7007
7252
|
table = run.get("profiles") if isinstance(run, dict) else None
|
|
7008
7253
|
if not isinstance(table, dict) or STORE is None:
|
|
7009
7254
|
return []
|
|
7010
|
-
out = []
|
|
7011
|
-
for
|
|
7255
|
+
out, seen = [], set()
|
|
7256
|
+
for key, conn in table.items():
|
|
7012
7257
|
if isinstance(conn, dict) and conn.get("type") in LOCAL_CONNECTIONS:
|
|
7013
7258
|
continue
|
|
7014
|
-
|
|
7015
|
-
|
|
7259
|
+
cid = answer_connection(key, conn)
|
|
7260
|
+
if cid in seen:
|
|
7261
|
+
continue
|
|
7262
|
+
seen.add(cid)
|
|
7263
|
+
if not STORE.shared("profile", cid, ws):
|
|
7264
|
+
out.append({"id": cid, "name": (isinstance(conn, dict) and conn.get("name")) or cid})
|
|
7016
7265
|
return out
|
|
7017
7266
|
|
|
7018
7267
|
|
|
@@ -7029,27 +7278,29 @@ def worker_destinations(run: dict):
|
|
|
7029
7278
|
return None, not_shared_sentence([b["name"] for b in bad], STORE.workspace_name(ws))
|
|
7030
7279
|
stored = []
|
|
7031
7280
|
if STORE is not None:
|
|
7032
|
-
doc = STORE.all().get("promptlab.
|
|
7281
|
+
doc = STORE.all().get("promptlab.connections") or {}
|
|
7033
7282
|
stored = (doc.get("body") or {}).get("list") or []
|
|
7283
|
+
# One variable a connection, however many targets or profiles share it.
|
|
7034
7284
|
env = {}
|
|
7035
|
-
for
|
|
7285
|
+
for key, conn in table.items():
|
|
7286
|
+
pid = answer_connection(key, conn)
|
|
7036
7287
|
name = conn.get("name") or pid
|
|
7037
7288
|
if isinstance(conn, dict) and conn.get("type") in LOCAL_CONNECTIONS:
|
|
7038
7289
|
continue
|
|
7039
7290
|
base = api_base(str(conn.get("url") or "")) or api_base(OLLAMA)
|
|
7040
7291
|
why = allowed(base, "")
|
|
7041
7292
|
if why:
|
|
7042
|
-
return None, f"
|
|
7293
|
+
return None, f"Connection {name}: {why}"
|
|
7043
7294
|
profile = next((p for p in stored if isinstance(p, dict) and p.get("id") == pid), None)
|
|
7044
7295
|
if profile is None:
|
|
7045
|
-
return None, f"
|
|
7296
|
+
return None, f"Connection {name} not found"
|
|
7046
7297
|
key = str(profile.get("key") or "").strip()
|
|
7047
7298
|
if key:
|
|
7048
7299
|
if not header_safe(key):
|
|
7049
|
-
return None, f"
|
|
7300
|
+
return None, f"Connection {name} has a key that cannot go in a header"
|
|
7050
7301
|
why = allowed(base, key)
|
|
7051
7302
|
if why:
|
|
7052
|
-
return None, f"
|
|
7303
|
+
return None, f"Connection {name}: {why}"
|
|
7053
7304
|
env[key_var(pid, conn.get("slug"))] = key
|
|
7054
7305
|
return env, None
|
|
7055
7306
|
|
|
@@ -7078,11 +7329,14 @@ def worker_destinations(run: dict):
|
|
|
7078
7329
|
# inline Drop items / Reject rules migrate into a private one (§18, #339).
|
|
7079
7330
|
# 15: every cleaning step is a filter -- a job's Tidy / Drop prompt echoes /
|
|
7080
7331
|
# Remove duplicates / Keep first / Case modifiers and its List `preamble`
|
|
7081
|
-
# setting migrate into
|
|
7082
|
-
|
|
7332
|
+
# setting migrate into Pre-/Post-format filter sets (§18, #405).
|
|
7333
|
+
# 16: a target names an AI Connection and, optionally, a Connection Profile,
|
|
7334
|
+
# where it named one Target profile; the run's table is keyed by the pair
|
|
7335
|
+
# (answer_key), each entry naming the connection whose key it takes (#445).
|
|
7336
|
+
PIPELINE_VERSION = 16
|
|
7083
7337
|
# What a stored run may be: the current version, and the ones evals-core.ts's
|
|
7084
7338
|
# upgradePipeline reads. A new submission is upgraded to the current one.
|
|
7085
|
-
READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)
|
|
7339
|
+
READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
|
|
7086
7340
|
TARGET_CAP = 4
|
|
7087
7341
|
# Target steps whose words the Prompt library does not record as a use: they
|
|
7088
7342
|
# ask no model (evals-core.ts's STEP_TYPES.echo).
|
|
@@ -7133,11 +7387,88 @@ BUILTIN_CONNECTION_TYPES = dict(CONNECTION_TYPES)
|
|
|
7133
7387
|
BUILTIN_LOCAL = set(LOCAL_CONNECTIONS)
|
|
7134
7388
|
BUILTIN_CHAT_PATHS = dict(CONNECTION_CHAT_PATHS)
|
|
7135
7389
|
BUILTIN_AUTH = dict(CONNECTION_AUTH)
|
|
7136
|
-
CONNECTION_FIELDS = ("name", "slug", "url", "model", "type", "temperature", "px", "format",
|
|
7390
|
+
CONNECTION_FIELDS = ("name", "slug", "connection", "url", "model", "type", "temperature", "px", "format",
|
|
7137
7391
|
"quality", "options")
|
|
7138
7392
|
LOOKS_LIKE_A_KEY = re.compile(r"^(?:api[-_]?)?key$|^(?:authorization|bearer|secret|password|token)$",
|
|
7139
7393
|
re.IGNORECASE)
|
|
7140
7394
|
PROFILE_ID = re.compile(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)?")
|
|
7395
|
+
# A run's table key (evals-core.ts answerKey): a connection's id, then a dot
|
|
7396
|
+
# and its profile's id -- or nothing, for none -- or a grader's bare id.
|
|
7397
|
+
ANSWER_KEY = re.compile(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)?(?:\.(?:[A-Za-z0-9]+(?:-[A-Za-z0-9]+)?)?)?")
|
|
7398
|
+
|
|
7399
|
+
|
|
7400
|
+
def answer_key(connection: str, profile=None) -> str:
|
|
7401
|
+
return f"{connection}.{profile or ''}"
|
|
7402
|
+
|
|
7403
|
+
|
|
7404
|
+
def answer_connection(key, conn) -> str:
|
|
7405
|
+
"""The AI Connection a run's entry [key] takes its key from: the one it
|
|
7406
|
+
names, or -- a run from before version 16 -- the key's own id."""
|
|
7407
|
+
cid = conn.get("connection") if isinstance(conn, dict) else None
|
|
7408
|
+
return cid if isinstance(cid, str) and cid else str(key).split(".", 1)[0]
|
|
7409
|
+
|
|
7410
|
+
|
|
7411
|
+
def run_answer(table, key):
|
|
7412
|
+
"""What a run's table holds for [key] (evals-core.ts runAnswer): its own
|
|
7413
|
+
entry, or -- a run from before version 16, keyed by Target profile id --
|
|
7414
|
+
the connection's, where the key names no profile."""
|
|
7415
|
+
if key in table:
|
|
7416
|
+
return table[key]
|
|
7417
|
+
head, dot, tail = key.partition(".")
|
|
7418
|
+
return table.get(head) if dot and not tail else None
|
|
7419
|
+
|
|
7420
|
+
|
|
7421
|
+
# What an AI Connection keeps of a Target profile (evals-core.ts
|
|
7422
|
+
# AI_CONNECTION_FIELDS), beside the settings of its type that say how it is
|
|
7423
|
+
# reached (CONNECTION_SCOPED, the registry's `scope: "connection"`); every
|
|
7424
|
+
# other setting set is a Connection Profile's. Mirrored, and held to the
|
|
7425
|
+
# core's splitProfile, by proxy-check.py.
|
|
7426
|
+
AI_CONNECTION_FIELDS = ("id", "name", "slug", "type", "url", "key", "model", "profile")
|
|
7427
|
+
CONNECTION_SCOPED = {"http": ("auth", "headers", "testPath")}
|
|
7428
|
+
# evals-core.ts REPLY_TOKENS_BEFORE, and the setting of each type that sent it.
|
|
7429
|
+
REPLY_TOKENS_BEFORE = "160"
|
|
7430
|
+
REPLY_TOKENS_SETTING = {"openai-compatible": "nPredict", "llama.cpp": "nPredict", "anthropic": "maxTokens"}
|
|
7431
|
+
|
|
7432
|
+
|
|
7433
|
+
def pin_reply_tokens(p):
|
|
7434
|
+
"""evals-core.ts pinReplyTokens: a profile with no Reply tokens setting, of
|
|
7435
|
+
a type that sent the cap, has the cap it was sending written in. The split
|
|
7436
|
+
keeps no blank setting, so after it nothing could tell such a profile from
|
|
7437
|
+
one made since; it is pinned here, before."""
|
|
7438
|
+
key = REPLY_TOKENS_SETTING.get(str(p.get("type")))
|
|
7439
|
+
return p if not key or key in p else {**p, key: REPLY_TOKENS_BEFORE}
|
|
7440
|
+
|
|
7441
|
+
|
|
7442
|
+
def split_profile(p):
|
|
7443
|
+
"""A Target profile as (AI Connection, Connection Profile or None): the
|
|
7444
|
+
connection keeps its id, key and address; a profile, stamped with the id
|
|
7445
|
+
it came from, holds every other setting it set (its reply-token cap
|
|
7446
|
+
pinned, pin_reply_tokens)."""
|
|
7447
|
+
p = pin_reply_tokens(p)
|
|
7448
|
+
scoped = CONNECTION_SCOPED.get(p.get("type"), ())
|
|
7449
|
+
conn, settings = {}, {}
|
|
7450
|
+
for k, v in p.items():
|
|
7451
|
+
if k in AI_CONNECTION_FIELDS or k in scoped:
|
|
7452
|
+
conn[k] = v
|
|
7453
|
+
elif v is not None and v != "":
|
|
7454
|
+
settings[k] = v
|
|
7455
|
+
if not settings:
|
|
7456
|
+
return conn, None
|
|
7457
|
+
pid = str(p.get("id") or "")
|
|
7458
|
+
return conn, {"id": pid, "name": str(p.get("name") or ""), "fromProfile": pid, **settings}
|
|
7459
|
+
|
|
7460
|
+
|
|
7461
|
+
def split_profiles(listed):
|
|
7462
|
+
"""A profiles store's list as (connections, connection profiles)."""
|
|
7463
|
+
connections, profiles = [], []
|
|
7464
|
+
for p in listed if isinstance(listed, list) else []:
|
|
7465
|
+
if not isinstance(p, dict):
|
|
7466
|
+
continue
|
|
7467
|
+
conn, prof = split_profile(p)
|
|
7468
|
+
connections.append(conn)
|
|
7469
|
+
if prof is not None:
|
|
7470
|
+
profiles.append(prof)
|
|
7471
|
+
return connections, profiles
|
|
7141
7472
|
# evals-core.ts's SLUG and SLUG_MAX: what a key's variable is spelt from.
|
|
7142
7473
|
PROFILE_SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
|
7143
7474
|
SLUG_MAX = 32
|
|
@@ -7218,7 +7549,7 @@ def connection_problems(pid, conn, at, bad):
|
|
|
7218
7549
|
if slug is not None and not (isinstance(slug, str) and PROFILE_SLUG.fullmatch(slug) and len(slug) <= SLUG_MAX):
|
|
7219
7550
|
bad.append(f"{at}: slug has to be lowercase letters and digits, with hyphens between, at most {SLUG_MAX}")
|
|
7220
7551
|
slug = None
|
|
7221
|
-
var = key_var(pid, slug)
|
|
7552
|
+
var = key_var(answer_connection(pid, conn), slug)
|
|
7222
7553
|
_fields(conn, at, CONNECTION_FIELDS, bad, var)
|
|
7223
7554
|
ctype = conn.get("type")
|
|
7224
7555
|
if not isinstance(ctype, str) or ctype not in CONNECTION_TYPES:
|
|
@@ -7364,17 +7695,23 @@ def run_problems(run):
|
|
|
7364
7695
|
spelt = {}
|
|
7365
7696
|
for pid, conn in table.items():
|
|
7366
7697
|
at = f"profile {pid}"
|
|
7367
|
-
if not
|
|
7698
|
+
if not ANSWER_KEY.fullmatch(str(pid)):
|
|
7368
7699
|
bad.append(f"{at}: an id is letters and digits, with at most one hyphen")
|
|
7369
7700
|
continue
|
|
7370
7701
|
if not isinstance(conn, dict):
|
|
7371
7702
|
bad.append(f"{at} has to be an object")
|
|
7372
7703
|
continue
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7704
|
+
if conn.get("connection") is not None and not (
|
|
7705
|
+
isinstance(conn["connection"], str) and PROFILE_ID.fullmatch(conn["connection"])):
|
|
7706
|
+
bad.append(f"{at}: connection has to be an AI Connection's id")
|
|
7707
|
+
continue
|
|
7708
|
+
# Entries sharing a connection share its key; two connections
|
|
7709
|
+
# spelling one variable would hand one the other's.
|
|
7710
|
+
cid = answer_connection(pid, conn)
|
|
7711
|
+
var = key_var(cid, conn.get("slug") if isinstance(conn.get("slug"), str) else None)
|
|
7712
|
+
if var in spelt and spelt[var] != cid:
|
|
7713
|
+
bad.append(f"profiles {spelt[var]} and {cid} both take their key from ${var}")
|
|
7714
|
+
spelt[var] = cid
|
|
7378
7715
|
connection_problems(pid, conn, at, bad)
|
|
7379
7716
|
targets = run.get("targets")
|
|
7380
7717
|
if not isinstance(targets, list) or not 1 <= len(targets) <= TARGET_CAP:
|
|
@@ -7394,10 +7731,17 @@ def run_problems(run):
|
|
|
7394
7731
|
if not isinstance(steps, list) or len(steps) != len(jobs):
|
|
7395
7732
|
bad.append(f"{at} has to have one step per job")
|
|
7396
7733
|
continue
|
|
7397
|
-
# A target with no
|
|
7398
|
-
# name their own; which steps need one is the core's to judge.
|
|
7399
|
-
|
|
7400
|
-
|
|
7734
|
+
# A target with no connection is one whose steps ask none (Echo) or
|
|
7735
|
+
# each name their own; which steps need one is the core's to judge.
|
|
7736
|
+
# Each pair named -- the target's, a step's own -- is one the run
|
|
7737
|
+
# carries (evals-core.ts targetAnswerOf).
|
|
7738
|
+
def pair(o, base=None):
|
|
7739
|
+
c = o.get("connection") if o.get("connection") is not None else (base or {}).get("connection")
|
|
7740
|
+
p = o.get("profile")
|
|
7741
|
+
return c, p
|
|
7742
|
+
refs = [pair(t)] if t.get("connection") is not None or t.get("profile") is not None else []
|
|
7743
|
+
refs += [pair(st, t) for st in steps
|
|
7744
|
+
if isinstance(st, dict) and (st.get("connection") is not None or st.get("profile") is not None)]
|
|
7401
7745
|
# Whether the words may be blank -- Echo answering from the item --
|
|
7402
7746
|
# is the core's to judge, and the worker refuses the run in its words.
|
|
7403
7747
|
for k, step in enumerate(steps):
|
|
@@ -7408,9 +7752,11 @@ def run_problems(run):
|
|
|
7408
7752
|
elif step.get("from") is not None and not (
|
|
7409
7753
|
isinstance(step["from"], dict) and isinstance(step["from"].get("id"), str)):
|
|
7410
7754
|
bad.append(f"{at}, job {k + 1} names the prompt it was picked from without an id")
|
|
7411
|
-
for
|
|
7412
|
-
if not isinstance(
|
|
7413
|
-
|
|
7755
|
+
for c, p in refs:
|
|
7756
|
+
if (not isinstance(c, dict) or not isinstance(c.get("id"), str)
|
|
7757
|
+
or (p is not None and not (isinstance(p, dict) and isinstance(p.get("id"), str)))
|
|
7758
|
+
or run_answer(table, answer_key(c["id"], p["id"] if p else None)) is None):
|
|
7759
|
+
bad.append(f"{at} names a connection the run does not carry")
|
|
7414
7760
|
content = content_of(run)
|
|
7415
7761
|
kind = content.get("type") if isinstance(content, dict) else None
|
|
7416
7762
|
if kind == "source":
|
|
@@ -7747,7 +8093,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7747
8093
|
return self._send(404, b"not found", "text/plain")
|
|
7748
8094
|
return self._json(200, {"plugins": PLUGINS.list(), "cap": PLUGIN_CAP})
|
|
7749
8095
|
if path == "/api/connections/shares":
|
|
7750
|
-
# Which workspaces may use each
|
|
8096
|
+
# Which workspaces may use each AI Connection and Account
|
|
7751
8097
|
# (docs/workspaces.md): the share sets the Connections menus read.
|
|
7752
8098
|
# Keyed by "<kind>:<id>", workspace ids and the sentinels, no key.
|
|
7753
8099
|
if STORE is None:
|
|
@@ -8480,7 +8826,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8480
8826
|
if revs[name] is None:
|
|
8481
8827
|
return self._json(400, {"error": f"{name!r} is not in that Source"})
|
|
8482
8828
|
content["revs"] = revs
|
|
8483
|
-
# A
|
|
8829
|
+
# A connection not shared with this workspace blocks the run with the
|
|
8484
8830
|
# sentence the Run bar shows and the connections it names, so its Share
|
|
8485
8831
|
# link can grant them at once (docs/workspaces.md).
|
|
8486
8832
|
ws = workspace_of(STORE) if STORE is not None else None
|
|
@@ -9001,6 +9347,24 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9001
9347
|
code, message = err
|
|
9002
9348
|
return self._json(code, {"error": message})
|
|
9003
9349
|
return self._json(200, {"trash": token})
|
|
9350
|
+
if len(parts) == 6 and parts[4] == "calls" and parts[5] in ("edit", "clone"):
|
|
9351
|
+
payload = self._payload() or {}
|
|
9352
|
+
name = source_name(payload.get("name"))
|
|
9353
|
+
if name is None:
|
|
9354
|
+
return self._json(400, {"error": "a call name is not accepted here"})
|
|
9355
|
+
if parts[5] == "clone":
|
|
9356
|
+
call, err = SOURCES.clone_call(parts[3], name)
|
|
9357
|
+
else:
|
|
9358
|
+
to = payload.get("to")
|
|
9359
|
+
if to is not None and not isinstance(to, str):
|
|
9360
|
+
return self._json(400, {"error": "that name is not accepted here", "field": "name"})
|
|
9361
|
+
sid = parts[3]
|
|
9362
|
+
call, err = SOURCES.edit_call(
|
|
9363
|
+
sid, name, to, payload.get("content"), payload.get("origin"),
|
|
9364
|
+
named_by=(lambda item: DATASETS.naming_item(sid, item)) if DATASETS else None)
|
|
9365
|
+
if err:
|
|
9366
|
+
return self._json(err[0], {"error": err[1], **({"field": err[2]} if len(err) > 2 else {})})
|
|
9367
|
+
return self._json(201 if parts[5] == "clone" else 200, {"call": call})
|
|
9004
9368
|
if len(parts) == 5 and parts[4] == "copy":
|
|
9005
9369
|
payload = self._payload() or {}
|
|
9006
9370
|
source, err = SOURCES.copy_files(parts[3], payload.get("from"),
|