From 94581b0680f43da1123bdd281bf8cb70d366467e Mon Sep 17 00:00:00 2001 From: Adrian Coroi Date: Sat, 25 Jul 2026 17:31:31 +0200 Subject: [PATCH 1/2] feat(providers): add Cloudflare Workers AI as an LLM and embedding provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workers AI exposes OpenAI-compatible /ai/v1/chat/completions and /ai/v1/embeddings endpoints, so both providers reuse the existing fetchWithTimeout transport and the same request/response shapes as the OpenAI providers. CLOUDFLARE_AI_BASE_URL / CLOUDFLARE_EMBEDDING_BASE_URL also accept an AI Gateway URL for logging, caching and rate limiting. CLOUDFLARE_API_TOKEN alone enables Cloudflare for both roles. It sits after MINIMAX and before ANTHROPIC in the LLM detection order, and after OPENAI in the embedding order, so it never displaces an already-working setup. EMBEDDING_PROVIDER still overrides if you want to mix providers. Requests already flow through the account's default AI Gateway, so logging, caching, rate limiting and guardrails apply with no config. CLOUDFLARE_AI_GATEWAY_ID pins a named gateway instead — Cloudflare selects it via the cf-aig-gateway-id header, not a different base URL. Default chat model is @cf/meta/llama-3.1-8b-instruct-fp8: the non-fp8 @cf/meta/llama-3.1-8b-instruct was deprecated on 2026-05-30 and now returns HTTP 410, so it cannot be the default. Content extraction deliberately has no `reasoning` fallback. Reasoning models (@cf/zai-org/glm-*, qwq, deepseek-r1) return content:null plus a populated `reasoning` field once the token budget is spent thinking; treating that scratchpad as the answer would write chain-of-thought into memory and feed it to the XML parsers in summarize.ts. Truncation now raises an error naming MAX_TOKENS instead. Endpoint construction, auth headers and gateway selection live in _cloudflare-shared.ts so the chat and embedding providers do not mirror them, matching the existing _openai-shared.ts split. Embedding dimensions come from a known-models table (bge-small 384, bge-base 768, bge-large / bge-m3 / qwen3-embedding 1024, embeddinggemma-300m 768) because withDimensionGuard throws on every embed when the reported size is wrong. CLOUDFLARE_EMBEDDING_DIMENSIONS covers anything not in the table. Verified against live Workers AI: compress, summarize, the full loadConfig -> createProvider path, 384/768/1024-dim embeddings through withDimensionGuard, and gateway routing (unpinned, named, and a bad gateway id surfacing a 400). Verify: CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... npx @agentmemory/agentmemory doctor npx vitest run test/cloudflare-provider.test.ts test/embedding-provider.test.ts Signed-off-by: Adrian Coroi --- .env.example | 32 +++- README.md | 7 + src/cli.ts | 6 +- src/cli/doctor-diagnostics.ts | 3 +- src/cli/onboarding.ts | 2 + src/config.ts | 17 +- src/functions/consolidation-pipeline.ts | 2 +- src/functions/summarize.ts | 2 +- src/providers/_cloudflare-shared.ts | 59 ++++++ src/providers/cloudflare.ts | 131 ++++++++++++++ src/providers/embedding/cloudflare.ts | 116 ++++++++++++ src/providers/embedding/index.ts | 4 + src/providers/index.ts | 11 ++ src/types.ts | 2 +- src/viewer/index.html | 6 +- test/cloudflare-provider.test.ts | 231 ++++++++++++++++++++++++ test/embedding-provider.test.ts | 10 + 17 files changed, 625 insertions(+), 16 deletions(-) create mode 100644 src/providers/_cloudflare-shared.ts create mode 100644 src/providers/cloudflare.ts create mode 100644 src/providers/embedding/cloudflare.ts create mode 100644 test/cloudflare-provider.test.ts diff --git a/.env.example b/.env.example index 77ca0f3a3..3afc27dd2 100644 --- a/.env.example +++ b/.env.example @@ -23,12 +23,28 @@ # Without a provider key, agentmemory runs in noop mode: observations are # indexed via zero-LLM synthetic compression, hybrid search still works, # but LLM-backed summarisation / reflection / consolidation are disabled. -# The detection order is OPENAI_API_KEY → MINIMAX_API_KEY → ANTHROPIC_API_KEY -# → GEMINI_API_KEY → OPENROUTER_API_KEY → noop. +# The detection order is OPENAI_API_KEY → MINIMAX_API_KEY → CLOUDFLARE_API_TOKEN +# → ANTHROPIC_API_KEY → GEMINI_API_KEY → OPENROUTER_API_KEY → noop. # OPENAI_API_KEY=sk-... # Used for OpenAI-compatible embeddings today. PR #307 will extend this to chat completions (DeepSeek, SiliconFlow, vLLM, LM Studio, Ollama via `/v1`). # OPENAI_BASE_URL=https://api.openai.com # Override for OpenAI-compatible providers +# CLOUDFLARE_API_TOKEN=... # Cloudflare Workers AI API token +# CLOUDFLARE_ACCOUNT_ID=... # Required when CLOUDFLARE_AI_BASE_URL is not set +# CLOUDFLARE_MODEL=@cf/meta/llama-3.1-8b-instruct-fp8 # Default chat model +# CLOUDFLARE_AI_BASE_URL=https://api.cloudflare.com/client/v4/accounts//ai/v1/chat/completions +# CLOUDFLARE_AI_GATEWAY_ID=my-gateway # Optional: pin a named AI Gateway. +# # The default endpoint above already routes through your +# # account's default gateway, so logging / caching / rate +# # limiting / guardrails apply with no config. Set this only +# # to target a specific gateway (sent as cf-aig-gateway-id). +# CLOUDFLARE_TIMEOUT_MS=60000 # Per-request timeout; falls back to AGENTMEMORY_LLM_TIMEOUT_MS +# +# Reasoning models (@cf/zai-org/glm-*, @cf/qwen/qwq-*, deepseek-r1) spend the +# token budget thinking before emitting content. They need a generous MAX_TOKENS +# (4096+) or every call fails with finish_reason=length, and they cost 10-100x +# a small instruct model per background compression. Prefer a small model here. + # ANTHROPIC_API_KEY=sk-ant-... # ANTHROPIC_MODEL=claude-sonnet-4-20250514 # Default Anthropic model # ANTHROPIC_BASE_URL=https://api.anthropic.com # Override for Anthropic-compatible proxies / Azure AI Foundry @@ -64,10 +80,10 @@ # # Without an embedding key, agentmemory runs in BM25-only mode for hybrid # search. Detection order: EMBEDDING_PROVIDER override → GEMINI_API_KEY → -# OPENAI_API_KEY → VOYAGE_API_KEY → COHERE_API_KEY → OPENROUTER_API_KEY → -# local (Xenova/all-MiniLM-L6-v2, 384-dim). +# OPENAI_API_KEY → CLOUDFLARE_API_TOKEN → VOYAGE_API_KEY → COHERE_API_KEY → +# OPENROUTER_API_KEY → local (Xenova/all-MiniLM-L6-v2, 384-dim). -# EMBEDDING_PROVIDER=local # local | openai | voyage | cohere | gemini | openrouter +# EMBEDDING_PROVIDER=local # local | openai | cloudflare | voyage | cohere | gemini | openrouter # VOYAGE_API_KEY=pa-... # Optimised for code embeddings @@ -77,6 +93,12 @@ # OPENAI_EMBEDDING_MODEL=text-embedding-3-small # Embedding model when EMBEDDING_PROVIDER=openai # OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table +# CLOUDFLARE_API_TOKEN=... # Reused from the LLM section; set if only using Cloudflare embeddings +# CLOUDFLARE_ACCOUNT_ID=... # Required when CLOUDFLARE_EMBEDDING_BASE_URL is not set +# CLOUDFLARE_EMBEDDING_MODEL=@cf/baai/bge-base-en-v1.5 +# CLOUDFLARE_EMBEDDING_DIMENSIONS=768 # Required when the model is not in the known-models table +# CLOUDFLARE_EMBEDDING_BASE_URL=https://api.cloudflare.com/client/v4/accounts//ai/v1/embeddings + # OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small # When EMBEDDING_PROVIDER=openrouter # ----------------------------------------------------------------------------- diff --git a/README.md b/README.md index 0c4ea33e0..a7b2154f3 100644 --- a/README.md +++ b/README.md @@ -1226,6 +1226,7 @@ agentmemory auto-detects from your environment. By default, no LLM calls are mad | Gemini | `GEMINI_API_KEY` | Also enables embeddings | | OpenRouter | `OPENROUTER_API_KEY` | Any model | | OpenAI API | `OPENAI_API_KEY` | Default `gpt-4o-mini`, override with `OPENAI_MODEL` | +| Cloudflare Workers AI | `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` | Default `@cf/meta/llama-3.1-8b-instruct-fp8`, override with `CLOUDFLARE_MODEL`. Same token also enables Cloudflare embeddings. Requests already flow through your account's default [AI Gateway](https://developers.cloudflare.com/ai-gateway/) (logging, caching, rate limiting, guardrails); set `CLOUDFLARE_AI_GATEWAY_ID` to pin a named one. Reasoning models (`@cf/zai-org/glm-*`, `qwq`, `deepseek-r1`) need `MAX_TOKENS` ≥ 4096 and cost far more per background compression — prefer a small instruct model. | | **Local (Ollama / LM Studio / vLLM / llama.cpp)** | `OPENAI_API_KEY=local` + `OPENAI_BASE_URL=http://localhost:11434/v1` (Ollama) or `http://localhost:1234/v1` (LM Studio) + `OPENAI_MODEL=` | Anything OpenAI-API-compatible. Zero cost, runs on your hardware. See [Local models](#local-models-ollama--lm-studio--vllm) below. | | Claude subscription fallback | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | Opt-in only. Spawns `@anthropic-ai/claude-agent-sdk` sessions — used to cause unbounded Stop-hook recursion so it is no longer the default. | @@ -1382,6 +1383,8 @@ Create `~/.agentmemory/.env`: # GEMINI_API_KEY=... # OPENROUTER_API_KEY=... # MINIMAX_API_KEY=... +# CLOUDFLARE_API_TOKEN=... # Workers AI; also enables Cloudflare embeddings +# CLOUDFLARE_ACCOUNT_ID=... # Required unless CLOUDFLARE_AI_BASE_URL is set # OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the # # OpenAI LLM provider (here) AND the OpenAI # # embedding provider (further below). Set @@ -1417,6 +1420,10 @@ Create `~/.agentmemory/.env`: # OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies # OPENAI_EMBEDDING_MODEL=text-embedding-3-small # OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table +# CLOUDFLARE_API_TOKEN=... +# CLOUDFLARE_ACCOUNT_ID=... +# CLOUDFLARE_EMBEDDING_MODEL=@cf/baai/bge-base-en-v1.5 +# CLOUDFLARE_EMBEDDING_DIMENSIONS=768 # Required when the model is not in the known-models table # Outbound LLM / embedding timeout # AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every diff --git a/src/cli.ts b/src/cli.ts index 2ae20f08b..ae830d6de 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1794,14 +1794,14 @@ async function passiveServerChecks(): Promise { { name: "LLM provider", ok: hasLlm, - hint: hasLlm ? undefined : "set ANTHROPIC_API_KEY (or GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env", + hint: hasLlm ? undefined : "set ANTHROPIC_API_KEY (or OPENAI/CLOUDFLARE/GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env", }, { name: "Embedding provider", ok: hasEmbed, hint: hasEmbed ? undefined - : "Running BM25-only. Add OPENAI_API_KEY / VOYAGE_API_KEY / COHERE_API_KEY / OLLAMA_HOST", + : "Running BM25-only. Add OPENAI_API_KEY / CLOUDFLARE_API_TOKEN / VOYAGE_API_KEY / COHERE_API_KEY / OLLAMA_HOST", }, ); @@ -2229,7 +2229,7 @@ async function runInit() { "All keys are commented out by default. Uncomment the ones you want.", "", "Common next steps:", - " 1. Pick an LLM provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / etc.)", + " 1. Pick an LLM provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY / CLOUDFLARE_API_TOKEN / GEMINI_API_KEY / etc.)", " 2. Run `npx @agentmemory/agentmemory doctor` to verify the daemon sees them", " 3. Run `npx @agentmemory/agentmemory` to start the worker", ].join("\n"), diff --git a/src/cli/doctor-diagnostics.ts b/src/cli/doctor-diagnostics.ts index 10d95136d..67f566fc8 100644 --- a/src/cli/doctor-diagnostics.ts +++ b/src/cli/doctor-diagnostics.ts @@ -89,6 +89,7 @@ const PLACEHOLDER_VALUES = new Set([ const PROVIDER_KEY_NAMES = [ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", + "CLOUDFLARE_API_TOKEN", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", @@ -197,7 +198,7 @@ export function buildDiagnostics(effects: DoctorEffects): Diagnostic[] { message: "No LLM provider API key found in ~/.agentmemory/.env.", fixPreview: "Open ~/.agentmemory/.env in $EDITOR and paste your key, then re-check.", moreInfo: - "Set at least one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, " + + "Set at least one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, CLOUDFLARE_API_TOKEN, GEMINI_API_KEY, " + "OPENROUTER_API_KEY, MINIMAX_API_KEY. The daemon picks the first that resolves " + "to a real (non-placeholder) value at startup.", check: async () => { diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts index 6d0493554..6e6293e96 100644 --- a/src/cli/onboarding.ts +++ b/src/cli/onboarding.ts @@ -51,6 +51,7 @@ const AGENT_GLYPH: Record = { const PROVIDERS: { value: string; label: string; envKey: string | null }[] = [ { value: "anthropic", label: "Anthropic — claude", envKey: "ANTHROPIC_API_KEY" }, { value: "openai", label: "OpenAI — gpt", envKey: "OPENAI_API_KEY" }, + { value: "cloudflare", label: "Cloudflare Workers AI — @cf/*", envKey: "CLOUDFLARE_API_TOKEN" }, { value: "gemini", label: "Google — gemini", envKey: "GEMINI_API_KEY" }, { value: "openrouter", label: "OpenRouter — multi-model", envKey: "OPENROUTER_API_KEY" }, { value: "minimax", label: "MiniMax — minimax-m1", envKey: "MINIMAX_API_KEY" }, @@ -60,6 +61,7 @@ const PROVIDERS: { value: string; label: string; envKey: string | null }[] = [ const PROVIDER_COST_HINTS: Record = { anthropic: "rough cost: a fast Haiku-class model keeps compress/consolidate at fractions of a cent per session.", openai: "rough cost: a mini-class model keeps compress/consolidate at fractions of a cent per session.", + cloudflare: "rough cost: scales with the chosen @cf model's per-token price on Cloudflare Workers AI.", gemini: "rough cost: a Flash-class model keeps compress/consolidate at fractions of a cent per session.", openrouter: "rough cost: pick a small model; spend tracks your chosen model's per-token price.", minimax: "rough cost: scales with the MiniMax model price per token.", diff --git a/src/config.ts b/src/config.ts index d27c39e4b..48313c67c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -105,6 +105,18 @@ function detectProvider(env: Record): ProviderConfig { }; } + if (hasRealValue(env["CLOUDFLARE_API_TOKEN"])) { + return { + provider: "cloudflare", + // Literal rather than CLOUDFLARE_DEFAULT_CHAT_MODEL: providers/ imports + // config.ts, so importing back would cycle. Same trade-off every other + // provider default in this function makes. + model: env["CLOUDFLARE_MODEL"] || "@cf/meta/llama-3.1-8b-instruct-fp8", + maxTokens, + baseURL: env["CLOUDFLARE_AI_BASE_URL"], + }; + } + if (hasRealValue(env["ANTHROPIC_API_KEY"])) { return { provider: "anthropic", @@ -162,7 +174,7 @@ function detectProvider(env: Record): ProviderConfig { process.stderr.write( pc.dim( "[agentmemory] No LLM provider key set — running zero-LLM (BM25 + on-device embeddings). " + - "Set ANTHROPIC_API_KEY (or GEMINI/OPENAI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env for LLM compression and summaries. " + + "Set ANTHROPIC_API_KEY (or OPENAI/CLOUDFLARE/GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env for LLM compression and summaries. " + "Agent-SDK fallback stays off by default to avoid a Stop-hook recursion loop; opt in with AGENTMEMORY_AUTO_COMPRESS=true + AGENTMEMORY_ALLOW_AGENT_SDK=true.\n", ), ); @@ -236,6 +248,7 @@ export function detectLlmProviderKind(): "llm" | "noop" { const env = getMergedEnv(); if ( hasRealValue(env["ANTHROPIC_API_KEY"]) || + hasRealValue(env["CLOUDFLARE_API_TOKEN"]) || hasRealValue(env["GEMINI_API_KEY"]) || hasRealValue(env["GOOGLE_API_KEY"]) || hasRealValue(env["OPENROUTER_API_KEY"]) || @@ -272,6 +285,7 @@ export function detectEmbeddingProvider( if (source["GEMINI_API_KEY"]) return "gemini"; if (source["OPENAI_API_KEY"]) return "openai"; + if (source["CLOUDFLARE_API_TOKEN"]) return "cloudflare"; if (source["VOYAGE_API_KEY"]) return "voyage"; if (source["COHERE_API_KEY"]) return "cohere"; if (source["OPENROUTER_API_KEY"]) return "openrouter"; @@ -483,6 +497,7 @@ const VALID_PROVIDERS = new Set([ "agent-sdk", "minimax", "openai", + "cloudflare", ]); export function loadFallbackConfig(): FallbackConfig { diff --git a/src/functions/consolidation-pipeline.ts b/src/functions/consolidation-pipeline.ts index e51ab97c8..d3bb15124 100644 --- a/src/functions/consolidation-pipeline.ts +++ b/src/functions/consolidation-pipeline.ts @@ -50,7 +50,7 @@ export function registerConsolidationPipelineFunction( sdk.registerFunction("mem::consolidate-pipeline", async (data?: { tier?: string; force?: boolean; project?: string }) => { if (!data?.force && !isConsolidationEnabled()) { - return { success: false, skipped: true, reason: "Consolidation disabled: set CONSOLIDATION_ENABLED=true or configure an LLM provider (ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY / GEMINI_API_KEY / GOOGLE_API_KEY / MINIMAX_API_KEY / OPENAI_BASE_URL / AGENTMEMORY_PROVIDER=agent-sdk)" }; + return { success: false, skipped: true, reason: "Consolidation disabled: set CONSOLIDATION_ENABLED=true or configure an LLM provider (ANTHROPIC_API_KEY / OPENAI_API_KEY / CLOUDFLARE_API_TOKEN / OPENROUTER_API_KEY / GEMINI_API_KEY / GOOGLE_API_KEY / MINIMAX_API_KEY / OPENAI_BASE_URL / AGENTMEMORY_PROVIDER=agent-sdk)" }; } const tier = data?.tier || "all"; const decayDays = getConsolidationDecayDays(); diff --git a/src/functions/summarize.ts b/src/functions/summarize.ts index 4c501ca8c..4b052e9bd 100644 --- a/src/functions/summarize.ts +++ b/src/functions/summarize.ts @@ -268,7 +268,7 @@ export function registerSummarizeFunction( success: false, error: "no_provider", reason: - "No LLM provider key set; Summarize is a no-op. Set ANTHROPIC_API_KEY (or GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env to enable.", + "No LLM provider key set; Summarize is a no-op. Set ANTHROPIC_API_KEY (or OPENAI/CLOUDFLARE/GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env to enable.", }; } diff --git a/src/providers/_cloudflare-shared.ts b/src/providers/_cloudflare-shared.ts new file mode 100644 index 000000000..04cf1aaef --- /dev/null +++ b/src/providers/_cloudflare-shared.ts @@ -0,0 +1,59 @@ +// Shared transport for the Cloudflare Workers AI LLM + embedding providers. +// Both surfaces speak the OpenAI-compatible wire shape on the same host and +// differ only in the trailing route, so endpoint construction, auth headers +// and AI Gateway selection live here rather than being mirrored in two files. +// Mirrors the _openai-shared.ts split. + +import { getEnvVar } from "../config.js"; + +const ACCOUNTS_BASE = "https://api.cloudflare.com/client/v4/accounts"; + +export const CLOUDFLARE_DEFAULT_CHAT_MODEL = "@cf/meta/llama-3.1-8b-instruct-fp8"; +export const CLOUDFLARE_DEFAULT_EMBEDDING_MODEL = "@cf/baai/bge-base-en-v1.5"; + +/** + * Resolve a Workers AI endpoint: the operator's full-URL override if set, + * otherwise the account-scoped default. + * + * `overrideVar` is threaded through so the error names the knob that surface + * actually reads (CLOUDFLARE_AI_BASE_URL vs CLOUDFLARE_EMBEDDING_BASE_URL) + * instead of a generic one the operator may not have. + */ +export function resolveEndpoint( + route: "chat/completions" | "embeddings", + overrideVar: string, + surface: string, +): string { + const override = getEnvVar(overrideVar); + if (override) return override; + + const accountId = getEnvVar("CLOUDFLARE_ACCOUNT_ID"); + if (!accountId) { + throw new Error( + `CLOUDFLARE_ACCOUNT_ID or ${overrideVar} is required for the cloudflare ${surface} provider`, + ); + } + return `${ACCOUNTS_BASE}/${accountId}/ai/v1/${route}`; +} + +export function resolveGatewayId(): string | undefined { + return getEnvVar("CLOUDFLARE_AI_GATEWAY_ID") || undefined; +} + +/** + * Auth + content headers, plus AI Gateway selection. + * + * The default endpoint already routes through the account's default gateway, + * so logging/caching/rate limiting apply with no config. Cloudflare pins a + * *named* gateway by the cf-aig-gateway-id header, not by a different URL. + */ +export function buildHeaders( + apiKey: string, + gatewayId?: string, +): Record { + return { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + ...(gatewayId ? { "cf-aig-gateway-id": gatewayId } : {}), + }; +} diff --git a/src/providers/cloudflare.ts b/src/providers/cloudflare.ts new file mode 100644 index 000000000..55cf79c29 --- /dev/null +++ b/src/providers/cloudflare.ts @@ -0,0 +1,131 @@ +import type { MemoryProvider } from "../types.js"; +import { getEnvVar } from "../config.js"; +import { fetchWithTimeout } from "./_fetch.js"; +import { + CLOUDFLARE_DEFAULT_CHAT_MODEL, + buildHeaders, + resolveEndpoint, + resolveGatewayId, +} from "./_cloudflare-shared.js"; + +const DEFAULT_TIMEOUT_MS = 60_000; + +/** + * Cloudflare Workers AI chat-completion provider. + * + * Talks to the OpenAI-compatible `/ai/v1/chat/completions` endpoint. + * + * Required env vars: + * CLOUDFLARE_API_TOKEN — Workers AI API token + * CLOUDFLARE_ACCOUNT_ID — used to build the endpoint URL; not needed when + * CLOUDFLARE_AI_BASE_URL is set + * + * Optional: + * CLOUDFLARE_MODEL — chat model (default: CLOUDFLARE_DEFAULT_CHAT_MODEL) + * CLOUDFLARE_AI_BASE_URL — full chat endpoint override + * CLOUDFLARE_AI_GATEWAY_ID — route through a named AI Gateway (see below) + * CLOUDFLARE_TIMEOUT_MS — per-request timeout; falls back to + * AGENTMEMORY_LLM_TIMEOUT_MS, then 60s + * + * AI Gateway: the default endpoint already flows through the account's default + * gateway, so logging, caching, rate limiting and guardrails apply without any + * configuration. CLOUDFLARE_AI_GATEWAY_ID pins a specific gateway instead — + * Cloudflare selects it by the cf-aig-gateway-id header, not by a different URL. + */ +export class CloudflareProvider implements MemoryProvider { + name = "cloudflare"; + private apiKey: string; + private model: string; + private maxTokens: number; + private baseUrl: string; + private timeoutMs: number; + private gatewayId: string | undefined; + + constructor(apiKey: string, model: string, maxTokens: number, baseURL?: string) { + this.apiKey = apiKey; + this.model = model || CLOUDFLARE_DEFAULT_CHAT_MODEL; + this.maxTokens = maxTokens; + this.baseUrl = + baseURL || resolveEndpoint("chat/completions", "CLOUDFLARE_AI_BASE_URL", "chat"); + this.timeoutMs = resolveTimeout(); + this.gatewayId = resolveGatewayId(); + } + + async compress(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt); + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt); + } + + private async call(systemPrompt: string, userPrompt: string): Promise { + const response = await fetchWithTimeout( + this.baseUrl, + { + method: "POST", + headers: buildHeaders(this.apiKey, this.gatewayId), + body: JSON.stringify({ + model: this.model, + // Workers AI accepts max_tokens across its catalogue; newer + // OpenAI-compatible shims read max_completion_tokens instead and + // ignore unknown keys, so sending both keeps every @cf model bounded. + max_tokens: this.maxTokens, + max_completion_tokens: this.maxTokens, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }), + }, + this.timeoutMs, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Cloudflare API error (${response.status}): ${text}`); + } + + const data = (await response.json()) as { + choices?: Array<{ + finish_reason?: string | null; + message?: { content?: string | null }; + text?: string | null; + }>; + }; + const choice = data.choices?.[0]; + // Deliberately no `reasoning` fallback. Reasoning models (@cf/zai-org/glm-*, + // qwq, deepseek-r1) return content:null plus a populated `reasoning` field + // when the token budget is spent thinking. Treating that chain-of-thought as + // the answer writes model scratchpad into memory and feeds it to the XML + // parsers in summarize.ts, which is worse than failing loudly. + const content = choice?.message?.content ?? choice?.text; + if (!content || !content.trim()) { + if (choice?.finish_reason === "length") { + throw new Error( + `Cloudflare model ${this.model} hit the token limit before emitting content ` + + `(finish_reason=length). Reasoning models need a larger MAX_TOKENS, ` + + `currently ${this.maxTokens}.`, + ); + } + throw new Error( + `Cloudflare returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`, + ); + } + return content; + } +} + +function resolveTimeout(): number { + const raw = getEnvVar("CLOUDFLARE_TIMEOUT_MS") || getEnvVar("AGENTMEMORY_LLM_TIMEOUT_MS"); + const parsed = parsePositiveInt(raw); + return parsed ?? DEFAULT_TIMEOUT_MS; +} + +function parsePositiveInt(raw: string | undefined): number | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) return undefined; + const n = Number(trimmed); + return Number.isFinite(n) && n > 0 ? n : undefined; +} diff --git a/src/providers/embedding/cloudflare.ts b/src/providers/embedding/cloudflare.ts new file mode 100644 index 000000000..f175141ca --- /dev/null +++ b/src/providers/embedding/cloudflare.ts @@ -0,0 +1,116 @@ +import type { EmbeddingProvider } from "../../types.js"; +import { getEnvVar } from "../../config.js"; +import { fetchWithTimeout } from "../_fetch.js"; +import { + CLOUDFLARE_DEFAULT_EMBEDDING_MODEL, + buildHeaders, + resolveEndpoint, + resolveGatewayId, +} from "../_cloudflare-shared.js"; + +/** + * Known Workers AI embedding model dimensions. Extend as new models ship. + * Override in any case via CLOUDFLARE_EMBEDDING_DIMENSIONS — a wrong value + * here makes withDimensionGuard throw on every embed call, so an unknown + * model falling back to the default size is a hard failure, not a silent one. + */ +const MODEL_DIMENSIONS: Record = { + "@cf/baai/bge-small-en-v1.5": 384, + "@cf/baai/bge-base-en-v1.5": 768, + "@cf/baai/bge-large-en-v1.5": 1024, + "@cf/baai/bge-m3": 1024, + "@cf/qwen/qwen3-embedding-0.6b": 1024, + "@cf/google/embeddinggemma-300m": 768, +}; + +const DEFAULT_DIMENSIONS = MODEL_DIMENSIONS[CLOUDFLARE_DEFAULT_EMBEDDING_MODEL] ?? 768; + +function resolveDimensions(model: string, override: string | undefined): number { + if (override !== undefined && override.trim().length > 0) { + const parsed = parseInt(override, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error( + `CLOUDFLARE_EMBEDDING_DIMENSIONS must be a positive integer, got: ${override}`, + ); + } + return parsed; + } + return MODEL_DIMENSIONS[model] ?? DEFAULT_DIMENSIONS; +} + +/** + * Cloudflare Workers AI embedding provider. + * + * Talks to the OpenAI-compatible `/ai/v1/embeddings` endpoint, so the request + * and response shapes match `OpenAIEmbeddingProvider`. + * + * Required env vars: + * CLOUDFLARE_API_TOKEN — Workers AI API token + * CLOUDFLARE_ACCOUNT_ID — used to build the endpoint URL; not + * needed when CLOUDFLARE_EMBEDDING_BASE_URL + * is set + * + * Optional: + * CLOUDFLARE_EMBEDDING_MODEL — model name (default: + * CLOUDFLARE_DEFAULT_EMBEDDING_MODEL) + * CLOUDFLARE_EMBEDDING_DIMENSIONS — override reported dimensions (required for + * models not in the MODEL_DIMENSIONS table above) + * CLOUDFLARE_EMBEDDING_BASE_URL — full embedding endpoint override + * CLOUDFLARE_AI_GATEWAY_ID — route through a named AI Gateway; shared + * with the chat provider, selected by the + * cf-aig-gateway-id header + */ +export class CloudflareEmbeddingProvider implements EmbeddingProvider { + readonly name = "cloudflare"; + readonly dimensions: number; + private apiKey: string; + private model: string; + private baseUrl: string; + private gatewayId: string | undefined; + + constructor(apiKey?: string) { + this.apiKey = apiKey || getEnvVar("CLOUDFLARE_API_TOKEN") || ""; + if (!this.apiKey) { + throw new Error("CLOUDFLARE_API_TOKEN is required"); + } + this.model = + getEnvVar("CLOUDFLARE_EMBEDDING_MODEL") || CLOUDFLARE_DEFAULT_EMBEDDING_MODEL; + this.dimensions = resolveDimensions( + this.model, + getEnvVar("CLOUDFLARE_EMBEDDING_DIMENSIONS"), + ); + this.baseUrl = resolveEndpoint( + "embeddings", + "CLOUDFLARE_EMBEDDING_BASE_URL", + "embedding", + ); + this.gatewayId = resolveGatewayId(); + } + + async embed(text: string): Promise { + const [result] = await this.embedBatch([text]); + return result; + } + + async embedBatch(texts: string[]): Promise { + const response = await fetchWithTimeout(this.baseUrl, { + method: "POST", + headers: buildHeaders(this.apiKey, this.gatewayId), + body: JSON.stringify({ + model: this.model, + input: texts, + }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`Cloudflare embedding failed (${response.status}): ${err}`); + } + + const data = (await response.json()) as { + data: Array<{ embedding: number[] }>; + }; + + return data.data.map((d) => new Float32Array(d.embedding)); + } +} diff --git a/src/providers/embedding/index.ts b/src/providers/embedding/index.ts index d18de2328..7fcf9225d 100644 --- a/src/providers/embedding/index.ts +++ b/src/providers/embedding/index.ts @@ -7,6 +7,7 @@ import { CohereEmbeddingProvider } from "./cohere.js"; import { OpenRouterEmbeddingProvider } from "./openrouter.js"; import { LocalEmbeddingProvider } from "./local.js"; import { ClipEmbeddingProvider } from "./clip.js"; +import { CloudflareEmbeddingProvider } from "./cloudflare.js"; export { GeminiEmbeddingProvider, @@ -16,6 +17,7 @@ export { OpenRouterEmbeddingProvider, LocalEmbeddingProvider, ClipEmbeddingProvider, + CloudflareEmbeddingProvider, }; let imageEmbeddingProvider: EmbeddingProvider | null = null; @@ -36,6 +38,8 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { return withDimensionGuard(new GeminiEmbeddingProvider(getEnvVar("GEMINI_API_KEY")!)); case "openai": return withDimensionGuard(new OpenAIEmbeddingProvider(getEnvVar("OPENAI_API_KEY")!)); + case "cloudflare": + return withDimensionGuard(new CloudflareEmbeddingProvider(getEnvVar("CLOUDFLARE_API_TOKEN")!)); case "voyage": return withDimensionGuard(new VoyageEmbeddingProvider(getEnvVar("VOYAGE_API_KEY")!)); case "cohere": diff --git a/src/providers/index.ts b/src/providers/index.ts index 0ec3feba0..530b03508 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -5,6 +5,8 @@ import type { } from "../types.js"; import { AgentSDKProvider } from "./agent-sdk.js"; import { AnthropicProvider } from "./anthropic.js"; +import { CloudflareProvider } from "./cloudflare.js"; +import { CLOUDFLARE_DEFAULT_CHAT_MODEL } from "./_cloudflare-shared.js"; import { MinimaxProvider } from "./minimax.js"; import { NoopProvider } from "./noop.js"; import { OpenAIProvider } from "./openai.js"; @@ -36,6 +38,8 @@ function defaultModelFor(providerType: ProviderConfig["provider"]): string { switch (providerType) { case "openai": return getEnvVar("OPENAI_MODEL") || "gpt-4o-mini"; + case "cloudflare": + return getEnvVar("CLOUDFLARE_MODEL") || CLOUDFLARE_DEFAULT_CHAT_MODEL; case "anthropic": return getEnvVar("ANTHROPIC_MODEL") || "claude-sonnet-4-20250514"; case "gemini": @@ -100,6 +104,13 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { config.model, config.maxTokens, ); + case "cloudflare": + return new CloudflareProvider( + requireEnvVar("CLOUDFLARE_API_TOKEN"), + config.model, + config.maxTokens, + config.baseURL, + ); case "anthropic": return new AnthropicProvider( requireEnvVar("ANTHROPIC_API_KEY"), diff --git a/src/types.ts b/src/types.ts index 113daeae1..17ef03be1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -147,7 +147,7 @@ export interface ProviderConfig { baseURL?: string; } -export type ProviderType = "agent-sdk" | "anthropic" | "gemini" | "openrouter" | "minimax" | "openai" | "noop"; +export type ProviderType = "agent-sdk" | "anthropic" | "cloudflare" | "gemini" | "openrouter" | "minimax" | "openai" | "noop"; export interface MemoryProvider { name: string; diff --git a/src/viewer/index.html b/src/viewer/index.html index 3efe43425..946289080 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -3749,7 +3749,7 @@

