From 56b0cff66de175584337d5b53ea58e0f92081374 Mon Sep 17 00:00:00 2001 From: Erick Date: Sun, 21 Jun 2026 09:34:15 -0700 Subject: [PATCH 1/2] feat(memos): add OpenRouter provider routing --- .../src/ingest/providers/openai.ts | 6 ++ .../src/shared/llm-call.ts | 27 ++++-- apps/memos-local-openclaw/src/types.ts | 4 + .../tests/llm-call.test.ts | 70 +++++++++++++++ .../core/config/defaults.ts | 6 ++ apps/memos-local-plugin/core/config/schema.ts | 16 ++++ .../core/embedding/types.ts | 4 + .../core/llm/providers/openai.ts | 17 ++++ apps/memos-local-plugin/core/llm/types.ts | 4 + .../docs/CONFIG-ADVANCED.md | 24 +++++ .../tests/unit/config/load.test.ts | 45 ++++++++++ .../tests/unit/llm/providers.test.ts | 89 +++++++++++++++++++ 12 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 apps/memos-local-openclaw/tests/llm-call.test.ts diff --git a/apps/memos-local-openclaw/src/ingest/providers/openai.ts b/apps/memos-local-openclaw/src/ingest/providers/openai.ts index 825e2131d..be384285e 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/openai.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/openai.ts @@ -583,5 +583,11 @@ function buildRequestBody(cfg: SummarizerConfig, body: Record): if (isZhipuEndpoint(endpoint)) { body.thinking = { type: "disabled" }; } + if (endpoint.includes("openrouter.ai")) { + const providerPrefs: Record = {}; + if (cfg.providerIgnore?.length) providerPrefs.ignore = cfg.providerIgnore; + if (cfg.providerOrder?.length) providerPrefs.order = cfg.providerOrder; + if (Object.keys(providerPrefs).length > 0) body.provider = providerPrefs; + } return body; } diff --git a/apps/memos-local-openclaw/src/shared/llm-call.ts b/apps/memos-local-openclaw/src/shared/llm-call.ts index aa868fc4c..bc1743b56 100644 --- a/apps/memos-local-openclaw/src/shared/llm-call.ts +++ b/apps/memos-local-openclaw/src/shared/llm-call.ts @@ -210,16 +210,18 @@ async function callLLMOnceOpenAI( Authorization: `Bearer ${cfg.apiKey}`, ...cfg.headers, }; + const body: Record = { + model, + temperature: opts.temperature ?? 0.1, + max_tokens: opts.maxTokens ?? 1024, + messages: [{ role: "user", content: prompt }], + }; + applyOpenRouterProviderRouting(cfg, body); const resp = await fetch(endpoint, { method: "POST", headers, - body: JSON.stringify({ - model, - temperature: opts.temperature ?? 0.1, - max_tokens: opts.maxTokens ?? 1024, - messages: [{ role: "user", content: prompt }], - }), + body: JSON.stringify(body), signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000), }); @@ -232,6 +234,19 @@ async function callLLMOnceOpenAI( return json.choices[0]?.message?.content?.trim() ?? ""; } +function applyOpenRouterProviderRouting( + cfg: SummarizerConfig, + body: Record, +): void { + const endpoint = cfg.endpoint ?? ""; + if (!endpoint.includes("openrouter.ai")) return; + + const providerPrefs: Record = {}; + if (cfg.providerIgnore?.length) providerPrefs.ignore = cfg.providerIgnore; + if (cfg.providerOrder?.length) providerPrefs.order = cfg.providerOrder; + if (Object.keys(providerPrefs).length > 0) body.provider = providerPrefs; +} + /** * Call LLM with fallback chain: tries each config in order until one succeeds. * Returns the result string, or throws if ALL configs fail. diff --git a/apps/memos-local-openclaw/src/types.ts b/apps/memos-local-openclaw/src/types.ts index cb08eb1cf..ea900ecc5 100644 --- a/apps/memos-local-openclaw/src/types.ts +++ b/apps/memos-local-openclaw/src/types.ts @@ -177,6 +177,10 @@ export interface ProviderConfig { headers?: Record; timeoutMs?: number; temperature?: number; + /** OpenRouter provider routing — providers to skip. */ + providerIgnore?: string[]; + /** OpenRouter provider routing — preferred order. */ + providerOrder?: string[]; capabilities?: SharingCapabilities; } diff --git a/apps/memos-local-openclaw/tests/llm-call.test.ts b/apps/memos-local-openclaw/tests/llm-call.test.ts new file mode 100644 index 000000000..5b818b333 --- /dev/null +++ b/apps/memos-local-openclaw/tests/llm-call.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { callLLMOnce } from "../src/shared/llm-call"; +import type { SummarizerConfig } from "../src/types"; + +describe("shared/llm-call", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("adds OpenRouter provider preferences for OpenAI-compatible calls", async () => { + const cap: { url?: string; init?: RequestInit } = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown, init?: unknown) => { + cap.url = String(url); + cap.init = init as RequestInit; + return new Response( + JSON.stringify({ choices: [{ message: { content: "ok" } }] }), + { status: 200 }, + ); + }), + ); + + const cfg: SummarizerConfig = { + provider: "openai_compatible", + endpoint: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + model: "google/gemini-test", + providerIgnore: ["together", "novita"], + providerOrder: ["google", "anthropic"], + }; + + const result = await callLLMOnce(cfg, "summarize this"); + + expect(result).toBe("ok"); + expect(cap.url).toBe("https://openrouter.ai/api/v1/chat/completions"); + const body = JSON.parse(cap.init!.body as string); + expect(body.provider).toEqual({ + ignore: ["together", "novita"], + order: ["google", "anthropic"], + }); + }); + + it("omits provider preferences for non-OpenRouter OpenAI-compatible calls", async () => { + const cap: { init?: RequestInit } = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: unknown, init?: unknown) => { + cap.init = init as RequestInit; + return new Response( + JSON.stringify({ choices: [{ message: { content: "ok" } }] }), + { status: 200 }, + ); + }), + ); + + const cfg: SummarizerConfig = { + provider: "openai_compatible", + endpoint: "https://api.openai.com/v1", + apiKey: "sk-test", + model: "gpt-test", + providerIgnore: ["together"], + providerOrder: ["google"], + }; + + await callLLMOnce(cfg, "summarize this"); + + const body = JSON.parse(cap.init!.body as string); + expect("provider" in body).toBe(false); + }); +}); diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 1cf2d2cf6..bb76e1ecd 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -27,6 +27,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = { endpoint: "", model: "Xenova/all-MiniLM-L6-v2", apiKey: "", + providerIgnore: [], + providerOrder: [], cache: { enabled: true, maxItems: 20_000, @@ -41,6 +43,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = { apiKey: "", timeoutMs: 45_000, maxRetries: 3, + providerIgnore: [], + providerOrder: [], }, skillEvolver: { // Empty by default — falls back to the shared `llm` settings. @@ -52,6 +56,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = { apiKey: "", temperature: 0, timeoutMs: 60_000, + providerIgnore: [], + providerOrder: [], }, algorithm: { lightweightMemory: { diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 7c9ff193b..655f8ad91 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -39,6 +39,10 @@ const EmbeddingSchema = Type.Object({ endpoint: StringWithDefault(""), model: StringWithDefault("Xenova/all-MiniLM-L6-v2"), apiKey: StringWithDefault(""), + /** OpenRouter provider routing — providers to skip. */ + providerIgnore: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** OpenRouter provider routing — preferred order. */ + providerOrder: Type.Optional(Type.Array(Type.String(), { default: [] })), cache: Type.Object({ enabled: Bool(true), maxItems: NumberInRange(20_000, 0), @@ -65,6 +69,12 @@ const LlmSchema = Type.Object({ timeoutMs: NumberInRange(45_000, 1_000), /** Max retries on transient errors. */ maxRetries: NumberInRange(3, 0, 10), + /** OpenRouter provider routing — providers to skip. */ + providerIgnore: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** OpenRouter provider routing — preferred order. */ + providerOrder: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ + reasoning: Type.Optional(ReasoningSchema), }, { default: {} }); /** @@ -89,6 +99,12 @@ const SkillEvolverSchema = Type.Object({ apiKey: StringWithDefault(""), temperature: NumberInRange(0, 0, 2), timeoutMs: NumberInRange(60_000, 1_000), + /** OpenRouter provider routing — providers to skip. */ + providerIgnore: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** OpenRouter provider routing — preferred order. */ + providerOrder: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ + reasoning: Type.Optional(ReasoningSchema), }, { default: {} }); const AlgorithmSchema = Type.Object({ diff --git a/apps/memos-local-plugin/core/embedding/types.ts b/apps/memos-local-plugin/core/embedding/types.ts index 5371b875e..436b665bf 100644 --- a/apps/memos-local-plugin/core/embedding/types.ts +++ b/apps/memos-local-plugin/core/embedding/types.ts @@ -27,6 +27,10 @@ export interface EmbeddingConfig { model: string; dimensions: number; apiKey?: string; + /** OpenRouter provider routing — providers to skip. */ + providerIgnore?: string[]; + /** OpenRouter provider routing — preferred order. */ + providerOrder?: string[]; cache: { enabled: boolean; maxItems: number; diff --git a/apps/memos-local-plugin/core/llm/providers/openai.ts b/apps/memos-local-plugin/core/llm/providers/openai.ts index c24c96236..edd8670ba 100644 --- a/apps/memos-local-plugin/core/llm/providers/openai.ts +++ b/apps/memos-local-plugin/core/llm/providers/openai.ts @@ -72,6 +72,8 @@ export class OpenAiLlmProvider implements LlmProvider { }; if (opts.jsonMode) body.response_format = { type: "json_object" }; if (opts.stop && opts.stop.length > 0) body.stop = opts.stop; + if (config.reasoning) body.reasoning = config.reasoning; + applyOpenRouterProviderRouting(config, body); const { json, durationMs } = await httpPostJson({ url, @@ -132,6 +134,8 @@ export class OpenAiLlmProvider implements LlmProvider { }; if (opts.jsonMode) body.response_format = { type: "json_object" }; if (opts.stop && opts.stop.length > 0) body.stop = opts.stop; + if (config.reasoning) body.reasoning = config.reasoning; + applyOpenRouterProviderRouting(config, body); const resp = await httpPostStream({ url, @@ -196,6 +200,19 @@ function normalizeEndpoint(url: string): string { return `${stripped}/chat/completions`; } +function applyOpenRouterProviderRouting( + config: LlmProviderCtx["config"], + body: Record, +): void { + const endpoint = config.endpoint ?? ""; + if (!endpoint.includes("openrouter.ai")) return; + + const providerPrefs: Record = {}; + if (config.providerIgnore?.length) providerPrefs.ignore = config.providerIgnore; + if (config.providerOrder?.length) providerPrefs.order = config.providerOrder; + if (Object.keys(providerPrefs).length > 0) body.provider = providerPrefs; +} + function mapFinish(reason: string | undefined): ProviderCompletion["finishReason"] { switch (reason) { case "stop": diff --git a/apps/memos-local-plugin/core/llm/types.ts b/apps/memos-local-plugin/core/llm/types.ts index ddfe80c1e..e5185cfc8 100644 --- a/apps/memos-local-plugin/core/llm/types.ts +++ b/apps/memos-local-plugin/core/llm/types.ts @@ -28,6 +28,10 @@ export interface LlmConfig { apiKey?: string; timeoutMs: number; maxRetries: number; + /** OpenRouter provider routing — providers to skip. */ + providerIgnore?: string[]; + /** OpenRouter provider routing — preferred order. */ + providerOrder?: string[]; /** Optional per-call default. Default: 1024. */ maxTokens?: number; /** Extra HTTP headers for outgoing requests. */ diff --git a/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md b/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md index 014e3deea..643d32fac 100644 --- a/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md +++ b/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md @@ -47,6 +47,30 @@ llm: maxRetries: 3 ``` +For OpenRouter-backed OpenAI-compatible models, you can ask OpenRouter to +skip providers that are unreliable for reflection/scoring prompts: + +```yaml +llm: + provider: openai_compatible + endpoint: https://openrouter.ai/api/v1 + model: google/gemini-2.5-flash-lite + apiKey: sk-or-v1-... + providerIgnore: + - together + - deepinfra + - novita + # providerOrder: + # - google + # - anthropic + # - openai +``` + +The same `providerIgnore` and `providerOrder` fields are accepted on +`skillEvolver`, `entityExtractor`, `l3Llm`, and `embedding` config blocks. +They map to OpenRouter's `provider.ignore` and `provider.order` request +fields and are omitted when empty or when the endpoint is not OpenRouter. + ### `algorithm` Direct mapping to the V7 spec (γ, support, gain, top-K, etc.). Change only if you know what you're doing — defaults are calibrated for the paper. diff --git a/apps/memos-local-plugin/tests/unit/config/load.test.ts b/apps/memos-local-plugin/tests/unit/config/load.test.ts index 77f2f6b3f..b6363c106 100644 --- a/apps/memos-local-plugin/tests/unit/config/load.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/load.test.ts @@ -21,6 +21,20 @@ describe("config/loadConfig", () => { expect(result.config.embedding.provider).toBe(DEFAULT_CONFIG.embedding.provider); }); + it("defaults OpenRouter provider routing lists to empty arrays", () => { + const cfg = resolveConfig({}); + expect(cfg.llm.providerIgnore).toEqual([]); + expect(cfg.llm.providerOrder).toEqual([]); + expect(cfg.skillEvolver.providerIgnore).toEqual([]); + expect(cfg.skillEvolver.providerOrder).toEqual([]); + expect(cfg.l3Llm.providerIgnore).toEqual([]); + expect(cfg.l3Llm.providerOrder).toEqual([]); + expect(cfg.entityExtractor.providerIgnore).toEqual([]); + expect(cfg.entityExtractor.providerOrder).toEqual([]); + expect(cfg.embedding.providerIgnore).toEqual([]); + expect(cfg.embedding.providerOrder).toEqual([]); + }); + it("merges YAML over defaults and preserves unspecified branches", async () => { const yaml = ` viewer: @@ -42,6 +56,37 @@ algorithm: expect(ctx.config.algorithm.skill.minSupport).toBe(DEFAULT_CONFIG.algorithm.skill.minSupport); }); + it("accepts OpenRouter provider routing fields on LLM config branches", () => { + const cfg = resolveConfig({ + llm: { + providerIgnore: ["together", "deepinfra"], + providerOrder: ["google", "anthropic"], + }, + skillEvolver: { + providerIgnore: ["novita"], + providerOrder: ["openai"], + }, + l3Llm: { + providerIgnore: ["together"], + }, + entityExtractor: { + providerOrder: ["google"], + }, + embedding: { + providerIgnore: ["deepinfra"], + providerOrder: ["openai"], + }, + }); + expect(cfg.llm.providerIgnore).toEqual(["together", "deepinfra"]); + expect(cfg.llm.providerOrder).toEqual(["google", "anthropic"]); + expect(cfg.skillEvolver.providerIgnore).toEqual(["novita"]); + expect(cfg.skillEvolver.providerOrder).toEqual(["openai"]); + expect(cfg.l3Llm.providerIgnore).toEqual(["together"]); + expect(cfg.entityExtractor.providerOrder).toEqual(["google"]); + expect(cfg.embedding.providerIgnore).toEqual(["deepinfra"]); + expect(cfg.embedding.providerOrder).toEqual(["openai"]); + }); + it("rejects invalid types with a helpful error", async () => { // Don't use makeTmpHome here — it would eagerly loadConfig and throw // before we can capture it. Lay out the dir manually instead. diff --git a/apps/memos-local-plugin/tests/unit/llm/providers.test.ts b/apps/memos-local-plugin/tests/unit/llm/providers.test.ts index 03cb17e8a..5f167fa7b 100644 --- a/apps/memos-local-plugin/tests/unit/llm/providers.test.ts +++ b/apps/memos-local-plugin/tests/unit/llm/providers.test.ts @@ -99,6 +99,95 @@ describe("llm/providers", () => { const p = new OpenAiLlmProvider(); await expect(p.complete(msgs, call(), ctxFor(cfg({ apiKey: "" })))).rejects.toBeInstanceOf(MemosError); }); + + it("forwards config.reasoning into the request body", async () => { + const cap = captureFetch({ choices: [{ message: { content: "{}" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete(msgs, call(), ctxFor(cfg({ reasoning: { enabled: false } }))); + const body = JSON.parse(cap.init!.body as string); + expect(body.reasoning).toEqual({ enabled: false }); + }); + + it("omits reasoning from the body when config.reasoning is unset", async () => { + const cap = captureFetch({ choices: [{ message: { content: "{}" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete(msgs, call(), ctxFor(cfg())); + const body = JSON.parse(cap.init!.body as string); + expect("reasoning" in body).toBe(false); + }); + + it("adds OpenRouter provider preferences for non-streaming calls", async () => { + const cap = captureFetch({ choices: [{ message: { content: "ok" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete( + msgs, + call(), + ctxFor( + cfg({ + endpoint: "https://openrouter.ai/api/v1", + providerIgnore: ["together", "deepinfra"], + providerOrder: ["google", "anthropic"], + }), + ), + ); + const body = JSON.parse(cap.init!.body as string); + expect(body.provider).toEqual({ + ignore: ["together", "deepinfra"], + order: ["google", "anthropic"], + }); + }); + + it("omits provider preferences for non-OpenRouter endpoints", async () => { + const cap = captureFetch({ choices: [{ message: { content: "ok" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete( + msgs, + call(), + ctxFor( + cfg({ + endpoint: "https://api.openai.com/v1", + providerIgnore: ["together"], + providerOrder: ["google"], + }), + ), + ); + const body = JSON.parse(cap.init!.body as string); + expect("provider" in body).toBe(false); + }); + + it("adds OpenRouter provider preferences for streaming calls", async () => { + const cap: { url?: string; init?: RequestInit } = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown, init?: unknown) => { + cap.url = String(url); + cap.init = init as RequestInit; + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }), + ); + const p = new OpenAiLlmProvider(); + for await (const _chunk of p.stream( + msgs, + call(), + ctxFor( + cfg({ + endpoint: "https://openrouter.ai/api/v1", + providerIgnore: ["novita"], + providerOrder: ["google"], + }), + ), + )) { + // Drain the stream so the fetch request is issued. + } + const body = JSON.parse(cap.init!.body as string); + expect(body.provider).toEqual({ + ignore: ["novita"], + order: ["google"], + }); + }); }); // ─── anthropic ───────────────────────────────────────────────────────────── From fe8908950f86f94f38a4aadd8c0e660951a8b385 Mon Sep 17 00:00:00 2001 From: Erick Date: Sun, 21 Jun 2026 10:46:03 -0700 Subject: [PATCH 2/2] fix(memos): route OpenRouter prefs through all slots --- .../core/embedding/providers/openai.ts | 17 ++- .../core/pipeline/memory-core.ts | 17 ++- .../tests/unit/config/load.test.ts | 12 -- .../tests/unit/embedding/providers.test.ts | 37 ++++++ .../pipeline/bootstrap-llm-config.test.ts | 113 ++++++++++++++++++ 5 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 apps/memos-local-plugin/tests/unit/pipeline/bootstrap-llm-config.test.ts diff --git a/apps/memos-local-plugin/core/embedding/providers/openai.ts b/apps/memos-local-plugin/core/embedding/providers/openai.ts index fd454ae29..48b0439b8 100644 --- a/apps/memos-local-plugin/core/embedding/providers/openai.ts +++ b/apps/memos-local-plugin/core/embedding/providers/openai.ts @@ -40,9 +40,11 @@ export class OpenAiEmbeddingProvider implements EmbeddingProvider { : "https://api.openai.com/v1/embeddings", ); const model = config.model && config.model.length > 0 ? config.model : "text-embedding-3-small"; + const body: Record = { input: texts, model }; + applyOpenRouterProviderRouting(config, body); const resp = await httpPostJson({ url, - body: { input: texts, model }, + body, headers: { Authorization: `Bearer ${config.apiKey}`, ...config.headers, @@ -82,3 +84,16 @@ function normalizeEndpoint(url: string): string { if (stripped.endsWith("/embeddings")) return stripped; return `${stripped}/embeddings`; } + +function applyOpenRouterProviderRouting( + config: ProviderCallCtx["config"], + body: Record, +): void { + const endpoint = config.endpoint ?? ""; + if (!endpoint.includes("openrouter.ai")) return; + + const providerPrefs: Record = {}; + if (config.providerIgnore?.length) providerPrefs.ignore = config.providerIgnore; + if (config.providerOrder?.length) providerPrefs.order = config.providerOrder; + if (Object.keys(providerPrefs).length > 0) body.provider = providerPrefs; +} diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index b4e331c71..70ecd1658 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -113,6 +113,18 @@ import type { UserFeedback } from "../reward/types.js"; const FINAL_HUB_LLM_FILTER_TIMEOUT_MS = 3_000; const IMPORT_WRITE_BATCH_SIZE = 500; +type DedicatedLlmConfig = { + provider?: string; + model?: string; + endpoint?: string; + apiKey?: string; + temperature?: number; + timeoutMs?: number; + providerIgnore?: string[]; + providerOrder?: string[]; + reasoning?: ReasoningConfig; +}; + export interface BootstrapOptions { agent: AgentKind; namespace?: RuntimeNamespace; @@ -376,7 +388,7 @@ export async function bootstrapMemoryCoreFull( // back to the main `llm` when skillEvolver.model is blank. let reflectLlm: ReturnType | null = null; try { - const evolver = (config as { skillEvolver?: { provider?: string; model?: string; endpoint?: string; apiKey?: string; temperature?: number; timeoutMs?: number } }).skillEvolver; + const evolver = (config as { skillEvolver?: DedicatedLlmConfig }).skillEvolver; const evolverModel = (evolver?.model ?? "").trim(); const evolverProvider = (evolver?.provider ?? "").trim(); if (evolverModel && evolverProvider) { @@ -387,6 +399,9 @@ export async function bootstrapMemoryCoreFull( apiKey: evolver?.apiKey ?? "", temperature: evolver?.temperature ?? 0, timeoutMs: evolver?.timeoutMs ?? 60_000, + providerIgnore: evolver?.providerIgnore, + providerOrder: evolver?.providerOrder, + reasoning: evolver?.reasoning, maxRetries: 3, // V7 §0.x — when the user's dedicated skill-evolver model is // down (auth, model name typo, server outage), prefer falling diff --git a/apps/memos-local-plugin/tests/unit/config/load.test.ts b/apps/memos-local-plugin/tests/unit/config/load.test.ts index b6363c106..51f7af07c 100644 --- a/apps/memos-local-plugin/tests/unit/config/load.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/load.test.ts @@ -27,10 +27,6 @@ describe("config/loadConfig", () => { expect(cfg.llm.providerOrder).toEqual([]); expect(cfg.skillEvolver.providerIgnore).toEqual([]); expect(cfg.skillEvolver.providerOrder).toEqual([]); - expect(cfg.l3Llm.providerIgnore).toEqual([]); - expect(cfg.l3Llm.providerOrder).toEqual([]); - expect(cfg.entityExtractor.providerIgnore).toEqual([]); - expect(cfg.entityExtractor.providerOrder).toEqual([]); expect(cfg.embedding.providerIgnore).toEqual([]); expect(cfg.embedding.providerOrder).toEqual([]); }); @@ -66,12 +62,6 @@ algorithm: providerIgnore: ["novita"], providerOrder: ["openai"], }, - l3Llm: { - providerIgnore: ["together"], - }, - entityExtractor: { - providerOrder: ["google"], - }, embedding: { providerIgnore: ["deepinfra"], providerOrder: ["openai"], @@ -81,8 +71,6 @@ algorithm: expect(cfg.llm.providerOrder).toEqual(["google", "anthropic"]); expect(cfg.skillEvolver.providerIgnore).toEqual(["novita"]); expect(cfg.skillEvolver.providerOrder).toEqual(["openai"]); - expect(cfg.l3Llm.providerIgnore).toEqual(["together"]); - expect(cfg.entityExtractor.providerOrder).toEqual(["google"]); expect(cfg.embedding.providerIgnore).toEqual(["deepinfra"]); expect(cfg.embedding.providerOrder).toEqual(["openai"]); }); diff --git a/apps/memos-local-plugin/tests/unit/embedding/providers.test.ts b/apps/memos-local-plugin/tests/unit/embedding/providers.test.ts index 5123073cf..62cfb9e77 100644 --- a/apps/memos-local-plugin/tests/unit/embedding/providers.test.ts +++ b/apps/memos-local-plugin/tests/unit/embedding/providers.test.ts @@ -131,6 +131,43 @@ describe("embedding/providers", () => { expect(cap.url).toBe("https://x.example.com/embeddings"); }); + it("adds OpenRouter provider preferences for embedding calls", async () => { + const cap = captureFetchRequest(); + const p = new OpenAiEmbeddingProvider(); + await p.embed( + ["a"], + "document", + ctxFor(cfg({ + provider: "openai_compatible", + endpoint: "https://openrouter.ai/api/v1", + providerIgnore: ["together", "deepinfra"], + providerOrder: ["openai"], + })), + ); + const body = JSON.parse(cap.init!.body as string); + expect(body.provider).toEqual({ + ignore: ["together", "deepinfra"], + order: ["openai"], + }); + }); + + it("omits provider preferences for non-OpenRouter embedding calls", async () => { + const cap = captureFetchRequest(); + const p = new OpenAiEmbeddingProvider(); + await p.embed( + ["a"], + "document", + ctxFor(cfg({ + provider: "openai_compatible", + endpoint: "https://api.openai.com/v1", + providerIgnore: ["together"], + providerOrder: ["openai"], + })), + ); + const body = JSON.parse(cap.init!.body as string); + expect("provider" in body).toBe(false); + }); + it("rejects malformed response (no data[])", async () => { mockResponses([ new Response(JSON.stringify({ notdata: true }), { status: 200 }), diff --git a/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-llm-config.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-llm-config.test.ts new file mode 100644 index 000000000..fb761cd1f --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-llm-config.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { makeTmpHome, type TmpHomeContext } from "../../helpers/tmp-home.js"; +import type { + LlmCallOptions, + LlmClient, + LlmClientStats, + LlmConfig, + LlmMessage, + LlmProviderName, +} from "../../../core/llm/types.js"; +import type { MemoryCore } from "../../../agent-contract/memory-core.js"; + +const capturedLlmConfigs: LlmConfig[] = []; + +function fakeLlmClient(config: LlmConfig): LlmClient { + return { + provider: config.provider as LlmProviderName, + model: config.model, + canStream: false, + async complete(_messages: LlmMessage[] | string, _opts?: LlmCallOptions) { + return { + text: "{}", + provider: config.provider as LlmProviderName, + model: config.model, + servedBy: config.provider as LlmProviderName, + durationMs: 0, + }; + }, + async completeJson() { + return { + value: {} as T, + raw: "{}", + provider: config.provider as LlmProviderName, + model: config.model, + servedBy: config.provider as LlmProviderName, + durationMs: 0, + }; + }, + async *stream() { + yield { delta: "", done: true }; + }, + stats(): LlmClientStats { + return { + requests: 0, + hostFallbacks: 0, + failures: 0, + retries: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + lastOkAt: null, + lastError: null, + lastStatus: null, + }; + }, + resetStats() {}, + async close() {}, + }; +} + +vi.mock("../../../core/llm/client.js", () => ({ + createLlmClient: (config: LlmConfig) => { + capturedLlmConfigs.push(config); + return fakeLlmClient(config); + }, +})); + +describe("bootstrapMemoryCore dedicated LLM config", () => { + let home: TmpHomeContext | null = null; + let core: MemoryCore | null = null; + + afterEach(async () => { + if (core) await core.shutdown(); + if (home) await home.cleanup(); + core = null; + home = null; + capturedLlmConfigs.length = 0; + vi.resetModules(); + }); + + it("forwards OpenRouter provider routing to dedicated LLM clients", async () => { + const { bootstrapMemoryCore } = await import("../../../core/pipeline/memory-core.js"); + home = await makeTmpHome({ + agent: "openclaw", + configYaml: ` +llm: + provider: local_only + model: main +skillEvolver: + provider: openai_compatible + endpoint: https://openrouter.ai/api/v1 + model: skill-model + apiKey: sk-test + providerIgnore: + - together + providerOrder: + - anthropic +`, + }); + + core = await bootstrapMemoryCore({ + agent: "openclaw", + home: home.home, + config: home.config, + pkgVersion: "bootstrap-test", + }); + + expect(capturedLlmConfigs.find((cfg) => cfg.model === "skill-model")).toMatchObject({ + providerIgnore: ["together"], + providerOrder: ["anthropic"], + }); + }); +});