diff --git a/README.md b/README.md index 51adce7c..e0597a45 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,7 @@ Query → BM25 FTS ─────┘ }, "smartExtraction": true, "llm": { + "api": "openai-completions", "apiKey": "${OPENAI_API_KEY}", "model": "gpt-4o-mini", "baseURL": "https://api.openai.com/v1" @@ -509,13 +510,21 @@ Any Jina-compatible rerank endpoint also works — set `rerankProvider: "jina"` When `smartExtraction` is enabled (default: `true`), the plugin uses an LLM to intelligently extract and classify memories instead of regex-based triggers. +Sensitive config fields support both `${ENV_VAR}` interpolation and direct Bitwarden Secrets Manager refs via `bws://`: + +- `embedding.apiKey` +- `llm.apiKey` +- `retrieval.rerankApiKey` + | Field | Type | Default | Description | |-------|------|---------|-------------| | `smartExtraction` | boolean | `true` | Enable/disable LLM-powered 6-category extraction | +| `llm.api` | string | `openai-completions` | `openai-completions` uses OpenAI-compatible chat completions; `anthropic-messages` uses Anthropic-compatible `/v1/messages` | | `llm.auth` | string | `api-key` | `api-key` uses `llm.apiKey` / `embedding.apiKey`; `oauth` uses a plugin-scoped OAuth token file by default | | `llm.apiKey` | string | *(falls back to `embedding.apiKey`)* | API key for the LLM provider | | `llm.model` | string | `openai/gpt-oss-120b` | LLM model name | | `llm.baseURL` | string | *(falls back to `embedding.baseURL`)* | LLM API endpoint | +| `llm.anthropicVersion` | string | `2023-06-01` | `anthropic-version` header used when `llm.api = anthropic-messages` | | `llm.oauthProvider` | string | `openai-codex` | OAuth provider id used when `llm.auth` is `oauth` | | `llm.oauthPath` | string | `~/.openclaw/.memory-lancedb-pro/oauth.json` | OAuth token file used when `llm.auth` is `oauth` | | `llm.timeoutMs` | number | `30000` | LLM request timeout in milliseconds | @@ -527,6 +536,7 @@ OAuth `llm` config (use existing Codex / ChatGPT login cache for LLM calls): ```json { "llm": { + "api": "openai-completions", "auth": "oauth", "oauthProvider": "openai-codex", "model": "gpt-5.4", @@ -538,12 +548,27 @@ OAuth `llm` config (use existing Codex / ChatGPT login cache for LLM calls): Notes for `llm.auth: "oauth"`: +- OAuth currently requires `llm.api: "openai-completions"`. - `llm.oauthProvider` is currently `openai-codex`. - OAuth tokens default to `~/.openclaw/.memory-lancedb-pro/oauth.json`. - You can set `llm.oauthPath` if you want to store that file somewhere else. - `auth login` snapshots the previous api-key `llm` config next to the OAuth file, and `auth logout` restores that snapshot when available. - Switching from `api-key` to `oauth` does not automatically carry over `llm.baseURL`. Set it manually in OAuth mode only when you intentionally want a custom ChatGPT/Codex-compatible backend. +Anthropic-compatible `llm` config: +```json +{ + "llm": { + "api": "anthropic-messages", + "apiKey": "bws://YOUR-BWS-SECRET-UUID", + "model": "claude-sonnet-4-5", + "baseURL": "https://api.anthropic.com/v1", + "anthropicVersion": "2023-06-01", + "timeoutMs": 30000 + } +} +``` +
diff --git a/cli.ts b/cli.ts index 99203916..e890d750 100644 --- a/cli.ts +++ b/cli.ts @@ -109,10 +109,12 @@ function resolveConfiguredOauthPath(configPath: string, rawPath: unknown): strin } type RestorableApiKeyLlmConfig = { + api?: "openai-completions" | "anthropic-messages"; auth?: "api-key"; apiKey?: string; model?: string; baseURL?: string; + anthropicVersion?: string; timeoutMs?: number; }; @@ -136,6 +138,9 @@ function extractRestorableApiKeyLlmConfig(value: unknown): RestorableApiKeyLlmCo } const result: RestorableApiKeyLlmConfig = {}; + if (value.api === "openai-completions" || value.api === "anthropic-messages") { + result.api = value.api; + } if (value.auth === "api-key") { result.auth = "api-key"; } @@ -148,6 +153,9 @@ function extractRestorableApiKeyLlmConfig(value: unknown): RestorableApiKeyLlmCo if (typeof value.baseURL === "string") { result.baseURL = value.baseURL; } + if (typeof value.anthropicVersion === "string") { + result.anthropicVersion = value.anthropicVersion; + } if (typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs) && value.timeoutMs > 0) { result.timeoutMs = Math.trunc(value.timeoutMs); } @@ -160,6 +168,7 @@ function extractOauthSafeLlmConfig(value: unknown): RestorableApiKeyLlmConfig { } const result: RestorableApiKeyLlmConfig = {}; + result.api = "openai-completions"; if (typeof value.baseURL === "string") { result.baseURL = value.baseURL; } @@ -540,6 +549,7 @@ export function registerMemoryCLI(program: Command, context: CLIContext): void { } pluginConfig.llm = { ...nextLlm, + api: "openai-completions", auth: "oauth", oauthProvider: selectedProvider.providerId, model: oauthModel, @@ -589,6 +599,7 @@ export function registerMemoryCLI(program: Command, context: CLIContext): void { console.log(`Config file: ${configPath}`); console.log(`Plugin: ${pluginId}`); + console.log(`llm.api: ${typeof llm.api === "string" ? llm.api : "openai-completions"}`); console.log(`llm.auth: ${typeof llm.auth === "string" ? llm.auth : "api-key"}`); console.log(`llm.oauthProvider: ${oauthProviderDisplay}`); console.log(`llm.model: ${typeof llm.model === "string" ? llm.model : "openai/gpt-oss-120b"}`); diff --git a/examples/new-session-distill/README.md b/examples/new-session-distill/README.md index 832a042e..2e34a887 100644 --- a/examples/new-session-distill/README.md +++ b/examples/new-session-distill/README.md @@ -4,7 +4,7 @@ This example shows a **non-blocking /new distillation pipeline**: - Trigger: `command:new` (when you type `/new`) - Hook: enqueue a small JSON task file (fast, no LLM calls) -- Worker: a user-level systemd service watches the inbox and runs **Gemini Map-Reduce** over the session JSONL transcript +- Worker: a user-level systemd service watches the inbox and runs **Gemini or Anthropic-compatible Map-Reduce** over the session JSONL transcript - Storage: write high-signal, atomic lessons into LanceDB Pro via `openclaw memory-pro import` - Notify: send a notification message (optional) @@ -13,7 +13,19 @@ Files included: - `worker/lesson-extract-worker.mjs` — Map-Reduce extractor + importer + notifier - `worker/systemd/lesson-extract-worker.service` — user systemd unit -You must provide: -- `GEMINI_API_KEY` in an env file loaded by systemd +You must provide one of: + +- Gemini-native: + - `DISTILL_API=gemini-native` or omit it + - `GEMINI_API_KEY` or `DISTILL_API_KEY` + - optional `GEMINI_MODEL` / `DISTILL_MODEL` +- Anthropic-compatible: + - `DISTILL_API=anthropic-messages` + - `DISTILL_API_KEY` + - `DISTILL_MODEL` + - optional `DISTILL_BASE_URL` (defaults to `https://api.anthropic.com/v1`) + - optional `DISTILL_ANTHROPIC_VERSION` (defaults to `2023-06-01`) + +`DISTILL_API_KEY` and `GEMINI_API_KEY` also accept `bws://` Bitwarden Secrets Manager refs. Install steps are documented in the main repo README. diff --git a/examples/new-session-distill/worker/lesson-extract-worker.mjs b/examples/new-session-distill/worker/lesson-extract-worker.mjs index 11b3da13..23731628 100644 --- a/examples/new-session-distill/worker/lesson-extract-worker.mjs +++ b/examples/new-session-distill/worker/lesson-extract-worker.mjs @@ -15,11 +15,13 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { spawn } from "node:child_process"; +import { spawn, execFile as execFileCallback } from "node:child_process"; import readline from "node:readline"; +import { promisify } from "node:util"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const execFile = promisify(execFileCallback); // In your deployment, set LESSON_QUEUE_ROOT to your workspace queue. // By default we assume repo layout similar to OpenClaw-Memory. @@ -31,8 +33,10 @@ const PROCESSING = path.join(QUEUE_ROOT, "processing"); const DONE = path.join(QUEUE_ROOT, "done"); const ERROR = path.join(QUEUE_ROOT, "error"); -const GEMINI_API_KEY = process.env.GEMINI_API_KEY; -const GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-3-flash-preview"; +const DISTILL_API = process.env.DISTILL_API || "gemini-native"; +const DISTILL_MODEL = process.env.DISTILL_MODEL || process.env.GEMINI_MODEL || "gemini-3-flash-preview"; +const DISTILL_BASE_URL = process.env.DISTILL_BASE_URL || ""; +const DISTILL_ANTHROPIC_VERSION = process.env.DISTILL_ANTHROPIC_VERSION || "2023-06-01"; const ONCE = process.argv.includes("--once"); @@ -65,6 +69,58 @@ function safeJsonParse(text) { } } +function resolveEnvVars(value) { + return String(value).replace(/\$\{([^}]+)\}/g, (_, envVar) => { + const envValue = process.env[envVar]; + if (!envValue) { + throw new Error(`Environment variable ${envVar} is not set`); + } + return envValue; + }); +} + +// NOTE: This duplicates the logic in src/secret-resolver.ts (parseBitwardenSecretRef + +// resolveBitwardenSecret). This worker is a standalone ESM script that cannot import +// the compiled TypeScript, so the logic lives here as well. +// IMPORTANT: If you update either copy, keep the other in sync. +async function resolveMaybeBitwardenSecret(value) { + const resolved = resolveEnvVars(String(value || "").trim()); + if (!resolved) return ""; + if (!resolved.startsWith("bws://")) return resolved; + + const parsed = new URL(resolved); + const secretId = `${parsed.hostname}${parsed.pathname}`.replace(/^\/+/, "").replace(/^secret\//i, ""); + if (!secretId) throw new Error(`Invalid Bitwarden secret reference: ${resolved}`); + + const args = ["secret", "get", secretId, "--output", "json"]; + const accessToken = parsed.searchParams.get("accessToken"); + const configFile = parsed.searchParams.get("configFile"); + const profile = parsed.searchParams.get("profile"); + const serverUrl = parsed.searchParams.get("serverUrl"); + if (accessToken) args.push("--access-token", resolveEnvVars(accessToken)); + if (configFile) args.push("--config-file", resolveEnvVars(configFile)); + if (profile) args.push("--profile", resolveEnvVars(profile)); + if (serverUrl) args.push("--server-url", resolveEnvVars(serverUrl)); + + const { stdout } = await execFile("bws", args, { timeout: 10_000 }); + const payload = JSON.parse(stdout); + if (typeof payload?.value !== "string" || !payload.value.trim()) { + throw new Error(`Bitwarden secret ${secretId} has no value`); + } + return payload.value; +} + +async function resolveDistillApiKey() { + const raw = process.env.DISTILL_API_KEY || process.env.GEMINI_API_KEY || ""; + if (!raw.trim()) { + if (DISTILL_API === "gemini-native") { + throw new Error("DISTILL_API_KEY or GEMINI_API_KEY is not set"); + } + throw new Error("DISTILL_API_KEY is not set"); + } + return resolveMaybeBitwardenSecret(raw); +} + function normalizeText(s) { return (s || "") .trim() @@ -156,9 +212,8 @@ function buildMapPrompt({ lang, chunk }) { } async function geminiGenerateJson(prompt) { - if (!GEMINI_API_KEY) throw new Error("GEMINI_API_KEY is not set"); - - const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`; + const apiKey = await resolveDistillApiKey(); + const url = `https://generativelanguage.googleapis.com/v1beta/models/${DISTILL_MODEL}:generateContent?key=${encodeURIComponent(apiKey)}`; const body = { contents: [{ role: "user", parts: [{ text: prompt }] }], @@ -183,6 +238,56 @@ async function geminiGenerateJson(prompt) { return text; } +function normalizeAnthropicEndpoint(baseURL) { + const trimmed = String(baseURL || "").trim(); + if (!trimmed) return "https://api.anthropic.com/v1/messages"; + if (/\/messages\/?$/i.test(trimmed)) return trimmed; + return `${trimmed.replace(/\/+$/, "")}/messages`; +} + +// TODO: This builds Anthropic requests independently from createAnthropicApiKeyClient +// in src/llm-client.ts. The two implementations may diverge over time (e.g. retry +// logic, model defaults, streaming). Consider unifying into a shared module. +async function anthropicGenerateJson(prompt) { + const apiKey = await resolveDistillApiKey(); + const res = await fetch(normalizeAnthropicEndpoint(DISTILL_BASE_URL), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-api-key": apiKey, + "anthropic-version": DISTILL_ANTHROPIC_VERSION, + }, + body: JSON.stringify({ + model: DISTILL_MODEL, + system: "You extract high-signal technical lessons. Return valid JSON only.", + messages: [{ role: "user", content: prompt }], + max_tokens: 4096, + temperature: 0.2, + }), + }); + + const json = await res.json(); + if (!res.ok) { + throw new Error(`Anthropic error ${res.status}: ${JSON.stringify(json).slice(0, 500)}`); + } + + const text = Array.isArray(json?.content) + ? json.content + .filter((part) => part && part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("") + : ""; + return text; +} + +async function generateJson(prompt) { + if (DISTILL_API === "anthropic-messages") { + return anthropicGenerateJson(prompt); + } + return geminiGenerateJson(prompt); +} + function coerceLessons(obj) { const lessons = Array.isArray(obj?.lessons) ? obj.lessons : []; return lessons @@ -297,7 +402,7 @@ async function processTaskFile(taskPath) { for (let idx = 0; idx < chunks.length; idx++) { const prompt = buildMapPrompt({ lang, chunk: chunks[idx] }); try { - const text = await geminiGenerateJson(prompt); + const text = await generateJson(prompt); const obj = safeJsonParse(text); if (!obj) { mapErrors++; diff --git a/index.ts b/index.ts index 52f1962e..22784433 100644 --- a/index.ts +++ b/index.ts @@ -163,10 +163,12 @@ interface PluginConfig { // Smart extraction config smartExtraction?: boolean; llm?: { + api?: "openai-completions" | "anthropic-messages"; auth?: "api-key" | "oauth"; apiKey?: string; model?: string; baseURL?: string; + anthropicVersion?: string; oauthProvider?: string; oauthPath?: string; timeoutMs?: number; @@ -1705,11 +1707,14 @@ const memoryLanceDBProPlugin = { : undefined; const llmTimeoutMs = resolveLlmTimeoutMs(config); + const llmApi = config.llm?.api || "openai-completions"; const llmClient = createLlmClient({ + api: llmApi, auth: llmAuth, apiKey: llmApiKey, model: llmModel, baseURL: llmBaseURL, + anthropicVersion: config.llm?.anthropicVersion, oauthProvider: llmOauthProvider, oauthPath: llmOauthPath, timeoutMs: llmTimeoutMs, @@ -2205,11 +2210,14 @@ const memoryLanceDBProPlugin = { ? config.llm?.oauthProvider : undefined; const llmTimeoutMs = resolveLlmTimeoutMs(config); + const llmApi = config.llm?.api || "openai-completions"; return createLlmClient({ + api: llmApi, auth: llmAuth, apiKey: llmApiKey, model: config.llm?.model || "openai/gpt-oss-120b", baseURL: llmBaseURL, + anthropicVersion: config.llm?.anthropicVersion, oauthProvider: llmOauthProvider, oauthPath: llmOauthPath, timeoutMs: llmTimeoutMs, diff --git a/openclaw.plugin.json b/openclaw.plugin.json index a2cfb1f5..cddbe6ec 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -666,6 +666,15 @@ "type": "object", "additionalProperties": false, "properties": { + "api": { + "type": "string", + "enum": [ + "openai-completions", + "anthropic-messages" + ], + "default": "openai-completions", + "description": "LLM API format. openai-completions for OpenAI-compatible endpoints, anthropic-messages for Anthropic /v1/messages." + }, "auth": { "type": "string", "enum": [ @@ -676,7 +685,8 @@ "description": "LLM authentication mode. oauth uses the local Codex/ChatGPT login cache instead of llm.apiKey." }, "apiKey": { - "type": "string" + "type": "string", + "description": "LLM API key. Supports ${ENV_VAR} and bws:// Bitwarden secret references." }, "model": { "type": "string", @@ -685,6 +695,11 @@ "baseURL": { "type": "string" }, + "anthropicVersion": { + "type": "string", + "default": "2023-06-01", + "description": "Anthropic API version header used when llm.api=anthropic-messages." + }, "oauthProvider": { "type": "string", "description": "OAuth provider id for llm.auth=oauth. Currently supported: openai-codex." @@ -902,8 +917,13 @@ "llm.apiKey": { "label": "LLM API Key", "sensitive": true, - "placeholder": "sk-... or ${GROQ_API_KEY}", - "help": "API key for LLM used by smart memory extraction (defaults to embedding.apiKey if omitted)" + "placeholder": "sk-... or ${GROQ_API_KEY} or bws://", + "help": "API key for LLM used by smart memory extraction (defaults to embedding.apiKey if omitted). Supports Bitwarden bws:// refs." + }, + "llm.api": { + "label": "LLM API Format", + "help": "openai-completions for OpenAI-compatible chat endpoints, anthropic-messages for Anthropic-compatible /v1/messages endpoints.", + "advanced": true }, "llm.model": { "label": "LLM Model", @@ -913,7 +933,13 @@ "llm.baseURL": { "label": "LLM Base URL", "placeholder": "https://api.groq.com/openai/v1", - "help": "OpenAI-compatible base URL for LLM (defaults to embedding.baseURL if omitted)", + "help": "Base URL for the configured llm.api format (defaults to embedding.baseURL if omitted for openai-completions)", + "advanced": true + }, + "llm.anthropicVersion": { + "label": "Anthropic Version", + "placeholder": "2023-06-01", + "help": "Anthropic API version header used when llm.api=anthropic-messages", "advanced": true }, "extractMinMessages": { @@ -1292,7 +1318,7 @@ }, "llm.auth": { "label": "LLM Auth", - "help": "api-key uses llm.apiKey or embedding.apiKey. oauth uses a plugin-scoped OAuth token file by default.", + "help": "api-key uses llm.apiKey or embedding.apiKey. oauth uses a plugin-scoped OAuth token file by default and only supports llm.api=openai-completions.", "advanced": true }, "llm.oauthProvider": { diff --git a/src/llm-client.ts b/src/llm-client.ts index 79182ede..3216c8bc 100644 --- a/src/llm-client.ts +++ b/src/llm-client.ts @@ -18,6 +18,8 @@ export interface LlmClientConfig { apiKey?: string; model: string; baseURL?: string; + api?: "openai-completions" | "anthropic-messages"; + anthropicVersion?: string; auth?: "api-key" | "oauth"; oauthPath?: string; oauthProvider?: string; @@ -177,6 +179,10 @@ function createApiKeyClient(config: LlmClientConfig, log: (msg: string) => void) throw new Error("LLM api-key mode requires llm.apiKey or embedding.apiKey"); } + if (config.api === "anthropic-messages") { + return createAnthropicApiKeyClient(config, log); + } + const client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseURL, @@ -410,9 +416,120 @@ function createOauthClient(config: LlmClientConfig, log: (msg: string) => void): }; } +function normalizeAnthropicMessagesEndpoint(baseURL?: string): string { + const trimmed = baseURL?.trim(); + if (!trimmed) return "https://api.anthropic.com/v1/messages"; + if (/\/messages\/?$/i.test(trimmed)) return trimmed; + return `${trimmed.replace(/\/+$/, "")}/messages`; +} + +function extractAnthropicText(payload: Record): string | null { + const content = Array.isArray(payload.content) ? payload.content : []; + const text = content + .filter( + (part) => + part && + typeof part === "object" && + (part as Record).type === "text" && + typeof (part as Record).text === "string", + ) + .map((part) => String((part as Record).text)) + .join(""); + return text.trim() || null; +} + +// TODO: anthropicGenerateJson in examples/new-session-distill/worker/lesson-extract-worker.mjs +// builds Anthropic requests independently of this function. The two may diverge over time +// (e.g. retry logic, model defaults, streaming). Consider unifying into a shared module. +function createAnthropicApiKeyClient(config: LlmClientConfig, log: (msg: string) => void): LlmClient { + let lastError: string | null = null; + const endpoint = normalizeAnthropicMessagesEndpoint(config.baseURL); + const anthropicVersion = config.anthropicVersion?.trim() || "2023-06-01"; + return { + async completeJson(prompt: string, label = "generic"): Promise { + lastError = null; + const { signal, dispose } = createTimeoutSignal(config.timeoutMs); + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-api-key": config.apiKey!, + "anthropic-version": anthropicVersion, + }, + signal, + body: JSON.stringify({ + model: config.model, + system: + "You are a memory extraction assistant. Always respond with valid JSON only.", + messages: [ + { + role: "user", + content: prompt, + }, + ], + max_tokens: 2048, + temperature: 0.1, + }), + }); + + const bodyText = await response.text(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${bodyText.slice(0, 300)}`); + } + + let payload: Record; + try { + payload = JSON.parse(bodyText) as Record; + } catch { + throw new Error(`Anthropic response is not JSON: ${bodyText.slice(0, 300)}`); + } + + const text = extractAnthropicText(payload); + if (!text) { + lastError = `memory-lancedb-pro: llm-client [${label}] Anthropic returned empty content`; + log(lastError); + return null; + } + const jsonStr = extractJsonFromResponse(text); + try { + return JSON.parse(jsonStr) as T; + } catch (err) { + try { + const repaired = repairCommonJson(jsonStr) as T; + log( + `memory-lancedb-pro: llm-client [${label}] Anthropic repaired JSON via heuristic repair (jsonChars=${jsonStr.length})`, + ); + return repaired; + } catch (repairErr) { + lastError = + `memory-lancedb-pro: llm-client [${label}] Anthropic JSON.parse failed: ${err instanceof Error ? err.message : String(err)}; repair failed: ${repairErr instanceof Error ? repairErr.message : String(repairErr)} (jsonChars=${jsonStr.length}, jsonPreview=${JSON.stringify(previewText(jsonStr))})`; + log(lastError); + return null; + } + } + } catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] Anthropic request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + log(lastError); + return null; + } finally { + dispose(); + } + }, + getLastError(): string | null { + return lastError; + }, + }; +} + export function createLlmClient(config: LlmClientConfig): LlmClient { const log = config.log ?? (() => {}); if (config.auth === "oauth") { + if (config.api === "anthropic-messages") { + throw new Error("LLM oauth mode only supports llm.api=openai-completions"); + } return createOauthClient(config, log); } return createApiKeyClient(config, log); diff --git a/test/llm-api-key-client.test.mjs b/test/llm-api-key-client.test.mjs index 6d7f7518..5cd6552d 100644 --- a/test/llm-api-key-client.test.mjs +++ b/test/llm-api-key-client.test.mjs @@ -65,4 +65,57 @@ describe("LLM api-key client", () => { ]); assert.equal(requestBody.temperature, 0.1); }); + + it("uses Anthropic-compatible messages semantics when llm.api=anthropic-messages", async () => { + let requestHeaders; + let requestBody; + + server = http.createServer(async (req, res) => { + requestHeaders = req.headers; + + let body = ""; + for await (const chunk of req) body += chunk; + requestBody = JSON.parse(body); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + id: "msg_test", + type: "message", + role: "assistant", + content: [ + { + type: "text", + text: "{\"memories\":[]}", + }, + ], + })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + + const llm = createLlmClient({ + api: "anthropic-messages", + auth: "api-key", + apiKey: "anthropic-test-key", + model: "claude-sonnet-4-5", + baseURL: `http://127.0.0.1:${port}/v1`, + anthropicVersion: "2023-06-01", + timeoutMs: 4321, + }); + + const result = await llm.completeJson("hello", "anthropic-probe"); + assert.deepEqual(result, { memories: [] }); + assert.equal(requestHeaders["x-api-key"], "anthropic-test-key"); + assert.equal(requestHeaders["anthropic-version"], "2023-06-01"); + assert.equal(requestBody.model, "claude-sonnet-4-5"); + assert.equal(requestBody.system, "You are a memory extraction assistant. Always respond with valid JSON only."); + assert.deepEqual(requestBody.messages, [ + { + role: "user", + content: "hello", + }, + ]); + assert.equal(requestBody.max_tokens, 2048); + assert.equal(requestBody.temperature, 0.1); + }); }); diff --git a/test/plugin-manifest-regression.mjs b/test/plugin-manifest-regression.mjs index 65e9ec23..768fb7f2 100644 --- a/test/plugin-manifest-regression.mjs +++ b/test/plugin-manifest-regression.mjs @@ -71,6 +71,10 @@ for (const key of [ ); } +assert.ok( + Object.prototype.hasOwnProperty.call(manifest.configSchema.properties.llm.properties, "api"), + "configSchema should declare llm.api", +); assert.ok( Object.prototype.hasOwnProperty.call(manifest.configSchema.properties.llm.properties, "auth"), "configSchema should declare llm.auth", @@ -83,6 +87,10 @@ assert.ok( Object.prototype.hasOwnProperty.call(manifest.configSchema.properties.llm.properties, "oauthProvider"), "configSchema should declare llm.oauthProvider", ); +assert.ok( + Object.prototype.hasOwnProperty.call(manifest.configSchema.properties.llm.properties, "anthropicVersion"), + "configSchema should declare llm.anthropicVersion", +); assert.equal( manifest.configSchema.properties.autoRecallMinRepeated.default, @@ -139,6 +147,7 @@ try { { dbPath: path.join(workDir, "db"), autoRecall: false, + selfImprovement: { enabled: false }, embedding: { provider: "openai-compatible", apiKey: "dummy", @@ -149,7 +158,7 @@ try { }, { services }, ); - plugin.register(api); + await plugin.register(api); assert.equal(services.length, 1, "plugin should register its background service"); assert.equal(typeof api.hooks.agent_end, "function", "autoCapture should remain enabled by default"); assert.equal(api.hooks["command:new"], undefined, "sessionMemory should stay disabled by default"); @@ -162,6 +171,7 @@ try { dbPath: path.join(workDir, "db-session-default"), autoCapture: false, autoRecall: false, + selfImprovement: { enabled: false }, sessionMemory: {}, embedding: { provider: "openai-compatible", @@ -171,7 +181,7 @@ try { dimensions: 1536, }, }); - plugin.register(sessionDefaultApi); + await plugin.register(sessionDefaultApi); assert.equal( sessionDefaultApi.hooks["command:new"], undefined, @@ -182,6 +192,7 @@ try { dbPath: path.join(workDir, "db-session-enabled"), autoCapture: false, autoRecall: false, + selfImprovement: { enabled: false }, sessionMemory: { enabled: true }, embedding: { provider: "openai-compatible", @@ -191,7 +202,7 @@ try { dimensions: 1536, }, }); - plugin.register(sessionEnabledApi); + await plugin.register(sessionEnabledApi); assert.equal( typeof sessionEnabledApi.hooks.before_reset, "function", @@ -263,7 +274,7 @@ try { chunking: false, }, }); - plugin.register(chunkingOffApi); + await plugin.register(chunkingOffApi); const chunkingOffTool = chunkingOffApi.toolFactories.memory_store({ agentId: "main", sessionKey: "agent:main:test", @@ -291,7 +302,7 @@ try { chunking: true, }, }); - plugin.register(chunkingOnApi); + await plugin.register(chunkingOnApi); const chunkingOnTool = chunkingOnApi.toolFactories.memory_store({ agentId: "main", sessionKey: "agent:main:test", @@ -318,7 +329,7 @@ try { dimensions: 4, }, }); - plugin.register(withDimensionsApi); + await plugin.register(withDimensionsApi); const withDimensionsTool = withDimensionsApi.toolFactories.memory_store({ agentId: "main", sessionKey: "agent:main:test", @@ -348,7 +359,7 @@ try { omitDimensions: true, }, }); - plugin.register(omitDimensionsApi); + await plugin.register(omitDimensionsApi); const omitDimensionsTool = omitDimensionsApi.toolFactories.memory_store({ agentId: "main", sessionKey: "agent:main:test",