@gr8ful/spf 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -194,6 +194,38 @@ That's the whole config change. `ollama` is a keyless provider — `spf doctor`
194
194
 
195
195
  Two things worth knowing before pointing a full roster at local models: tool-calling — the injected `sf_report` contract every agent's structured output rides on — worked reliably in testing down to a 3B-parameter model, which isn't a "only frontier models get tools" situation. And context-window occupancy reporting is disabled for `ollama/*` models specifically, which turns off threshold-based compaction rather than reporting a number Ollama's OpenAI-compatible API doesn't actually provide per model.
196
196
 
197
+ ### flue + Cloudflare Workers AI
198
+
199
+ Point the default `flue` backend at a [Cloudflare Workers AI](https://developers.cloudflare.com/ai/) model the same way you'd pick any other Flue provider — the model string's own prefix, `cloudflare/<model-id>`, where `<model-id>` is the full `@cf/...` catalog id (the slash inside the id is fine; spf splits on the first slash only):
200
+
201
+ ```yaml
202
+ defaults:
203
+ coding_agent: flue
204
+ model: cloudflare/@cf/zai-org/glm-5.3-flash
205
+ ```
206
+
207
+ ```bash
208
+ export CLOUDFLARE_ACCOUNT_ID=your-account-id # derives https://api.cloudflare.com/client/v4/accounts/$ID/ai/v1
209
+ export CLOUDFLARE_API_TOKEN=your-token # Workers AI > Read permission
210
+ spf build "your prompt"
211
+ ```
212
+
213
+ That's the whole config change. `cloudflare` is a keyed provider (a real Bearer token, unlike keyless `ollama` — Cloudflare's API 401s on an empty Authorization header). `spf init` asks for the token plus either `CLOUDFLARE_ACCOUNT_ID` (the standard endpoint) or an explicit `CLOUDFLARE_AI_BASE_URL` (a custom domain or an AI Gateway endpoint), and overrides the three packaged-roster agents that a Workers AI account can't serve. `spf doctor` probes the resolved endpoint (informational, never a hard failure). A ready-to-run starting point ships at [`assets/templates/ts-flue-cloudflare.spf.config.yaml`](assets/templates/ts-flue-cloudflare.spf.config.yaml) (`spf init --template ts-flue-cloudflare`).
214
+
215
+ Two things worth knowing, both inherited from the `ollama/*` convention for self-served OpenAI-compatible endpoints: context-window occupancy reporting is disabled for `cloudflare/*` models (`contextWindow: 0`, turning off threshold-based compaction rather than fabricating a number Workers AI's API doesn't provide per model), and `reasoning: false` — the model's own `reasoning_effort` parameter is not wired through, so an agent's `thinking:` level is moot for `cloudflare/*` models today.
216
+
217
+ ### claude_code + Cloudflare AI Gateway
218
+
219
+ To route the `claude_code` backend through a [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/) (so the gateway holds the Anthropic credentials via Unified Billing or BYOK, and you authenticate to the gateway), pick **Cloudflare AI Gateway** at the `Authentication` step in `spf init`. spf writes the three env vars the gateway needs:
220
+
221
+ ```bash
222
+ ANTHROPIC_BASE_URL=https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/<GATEWAY_ID>/anthropic
223
+ ANTHROPIC_API_KEY=<CF_AIG_TOKEN> # any value — the gateway ignores it; Claude Code requires it set
224
+ ANTHROPIC_CUSTOM_HEADERS=cf-aig-authorization: Bearer <CF_AIG_TOKEN>
225
+ ```
226
+
227
+ `spf doctor` probes the gateway's `/v1/messages` with the `cf-aig-authorization` header (informational, never a hard failure). This is the `claude_code` backend — it speaks the Anthropic API, so it's independent of the Workers AI `flue` provider above; you can run some agents on Workers AI (`flue`) and others through the gateway (`claude_code`) in the same roster.
228
+
197
229
  ---
198
230
 
199
231
  ## Isolation: post-hoc, not a sandbox
