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
2 changes: 2 additions & 0 deletions apps/memos-local-openclaw/src/ingest/providers/openai.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SummarizerConfig, Logger } from "../../types";
import { applyOpenRouterProviderRouting } from "../../shared/openrouter";

const SYSTEM_PROMPT = `You generate a retrieval-friendly title.

Expand Down Expand Up @@ -592,5 +593,6 @@ function buildRequestBody(cfg: SummarizerConfig, body: Record<string, unknown>):
if (isZhipuEndpoint(endpoint)) {
body.thinking = { type: "disabled" };
}
applyOpenRouterProviderRouting(cfg, body);
return body;
}
23 changes: 13 additions & 10 deletions apps/memos-local-openclaw/src/shared/llm-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as fs from "fs";
import * as path from "path";
import type { SummarizerConfig, SummaryProvider, Logger, PluginContext, OpenClawAPI } from "../types";
import { parseJsonOrJson5 } from "./json5";
import { applyOpenRouterProviderRouting } from "./openrouter";

/**
* Resolve a SecretInput (string | SecretRef) to a plain string.
Expand Down Expand Up @@ -189,8 +190,8 @@ async function callLLMOnceAnthropic(
});

if (!resp.ok) {
const body = await resp.text();
throw new Error(`LLM call failed (${resp.status}): ${body}`);
const errorText = await resp.text();
throw new Error(`LLM call failed (${resp.status}): ${errorText}`);
}

const json = (await resp.json()) as { content: Array<{ type: string; text: string }> };
Expand All @@ -211,22 +212,24 @@ async function callLLMOnceOpenAI(
Authorization: `Bearer ${cfg.apiKey}`,
...cfg.headers,
};
const requestBody: Record<string, unknown> = {
model,
temperature: opts.temperature ?? 0.1,
max_tokens: opts.maxTokens ?? 1024,
messages: [{ role: "user", content: prompt }],
};
applyOpenRouterProviderRouting(cfg, requestBody);

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(requestBody),
signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000),
});

if (!resp.ok) {
const body = await resp.text();
throw new Error(`LLM call failed (${resp.status}): ${body}`);
const errorText = await resp.text();
throw new Error(`LLM call failed (${resp.status}): ${errorText}`);
}

const json = (await resp.json()) as { choices: Array<{ message: { content: string } }> };
Expand Down
34 changes: 34 additions & 0 deletions apps/memos-local-openclaw/src/shared/openrouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** Shared OpenRouter request routing for OpenAI-compatible providers. */

export interface OpenRouterRoutingConfig {
endpoint?: string;
/** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */
openRouter?: boolean;
providerIgnore?: string[];
providerOrder?: string[];
}

const OPENROUTER_HOSTS = new Set(["openrouter.ai"]);

export function applyOpenRouterProviderRouting(
config: OpenRouterRoutingConfig,
body: Record<string, unknown>,
): boolean {
if (!isOpenRouter(config)) return false;

const provider: Record<string, unknown> = {};
if (config.providerIgnore?.length) provider.ignore = config.providerIgnore;
if (config.providerOrder?.length) provider.order = config.providerOrder;
if (Object.keys(provider).length > 0) body.provider = provider;
return true;
}

function isOpenRouter(config: OpenRouterRoutingConfig): boolean {
if (config.openRouter) return true;
if (!config.endpoint) return false;
try {
return OPENROUTER_HOSTS.has(new URL(config.endpoint).hostname.toLowerCase());
} catch {
return false;
}
}
6 changes: 6 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,12 @@ 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[];
/** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */
openRouter?: boolean;
capabilities?: SharingCapabilities;
}

Expand Down
110 changes: 110 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,110 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { callLLMOnce } from "../src/shared/llm-call";
import { applyOpenRouterProviderRouting } from "../src/shared/openrouter";
import type { SummarizerConfig } from "../src/types";

describe("shared/llm-call", () => {
afterEach(() => vi.unstubAllGlobals());

it("reports whether OpenRouter provider routing was applied", () => {
const body: Record<string, unknown> = {};

expect(applyOpenRouterProviderRouting({
endpoint: "https://openrouter.ai/api/v1",
providerIgnore: ["together"],
}, body)).toBe(true);
expect(body.provider).toEqual({ ignore: ["together"] });
expect(applyOpenRouterProviderRouting({ endpoint: "https://api.openai.com/v1" }, {})).toBe(false);
});

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("allows an explicit OpenRouter opt-in for reverse proxies", 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 = {
provider: "openai_compatible",
endpoint: "https://llm-proxy.example.com/v1",
apiKey: "sk-test",
model: "google/gemini-test",
providerIgnore: ["together"],
openRouter: true,
} as SummarizerConfig;

await callLLMOnce(cfg, "summarize this");

const body = JSON.parse(cap.init!.body as string);
expect(body.provider).toEqual({ ignore: ["together"] });
});

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);
});
});
12 changes: 12 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,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
endpoint: "",
model: "Xenova/all-MiniLM-L6-v2",
apiKey: "",
providerIgnore: [],
providerOrder: [],
openRouter: false,
cache: {
enabled: true,
maxItems: 20_000,
Expand All @@ -41,6 +44,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
apiKey: "",
timeoutMs: 45_000,
maxRetries: 3,
providerIgnore: [],
providerOrder: [],
openRouter: false,
},
l3Llm: {
// Empty by default — falls back to the shared `llm` settings.
Expand All @@ -54,6 +60,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
apiKey: "",
temperature: 0,
timeoutMs: 60_000,
providerIgnore: [],
providerOrder: [],
openRouter: false,
},
skillEvolver: {
// Empty by default — falls back to the shared `llm` settings.
Expand All @@ -65,6 +74,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
apiKey: "",
temperature: 0,
timeoutMs: 60_000,
providerIgnore: [],
providerOrder: [],
openRouter: false,
},
storage: {
ftsTokenizer: "trigram",
Expand Down
43 changes: 43 additions & 0 deletions apps/memos-local-plugin/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,38 @@ 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: [] })),
/** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */
openRouter: Type.Optional(Bool(false)),
cache: Type.Object({
enabled: Bool(true),
maxItems: NumberInRange(20_000, 0),
}, { default: {} }),
}, { default: {} });

const ReasoningSchema = Type.Object({
/**
* OpenRouter-compatible reasoning toggle. Omit the whole block to keep
* the provider/model default.
*/
enabled: Type.Optional(Type.Boolean()),
/** Optional provider effort hint for reasoning-capable models. */
effort: Type.Optional(Type.Union([
Type.Literal("minimal"),
Type.Literal("none"),
Type.Literal("low"),
Type.Literal("medium"),
Type.Literal("high"),
Type.Literal("xhigh"),
Type.Literal("max"),
])),
/** Optional token budget for reasoning-capable providers. */
maxTokens: Type.Optional(Type.Number({ minimum: 1 })),
}, { default: {} });

const LlmSchema = Type.Object({
provider: Type.Union([
Type.Literal(""),
Expand All @@ -65,6 +91,14 @@ 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: [] })),
/** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */
openRouter: Type.Optional(Bool(false)),
/** Optional reasoning control (see ReasoningSchema). Omit = model default. */
reasoning: Type.Optional(ReasoningSchema),
}, { default: {} });

/**
Expand All @@ -89,6 +123,14 @@ 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: [] })),
/** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */
openRouter: Type.Optional(Bool(false)),
/** Optional reasoning control (see ReasoningSchema). Omit = model default. */
reasoning: Type.Optional(ReasoningSchema),
}, { default: {} });

const StorageSchema = Type.Object({
Expand Down Expand Up @@ -601,4 +643,5 @@ export const ConfigSchema = Type.Object({
logging: LoggingSchema,
}, { default: {} });

export type ReasoningConfig = Static<typeof ReasoningSchema>;
export type ResolvedConfig = Static<typeof ConfigSchema>;
5 changes: 4 additions & 1 deletion apps/memos-local-plugin/core/embedding/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import { ERROR_CODES, MemosError } from "../../../agent-contract/errors.js";
import { applyOpenRouterProviderRouting } from "../../openrouter.js";
import { httpPostJson } from "../fetcher.js";
import type {
EmbedRole,
Expand Down Expand Up @@ -40,9 +41,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
6 changes: 6 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,12 @@ export interface EmbeddingConfig {
model: string;
dimensions: number;
apiKey?: string;
/** OpenRouter provider routing — providers to skip. */
providerIgnore?: string[];
/** OpenRouter provider routing — preferred order. */
providerOrder?: string[];
/** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */
openRouter: boolean;
cache: {
enabled: boolean;
maxItems: number;
Expand Down
Loading
Loading