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
6 changes: 6 additions & 0 deletions apps/memos-local-openclaw/src/ingest/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,5 +583,11 @@ function buildRequestBody(cfg: SummarizerConfig, body: Record<string, unknown>):
if (isZhipuEndpoint(endpoint)) {
body.thinking = { type: "disabled" };
}
if (endpoint.includes("openrouter.ai")) {
const providerPrefs: Record<string, unknown> = {};
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;
}
27 changes: 21 additions & 6 deletions apps/memos-local-openclaw/src/shared/llm-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,16 +210,18 @@ async function callLLMOnceOpenAI(
Authorization: `Bearer ${cfg.apiKey}`,
...cfg.headers,
};
const body: Record<string, unknown> = {
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),
});

Expand All @@ -232,6 +234,19 @@ async function callLLMOnceOpenAI(
return json.choices[0]?.message?.content?.trim() ?? "";
}

function applyOpenRouterProviderRouting(
cfg: SummarizerConfig,
body: Record<string, unknown>,
): void {
const endpoint = cfg.endpoint ?? "";
if (!endpoint.includes("openrouter.ai")) return;

const providerPrefs: Record<string, unknown> = {};
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.
Expand Down
4 changes: 4 additions & 0 deletions apps/memos-local-openclaw/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ export interface ProviderConfig {
headers?: Record<string, string>;
timeoutMs?: number;
temperature?: number;
/** OpenRouter provider routing — providers to skip. */
providerIgnore?: string[];
/** OpenRouter provider routing — preferred order. */
providerOrder?: string[];
capabilities?: SharingCapabilities;
}

Expand Down
70 changes: 70 additions & 0 deletions apps/memos-local-openclaw/tests/llm-call.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 6 additions & 0 deletions apps/memos-local-plugin/core/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -52,6 +56,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
apiKey: "",
temperature: 0,
timeoutMs: 60_000,
providerIgnore: [],
providerOrder: [],
},
algorithm: {
lightweightMemory: {
Expand Down
16 changes: 16 additions & 0 deletions apps/memos-local-plugin/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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: {} });

/**
Expand All @@ -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({
Expand Down
17 changes: 16 additions & 1 deletion apps/memos-local-plugin/core/embedding/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { input: texts, model };
applyOpenRouterProviderRouting(config, body);
const resp = await httpPostJson<OpenAiResp>({
url,
body: { input: texts, model },
body,
headers: {
Authorization: `Bearer ${config.apiKey}`,
...config.headers,
Expand Down Expand Up @@ -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<string, unknown>,
): void {
const endpoint = config.endpoint ?? "";
if (!endpoint.includes("openrouter.ai")) return;

const providerPrefs: Record<string, unknown> = {};
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;
}
4 changes: 4 additions & 0 deletions apps/memos-local-plugin/core/embedding/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions apps/memos-local-plugin/core/llm/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OaResp>({
url,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -196,6 +200,19 @@ function normalizeEndpoint(url: string): string {
return `${stripped}/chat/completions`;
}

function applyOpenRouterProviderRouting(
config: LlmProviderCtx["config"],
body: Record<string, unknown>,
): void {
const endpoint = config.endpoint ?? "";
if (!endpoint.includes("openrouter.ai")) return;

const providerPrefs: Record<string, unknown> = {};
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":
Expand Down
4 changes: 4 additions & 0 deletions apps/memos-local-plugin/core/llm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
17 changes: 16 additions & 1 deletion apps/memos-local-plugin/core/pipeline/memory-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -376,7 +388,7 @@ export async function bootstrapMemoryCoreFull(
// back to the main `llm` when skillEvolver.model is blank.
let reflectLlm: ReturnType<typeof createLlmClient> | 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) {
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions apps/memos-local-plugin/docs/CONFIG-ADVANCED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading