foldrun 0.5.0 → 0.7.2
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 +98 -15
- package/bin/foldrun.mjs +331 -47
- package/docs/agents.md +483 -0
- package/docs/api-usage.md +156 -0
- package/docs/api.md +435 -0
- package/docs/backups-and-restore.md +171 -0
- package/docs/budgets-and-billing.md +362 -0
- package/docs/cli.md +428 -0
- package/docs/coding-agents.md +224 -0
- package/docs/deploying.md +77 -0
- package/docs/evals.md +90 -0
- package/docs/faq.md +53 -0
- package/docs/flows.md +670 -0
- package/docs/getting-started.md +141 -0
- package/docs/glossary.md +78 -0
- package/docs/harness.md +128 -0
- package/docs/how-it-works.md +255 -0
- package/docs/introduction.md +47 -0
- package/docs/knowledge-and-memory.md +85 -0
- package/docs/notifications.md +320 -0
- package/docs/providers.md +241 -0
- package/docs/runs.md +238 -0
- package/docs/scheduling.md +103 -0
- package/docs/secrets.md +163 -0
- package/docs/security.md +167 -0
- package/docs/self-hosting.md +65 -0
- package/docs/status.md +73 -0
- package/docs/storage-and-sharing.md +118 -0
- package/docs/team-and-access.md +146 -0
- package/docs/tools.md +1810 -0
- package/docs/troubleshooting.md +155 -0
- package/docs/versions.md +79 -0
- package/docs/workspaces.md +160 -0
- package/package.json +15 -5
- package/src/commands.mjs +6984 -331
- package/src/credentials.mjs +3 -0
- package/src/guide.mjs +172 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Knowledge, memory and state
|
|
2
|
+
|
|
3
|
+
Four places hold what an agent knows, and they differ only by who may write.
|
|
4
|
+
Getting the four right is most of what separates a desk that improves from
|
|
5
|
+
one that repeats itself.
|
|
6
|
+
|
|
7
|
+
| place | who writes | what belongs there |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| **knowledge/** | people | what you tell the agents: reference material, standards, the price list |
|
|
10
|
+
| **memory/** | the agent | what it worked out: one fact per file, indexed |
|
|
11
|
+
| **state/** | the workspace | the bookmark between runs: a cursor, an append-only history |
|
|
12
|
+
| **storage/** | anyone | bytes a person reads — see [Storage and sharing](storage-and-sharing) |
|
|
13
|
+
|
|
14
|
+
## Knowledge is given, not learned
|
|
15
|
+
|
|
16
|
+
`knowledge/` at the workspace is read by every agent there; `knowledge/`
|
|
17
|
+
inside an agent's folder by that agent alone; the account library's by
|
|
18
|
+
every workspace. Writing to any of them is denied outright — through the
|
|
19
|
+
file tools and through a script. An agent that rewrites the price list it was
|
|
20
|
+
handed has corrupted its own source of truth; what it discovers goes to
|
|
21
|
+
`memory/`.
|
|
22
|
+
|
|
23
|
+
Knowledge files are **concepts** in the open OKF shape: a
|
|
24
|
+
markdown file with frontmatter naming its `type:`, a title, and a
|
|
25
|
+
description. The platform maintains `index.md` and `log.md` in each
|
|
26
|
+
knowledge and memory directory — an index of what is there and a provenance
|
|
27
|
+
log of who changed what — and refuses a push that adds a concept with no
|
|
28
|
+
`type:`. Those two files are generated on every write; do not edit them.
|
|
29
|
+
|
|
30
|
+
```markdown
|
|
31
|
+
---
|
|
32
|
+
type: Reference
|
|
33
|
+
title: Target keywords
|
|
34
|
+
description: The queries the business wants to win, and where each is measured.
|
|
35
|
+
---
|
|
36
|
+
| keyword | location | page |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| building inspection geelong | Geelong,Victoria,Australia | /vic/building-inspections-geelong |
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Memory is what the agent learned
|
|
42
|
+
|
|
43
|
+
`memory/` is the one place an agent writes without an explicit `write`
|
|
44
|
+
grant. One fact per file, with frontmatter the index can read. An agent that
|
|
45
|
+
learns a customer's phone format, a site's quirk, a date that mattered,
|
|
46
|
+
writes it there and finds it next run — and a deploy never overwrites memory
|
|
47
|
+
a push does not mention, so what was learned survives the next edit to the
|
|
48
|
+
prompt.
|
|
49
|
+
|
|
50
|
+
Memory is per agent by default; the workspace's `memory/` is shared by the
|
|
51
|
+
desk, and the library's by the account.
|
|
52
|
+
|
|
53
|
+
## Finding it: search and history
|
|
54
|
+
|
|
55
|
+
Two tool groups give an agent its own past at scale:
|
|
56
|
+
|
|
57
|
+
- **`tools: [search]`** — `search_files(query)`: ranked full-text search over
|
|
58
|
+
knowledge, memory, state and storage, at the agent's, the workspace's and
|
|
59
|
+
the library's scope. Lexical (BM25), not embeddings — nothing leaves the
|
|
60
|
+
box, and the queries an agent makes are rare exact tokens where that
|
|
61
|
+
wins. Behind it is an inverted index built inside the step on the first
|
|
62
|
+
call and refreshed by mtime after that: a file the agent wrote to
|
|
63
|
+
`state/` a moment ago is found on the next search and is the only file
|
|
64
|
+
re-read, and nothing is persisted, so the index can never be stale across
|
|
65
|
+
a deploy or a hand edit. Search first, then read the hit.
|
|
66
|
+
- **`tools: [history]`** — `recall_runs()` and `read_run(id)`: what this
|
|
67
|
+
workspace's earlier runs concluded and what their steps said. A reporter
|
|
68
|
+
that wants "what did last week's run find" asks, rather than being handed
|
|
69
|
+
every prior run.
|
|
70
|
+
|
|
71
|
+
## State is the bookmark
|
|
72
|
+
|
|
73
|
+
A cursor into a list that takes a fortnight to walk; a dated, append-only
|
|
74
|
+
history line per run; last week's positions to diff against this week's.
|
|
75
|
+
The next run reads it, a person rarely does. It travels with a deploy and a
|
|
76
|
+
deploy never overwrites it. The one habit that keeps it useful: **append,
|
|
77
|
+
never rewrite**, so a run that went wrong cannot erase the record of the runs
|
|
78
|
+
that went right.
|
|
79
|
+
|
|
80
|
+
## The rule to write by
|
|
81
|
+
|
|
82
|
+
A value **regenerated** every run — a fetched list, a report — is `storage/`.
|
|
83
|
+
A value **accumulated** across runs — dead ends, a history, a cursor — is
|
|
84
|
+
`state/`. What the agent *learned* — a fact about the world — is `memory/`.
|
|
85
|
+
What you *told* it is `knowledge/`.
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# Notifications
|
|
2
|
+
|
|
3
|
+
An agent that runs while the laptop is closed needs a way to say "I
|
|
4
|
+
finished", "I failed", and above all "I am waiting for you". A run parked at
|
|
5
|
+
an approval nobody hears about is a run that never happens. Notifications are
|
|
6
|
+
one destination — an email address or a webhook URL — declared once in
|
|
7
|
+
`AGENTS.md`, and a short list of the events that should reach it.
|
|
8
|
+
|
|
9
|
+
## Declaring one
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
notify:
|
|
13
|
+
email: ops@example.com
|
|
14
|
+
events: [failed, awaiting-approval, completed]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
or, for people who live in a channel rather than an inbox:
|
|
18
|
+
|
|
19
|
+
```yaml
|
|
20
|
+
notify:
|
|
21
|
+
url: ${SLACK_WEBHOOK_URL} # a secret name, or a literal URL
|
|
22
|
+
events: [failed, awaiting-approval]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A bare string is whichever destination it looks like: `notify: ops@example.com`.
|
|
26
|
+
|
|
27
|
+
A webhook can also be signed, so the receiver can tell a real notification
|
|
28
|
+
from anyone who learned the URL:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
notify:
|
|
32
|
+
url: ${OPS_WEBHOOK_URL}
|
|
33
|
+
signing_secret: OPS_WEBHOOK_SIGNING_KEY # a secret NAME, never its value
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Every delivery then carries `x-foldrun-timestamp` and
|
|
37
|
+
`x-foldrun-signature: sha256=<hex>`, the hex HMAC-SHA256 of
|
|
38
|
+
`"<timestamp>.<body>"` keyed by that secret. The timestamp is inside the
|
|
39
|
+
signed string, so a captured delivery cannot be replayed later with a fresh
|
|
40
|
+
one. Beside it, `x-signature` is the plain HMAC of the body — the scheme an
|
|
41
|
+
inbound `signature: hmac` flow verifies — so one foldrun install can notify
|
|
42
|
+
another's webhook flow and be accepted. Name no secret and nothing is
|
|
43
|
+
signed, which is what every existing destination keeps doing. How to check
|
|
44
|
+
it is under [Verifying a delivery](#verifying-a-delivery).
|
|
45
|
+
|
|
46
|
+
The default events are `failed` and `awaiting-approval`. `completed` is
|
|
47
|
+
opt-in on purpose: a schedule that works is the quiet kind of good news, and
|
|
48
|
+
a channel that pings on every success gets muted — which un-pings the
|
|
49
|
+
failures too. Turn it on when the run's headline is the thing you want to
|
|
50
|
+
read each morning, which for a scheduled desk it usually is.
|
|
51
|
+
|
|
52
|
+
Even with `completed` on, an eval case (`eval:<name>`) or an adhoc
|
|
53
|
+
single-agent run (`adhoc:<agent>`) completing sends nothing: the person who
|
|
54
|
+
started it is watching it, and a desk that emailed every test would bury
|
|
55
|
+
its weekly verdict. A test that fails, or parks at a gate, still sends.
|
|
56
|
+
|
|
57
|
+
## `blocked` — a run that finished but did nothing
|
|
58
|
+
|
|
59
|
+
Every desk ends its last step on a verdict word: `GOOD`, `BAD`, `QUIET` or
|
|
60
|
+
`BLOCKED`. The platform reads that word from the run's summary and records it
|
|
61
|
+
beside the status. A run can be `completed` — every step ran, nothing
|
|
62
|
+
crashed — and still be `BLOCKED`: the publisher checked, saw something
|
|
63
|
+
wrong, and refused to push. That is a success of the machine and a failure
|
|
64
|
+
of the work, and a green tick hid it.
|
|
65
|
+
|
|
66
|
+
So a completed run whose verdict is `BLOCKED` is sent as its own event,
|
|
67
|
+
`blocked`, with the headline `⛔ <flow> blocked` and `verdict: "BLOCKED"`
|
|
68
|
+
in the body. **It follows `failed`**, like the alerts below: a destination
|
|
69
|
+
that hears about failures hears about it, and a `completed`-only list does
|
|
70
|
+
not. Name `blocked` alone to hear only those. Every run notification's body
|
|
71
|
+
carries `verdict` — `GOOD`, `BAD`, `QUIET`, `BLOCKED`, or `null` when the
|
|
72
|
+
summary leads with none.
|
|
73
|
+
|
|
74
|
+
## The three that are about nothing happening
|
|
75
|
+
|
|
76
|
+
`failed`, `completed`, `blocked` and `awaiting-approval` are all about a run. Three
|
|
77
|
+
more events are about a run that **did not** happen, or has not finished,
|
|
78
|
+
which is the class of failure that otherwise reaches nobody:
|
|
79
|
+
|
|
80
|
+
| event | sent when |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `quarantined` | a flow's `disable_after:` tripped and it has stopped firing |
|
|
83
|
+
| `sla` | a run has passed its flow's `sla:` and is still going |
|
|
84
|
+
| `budget` | a workspace has spent 80%, then 95%, of its `budget:` for the window — today, this week or this month |
|
|
85
|
+
|
|
86
|
+
Each is sent once per occurrence, not once per check: an alarm that repeats
|
|
87
|
+
every thirty seconds gets muted, and a muted alarm is the same as no alarm.
|
|
88
|
+
The budget marks reset with the month, and a quarantine is forgotten as soon
|
|
89
|
+
as the flow succeeds again, so the next outage is announced as its own.
|
|
90
|
+
|
|
91
|
+
**They follow `failed`.** If a destination is told about failures it is told
|
|
92
|
+
about these too, because each one is a failure that produces no failed run —
|
|
93
|
+
and nobody thinks to opt in to hearing about something that has not
|
|
94
|
+
happened. Naming one explicitly in `events:` also turns it on. To have none
|
|
95
|
+
of them, leave `failed` out of the list.
|
|
96
|
+
|
|
97
|
+
## Proving it arrives
|
|
98
|
+
|
|
99
|
+
A notification path that has never been tested is not a notification path.
|
|
100
|
+
Every message here is sent when nobody is watching, to a destination nobody
|
|
101
|
+
has checked, through a mail domain nobody has verified — and on 2026-09-06
|
|
102
|
+
every run email had been going to a bin for weeks, with silence as the only
|
|
103
|
+
symptom. Silence looks exactly like nothing having gone wrong.
|
|
104
|
+
|
|
105
|
+
Workspace settings has a button that sends one real message to the saved
|
|
106
|
+
destination and reports what came back, including the provider's own words
|
|
107
|
+
on a failure. Those words are the value: "domain not verified" and "you can
|
|
108
|
+
only send to your own address" send you to two different pages, and a status
|
|
109
|
+
code sends you to neither. `POST /api/workspaces/<ws>/notify/test` is the
|
|
110
|
+
same thing.
|
|
111
|
+
|
|
112
|
+
## Where it is set, and the rule that bites
|
|
113
|
+
|
|
114
|
+
`notify:` lives in the account's `AGENTS.md` and every workspace inherits it.
|
|
115
|
+
A workspace may declare its own — and when it does, its block **replaces**
|
|
116
|
+
the account's whole, it does not merge. A workspace that wants the account's
|
|
117
|
+
destination and events simply says nothing.
|
|
118
|
+
|
|
119
|
+
## What arrives
|
|
120
|
+
|
|
121
|
+
**The subject is the run's headline** — the first line of the last step's
|
|
122
|
+
reply — followed by its summary. Ids and costs are in the body. The subject
|
|
123
|
+
is meant to be read on a phone: "BAD — 3 targets fell out of the top 20" is
|
|
124
|
+
the whole message; "run-mtmv20up-oote completed" is not.
|
|
125
|
+
|
|
126
|
+
The body, and the webhook's JSON:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"text": "BAD — 3 targets fell out of the top 20 · rank-desk/run-… · $1.01",
|
|
131
|
+
"workspace": "rank-desk", "runId": "run-…", "flow": "rankings",
|
|
132
|
+
"status": "completed", "summary": "…", "costUsd": 1.01,
|
|
133
|
+
"startedAt": "…", "finishedAt": "…",
|
|
134
|
+
"approveUrl": "…", "rejectUrl": "…",
|
|
135
|
+
"links": [{ "step": 3, "agent": "publisher", "approveUrl": "…", "rejectUrl": "…" }]
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`text` is what Slack, Discord and ntfy render as-is; the rest is for anything
|
|
140
|
+
that wants the data. A webhook URL is the one integration: those services,
|
|
141
|
+
and every phone-push relay, all accept a POST of that shape.
|
|
142
|
+
|
|
143
|
+
## Webhook deliveries, retries and redelivery
|
|
144
|
+
|
|
145
|
+
Every webhook send is a **delivery**: recorded first, attempted at once,
|
|
146
|
+
and — when the receiver is down, slow or answers anything but 2xx — tried
|
|
147
|
+
again on a backoff from the platform's worker:
|
|
148
|
+
|
|
149
|
+
| attempt | when |
|
|
150
|
+
|---|---|
|
|
151
|
+
| 1 | at once |
|
|
152
|
+
| 2 | 1 minute after the first failed |
|
|
153
|
+
| 3 | 5 minutes after that |
|
|
154
|
+
| 4 | 30 minutes |
|
|
155
|
+
| 5 | 2 hours |
|
|
156
|
+
| 6 | 6 hours |
|
|
157
|
+
|
|
158
|
+
After the sixth the delivery is `failed` and stays in the log, one press
|
|
159
|
+
from a redelivery. Each attempt has an 8-second timeout and is written down:
|
|
160
|
+
the status code, how long it took, the first 500 characters of the answer
|
|
161
|
+
and the error. The log is kept 30 days.
|
|
162
|
+
|
|
163
|
+
Every attempt carries these headers:
|
|
164
|
+
|
|
165
|
+
| header | |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `X-Foldrun-Delivery` | the delivery's id, `dlv_…` — **the same on every attempt and every redelivery**. Dedupe on it: a receiver that took attempt 2 but timed out answering may see attempt 3 |
|
|
168
|
+
| `X-Foldrun-Event` | `failed`, `completed`, `blocked`, `awaiting-approval`, `quarantined`, `sla`, `budget`, or `test` |
|
|
169
|
+
| `X-Foldrun-Attempt` | 1, 2, … |
|
|
170
|
+
| `X-Foldrun-Timestamp`, `X-Foldrun-Signature` | when `signing_secret:` is set — see below |
|
|
171
|
+
|
|
172
|
+
The URL is read from `notify:` at each attempt, so fixing a typo in it is
|
|
173
|
+
picked up by the next retry. The body is kept as first sent, so a retry is
|
|
174
|
+
the same bytes.
|
|
175
|
+
|
|
176
|
+
**Where to see it.** The workspace's Triggers page ends with *Outbound
|
|
177
|
+
webhook deliveries*: every delivery, its status, its attempts, and a
|
|
178
|
+
**Redeliver** button. From a terminal, `foldrun webhooks deliveries --to
|
|
179
|
+
<ws>` (`--failed` for the ones that gave up) and `foldrun webhooks
|
|
180
|
+
redeliver <id> --to <ws>`; over HTTP, `GET
|
|
181
|
+
/api/workspaces/<ws>/notify/deliveries` and `POST
|
|
182
|
+
…/deliveries/<id>/redeliver`. A redelivery is one attempt, now, with the
|
|
183
|
+
same id and body, signed afresh. The log shows a gate's approve and
|
|
184
|
+
reject links as `[redacted]`: they decide without a login, and every role
|
|
185
|
+
can read the log. The receiver still gets them, on a redelivery too.
|
|
186
|
+
|
|
187
|
+
**An endpoint that stays down is switched off.** When every attempt to a
|
|
188
|
+
workspace's URL has failed for **3 days running** (the operator's
|
|
189
|
+
`FOLDRUN_WEBHOOK_DISABLE_DAYS`), the next delivery to give up after all
|
|
190
|
+
six attempts switches it off, and the platform emails the account's owner
|
|
191
|
+
once. A first attempt that fails does not switch it off on its own, however
|
|
192
|
+
old the last failure: its retries get their chance. Deliveries made while it is off are
|
|
193
|
+
recorded as `skipped`, not attempted. To turn it back on, fix the receiver
|
|
194
|
+
and press Redeliver, or send a test (`foldrun notify test --to <ws>`): the
|
|
195
|
+
first accepted one switches it on. Changing the URL in `notify:` starts it
|
|
196
|
+
fresh.
|
|
197
|
+
|
|
198
|
+
A laptop install has no worker to retry from: there a webhook is one
|
|
199
|
+
attempt, with the same headers, and no log.
|
|
200
|
+
|
|
201
|
+
## Verifying a delivery
|
|
202
|
+
|
|
203
|
+
With `signing_secret:` set, check every delivery before acting on it:
|
|
204
|
+
|
|
205
|
+
1. Read `X-Foldrun-Timestamp` (Unix seconds) and refuse it when it is more
|
|
206
|
+
than five minutes from your clock — a captured delivery replayed later
|
|
207
|
+
carries an old one, and the timestamp is inside the signature, so it
|
|
208
|
+
cannot be swapped for a fresh one.
|
|
209
|
+
2. Compute the hex HMAC-SHA256 of `"<timestamp>.<raw body>"` with the
|
|
210
|
+
secret's value, and compare it to `X-Foldrun-Signature` after its
|
|
211
|
+
`sha256=` prefix, in constant time. Use the raw bytes of the body —
|
|
212
|
+
re-serialised JSON will not match.
|
|
213
|
+
3. Dedupe on `X-Foldrun-Delivery`.
|
|
214
|
+
|
|
215
|
+
Node:
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
import crypto from "node:crypto";
|
|
219
|
+
|
|
220
|
+
export function verifyFoldrun(rawBody, headers, secret, toleranceSec = 300) {
|
|
221
|
+
const ts = headers["x-foldrun-timestamp"];
|
|
222
|
+
const sig = (headers["x-foldrun-signature"] ?? "").replace(/^sha256=/, "");
|
|
223
|
+
if (!ts || !sig) return false;
|
|
224
|
+
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;
|
|
225
|
+
const want = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
|
|
226
|
+
return sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want));
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Python:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
import hashlib, hmac, time
|
|
234
|
+
|
|
235
|
+
def verify_foldrun(raw_body: bytes, headers, secret: str, tolerance=300) -> bool:
|
|
236
|
+
ts = headers.get("X-Foldrun-Timestamp")
|
|
237
|
+
sig = (headers.get("X-Foldrun-Signature") or "").removeprefix("sha256=")
|
|
238
|
+
if not ts or not sig or abs(time.time() - int(ts)) > tolerance:
|
|
239
|
+
return False
|
|
240
|
+
want = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
|
|
241
|
+
return hmac.compare_digest(sig, want)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Answer 2xx as soon as it is verified and recorded; do the work after. A
|
|
245
|
+
receiver that takes longer than 8 seconds is counted as failed and is
|
|
246
|
+
sent the delivery again.
|
|
247
|
+
|
|
248
|
+
## Approve and reject links
|
|
249
|
+
|
|
250
|
+
A run parked on a person sends its notification with two links per waiting
|
|
251
|
+
step — `links` carries every pair, `approveUrl`/`rejectUrl` the first.
|
|
252
|
+
Clicking one opens a page that shows what is waiting and asks for the
|
|
253
|
+
decision — it never decides on the GET, because inbox link-checkers follow
|
|
254
|
+
links. The token in a link is bound to that one step, to a moment (the
|
|
255
|
+
flow's `approve_within:`, else a week), and — when the mail went to an
|
|
256
|
+
address in the flow's `approvers:` — to that person, so a forwarded mail
|
|
257
|
+
decides as nobody. It is derived, never stored; a key rotation kills every
|
|
258
|
+
old link at once, and a link that has decided is spent. A `wait: event` park
|
|
259
|
+
gets no link: it is waiting for a machine.
|
|
260
|
+
|
|
261
|
+
The links exist only when the install knows its own public address
|
|
262
|
+
(`FOLDRUN_PUBLIC_URL`). Without it the notification goes out without them and
|
|
263
|
+
the run is approved from the dashboard.
|
|
264
|
+
|
|
265
|
+
## Email
|
|
266
|
+
|
|
267
|
+
Notification email is **your mail**: a desk telling its owner what it
|
|
268
|
+
found comes from the sender you chose. Set `RESEND_API_KEY` and
|
|
269
|
+
`EMAIL_FROM` as account secrets — the same key an email tool of your own
|
|
270
|
+
would use — and every notification goes out through your key, from your
|
|
271
|
+
address, under your domain's reputation and your inbox's rules. `EMAIL_FROM`
|
|
272
|
+
must be an address the Resend account behind that key may send from (a
|
|
273
|
+
verified domain, or Resend's onboarding sender for testing).
|
|
274
|
+
|
|
275
|
+
An account that has set neither still gets its notifications: they fall
|
|
276
|
+
back to the platform's connection and come from `foldrun <hello@foldrun.io>`.
|
|
277
|
+
An invite and a low-balance warning are the platform's own mail and always
|
|
278
|
+
come from foldrun; the platform never sends those through your key.
|
|
279
|
+
|
|
280
|
+
## Choosing what mail you get
|
|
281
|
+
|
|
282
|
+
Every mail the platform sends belongs to a category, and each person
|
|
283
|
+
chooses which they take — under **Profile → Notifications**, with `foldrun
|
|
284
|
+
notifications` / `foldrun notifications set <category> on|off`, or through
|
|
285
|
+
`GET`/`PATCH /api/me/notifications`. The choice is asked at the moment of
|
|
286
|
+
sending, so it holds for every mail, from every part of the platform.
|
|
287
|
+
|
|
288
|
+
| category | what | can be turned off |
|
|
289
|
+
|---|---|---|
|
|
290
|
+
| `security` | password resets, email confirmations, a login moved, an account made for you | no |
|
|
291
|
+
| `invites` | an invitation to join an account | no |
|
|
292
|
+
| `billing` | a payment failed, and the grace before runs stop | no |
|
|
293
|
+
| `account` | a notification webhook switched off after days of failures; a close request | no |
|
|
294
|
+
| `run-alerts` | a workspace's `notify:` email: failed, completed, blocked, quarantined, `sla`, `budget` | yes — per workspace |
|
|
295
|
+
| `approvals` | a run waiting for you, with approve and reject links | yes — per workspace |
|
|
296
|
+
| `low-balance` | credits below the warning level | yes |
|
|
297
|
+
| `product-updates` | what changed in foldrun | yes |
|
|
298
|
+
|
|
299
|
+
The four that cannot be turned off are how you get back into the account
|
|
300
|
+
and how you hear that something you rely on has stopped; the page shows
|
|
301
|
+
them, switched on and locked, with that reason. `run-alerts` and
|
|
302
|
+
`approvals` can be off for one workspace and on for the rest (`foldrun
|
|
303
|
+
notifications set run-alerts off --to seo-desk`).
|
|
304
|
+
|
|
305
|
+
**Unsubscribing from a mail.** Every mail in an optional category carries a
|
|
306
|
+
one-click unsubscribe, as RFC 8058 describes: `List-Unsubscribe:
|
|
307
|
+
<https://…/api/unsubscribe?token=…>` and `List-Unsubscribe-Post:
|
|
308
|
+
List-Unsubscribe=One-Click`, so the mail client's own Unsubscribe button
|
|
309
|
+
works, plus a "Stop …" link at the bottom that opens a page asking first
|
|
310
|
+
(opening a link never unsubscribes — inbox scanners open links). The token
|
|
311
|
+
is signed by the install, names one address, one category and — for run
|
|
312
|
+
alerts and approvals — one workspace, and works for 180 days. No login is
|
|
313
|
+
needed, and the address need not belong to anyone with one: a `notify:
|
|
314
|
+
email` that reaches a shared inbox can be unsubscribed by whoever reads it.
|
|
315
|
+
A choice made this way shows on that person's Profile → Notifications, and
|
|
316
|
+
is turned back on there.
|
|
317
|
+
|
|
318
|
+
The links need the install to know its public address
|
|
319
|
+
(`FOLDRUN_PUBLIC_URL`); without it mail goes out without them, and
|
|
320
|
+
preferences are still honoured.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Providers, by name
|
|
2
|
+
|
|
3
|
+
What `provider: { name: … }` resolves to. Mirrors `foldrun-core/src/providers.ts`,
|
|
4
|
+
checked against each provider's own documentation on 2026-09-02 and again on
|
|
5
|
+
2026-09-06; the table below is what the runtime believes, and
|
|
6
|
+
`tests/providers.test.ts` fails if a name exists in the code and not here.
|
|
7
|
+
|
|
8
|
+
**Three formats.** `anthropic` endpoints are spoken to directly. `openai`
|
|
9
|
+
endpoints (Chat Completions) and `responses` endpoints (OpenAI's newer
|
|
10
|
+
Responses API) are reached through the runtime's own translator, a loopback
|
|
11
|
+
server inside the run sandbox that lives for one step — the same server,
|
|
12
|
+
a second pair of mappings. No preset defaults to `responses`; a block asks
|
|
13
|
+
for it by name (`name: openai` + `format: responses`). **Verified**
|
|
14
|
+
means a tool loop was actually driven through the endpoint from this
|
|
15
|
+
runtime. A name without it is **documented, not proven**: the address, wire
|
|
16
|
+
format and header shape were read from the provider's own documentation and
|
|
17
|
+
nobody here has watched an agent complete a run against it. Run
|
|
18
|
+
`foldrun probe <model>` before a flow depends on one.
|
|
19
|
+
|
|
20
|
+
Today that is **3 of 29**: only `anthropic`, `openrouter` and `openai` have
|
|
21
|
+
ever run an agent from this runtime. Those counts and every `verified` mark
|
|
22
|
+
in the table are checked against the code by `tests/providers.test.ts` — the
|
|
23
|
+
figure above went stale once already, which is exactly the kind of claim
|
|
24
|
+
that should not be maintained by hand.
|
|
25
|
+
|
|
26
|
+
`params:` on the provider block reaches only `openai`- and `responses`-format
|
|
27
|
+
endpoints, where the translator builds the request. An `anthropic`-shaped endpoint is spoken to
|
|
28
|
+
directly by the SDK, so its knobs are set at the provider (an OpenRouter
|
|
29
|
+
preset, say); the file warns if you try.
|
|
30
|
+
|
|
31
|
+
| name | provider | format | base URL | key header | verified | note |
|
|
32
|
+
|---|---|---|---|---|---|---|
|
|
33
|
+
| `anthropic` | Anthropic | anthropic | `https://api.anthropic.com` | x-api-key | yes | Models newer than Opus 4.6 reject `top_k` with a 400; the runtime never sends it unless a `params:` block does. |
|
|
34
|
+
| `openrouter` | OpenRouter | anthropic | `https://openrouter.ai/api` | bearer | yes | Hundreds of models behind one key. Its own docs disagree on how well non-Anthropic models hold a tool loop on this endpoint — probe the model you mean to use. |
|
|
35
|
+
| `deepseek` | DeepSeek | anthropic | `https://api.deepseek.com/anthropic` | x-api-key | | Ignores top_k, cache_control and thinking budgets; Claude model names are remapped to DeepSeek's. |
|
|
36
|
+
| `kimi` | Moonshot Kimi | anthropic | `https://api.moonshot.ai/anthropic` | bearer | | Own model ids only. Known bug (2026-09): K3 reuses one `tool_use` id across separate calls, which breaks a tool loop. |
|
|
37
|
+
| `moonshot` | Moonshot Kimi | anthropic | `https://api.moonshot.ai/anthropic` | bearer | | Same endpoint as `kimi`. |
|
|
38
|
+
| `zai` | z.ai (GLM) | anthropic | `https://api.z.ai/api/anthropic` | bearer | | |
|
|
39
|
+
| `minimax` | MiniMax | anthropic | `https://api.minimax.io/anthropic` | bearer | | |
|
|
40
|
+
| `qwen` | Alibaba Qwen (Model Studio) | anthropic | `(per plan — set base_url)` | x-api-key | | base_url depends on the plan: pay-as-you-go `https://<workspace>.<region>.maas.aliyuncs.com/apps/anthropic`; Coding Plan `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic`. Own model ids, no remap; no `reasoning_effort`, use thinking. |
|
|
41
|
+
| `fireworks` | Fireworks AI | anthropic | `https://api.fireworks.ai/inference` | bearer | | No server-side tools; no adaptive thinking. |
|
|
42
|
+
| `deepinfra` | DeepInfra | anthropic | `https://api.deepinfra.com/anthropic` | bearer | | |
|
|
43
|
+
| `sambanova` | SambaNova | anthropic | `https://api.sambanova.ai` | x-api-key | | No server-side tools, base64 images only. |
|
|
44
|
+
| `vercel` | Vercel AI Gateway | anthropic | `https://ai-gateway.vercel.sh` | bearer | | Model ids are namespaced, e.g. openai/gpt-5. |
|
|
45
|
+
| `litellm` | LiteLLM (yours) | anthropic | `(per account — set base_url)` | x-api-key | | base_url is your proxy, e.g. http://litellm.internal:4000. It presents an Anthropic endpoint and speaks anything behind it. |
|
|
46
|
+
| `cloudflare-gateway` | Cloudflare AI Gateway | anthropic | `(per account — set base_url)` | x-api-key | | base_url is https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic — a pass-through to Anthropic with logging; for Cloudflare's own models use name: cloudflare. |
|
|
47
|
+
| `ollama` | Ollama (local) | anthropic | `http://localhost:11434` | x-api-key | | v0.14+ (2026-01). Ignores `tool_choice` and `cache_control`; accepts but does not enforce thinking budgets; base64 images only. Any key value is accepted. |
|
|
48
|
+
| `lmstudio` | LM Studio (local) | anthropic | `http://localhost:1234` | x-api-key | | |
|
|
49
|
+
| `vllm` | vLLM (yours) | anthropic | `(per account — set base_url)` | bearer | | base_url is your server (default :8000). Start it with `--enable-auto-tool-choice` and a `--tool-call-parser` or no tool loop closes. |
|
|
50
|
+
| `openai` | OpenAI | openai | `https://api.openai.com/v1` | bearer | yes | Chat Completions by default, which OpenAI keeps supporting indefinitely. Add `format: responses` for the Responses API ("recommended for all new projects"): PDFs cross as input files and `reasoning.effort` is native; still every turn carries the whole conversation. |
|
|
51
|
+
| `gemini` | Google Gemini | openai | `https://generativelanguage.googleapis.com/v1beta/openai` | bearer | | Google's OpenAI-compatible route. Unknown parameters are ignored silently; reasoning cannot be switched off on the newest models. |
|
|
52
|
+
| `xai` | xAI Grok | openai | `https://api.x.ai/v1` | bearer | | Sends `max_completion_tokens` and `reasoning_effort`. |
|
|
53
|
+
| `groq` | Groq | openai | `https://api.groq.com/openai/v1` | bearer | | Sends `max_completion_tokens` and `reasoning_effort`. |
|
|
54
|
+
| `mistral` | Mistral | openai | `https://api.mistral.ai/v1` | bearer | | |
|
|
55
|
+
| `together` | Together AI | openai | `https://api.together.ai/v1` | bearer | | |
|
|
56
|
+
| `cerebras` | Cerebras | openai | `https://api.cerebras.ai/v1` | bearer | | Sends `max_completion_tokens` and `reasoning_effort`. |
|
|
57
|
+
| `huggingface` | Hugging Face | openai | `https://router.huggingface.co/v1` | bearer | | The Inference Providers router; model ids are Hub ids, e.g. meta-llama/Llama-3.3-70B-Instruct. |
|
|
58
|
+
| `cloudflare` | Cloudflare Workers AI | openai | `(per account — set base_url)` | bearer | | base_url is https://api.cloudflare.com/client/v4/accounts/<account>/ai/v1 |
|
|
59
|
+
| `nebius` | Nebius Token Factory | openai | `https://api.tokenfactory.nebius.com/v1` | bearer | | |
|
|
60
|
+
| `novita` | Novita | openai | `https://api.novita.ai/openai` | bearer | | Also has an Anthropic-shaped route at `https://api.novita.ai/anthropic` — spell it out with `format: anthropic` to skip the translator (header shape unverified). |
|
|
61
|
+
| `hyperbolic` | Hyperbolic | openai | `https://api.hyperbolic.xyz/v1` | bearer | | |
|
|
62
|
+
|
|
63
|
+
**Not on the list, and why.** Vertex AI puts the model in the URL, so a base
|
|
64
|
+
URL alone cannot reach it; Bedrock's legacy InvokeModel streams AWS events, not
|
|
65
|
+
SSE — both are enterprise arrangements with their own gateways. xAI documents
|
|
66
|
+
no Anthropic-shaped route (an earlier note that it was deprecated traced only
|
|
67
|
+
to an unofficial mirror), so xAI is reached as `openai`. GitHub Models retired
|
|
68
|
+
on 2026-07-30. OpenAI's Assistants API sunset on 2026-08-26 and was never
|
|
69
|
+
targeted. Perplexity was removed on 2026-09-06: its Sonar chat route sunsets
|
|
70
|
+
on 2026-09-27 ("Sonar will be supported until September 27, 2026") and the
|
|
71
|
+
successor Agent API is not Chat-Completions-shaped, so there is nothing to
|
|
72
|
+
spell out for it.
|
|
73
|
+
|
|
74
|
+
**Chat Completions vs Responses.** Every non-OpenAI vendor in the `openai`
|
|
75
|
+
rows speaks Chat Completions and none has retired it — OpenAI says it will
|
|
76
|
+
support Chat Completions indefinitely; what it retires is the Assistants API
|
|
77
|
+
(26 August 2026). Chat Completions stays the translation that reaches
|
|
78
|
+
everyone. `format: responses` (added 2026-09-07) speaks the Responses API for
|
|
79
|
+
the endpoints that have it — OpenAI, and xAI, Groq, Nebius, Cloudflare in
|
|
80
|
+
various states of beta. What it carries that Chat Completions cannot: a
|
|
81
|
+
`document` block crosses as an `input_file`, so a PDF an agent reads reaches
|
|
82
|
+
the model instead of being dropped; `reasoning.effort` is the native knob;
|
|
83
|
+
`store: false` is sent so nothing is kept on the provider's side. What it
|
|
84
|
+
does not do: OpenAI's server-side tools (web search, file search, code
|
|
85
|
+
interpreter) are still dropped — tools run on the box for every model —
|
|
86
|
+
and `previous_response_id` is not used, so every turn carries the whole
|
|
87
|
+
conversation, as it does everywhere else. Mapped against the shapes in
|
|
88
|
+
OpenAI's own SDK (`openai@6`, `resources/responses`) and driven live on
|
|
89
|
+
2026-09-07 through OpenRouter's Responses route (`base_url:
|
|
90
|
+
https://openrouter.ai/api/v1`, `format: responses`): `foldrun probe
|
|
91
|
+
openai/gpt-4o-mini` closed a streamed tool loop — tool call made, result
|
|
92
|
+
read back, three requests, $0.0057. Then against OpenAI's own endpoint
|
|
93
|
+
(`name: openai`, `format: responses`, no `base_url`) the same day:
|
|
94
|
+
`gpt-4o-mini` closed the loop for $0.0059 and `gpt-5-mini` for $0.0311. The
|
|
95
|
+
origin found two things the gateway had hidden: `safety_identifier` is
|
|
96
|
+
capped at 64 characters, so a long tenant id is now sent hashed; and a
|
|
97
|
+
model that does not reason (`gpt-4*`, `gpt-3*`, `chatgpt-*`, `gpt-5-chat-*`)
|
|
98
|
+
refuses `reasoning.effort` outright — the translator leaves the knob off for
|
|
99
|
+
those families on both wires and says so in the drop line. Every other id
|
|
100
|
+
keeps it, because a gateway ignores a field it cannot use and an o-series,
|
|
101
|
+
gpt-5, grok or deepseek-r model wants it.
|
|
102
|
+
|
|
103
|
+
## Why a turn stopped
|
|
104
|
+
|
|
105
|
+
Every model reply ends with a stop reason, and the model loop acts on it: run
|
|
106
|
+
the tool, carry on, or stop. An Anthropic-shaped endpoint sends its own. For a
|
|
107
|
+
translated one the translator reads what the provider said and gives the
|
|
108
|
+
closest Anthropic word, because the loop only understands those:
|
|
109
|
+
|
|
110
|
+
| stop reason | Chat Completions (`format: openai`) | Responses (`format: responses`) |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `end_turn` | `stop`, or anything unrecognised | a completed response |
|
|
113
|
+
| `max_tokens` | `length` | `incomplete_details.reason: max_output_tokens` |
|
|
114
|
+
| `tool_use` | `tool_calls`, `function_call`, or any reply carrying a tool call | any reply carrying a function call |
|
|
115
|
+
| `stop_sequence` | `stop` plus the matched string — vLLM's `stop_reason`, SGLang's `matched_stop` — when it is one of the request's `stop_sequences` | never: the Responses API has no stop sequences, and they are dropped |
|
|
116
|
+
| `refusal` | `content_filter` (OpenAI, Azure, Gemini's safety block) | a `refusal` content part, or `incomplete_details.reason: content_filter` |
|
|
117
|
+
| `model_context_window_exceeded` | Mistral's `model_length` | never: a full context is a 400 there, and the error crosses as one |
|
|
118
|
+
| `pause_turn` | never | never — it belongs to Anthropic's server-side tools, which the translator drops |
|
|
119
|
+
|
|
120
|
+
A cut-off reply beats a tool call: a `length` finish with half a tool call
|
|
121
|
+
in it reads as `max_tokens`, so the loop does not run a truncated call. A
|
|
122
|
+
refusal beats everything. Plain OpenAI says `stop` whether the model finished
|
|
123
|
+
or hit a stop sequence, so on OpenAI itself a stop sequence reads as
|
|
124
|
+
`end_turn`.
|
|
125
|
+
|
|
126
|
+
`foldrun probe` prints the stop reason the model ended on. A healthy probe
|
|
127
|
+
says `end_turn`; `max_tokens`, `refusal` or `model_context_window_exceeded`
|
|
128
|
+
there names the failure a run would hit the same way.
|
|
129
|
+
## Verified 2026-09-02
|
|
130
|
+
|
|
131
|
+
The translator itself was driven end to end from `foldrun probe` against
|
|
132
|
+
OpenRouter's Chat-Completions route (`format: openai`,
|
|
133
|
+
`base_url: https://openrouter.ai/api/v1`) — the one OpenAI-shaped endpoint we
|
|
134
|
+
hold a key for. A tool loop closed, streamed, on:
|
|
135
|
+
|
|
136
|
+
| model | tool call | result read back | cost |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| `openai/gpt-4o-mini` | yes | yes | $0.0058 |
|
|
139
|
+
| `meta-llama/llama-3.3-70b-instruct` | yes | yes | $0.0073 |
|
|
140
|
+
|
|
141
|
+
`google/gemini-2.5-flash` was refused with a 402 by OpenRouter — the key's
|
|
142
|
+
weekly limit could not reserve the 32,000 `max_tokens` the model loop asks
|
|
143
|
+
for — and that refusal arrived as a well-formed Anthropic error with the
|
|
144
|
+
provider's own words, which is the pass-through working. Gemini's own
|
|
145
|
+
endpoint (`name: gemini`) awaits a Google key.
|
|
146
|
+
|
|
147
|
+
## Is the key still good?
|
|
148
|
+
|
|
149
|
+
A key can be revoked, expire, run out of credit or have its endpoint moved,
|
|
150
|
+
and the platform used to find that out the way you would: a desk failing at
|
|
151
|
+
five in the morning.
|
|
152
|
+
|
|
153
|
+
Once a day, every provider block this account's files actually use — the
|
|
154
|
+
account's, each workspace's, each agent's — is asked one minimal question:
|
|
155
|
+
a single request, one token of output, at the endpoint a run would use, with
|
|
156
|
+
the key a run would send. It costs a fraction of a cent, and a year of it
|
|
157
|
+
costs less than one ordinary run. Blocks sharing an endpoint and a key are
|
|
158
|
+
checked once, because they are one credential.
|
|
159
|
+
|
|
160
|
+
This is not `foldrun probe`. That asks whether a model can drive an agent,
|
|
161
|
+
which is a question about a model and is asked once. This asks whether an
|
|
162
|
+
endpoint still takes your key, which is a question about a credential and
|
|
163
|
+
has to be asked over and over — so it has to be cheap.
|
|
164
|
+
|
|
165
|
+
| verdict | what it means |
|
|
166
|
+
|---|---|
|
|
167
|
+
| ok | the key was accepted and the model answered |
|
|
168
|
+
| busy | rate limited, which means the key is fine — never reported as a fault |
|
|
169
|
+
| credential | 401, 402 or 403: revoked, rotated, wrong for this endpoint, or out of credit |
|
|
170
|
+
| not-found | the endpoint or the model id does not exist there — check `base_url` and `models:` |
|
|
171
|
+
| unreachable | no answer, or a `base_url` that is not a URL |
|
|
172
|
+
| provider-error | failing at their end |
|
|
173
|
+
|
|
174
|
+
Only a newly broken provider is reported. A key that was dead yesterday and
|
|
175
|
+
is dead today is not news, and an alert that arrives every morning is an
|
|
176
|
+
alert people filter. `GET /api/account/providers` shows the last check and
|
|
177
|
+
what would be checked; `POST` forces one, for the minute after rotating a
|
|
178
|
+
key. From the terminal, `foldrun account providers` prints the same table
|
|
179
|
+
(✓, ✗ with the verdict, or · for never checked) and `--check` forces one;
|
|
180
|
+
a dead key is a non-zero exit, so a deploy script can refuse to continue.
|
|
181
|
+
|
|
182
|
+
## When a provider says no, the run says why
|
|
183
|
+
|
|
184
|
+
A refused model call used to leave two facts in the record and neither was
|
|
185
|
+
the reason:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
egress: POST api.anthropic.com/v1/messages → 401 × 4 [FOLDRUN_MODEL_KEY]
|
|
189
|
+
API Error: 402 This request would exceed your available credits
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
That is a status code and a sentence written by an SDK. The actual cause —
|
|
193
|
+
the subscription's five-hour window part used, with overage switched off for
|
|
194
|
+
lack of credits, a state that clears by itself at a known minute — was in
|
|
195
|
+
the response headers all along, and nothing read them.
|
|
196
|
+
|
|
197
|
+
Now it does. On a refusal (401, 402, 403, 429) foldrun keeps the
|
|
198
|
+
`anthropic-ratelimit-unified-*` headers and `retry-after` from that one
|
|
199
|
+
response, and writes one line into the step's trace and onto the step's
|
|
200
|
+
failure line — the line the run record shows, the desks' headlines quote and
|
|
201
|
+
`foldrun report` reads:
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
the subscription refused this burst — the 5-hour window is 35% used but
|
|
205
|
+
overage is off (out of credits); it resets at 14:00 Sydney — no second
|
|
206
|
+
supply is configured
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Reset times are told in the step's own timezone, resolved by the usual
|
|
210
|
+
cascade (agent → flow → workspace → account → `FOLDRUN_TIMEZONE` → UTC), so
|
|
211
|
+
a run scheduled in Sydney is not told when its quota returns in UTC.
|
|
212
|
+
|
|
213
|
+
Three things the line is careful about:
|
|
214
|
+
|
|
215
|
+
**A spent window is not a dead key.** A refusal that carries rate-limit
|
|
216
|
+
headers is the supply — wait, or add credits. A 401 or 403 carrying *no*
|
|
217
|
+
rate-limit headers at all is the credential itself, and says so:
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
the credential was refused (HTTP 401) and the response carried no
|
|
221
|
+
rate-limit headers, so this is the key itself — invalid, revoked, or wrong
|
|
222
|
+
for this endpoint — not a spent window
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The two used to read identically, which is how an hour goes into proving a
|
|
226
|
+
valid key was valid.
|
|
227
|
+
|
|
228
|
+
**A wait is a number.** A 429 that named `retry-after` says how long — `and
|
|
229
|
+
asked us to wait 30s` — and one that named nothing says that instead of
|
|
230
|
+
implying a wait nobody promised.
|
|
231
|
+
|
|
232
|
+
**Where the step can go next is part of the sentence.** Every refusal line
|
|
233
|
+
ends with one of three states: `trying the second supply`, `the second
|
|
234
|
+
supply refused it too`, or `no second supply is configured`. The last is a
|
|
235
|
+
deliberate arrangement, not a gap — a workspace with one provider and no
|
|
236
|
+
fallback is a normal thing to run — and it is said out loud so a step never
|
|
237
|
+
ends in silence over it.
|
|
238
|
+
|
|
239
|
+
Nothing else from the response's headers is kept. The list is an allow-list
|
|
240
|
+
of the rate-limit headers above, not a prefix match, and no request header —
|
|
241
|
+
so no credential — is read at all.
|