@@ -0,0 +1,61 @@
1
+ # .spf/spf.config.yaml — Flue backend (the default), routed at Cloudflare
2
+ # Workers AI instead of a hosted provider. `spf init --template
3
+ # ts-flue-cloudflare` writes this file as-is.
4
+ #
5
+ # model: is ALWAYS provider/model-id for Flue — "cloudflare/<id>", where
6
+ # <id> is the full @cf/... catalog id from developers.cloudflare.com/ai/models
7
+ # (agent_flue.ts registers each distinct "cloudflare/<id>" it sees with
8
+ # Flue's own provider registry the first time it's dispatched — no separate
9
+ # model catalog to keep in sync here). The slash INSIDE @cf/... is fine: spf
10
+ # splits on the first slash only, so "cloudflare/@cf/zai-org/glm-5.3-flash"
11
+ # is provider "cloudflare", model id "@cf/zai-org/glm-5.3-flash". agents.
12
+ # validate() only checks the STRING SHAPE (provider/id) — never that the id
13
+ # actually exists in your account. A wrong or unavailable id only surfaces at
14
+ # the first real dispatch, as an error from Workers AI itself, not at
15
+ # validate() time.
16
+ #
17
+ # cloudflare is a KEYED provider (providers.ts's PROVIDER_ENV_KEYS.cloudflare
18
+ # is ["CLOUDFLARE_API_TOKEN"]) — a real Bearer token, unlike keyless ollama
19
+ # (Cloudflare's API 401s on an empty Authorization header). What's required:
20
+ # export CLOUDFLARE_API_TOKEN=... # Workers AI > Read permission
21
+ # export CLOUDFLARE_ACCOUNT_ID=... # derives https://api.cloudflare.com/client/v4/accounts/$ID/ai/v1
22
+ # (or set CLOUDFLARE_AI_BASE_URL to override the endpoint — a custom domain or
23
+ # a Cloudflare AI Gateway URL.) `spf doctor` probes the resolved endpoint
24
+ # (informational, never a hard failure) so a misconfigured account id shows up
25
+ # before your first real run does, not during it.
26
+ #
27
+ # Context-window occupancy reporting is disabled for every cloudflare/*
28
+ # model (contextWindow: 0, the same convention as ollama/*), which turns off
29
+ # threshold-based compaction rather than reporting a fabricated number
30
+ # Workers AI's OpenAI-compatible API doesn't provide per-model. reasoning is
31
+ # also false: the model's own reasoning_effort is not wired through Workers
32
+ # AI's OpenAI-compatible surface, so an agent's thinking: level is moot.
33
+ quality:
34
+ checks:
35
+ - { name: typecheck, operation: typecheck, argv: ["npm", "run", "typecheck"], timeout_seconds: 60 }
36
+ - { name: lint, operation: lint, argv: ["npm", "run", "lint"], timeout_seconds: 60 }
37
+ - { name: build, operation: build, argv: ["npm", "run", "build"], timeout_seconds: 300 }
38
+ - { name: test, operation: build, argv: ["npm", "test"], timeout_seconds: 300 }
39
+ suites:
40
+ test: [test]
41
+ all: [typecheck, lint, build, test]
42
+
43
+ defaults:
44
+ coding_agent: flue
45
+ # EXAMPLE — replace with a heavier reasoning model from your own account's
46
+ # available list (developers.cloudflare.com/ai/models).
47
+ model: cloudflare/@cf/zai-org/glm-5.3-flash
48
+
49
+ # Overridden here (same rationale as the ollama template): the packaged
50
+ # roster's planner/reviewer/documenter pin their own fireworks/openai
51
+ # model ids, which a Workers AI account CANNOT serve — left alone, those
52
+ # three would route at providers whose keys this template never asks for.
53
+ # Overridden so every agent actually demonstrates Workers AI routing.
54
+ agents:
55
+ - name: planner
56
+ model: cloudflare/@cf/zai-org/glm-5.3-flash
57
+ - name: reviewer
58
+ model: cloudflare/@cf/zai-org/glm-5.3-flash
59
+ - name: documenter
60
+ # EXAMPLE — replace with a lighter/faster model from your account.
61
+ model: cloudflare/@cf/meta/llama-3.1-8b-instruct
@@ -17,6 +17,7 @@ import { DEFAULT_NOTIFY_ENV_KEY } from "../../core/notify/notifier.js";
17
17
  import { endpointLabel, redact, resolveTracesUrl } from "../../core/otel.js";
18
18
  import { isKnownToolName as isKnownFlueToolName, resolveModel } from "../../core/agent_flue.js";
19
19
  import { ollamaBaseUrl } from "../../core/ollama_provider.js";
20
+ import { cloudflareAiBaseUrl } from "../../core/cloudflare_provider.js";
20
21
  import { binaryOnPath, parseCli } from "../../core/utils.js";
21
22
  import { PROVIDER_ENV_KEYS } from "../../core/providers.js";
22
23
  import { probeServedOllamaTags, resolveTiering } from "../../core/tiering.js";
@@ -95,11 +96,11 @@ function repoChainLabel(source) {
95
96
  }
96
97
  const PROBE_TIMEOUT_MS = 3_000;
97
98
  /** A GET that never throws — a down/unreachable server is a finding to report, never a crash of `spf doctor` itself. */
