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/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 = 1
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 ("run", "hand", "upload"):
604
- bad.append("a record's origin is run, hand or upload")
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 profiles store's, by id, sent the way 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.profiles") or {}) if STORE is not None else {}
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. Returns the shape _relay_produce does."""
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
- SYNCED = ("promptlab.workflows", "promptlab.profiles",
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
- # A Target profile's key is written from the page and never read back by it
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 profile holds one, what is served
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 profile's id instead, and a write that brings that back
1096
- # keeps the key the store holds for the id: the profile's own, or the one it
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
- """Every profile a synced document holds: the profiles store's list, and
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.profiles":
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
- DOC_GLOBAL = ("promptlab.profiles", "promptlab.tokens")
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 profile id's key as the store holds it: its profile's, else a
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.profiles", now.get("promptlab.profiles", {}).get("body")):
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.profiles" in docs:
1468
+ if "promptlab.connections" in docs:
1412
1469
  kept = {str(p.get("id") or "") for p in profiles_in(
1413
- "promptlab.profiles", docs["promptlab.profiles"]["body"])}
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.profiles", have("promptlab.profiles")["body"])
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
- # A Target profile or an Account (kind "profile"/"account", id the
1438
- # profile's or the account's) is usable in a workspace by its share set in
1439
- # `connection_shares`: a row for that workspace, or the SHARE_ALL sentinel.
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
- """A Target profile's display name, by id, from the global profiles
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.profiles' "
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", "") or secrets.token_hex(6))
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 Target profile's key, and a key never leaves the lab"
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.profiles") or {}).get("body") or {}).get("list") or []
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 profiles that earn it -- the run's
6998
- # Target profiles not shared with workspace `ws`. A local profile reaches
6999
- # nothing, so sharing does not gate it. Keyed by the run's profiles table,
7000
- # whose keys are the profile ids the share set is written against.
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 pid, conn in table.items():
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
- if not STORE.shared("profile", pid, ws):
7015
- out.append({"id": pid, "name": (isinstance(conn, dict) and conn.get("name")) or pid})
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.profiles") or {}
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 pid, conn in table.items():
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"Target profile {name}: {why}"
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"Target profile {name} not found"
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"Target profile {name} has a key that cannot go in a header"
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"Target profile {name}: {why}"
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 before/after-Read-as filter sets (§18, #405).
7082
- PIPELINE_VERSION = 15
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 PROFILE_ID.fullmatch(str(pid)):
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
- # Two profiles spelling one variable would hand one the other's key.
7374
- var = key_var(pid, conn.get("slug") if isinstance(conn.get("slug"), str) else None)
7375
- if var in spelt:
7376
- bad.append(f"profiles {spelt[var]} and {pid} both take their key from ${var}")
7377
- spelt[var] = pid
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 profile is one whose steps ask none (Echo) or each
7398
- # name their own; which steps need one is the core's to judge.
7399
- refs = ([t["profile"]] if t.get("profile") is not None else []) + [
7400
- st.get("profile") for st in steps if isinstance(st, dict) and st.get("profile") is not None]
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 ref in refs:
7412
- if not isinstance(ref, dict) or ref.get("id") not in table:
7413
- bad.append(f"{at} names a Target profile the run does not carry")
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 Target profile and Account
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 profile not shared with this workspace blocks the run with the
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"),