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.
Files changed (58) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -4
  3. package/bin/evals-lab.js +9 -1
  4. package/bin/lab-file.js +97 -0
  5. package/bin/run.js +1 -1
  6. package/lab/VERSION +1 -1
  7. package/lab/demo/CREDITS.md +37 -3
  8. package/lab/demo/datasets/demo-1.json +2 -2
  9. package/lab/demo/datasets/demo-2.json +2 -2
  10. package/lab/demo/datasets/sentiment.json +383 -0
  11. package/lab/demo/datasets/weather-alerts.json +61 -0
  12. package/lab/demo/datasets/wordings.json +39 -0
  13. package/lab/demo/manifest.json +3 -3
  14. package/lab/demo/pipelines/demo-1.json +4 -4
  15. package/lab/demo/pipelines/demo-2.json +4 -4
  16. package/lab/demo/pipelines/sentiment.json +65 -0
  17. package/lab/demo/pipelines/weather-alerts.json +66 -0
  18. package/lab/demo/pipelines/wordings.json +104 -0
  19. package/lab/demo/sources/Demo 1/source.json +3 -0
  20. package/lab/demo/sources/Demo 2/source.json +3 -0
  21. package/lab/demo/sources/sentiment/bakery-croissant.txt +1 -0
  22. package/lab/demo/sources/sentiment/bus-timetable.txt +1 -0
  23. package/lab/demo/sources/sentiment/cafe-coffee.txt +1 -0
  24. package/lab/demo/sources/sentiment/concert-night.txt +1 -0
  25. package/lab/demo/sources/sentiment/film-sequel.txt +1 -0
  26. package/lab/demo/sources/sentiment/garden-tomatoes.txt +1 -0
  27. package/lab/demo/sources/sentiment/gym-contract.txt +1 -0
  28. package/lab/demo/sources/sentiment/headphones-review.txt +1 -0
  29. package/lab/demo/sources/sentiment/hotel-room.txt +1 -0
  30. package/lab/demo/sources/sentiment/library-hours.txt +1 -0
  31. package/lab/demo/sources/sentiment/meeting-minutes.txt +1 -0
  32. package/lab/demo/sources/sentiment/parcel-lost.txt +1 -0
  33. package/lab/demo/sources/sentiment/phone-update.txt +1 -0
  34. package/lab/demo/sources/sentiment/puppy-home.txt +1 -0
  35. package/lab/demo/sources/sentiment/recipe-note.txt +1 -0
  36. package/lab/demo/sources/sentiment/road-closure.txt +1 -0
  37. package/lab/demo/sources/sentiment/source.json +3 -0
  38. package/lab/demo/sources/sentiment/teacher-thanks.txt +1 -0
  39. package/lab/demo/sources/sentiment/train-delay.txt +1 -0
  40. package/lab/demo/sources/sentiment/volunteer-day.txt +1 -0
  41. package/lab/demo/sources/sentiment/weather-report.txt +1 -0
  42. package/lab/demo/sources/weather-alerts/https-api.weather.gov-alerts-types.txt +116 -0
  43. package/lab/demo/sources/weather-alerts/source.json +8 -0
  44. package/lab/evals-core.mjs +76 -16
  45. package/lab/server.py +484 -54
  46. package/lab/web/dist/assets/gallery-CFntjQFG.js +5 -0
  47. package/lab/web/dist/assets/main-BlTG2i20.css +1 -0
  48. package/lab/web/dist/assets/main-D6MNWqak.js +17 -0
  49. package/lab/web/dist/assets/tokens-DwzzdSIF.css +1 -0
  50. package/lab/web/dist/assets/tokens-bvuNFuYg.js +60 -0
  51. package/lab/web/dist/gallery.html +3 -3
  52. package/lab/web/dist/index.html +4 -4
  53. package/package.json +1 -1
  54. package/lab/web/dist/assets/gallery-DKy8QK3o.js +0 -5
  55. package/lab/web/dist/assets/main-BGmSu7aS.css +0 -1
  56. package/lab/web/dist/assets/main-DdGON-yl.js +0 -16
  57. package/lab/web/dist/assets/tokens-BmytrwxE.js +0 -60
  58. package/lab/web/dist/assets/tokens-DBN7I0ru.css +0 -1