98
- async function probeGet(url) {
99
+ async function probeGet(url, headers) {
99
100
  const controller = new AbortController();
100
101
  const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
101
102
  try {
102
- const res = await fetch(url, { signal: controller.signal });
103
+ const res = await fetch(url, { signal: controller.signal, headers });
103
104
  return { ok: true, status: res.status };
104
105
  }
105
106
  catch (error) {
@@ -167,12 +168,26 @@ async function probeAnthropicMessages(base, model) {
167
168
  // though the probe itself never authenticated. Harmless for Ollama,
168
169
  // which ignores the header entirely (see the module doc above).
169
170
  const apiKey = process.env["ANTHROPIC_AUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"];
171
+ // ANTHROPIC_CUSTOM_HEADERS (Claude Code's own escape hatch) carries the
172
+ // Cloudflare AI Gateway's `cf-aig-authorization: Bearer <token>` — without
173
+ // it, a gateway probe 401s even though the gateway is up. Parsed the
174
+ // same loose way Claude Code parses it: one `Name: value` per line, so a
175
+ // single header (the common case) needs no newline. Empty/comment-only
176
+ // lines are skipped, never crash the probe.
177
+ const customHeaders = {};
178
+ for (const line of (process.env["ANTHROPIC_CUSTOM_HEADERS"] ?? "").split(/\r?\n/)) {
179
+ const colon = line.indexOf(":");
180
+ if (colon <= 0)
181
+ continue;
182
+ customHeaders[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
183
+ }
170
184
  const res = await fetch(`${base}/v1/messages`, {
171
185
  method: "POST",
172
186
  headers: {
173
187
  "content-type": "application/json",
174
188
  "anthropic-version": "2023-06-01",
175
189
  ...(apiKey ? { "x-api-key": apiKey } : {}),
190
+ ...customHeaders,
176
191
  },
177
192
  body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: "user", content: "ping" }] }),
178
193
  signal: controller.signal,
@@ -381,6 +396,36 @@ export async function doctorCommand(argv) {
381
396
  check(report, "OLLAMA_BASE_URL reachability", true, // informational/warning only — see the ANTHROPIC_BASE_URL check above for why
382
397
  result.ok ? `reachable: GET ${ollamaBase}/models -> HTTP ${result.status}` : `unreachable: GET ${ollamaBase}/models -> ${result.error}`, result.ok ? "info" : "warn");
383
398
  }
399
+ // Cloudflare Workers AI — the same reachability check the Ollama block
400
+ // above runs, for the same reason: a keyed provider whose endpoint
401
+ // (derived from CLOUDFLARE_ACCOUNT_ID, or overridden by
402
+ // CLOUDFLARE_AI_BASE_URL) might simply be misconfigured. Unlike Ollama,
403
+ // Workers AI needs the Bearer token to answer at all (a 401 without it),
404
+ // so the probe sends `Authorization: Bearer $CLOUDFLARE_API_TOKEN` when
405
+ // set. `/models` is the OpenAI-compatible list endpoint (same shape the
406
+ // Ollama probe hits); a 404 here still means "host up, path differs",
407
+ // which is the useful signal, and the check is informational only — same
408
+ // never-a-hard-failure contract as the Ollama and ANTHROPIC_BASE_URL
409
+ // probes. Gated on tiering.enabled for ladder rungs, identically to
410
+ // usesOllamaFlue above.
411
+ const usesCloudflareFlue = cfg.agents.some((a) => a.coding_agent !== "claude_code" && a.model.startsWith("cloudflare/")) ||
412
+ (cfg.tiering.enabled && cfg.tiering.tiers.some((t) => t.coding_agent !== "claude_code" && t.model.startsWith("cloudflare/")));
413
+ if (usesCloudflareFlue && !flags["no-probe"]) {
414
+ const cfBase = cloudflareAiBaseUrl();
415
+ if (!cfBase) {
416
+ check(report, "Cloudflare Workers AI endpoint", true, "CLOUDFLARE_ACCOUNT_ID (or CLOUDFLARE_AI_BASE_URL) is not set — Workers AI base URL can't be resolved", "warn");
417
+ }
418
+ else {
419
+ const cfBaseTrimmed = cfBase.replace(/\/+$/, "");
420
+ const token = process.env["CLOUDFLARE_API_TOKEN"];
421
+ const headers = token ? { authorization: `Bearer ${token}` } : {};
422
+ const result = await withProbeStatus("Cloudflare Workers AI reachability", () => probeGet(`${cfBaseTrimmed}/models`, headers));
423
+ check(report, "Cloudflare Workers AI reachability", true, // informational/warning only — same contract as the Ollama check above
424
+ result.ok
425
+ ? `reachable: GET ${cfBaseTrimmed}/models -> HTTP ${result.status}${token ? "" : " (no CLOUDFLARE_API_TOKEN — sent unauthenticated)"}`
426
+ : `unreachable: GET ${cfBaseTrimmed}/models -> ${result.error}`, result.ok ? "info" : "warn");
427
+ }
428
+ }
384
429
  for (const agent of cfg.agents) {
385
430
  const label = `agent "${agent.name}"`;
386
431
  if (agent.coding_agent === "claude_code") {
@@ -141,6 +141,7 @@ export async function runInterview(asker, ctx) {
141
141
  const auth = await asker.select("Authentication", [
142
142
  { value: "login", label: "already logged in via `claude login`", hint: "writes nothing" },
143
143
  { value: "key", label: "ANTHROPIC_API_KEY" },
144
+ { value: "aig", label: "Cloudflare AI Gateway (Anthropic endpoint)" },
144
145
  { value: "endpoint", label: "custom endpoint (Ollama / Ollama Cloud / a proxy)" },
145
146
  ], "login");
146
147
  if (auth === "key") {
@@ -158,6 +159,32 @@ export async function runInterview(asker, ctx) {
158
159
  envExampleKeys.push("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN");
159
160
  asker.note("spf doctor now probes ANTHROPIC_BASE_URL with a minimal POST /v1/messages (informational only, never a hard failure) and flags a base URL that already ends in \"/v1\" as a likely double-path mistake — the default above (without a trailing /v1) is exactly what that check is guarding against. It still cannot validate ANTHROPIC_AUTH_TOKEN itself.");
160
161
  }
162
+ else if (auth === "aig") {
163
+ // Cloudflare AI Gateway's Anthropic endpoint — routes Claude Code's
164
+ // traffic through a gateway that holds the real Anthropic credentials
165
+ // (Unified Billing or BYOK), so the operator authenticates to the
166
+ // GATEWAY, not to Anthropic. The exact env shape comes straight from
167
+ // developers.cloudflare.com/ai-gateway/integrations/coding-agents/claude-code:
168
+ // ANTHROPIC_BASE_URL = https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/<GATEWAY_ID>/anthropic
169
+ // ANTHROPIC_API_KEY = <CF_AIG_TOKEN> (any value — Claude Code requires it set; the gateway ignores it)
170
+ // ANTHROPIC_CUSTOM_HEADERS = cf-aig-authorization: Bearer <CF_AIG_TOKEN>
171
+ // The gateway token must have Run permission on the gateway.
172
+ asker.note("Create the gateway in the Cloudflare dashboard (AI Gateway), then use its token with Run permission. The gateway holds the Anthropic credentials (Unified Billing / BYOK); this token authenticates YOU to the gateway.");
173
+ const baseUrl = await asker.text("ANTHROPIC_BASE_URL", {
174
+ default: "https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/<GATEWAY_ID>/anthropic",
175
+ validate: (v) => (v.trim() && !v.includes("<") && v.endsWith("/anthropic") ? null : "must be the gateway's /anthropic endpoint — https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/<GATEWAY_ID>/anthropic"),
176
+ });
177
+ env["ANTHROPIC_BASE_URL"] = baseUrl;
178
+ const token = await asker.secret("CF_AIG_TOKEN (gateway token)", { current: ctx.existingEnv.get("ANTHROPIC_API_KEY") });
179
+ const tok = token || "replace-me";
180
+ // Claude Code requires ANTHROPIC_API_KEY to be set even when the
181
+ // gateway ignores it — the CF doc reuses the gateway token as the
182
+ // placeholder. The real auth rides on the custom header below.
183
+ env["ANTHROPIC_API_KEY"] = tok;
184
+ env["ANTHROPIC_CUSTOM_HEADERS"] = `cf-aig-authorization: Bearer ${tok}`;
185
+ envExampleKeys.push("ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_CUSTOM_HEADERS");
186
+ asker.note("spf doctor probes this gateway's /v1/messages with the cf-aig-authorization header (informational, never a hard failure).");
187
+ }
161
188
  // The packaged roster pins planner/reviewer/documenter to their own
162
189
  // Flue-style provider/model-id strings, which always win over
163
190
  // defaults.model (agents.ts's back-fill only applies when an agent
@@ -223,6 +250,46 @@ export async function runInterview(asker, ctx) {
223
250
  notes.push("planner/reviewer/documenter pin their own model in the packaged roster and always win over defaults.model — overriding all three to the ollama model chosen above, or spf doctor would report missing FIREWORKS_API_KEY/GEMINI_API_KEY/OPENAI_API_KEY for providers this flow never asked about.");
224
251
  }
225
252
  }
253
+ // Cloudflare Workers AI — a keyed provider, so the API token was
254
+ // collected by the envKeys block above. It ALSO needs an endpoint
255
+ // address (like ollama needs OLLAMA_BASE_URL): an account id to derive
256
+ // the standard https://api.cloudflare.com/client/v4/accounts/{id}/ai/v1
257
+ // URL, or an explicit CLOUDFLARE_AI_BASE_URL override (a custom domain,
258
+ // or a Cloudflare AI Gateway endpoint). See cloudflare_provider.ts's
259
+ // cloudflareAiBaseUrl() for the resolution order doctor and dispatch
260
+ // both share — asking here keeps the interview's answers and the
261
+ // runtime's resolution from drifting apart.
262
+ if (provider === "cloudflare") {
263
+ asker.note("Workers AI model ids start with @cf/ — e.g. @cf/zai-org/glm-5.3-flash. See developers.cloudflare.com/ai/models for the catalog.");
264
+ const useExplicit = await asker.confirm("Use an explicit base URL instead of an account id (custom domain / AI Gateway)?", false);
265
+ if (useExplicit) {
266
+ const baseUrl = await asker.text("CLOUDFLARE_AI_BASE_URL", {
267
+ default: "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/v1",
268
+ validate: (v) => (v.trim() && !v.includes("<") ? null : "set the full OpenAI-compatible base URL (no placeholders)"),
269
+ });
270
+ env["CLOUDFLARE_AI_BASE_URL"] = baseUrl;
271
+ envExampleKeys.push("CLOUDFLARE_AI_BASE_URL");
272
+ }
273
+ else {
274
+ const accountId = await asker.text("CLOUDFLARE_ACCOUNT_ID", {
275
+ validate: (v) => (v.trim() ? null : "required — find it in the Cloudflare dashboard (right sidebar)"),
276
+ });
277
+ env["CLOUDFLARE_ACCOUNT_ID"] = accountId;
278
+ envExampleKeys.push("CLOUDFLARE_ACCOUNT_ID");
279
+ }
280
+ asker.note("spf doctor probes the resolved Workers AI endpoint (informational, never a hard failure).");
281
+ // Same pinned-roster fix as the ollama branch above: cloudflare is a
282
+ // single self-served endpoint, not a multi-model aggregator like
283
+ // openrouter — the packaged roster's planner (fireworks/...),
284
+ // reviewer, and documenter (openai/...) are NOT reachable through a
285
+ // Workers AI account, so leaving them pinned would route three agents
286
+ // at providers whose keys this interview never asked for. Overriding
287
+ // all three to the chosen cloudflare model is the exact same fix.
288
+ for (const name of ["planner", "reviewer", "documenter"]) {
289
+ agentOverrides.push({ name, model: defaults.model });
290
+ }
291
+ notes.push("planner/reviewer/documenter pin their own model in the packaged roster and always win over defaults.model — overriding all three to the cloudflare model chosen above, or spf doctor would report missing FIREWORKS_API_KEY/OPENAI_API_KEY for providers a Workers AI account can't serve.");
292
+ }
226
293
  }
227
294
  // Declined (the default): today's behavior exactly — on claude_code, the
228
295
  // three-agent auto-pin above stands unchanged; on flue, no agents: block
@@ -26,6 +26,7 @@ import { AgentRunError, createBashTool, createEditTool, createGlobTool, createGr
26
26
  import { local, sqlite, start } from "@flue/runtime/node";
27
27
  import { UsageBreakdown, makeAgentResult } from "./data_types.js";
28
28
  import { registerOllamaModel } from "./ollama_provider.js";
29
+ import { registerCloudflareModel } from "./cloudflare_provider.js";
29
30
  import * as sandbox from "./sandbox.js";
30
31
  import { nowIso, operatorEnv } from "./utils.js";
31
32
  const RESULT_SNIPPET_CHARS = 20_000; // tool output rides along whole; clip only guards pathological cases
@@ -296,6 +297,13 @@ export async function run(request, onEvent, onSpawn, onExit) {
296
297
  const [provider, modelId] = resolveModel(request.model);
297
298
  if (provider === "ollama")
298
299
  await registerOllamaModel(modelId);
300
+ // Cloudflare Workers AI is the same self-registration shape as Ollama
301
+ // (no pi-ai/Flue built-in "cloudflare" provider on Node) — see
302
+ // cloudflare_provider.ts's header comment for the Workers AI OpenAI-
303
+ // compatible endpoint, the slashed `@cf/...` model-id handling, and the
304
+ // real-Bearer-token (not dummy-key) auth.
305
+ if (provider === "cloudflare")
306
+ await registerCloudflareModel(modelId);
299
307
  await ensureRuntime(request.flue_db_path);
300
308
  REGISTRY.set(request.session_id, {
301
309
  model: request.model,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Cloudflare Workers AI registration for the Flue backend (agent_flue.ts).
3
+ *
4
+ * A sibling of ollama_provider.ts — read that file first; only the deltas are
5
+ * documented here. Neither pi-ai nor Flue ships a built-in "cloudflare"
6
+ * provider on Node (the `cloudflare` provider id exists ONLY inside a
7
+ * Cloudflare Worker's own runtime, where a generated Workers AI binding
8
+ * registers it — see `@flue/runtime`'s providers module: "On Cloudflare,
9
+ * registering a `cloudflare` provider in `app.ts` takes precedence over the
10
+ * generated Workers AI binding default"). In a plain Node process (spf is a
11
+ * Node CLI), there is no such binding, so we register our own against the
12
+ * same OpenAI-compatible surface Ollama uses.
13
+ *
14
+ * Workers AI's OpenAI-compatible endpoint is served under
15
+ * https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1
16
+ * (the `/v1/chat/completions` the OpenAI SDK appends to `baseURL` is exactly
17
+ * what Workers AI speaks — verified against developers.cloudflare.com/ai).
18
+ * Auth is a real Bearer token (`CLOUDFLARE_API_TOKEN`), NOT a dummy: unlike
19
+ * a local keyless Ollama server, Cloudflare's API rejects an empty/wrong
20
+ * Authorization header with HTTP 401, so the placeholder-key trick
21
+ * ollama_provider.ts uses would surface as a 401 at the first dispatch
22
+ * instead of a clear "set CLOUDFLARE_API_TOKEN" message.
23
+ *
24
+ * pi-ai's `openai-completions` api constructs the HTTP client with the
25
+ * official `OpenAI` SDK (`new OpenAI({ apiKey, baseURL })`), which sends
26
+ * `Authorization: Bearer <apiKey>` and `params.model = model.id` in the
27
+ * body — both exactly what Workers AI expects. Its `detectCompat` ALSO has
28
+ * first-class Workers AI handling keyed off the base URL
29
+ * (`isCloudflareWorkersAI = ... || baseUrl.includes("api.cloudflare.com")`),
30
+ * so registering under provider id `"cloudflare"` (not pi-ai's internal
31
+ * `"cloudflare-workers-ai"`) still activates the right transport compat
32
+ * (no `store`, `max_completion_tokens`, no long cache retention) via the
33
+ * URL match. Using `"cloudflare"` matches the provider id Flue's own docs
34
+ * reserve for the Workers AI override, and keeps the model string short
35
+ * (`cloudflare/@cf/...`).
36
+ *
37
+ * SLASHED MODEL IDS — Workers AI model ids contain slashes
38
+ * (`@cf/zai-org/glm-5.3-flash`), which is unusual for Flue's
39
+ * `provider/model-id` format. Verified against `@flue/runtime`'s own
40
+ * `resolveModel`: it splits on the FIRST slash only (`providerId =
41
+ * slice(0, slash)`, `modelId = slice(slash+1)`), then does an exact
42
+ * `models.getModel(providerId, modelId)` lookup — so
43
+ * `cloudflare/@cf/zai-org/glm-5.3-flash` resolves provider `cloudflare`,
44
+ * model id `@cf/zai-org/glm-5.3-flash`, and matches a model registered
45
+ * with exactly that `id`. No special-casing needed; the hermetic test in
46
+ * cloudflare_provider.test.ts pins this so a future Flue rewrite that
47
+ * changed the split would fail loudly here, not at a user's first run.
48
+ *
49
+ * REASONING / CONTEXT WINDOW — `reasoning: false` and `contextWindow: 0`
50
+ * mirror ollama_provider.ts's stance for a self-served, arbitrary-model
51
+ * endpoint: Workers AI's catalog has no single reliable per-model context
52
+ * window, and the zai/glm thinking-format params pi-ai would emit for a
53
+ * `reasoning: true` model (`thinking: {type: ...}`, `reasoning_effort`)
54
+ * are the zai *direct* API's params, not guaranteed to be honored through
55
+ * Workers AI's OpenAI-compatible surface. Disabled keeps spf's behavior
56
+ * identical to the ollama case (the agent's `thinking:` level is moot) and
57
+ * avoids fabricating metadata Workers AI's API doesn't actually provide.
58
+ */
59
+ import type { Provider } from "@earendil-works/pi-ai";
60
+ /**
61
+ * The Workers AI OpenAI-compatible base URL. Resolution order, matching
62
+ * ollama_provider.ts's `ollamaBaseUrl()` "fresh per registration" read:
63
+ *
64
+ * 1. `CLOUDFLARE_AI_BASE_URL` — an explicit full URL (gateway, a custom
65
+ * domain, an AI Gateway endpoint). Takes precedence so a power user
66
+ * can redirect without touching the account id.
67
+ * 2. `https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1`
68
+ * — the standard shape, constructed from the account id.
69
+ *
70
+ * Returns `undefined` when NEITHER is set: unlike Ollama, a hosted
71
+ * Cloudflare endpoint has no sensible localhost default to fall back to —
72
+ * silently synthesizing one would misroute real dispatches. Callers
73
+ * (registerCloudflareModel, doctor's probe) treat `undefined` as
74
+ * "unconfigured, report it" rather than guessing.
75
+ */
76
+ export declare function cloudflareAiBaseUrl(): string | undefined;
77
+ /**
78
+ * Registers `modelId` (the part after `cloudflare/` in an agent's `model`
79
+ * config — e.g. `@cf/zai-org/glm-5.3-flash`) with Flue's provider registry,
80
+ * alongside every other `cloudflare/*` id ever registered this process.
81
+ * Idempotent and concurrency-safe in exactly the ways
82
+ * ollama_provider.ts's `registerOllamaModel` is — see that function's doc;
83
+ * the `registeredIds`-after-`setProvider` ordering, the `inflight` join,
84
+ * and the dynamic-import boundary are all identical and kept identical on
85
+ * purpose (one OpenAI-compatible self-registration pattern across both).
86
+ *
87
+ * Throws BEFORE touching Flue's registry if the base URL or API token is
88
+ * unconfigured — a clearer failure than letting `setProvider()` succeed
89
+ * and surfacing a 401 / "No API key for provider: cloudflare" at the first
90
+ * real dispatch.
91
+ */
92
+ export declare function registerCloudflareModel(modelId: string): Promise<void>;
93
+ /** Test-only: the most recently constructed provider object (see `lastProvider`'s doc). */
94
+ export declare function providerForTest(): Provider<"openai-completions"> | undefined;
95
+ /** Test-only: forgets accumulated ids so test files don't leak into each other. Does not touch Flue's own registry — pair with `resetModelsForTests()` from `@flue/runtime/internal`. */
96
+ export declare function resetCloudflareRegistrationForTest(): void;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Cloudflare Workers AI registration for the Flue backend (agent_flue.ts).
3
+ *
4
+ * A sibling of ollama_provider.ts — read that file first; only the deltas are
5
+ * documented here. Neither pi-ai nor Flue ships a built-in "cloudflare"
6
+ * provider on Node (the `cloudflare` provider id exists ONLY inside a
7
+ * Cloudflare Worker's own runtime, where a generated Workers AI binding
8
+ * registers it — see `@flue/runtime`'s providers module: "On Cloudflare,
9
+ * registering a `cloudflare` provider in `app.ts` takes precedence over the
10
+ * generated Workers AI binding default"). In a plain Node process (spf is a
11
+ * Node CLI), there is no such binding, so we register our own against the
12
+ * same OpenAI-compatible surface Ollama uses.
13
+ *
14
+ * Workers AI's OpenAI-compatible endpoint is served under
15
+ * https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1
16
+ * (the `/v1/chat/completions` the OpenAI SDK appends to `baseURL` is exactly
17
+ * what Workers AI speaks — verified against developers.cloudflare.com/ai).
18
+ * Auth is a real Bearer token (`CLOUDFLARE_API_TOKEN`), NOT a dummy: unlike
19
+ * a local keyless Ollama server, Cloudflare's API rejects an empty/wrong
20
+ * Authorization header with HTTP 401, so the placeholder-key trick
21
+ * ollama_provider.ts uses would surface as a 401 at the first dispatch
22
+ * instead of a clear "set CLOUDFLARE_API_TOKEN" message.
23
+ *
24
+ * pi-ai's `openai-completions` api constructs the HTTP client with the
25
+ * official `OpenAI` SDK (`new OpenAI({ apiKey, baseURL })`), which sends
26
+ * `Authorization: Bearer <apiKey>` and `params.model = model.id` in the
27
+ * body — both exactly what Workers AI expects. Its `detectCompat` ALSO has
28
+ * first-class Workers AI handling keyed off the base URL
29
+ * (`isCloudflareWorkersAI = ... || baseUrl.includes("api.cloudflare.com")`),
30
+ * so registering under provider id `"cloudflare"` (not pi-ai's internal
31
+ * `"cloudflare-workers-ai"`) still activates the right transport compat
32
+ * (no `store`, `max_completion_tokens`, no long cache retention) via the
33
+ * URL match. Using `"cloudflare"` matches the provider id Flue's own docs
34
+ * reserve for the Workers AI override, and keeps the model string short
35
+ * (`cloudflare/@cf/...`).
36
+ *
37
+ * SLASHED MODEL IDS — Workers AI model ids contain slashes
38
+ * (`@cf/zai-org/glm-5.3-flash`), which is unusual for Flue's
39
+ * `provider/model-id` format. Verified against `@flue/runtime`'s own
40
+ * `resolveModel`: it splits on the FIRST slash only (`providerId =
41
+ * slice(0, slash)`, `modelId = slice(slash+1)`), then does an exact
42
+ * `models.getModel(providerId, modelId)` lookup — so
43
+ * `cloudflare/@cf/zai-org/glm-5.3-flash` resolves provider `cloudflare`,
44
+ * model id `@cf/zai-org/glm-5.3-flash`, and matches a model registered
45
+ * with exactly that `id`. No special-casing needed; the hermetic test in
46
+ * cloudflare_provider.test.ts pins this so a future Flue rewrite that
47
+ * changed the split would fail loudly here, not at a user's first run.
48
+ *
49
+ * REASONING / CONTEXT WINDOW — `reasoning: false` and `contextWindow: 0`
50
+ * mirror ollama_provider.ts's stance for a self-served, arbitrary-model
51
+ * endpoint: Workers AI's catalog has no single reliable per-model context
52
+ * window, and the zai/glm thinking-format params pi-ai would emit for a
53
+ * `reasoning: true` model (`thinking: {type: ...}`, `reasoning_effort`)
54
+ * are the zai *direct* API's params, not guaranteed to be honored through
55
+ * Workers AI's OpenAI-compatible surface. Disabled keeps spf's behavior
56
+ * identical to the ollama case (the agent's `thinking:` level is moot) and
57
+ * avoids fabricating metadata Workers AI's API doesn't actually provide.
58
+ */
59
+ /**
60
+ * The Workers AI OpenAI-compatible base URL. Resolution order, matching
61
+ * ollama_provider.ts's `ollamaBaseUrl()` "fresh per registration" read:
62
+ *
63
+ * 1. `CLOUDFLARE_AI_BASE_URL` — an explicit full URL (gateway, a custom
64
+ * domain, an AI Gateway endpoint). Takes precedence so a power user
65
+ * can redirect without touching the account id.
66
+ * 2. `https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1`
67
+ * — the standard shape, constructed from the account id.
68
+ *
69
+ * Returns `undefined` when NEITHER is set: unlike Ollama, a hosted
70
+ * Cloudflare endpoint has no sensible localhost default to fall back to —
71
+ * silently synthesizing one would misroute real dispatches. Callers
72
+ * (registerCloudflareModel, doctor's probe) treat `undefined` as
73
+ * "unconfigured, report it" rather than guessing.
74
+ */
75
+ export function cloudflareAiBaseUrl() {
76
+ const explicit = (process.env.CLOUDFLARE_AI_BASE_URL ?? "").trim();
77
+ if (explicit)
78
+ return explicit.replace(/\/+$/, "");
79
+ const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "").trim();
80
+ if (accountId)
81
+ return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`;
82
+ return undefined;
83
+ }
84
+ /**
85
+ * The Bearer token Workers AI expects. `CLOUDFLARE_API_TOKEN` is the env
86
+ * var name spf already uses for its Cloudflare *sandbox* backend
87
+ * (data_types.ts's SandboxCloudflareSchema.api_token_env) — reusing it here
88
+ * means one token serves both a Workers AI model provider AND a Cloudflare
89
+ * sandbox in the same repo, and a user who already set it for the sandbox
90
+ * gets the model provider for free. Read fresh per request (the resolver
91
+ * runs at dispatch time), matching ollama_provider.ts's per-call base URL
92
+ * read.
93
+ */
94
+ function cloudflareApiToken() {
95
+ const token = (process.env.CLOUDFLARE_API_TOKEN ?? "").trim();
96
+ if (!token)
97
+ throw new Error("CLOUDFLARE_API_TOKEN is not set — required to authenticate Cloudflare Workers AI (get one at https://dash.cloudflare.com/profile/api-tokens, with the Workers AI > Read permission)");
98
+ return token;
99
+ }
100
+ // Advisory only — same role as ollama_provider.ts's DEFAULT_MAX_TOKENS:
101
+ // feeds Flue's compaction-reserve sizing, moot since `contextWindow: 0`
102
+ // below disables threshold compaction. Workers AI's OpenAI-compatible
103
+ // surface accepts `max_completion_tokens` (the field pi-ai picks for this
104
+ // provider — detectCompat's `useMaxTokens` does NOT include Workers AI, so
105
+ // `max_completion_tokens`, not `max_tokens`), and the per-model hard cap
106
+ // varies; kept generous.
107
+ const DEFAULT_MAX_TOKENS = 8192;
108
+ /**
109
+ * Every `cloudflare/<id>` model id a `registerCloudflareModel` call has
110
+ * ever SUCCEEDED in registering, in call order. `setProvider()` REPLACES
111
+ * the named provider's entire model list on every call (not additive), so
112
+ * this Set is what lets each call re-register the FULL union instead of
113
+ * just the newest id — the same invariant ollama_provider.ts's
114
+ * `registeredIds` exists to preserve. Without it: register
115
+ * `@cf/zai-org/glm-5.3-flash`, then `@cf/meta/llama-3.1-8b-instruct`, and
116
+ * the first becomes an "Unknown model ID" at its next dispatch.
117
+ */
118
+ const registeredIds = new Set();
119
+ // Registrations currently in flight, keyed by model id — same join semantics
120
+ // as ollama_provider.ts's `inflight`: a second caller for the SAME id that
121
+ // arrives before the first `await` resolves joins that in-progress
122
+ // registration instead of returning with nothing registered yet.
123
+ const inflight = new Map();
124
+ // The most recently constructed provider object — test-only, same reason
125
+ // as ollama_provider.ts's `lastProvider` (Flue exports no `getProvider`).
126
+ let lastProvider;
127
+ function modelFor(id, baseUrl) {
128
+ return {
129
+ id,
130
+ name: id,
131
+ api: "openai-completions",
132
+ provider: "cloudflare",
133
+ baseUrl,
134
+ reasoning: false,
135
+ input: ["text"],
136
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
137
+ // Disables Flue's threshold-based compaction — same convention as
138
+ // ollama_provider.ts: no reliable per-model context window across
139
+ // Workers AI's catalog, and agent_flue.ts already treats 0 as
140
+ // "unknown, don't compact".
141
+ contextWindow: 0,
142
+ maxTokens: DEFAULT_MAX_TOKENS,
143
+ };
144
+ }
145
+ /**
146
+ * Registers `modelId` (the part after `cloudflare/` in an agent's `model`
147
+ * config — e.g. `@cf/zai-org/glm-5.3-flash`) with Flue's provider registry,
148
+ * alongside every other `cloudflare/*` id ever registered this process.
149
+ * Idempotent and concurrency-safe in exactly the ways
150
+ * ollama_provider.ts's `registerOllamaModel` is — see that function's doc;
151
+ * the `registeredIds`-after-`setProvider` ordering, the `inflight` join,
152
+ * and the dynamic-import boundary are all identical and kept identical on
153
+ * purpose (one OpenAI-compatible self-registration pattern across both).
154
+ *
155
+ * Throws BEFORE touching Flue's registry if the base URL or API token is
156
+ * unconfigured — a clearer failure than letting `setProvider()` succeed
157
+ * and surfacing a 401 / "No API key for provider: cloudflare" at the first
158
+ * real dispatch.
159
+ */
160
+ export async function registerCloudflareModel(modelId) {
161
+ if (registeredIds.has(modelId))
162
+ return;
163
+ const existing = inflight.get(modelId);
164
+ if (existing)
165
+ return existing;
166
+ const baseUrl = cloudflareAiBaseUrl();
167
+ if (!baseUrl) {
168
+ throw new Error("Cloudflare Workers AI base URL is not configured — set CLOUDFLARE_ACCOUNT_ID (for the standard https://api.cloudflare.com/client/v4/accounts/{id}/ai/v1 endpoint) or CLOUDFLARE_AI_BASE_URL (for a custom/AI Gateway endpoint)");
169
+ }
170
+ // Eagerly resolve the token too: a missing token throws here, before
171
+ // setProvider(), so the error names the env var rather than arriving as
172
+ // pi-ai's generic "No API key for provider: cloudflare" at dispatch.
173
+ cloudflareApiToken();
174
+ const promise = (async () => {
175
+ // Same lazy-import rationale as ollama_provider.ts: keeps this module's
176
+ // cloudflare-only symbols (especially the deep `openai-completions.lazy`
177
+ // subpath) off the module graph of anything that imports agent_flue.ts
178
+ // for `resolveModel()` alone (doctor.ts, interview.ts) without ever
179
+ // dispatching a cloudflare call.
180
+ const [{ createProvider }, { openAICompletionsApi }, { setProvider }] = await Promise.all([
181
+ import("@earendil-works/pi-ai"),
182
+ import("@earendil-works/pi-ai/api/openai-completions.lazy"),
183
+ import("@flue/runtime/internal"),
184
+ ]);
185
+ const ids = new Set(registeredIds);
186
+ ids.add(modelId);
187
+ const models = [...ids].map((id) => modelFor(id, baseUrl));
188
+ const options = {
189
+ id: "cloudflare",
190
+ name: "Cloudflare Workers AI",
191
+ baseUrl,
192
+ auth: {
193
+ apiKey: {
194
+ name: "CLOUDFLARE_API_TOKEN",
195
+ // Read fresh per dispatch (per pi-ai's resolve() contract), so a
196
+ // token set AFTER the first registration still works for the next
197
+ // dispatch without re-registering. Throws a clear message if
198
+ // unset — see cloudflareApiToken()'s doc.
199
+ resolve: async () => ({ auth: { apiKey: cloudflareApiToken() } }),
200
+ },
201
+ },
202
+ models,
203
+ api: openAICompletionsApi(),
204
+ };
205
+ // setProvider() upserts this one provider id and leaves every other
206
+ // registered provider untouched — same additive primitive
207
+ // ollama_provider.ts uses (NOT `start({ providers: [...] })`, which
208
+ // would REPLACE the whole default set and drop anthropic/openai/etc.).
209
+ const provider = createProvider(options);
210
+ setProvider(provider);
211
+ lastProvider = provider;
212
+ for (const id of ids)
213
+ registeredIds.add(id);
214
+ })();
215
+ inflight.set(modelId, promise);
216
+ try {
217
+ await promise;
218
+ }
219
+ finally {
220
+ inflight.delete(modelId);
221
+ }
222
+ }
223
+ /** Test-only: the most recently constructed provider object (see `lastProvider`'s doc). */
224
+ export function providerForTest() {
225
+ return lastProvider;
226
+ }
227
+ /** Test-only: forgets accumulated ids so test files don't leak into each other. Does not touch Flue's own registry — pair with `resetModelsForTests()` from `@flue/runtime/internal`. */
228
+ export function resetCloudflareRegistrationForTest() {
229
+ registeredIds.clear();
230
+ inflight.clear();
231
+ lastProvider = undefined;
232
+ }
@@ -25,4 +25,14 @@ export const PROVIDER_ENV_KEYS = {
25
25
  // prompt for. An empty array here means "known provider, needs no key",
26
26
  // never "unknown provider" (that's a missing table entry, not `[]`).
27
27
  ollama: [],
28
+ // Cloudflare Workers AI — a real Bearer token (NOT keyless like ollama;
29
+ // Cloudflare's API 401s on an empty Authorization header). The base URL
30
+ // is derived from CLOUDFLARE_ACCOUNT_ID (or overridden by
31
+ // CLOUDFLARE_AI_BASE_URL) — that's collected separately in the `spf init`
32
+ // interview's cloudflare branch, the same way the ollama branch collects
33
+ // OLLAMA_BASE_URL, since PROVIDER_ENV_KEYS only carries the KEY a provider
34
+ // needs, not a base address. Same env var name spf's Cloudflare *sandbox*
35
+ // backend already uses (data_types.ts's SandboxCloudflareSchema), so one
36
+ // token serves both.
37
+ cloudflare: ["CLOUDFLARE_API_TOKEN"],
28
38
  };
@@ -57,6 +57,7 @@
57
57
  import path from "node:path";
58
58
  import { attemptAdwId, attemptBranch, attemptWorktreePath, runBestOf } from "./fanout.js";
59
59
  import { PRIORITY_RANK } from "./data_types.js";
60
+ import { redact } from "./otel.js";
60
61
  import { parseRefineMarker } from "./refine.js";
61
62
  import { newId } from "./utils.js";
62
63
  const MAX_ORPHAN_ATTEMPTS = 2;
@@ -1255,7 +1256,18 @@ export async function claimSpecs(deps, state, from = "spec-ready") {
1255
1256
  }
1256
1257
  function tickErrorHandler(deps, stage) {
1257
1258
  return (error) => {
1258
- const message = error.message;
1259
+ // undici (and the tracker/code-host clients built on `fetch`) collapse
1260
+ // every connection-level failure to the bare string "fetch failed" and
1261
+ // put the actual reason (DNS, ECONNREFUSED, a TLS error — each with a
1262
+ // different fix) on `error.cause` — same fold-in `doctor.ts`'s
1263
+ // `probeOtel` already does, otherwise this alert has no diagnostic value.
1264
+ const err = error;
1265
+ const cause = err.cause;
1266
+ const detail = cause ? ` (${cause.code ?? cause.message ?? String(cause)})` : "";
1267
+ // redact(): a fetch failure routinely embeds the URL it attempted, which
1268
+ // may carry credentials (e.g. a tracker API token) — never let that reach
1269
+ // a log line or an outbound Slack/Teams/webhook notification.
1270
+ const message = redact(`${err.message}${detail}`, []);
1259
1271
  deps.log(`watch: ${stage} error: ${message}`);
1260
1272
  deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
1261
1273
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",