agentmemory

icon: '⚠', title: f.label, keyLabel: f.key, - desc: f.description + (f.needsLlm ? ' Requires an LLM provider key (ANTHROPIC_API_KEY, GEMINI_API_KEY, etc.).' : ''), + desc: f.description + (f.needsLlm ? ' Requires an LLM provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY, CLOUDFLARE_API_TOKEN, etc.).' : ''), enable: f.enableHow, docs: f.docsHref, dismissKey: f.key, @@ -3762,7 +3762,7 @@

agentmemory

title: 'No LLM provider key set', keyLabel: 'ANTHROPIC_API_KEY', desc: 'Compression, summarization, and graph extraction stay disabled until a key is provided.', - enable: 'export ANTHROPIC_API_KEY=sk-ant-...\n# then restart: npx @agentmemory/agentmemory', + enable: 'export ANTHROPIC_API_KEY=sk-ant-...\n# or OPENAI_API_KEY, CLOUDFLARE_API_TOKEN, GEMINI_API_KEY, etc.\n# then restart: npx @agentmemory/agentmemory', docs: 'https://github.com/rohitg00/agentmemory#quick-start', dismissKey: '__provider_noop', }); @@ -3774,7 +3774,7 @@

agentmemory

title: 'Running in BM25-only mode', keyLabel: 'OPENAI_API_KEY', desc: 'Semantic vector search is off. BM25 keyword search is active and good for exact matches.', - enable: 'export OPENAI_API_KEY=sk-...\n# or VOYAGE_API_KEY, COHERE_API_KEY, OLLAMA_HOST', + enable: 'export OPENAI_API_KEY=sk-...\n# or CLOUDFLARE_API_TOKEN, VOYAGE_API_KEY, COHERE_API_KEY, OLLAMA_HOST', docs: 'https://github.com/rohitg00/agentmemory#embedding-providers', dismissKey: '__embedding_none', }); diff --git a/test/cloudflare-provider.test.ts b/test/cloudflare-provider.test.ts new file mode 100644 index 000000000..1eccdaca9 --- /dev/null +++ b/test/cloudflare-provider.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { CloudflareProvider } from "../src/providers/cloudflare.js"; +import { CloudflareEmbeddingProvider } from "../src/providers/embedding/cloudflare.js"; +import { loadConfig, loadFallbackConfig } from "../src/config.js"; + +const CLOUDFLARE_KEYS = [ + "CLOUDFLARE_API_TOKEN", + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_MODEL", + "CLOUDFLARE_AI_BASE_URL", + "CLOUDFLARE_AI_GATEWAY_ID", + "CLOUDFLARE_TIMEOUT_MS", + "CLOUDFLARE_EMBEDDING_MODEL", + "CLOUDFLARE_EMBEDDING_BASE_URL", + "CLOUDFLARE_EMBEDDING_DIMENSIONS", +]; + +// Keys that would win the detectProvider / detectEmbeddingProvider race and +// mask the Cloudflare branch under test. +const COMPETING_KEYS = [ + "OPENAI_API_KEY", + "MINIMAX_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "AGENTMEMORY_LLM_TIMEOUT_MS", + "FALLBACK_PROVIDERS", +]; + +// Blank rather than delete: getMergedEnv layers process.env over the +// developer's real ~/.agentmemory/.env, so deleting a key here would let that +// file's value through. Every read path treats "" as absent (hasRealValue +// trims, the rest test truthiness), so blanking neutralises both layers. +function clearEnv(keys: string[]): void { + for (const key of keys) process.env[key] = ""; +} + +const baseUrlOf = (p: CloudflareProvider) => + (p as unknown as { baseUrl: string }).baseUrl; +const timeoutOf = (p: CloudflareProvider) => + (p as unknown as { timeoutMs: number }).timeoutMs; + +describe("CloudflareProvider", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + clearEnv([...CLOUDFLARE_KEYS, ...COMPETING_KEYS]); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("builds the account-scoped OpenAI-compatible chat endpoint", () => { + process.env["CLOUDFLARE_ACCOUNT_ID"] = "acct-123"; + const provider = new CloudflareProvider("test-token", "@cf/meta/llama-3.1-8b-instruct-fp8", 800); + expect(baseUrlOf(provider)).toBe( + "https://api.cloudflare.com/client/v4/accounts/acct-123/ai/v1/chat/completions", + ); + }); + + it("prefers an explicit base URL over the account-derived one", () => { + process.env["CLOUDFLARE_ACCOUNT_ID"] = "acct-123"; + process.env["CLOUDFLARE_AI_BASE_URL"] = "https://gateway.example.com/v1/chat/completions"; + const provider = new CloudflareProvider("test-token", "@cf/meta/llama-3.1-8b-instruct-fp8", 800); + expect(baseUrlOf(provider)).toBe("https://gateway.example.com/v1/chat/completions"); + }); + + it("throws when neither account id nor base URL is available", () => { + expect( + () => new CloudflareProvider("test-token", "@cf/meta/llama-3.1-8b-instruct-fp8", 800), + ).toThrow(/CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_AI_BASE_URL/); + }); + + it("honors CLOUDFLARE_TIMEOUT_MS ahead of AGENTMEMORY_LLM_TIMEOUT_MS", () => { + process.env["CLOUDFLARE_ACCOUNT_ID"] = "acct-123"; + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "5000"; + process.env["CLOUDFLARE_TIMEOUT_MS"] = "12000"; + const provider = new CloudflareProvider("test-token", "@cf/meta/llama-3.1-8b-instruct-fp8", 800); + expect(timeoutOf(provider)).toBe(12000); + }); + + it("falls back to the 60s default when both timeout vars are unparseable", () => { + process.env["CLOUDFLARE_ACCOUNT_ID"] = "acct-123"; + process.env["CLOUDFLARE_TIMEOUT_MS"] = "not-a-number"; + const provider = new CloudflareProvider("test-token", "@cf/meta/llama-3.1-8b-instruct-fp8", 800); + expect(timeoutOf(provider)).toBe(60_000); + }); + + it("is selected by detectProvider when CLOUDFLARE_API_TOKEN is the only key", () => { + process.env["CLOUDFLARE_API_TOKEN"] = "test-token"; + process.env["CLOUDFLARE_ACCOUNT_ID"] = "acct-123"; + const config = loadConfig(); + expect(config.provider.provider).toBe("cloudflare"); + expect(config.provider.model).toBe("@cf/meta/llama-3.1-8b-instruct-fp8"); + }); + + it("is accepted as a FALLBACK_PROVIDERS entry", () => { + process.env["FALLBACK_PROVIDERS"] = "cloudflare"; + expect(loadFallbackConfig().providers).toContain("cloudflare"); + }); +}); + +describe("CloudflareEmbeddingProvider — dimensions", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + clearEnv([...CLOUDFLARE_KEYS, ...COMPETING_KEYS]); + process.env["CLOUDFLARE_ACCOUNT_ID"] = "acct-123"; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("defaults to 768 for bge-base-en-v1.5", () => { + expect(new CloudflareEmbeddingProvider("test-token").dimensions).toBe(768); + }); + + it("resolves 1024 for bge-large-en-v1.5 from the known-models table", () => { + process.env["CLOUDFLARE_EMBEDDING_MODEL"] = "@cf/baai/bge-large-en-v1.5"; + expect(new CloudflareEmbeddingProvider("test-token").dimensions).toBe(1024); + }); + + it("resolves 384 for bge-small-en-v1.5 from the known-models table", () => { + process.env["CLOUDFLARE_EMBEDDING_MODEL"] = "@cf/baai/bge-small-en-v1.5"; + expect(new CloudflareEmbeddingProvider("test-token").dimensions).toBe(384); + }); + + it("lets CLOUDFLARE_EMBEDDING_DIMENSIONS override a known model", () => { + process.env["CLOUDFLARE_EMBEDDING_MODEL"] = "@cf/baai/bge-large-en-v1.5"; + process.env["CLOUDFLARE_EMBEDDING_DIMENSIONS"] = "256"; + expect(new CloudflareEmbeddingProvider("test-token").dimensions).toBe(256); + }); + + it("rejects a non-positive dimensions override", () => { + process.env["CLOUDFLARE_EMBEDDING_DIMENSIONS"] = "0"; + expect(() => new CloudflareEmbeddingProvider("test-token")).toThrow( + /must be a positive integer/, + ); + }); + + it("throws without an API token", () => { + expect(() => new CloudflareEmbeddingProvider()).toThrow(/CLOUDFLARE_API_TOKEN is required/); + }); + + it("builds the account-scoped embeddings endpoint", () => { + const provider = new CloudflareEmbeddingProvider("test-token"); + expect((provider as unknown as { baseUrl: string }).baseUrl).toBe( + "https://api.cloudflare.com/client/v4/accounts/acct-123/ai/v1/embeddings", + ); + }); +}); + +describe("CloudflareProvider — response parsing", () => { + const originalEnv = process.env; + + const reply = (choice: unknown) => + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify({ choices: [choice] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const provider = () => + new CloudflareProvider("test-token", "@cf/zai-org/glm-4.7-flash", 64); + + beforeEach(() => { + process.env = { ...originalEnv }; + clearEnv([...CLOUDFLARE_KEYS, ...COMPETING_KEYS]); + process.env["CLOUDFLARE_ACCOUNT_ID"] = "acct-123"; + }); + + afterEach(() => { + process.env = originalEnv; + vi.unstubAllGlobals(); + }); + + it("returns message content when present", async () => { + reply({ finish_reason: "stop", message: { content: "a summary" } }); + await expect(provider().summarize("sys", "user")).resolves.toBe("a summary"); + }); + + // Reasoning models return content:null plus a populated `reasoning` field when + // the budget is spent thinking. That scratchpad must never reach memory. + it("never returns chain-of-thought when a reasoning model truncates", async () => { + reply({ + finish_reason: "length", + message: { content: null, reasoning: "1. **Analyze the input:** the user wants..." }, + }); + await expect(provider().summarize("sys", "user")).rejects.toThrow( + /hit the token limit before emitting content/, + ); + }); + + it("names MAX_TOKENS in the truncation error so the fix is obvious", async () => { + reply({ finish_reason: "length", message: { content: "" } }); + await expect(provider().compress("sys", "user")).rejects.toThrow(/MAX_TOKENS/); + }); + + it("falls back to the completion-style text field", async () => { + reply({ finish_reason: "stop", text: "legacy shape" }); + await expect(provider().summarize("sys", "user")).resolves.toBe("legacy shape"); + }); + + const headersOfLastCall = () => { + const mock = globalThis.fetch as unknown as { mock: { calls: unknown[][] } }; + return (mock.mock.calls[0]![1] as RequestInit).headers as Record; + }; + + // AI Gateway selects a named gateway by header, not by a different base URL. + it("omits cf-aig-gateway-id when no gateway is pinned", async () => { + reply({ finish_reason: "stop", message: { content: "x" } }); + await provider().summarize("sys", "user"); + expect(headersOfLastCall()).not.toHaveProperty("cf-aig-gateway-id"); + }); + + it("sends cf-aig-gateway-id when CLOUDFLARE_AI_GATEWAY_ID is set", async () => { + process.env["CLOUDFLARE_AI_GATEWAY_ID"] = "my-gateway"; + reply({ finish_reason: "stop", message: { content: "x" } }); + await provider().summarize("sys", "user"); + expect(headersOfLastCall()["cf-aig-gateway-id"]).toBe("my-gateway"); + }); +}); diff --git a/test/embedding-provider.test.ts b/test/embedding-provider.test.ts index 6c2d263ec..32a98b715 100644 --- a/test/embedding-provider.test.ts +++ b/test/embedding-provider.test.ts @@ -5,6 +5,7 @@ import { } from "../src/providers/embedding/index.js"; import { GeminiEmbeddingProvider } from "../src/providers/embedding/gemini.js"; import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; +import { CloudflareEmbeddingProvider } from "../src/providers/embedding/cloudflare.js"; import type { EmbeddingProvider } from "../src/types.js"; describe("createEmbeddingProvider", () => { @@ -14,6 +15,7 @@ describe("createEmbeddingProvider", () => { process.env = { ...originalEnv }; delete process.env["GEMINI_API_KEY"]; delete process.env["OPENAI_API_KEY"]; + delete process.env["CLOUDFLARE_API_TOKEN"]; delete process.env["VOYAGE_API_KEY"]; delete process.env["COHERE_API_KEY"]; delete process.env["OPENROUTER_API_KEY"]; @@ -43,6 +45,14 @@ describe("createEmbeddingProvider", () => { expect(provider!.name).toBe("openai"); }); + it("returns CloudflareEmbeddingProvider when CLOUDFLARE_API_TOKEN is set", () => { + process.env["CLOUDFLARE_API_TOKEN"] = "test-key-789"; + process.env["CLOUDFLARE_ACCOUNT_ID"] = "test-account"; + const provider = createEmbeddingProvider(); + expect(provider).toBeInstanceOf(CloudflareEmbeddingProvider); + expect(provider!.name).toBe("cloudflare"); + }); + it("EMBEDDING_PROVIDER override takes precedence", () => { process.env["GEMINI_API_KEY"] = "test-key-123"; process.env["OPENAI_API_KEY"] = "test-key-456"; From 889d2a4f033e3974dcd420ad5591fbf763d9679f Mon Sep 17 00:00:00 2001 From: Adrian Coroi Date: Sat, 25 Jul 2026 19:36:58 +0200 Subject: [PATCH 2/2] fix(providers): address CodeRabbit review on the Cloudflare provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Setup hints advertised CLOUDFLARE_API_TOKEN alone. Cloudflare is the only provider needing a second variable, so doctor, init, the diagnostics moreInfo and the three viewer banners now name CLOUDFLARE_ACCOUNT_ID (or the base-URL override) alongside the token. - CLOUDFLARE_EMBEDDING_DIMENSIONS used parseInt, which took "1024abc" as 1024 and "10.5" as 10 — silently producing vectors withDimensionGuard rejects on every embed. It now shares the strict parsePositiveInt used for CLOUDFLARE_TIMEOUT_MS, moved into _cloudflare-shared.ts so the two providers stop carrying separate copies. Covered by a table test. - MODEL_DIMENSIONS doc claimed the override was "required" for unknown models; the code falls back to the default size instead. Reworded to describe what actually happens. - README omitted CLOUDFLARE_AI_BASE_URL, CLOUDFLARE_EMBEDDING_BASE_URL and CLOUDFLARE_AI_GATEWAY_ID from the env blocks. Re-verified against live Workers AI: chat and 768-dim embeddings still resolve through the default path. Signed-off-by: Adrian Coroi --- README.md | 3 +++ src/cli.ts | 10 +++++++--- src/cli/doctor-diagnostics.ts | 3 ++- src/providers/_cloudflare-shared.ts | 15 +++++++++++++++ src/providers/cloudflare.ts | 9 +-------- src/providers/embedding/cloudflare.ts | 19 ++++++++++++------- src/viewer/index.html | 6 +++--- test/cloudflare-provider.test.ts | 12 ++++++++++++ 8 files changed, 55 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index a7b2154f3..f6ff0e360 100644 --- a/README.md +++ b/README.md @@ -1385,6 +1385,8 @@ Create `~/.agentmemory/.env`: # MINIMAX_API_KEY=... # CLOUDFLARE_API_TOKEN=... # Workers AI; also enables Cloudflare embeddings # CLOUDFLARE_ACCOUNT_ID=... # Required unless CLOUDFLARE_AI_BASE_URL is set +# CLOUDFLARE_AI_BASE_URL=... # Override the chat endpoint +# CLOUDFLARE_AI_GATEWAY_ID=... # Pin a named AI Gateway (cf-aig-gateway-id) # OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the # # OpenAI LLM provider (here) AND the OpenAI # # embedding provider (further below). Set @@ -1424,6 +1426,7 @@ Create `~/.agentmemory/.env`: # CLOUDFLARE_ACCOUNT_ID=... # CLOUDFLARE_EMBEDDING_MODEL=@cf/baai/bge-base-en-v1.5 # CLOUDFLARE_EMBEDDING_DIMENSIONS=768 # Required when the model is not in the known-models table +# CLOUDFLARE_EMBEDDING_BASE_URL=... # Override the embedding endpoint # Outbound LLM / embedding timeout # AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every diff --git a/src/cli.ts b/src/cli.ts index ae830d6de..f6e83fe2e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1794,14 +1794,18 @@ async function passiveServerChecks(): Promise { { name: "LLM provider", ok: hasLlm, - hint: hasLlm ? undefined : "set ANTHROPIC_API_KEY (or OPENAI/CLOUDFLARE/GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env", + hint: hasLlm + ? undefined + : "set ANTHROPIC_API_KEY (or OPENAI/CLOUDFLARE/GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env. " + + "Cloudflare also needs CLOUDFLARE_ACCOUNT_ID (or CLOUDFLARE_AI_BASE_URL)", }, { name: "Embedding provider", ok: hasEmbed, hint: hasEmbed ? undefined - : "Running BM25-only. Add OPENAI_API_KEY / CLOUDFLARE_API_TOKEN / VOYAGE_API_KEY / COHERE_API_KEY / OLLAMA_HOST", + : "Running BM25-only. Add OPENAI_API_KEY / CLOUDFLARE_API_TOKEN (plus CLOUDFLARE_ACCOUNT_ID) / " + + "VOYAGE_API_KEY / COHERE_API_KEY / OLLAMA_HOST", }, ); @@ -2229,7 +2233,7 @@ async function runInit() { "All keys are commented out by default. Uncomment the ones you want.", "", "Common next steps:", - " 1. Pick an LLM provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY / CLOUDFLARE_API_TOKEN / GEMINI_API_KEY / etc.)", + " 1. Pick an LLM provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY / CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID / GEMINI_API_KEY / etc.)", " 2. Run `npx @agentmemory/agentmemory doctor` to verify the daemon sees them", " 3. Run `npx @agentmemory/agentmemory` to start the worker", ].join("\n"), diff --git a/src/cli/doctor-diagnostics.ts b/src/cli/doctor-diagnostics.ts index 67f566fc8..d619baf60 100644 --- a/src/cli/doctor-diagnostics.ts +++ b/src/cli/doctor-diagnostics.ts @@ -200,7 +200,8 @@ export function buildDiagnostics(effects: DoctorEffects): Diagnostic[] { moreInfo: "Set at least one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, CLOUDFLARE_API_TOKEN, GEMINI_API_KEY, " + "OPENROUTER_API_KEY, MINIMAX_API_KEY. The daemon picks the first that resolves " + - "to a real (non-placeholder) value at startup.", + "to a real (non-placeholder) value at startup. CLOUDFLARE_API_TOKEN additionally " + + "requires CLOUDFLARE_ACCOUNT_ID unless a Cloudflare base URL is set.", check: async () => { if (!effects.envFileExists()) { return { ok: false, detail: "env file missing (run env-missing fix first)" }; diff --git a/src/providers/_cloudflare-shared.ts b/src/providers/_cloudflare-shared.ts index 04cf1aaef..f1fa2e810 100644 --- a/src/providers/_cloudflare-shared.ts +++ b/src/providers/_cloudflare-shared.ts @@ -40,6 +40,21 @@ export function resolveGatewayId(): string | undefined { return getEnvVar("CLOUDFLARE_AI_GATEWAY_ID") || undefined; } +/** + * Strict positive-integer parse: the whole string must be digits. + * + * parseInt() would accept "1024abc" as 1024 and "10.5" as 10. For a dimension + * count that silently produces vectors withDimensionGuard rejects on every + * embed, so a typo has to fail at parse time, not at first use. + */ +export function parsePositiveInt(raw: string | undefined): number | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) return undefined; + const n = Number(trimmed); + return Number.isFinite(n) && n > 0 ? n : undefined; +} + /** * Auth + content headers, plus AI Gateway selection. * diff --git a/src/providers/cloudflare.ts b/src/providers/cloudflare.ts index 55cf79c29..340a3050a 100644 --- a/src/providers/cloudflare.ts +++ b/src/providers/cloudflare.ts @@ -4,6 +4,7 @@ import { fetchWithTimeout } from "./_fetch.js"; import { CLOUDFLARE_DEFAULT_CHAT_MODEL, buildHeaders, + parsePositiveInt, resolveEndpoint, resolveGatewayId, } from "./_cloudflare-shared.js"; @@ -121,11 +122,3 @@ function resolveTimeout(): number { const parsed = parsePositiveInt(raw); return parsed ?? DEFAULT_TIMEOUT_MS; } - -function parsePositiveInt(raw: string | undefined): number | undefined { - if (!raw) return undefined; - const trimmed = raw.trim(); - if (!/^\d+$/.test(trimmed)) return undefined; - const n = Number(trimmed); - return Number.isFinite(n) && n > 0 ? n : undefined; -} diff --git a/src/providers/embedding/cloudflare.ts b/src/providers/embedding/cloudflare.ts index f175141ca..146475b47 100644 --- a/src/providers/embedding/cloudflare.ts +++ b/src/providers/embedding/cloudflare.ts @@ -4,15 +4,18 @@ import { fetchWithTimeout } from "../_fetch.js"; import { CLOUDFLARE_DEFAULT_EMBEDDING_MODEL, buildHeaders, + parsePositiveInt, resolveEndpoint, resolveGatewayId, } from "../_cloudflare-shared.js"; /** * Known Workers AI embedding model dimensions. Extend as new models ship. - * Override in any case via CLOUDFLARE_EMBEDDING_DIMENSIONS — a wrong value - * here makes withDimensionGuard throw on every embed call, so an unknown - * model falling back to the default size is a hard failure, not a silent one. + * + * A model absent from this table reports DEFAULT_DIMENSIONS rather than + * throwing; if that guess is wrong, withDimensionGuard rejects the first embed + * with the real size in the message. Set CLOUDFLARE_EMBEDDING_DIMENSIONS to + * skip that round trip. */ const MODEL_DIMENSIONS: Record = { "@cf/baai/bge-small-en-v1.5": 384, @@ -27,8 +30,8 @@ const DEFAULT_DIMENSIONS = MODEL_DIMENSIONS[CLOUDFLARE_DEFAULT_EMBEDDING_MODEL] function resolveDimensions(model: string, override: string | undefined): number { if (override !== undefined && override.trim().length > 0) { - const parsed = parseInt(override, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { + const parsed = parsePositiveInt(override); + if (parsed === undefined) { throw new Error( `CLOUDFLARE_EMBEDDING_DIMENSIONS must be a positive integer, got: ${override}`, ); @@ -53,8 +56,10 @@ function resolveDimensions(model: string, override: string | undefined): number * Optional: * CLOUDFLARE_EMBEDDING_MODEL — model name (default: * CLOUDFLARE_DEFAULT_EMBEDDING_MODEL) - * CLOUDFLARE_EMBEDDING_DIMENSIONS — override reported dimensions (required for - * models not in the MODEL_DIMENSIONS table above) + * CLOUDFLARE_EMBEDDING_DIMENSIONS — override reported dimensions; set it for + * models absent from the MODEL_DIMENSIONS + * table above, which otherwise report the + * default size * CLOUDFLARE_EMBEDDING_BASE_URL — full embedding endpoint override * CLOUDFLARE_AI_GATEWAY_ID — route through a named AI Gateway; shared * with the chat provider, selected by the diff --git a/src/viewer/index.html b/src/viewer/index.html index 946289080..6225d3749 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -3749,7 +3749,7 @@

