@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 +32 -0
- package/assets/templates/ts-flue-cloudflare.spf.config.yaml +61 -0
- package/dist/cli/commands/doctor.js +47 -2
- package/dist/cli/interview.js +67 -0
- package/dist/core/agent_flue.js +8 -0
- package/dist/core/cloudflare_provider.d.ts +96 -0
- package/dist/core/cloudflare_provider.js +232 -0
- package/dist/core/providers.js +10 -0
- package/dist/core/watch.js +13 -1
- package/package.json +1 -1
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") {
|
package/dist/cli/interview.js
CHANGED
|
@@ -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
|
package/dist/core/agent_flue.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/providers.js
CHANGED
|
@@ -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
|
};
|
package/dist/core/watch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|