@@ -0,0 +1,104 @@
1
+ {
2
+ "version": 17,
3
+ "id": "wordings",
4
+ "name": "Demo: compare wordings",
5
+ "jobs": [
6
+ {
7
+ "type": "job",
8
+ "id": "j1",
9
+ "name": "",
10
+ "steps": [
11
+ {
12
+ "type": "attachContent",
13
+ "content": {
14
+ "type": "prompt"
15
+ }
16
+ },
17
+ {
18
+ "type": "readReply",
19
+ "out": {
20
+ "kind": "text"
21
+ }
22
+ }
23
+ ]
24
+ }
25
+ ],
26
+ "targets": [
27
+ {
28
+ "id": "s1",
29
+ "name": "",
30
+ "connection": {
31
+ "id": "model",
32
+ "name": "Model"
33
+ },
34
+ "profile": null,
35
+ "steps": [
36
+ {
37
+ "type": "prompt",
38
+ "prompt": "Which planet is closest to the Sun?"
39
+ }
40
+ ]
41
+ },
42
+ {
43
+ "id": "s2",
44
+ "name": "",
45
+ "connection": {
46
+ "id": "model",
47
+ "name": "Model"
48
+ },
49
+ "profile": null,
50
+ "steps": [
51
+ {
52
+ "type": "prompt",
53
+ "prompt": "Name the planet nearest the Sun. Reply with its name only."
54
+ }
55
+ ]
56
+ },
57
+ {
58
+ "id": "s3",
59
+ "name": "",
60
+ "connection": {
61
+ "id": "model",
62
+ "name": "Model"
63
+ },
64
+ "profile": null,
65
+ "steps": [
66
+ {
67
+ "type": "prompt",
68
+ "prompt": "Of the eight planets in our solar system, which one orbits closest to the Sun? Answer in one word."
69
+ }
70
+ ]
71
+ },
72
+ {
73
+ "id": "s4",
74
+ "name": "",
75
+ "connection": {
76
+ "id": "model",
77
+ "name": "Model"
78
+ },
79
+ "profile": null,
80
+ "steps": [
81
+ {
82
+ "type": "prompt",
83
+ "prompt": "Quick question: what's the innermost planet?"
84
+ }
85
+ ]
86
+ }
87
+ ],
88
+ "evals": [
89
+ {
90
+ "id": "t1",
91
+ "name": "",
92
+ "type": "group",
93
+ "continueOnFailure": true,
94
+ "group": {
95
+ "id": "wordings",
96
+ "name": "Demo: compare wordings"
97
+ },
98
+ "pin": null
99
+ }
100
+ ],
101
+ "pass": {
102
+ "mode": "all"
103
+ }
104
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "name": "Demo: describe images"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "name": "Demo: describe images 2"
3
+ }
@@ -0,0 +1 @@
1
+ Stopped at the new bakery on Elm Street this morning. The croissant was still warm, flaky all the way through, and the woman at the till remembered my name from last week. I'll be back tomorrow.
@@ -0,0 +1 @@
1
+ Route 12 now leaves the station every twenty minutes between seven and seven, and every forty minutes in the evening. The last bus is at eleven fifteen.
@@ -0,0 +1 @@
1
+ Waited twenty-five minutes for a lukewarm flat white that tasted burnt. The staff were chatting the whole time and nobody apologised.
@@ -0,0 +1 @@
1
+ The band played for nearly three hours and the whole crowd sang the last song back to them. My voice is gone today and I would do it all again.
@@ -0,0 +1 @@
1
+ The sequel takes everything that made the first film charming and buries it under two and a half hours of noise. I checked my watch more than the screen.
@@ -0,0 +1 @@
1
+ Picked the first tomatoes of the summer today, and they were worth every evening of watering. Sweet, a little sharp, and still warm from the sun.
@@ -0,0 +1 @@
1
+ Cancelling my gym membership took four phone calls, a letter by recorded post and a fee nobody mentioned when I signed up. Avoid.
@@ -0,0 +1 @@
1
+ Six months in and these headphones still last two full days on a charge. The noise cancelling turns my open-plan office into a quiet room. Best thing I bought this year.
@@ -0,0 +1 @@
1
+ The website promised a sea view. Our window faced a car park and the air conditioner rattled all night. When we asked to move, the desk said nothing else was free.
@@ -0,0 +1 @@
1
+ From the first of next month, the library will open at ten on weekdays instead of nine. Weekend hours stay the same. Returns can still be left in the box by the door at any time.
@@ -0,0 +1 @@
1
+ The committee met on Tuesday. The budget for the spring fair was approved as proposed. The next meeting is on the fourteenth, in the community hall.
@@ -0,0 +1 @@
1
+ The tracking says delivered, but nothing came. Customer service closed my complaint without reply, twice. Forty pounds gone and nobody will even answer.
@@ -0,0 +1 @@
1
+ Since last night's update my phone drains from full to empty by lunchtime, and half my apps crash on opening. I wish I had never pressed install.
@@ -0,0 +1 @@
1
+ We brought our puppy home on Saturday. She has already learned to sit, chewed one slipper, and fallen asleep on my feet every evening. We adore her.
@@ -0,0 +1 @@
1
+ Preheat the oven to 180 degrees. Mix the flour, sugar and butter until it looks like breadcrumbs, then add the egg and press into the tin.
@@ -0,0 +1 @@
1
+ Mill Lane will be closed between the bridge and the school from Monday for two weeks while the water main is replaced. A diversion will be signed.
@@ -0,0 +1,3 @@
1
+ {
2
+ "name": "Demo: classify sentiment"
3
+ }
@@ -0,0 +1 @@
1
+ Mr Okafor stayed after class every Thursday for a term to help me with algebra. I passed the exam last week with a grade I never thought I'd get. Thank you, sir.
@@ -0,0 +1 @@
1
+ Third cancelled train this week. No announcement, no replacement bus, just a blank board and a crowd of us standing in the rain. I was an hour late for work again.
@@ -0,0 +1 @@
1
+ Thirty of us turned out to clear the riverbank on Sunday. We filled forty bags, met neighbours we'd never spoken to, and finished with tea and cake. A lovely day.
@@ -0,0 +1 @@
1
+ Tomorrow will be cloudy in the morning with sunny spells later. Highs of seventeen degrees, with a light westerly wind.
@@ -0,0 +1,116 @@
1
+ {
2
+ "@context": [],
3
+ "eventTypes": [
4
+ "911 Telephone Outage",
5
+ "Administrative Message",
6
+ "Air Quality Alert",
7
+ "Air Stagnation Advisory",
8
+ "Ashfall Advisory",
9
+ "Ashfall Warning",
10
+ "Avalanche Advisory",
11
+ "Avalanche Warning",
12
+ "Avalanche Watch",
13
+ "Beach Hazards Statement",
14
+ "Blizzard Warning",
15
+ "Blowing Dust Advisory",
16
+ "Blowing Dust Warning",
17
+ "Blue Alert",
18
+ "Brisk Wind Advisory",
19
+ "Child Abduction Emergency",
20
+ "Civil Danger Warning",
21
+ "Civil Emergency Message",
22
+ "Coastal Flood Advisory",
23
+ "Coastal Flood Statement",
24
+ "Coastal Flood Warning",
25
+ "Coastal Flood Watch",
26
+ "Cold Weather Advisory",
27
+ "Dense Fog Advisory",
28
+ "Dense Smoke Advisory",
29
+ "Dust Advisory",
30
+ "Dust Storm Warning",
31
+ "Earthquake Warning",
32
+ "Evacuation Immediate",
33
+ "Extreme Heat Warning",
34
+ "Extreme Heat Watch",
35
+ "Extreme Cold Warning",
36
+ "Extreme Cold Watch",
37
+ "Extreme Fire Danger",
38
+ "Extreme Wind Warning",
39
+ "Fire Warning",
40
+ "Fire Weather Watch",
41
+ "Flash Flood Statement",
42
+ "Flash Flood Warning",
43
+ "Flash Flood Watch",
44
+ "Flood Advisory",
45
+ "Flood Statement",
46
+ "Flood Warning",
47
+ "Flood Watch",
48
+ "Freeze Warning",
49
+ "Freeze Watch",
50
+ "Freezing Fog Advisory",
51
+ "Freezing Spray Advisory",
52
+ "Frost Advisory",
53
+ "Gale Warning",
54
+ "Gale Watch",
55
+ "Hazardous Materials Warning",
56
+ "Hazardous Seas Warning",
57
+ "Hazardous Seas Watch",
58
+ "Hazardous Weather Outlook",
59
+ "Heat Advisory",
60
+ "Heavy Freezing Spray Warning",
61
+ "Heavy Freezing Spray Watch",
62
+ "High Surf Advisory",
63
+ "High Surf Warning",
64
+ "High Wind Warning",
65
+ "High Wind Watch",
66
+ "Hurricane Force Wind Warning",
67
+ "Hurricane Force Wind Watch",
68
+ "Hurricane Warning",
69
+ "Hurricane Watch",
70
+ "Hydrologic Outlook",
71
+ "Ice Storm Warning",
72
+ "Lake Effect Snow Warning",
73
+ "Lake Wind Advisory",
74
+ "Lakeshore Flood Advisory",
75
+ "Lakeshore Flood Statement",
76
+ "Lakeshore Flood Warning",
77
+ "Lakeshore Flood Watch",
78
+ "Law Enforcement Warning",
79
+ "Local Area Emergency",
80
+ "Low Water Advisory",
81
+ "Marine Weather Statement",
82
+ "Nuclear Power Plant Warning",
83
+ "Radiological Hazard Warning",
84
+ "Red Flag Warning",
85
+ "Rip Current Statement",
86
+ "Severe Thunderstorm Warning",
87
+ "Severe Thunderstorm Watch",
88
+ "Severe Weather Statement",
89
+ "Shelter In Place Warning",
90
+ "Short Term Forecast",
91
+ "Small Craft Advisory",
92
+ "Snow Squall Warning",
93
+ "Special Marine Warning",
94
+ "Special Weather Statement",
95
+ "Storm Surge Warning",
96
+ "Storm Surge Watch",
97
+ "Storm Warning",
98
+ "Storm Watch",
99
+ "Test",
100
+ "Tornado Warning",
101
+ "Tornado Watch",
102
+ "Tropical Cyclone Local Statement",
103
+ "Tropical Storm Warning",
104
+ "Tropical Storm Watch",
105
+ "Tsunami Advisory",
106
+ "Tsunami Warning",
107
+ "Tsunami Watch",
108
+ "Typhoon Warning",
109
+ "Typhoon Watch",
110
+ "Volcano Warning",
111
+ "Wind Advisory",
112
+ "Winter Storm Warning",
113
+ "Winter Storm Watch",
114
+ "Winter Weather Advisory"
115
+ ]
116
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "Demo: sort weather alerts",
3
+ "type": "dynamic",
4
+ "config": {
5
+ "provider": "http-get",
6
+ "url": "https://api.weather.gov/alerts/types"
7
+ }
8
+ }
@@ -166,13 +166,11 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
166
166
  /** A target step that sends a Power Automate step's request
167
167
  (docs/power-automate.md § The HTTP Request step): the flow's own template
168
168
  -- method, path, query and body, Workflow Definition Language expressions
169
- and all -- with the target's words and fields placed in the body by its
170
- HTTP API, evaluated against each item's record. The profile gives the
171
- address and the key. */
169
+ and all -- with the target's words and fields placed in the body by the
170
+ job's HTTP API (its flow step's), evaluated against each item's record.
171
+ The profile gives the address and the key. */
172
172
 
