foldrun 0.6.0 → 0.8.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.
@@ -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.