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-plugin/core/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
providerIgnore: [],
providerOrder: [],
openRouter: false,
maxTokens: 1024,
headers: {},
},
l3Llm: {
// Empty by default — falls back to the shared `llm` settings.
Expand All @@ -73,6 +75,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
providerIgnore: [],
providerOrder: [],
openRouter: false,
maxTokens: 4096,
headers: {},
},
skillEvolver: {
// Empty by default — falls back to the shared `llm` settings.
Expand All @@ -87,6 +91,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
providerIgnore: [],
providerOrder: [],
openRouter: false,
maxTokens: 4096,
headers: {},
},
storage: {
ftsTokenizer: "trigram",
Expand Down
7 changes: 7 additions & 0 deletions apps/memos-local-plugin/core/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ function pruneUnknown(
continue;
}
if (isPlainObject(v) && isPlainObject((defaults as Record<string, unknown>)[k])) {
if (Object.keys((defaults as Record<string, unknown>)[k] as Record<string, unknown>).length === 0) {
// Empty-object default slot = free-form map (e.g. llm.headers, a
// Record<string,string>). Keep the whole user object as-is; recursing
// would warn on every user key.
out[k] = v;
continue;
}
out[k] = pruneUnknown(v, (defaults as Record<string, unknown>)[k], path, warnings);
} else {
out[k] = v;
Expand Down
8 changes: 8 additions & 0 deletions apps/memos-local-plugin/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ const LlmSchema = Type.Object({
openRouter: Type.Optional(Bool(false)),
/** Optional reasoning control (see ReasoningSchema). Omit = model default. */
reasoning: Type.Optional(ReasoningSchema),
/** Max output tokens per completion (deepseek-v4-flash needs >= 100). */
maxTokens: NumberInRange(1024, 100, 131072),
/** Extra HTTP headers for the provider request. */
headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })),
}, { default: {} });

/**
Expand Down Expand Up @@ -131,6 +135,10 @@ const SkillEvolverSchema = Type.Object({
openRouter: Type.Optional(Bool(false)),
/** Optional reasoning control (see ReasoningSchema). Omit = model default. */
reasoning: Type.Optional(ReasoningSchema),
/** Max output tokens per completion. */
maxTokens: NumberInRange(1024, 100, 131072),
/** Extra HTTP headers for the provider request. */
headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })),
}, { default: {} });

const StorageSchema = Type.Object({
Expand Down
8 changes: 8 additions & 0 deletions apps/memos-local-plugin/core/pipeline/memory-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ type DedicatedLlmConfig = {
providerOrder?: string[];
openRouter?: boolean;
reasoning?: ReasoningConfig;
/** Max output tokens per completion. */
maxTokens?: number;
/** Extra HTTP headers for the provider request. */
headers?: Record<string, string>;
};

export interface BootstrapOptions {
Expand Down Expand Up @@ -437,6 +441,8 @@ export async function bootstrapMemoryCoreFull(
providerOrder: evolver?.providerOrder,
openRouter: evolver?.openRouter ?? false,
reasoning: evolver?.reasoning,
maxTokens: evolver?.maxTokens,
headers: evolver?.headers,
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 Expand Up @@ -499,6 +505,8 @@ export async function bootstrapMemoryCoreFull(
providerOrder: l3c?.providerOrder,
openRouter: l3c?.openRouter ?? false,
reasoning: l3c?.reasoning,
maxTokens: l3c?.maxTokens,
headers: l3c?.headers,
maxRetries: 3,
fallbackToHost: true,
onError: (d: { provider: string; model: string; message: string; code?: string; at?: number }) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";

import { DEFAULT_CONFIG, resolveConfig } from "../../../core/config/index.js";

describe("resolveConfig llm.maxTokens + llm.headers", () => {
it("accepts llm.maxTokens and llm.headers without unknown-key warnings", () => {
const warnings: string[] = [];
const cfg = resolveConfig(
{
llm: {
maxTokens: 2048,
headers: { "User-Agent": "hermes-test", "X-Custom": "v1" },
},
},
warnings,
);
expect(cfg.llm.maxTokens).toBe(2048);
expect(cfg.llm.headers).toEqual({ "User-Agent": "hermes-test", "X-Custom": "v1" });
// The free-form-map special case must not warn per header key.
expect(warnings).toEqual([]);
});

it("declares llm.maxTokens with a sane default of 1024", () => {
expect(DEFAULT_CONFIG.llm.maxTokens).toBe(1024);
const cfg = resolveConfig({});
expect(cfg.llm.maxTokens).toBe(1024);
});

it("declares llm.headers defaulting to an empty map", () => {
expect(DEFAULT_CONFIG.llm.headers).toEqual({});
const cfg = resolveConfig({});
expect(cfg.llm.headers).toEqual({});
});

it("declares skillEvolver.maxTokens (default 4096) for the crystallizer LLM slot", () => {
expect(DEFAULT_CONFIG.skillEvolver.maxTokens).toBe(4096);
const cfg = resolveConfig({ skillEvolver: { maxTokens: 8192 } });
expect(cfg.skillEvolver.maxTokens).toBe(8192);
});

it("declares l3Llm.maxTokens (default 4096) sharing the SkillEvolver schema", () => {
expect(DEFAULT_CONFIG.l3Llm.maxTokens).toBe(4096);
const cfg = resolveConfig({ l3Llm: { maxTokens: 8192 } });
expect(cfg.l3Llm.maxTokens).toBe(8192);
});

it("accepts headers on skillEvolver/l3Llm slots without unknown-key warnings", () => {
const warnings: string[] = [];
const cfg = resolveConfig(
{
skillEvolver: { headers: { "X-Evolver": "v1" } },
l3Llm: { headers: { "X-L3": "v2" } },
},
warnings,
);
expect(cfg.skillEvolver.headers).toEqual({ "X-Evolver": "v1" });
expect(cfg.l3Llm.headers).toEqual({ "X-L3": "v2" });
expect(cfg.l3Llm.maxTokens).toBe(4096);
expect(warnings).toEqual([]);
});

it("declares headers defaulting to empty on the dedicated slots", () => {
expect(DEFAULT_CONFIG.skillEvolver.headers).toEqual({});
expect(DEFAULT_CONFIG.l3Llm.headers).toEqual({});
});

it("rejects out-of-range maxTokens with config_invalid", () => {
expect(() => resolveConfig({ llm: { maxTokens: 50 } })).toThrow(/config failed schema validation/);
});

it("rejects non-string header values", () => {
expect(() => resolveConfig({ llm: { headers: { "X-Bad": 42 } } })).toThrow(
/config failed schema validation/,
);
});

it("keeps unrelated llm fields untouched when maxTokens/headers are set", () => {
const cfg = resolveConfig({
llm: { provider: "openai_compatible", model: "deepseek-v4-flash", maxTokens: 2048 },
});
expect(cfg.llm.provider).toBe("openai_compatible");
expect(cfg.llm.model).toBe("deepseek-v4-flash");
expect(cfg.llm.temperature).toBe(0);
expect(cfg.llm.fallbackToHost).toBe(true);
expect(cfg.llm.timeoutMs).toBe(45_000);
expect(cfg.llm.maxRetries).toBe(3);
});
});