evals-lab 0.1.3 → 0.2.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/README.md +95 -1
- package/bin/evals-lab.js +10 -2
- package/lab/VERSION +1 -1
- package/lab/demo/pipelines/demo-1.json +34 -24
- package/lab/demo/pipelines/demo-2.json +34 -24
- package/lab/evals-core.mjs +701 -284
- package/lab/run-evals.js +42 -16
- package/lab/server.py +339 -80
- package/lab/web/dist/assets/gallery-DsetJSXv.js +3 -0
- package/lab/web/dist/assets/{main-DjQQums6.css → main-B-VtDGxC.css} +1 -1
- package/lab/web/dist/assets/main-ERoD2Kll.js +19 -0
- package/lab/web/dist/assets/tokens-ClRQ7Mui.js +51 -0
- package/lab/web/dist/assets/tokens-D3C8O2Ib.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-DipkRvqJ.js +0 -3
- package/lab/web/dist/assets/main-HJGRUBM-.js +0 -18
- package/lab/web/dist/assets/tokens-B9intIuT.js +0 -51
- package/lab/web/dist/assets/tokens-s6I-RMVq.css +0 -1
package/lab/server.py
CHANGED
|
@@ -288,9 +288,44 @@ DEFAULT_SOURCE_TYPE = "files"
|
|
|
288
288
|
# The Entra app the page signs in to Microsoft 365 with (docs/power-automate.md
|
|
289
289
|
# § Registering the app). A single-page app has no secret, so both are public
|
|
290
290
|
# and served to the page; the token it gets stays in the browser, and the
|
|
291
|
-
# server never sees one.
|
|
291
|
+
# server never sees one. The deployment's own, when it names one, then the one
|
|
292
|
+
# entered in Setup (MicrosoftApp), then the published app below; with none, a
|
|
293
|
+
# Power Automate workflow cannot be made.
|
|
292
294
|
M365_CLIENT_ID = os.environ.get("M365_CLIENT_ID") or None
|
|
293
295
|
M365_TENANT = os.environ.get("M365_TENANT") or "organizations"
|
|
296
|
+
# The multitenant app published for every lab, a tenant's admin consenting to
|
|
297
|
+
# it once. None until it is registered and its publisher verified: an
|
|
298
|
+
# unverified app from another organisation is one most tenants' users may not
|
|
299
|
+
# consent to, so shipping it earlier would only move the failure to sign-in.
|
|
300
|
+
PUBLIC_M365_CLIENT_ID = None
|
|
301
|
+
|
|
302
|
+
GUID = re.compile(r"[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}")
|
|
303
|
+
TENANT_DOMAIN = re.compile(r"(?=.{4,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def microsoft_config():
|
|
307
|
+
"""The Microsoft app this lab signs in with, and where it came from."""
|
|
308
|
+
if M365_CLIENT_ID:
|
|
309
|
+
return {"clientId": M365_CLIENT_ID, "tenant": M365_TENANT, "from": "env"}
|
|
310
|
+
kept = MICROSOFT.get() if MICROSOFT is not None else None
|
|
311
|
+
if kept:
|
|
312
|
+
return {**kept, "from": "lab"}
|
|
313
|
+
if PUBLIC_M365_CLIENT_ID:
|
|
314
|
+
return {"clientId": PUBLIC_M365_CLIENT_ID, "tenant": "organizations", "from": "default"}
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def microsoft_problem(client_id, tenant):
|
|
319
|
+
"""Why a client and tenant cannot be this lab's Microsoft app, or None."""
|
|
320
|
+
if not GUID.fullmatch(client_id):
|
|
321
|
+
return "a client ID is a GUID, the app registration's Application (client) ID"
|
|
322
|
+
if tenant.lower() in ("common", "consumers"):
|
|
323
|
+
# Microsoft retired cloud flows for personal accounts on 2025-07-26,
|
|
324
|
+
# so an authority that admits them only admits a sign-in with no flows.
|
|
325
|
+
return "a personal Microsoft account has no Power Automate flows: name a tenant, or organizations"
|
|
326
|
+
if not (tenant == "organizations" or GUID.fullmatch(tenant) or TENANT_DOMAIN.fullmatch(tenant)):
|
|
327
|
+
return "a tenant is its Directory (tenant) ID, one of its domains, or organizations"
|
|
328
|
+
return None
|
|
294
329
|
|
|
295
330
|
# How much of a flow a Source keeps: a definition's size, and how many of its
|
|
296
331
|
# versions -- the documents rule's ten.
|
|
@@ -420,7 +455,7 @@ SOURCE_TYPES["power-automate"]["take"] = take_record
|
|
|
420
455
|
|
|
421
456
|
# The sign-ins a Source type may be made with, and whether this lab has each:
|
|
422
457
|
# a type naming one this lab lacks cannot be made.
|
|
423
|
-
SIGN_INS = {"microsoft": lambda:
|
|
458
|
+
SIGN_INS = {"microsoft": lambda: microsoft_config() is not None}
|
|
424
459
|
|
|
425
460
|
|
|
426
461
|
# The browser stores that follow their user between devices, by their
|
|
@@ -1441,7 +1476,7 @@ def scenario_ref(sc, i):
|
|
|
1441
1476
|
what version 4's upgrade gives one, by position."""
|
|
1442
1477
|
sid = sc.get("id") if isinstance(sc, dict) else None
|
|
1443
1478
|
name = (sc.get("name") or "").strip() if isinstance(sc, dict) and isinstance(sc.get("name"), str) else ""
|
|
1444
|
-
return (sid if isinstance(sid, str) and sid else f"s{i + 1}"), (name or f"
|
|
1479
|
+
return (sid if isinstance(sid, str) and sid else f"s{i + 1}"), (name or f"Target {i + 1}")
|
|
1445
1480
|
|
|
1446
1481
|
|
|
1447
1482
|
class Prompts:
|
|
@@ -1597,27 +1632,28 @@ class Prompts:
|
|
|
1597
1632
|
recorded once: a second call for the same run changes nothing."""
|
|
1598
1633
|
# The caller's connection: a Row still reads by index, as it does.
|
|
1599
1634
|
db.row_factory = sqlite3.Row
|
|
1600
|
-
for i, sc in
|
|
1601
|
-
if not isinstance(sc, dict):
|
|
1602
|
-
continue
|
|
1635
|
+
for i, sc, k, cell in cells_of(run):
|
|
1603
1636
|
sid, sname = scenario_ref(sc, i)
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1637
|
+
# Echo's words are no wording under test: the item is its reply,
|
|
1638
|
+
# and over Prompt only the words are the reply itself.
|
|
1639
|
+
if isinstance(cell, dict) and cell.get("type") in UNRECORDED_STEPS:
|
|
1640
|
+
continue
|
|
1641
|
+
text = cell.get("prompt") if isinstance(cell, dict) else None
|
|
1642
|
+
if not isinstance(text, str) or not text.strip():
|
|
1643
|
+
continue
|
|
1644
|
+
pid = version = None
|
|
1645
|
+
ref = cell.get("from")
|
|
1646
|
+
if isinstance(ref, dict) and isinstance(ref.get("id"), str) and self._live(db, ref["id"]):
|
|
1647
|
+
pid = ref["id"]
|
|
1648
|
+
hit = db.execute("SELECT version FROM prompt_versions WHERE prompt_id = ? AND text = ? "
|
|
1649
|
+
"ORDER BY version DESC LIMIT 1", (pid, text)).fetchone()
|
|
1650
|
+
version = hit[0] if hit else self._cut(db, pid, text)
|
|
1651
|
+
if pid is None:
|
|
1652
|
+
hit = self._matching(db, text)
|
|
1653
|
+
pid, version = (hit[0], hit[1]) if hit else (self._insert(db, "", text), 1)
|
|
1654
|
+
db.execute("INSERT OR IGNORE INTO prompt_uses (prompt_id, version, run_id, scenario_id, "
|
|
1655
|
+
"scenario_name, job, at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
1656
|
+
(pid, version, rid, sid, sname, k, at))
|
|
1621
1657
|
|
|
1622
1658
|
def backfill(self, runs):
|
|
1623
1659
|
"""The runs from before the library, read into it once. [runs] is
|
|
@@ -2542,16 +2578,56 @@ def registered_ids(manifest: dict) -> set:
|
|
|
2542
2578
|
# ---- Connections: the lab's grants to outside services (#127) --------------
|
|
2543
2579
|
#
|
|
2544
2580
|
# One Google grant per lab, for Sources that read a Drive folder. The OAuth
|
|
2545
|
-
# client is the deployment's (
|
|
2546
|
-
#
|
|
2547
|
-
#
|
|
2548
|
-
#
|
|
2549
|
-
#
|
|
2550
|
-
#
|
|
2581
|
+
# client it signs in with is the deployment's (GOOGLE_* below), then the one
|
|
2582
|
+
# entered in Setup (GoogleApp), then the published app (PUBLIC_GOOGLE_APP),
|
|
2583
|
+
# as Microsoft's is (docs/power-automate.md). The refresh token the grant
|
|
2584
|
+
# yields is the store's, in a table of its own, so /api/state -- which serves
|
|
2585
|
+
# the synced documents to the page -- can never carry it, and nor can the
|
|
2586
|
+
# client's secret. The page is told only whether there is a grant and whose.
|
|
2587
|
+
# The hosts are fixed here, not chosen by anything a request carries, and
|
|
2588
|
+
# reached through OPENER: no redirects, no proxy from the environment.
|
|
2551
2589
|
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID") or None
|
|
2552
2590
|
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET") or None
|
|
2553
2591
|
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY") or None
|
|
2554
|
-
|
|
2592
|
+
# A deployment reached at its own address signs in with a Web application
|
|
2593
|
+
# client, whose redirect it registers; "installed" is Google's Desktop app.
|
|
2594
|
+
GOOGLE_CLIENT_TYPE = os.environ.get("GOOGLE_CLIENT_TYPE") or "web"
|
|
2595
|
+
GOOGLE_CLIENT_TYPES = ("installed", "web")
|
|
2596
|
+
# An OAuth client's id is its project's number, a dash, and Google's own
|
|
2597
|
+
# suffix: the number is the app id the Picker is told, so it is never asked.
|
|
2598
|
+
GOOGLE_CLIENT = re.compile(r"(\d+)-[0-9a-z]+\.apps\.googleusercontent\.com")
|
|
2599
|
+
GOOGLE_KEY = re.compile(r"[A-Za-z0-9_-]{30,60}")
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
def read_published_google(path):
|
|
2603
|
+
"""The published app from the file the npm package's build writes
|
|
2604
|
+
beside server.py: (app, None), (None, None) when there is no file, or
|
|
2605
|
+
(None, why) for a file that is not one."""
|
|
2606
|
+
if not path.is_file():
|
|
2607
|
+
return None, None
|
|
2608
|
+
try:
|
|
2609
|
+
got = json.loads(path.read_text("utf-8"))
|
|
2610
|
+
except (OSError, ValueError):
|
|
2611
|
+
return None, f"{path} is not JSON"
|
|
2612
|
+
if not (isinstance(got, dict) and isinstance(got.get("clientId"), str)
|
|
2613
|
+
and GOOGLE_CLIENT.fullmatch(got["clientId"]) and isinstance(got.get("clientSecret"), str)
|
|
2614
|
+
and got["clientSecret"] and isinstance(got.get("apiKey"), str) and GOOGLE_KEY.fullmatch(got["apiKey"])):
|
|
2615
|
+
return None, f"{path} is not a Google app: {{ clientId, clientSecret, apiKey }}"
|
|
2616
|
+
return {"clientId": got["clientId"], "clientSecret": got["clientSecret"],
|
|
2617
|
+
"apiKey": got["apiKey"], "clientType": "installed"}, None
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
|
+
# The Desktop-app client published for every lab, so a lab installed from
|
|
2621
|
+
# npm signs in with nothing to set up. A Desktop client signs in back to any
|
|
2622
|
+
# port on this machine with no redirect registered, and Google does not hold
|
|
2623
|
+
# its secret to be one: it is in every copy of the package by design. It
|
|
2624
|
+
# serves only a lab reached on this machine -- one reached at its own address
|
|
2625
|
+
# brings a Web application client of its own. Never in the repository: the
|
|
2626
|
+
# package's build writes it beside server.py from the Package workflow's
|
|
2627
|
+
# PUBLIC_GOOGLE_APP secret (docs/google-drive.md § The published app), and
|
|
2628
|
+
# a checkout or the image has none.
|
|
2629
|
+
GOOGLE_APP_FILE = HERE / "google-app.json"
|
|
2630
|
+
PUBLIC_GOOGLE_APP, PUBLIC_GOOGLE_PROBLEM = read_published_google(GOOGLE_APP_FILE)
|
|
2555
2631
|
# drive.file: only what the user picks in Google's Picker, and non-sensitive,
|
|
2556
2632
|
# so the app can be published without Google's verification (#127).
|
|
2557
2633
|
GOOGLE_SCOPE = "https://www.googleapis.com/auth/drive.file"
|
|
@@ -2565,6 +2641,94 @@ GOOGLE_CALLBACK = "/api/connections/google/callback"
|
|
|
2565
2641
|
GOOGLE_STATE_SECONDS = 600
|
|
2566
2642
|
|
|
2567
2643
|
|
|
2644
|
+
def google_app():
|
|
2645
|
+
"""The Google app this lab signs in with -- secret included, for the
|
|
2646
|
+
server's own use only -- and where it came from; None with none."""
|
|
2647
|
+
if GOOGLE_CLIENT_ID:
|
|
2648
|
+
return {"clientId": GOOGLE_CLIENT_ID, "clientSecret": GOOGLE_CLIENT_SECRET,
|
|
2649
|
+
"apiKey": GOOGLE_API_KEY, "clientType": GOOGLE_CLIENT_TYPE, "from": "env"}
|
|
2650
|
+
kept = GOOGLE.get() if GOOGLE is not None else None
|
|
2651
|
+
if kept:
|
|
2652
|
+
return {**kept, "from": "lab"}
|
|
2653
|
+
if PUBLIC_GOOGLE_APP:
|
|
2654
|
+
return {**PUBLIC_GOOGLE_APP, "from": "default"}
|
|
2655
|
+
return None
|
|
2656
|
+
|
|
2657
|
+
|
|
2658
|
+
def google_public(app):
|
|
2659
|
+
"""What the page is told of an app: everything but its secret."""
|
|
2660
|
+
if app is None:
|
|
2661
|
+
return None
|
|
2662
|
+
m = GOOGLE_CLIENT.fullmatch(app.get("clientId") or "")
|
|
2663
|
+
return {"clientId": app.get("clientId"), "clientType": app.get("clientType"),
|
|
2664
|
+
"apiKey": app.get("apiKey"), "appId": m.group(1) if m else None,
|
|
2665
|
+
"hasSecret": bool(app.get("clientSecret")), "from": app["from"]}
|
|
2666
|
+
|
|
2667
|
+
|
|
2668
|
+
def loopback_origin(origin) -> bool:
|
|
2669
|
+
"""Whether a page's origin is this machine: where a Desktop client may
|
|
2670
|
+
send the browser back to."""
|
|
2671
|
+
host = urllib.parse.urlsplit(origin).hostname or ""
|
|
2672
|
+
return host in ("localhost", "::1") or host.startswith("127.")
|
|
2673
|
+
|
|
2674
|
+
|
|
2675
|
+
class GoogleApp:
|
|
2676
|
+
"""The Google app entered in Setup: a client and its secret, from the
|
|
2677
|
+
client file Google hands out, and the Picker's key. Kept so a lab with no
|
|
2678
|
+
deployment around it needs no environment variable; the secret is
|
|
2679
|
+
written here and never read back out to the page."""
|
|
2680
|
+
|
|
2681
|
+
KEYS = ("google.clientId", "google.clientSecret", "google.apiKey", "google.clientType")
|
|
2682
|
+
|
|
2683
|
+
def __init__(self, store: Store):
|
|
2684
|
+
self.store = store
|
|
2685
|
+
with store.lock, closing(sqlite3.connect(store.path)) as db, db:
|
|
2686
|
+
db.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
|
2687
|
+
|
|
2688
|
+
def _read(self, db):
|
|
2689
|
+
rows = dict(db.execute("SELECT key, value FROM settings WHERE key IN (?, ?, ?, ?)", self.KEYS))
|
|
2690
|
+
return dict(zip(("clientId", "clientSecret", "apiKey", "clientType"),
|
|
2691
|
+
(rows.get(k) or None for k in self.KEYS)))
|
|
2692
|
+
|
|
2693
|
+
def get(self):
|
|
2694
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2695
|
+
got = self._read(db)
|
|
2696
|
+
return {**got, "clientType": got["clientType"] or "installed"} if got["clientId"] else None
|
|
2697
|
+
|
|
2698
|
+
def set(self, payload):
|
|
2699
|
+
"""Keeps what is sent, or with no client id forgets it all: (kept,
|
|
2700
|
+
error, whether the client changed). A secret not sent is kept while
|
|
2701
|
+
the client is the same one, and forgotten when it is not: a secret
|
|
2702
|
+
belongs to its client."""
|
|
2703
|
+
if not isinstance(payload, dict):
|
|
2704
|
+
return None, (400, "a Google app is { clientId, clientSecret, apiKey, clientType }"), False
|
|
2705
|
+
client = payload.get("clientId")
|
|
2706
|
+
secret, key = payload.get("clientSecret"), payload.get("apiKey")
|
|
2707
|
+
ctype = payload.get("clientType") or "installed"
|
|
2708
|
+
if not isinstance(client, str) or not all(v is None or isinstance(v, str) for v in (secret, key)):
|
|
2709
|
+
return None, (400, "a Google app is { clientId, clientSecret, apiKey, clientType }"), False
|
|
2710
|
+
client, key = client.strip(), (key or "").strip()
|
|
2711
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2712
|
+
had = self._read(db)
|
|
2713
|
+
changed = (had["clientId"] or "") != client
|
|
2714
|
+
if not client:
|
|
2715
|
+
db.execute("DELETE FROM settings WHERE key IN (?, ?, ?, ?)", self.KEYS)
|
|
2716
|
+
return None, None, changed
|
|
2717
|
+
if not GOOGLE_CLIENT.fullmatch(client):
|
|
2718
|
+
return None, (400, "a client ID ends .apps.googleusercontent.com"), False
|
|
2719
|
+
if key and not GOOGLE_KEY.fullmatch(key):
|
|
2720
|
+
return None, (400, "that is not an API key"), False
|
|
2721
|
+
if ctype not in GOOGLE_CLIENT_TYPES:
|
|
2722
|
+
return None, (400, "a client is a Desktop app or a Web application"), False
|
|
2723
|
+
if secret is None:
|
|
2724
|
+
secret = None if changed else had["clientSecret"]
|
|
2725
|
+
secret = (secret or "").strip()
|
|
2726
|
+
db.execute("DELETE FROM settings WHERE key IN (?, ?, ?, ?)", self.KEYS)
|
|
2727
|
+
db.executemany("INSERT INTO settings VALUES (?, ?)",
|
|
2728
|
+
[(k, v) for k, v in zip(self.KEYS, (client, secret, key, ctype)) if v])
|
|
2729
|
+
return self.get(), None, changed
|
|
2730
|
+
|
|
2731
|
+
|
|
2568
2732
|
class Connections:
|
|
2569
2733
|
"""The lab's grants, held server-side; the page sees their state only."""
|
|
2570
2734
|
|
|
@@ -2584,7 +2748,8 @@ class Connections:
|
|
|
2584
2748
|
|
|
2585
2749
|
@staticmethod
|
|
2586
2750
|
def configured() -> bool:
|
|
2587
|
-
|
|
2751
|
+
app = google_app()
|
|
2752
|
+
return bool(app and app.get("clientId") and app.get("clientSecret"))
|
|
2588
2753
|
|
|
2589
2754
|
def _row(self):
|
|
2590
2755
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
@@ -2598,15 +2763,21 @@ class Connections:
|
|
|
2598
2763
|
|
|
2599
2764
|
def start(self, origin: str):
|
|
2600
2765
|
"""The consent address the page sends the browser to."""
|
|
2766
|
+
app = google_app()
|
|
2601
2767
|
if not self.configured():
|
|
2602
2768
|
return None, (409, "Google is not configured for this lab")
|
|
2769
|
+
if app["clientType"] == "installed" and not loopback_origin(origin):
|
|
2770
|
+
return None, (409, "a Desktop app client signs in only on this machine: "
|
|
2771
|
+
"give this lab a Web application client in Configure…")
|
|
2603
2772
|
state = secrets.token_urlsafe(24)
|
|
2604
2773
|
now = time.time()
|
|
2605
2774
|
with self.lock:
|
|
2606
2775
|
self.states = {s: v for s, v in self.states.items() if v[0] > now}
|
|
2607
|
-
|
|
2776
|
+
# The app is kept with the state: the code Google sends back is
|
|
2777
|
+
# exchanged with the client that asked for it, whatever changes.
|
|
2778
|
+
self.states[state] = (now + GOOGLE_STATE_SECONDS, origin, app)
|
|
2608
2779
|
return GOOGLE_CONSENT_URL + "?" + urllib.parse.urlencode({
|
|
2609
|
-
"client_id":
|
|
2780
|
+
"client_id": app["clientId"], "redirect_uri": origin + GOOGLE_CALLBACK,
|
|
2610
2781
|
"response_type": "code", "scope": GOOGLE_SCOPE, "state": state,
|
|
2611
2782
|
"access_type": "offline", "prompt": "consent", "include_granted_scopes": "true",
|
|
2612
2783
|
}), None
|
|
@@ -2626,7 +2797,7 @@ class Connections:
|
|
|
2626
2797
|
which never include the code or a token."""
|
|
2627
2798
|
state = (query.get("state") or [""])[0]
|
|
2628
2799
|
with self.lock:
|
|
2629
|
-
lapses, origin = self.states.pop(state, (0, ""))
|
|
2800
|
+
lapses, origin, app = self.states.pop(state, (0, "", None))
|
|
2630
2801
|
if lapses <= time.time():
|
|
2631
2802
|
return "that sign-in had lapsed or was not this lab's; sign in again"
|
|
2632
2803
|
if query.get("error"):
|
|
@@ -2636,7 +2807,7 @@ class Connections:
|
|
|
2636
2807
|
return "Google sent no code back"
|
|
2637
2808
|
try:
|
|
2638
2809
|
got = self._call(GOOGLE_TOKEN_URL, {
|
|
2639
|
-
"code": code, "client_id":
|
|
2810
|
+
"code": code, "client_id": app["clientId"], "client_secret": app["clientSecret"],
|
|
2640
2811
|
"redirect_uri": origin + GOOGLE_CALLBACK, "grant_type": "authorization_code"})
|
|
2641
2812
|
except (OSError, ValueError) as e:
|
|
2642
2813
|
return f"the code could not be exchanged ({type(e).__name__})"
|
|
@@ -2654,6 +2825,12 @@ class Connections:
|
|
|
2654
2825
|
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
|
|
2655
2826
|
return None
|
|
2656
2827
|
|
|
2828
|
+
def forget(self):
|
|
2829
|
+
"""Drops the grant without a word to Google: the client it was made
|
|
2830
|
+
with is no longer this lab's, so the grant is no use to it."""
|
|
2831
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2832
|
+
db.execute("DELETE FROM connections WHERE id = 'google'")
|
|
2833
|
+
|
|
2657
2834
|
def sign_out(self):
|
|
2658
2835
|
"""Forgets the grant, and asks Google to revoke it; a revoke that
|
|
2659
2836
|
fails still forgets it here, which is what signing out means."""
|
|
@@ -2668,6 +2845,43 @@ class Connections:
|
|
|
2668
2845
|
self.problem = None
|
|
2669
2846
|
|
|
2670
2847
|
|
|
2848
|
+
class MicrosoftApp:
|
|
2849
|
+
"""The Microsoft app entered in Setup: a client ID and a tenant, neither
|
|
2850
|
+
secret, kept so a lab installed rather than deployed needs no environment
|
|
2851
|
+
variable to read a flow."""
|
|
2852
|
+
|
|
2853
|
+
KEYS = ("m365.clientId", "m365.tenant")
|
|
2854
|
+
|
|
2855
|
+
def __init__(self, store: Store):
|
|
2856
|
+
self.store = store
|
|
2857
|
+
with store.lock, closing(sqlite3.connect(store.path)) as db, db:
|
|
2858
|
+
db.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
|
2859
|
+
|
|
2860
|
+
def get(self):
|
|
2861
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2862
|
+
rows = dict(db.execute("SELECT key, value FROM settings WHERE key IN (?, ?)", self.KEYS))
|
|
2863
|
+
client = rows.get(self.KEYS[0])
|
|
2864
|
+
return {"clientId": client, "tenant": rows.get(self.KEYS[1]) or "organizations"} if client else None
|
|
2865
|
+
|
|
2866
|
+
def set(self, client_id, tenant):
|
|
2867
|
+
"""Keeps them, or with no client ID forgets them: (kept, error)."""
|
|
2868
|
+
if not isinstance(client_id, str) or not isinstance(tenant, str):
|
|
2869
|
+
return None, (400, "a Microsoft app is { clientId, tenant }")
|
|
2870
|
+
client_id, tenant = client_id.strip(), tenant.strip() or "organizations"
|
|
2871
|
+
if tenant.lower() == "organizations":
|
|
2872
|
+
tenant = "organizations"
|
|
2873
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2874
|
+
if not client_id:
|
|
2875
|
+
db.execute("DELETE FROM settings WHERE key IN (?, ?)", self.KEYS)
|
|
2876
|
+
return None, None
|
|
2877
|
+
bad = microsoft_problem(client_id, tenant)
|
|
2878
|
+
if bad:
|
|
2879
|
+
return None, (400, bad)
|
|
2880
|
+
db.executemany("INSERT INTO settings VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
2881
|
+
zip(self.KEYS, (client_id, tenant)))
|
|
2882
|
+
return self.get(), None
|
|
2883
|
+
|
|
2884
|
+
|
|
2671
2885
|
class Plugins:
|
|
2672
2886
|
"""The installed plugins: their files on disk, their manifests in the store."""
|
|
2673
2887
|
|
|
@@ -3517,17 +3731,17 @@ def worker_destinations(run: dict):
|
|
|
3517
3731
|
base = api_base(str(conn.get("url") or "")) or api_base(OLLAMA)
|
|
3518
3732
|
why = allowed(base, "")
|
|
3519
3733
|
if why:
|
|
3520
|
-
return None, f"
|
|
3734
|
+
return None, f"Target profile {name}: {why}"
|
|
3521
3735
|
profile = next((p for p in stored if isinstance(p, dict) and p.get("id") == pid), None)
|
|
3522
3736
|
if profile is None:
|
|
3523
|
-
return None, f"
|
|
3737
|
+
return None, f"Target profile {name} not found"
|
|
3524
3738
|
key = str(profile.get("key") or "").strip()
|
|
3525
3739
|
if key:
|
|
3526
3740
|
if not header_safe(key):
|
|
3527
|
-
return None, f"
|
|
3741
|
+
return None, f"Target profile {name} has a key that cannot go in a header"
|
|
3528
3742
|
why = allowed(base, key)
|
|
3529
3743
|
if why:
|
|
3530
|
-
return None, f"
|
|
3744
|
+
return None, f"Target profile {name}: {why}"
|
|
3531
3745
|
env[key_var(pid)] = key
|
|
3532
3746
|
return env, None
|
|
3533
3747
|
|
|
@@ -3546,12 +3760,17 @@ def worker_destinations(run: dict):
|
|
|
3546
3760
|
# 7: chains are jobs: the field is `jobs` and each job's type is "job".
|
|
3547
3761
|
# 8: a job is its steps; the content is job 1's Attach Content step.
|
|
3548
3762
|
# 9: a test is Metrics; a Single Test or a Graded set is read converted.
|
|
3549
|
-
|
|
3763
|
+
# 10: a job's steps are its stages, and each scenario is a target whose own
|
|
3764
|
+
# step in each job is what it sends there (docs/pipeline-model.md §16).
|
|
3765
|
+
PIPELINE_VERSION = 10
|
|
3550
3766
|
# What a stored run may be: the current version, and the ones evals-core.ts's
|
|
3551
|
-
# upgradePipeline reads. A new submission is
|
|
3552
|
-
READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9)
|
|
3553
|
-
|
|
3554
|
-
|
|
3767
|
+
# upgradePipeline reads. A new submission is upgraded to the current one.
|
|
3768
|
+
READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
|
|
3769
|
+
TARGET_CAP = 4
|
|
3770
|
+
# Target steps whose words the Prompt library does not record as a use: they
|
|
3771
|
+
# ask no model (evals-core.ts's STEP_TYPES.echo).
|
|
3772
|
+
UNRECORDED_STEPS = {"echo"}
|
|
3773
|
+
RUN_FIELDS = ("version", "id", "name", "jobs", "targets", "tests", "profiles", "comment", "plugins")
|
|
3555
3774
|
|
|
3556
3775
|
|
|
3557
3776
|
def content_of(doc):
|
|
@@ -3751,16 +3970,20 @@ def upgrade_run(run):
|
|
|
3751
3970
|
return up, None
|
|
3752
3971
|
|
|
3753
3972
|
|
|
3754
|
-
def
|
|
3755
|
-
"""
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
if not isinstance(
|
|
3760
|
-
return
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3973
|
+
def cells_of(run):
|
|
3974
|
+
"""Every target's step in every job of a run, as (i, target, k, step):
|
|
3975
|
+
version 10's targets and their steps, or an earlier version's scenarios
|
|
3976
|
+
and their cells -- what the Prompt library records a use from, whichever
|
|
3977
|
+
version a stored run is."""
|
|
3978
|
+
if not isinstance(run, dict):
|
|
3979
|
+
return
|
|
3980
|
+
targets = run.get("targets") if isinstance(run.get("targets"), list) else run.get("scenarios")
|
|
3981
|
+
for i, t in enumerate(targets if isinstance(targets, list) else []):
|
|
3982
|
+
if not isinstance(t, dict):
|
|
3983
|
+
continue
|
|
3984
|
+
steps = t.get("steps") if isinstance(t.get("steps"), list) else t.get("stages")
|
|
3985
|
+
for k, step in enumerate(steps if isinstance(steps, list) else []):
|
|
3986
|
+
yield i, t, k, step
|
|
3764
3987
|
|
|
3765
3988
|
|
|
3766
3989
|
def run_problems(run):
|
|
@@ -3805,36 +4028,41 @@ def run_problems(run):
|
|
|
3805
4028
|
bad.append(f"{at} has to be an object")
|
|
3806
4029
|
continue
|
|
3807
4030
|
connection_problems(pid, conn, at, bad)
|
|
3808
|
-
|
|
3809
|
-
if not isinstance(
|
|
3810
|
-
return bad + [f"a run needs between one and {
|
|
3811
|
-
ids = [
|
|
3812
|
-
for i,
|
|
3813
|
-
at = f"
|
|
3814
|
-
if not isinstance(
|
|
4031
|
+
targets = run.get("targets")
|
|
4032
|
+
if not isinstance(targets, list) or not 1 <= len(targets) <= TARGET_CAP:
|
|
4033
|
+
return bad + [f"a run needs between one and {TARGET_CAP} targets"]
|
|
4034
|
+
ids = [t.get("id") for t in targets if isinstance(t, dict)]
|
|
4035
|
+
for i, t in enumerate(targets):
|
|
4036
|
+
at = f"target {i + 1}"
|
|
4037
|
+
if not isinstance(t, dict):
|
|
3815
4038
|
bad.append(f"{at} has to be an object")
|
|
3816
4039
|
continue
|
|
3817
4040
|
# The id the Prompt library records a use under (version 4).
|
|
3818
|
-
if not isinstance(
|
|
4041
|
+
if not isinstance(t.get("id"), str) or not t["id"].strip():
|
|
3819
4042
|
bad.append(f"{at} has no id")
|
|
3820
|
-
elif ids.count(
|
|
3821
|
-
bad.append(f"{at} has the id of another
|
|
3822
|
-
|
|
3823
|
-
if not isinstance(
|
|
3824
|
-
bad.append(f"{at} has to have one
|
|
4043
|
+
elif ids.count(t["id"]) > 1:
|
|
4044
|
+
bad.append(f"{at} has the id of another target")
|
|
4045
|
+
steps = t.get("steps")
|
|
4046
|
+
if not isinstance(steps, list) or len(steps) != len(jobs):
|
|
4047
|
+
bad.append(f"{at} has to have one step per job")
|
|
3825
4048
|
continue
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
if
|
|
3830
|
-
|
|
4049
|
+
# A target with no profile is one whose steps ask none (Echo) or each
|
|
4050
|
+
# name their own; which steps need one is the core's to judge.
|
|
4051
|
+
refs = ([t["profile"]] if t.get("profile") is not None else []) + [
|
|
4052
|
+
st.get("profile") for st in steps if isinstance(st, dict) and st.get("profile") is not None]
|
|
4053
|
+
# Whether the words may be blank -- Echo answering from the item --
|
|
4054
|
+
# is the core's to judge, and the worker refuses the run in its words.
|
|
4055
|
+
for k, step in enumerate(steps):
|
|
4056
|
+
if not isinstance(step, dict) or not isinstance(step.get("type"), str):
|
|
4057
|
+
bad.append(f"{at}, job {k + 1} has to name what it sends")
|
|
4058
|
+
elif not isinstance(step.get("prompt"), str):
|
|
3831
4059
|
bad.append(f"{at}, job {k + 1} has no prompt")
|
|
3832
|
-
elif
|
|
3833
|
-
isinstance(
|
|
4060
|
+
elif step.get("from") is not None and not (
|
|
4061
|
+
isinstance(step["from"], dict) and isinstance(step["from"].get("id"), str)):
|
|
3834
4062
|
bad.append(f"{at}, job {k + 1} names the prompt it was picked from without an id")
|
|
3835
4063
|
for ref in refs:
|
|
3836
4064
|
if not isinstance(ref, dict) or ref.get("id") not in table:
|
|
3837
|
-
bad.append(f"{at} names a
|
|
4065
|
+
bad.append(f"{at} names a Target profile the run does not carry")
|
|
3838
4066
|
content = content_of(run)
|
|
3839
4067
|
kind = content.get("type") if isinstance(content, dict) else None
|
|
3840
4068
|
if kind == "source":
|
|
@@ -3873,10 +4101,12 @@ if DATA_DIR:
|
|
|
3873
4101
|
PACKS = Packs(STORE)
|
|
3874
4102
|
PLUGINS = Plugins(STORE)
|
|
3875
4103
|
CONNECTIONS = Connections(STORE)
|
|
4104
|
+
MICROSOFT = MicrosoftApp(STORE)
|
|
4105
|
+
GOOGLE = GoogleApp(STORE)
|
|
3876
4106
|
QUEUE = Queue(STORE)
|
|
3877
4107
|
QUEUE.prompts = PROMPTS
|
|
3878
4108
|
else:
|
|
3879
|
-
STORE = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = QUEUE = None
|
|
4109
|
+
STORE = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = MICROSOFT = GOOGLE = QUEUE = None
|
|
3880
4110
|
|
|
3881
4111
|
|
|
3882
4112
|
class NoRedirects(urllib.request.HTTPRedirectHandler):
|
|
@@ -4030,9 +4260,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
4030
4260
|
# The lab's own limits, for the page to disable rather than
|
|
4031
4261
|
# hard-code: how many scenarios a run may have.
|
|
4032
4262
|
# And the Microsoft app a flow is read with, when there is one.
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4263
|
+
# The app may be entered in Setup unless the deployment names
|
|
4264
|
+
# its own, and only in a lab with a store to keep it in.
|
|
4265
|
+
return self._json(200, {"targetCap": TARGET_CAP, "microsoft": microsoft_config(),
|
|
4266
|
+
"microsoftEditable": MICROSOFT is not None and not M365_CLIENT_ID})
|
|
4036
4267
|
if path == "/api/state":
|
|
4037
4268
|
if STORE is None:
|
|
4038
4269
|
return self._send(404, b"not found", "text/plain")
|
|
@@ -4087,6 +4318,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
4087
4318
|
if CONNECTIONS is None:
|
|
4088
4319
|
return self._send(404, b"not found", "text/plain")
|
|
4089
4320
|
return self._json(200, {"connections": CONNECTIONS.list()})
|
|
4321
|
+
if path == "/api/google":
|
|
4322
|
+
if GOOGLE is None:
|
|
4323
|
+
return self._send(404, b"not found", "text/plain")
|
|
4324
|
+
return self._json(200, {"google": google_public(google_app()), "editable": not GOOGLE_CLIENT_ID})
|
|
4090
4325
|
if path == GOOGLE_CALLBACK:
|
|
4091
4326
|
return self._google_callback()
|
|
4092
4327
|
if path.startswith("/plugins/"):
|
|
@@ -4307,6 +4542,26 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
4307
4542
|
parts = self.path.split("?", 1)[0].split("/")
|
|
4308
4543
|
if len(parts) == 4 and parts[:3] == ["", "api", "prompts"] and PROMPTS is not None:
|
|
4309
4544
|
return self._prompts_put(parts[3])
|
|
4545
|
+
if parts == ["", "api", "google"] and GOOGLE is not None:
|
|
4546
|
+
if GOOGLE_CLIENT_ID:
|
|
4547
|
+
return self._json(409, {"error": "this lab's Google app is its deployment's (GOOGLE_CLIENT_ID)"})
|
|
4548
|
+
_, err, changed = GOOGLE.set(self._payload())
|
|
4549
|
+
if err:
|
|
4550
|
+
return self._json(err[0], {"error": err[1]})
|
|
4551
|
+
# A grant is its client's: one made with another client cannot be
|
|
4552
|
+
# refreshed with this one, so it goes rather than failing later.
|
|
4553
|
+
if changed and CONNECTIONS is not None:
|
|
4554
|
+
CONNECTIONS.forget()
|
|
4555
|
+
return self._json(200, {"google": google_public(google_app()), "editable": True})
|
|
4556
|
+
if parts == ["", "api", "microsoft"] and MICROSOFT is not None:
|
|
4557
|
+
if M365_CLIENT_ID:
|
|
4558
|
+
return self._json(409, {"error": "this lab's Microsoft app is its deployment's (M365_CLIENT_ID)"})
|
|
4559
|
+
payload = self._payload() or {}
|
|
4560
|
+
_, err = MICROSOFT.set(payload.get("clientId"), payload.get("tenant", ""))
|
|
4561
|
+
if err:
|
|
4562
|
+
code, message = err
|
|
4563
|
+
return self._json(code, {"error": message})
|
|
4564
|
+
return self._json(200, {"microsoft": microsoft_config()})
|
|
4310
4565
|
if len(parts) == 5 and parts[:3] == ["", "api", "sources"] and parts[4] == "definition" \
|
|
4311
4566
|
and SOURCES is not None:
|
|
4312
4567
|
payload, err = self._dataset_payload(DEFINITION_CAP * 2, "a definition")
|
|
@@ -4857,7 +5112,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
4857
5112
|
# made in a lab without that sign-in.
|
|
4858
5113
|
sign_in = (SOURCE_TYPES.get(kind) or {}).get("signIn")
|
|
4859
5114
|
if sign_in and not SIGN_INS.get(sign_in, lambda: False)():
|
|
4860
|
-
return self._json(400, {"error": "this lab has no Microsoft app to sign in with (
|
|
5115
|
+
return self._json(400, {"error": "this lab has no Microsoft app to sign in with (Setup › Connections)"
|
|
4861
5116
|
if sign_in == "microsoft" else f"this lab has no {sign_in} sign-in"})
|
|
4862
5117
|
source, err = SOURCES.create(payload.get("name"), kind, payload.get("config"))
|
|
4863
5118
|
if err:
|
|
@@ -4982,6 +5237,10 @@ def main():
|
|
|
4982
5237
|
if not (DEMO / "manifest.json").is_file():
|
|
4983
5238
|
raise SystemExit(f"no {DEMO / 'manifest.json'} -- the demo pack sits beside server.py, "
|
|
4984
5239
|
"and the image copies it there")
|
|
5240
|
+
# A published app the build wrote and that cannot be read is a broken
|
|
5241
|
+
# package, not one without the app: said at once, not at Sign in.
|
|
5242
|
+
if PUBLIC_GOOGLE_PROBLEM:
|
|
5243
|
+
raise SystemExit(PUBLIC_GOOGLE_PROBLEM)
|
|
4985
5244
|
print(f"prompt-lab on {HOST}:{PORT} -> {OLLAMA} by default", flush=True)
|
|
4986
5245
|
print(f"samples: {SAMPLES}", flush=True)
|
|
4987
5246
|
print(f"page: {WEB_DIST}", flush=True)
|