173
173
 
174
-
175
-
176
174
 
177
175
 
178
176
 
@@ -240,7 +238,7 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
240
238
 
241
239
 
242
240
  /** What a target sends in one job: one of the target step types. */
243
-
241
+
244
242
 
245
243
  /** A target (pipeline-model §16): one column through every job, compared
246
244
  side by side in Results -- what version 9 called a scenario. Its step in
@@ -1719,6 +1717,10 @@ function datasetRules(body ) {
1719
1717
 
1720
1718
 
1721
1719
 
1720
+
1721
+
1722
+
1723
+
1722
1724
 
1723
1725
 
1724
1726
 
@@ -2170,6 +2172,7 @@ CONNECTION_TYPES.anthropic = {
2170
2172
  // Asked words (a Prompt step, a grader) or sent a flow's whole request (an
2171
2173
  // HTTP Request step): one profile and one key do both.
2172
2174
  answers: ["prompt", "request"],
2175
+ speaks: () => "anthropic",
2173
2176
  // The Messages API: the x-api-key and anthropic-version headers, base64
2174
2177
  // image content blocks, and /v1/models for the list.
2175
2178
  request(conn, prompt, dataUrl, base, key) {
@@ -2501,8 +2504,17 @@ CONNECTION_TYPES.http = {
2501
2504
  { key: "headers", label: "Headers", control: "text", default: "", hint: "", scope: "connection" },
2502
2505
  // What Test connection asks of it: a GET, keyed as a run is.
2503
2506
  { key: "testPath", label: "Test path", control: "text", default: "", hint: "", scope: "connection" },
2507
+ // Read off the address (httpApiFor); set by hand for one the lab cannot
2508
+ // tell, such as a gateway in front of a provider.
2509
+ { key: "requestFormat", label: "Request format", control: "select", default: "", hint: "", scope: "connection",
2510
+ choices: [{ value: "", label: "From address" },
2511
+ ...Object.entries(HTTP_APIS).map(([value, a]) => ({ value, label: a.label }))] },
2504
2512
  ],
2505
2513
  answers: ["request"],
2514
+ speaks(conn) {
2515
+ const api = String(conn.requestFormat || "") || httpApiFor(originBase(conn.url));
2516
+ return HTTP_APIS[api] && api !== "raw" ? api : null;
2517
+ },
2506
2518
  lists: false,
2507
2519
  baseOf: originBase,
2508
2520
  send(conn, req, base, key) {
@@ -3297,7 +3309,7 @@ function applyModifiers (list , kind ,
3297
3309
  // 16: a target names an AI Connection and, optionally, a Connection Profile,
3298
3310
  // where it named one Target profile; a run's table is keyed by the pair
3299
3311
  // (answerKey) -- the Target profile split in the store as it opens (#445).
3300
- const PIPELINE_VERSION = 16 ;
3312
+ const PIPELINE_VERSION = 17 ;
3301
3313
 
3302
3314
  // Plain objects, so an entry is added by assignment and a reader never needs
3303
3315
  // to know which registered it.
@@ -4818,16 +4830,18 @@ STEP_TYPES.httpRequest = {
4818
4830
  label: "HTTP Request", slot: "target", asks: "request", verbatim: true, modelFrom: "step",
4819
4831
  description: "Sends a Power Automate step's own request, rebuilt from each record, with each target's words in it.",
4820
4832
  firstJobOnly: "an HTTP Request is sent from its item's record, so it is job 1's",
4821
- // A new target starts from the flow's own words and fields.
4822
- newStep: (step) => ({ type: "httpRequest", api: step.api, method: step.method, path: step.path,
4833
+ // A new target starts from the flow's own words and fields, read by the
4834
+ // job's HTTP API ([step] carries it: requestStepOf).
4835
+ newStep: (step) => ({ type: "httpRequest", method: step.method, path: step.path,
4823
4836
  query: clone(step.query), body: clone(step.body),
4824
4837
  ...(HTTP_APIS[step.api] ?? HTTP_APIS.raw ).cellOf(step.body) }),
4825
4838
  in: "item", out: "text",
4826
4839
  apply: "runPipeline",
4827
- fields: ["type", "api", "method", "path", "query", "body", "prompt", "connection", "profile", "from", "system", "model", "settings", "prefill"],
4840
+ fields: ["type", "method", "path", "query", "body", "prompt", "connection", "profile", "from", "system", "model", "settings", "prefill"],
4841
+ // [step] carries its job's HTTP API, where the job has a flow step to say
4842
+ // it (validatePipeline); the flow step's own check names an unknown one.
4828
4843
  validate(step, _ctx, bad, at){
4829
4844
  const api = HTTP_APIS[step.api];
4830
- if (!api) bad.push(`${at}: "${step.api}" is not an HTTP API this lab has`);
4831
4845
  if (!HTTP_METHODS.includes(step.method)) bad.push(`${at}: the method is one of ${HTTP_METHODS.join(", ")}`);
4832
4846
  if (!isStr(step.path) || !/^[/@]/.test(step.path)) bad.push(`${at}: the path starts with /`);
4833
4847
  if (!isObj(step.query) || !Object.values(step.query).every(isStr)) bad.push(`${at}: the query is names to text`);
@@ -5104,7 +5118,7 @@ function requestStepOf(doc
5104
5118
 
5105
5119
  /** What httpRequestOf builds a request from: a target's template, and the
5106
5120
  loop its flow step sits in. */
