Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<account-id>/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
Expand Down Expand Up @@ -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_KEYCOHERE_API_KEYOPENROUTER_API_KEY
# local (Xenova/all-MiniLM-L6-v2, 384-dim).
# OPENAI_API_KEY → CLOUDFLARE_API_TOKENVOYAGE_API_KEYCOHERE_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

Expand All @@ -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/<account-id>/ai/v1/embeddings

# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small # When EMBEDDING_PROVIDER=openrouter

# -----------------------------------------------------------------------------
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<your 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. |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1794,14 +1794,18 @@ async function passiveServerChecks(): Promise<DoctorCheck[]> {
{
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",
},
);

Expand Down Expand Up @@ -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"),
Expand Down
6 changes: 4 additions & 2 deletions src/cli/doctor-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)" };
Expand Down
2 changes: 2 additions & 0 deletions src/cli/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const AGENT_GLYPH: Record<string, string> = {
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" },
Expand All @@ -60,6 +61,7 @@ const PROVIDERS: { value: string; label: string; envKey: string | null }[] = [
const PROVIDER_COST_HINTS: Record<string, string> = {
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.",
Expand Down
17 changes: 16 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ function detectProvider(env: Record<string, string>): 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",
Expand Down Expand Up @@ -162,7 +174,7 @@ function detectProvider(env: Record<string, string>): 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",
),
);
Expand Down Expand Up @@ -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"]) ||
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -483,6 +497,7 @@ const VALID_PROVIDERS = new Set([
"agent-sdk",
"minimax",
"openai",
"cloudflare",
]);

export function loadFallbackConfig(): FallbackConfig {
Expand Down
2 changes: 1 addition & 1 deletion src/functions/consolidation-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/functions/summarize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
};
}

Expand Down
74 changes: 74 additions & 0 deletions src/providers/_cloudflare-shared.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
...(gatewayId ? { "cf-aig-gateway-id": gatewayId } : {}),
};
}
Loading