agentmemory

icon: '⚠', title: f.label, keyLabel: f.key, - desc: f.description + (f.needsLlm ? ' Requires an LLM provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY, CLOUDFLARE_API_TOKEN, etc.).' : ''), + desc: f.description + (f.needsLlm ? ' Requires an LLM provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY, CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID, etc.).' : ''), enable: f.enableHow, docs: f.docsHref, dismissKey: f.key, @@ -3762,7 +3762,7 @@

agentmemory

title: 'No LLM provider key set', keyLabel: 'ANTHROPIC_API_KEY', desc: 'Compression, summarization, and graph extraction stay disabled until a key is provided.', - enable: 'export ANTHROPIC_API_KEY=sk-ant-...\n# or OPENAI_API_KEY, CLOUDFLARE_API_TOKEN, GEMINI_API_KEY, etc.\n# then restart: npx @agentmemory/agentmemory', + enable: 'export ANTHROPIC_API_KEY=sk-ant-...\n# or OPENAI_API_KEY, GEMINI_API_KEY, etc.\n# Cloudflare: export CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=...\n# then restart: npx @agentmemory/agentmemory', docs: 'https://github.com/rohitg00/agentmemory#quick-start', dismissKey: '__provider_noop', }); @@ -3774,7 +3774,7 @@