5107
-
5121
+
5108
5122
  /** What httpReplyOf reads a reply with: the flow step, its API, its reading. */
5109
5123
 
5110
5124
 
@@ -5706,10 +5720,14 @@ function upgradePipeline (doc , ctx = {}) {
5706
5720
  }
5707
5721
  return localSteps(ownBodiesNow(out), ctx) ;
5708
5722
  }
5709
- if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].includes(doc.version )) return doc;
5723
+ if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16].includes(doc.version )) return doc;
5724
+ // Every version reads as 16 first, then as 17: its requests read by their
5725
+ // job's HTTP API (requestsOnJobApi).
5726
+ const seventeen = (d ) => localSteps(ownBodiesNow(requestsOnJobApi(d)), ctx) ;
5727
+ if (doc.version === 16) return seventeen(cutRefs(clone(doc) ));
5710
5728
  // Every version reads as 15 first, then as 16: its Target profiles split
5711
5729
  // into AI Connections and Connection Profiles (answersFromProfiles).
5712
- const sixteen = (fifteen ) => localSteps(ownBodiesNow(answersFromProfiles(fifteen, ctx)), ctx) ;
5730
+ const sixteen = (fifteen ) => seventeen(answersFromProfiles(fifteen, ctx));
5713
5731
  if (doc.version === 15) return sixteen(cutRefs(clone(doc) ));
