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..f6ff0e360 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,10 @@ 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 +# 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 @@ -1417,6 +1422,11 @@ 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 +# 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 2ae20f08b..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 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 / 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 / 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 10d95136d..d619baf60 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,9 +198,10 @@ 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.", + "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/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..f1fa2e810 --- /dev/null +++ b/src/providers/_cloudflare-shared.ts @@ -0,0 +1,74 @@ +// 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; +} + +/** + * 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. + * + * 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..340a3050a --- /dev/null +++ b/src/providers/cloudflare.ts @@ -0,0 +1,124 @@ +import type { MemoryProvider } from "../types.js"; +import { getEnvVar } from "../config.js"; +import { fetchWithTimeout } from "./_fetch.js"; +import { + CLOUDFLARE_DEFAULT_CHAT_MODEL, + buildHeaders, + parsePositiveInt, + 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; +} diff --git a/src/providers/embedding/cloudflare.ts b/src/providers/embedding/cloudflare.ts new file mode 100644 index 000000000..146475b47 --- /dev/null +++ b/src/providers/embedding/cloudflare.ts @@ -0,0 +1,121 @@ +import type { EmbeddingProvider } from "../../types.js"; +import { getEnvVar } from "../../config.js"; +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. + * + * 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, + "@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 = parsePositiveInt(override); + if (parsed === undefined) { + 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; 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 + * 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..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, GEMINI_API_KEY, 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# 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 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 new file mode 100644 index 000000000..9ca32ac4b --- /dev/null +++ b/test/cloudflare-provider.test.ts @@ -0,0 +1,243 @@ +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/, + ); + }); + + // 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/); + }); + + 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";