evals-lab 0.11.0 → 0.12.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 +47 -0
- package/README.md +4 -4
- package/bin/evals-lab.js +9 -1
- package/bin/lab-file.js +97 -0
- package/bin/run.js +1 -1
- package/lab/VERSION +1 -1
- package/lab/demo/CREDITS.md +37 -3
- package/lab/demo/datasets/demo-1.json +2 -2
- package/lab/demo/datasets/demo-2.json +2 -2
- package/lab/demo/datasets/sentiment.json +383 -0
- package/lab/demo/datasets/weather-alerts.json +61 -0
- package/lab/demo/datasets/wordings.json +39 -0
- package/lab/demo/manifest.json +3 -3
- package/lab/demo/pipelines/demo-1.json +4 -4
- package/lab/demo/pipelines/demo-2.json +4 -4
- package/lab/demo/pipelines/sentiment.json +65 -0
- package/lab/demo/pipelines/weather-alerts.json +66 -0
- package/lab/demo/pipelines/wordings.json +104 -0
- package/lab/demo/sources/Demo 1/source.json +3 -0
- package/lab/demo/sources/Demo 2/source.json +3 -0
- package/lab/demo/sources/sentiment/bakery-croissant.txt +1 -0
- package/lab/demo/sources/sentiment/bus-timetable.txt +1 -0
- package/lab/demo/sources/sentiment/cafe-coffee.txt +1 -0
- package/lab/demo/sources/sentiment/concert-night.txt +1 -0
- package/lab/demo/sources/sentiment/film-sequel.txt +1 -0
- package/lab/demo/sources/sentiment/garden-tomatoes.txt +1 -0
- package/lab/demo/sources/sentiment/gym-contract.txt +1 -0
- package/lab/demo/sources/sentiment/headphones-review.txt +1 -0
- package/lab/demo/sources/sentiment/hotel-room.txt +1 -0
- package/lab/demo/sources/sentiment/library-hours.txt +1 -0
- package/lab/demo/sources/sentiment/meeting-minutes.txt +1 -0
- package/lab/demo/sources/sentiment/parcel-lost.txt +1 -0
- package/lab/demo/sources/sentiment/phone-update.txt +1 -0
- package/lab/demo/sources/sentiment/puppy-home.txt +1 -0
- package/lab/demo/sources/sentiment/recipe-note.txt +1 -0
- package/lab/demo/sources/sentiment/road-closure.txt +1 -0
- package/lab/demo/sources/sentiment/source.json +3 -0
- package/lab/demo/sources/sentiment/teacher-thanks.txt +1 -0
- package/lab/demo/sources/sentiment/train-delay.txt +1 -0
- package/lab/demo/sources/sentiment/volunteer-day.txt +1 -0
- package/lab/demo/sources/sentiment/weather-report.txt +1 -0
- package/lab/demo/sources/weather-alerts/https-api.weather.gov-alerts-types.txt +116 -0
- package/lab/demo/sources/weather-alerts/source.json +8 -0
- package/lab/evals-core.mjs +76 -16
- package/lab/server.py +484 -54
- package/lab/web/dist/assets/gallery-CFntjQFG.js +5 -0
- package/lab/web/dist/assets/main-BlTG2i20.css +1 -0
- package/lab/web/dist/assets/main-D6MNWqak.js +17 -0
- package/lab/web/dist/assets/tokens-DwzzdSIF.css +1 -0
- package/lab/web/dist/assets/tokens-bvuNFuYg.js +60 -0
- package/lab/web/dist/gallery.html +3 -3
- package/lab/web/dist/index.html +4 -4
- package/package.json +1 -1
- package/lab/web/dist/assets/gallery-DKy8QK3o.js +0 -5
- package/lab/web/dist/assets/main-BGmSu7aS.css +0 -1
- package/lab/web/dist/assets/main-DdGON-yl.js +0 -16
- package/lab/web/dist/assets/tokens-BmytrwxE.js +0 -60
- package/lab/web/dist/assets/tokens-DBN7I0ru.css +0 -1
package/lab/server.py
CHANGED
|
@@ -42,6 +42,7 @@ import urllib.error
|
|
|
42
42
|
import urllib.parse
|
|
43
43
|
import urllib.request
|
|
44
44
|
import zipfile
|
|
45
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
45
46
|
from contextlib import closing
|
|
46
47
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
47
48
|
from pathlib import Path
|
|
@@ -53,6 +54,9 @@ OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
|
|
|
53
54
|
# as a lab run on a workstation always has been; the deployed one sets it,
|
|
54
55
|
# because it keeps setups and API keys and can spend GPU time.
|
|
55
56
|
PASSWORD = os.environ.get("LAB_PASSWORD") or None
|
|
57
|
+
# A lab file imported on every start (docs/lab-export.md): a demo lab that
|
|
58
|
+
# keeps no data starts set up, its keys from $EVALSLAB_API_KEY_<SLUG>.
|
|
59
|
+
LAB_IMPORT = os.environ.get("LAB_IMPORT") or None
|
|
56
60
|
# Where what follows the user between browsers is kept (#463). Unset means
|
|
57
61
|
# nothing is, and the page stays with its browser as it always has.
|
|
58
62
|
DATA_DIR = os.environ.get("DATA_DIR") or None
|
|
@@ -170,6 +174,21 @@ def auth_headers(ctype: str, key: str) -> dict:
|
|
|
170
174
|
return {"Authorization": "Bearer " + key}
|
|
171
175
|
|
|
172
176
|
|
|
177
|
+
def http_key_header(how, key: str) -> dict:
|
|
178
|
+
"""The header an HTTP endpoint connection sends its key in: its Key header
|
|
179
|
+
setting, kept flat on the connection (evals-core.ts's KEY_HEADERS)."""
|
|
180
|
+
if not key:
|
|
181
|
+
return {}
|
|
182
|
+
how = str(how or "bearer")
|
|
183
|
+
if how == "x-api-key":
|
|
184
|
+
return {"x-api-key": key}
|
|
185
|
+
if how == "api-key":
|
|
186
|
+
return {"api-key": key}
|
|
187
|
+
if how == "none":
|
|
188
|
+
return {}
|
|
189
|
+
return {"Authorization": "Bearer " + key}
|
|
190
|
+
|
|
191
|
+
|
|
173
192
|
def connection_url(base: str, ctype: str) -> str:
|
|
174
193
|
path = CONNECTION_CHAT_PATHS.get(ctype, "/chat/completions")
|
|
175
194
|
if ctype in ("ollama", "ollama-cloud"):
|
|
@@ -348,7 +367,8 @@ SOURCE_PROVIDERS = {
|
|
|
348
367
|
# The API call provider (docs/dynamic-sources.md § 2): HTTP GET plus a verb,
|
|
349
368
|
# headers and a body, keyed from a connection -- the same relay fetch, one
|
|
350
369
|
# layer up. `produce` and `take` are attached below, beside http-get's.
|
|
351
|
-
|
|
370
|
+
# `keyed`: it sends a connection's key, so a pack may not carry one (read_pack).
|
|
371
|
+
"api": {"label": "API call", "noun": "call", "tier": "pull", "flag": None, "keyed": True,
|
|
352
372
|
"uploads": dict(_CALL_UPLOADS), "longConfig": {"body": 8000}},
|
|
353
373
|
# Supplied has no `produce`: the caller hands items in (the CLI's --items, or
|
|
354
374
|
# an Upload… by hand), so the server fetches and executes nothing -- it only
|
|
@@ -774,7 +794,11 @@ def connection_auth(pid):
|
|
|
774
794
|
connection's type says (an HTTP endpoint's Key header, else the type's own,
|
|
775
795
|
auth_headers) -- it never leaves the server or reaches a Call (#258).
|
|
776
796
|
Returns (headers, key, None); ({}, "", None) for no connection or no key;
|
|
777
|
-
or (None, None, (code, why)).
|
|
797
|
+
or (None, None, (code, why)). This is the third seam a connection resolves
|
|
798
|
+
to a key, beside worker_destinations and the relay's _unshared, so it
|
|
799
|
+
refuses a connection not shared with the bound workspace the same way --
|
|
800
|
+
the request thread's for a snapshot, the submitting request's for a live
|
|
801
|
+
run (docs/workspaces.md)."""
|
|
778
802
|
if not pid:
|
|
779
803
|
return {}, "", None
|
|
780
804
|
doc = (STORE.all().get("promptlab.connections") or {}) if STORE is not None else {}
|
|
@@ -782,19 +806,16 @@ def connection_auth(pid):
|
|
|
782
806
|
profile = next((p for p in profiles if isinstance(p, dict) and p.get("id") == pid), None)
|
|
783
807
|
if profile is None:
|
|
784
808
|
return None, None, (400, "that Source's auth connection is not one the lab has")
|
|
809
|
+
ws = workspace_of(STORE)
|
|
810
|
+
if not STORE.shared("profile", pid, ws):
|
|
811
|
+
return None, None, (403, not_shared_sentence([profile.get("name") or pid], STORE.workspace_name(ws)))
|
|
785
812
|
key = str(profile.get("key") or "").strip()
|
|
786
813
|
if not key:
|
|
787
814
|
return {}, "", None
|
|
788
815
|
if not header_safe(key):
|
|
789
816
|
return None, None, (400, "that connection's key cannot be sent in a header")
|
|
790
817
|
ctype = profile.get("type")
|
|
791
|
-
if ctype == "http"
|
|
792
|
-
options = profile.get("options") if isinstance(profile.get("options"), dict) else {}
|
|
793
|
-
how = str(options.get("auth") or "bearer")
|
|
794
|
-
head = ({"x-api-key": key} if how == "x-api-key" else {"api-key": key} if how == "api-key"
|
|
795
|
-
else {} if how == "none" else {"Authorization": "Bearer " + key})
|
|
796
|
-
else:
|
|
797
|
-
head = auth_headers(ctype, key)
|
|
818
|
+
head = http_key_header(profile.get("auth"), key) if ctype == "http" else auth_headers(ctype, key)
|
|
798
819
|
return head, key, None
|
|
799
820
|
|
|
800
821
|
|
|
@@ -809,6 +830,10 @@ def produce_api(config):
|
|
|
809
830
|
method = (cfg.get("method").upper() if isinstance(cfg.get("method"), str) and cfg.get("method").strip()
|
|
810
831
|
else "GET")
|
|
811
832
|
auth, key, why = connection_auth(cfg.get("connection"))
|
|
833
|
+
if why and why[0] == 403:
|
|
834
|
+
# Refused, not failed: no request went out, so the caller answers the
|
|
835
|
+
# refusal rather than keeping a failed Call.
|
|
836
|
+
return {"error": why[1], "refused": why, "request": {"url": url, "method": method}, "status": None}
|
|
812
837
|
if why:
|
|
813
838
|
return {"error": why[1], "request": {"url": url, "method": method}, "status": None}
|
|
814
839
|
headers = {**_header_pairs(cfg.get("reqHeaders")), **auth}
|
|
@@ -2488,6 +2513,8 @@ class Sources:
|
|
|
2488
2513
|
if produce is None:
|
|
2489
2514
|
return None, (400, "that provider cannot be produced from on the server")
|
|
2490
2515
|
result = produce(cfg)
|
|
2516
|
+
if result.get("refused"):
|
|
2517
|
+
return None, result["refused"]
|
|
2491
2518
|
request = result.get("request")
|
|
2492
2519
|
status = result.get("status")
|
|
2493
2520
|
content = result.get("content")
|
|
@@ -5024,32 +5051,47 @@ class Pipelines:
|
|
|
5024
5051
|
self.store._put(db, self.VERSIONS, target_ws, tv + 1, tbody, self._now())
|
|
5025
5052
|
|
|
5026
5053
|
|
|
5027
|
-
def
|
|
5028
|
-
"""
|
|
5029
|
-
for every image type, and a full-size render for
|
|
5030
|
-
cannot decode, whose zoom view has no original to
|
|
5031
|
-
|
|
5032
|
-
|
|
5054
|
+
def thumb_jobs(sid, name):
|
|
5055
|
+
"""The `magick` runs that make a stored file's thumbnails, each (argv,
|
|
5056
|
+
out): a tile-size JPEG for every image type, and a full-size render for
|
|
5057
|
+
the formats a browser cannot decode, whose zoom view has no original to
|
|
5058
|
+
show. The paths are looked up here, in the request's own thread, since a
|
|
5059
|
+
Source is found through the workspace bound to it. #33 #527"""
|
|
5033
5060
|
if CONVERT is None or Path(name).suffix.lower() not in IMAGE_TYPES:
|
|
5034
|
-
return
|
|
5061
|
+
return []
|
|
5062
|
+
jobs = []
|
|
5035
5063
|
for full in (False, True):
|
|
5036
5064
|
if full and Path(name).suffix.lower() not in THUMB_TYPES:
|
|
5037
5065
|
continue
|
|
5038
5066
|
path = SOURCES.file_path(sid, name)
|
|
5039
5067
|
out = SOURCES.thumb_path(sid, name, full)
|
|
5040
5068
|
if path is None or out is None:
|
|
5041
|
-
return
|
|
5069
|
+
return jobs
|
|
5042
5070
|
size = FULL_SIZE if full else TILE_SIZE
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5071
|
+
jobs.append(([CONVERT, str(path), "-auto-orient",
|
|
5072
|
+
"-thumbnail", f"{size}x{size}>",
|
|
5073
|
+
"-quality", str(FULL_QUALITY if full else TILE_QUALITY),
|
|
5074
|
+
str(out)], out))
|
|
5075
|
+
return jobs
|
|
5076
|
+
|
|
5077
|
+
|
|
5078
|
+
def run_thumb(job):
|
|
5079
|
+
"""One thumbnail. Non-fatal by design: a missing thumb is a blank tile,
|
|
5080
|
+
not a failed upload, and the startup sweep clears an orphan on a later
|
|
5081
|
+
start."""
|
|
5082
|
+
argv, out = job
|
|
5083
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
5084
|
+
try:
|
|
5085
|
+
subprocess.run(argv, timeout=30, stdout=subprocess.DEVNULL,
|
|
5086
|
+
stderr=subprocess.DEVNULL, check=True)
|
|
5087
|
+
except (subprocess.SubprocessError, OSError):
|
|
5088
|
+
out.unlink(missing_ok=True)
|
|
5089
|
+
|
|
5090
|
+
|
|
5091
|
+
def make_thumbs(sid, name):
|
|
5092
|
+
"""A stored file's thumbnails, made now (thumb_jobs)."""
|
|
5093
|
+
for job in thumb_jobs(sid, name):
|
|
5094
|
+
run_thumb(job)
|
|
5053
5095
|
|
|
5054
5096
|
|
|
5055
5097
|
# ---- Packs ------------------------------------------------------------------
|
|
@@ -5120,7 +5162,9 @@ def pack_requires_problem(manifest: dict, plugins: set) -> str:
|
|
|
5120
5162
|
def read_pack(data: bytes):
|
|
5121
5163
|
"""A pack's zip read and checked whole: (pack, None) or (None, why). The
|
|
5122
5164
|
pack is {manifest, datasets: [(key, name, body, raw)], pipelines:
|
|
5123
|
-
[(key, doc)], presets: [...], sources: {name: [(file, bytes)]}
|
|
5165
|
+
[(key, doc)], presets: [...], sources: {name: [(file, bytes)]}, source_meta:
|
|
5166
|
+
{name: {name, type, config}}} -- a Source's meta from its folder's
|
|
5167
|
+
`source.json`, or the folder's name as a File Library."""
|
|
5124
5168
|
try:
|
|
5125
5169
|
zf = zipfile.ZipFile(io.BytesIO(data))
|
|
5126
5170
|
except zipfile.BadZipFile:
|
|
@@ -5149,7 +5193,8 @@ def read_pack(data: bytes):
|
|
|
5149
5193
|
for k in ("name", "packVersion"):
|
|
5150
5194
|
if not isinstance(manifest.get(k), str) or not manifest[k].strip():
|
|
5151
5195
|
return None, f"a pack's manifest needs a {k}"
|
|
5152
|
-
pack = {"manifest": manifest, "datasets": [], "pipelines": [], "presets": [], "sources": {}}
|
|
5196
|
+
pack = {"manifest": manifest, "datasets": [], "pipelines": [], "presets": [], "sources": {}, "source_meta": {}}
|
|
5197
|
+
declared = {}
|
|
5153
5198
|
for name in sorted(names):
|
|
5154
5199
|
parts = name.split("/")
|
|
5155
5200
|
if name == "manifest.json":
|
|
@@ -5192,21 +5237,86 @@ def read_pack(data: bytes):
|
|
|
5192
5237
|
or not (isinstance(doc.get("jobs"), list) or isinstance(doc.get("chains"), list))):
|
|
5193
5238
|
return None, f"{name} is not a pipeline document the lab reads"
|
|
5194
5239
|
pack["pipelines"].append((parts[1][:-5], doc))
|
|
5240
|
+
elif len(parts) == 3 and parts[0] == "sources" and parts[2] == PACK_SOURCE_META:
|
|
5241
|
+
src = source_name(parts[1])
|
|
5242
|
+
meta, why = load(name)
|
|
5243
|
+
if why:
|
|
5244
|
+
return None, why
|
|
5245
|
+
if src is None or not isinstance(meta, dict):
|
|
5246
|
+
return None, f"{name} is not a Source's {PACK_SOURCE_META}"
|
|
5247
|
+
declared[src] = meta
|
|
5195
5248
|
elif len(parts) == 3 and parts[0] == "sources":
|
|
5196
5249
|
src, file = source_name(parts[1]), source_name(parts[2])
|
|
5197
|
-
if src is None or file is None
|
|
5250
|
+
if src is None or file is None:
|
|
5198
5251
|
return None, f"{name} is not a file a Source takes"
|
|
5199
5252
|
if names[name].file_size > MAX_FILE:
|
|
5200
5253
|
return None, f"{name} is over the cap for a file"
|
|
5201
5254
|
pack["sources"].setdefault(src, []).append((file, zf.read(name)))
|
|
5202
5255
|
else:
|
|
5203
5256
|
return None, f"{name} is not something a pack holds"
|
|
5204
|
-
for src
|
|
5257
|
+
for src in sorted(set(pack["sources"]) | set(declared)):
|
|
5258
|
+
meta, why = pack_source_meta(src, declared.get(src))
|
|
5259
|
+
if why:
|
|
5260
|
+
return None, why
|
|
5261
|
+
pack["source_meta"][src] = meta
|
|
5262
|
+
files = pack["sources"].setdefault(src, [])
|
|
5263
|
+
entry = source_entry(meta["type"], meta["config"]) or {}
|
|
5264
|
+
takes, take = entry.get("uploads") or {}, entry.get("take")
|
|
5265
|
+
for i, (file, blob) in enumerate(files):
|
|
5266
|
+
if Path(file).suffix.lower() not in takes:
|
|
5267
|
+
return None, f"sources/{src}/{file} is not a file a Source takes"
|
|
5268
|
+
# A shipped Call is checked as any other is: a token refuses the
|
|
5269
|
+
# pack, and a secret's shape is redacted before it is kept.
|
|
5270
|
+
if take is not None:
|
|
5271
|
+
blob, err = take(file, blob)
|
|
5272
|
+
if err:
|
|
5273
|
+
return None, f"sources/{src}/{file}: {err[1]}"
|
|
5274
|
+
files[i] = (file, blob)
|
|
5205
5275
|
if sum(len(b) for _, b in files) > MAX_SOURCE:
|
|
5206
5276
|
return None, f"the Source {src} is over the cap for a Source"
|
|
5207
5277
|
return pack, None
|
|
5208
5278
|
|
|
5209
5279
|
|
|
5280
|
+
# A pack Source's own meta, beside its files: {name?, type?, config?}.
|
|
5281
|
+
PACK_SOURCE_META = "source.json"
|
|
5282
|
+
|
|
5283
|
+
|
|
5284
|
+
def pack_source_meta(folder, meta):
|
|
5285
|
+
"""A pack Source's {name, type, config} from its folder's source.json (or
|
|
5286
|
+
None: a File Library named after the folder), or (None, why). A pack may
|
|
5287
|
+
carry a File Library or a dynamic Source whose provider pulls with no key
|
|
5288
|
+
and is enrolled here -- the line a plugin's provider is held to
|
|
5289
|
+
(docs/packs.md); anything else refuses the pack whole."""
|
|
5290
|
+
meta = meta or {}
|
|
5291
|
+
at = f"sources/{folder}/{PACK_SOURCE_META}"
|
|
5292
|
+
unknown = set(meta) - {"name", "type", "config"}
|
|
5293
|
+
if unknown:
|
|
5294
|
+
return None, f"{at} holds {sorted(unknown)[0]}, which a pack Source does not"
|
|
5295
|
+
name, err = Sources._name(meta.get("name", folder))
|
|
5296
|
+
if err:
|
|
5297
|
+
return None, f"{at}: {err[1]}"
|
|
5298
|
+
kind = meta.get("type", DEFAULT_SOURCE_TYPE)
|
|
5299
|
+
config = meta.get("config")
|
|
5300
|
+
if kind == DEFAULT_SOURCE_TYPE:
|
|
5301
|
+
if config is not None:
|
|
5302
|
+
return None, f"{at}: a File Library takes no config"
|
|
5303
|
+
return {"name": name, "type": kind, "config": None}, None
|
|
5304
|
+
if not (SOURCE_TYPES.get(kind) or {}).get("providers"):
|
|
5305
|
+
return None, f"{at}: a pack carries a File Library or a dynamic Source, not {kind!r}"
|
|
5306
|
+
provider = config.get("provider") if isinstance(config, dict) else None
|
|
5307
|
+
entry = SOURCE_PROVIDERS.get(provider)
|
|
5308
|
+
if entry is None:
|
|
5309
|
+
return None, f"{at}: the lab has no provider {provider!r}"
|
|
5310
|
+
if entry.get("tier") != "pull" or entry.get("keyed") or config.get("connection"):
|
|
5311
|
+
return None, f"{at}: a pack's dynamic Source pulls with no key, and {entry.get('label', provider)} does not"
|
|
5312
|
+
if not provider_enrolled(entry):
|
|
5313
|
+
return None, f"{at}: this lab does not enrol {entry.get('label', provider)}"
|
|
5314
|
+
_, err = Sources._checked(kind, config)
|
|
5315
|
+
if err:
|
|
5316
|
+
return None, f"{at}: {err[1]}"
|
|
5317
|
+
return {"name": name, "type": kind, "config": config}, None
|
|
5318
|
+
|
|
5319
|
+
|
|
5210
5320
|
class Packs:
|
|
5211
5321
|
"""What each installed pack made, so it can be updated and removed."""
|
|
5212
5322
|
|
|
@@ -5341,22 +5451,41 @@ class Packs:
|
|
|
5341
5451
|
made = {}
|
|
5342
5452
|
# Sources first, then datasets: a pipeline names both.
|
|
5343
5453
|
src_ids = {}
|
|
5454
|
+
thumbs = []
|
|
5344
5455
|
for name, files in pack["sources"].items():
|
|
5456
|
+
meta = pack["source_meta"][name]
|
|
5345
5457
|
sid = owned.get(("source", name))
|
|
5346
|
-
|
|
5347
|
-
|
|
5458
|
+
have = SOURCES.get(sid) if sid else None
|
|
5459
|
+
# An update keeps the Source's name, so one renamed here, or
|
|
5460
|
+
# by a later pack, is not renamed back. Another kind is
|
|
5461
|
+
# another Source.
|
|
5462
|
+
if have and have.get("type") == meta["type"]:
|
|
5463
|
+
old = [f["name"] for f in have["files"]]
|
|
5348
5464
|
if old:
|
|
5349
5465
|
SOURCES.remove_files(sid, old)
|
|
5466
|
+
if meta["config"] is not None:
|
|
5467
|
+
SOURCES.update(sid, {"config": meta["config"]})
|
|
5350
5468
|
else:
|
|
5351
5469
|
taken = {s["name"] for s in SOURCES.list()}
|
|
5352
|
-
src, _ = SOURCES.create(unique_dataset_name(name, taken))
|
|
5470
|
+
src, _ = SOURCES.create(unique_dataset_name(meta["name"], taken), meta["type"], meta["config"])
|
|
5353
5471
|
sid = src["id"]
|
|
5354
5472
|
for file, blob in files:
|
|
5355
5473
|
stored, err = SOURCES.add_file(sid, file, blob)
|
|
5356
|
-
if
|
|
5357
|
-
|
|
5474
|
+
if err:
|
|
5475
|
+
continue
|
|
5476
|
+
thumbs += thumb_jobs(sid, stored)
|
|
5477
|
+
# A dynamic Source's files are Calls: the pack's are its
|
|
5478
|
+
# snapshot, fetched when the pack was made.
|
|
5479
|
+
if meta["type"] != DEFAULT_SOURCE_TYPE:
|
|
5480
|
+
cfg = meta["config"]
|
|
5481
|
+
request = {"url": cfg["url"], "method": cfg.get("method") or "GET"} if cfg.get("url") else None
|
|
5482
|
+
SOURCES._record_call(sid, stored, "snapshot", cfg.get("provider"), request, None, None, stored)
|
|
5358
5483
|
src_ids[name] = sid
|
|
5359
5484
|
made[("source", name)] = sid
|
|
5485
|
+
# The demo pack installs as the server starts, before it answers,
|
|
5486
|
+
# and one `magick` at a time was most of that wait.
|
|
5487
|
+
with ThreadPoolExecutor(max_workers=min(4, os.cpu_count() or 1)) as pool:
|
|
5488
|
+
list(pool.map(run_thumb, thumbs))
|
|
5360
5489
|
ds_ids = {}
|
|
5361
5490
|
cuts = [] # (kind, id, the document as it was), kept before it changes
|
|
5362
5491
|
for key, name, body, raw, preview in pack["datasets"]:
|
|
@@ -5413,7 +5542,8 @@ class Packs:
|
|
|
5413
5542
|
entry = next((w for w in body["list"] if w.get("id") == wid), None) if wid else None
|
|
5414
5543
|
if entry:
|
|
5415
5544
|
cuts.append(("pipeline", wid, entry.get("work")))
|
|
5416
|
-
|
|
5545
|
+
# Its name is the lab's now, as a dataset's is.
|
|
5546
|
+
entry["work"] = {**doc, "name": entry.get("name") or doc.get("name")}
|
|
5417
5547
|
else:
|
|
5418
5548
|
wid = "w" + secrets.token_hex(6)
|
|
5419
5549
|
name = unique_dataset_name(doc.get("name") or key, {w.get("name") for w in body["list"]})
|
|
@@ -7280,11 +7410,19 @@ def worker_destinations(run: dict):
|
|
|
7280
7410
|
if STORE is not None:
|
|
7281
7411
|
doc = STORE.all().get("promptlab.connections") or {}
|
|
7282
7412
|
stored = (doc.get("body") or {}).get("list") or []
|
|
7283
|
-
# One variable a connection, however many targets or profiles share it
|
|
7284
|
-
|
|
7413
|
+
# One variable a connection, however many targets or profiles share it --
|
|
7414
|
+
# and never two connections one variable, whichever path the run came by:
|
|
7415
|
+
# a stored snapshot is not checked again as a submit is (run_problems),
|
|
7416
|
+
# and the runner reads each connection's key from its slug's variable, so
|
|
7417
|
+
# the second would send the first one's key to its own address.
|
|
7418
|
+
env, spelt = {}, {}
|
|
7285
7419
|
for key, conn in table.items():
|
|
7286
7420
|
pid = answer_connection(key, conn)
|
|
7287
7421
|
name = conn.get("name") or pid
|
|
7422
|
+
var = key_var(pid, conn.get("slug") if isinstance(conn.get("slug"), str) else None)
|
|
7423
|
+
if var in spelt and spelt[var][0] != pid:
|
|
7424
|
+
return None, f"Connections {spelt[var][1]} and {name} both take their key from ${var}"
|
|
7425
|
+
spelt[var] = (pid, name)
|
|
7288
7426
|
if isinstance(conn, dict) and conn.get("type") in LOCAL_CONNECTIONS:
|
|
7289
7427
|
continue
|
|
7290
7428
|
base = api_base(str(conn.get("url") or "")) or api_base(OLLAMA)
|
|
@@ -7301,7 +7439,7 @@ def worker_destinations(run: dict):
|
|
|
7301
7439
|
why = allowed(base, key)
|
|
7302
7440
|
if why:
|
|
7303
7441
|
return None, f"Connection {name}: {why}"
|
|
7304
|
-
env[
|
|
7442
|
+
env[var] = key
|
|
7305
7443
|
return env, None
|
|
7306
7444
|
|
|
7307
7445
|
|
|
@@ -7333,10 +7471,12 @@ def worker_destinations(run: dict):
|
|
|
7333
7471
|
# 16: a target names an AI Connection and, optionally, a Connection Profile,
|
|
7334
7472
|
# where it named one Target profile; the run's table is keyed by the pair
|
|
7335
7473
|
# (answer_key), each entry naming the connection whose key it takes (#445).
|
|
7336
|
-
|
|
7474
|
+
# 17: an HTTP Request target has no `api` of its own; its job's flow step's
|
|
7475
|
+
# is the one its body is read by (#477).
|
|
7476
|
+
PIPELINE_VERSION = 17
|
|
7337
7477
|
# What a stored run may be: the current version, and the ones evals-core.ts's
|
|
7338
7478
|
# upgradePipeline reads. A new submission is upgraded to the current one.
|
|
7339
|
-
READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
|
|
7479
|
+
READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17)
|
|
7340
7480
|
TARGET_CAP = 4
|
|
7341
7481
|
# Target steps whose words the Prompt library does not record as a use: they
|
|
7342
7482
|
# ask no model (evals-core.ts's STEP_TYPES.echo).
|
|
@@ -7369,7 +7509,7 @@ CONNECTION_TYPES = {
|
|
|
7369
7509
|
"echo": (),
|
|
7370
7510
|
# An HTTP endpoint (evals-core.ts): an address and how its key travels; an
|
|
7371
7511
|
# HTTP Request step builds the whole request.
|
|
7372
|
-
"http": ("auth", "headers", "testPath"),
|
|
7512
|
+
"http": ("auth", "headers", "testPath", "requestFormat"),
|
|
7373
7513
|
"ollama": ("seed", "nPredict", "temperature", "repeatPenalty"),
|
|
7374
7514
|
"ollama-cloud": ("seed", "nPredict", "temperature", "repeatPenalty"),
|
|
7375
7515
|
"openai-compatible": ("temperature", "nPredict"),
|
|
@@ -7424,7 +7564,7 @@ def run_answer(table, key):
|
|
|
7424
7564
|
# other setting set is a Connection Profile's. Mirrored, and held to the
|
|
7425
7565
|
# core's splitProfile, by proxy-check.py.
|
|
7426
7566
|
AI_CONNECTION_FIELDS = ("id", "name", "slug", "type", "url", "key", "model", "profile")
|
|
7427
|
-
CONNECTION_SCOPED = {"http": ("auth", "headers", "testPath")}
|
|
7567
|
+
CONNECTION_SCOPED = {"http": ("auth", "headers", "testPath", "requestFormat")}
|
|
7428
7568
|
# evals-core.ts REPLY_TOKENS_BEFORE, and the setting of each type that sent it.
|
|
7429
7569
|
REPLY_TOKENS_BEFORE = "160"
|
|
7430
7570
|
REPLY_TOKENS_SETTING = {"openai-compatible": "nPredict", "llama.cpp": "nPredict", "anthropic": "maxTokens"}
|
|
@@ -7526,14 +7666,7 @@ def http_endpoint_test(payload, key):
|
|
|
7526
7666
|
name, value = part.split(":", 1)
|
|
7527
7667
|
if name.strip():
|
|
7528
7668
|
headers[name.strip()] = value.strip()
|
|
7529
|
-
|
|
7530
|
-
how = str(settings.get("auth") or "bearer")
|
|
7531
|
-
if how == "x-api-key":
|
|
7532
|
-
headers["x-api-key"] = key
|
|
7533
|
-
elif how == "api-key":
|
|
7534
|
-
headers["api-key"] = key
|
|
7535
|
-
elif how != "none":
|
|
7536
|
-
headers["Authorization"] = "Bearer " + key
|
|
7669
|
+
headers.update(http_key_header(settings.get("auth"), key))
|
|
7537
7670
|
if not all(header_safe(v) for v in headers.values()):
|
|
7538
7671
|
return "", {}, "A header of this profile has characters that cannot be sent."
|
|
7539
7672
|
return origin + (path if path.startswith("/") else "/" + path), headers, ""
|
|
@@ -8119,6 +8252,16 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8119
8252
|
return self._send(404, b"not found", "text/plain")
|
|
8120
8253
|
ctype = TYPES[".json"] if f.suffix in (".json", ".map") else TYPES[".js"]
|
|
8121
8254
|
return self._send(200, f.read_bytes(), ctype, (("Cache-Control", "no-store"),))
|
|
8255
|
+
if path == "/api/lab-export":
|
|
8256
|
+
# The lab's settings as a file (docs/lab-export.md): every
|
|
8257
|
+
# workspace's entry, or ?workspace=<slug>'s alone. No key.
|
|
8258
|
+
if STORE is None or WORKSPACES is None:
|
|
8259
|
+
return self._send(404, b"not found", "text/plain")
|
|
8260
|
+
only = (urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query).get("workspace") or [None])[0]
|
|
8261
|
+
out = lab_export(only)
|
|
8262
|
+
if out is None:
|
|
8263
|
+
return self._json(404, {"error": f"no workspace at /w/{only}"})
|
|
8264
|
+
return self._json(200, out)
|
|
8122
8265
|
if path == "/api/packs":
|
|
8123
8266
|
if PACKS is None:
|
|
8124
8267
|
return self._send(404, b"not found", "text/plain")
|
|
@@ -8471,6 +8614,22 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8471
8614
|
return self._filter_sets_post(path)
|
|
8472
8615
|
if path == "/api/copy-move" or path.startswith("/api/copy-move/"):
|
|
8473
8616
|
return self._copy_move_post(path)
|
|
8617
|
+
if path == "/api/lab-import":
|
|
8618
|
+
# A lab file into this lab (docs/lab-export.md), adding what it
|
|
8619
|
+
# lacks. `keys` is what `evals-lab import` read from its own
|
|
8620
|
+
# $EVALSLAB_API_KEY_<SLUG> variables; the page sends none.
|
|
8621
|
+
if STORE is None or WORKSPACES is None:
|
|
8622
|
+
return self._send(404, b"not found", "text/plain")
|
|
8623
|
+
payload = self._payload()
|
|
8624
|
+
if payload is None:
|
|
8625
|
+
return self._json(400, {"error": "an import is {lab, keys?} as JSON"})
|
|
8626
|
+
keys = payload.get("keys") if isinstance(payload.get("keys"), dict) else {}
|
|
8627
|
+
keys = {k: v for k, v in keys.items()
|
|
8628
|
+
if isinstance(k, str) and k.startswith("EVALSLAB_API_KEY_") and isinstance(v, str)}
|
|
8629
|
+
said, err = lab_import(payload.get("lab"), keys)
|
|
8630
|
+
if err:
|
|
8631
|
+
return self._json(err[0], {"error": err[1]})
|
|
8632
|
+
return self._json(200, {"said": said})
|
|
8474
8633
|
if path == "/api/packs" or path.startswith("/api/packs/"):
|
|
8475
8634
|
return self._packs_post(path)
|
|
8476
8635
|
if path == "/api/workspaces" or path.startswith("/api/workspaces/"):
|
|
@@ -8629,8 +8788,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8629
8788
|
else None. Only a held key (KEY_HELD<id>) names a stored profile; a key
|
|
8630
8789
|
typed into the Setup form is a connection not yet saved, so nothing
|
|
8631
8790
|
gates it. This is the relay/Test seam, beside worker_destinations' run
|
|
8632
|
-
seam
|
|
8633
|
-
places sharing is enforced. The caller writes the 403: a helper that
|
|
8791
|
+
seam and connection_auth's API Source seam -- the places a connection
|
|
8792
|
+
resolves to a key, so the places sharing is enforced. The caller writes the 403: a helper that
|
|
8634
8793
|
answers here would send the body and still fall through to the proxy."""
|
|
8635
8794
|
if STORE is None:
|
|
8636
8795
|
return None
|
|
@@ -8897,6 +9056,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8897
9056
|
if produce is None:
|
|
8898
9057
|
return None, None, (400, "that provider cannot be produced from on the server")
|
|
8899
9058
|
result = produce(cfg)
|
|
9059
|
+
if result.get("refused"):
|
|
9060
|
+
return None, None, result["refused"]
|
|
8900
9061
|
content = result.get("content")
|
|
8901
9062
|
if content is None:
|
|
8902
9063
|
return None, None, (502, "the live fetch produced nothing: " + (result.get("error") or "unknown"))
|
|
@@ -9514,6 +9675,252 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9514
9675
|
return self._json(502, {"error": f"{url}: {e.reason}"})
|
|
9515
9676
|
|
|
9516
9677
|
|
|
9678
|
+
# ---- Lab export and import (docs/lab-export.md) -----------------------------
|
|
9679
|
+
#
|
|
9680
|
+
# A lab's settings as one file: its AI Connections, Connection Profiles and
|
|
9681
|
+
# grader, and its workspaces, so a new lab can start as another one is set up
|
|
9682
|
+
# -- by Setup › Workspaces › Data, by `evals-lab export`/`import`, or on every
|
|
9683
|
+
# start with LAB_IMPORT (a demo lab that keeps no data). An import only adds:
|
|
9684
|
+
# what the lab already has, by a connection's or profile's id and a
|
|
9685
|
+
# workspace's address, is left as it is, so importing twice changes nothing
|
|
9686
|
+
# and a lab that keeps its data keeps its edits. The file never holds a key
|
|
9687
|
+
# (#258): each comes from $EVALSLAB_API_KEY_<SLUG>, as a CI run's does, and a
|
|
9688
|
+
# key that comes is the connection's from then on, a rotated one included.
|
|
9689
|
+
# A connection carries its share set (`shares`, workspace slugs and the
|
|
9690
|
+
# sentinels; absent is All) from version 2: version 1 carried none, so an
|
|
9691
|
+
# import reads its connections as All.
|
|
9692
|
+
|
|
9693
|
+
LAB_FORMAT = "evals-lab/lab"
|
|
9694
|
+
LAB_VERSIONS = (1, 2)
|
|
9695
|
+
LAB_FIELDS = ("format", "version", "exportedAt", "connections", "connectionProfiles", "grader", "workspaces")
|
|
9696
|
+
|
|
9697
|
+
|
|
9698
|
+
def lab_export(only=None) -> dict:
|
|
9699
|
+
"""The lab's settings as a file, every workspace's entry or the one at
|
|
9700
|
+
/w/[only]; None when no workspace is at that address. One workspace's file
|
|
9701
|
+
carries only what that workspace can use: the connections shared with it,
|
|
9702
|
+
the profiles over them (or over none) and the grader if it is one of them,
|
|
9703
|
+
each share set narrowed to that workspace, the one the file names, and
|
|
9704
|
+
*new kept where the connection has it. A connection shared only with
|
|
9705
|
+
workspaces in the trash is usable nowhere, and an empty share set would
|
|
9706
|
+
import as All, so it is left out, and out as the grader too."""
|
|
9707
|
+
body = (STORE.all().get("promptlab.connections") or {}).get("body") or {}
|
|
9708
|
+
profiles = (STORE.all().get("promptlab.connectionProfiles") or {}).get("body") or {}
|
|
9709
|
+
every = WORKSPACES.list()
|
|
9710
|
+
spaces = [w for w in every if not only or w["slug"] == only]
|
|
9711
|
+
if only and not spaces:
|
|
9712
|
+
return None
|
|
9713
|
+
slug_of = {w["id"]: w["slug"] for w in (spaces if only else every)}
|
|
9714
|
+
rows = STORE.shares()
|
|
9715
|
+
conns, nowhere = [], set()
|
|
9716
|
+
for c in body.get("list") or []:
|
|
9717
|
+
if not isinstance(c, dict) or (only and not STORE.shared("profile", str(c.get("id") or ""), spaces[0]["id"])):
|
|
9718
|
+
continue
|
|
9719
|
+
entry = {k: v for k, v in c.items() if k not in ("key", "shares")}
|
|
9720
|
+
held = rows.get(f"profile:{c.get('id')}")
|
|
9721
|
+
if held and SHARE_ALL not in held:
|
|
9722
|
+
entry["shares"] = [w if w == SHARE_NEW else slug_of[w] for w in held if w == SHARE_NEW or w in slug_of]
|
|
9723
|
+
if not entry["shares"]:
|
|
9724
|
+
nowhere.add(c.get("id"))
|
|
9725
|
+
continue
|
|
9726
|
+
conns.append(entry)
|
|
9727
|
+
kept = {c["id"] for c in conns}
|
|
9728
|
+
|
|
9729
|
+
def over_kept(p):
|
|
9730
|
+
ref = p.get("connection")
|
|
9731
|
+
return not ref or not only or (isinstance(ref, dict) and ref.get("id") in kept)
|
|
9732
|
+
out = {"format": LAB_FORMAT, "version": LAB_VERSIONS[-1],
|
|
9733
|
+
"exportedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
9734
|
+
"connections": conns,
|
|
9735
|
+
"connectionProfiles": [p for p in profiles.get("list") or [] if isinstance(p, dict) and over_kept(p)],
|
|
9736
|
+
"workspaces": [{"slug": w["slug"], "name": w["name"], **({"archived": True} if w["archived"] else {})}
|
|
9737
|
+
for w in spaces]}
|
|
9738
|
+
if (isinstance(body.get("grader"), str) and body["grader"] and body["grader"] not in nowhere
|
|
9739
|
+
and (not only or body["grader"] in kept)):
|
|
9740
|
+
out["grader"] = body["grader"]
|
|
9741
|
+
return out
|
|
9742
|
+
|
|
9743
|
+
|
|
9744
|
+
def _keyless(entry, what):
|
|
9745
|
+
"""A sentence when [entry] holds a key, which a lab file never does."""
|
|
9746
|
+
for k, v in entry.items():
|
|
9747
|
+
if LOOKS_LIKE_A_KEY.match(k) and v not in ("", None):
|
|
9748
|
+
return f"{what} holds a {k}; a lab file holds no key -- set ${key_var(entry['id'], entry.get('slug'))}"
|
|
9749
|
+
return None
|
|
9750
|
+
|
|
9751
|
+
|
|
9752
|
+
def lab_problems(doc) -> str:
|
|
9753
|
+
"""Why [doc] is not a lab file this lab imports, or None. Checked whole
|
|
9754
|
+
before anything is written, as a pack is."""
|
|
9755
|
+
if not isinstance(doc, dict) or doc.get("format") != LAB_FORMAT:
|
|
9756
|
+
return f"not a lab file: its format is not {LAB_FORMAT}"
|
|
9757
|
+
if doc.get("version") not in LAB_VERSIONS:
|
|
9758
|
+
return f"a lab file of version {doc.get('version')!r}; this lab reads {', '.join(map(str, LAB_VERSIONS))}"
|
|
9759
|
+
extra = sorted(set(doc) - set(LAB_FIELDS))
|
|
9760
|
+
if extra:
|
|
9761
|
+
return f"a lab file holds no {', '.join(extra)}"
|
|
9762
|
+
seen = set()
|
|
9763
|
+
for i, c in enumerate(doc.get("connections") or []):
|
|
9764
|
+
what = f"connection {i + 1}"
|
|
9765
|
+
if not isinstance(c, dict) or not isinstance(c.get("id"), str) or not PROFILE_ID.fullmatch(c["id"]):
|
|
9766
|
+
return f"{what} has no id of letters and digits"
|
|
9767
|
+
what = f"connection {c['id']}"
|
|
9768
|
+
if c["id"] in seen:
|
|
9769
|
+
return f"{what} is listed twice"
|
|
9770
|
+
seen.add(c["id"])
|
|
9771
|
+
if not isinstance(c.get("name"), str) or not c["name"].strip():
|
|
9772
|
+
return f"{what} has no name"
|
|
9773
|
+
if c.get("type") not in CONNECTION_TYPES:
|
|
9774
|
+
return f"{what} is of type {c.get('type')!r}, which this lab has no connection type for"
|
|
9775
|
+
bad = next((k for k, v in c.items() if not isinstance(v, str) and k != "shares"), None)
|
|
9776
|
+
if "shares" in c and doc["version"] < 2:
|
|
9777
|
+
bad = "shares"
|
|
9778
|
+
if bad:
|
|
9779
|
+
return f"{what}'s {bad} is not text"
|
|
9780
|
+
why = _keyless(c, what)
|
|
9781
|
+
if why:
|
|
9782
|
+
return why
|
|
9783
|
+
seen = set()
|
|
9784
|
+
for i, p in enumerate(doc.get("connectionProfiles") or []):
|
|
9785
|
+
what = f"connection profile {i + 1}"
|
|
9786
|
+
if not isinstance(p, dict) or not isinstance(p.get("id"), str) or not PROFILE_ID.fullmatch(p["id"]):
|
|
9787
|
+
return f"{what} has no id of letters and digits"
|
|
9788
|
+
what = f"connection profile {p['id']}"
|
|
9789
|
+
if p["id"] in seen:
|
|
9790
|
+
return f"{what} is listed twice"
|
|
9791
|
+
seen.add(p["id"])
|
|
9792
|
+
if not isinstance(p.get("name"), str) or not p["name"].strip():
|
|
9793
|
+
return f"{what} has no name"
|
|
9794
|
+
why = _keyless(p, what)
|
|
9795
|
+
if why:
|
|
9796
|
+
return why
|
|
9797
|
+
if "grader" in doc and not isinstance(doc["grader"], str):
|
|
9798
|
+
return "the grader is not a connection's id"
|
|
9799
|
+
seen = set()
|
|
9800
|
+
for i, w in enumerate(doc.get("workspaces") or []):
|
|
9801
|
+
what = f"workspace {i + 1}"
|
|
9802
|
+
if not isinstance(w, dict) or not isinstance(w.get("slug"), str) or not w["slug"]:
|
|
9803
|
+
return f"{what} has no address"
|
|
9804
|
+
if w["slug"] in seen:
|
|
9805
|
+
return f"workspace {w['slug']} is listed twice"
|
|
9806
|
+
seen.add(w["slug"])
|
|
9807
|
+
if not isinstance(w.get("name"), str) or not w["name"].strip():
|
|
9808
|
+
return f"workspace {w['slug']} has no name"
|
|
9809
|
+
if Workspaces._slugify(w["slug"]) != w["slug"]:
|
|
9810
|
+
return f"workspace {w['slug']}'s address is not one a lab makes: it would be {Workspaces._slugify(w['slug'])}"
|
|
9811
|
+
fields = {"slug", "name"} | ({"archived"} if doc["version"] >= 2 else set())
|
|
9812
|
+
if set(w) - fields:
|
|
9813
|
+
return f"workspace {w['slug']} holds no {', '.join(sorted(set(w) - fields))}"
|
|
9814
|
+
if "archived" in w and not isinstance(w["archived"], bool):
|
|
9815
|
+
return f"workspace {w['slug']}'s archived is not true or false"
|
|
9816
|
+
for k in ("connections", "connectionProfiles", "workspaces"):
|
|
9817
|
+
if k in doc and not isinstance(doc[k], list):
|
|
9818
|
+
return f"a lab file's {k} is a list"
|
|
9819
|
+
# Workspaces before shares: a share names the file's workspaces or the lab's.
|
|
9820
|
+
known = seen | ({w["slug"] for w in WORKSPACES.list()} if WORKSPACES is not None else set())
|
|
9821
|
+
for c in doc.get("connections") or []:
|
|
9822
|
+
if "shares" not in c:
|
|
9823
|
+
continue
|
|
9824
|
+
if not isinstance(c["shares"], list) or not all(isinstance(s, str) for s in c["shares"]):
|
|
9825
|
+
return f"connection {c['id']}'s shares is a list of workspace addresses"
|
|
9826
|
+
# No rows is All in the store, so an empty list would grant everywhere.
|
|
9827
|
+
if not c["shares"]:
|
|
9828
|
+
return (f"connection {c['id']}'s shares is empty; leave shares out for All, "
|
|
9829
|
+
"or name a workspace or *new")
|
|
9830
|
+
lost = [s for s in c["shares"] if s not in (SHARE_ALL, SHARE_NEW) and s not in known]
|
|
9831
|
+
if lost:
|
|
9832
|
+
return f"connection {c['id']} is shared with {', '.join(lost)}, which neither the file nor this lab has"
|
|
9833
|
+
return None
|
|
9834
|
+
|
|
9835
|
+
|
|
9836
|
+
def lab_import(doc, keys) -> tuple:
|
|
9837
|
+
"""[doc] into this lab, adding what it lacks; [keys] is where each
|
|
9838
|
+
connection's key is looked up by its variable's name ($EVALSLAB_API_KEY_
|
|
9839
|
+
<SLUG>) -- the server's environment as it starts, or what `evals-lab
|
|
9840
|
+
import` sent. Returns (lines saying what happened, None) or (None, error).
|
|
9841
|
+
No line holds a key."""
|
|
9842
|
+
why = lab_problems(doc)
|
|
9843
|
+
if why:
|
|
9844
|
+
return None, (400, why)
|
|
9845
|
+
said = []
|
|
9846
|
+
# A file's address to this lab's workspace id; one added where the trash
|
|
9847
|
+
# holds its address lands at the next free one, and its shares follow it.
|
|
9848
|
+
wid_of = {w["slug"]: w["id"] for w in WORKSPACES.list()}
|
|
9849
|
+
for w in doc.get("workspaces") or []:
|
|
9850
|
+
if w["slug"] in wid_of:
|
|
9851
|
+
continue
|
|
9852
|
+
wid, err = WORKSPACES.create(w["name"], w["slug"])
|
|
9853
|
+
wid_of[w["slug"]] = wid
|
|
9854
|
+
if not err and w.get("archived"):
|
|
9855
|
+
_, err = WORKSPACES.archive(wid)
|
|
9856
|
+
if err:
|
|
9857
|
+
return None, err
|
|
9858
|
+
said.append(f"workspace {w['name']} added at /w/{WORKSPACES.get(wid)['slug']}"
|
|
9859
|
+
+ (", archived" if w.get("archived") else ""))
|
|
9860
|
+
for _ in range(3):
|
|
9861
|
+
docs = STORE.all()
|
|
9862
|
+
cur = docs.get("promptlab.connections") or {"version": 0, "body": None}
|
|
9863
|
+
pcur = docs.get("promptlab.connectionProfiles") or {"version": 0, "body": None}
|
|
9864
|
+
body = json.loads(json.dumps(cur["body"] or {"list": [], "at": ""}))
|
|
9865
|
+
pbody = json.loads(json.dumps(pcur["body"] or {"list": []}))
|
|
9866
|
+
conns = body.setdefault("list", [])
|
|
9867
|
+
lines, changed, pchanged, added = [], False, False, []
|
|
9868
|
+
for c in doc.get("connections") or []:
|
|
9869
|
+
mine = next((x for x in conns if isinstance(x, dict) and x.get("id") == c["id"]), None)
|
|
9870
|
+
if mine is None:
|
|
9871
|
+
mine = {**{k: v for k, v in c.items() if k != "shares"}, "key": ""}
|
|
9872
|
+
added.append(c)
|
|
9873
|
+
conns.append(mine)
|
|
9874
|
+
changed = True
|
|
9875
|
+
lines.append(f"connection {c['name']} added")
|
|
9876
|
+
key = str(keys.get(key_var(c["id"], c.get("slug"))) or "").strip()
|
|
9877
|
+
if key and mine.get("key") != key:
|
|
9878
|
+
if not header_safe(key):
|
|
9879
|
+
return None, (400, f"${key_var(c['id'], c.get('slug'))} cannot go in a header")
|
|
9880
|
+
mine["key"] = key
|
|
9881
|
+
changed = True
|
|
9882
|
+
lines.append(f"connection {mine.get('name') or c['name']} takes its key from "
|
|
9883
|
+
f"${key_var(c['id'], c.get('slug'))}")
|
|
9884
|
+
profs = pbody.setdefault("list", [])
|
|
9885
|
+
for p in doc.get("connectionProfiles") or []:
|
|
9886
|
+
if not any(isinstance(x, dict) and x.get("id") == p["id"] for x in profs):
|
|
9887
|
+
profs.append(dict(p))
|
|
9888
|
+
pchanged = True
|
|
9889
|
+
lines.append(f"connection profile {p['name']} added")
|
|
9890
|
+
if doc.get("grader") and not body.get("grader"):
|
|
9891
|
+
body["grader"] = doc["grader"]
|
|
9892
|
+
changed = True
|
|
9893
|
+
lines.append(f"grader set to {doc['grader']}")
|
|
9894
|
+
# Only an added connection takes the file's share set: one the lab had
|
|
9895
|
+
# keeps its own, as everything else it had does. Set before the write,
|
|
9896
|
+
# so the connection is never there and shared with All in between; a
|
|
9897
|
+
# failed write puts back what the rows were, since its ids never landed.
|
|
9898
|
+
before = STORE.shares()
|
|
9899
|
+
for c in added:
|
|
9900
|
+
if "shares" in c:
|
|
9901
|
+
STORE.set_shares("profile", c["id"], [s if s in (SHARE_ALL, SHARE_NEW) else wid_of[s]
|
|
9902
|
+
for s in c["shares"]])
|
|
9903
|
+
writes = {}
|
|
9904
|
+
if changed:
|
|
9905
|
+
body["at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
9906
|
+
writes["promptlab.connections"] = {"version": cur["version"], "body": body}
|
|
9907
|
+
if pchanged:
|
|
9908
|
+
writes["promptlab.connectionProfiles"] = {"version": pcur["version"], "body": pbody}
|
|
9909
|
+
if not writes:
|
|
9910
|
+
break
|
|
9911
|
+
# Global documents, so the default workspace's write reaches them.
|
|
9912
|
+
ok, _ = STORE.write(writes, STORE.default_ws)
|
|
9913
|
+
if ok:
|
|
9914
|
+
break
|
|
9915
|
+
for c in added:
|
|
9916
|
+
if "shares" in c:
|
|
9917
|
+
STORE.set_shares("profile", c["id"], before.get(f"profile:{c['id']}") or [])
|
|
9918
|
+
else:
|
|
9919
|
+
return None, (409, "the lab's connections kept changing while they were imported; import again")
|
|
9920
|
+
said += lines
|
|
9921
|
+
return said or ["nothing to add"], None
|
|
9922
|
+
|
|
9923
|
+
|
|
9517
9924
|
def main():
|
|
9518
9925
|
if not (WEB_DIST / "index.html").is_file():
|
|
9519
9926
|
raise SystemExit(f"no {WEB_DIST / 'index.html'} -- the page is built from web/: "
|
|
@@ -9525,6 +9932,11 @@ def main():
|
|
|
9525
9932
|
# package, not one without the app: said at once, not at Sign in.
|
|
9526
9933
|
if PUBLIC_GOOGLE_PROBLEM:
|
|
9527
9934
|
raise SystemExit(PUBLIC_GOOGLE_PROBLEM)
|
|
9935
|
+
# A lab file named and not there is a broken deployment, said at once.
|
|
9936
|
+
if LAB_IMPORT and not Path(LAB_IMPORT).is_file():
|
|
9937
|
+
raise SystemExit(f"LAB_IMPORT names {LAB_IMPORT}, which is not a file")
|
|
9938
|
+
if LAB_IMPORT and STORE is None:
|
|
9939
|
+
raise SystemExit("LAB_IMPORT needs DATA_DIR: a lab file's settings are kept in its store")
|
|
9528
9940
|
# A root that is not there would refuse every File Source -- said at once.
|
|
9529
9941
|
if LOCAL_ROOT and not Path(LOCAL_ROOT).is_dir():
|
|
9530
9942
|
raise SystemExit(f"SOURCE_LOCAL_ROOT names {LOCAL_ROOT}, which is not a directory")
|
|
@@ -9593,6 +10005,24 @@ def main():
|
|
|
9593
10005
|
# The drop-in folder: a pack put there is installed as the lab starts.
|
|
9594
10006
|
for line in PACKS.install_dropins(Path(DATA_DIR) / "packs"):
|
|
9595
10007
|
print(f"pack: {line}", flush=True)
|
|
10008
|
+
if LAB_IMPORT:
|
|
10009
|
+
# After the demo: a lab file adds settings, never datasets, so the
|
|
10010
|
+
# demo still finds the new lab it installs into.
|
|
10011
|
+
try:
|
|
10012
|
+
doc = json.loads(Path(LAB_IMPORT).read_text("utf-8"))
|
|
10013
|
+
except (OSError, ValueError) as e:
|
|
10014
|
+
raise SystemExit(f"LAB_IMPORT: {LAB_IMPORT} is not JSON ({e})") from None
|
|
10015
|
+
said, err = lab_import(doc, os.environ)
|
|
10016
|
+
if err:
|
|
10017
|
+
raise SystemExit(f"LAB_IMPORT: {err[1]}")
|
|
10018
|
+
for line in said:
|
|
10019
|
+
print(f"import: {line}", flush=True)
|
|
10020
|
+
# A held key goes wherever a submitted run points it, so a lab open to
|
|
10021
|
+
# its network with keys in it is as loud as one with a Local provider on.
|
|
10022
|
+
if local_exposed() and any(os.environ.get(key_var(c["id"], c.get("slug")))
|
|
10023
|
+
for c in doc.get("connections") or []):
|
|
10024
|
+
print(f"WARNING: LAB_IMPORT gave a connection its key, and {HOST} is beyond loopback with no "
|
|
10025
|
+
"LAB_PASSWORD -- anyone who can reach this port can run against it", flush=True)
|
|
9596
10026
|
if QUEUE is not None:
|
|
9597
10027
|
QUEUE.start()
|
|
9598
10028
|
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|