5714
5732
  // Versions 13 and 14 read as 15 by migrating a job's cleaning modifier steps
5715
5733
  // and its List `preamble` setting into Pre-/Post-format filter sets
@@ -5854,6 +5872,37 @@ function localSteps(doc , ctx ) {
5854
5872
  return next;
5855
5873
  }
5856
5874
 
5875
+ /**
5876
+ * Version 16 to 17 (#477): a request step has no HTTP API of its own; its
5877
+ * job's flow step says it. A step that named another -- Any endpoint over an
5878
+ * Anthropic flow, say -- keeps its body as it would have sent it, read again
5879
+ * by the job's (its words and fields as that API holds them). A request in a
5880
+ * job with no flow step, which could never run, is a Prompt keeping its words.
5881
+ */
5882
+ function requestsOnJobApi(next ) {
5883
+ next.version = PIPELINE_VERSION;
5884
+ const jobs = Array.isArray(next.jobs) ? next.jobs : [];
5885
+ const step = (st , k ) => {
5886
+ if (!isObj(st) || st.type !== "httpRequest") return st;
5887
+ const { api: own, ...rest } = st;
5888
+ const flow = isObj(jobs[k]) ? flowStepOf(jobs[k] ) : null;
5889
+ if (!flow) {
5890
+ return { type: "prompt", prompt: rest.prompt,
5891
+ ...Object.fromEntries(["connection", "profile", "from"].filter(f => rest[f] != null).map(f => [f, rest[f]])) };
5892
+ }
5893
+ const was = HTTP_APIS[own ], now = HTTP_APIS[flow.api];
5894
+ if (!was || !now || own === flow.api) return rest;
5895
+ let body = rest.body;
5896
+ try { body = was.place(rest.body, rest ); } catch { /* words that never placed: the template stands */ }
5897
+ const { system: _s, model: _m, settings: _t, prefill: _p, ...kept } = rest;
5898
+ return { ...kept, body, ...now.cellOf(body) };
5899
+ };
5900
+ if (Array.isArray(next.targets)) {
5901
+ next.targets = next.targets.map((t ) => (isObj(t) && Array.isArray(t.steps) ? { ...t, steps: t.steps.map(step) } : t));
5902
+ }
5903
+ return next;
5904
+ }
5905
+
5857
5906
  /** [next] as version 9 held it, from any version before 10. */