agentmemory

title: 'Running in BM25-only mode', keyLabel: 'OPENAI_API_KEY', desc: 'Semantic vector search is off. BM25 keyword search is active and good for exact matches.', - enable: 'export OPENAI_API_KEY=sk-...\n# or CLOUDFLARE_API_TOKEN, VOYAGE_API_KEY, COHERE_API_KEY, OLLAMA_HOST', + enable: 'export OPENAI_API_KEY=sk-...\n# or VOYAGE_API_KEY, COHERE_API_KEY, OLLAMA_HOST\n# Cloudflare: export CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=...', docs: 'https://github.com/rohitg00/agentmemory#embedding-providers', dismissKey: '__embedding_none', }); diff --git a/test/cloudflare-provider.test.ts b/test/cloudflare-provider.test.ts index 1eccdaca9..9ca32ac4b 100644 --- a/test/cloudflare-provider.test.ts +++ b/test/cloudflare-provider.test.ts @@ -143,6 +143,18 @@ describe("CloudflareEmbeddingProvider — dimensions", () => { ); }); + // parseInt would take "1024abc" as 1024 and "10.5" as 10, producing vectors + // withDimensionGuard rejects on every embed. Typos fail at parse time. + it.each(["1024abc", "10.5", "-768", "abc", "1e3"])( + "rejects the malformed dimensions override %j", + (value) => { + process.env["CLOUDFLARE_EMBEDDING_DIMENSIONS"] = value; + expect(() => new CloudflareEmbeddingProvider("test-token")).toThrow( + /must be a positive integer/, + ); + }, + ); + it("throws without an API token", () => { expect(() => new CloudflareEmbeddingProvider()).toThrow(/CLOUDFLARE_API_TOKEN is required/); });