5858
5907
  function nineOf(next , ctx ) {
5859
5908
  if (next.version === 9) { profileRefs(next); return next; }
@@ -5975,7 +6024,9 @@ function validatePipeline(input , ctx = {}) {
5975
6024
  if (entry.local && (st.profile != null || st.connection != null)) return void bad.push(`${where}: ${entry.label} asks no connection`);
5976
6025
  if (k > 0 && entry.firstJobOnly) return void bad.push(`${where}: ${entry.firstJobOnly}`);
5977
6026
  onlyFields(st, where, entry.fields ?? [], bad);
5978
- entry.validate(st, c, bad, where);
6027
+ // A request is read and placed by its job's HTTP API.
6028
+ const flow = entry.asks === "request" ? flowStepOf(doc.jobs[k]) : null;
6029
+ entry.validate(flow ? { ...st, api: flow.api } : st, c, bad, where);
5979
6030
  answerRefsProblems(st, where, bad);
5980
6031
  if (st.from != null && !isRef(st.from)) bad.push(`${where} has to name the prompt it was picked from as { id, name }`);
5981
6032
  // A request is a flow step's, rebuilt from its records, and its
@@ -6050,6 +6101,15 @@ function validatePipeline(input , ctx = {}) {
6050
6101
  const type = CONNECTION_TYPES[typeOf(p )];
6051
6102
  if (type && !(type.answers ?? ["prompt"]).includes(step.asks ?? "prompt")) {
6052
6103
  bad.push(`${n > 1 ? `${jobLabel(doc, k)}, ` : ""}${targetLabel(doc, i)}: ${step.label ?? "this step"} needs a profile that answers it, and ${type.label} does not`);
6104
+ return;
6105
+ }
6106
+ // A request goes as the flow wrote it, so its connection has to speak
6107
+ // the job's HTTP API; one the lab cannot tell (Any endpoint) passes.
6108
+ const api = step.asks === "request" ? flowStepOf(doc.jobs[k])?.api : undefined;
6109
+ const speaks = api && api !== "raw" && type?.speaks ? type.speaks(p ) : null;
6110
+ if (api && speaks && speaks !== api) {
6111
+ const name = targetAnswerOf(doc, i, k)?.connection.name || type .label;
6112
+ bad.push(`${n > 1 ? `${jobLabel(doc, k)}, ` : ""}${targetLabel(doc, i)}: ${name} can't send an ${HTTP_APIS[api]?.label ?? api} request`);
6053
6113
  }
6054
6114
  });
6055
6115
  // The model is the profile's unless the target's step carries it.
@@ -7119,7 +7179,7 @@ export {
7119
7179
  CONNECTION_FIELDS, SETTING_KEYS, OPTION_FIELDS, jobLabel, scenarioLabel, jobWords, versionProblem,
7120
7180
  contentOf, withContent, replyOf,
7121
7181
  targetsOf, targetLetter, targetLabel, targetStepOf, targetEntryOf, jobTargetType, targetAnswerOf, targetAnswerKey, recordedTargetOf, withTarget,
7122
- tokensOf, withTokens, sendsImage, withImage, flowStepOf, readAsOf, outOf, withOut, slotEntry, SLOTS,
7182
+ tokensOf, withTokens, sendsImage, withImage, flowStepOf, requestStepOf, readAsOf, outOf, withOut, slotEntry, SLOTS,
7123
7183
  filterSetsOf, withFilterSets, stageModifiers, beforeFilters,
7124
7184
  blankPipeline, upgradePipeline, fatProfileRef, newId, newEval, newLink, validatePipeline, resolvePipeline, pipelineOfRun, stagesFor, profileIds, answerKeys,
7125
7185
  scenarioProfile, lastKind, lastModifier, modifierSummary, itemScores, scenarioEvals, scenarioPasses, groupPass, overallVerdict, passVerdict, isSkipped, failedScore,